dwarves-gui/tests/robot_axis_road.rs
//! **The dependency picture says WHICH ROAD it came down, on the real pane.**
//!
//! `dwarves::feature_axis_source` opens by refusing to let three states collapse:
//! the warehouse answered, the warehouse did not answer and here is why, and nobody
//! knows where the warehouse IS. A graph renders identically under all three, so the
//! refusal is only worth anything if it reaches a surface.
//!
//! It did not. The verdict shipped as prose inside `FeatureAxis::provenance`, which
//! `facett_buildthing::galois_pane` prints `.weak().small()` UNDERNEATH the graph,
//! and `is_from_warehouse()` — the accessor written so a caller could tell the roads
//! apart — had no callers anywhere in this repo.
//!
//! ## Why the provenances here are DERIVED and not written
//!
//! Every axis below comes out of `build_axis` against a real workspace on disk. A
//! fixture that hand-wrote `"feature_dep_edges@/srv/…"` would prove the pane can
//! render a string this file invented — and would stay green after the producer of
//! that string had been reworded or deleted. The whole failure mode being guarded is
//! a picture that claims a source it does not have.
//!
//! ## The shape of the guard
//!
//! Each state asserts its own head is laid out WITH AREA and that the other two are
//! NOT. A pane that drew one constant line passes the first half of that and fails
//! the second, which is the mistake worth catching: a road label that is always the
//! same is worse than no road label, because it is a confident one.
use dwarves::feature_axis_source::{
build_axis, AxisRequest, DerivedAxis, GateFacts, WarehouseAttempt, UNRESOLVED_WAREHOUSE,
};
use dwarves_gui::build_scene::{
Act, Axis, AxisPick, BuildScene, Forge, Menu, Query, Report, Row, Setting, Target,
};
use facett_core::Facet;
use nornir_build_thing::contract::Coordinate;
use nornir_build_thing::variant::ArtifactKind;
use nornir_robotui::RobotSession;
use std::path::Path;
use std::sync::{Arc, Mutex};
/// The one row the fake declares. Not any product's name — `robot_build_scene.rs`
/// enforces that the scene's own source is free of them, and a fixture that spelled
/// a real variant would make this file's neighbour pass for the wrong reason.
const ROW: &str = "alpha";
// ── the three heads, verbatim from `build_scene::draw_axis_road` ──────────────
//
// Duplicated here ON PURPOSE. These are the sentences an operator reads, and a test
// that imported them from the renderer would follow any rewording silently — the
// assertion would then be "the pane draws whatever it draws".
const HEAD_MANIFESTS: &str = "⚠ läst ur manifesten på disk — INTE ur warehouset";
const HEAD_UNLOCATED: &str = "✖ vet inte var warehouset ligger — grafen är läst ur manifesten";
fn head_warehouse(path: &str) -> String {
format!("⬢ läst ur warehouset · {path}")
}
/// The warehouse path the `Rows` fixture is stamped with.
const TREE: &str = "/srv/mimir/warehouse";
// ── a workspace on disk, so the manifest leg really resolves ──────────────────
/// A three-package workspace under a tempdir: a root with one unconditional and one
/// optional path dependency, both as subdirectories so a single-root scan finds them.
///
/// It has to RESOLVE — `build_axis` returns `AxisError::Empty` rather than a blank
/// axis when nothing does, and a fixture that failed there would never reach the
/// renderer this file is about.
fn workspace(dir: &Path) -> AxisRequest {
let root = dir.join("acme");
std::fs::create_dir_all(root.join("base")).unwrap();
std::fs::create_dir_all(root.join("opt")).unwrap();
std::fs::write(
root.join("Cargo.toml"),
"[package]\nname = \"acme\"\nversion = \"0.1.0\"\nedition = \"2021\"\n\
[features]\ntls = [\"dep:opt\"]\n\
[dependencies]\nbase = { path = \"base\" }\n\
opt = { path = \"opt\", optional = true }\n",
)
.unwrap();
for m in ["base", "opt"] {
std::fs::write(
root.join(m).join("Cargo.toml"),
format!("[package]\nname = \"{m}\"\nversion = \"1.0.0\"\nedition = \"2021\"\n"),
)
.unwrap();
}
std::fs::write(
root.join("Cargo.lock"),
"version = 4\n\n[[package]]\nname = \"acme\"\nversion = \"0.1.0\"\n\n\
[[package]]\nname = \"base\"\nversion = \"1.0.0\"\n\n\
[[package]]\nname = \"opt\"\nversion = \"1.0.0\"\n",
)
.unwrap();
let mut req = AxisRequest::new("acme", &root);
// A checkout root that is not a directory takes the single-root leg, which is
// the one this fixture is built for.
req.checkout_root = Some(dir.join("no-such-constellation"));
req
}
/// The same shape as [`workspace`], but with FOUR optional path dependencies behind
/// four features — so the removable set is big enough for "one click, one crate" and
/// "a second click ADDS" to be separable claims. With one removable crate both would
/// pass against a pane that simply hunted everything.
fn wide_workspace(dir: &Path) -> AxisRequest {
const OPTS: [&str; 4] = ["alfa", "bravo", "charlie", "delta"];
let root = dir.join("acme");
std::fs::create_dir_all(root.join("base")).unwrap();
let mut features = String::from("[features]\n");
let mut deps = String::from("[dependencies]\nbase = { path = \"base\" }\n");
let mut lock = String::from(
"version = 4\n\n[[package]]\nname = \"acme\"\nversion = \"0.1.0\"\n\n\
[[package]]\nname = \"base\"\nversion = \"1.0.0\"\n",
);
for o in OPTS {
std::fs::create_dir_all(root.join(o)).unwrap();
std::fs::write(
root.join(o).join("Cargo.toml"),
format!("[package]\nname = \"{o}\"\nversion = \"1.0.0\"\nedition = \"2021\"\n"),
)
.unwrap();
features.push_str(&format!("f-{o} = [\"dep:{o}\"]\n"));
deps.push_str(&format!("{o} = {{ path = \"{o}\", optional = true }}\n"));
lock.push_str(&format!(
"\n[[package]]\nname = \"{o}\"\nversion = \"1.0.0\"\n"
));
}
std::fs::write(
root.join("Cargo.toml"),
format!(
"[package]\nname = \"acme\"\nversion = \"0.1.0\"\nedition = \"2021\"\n{features}{deps}"
),
)
.unwrap();
std::fs::write(
root.join("base").join("Cargo.toml"),
"[package]\nname = \"base\"\nversion = \"1.0.0\"\nedition = \"2021\"\n",
)
.unwrap();
std::fs::write(root.join("Cargo.lock"), lock).unwrap();
let mut req = AxisRequest::new("acme", &root);
req.checkout_root = Some(dir.join("no-such-constellation"));
req
}
/// One `feature_dep_edges` row, in the producer's own row type.
fn row(feature: &str, enables: &str) -> nornir_feature_graph::FeatureDepEdgeRow {
nornir_feature_graph::FeatureDepEdgeRow {
repo: "acme".into(),
crate_name: "acme".into(),
feature: feature.into(),
enables: enables.into(),
kind: nornir_feature_graph::EnableKind::Dep,
// An unconditional pull (`feature == ""`) is by definition non-optional.
optional: !feature.is_empty(),
target_cfg: None,
dep_kind: nornir_feature_graph::DepKind::Normal,
}
}
// ── the fake forge ────────────────────────────────────────────────────────────
/// A forge whose only job is to hand the scene one finished axis.
struct OneAxis(Arc<Mutex<Axis>>);
impl Forge for OneAxis {
fn axis(&mut self, _pick: &AxisPick) -> Axis {
self.0.lock().unwrap().clone()
}
fn resolve(&mut self, _query: &Query) -> Result<Menu, String> {
Ok(Menu {
coordinate: Coordinate::parse("a-build-thing:0.1.0").unwrap(),
how: "fixture".to_string(),
rows: vec![Row {
name: ROW.to_string(),
badges: vec![],
targets: vec![Target {
name: "exe".to_string(),
kind: ArtifactKind::Exe,
pane: None,
declared: true,
variant: None,
settings: vec![Setting::open("gpu", "false")],
}],
}],
})
}
fn act(&mut self, _act: Act) {}
fn poll(&mut self) -> Report {
Report::default()
}
}
/// Drive the REAL pane to a row+column pick with `axis` already landed, and hand
/// back a live robot over it.
fn drawn(axis: Axis) -> RobotSession<'static, SceneApp> {
let mut scene = BuildScene::new(Box::new(OneAxis(Arc::new(Mutex::new(axis)))));
scene.update_json(r#"{"artifact":"alpha"}"#);
scene.update_json(r#"{"resolve":true}"#);
scene.update_json(&format!(r#"{{"pick_row":"{ROW}"}}"#));
scene.update_json(r#"{"pick_target":"exe"}"#);
let harness = nornir_robotui::egui_kittest::Harness::builder()
.with_size(nornir_robotui::egui::Vec2::new(1400.0, 1400.0))
.build_ui_state(
|ui, app: &mut SceneApp| Facet::ui(&mut app.0, ui),
SceneApp(scene),
);
let mut robot = RobotSession::new(harness, "dwarves-gui");
robot.run(3);
robot
}
// ── a forge that hunts on demand, like the shipped one ────────────────────────
/// A forge that DERIVES per pick, spending its budget exactly on `pick.focus`.
///
/// The two lines that turn a pick into a budget mirror `BuildForge::start_axis`
/// (`build_forge.rs`, the LAZY ON CLICK block). They are mirrored rather than called
/// because the real forge needs a whole resolved `BuildVariantSet` behind it; what is
/// under test here is the SCENE's half of the contract — that a chip click becomes a
/// changed pick, and that the changed pick reaches the forge as a budget.
struct HuntForge {
req: AxisRequest,
/// Every focus set this forge was asked for, in order. A scene that re-derived
/// with the same budget for ever would look identical on screen.
seen: Arc<Mutex<Vec<Vec<String>>>>,
}
impl Forge for HuntForge {
fn axis(&mut self, pick: &AxisPick) -> Axis {
self.seen
.lock()
.unwrap()
.push(pick.focus.iter().cloned().collect());
let mut req = self.req.clone();
req.focus = pick.focus.iter().cloned().collect();
req.max_analyses = pick.focus.len();
match build_axis(
&req,
WarehouseAttempt::Empty { path: TREE.into() },
GateFacts::Unavailable {
why: "no gate rows in a fixture".into(),
},
) {
Ok(d) => Axis::Ready(Box::new(d)),
Err(e) => Axis::Failed(e.to_string()),
}
}
fn resolve(&mut self, query: &Query) -> Result<Menu, String> {
OneAxis(Arc::new(Mutex::new(Axis::Absent))).resolve(query)
}
fn act(&mut self, _act: Act) {}
fn poll(&mut self) -> Report {
Report::default()
}
}
/// The harness's app: the scene and nothing else. Its observable state IS the
/// scene's own `Facet::state_json` — the robot must read what the pane publishes,
/// not a summary this file composed beside it.
struct SceneApp(BuildScene);
impl nornir_robotui::Observable for SceneApp {
fn state_json(&self) -> serde_json::Value {
Facet::state_json(&self.0)
}
}
// ── the guard ─────────────────────────────────────────────────────────────────
/// **Three roads, three lines on the pane, and each one excludes the other two.**
#[test]
fn the_dependency_picture_says_which_road_it_came_down() {
let dir = tempfile::tempdir().expect("tempdir");
let req = workspace(dir.path());
// ── the three axes, every provenance DERIVED by the real writer ───────────
let from_warehouse = build_axis(
&req,
WarehouseAttempt::from_query(TREE, Ok(vec![row("", "serde"), row("tls", "rustls")])),
GateFacts::Unavailable {
why: "no gate rows in a fixture".into(),
},
)
.expect("two rows are a universe");
let from_manifests = build_axis(
&req,
WarehouseAttempt::Empty { path: TREE.into() },
GateFacts::Unavailable {
why: "no gate rows in a fixture".into(),
},
)
.expect("an empty warehouse falls back, it does not fail");
let unlocated = build_axis(
&req,
WarehouseAttempt::Unavailable {
path: UNRESOLVED_WAREHOUSE.to_string(),
why: "could not resolve the nornir workspace warehouse".into(),
},
GateFacts::Unavailable {
why: "no gate rows in a fixture".into(),
},
)
.expect("an unlocatable warehouse falls back too");
let warehouse_head = head_warehouse(TREE);
let heads: [&str; 3] = [&warehouse_head, HEAD_MANIFESTS, HEAD_UNLOCATED];
let cases: [(&str, &str, &DerivedAxis); 3] = [
("warehouse", &warehouse_head, &from_warehouse),
("manifests", HEAD_MANIFESTS, &from_manifests),
("unlocated", HEAD_UNLOCATED, &unlocated),
];
for (word, head, axis) in cases {
let robot = drawn(Axis::Ready(Box::new(axis.clone())));
// MINE — laid out, with area. A widget with a zero rect is not on screen.
let rect = robot.label_visibility(head).unwrap_or_else(|e| {
panic!(
"the road line is not on the pane for the {word} road: {e}\n\
provenance: {}\nthe pane says: {}",
axis.provenance,
robot.state()
)
});
assert!(
rect.area() > 0.0,
"the {word} road line is laid out at zero area: {rect:?}"
);
// NOT THE OTHERS — a road label that is always the same is a confident lie.
for other in heads.iter().filter(|o| **o != head) {
assert!(
robot.label_visibility(other).is_err(),
"the pane drew the {other:?} road while the picture came down the \
{word} one"
);
}
// ── and headlessly, so a driver need not scrape pixels ────────────────
assert_eq!(
robot
.state_at("/axis_road")
.and_then(|v| v.as_str().map(str::to_string)),
Some(word.to_string()),
"the road is observable by NAME, not as a substring of prose: {}",
robot.state()
);
let detail = robot
.state_at("/axis_road_detail")
.and_then(|v| v.as_str().map(str::to_string))
.unwrap_or_default();
assert!(
!detail.is_empty(),
"every road carries its path or its excuse"
);
// Rule 3 of `feature_axis_source`: every provenance NAMES A PATH. The two
// fallbacks must carry the tree they failed on, or the operator cannot tell
// "go run a producer" from "you are reading the wrong directory".
assert!(
axis.provenance.contains(&detail),
"the road detail must be lifted from the provenance the pane prints, not \
composed beside it: detail={detail:?} provenance={}",
axis.provenance
);
}
}
/// **Nothing is hunted until it is clicked, and what was not hunted is on screen.**
///
/// ── WHAT WAS WRONG ────────────────────────────────────────────────────────────
///
/// The pane derived its picture at `DEFAULT_MAX_ANALYSES = 200`. MEASURED, the
/// minimal-cause hunt is 10-16 s per crate and a real pick has 98 removable ones, so
/// the picture took 17-27 minutes — it effectively never landed, and the owner read the
/// wait as "why is it not using the warehouse?". The warehouse was never the cost.
///
/// ── WHAT THIS PINS ────────────────────────────────────────────────────────────
///
/// 1. a fresh pick asks for a budget of NOTHING — the press lands;
/// 2. the crates that were therefore not hunted are NAMED on the pane, in AMBER,
/// with the sentence that says absence here is not absence from the build. That
/// sentence is the one property the coordinator asked to keep when the cap went:
/// an unanalysed crate must not read like an analysed one that came back clean;
/// 3. clicking one changes the PICK, so the forge is re-asked with exactly that
/// crate in its budget — asserted on `seen`, the forge's own record;
/// 4. the crate then leaves the unanalysed list and lands in the graph;
/// 5. a second click ADDS to the budget rather than replacing it. A pane that
/// replaced would lose the first hunt on the second click, and the operator would
/// never accumulate a picture.
#[test]
fn the_hunt_is_bought_one_click_at_a_time_and_the_rest_is_named() {
let dir = tempfile::tempdir().expect("tempdir");
let req = wide_workspace(dir.path());
let seen: Arc<Mutex<Vec<Vec<String>>>> = Arc::new(Mutex::new(Vec::new()));
let mut scene = BuildScene::new(Box::new(HuntForge {
req,
seen: seen.clone(),
}));
scene.update_json(r#"{"artifact":"alpha"}"#);
scene.update_json(r#"{"resolve":true}"#);
scene.update_json(&format!(r#"{{"pick_row":"{ROW}"}}"#));
scene.update_json(r#"{"pick_target":"exe"}"#);
let harness = nornir_robotui::egui_kittest::Harness::builder()
.with_size(nornir_robotui::egui::Vec2::new(1400.0, 1400.0))
.build_ui_state(
|ui, app: &mut SceneApp| Facet::ui(&mut app.0, ui),
SceneApp(scene),
);
let mut robot = RobotSession::new(harness, "dwarves-gui");
robot.run(3);
// ── 1. the first ask spent nothing ────────────────────────────────────────
assert_eq!(
seen.lock().unwrap().first().cloned(),
Some(Vec::new()),
"a fresh pick must ask for a budget of NOTHING — anything else is the old \
17-27 minute press"
);
// ── 2. and what that cost, in crates, is on screen ────────────────────────
let names = |r: &RobotSession<'static, SceneApp>| -> Vec<String> {
r.state_at("/axis_unanalysed")
.and_then(|v| {
v.as_array().map(|a| {
a.iter()
.filter_map(|x| x.as_str().map(str::to_string))
.collect()
})
})
.unwrap_or_default()
};
let before = names(&robot);
assert!(
before.len() >= 2,
"the fixture must leave several crates unhunted or 'one click, one crate' \
proves nothing; got {before:?}"
);
// The sentence, not just the list. This is the property the cap's own note got
// wrong: it described the mechanism and left the reader to infer the meaning.
let banner = format!(
"⚠ {} beroende(n) är INTE analyserade — de saknas i grafen nedan. \
Saknas här betyder INTE FRÅGAT ÄN, inte 'ingår inte i bygget'.",
before.len()
);
let rect = robot.label_visibility(&banner).unwrap_or_else(|e| {
panic!(
"the unanalysed banner is not on the pane: {e}\n{}",
robot.state()
)
});
assert!(
rect.area() > 0.0,
"the banner is laid out at zero area: {rect:?}"
);
// Every named crate is a real widget the operator can press.
for name in before.iter().take(3) {
assert!(
robot.label_visibility(name).is_ok(),
"{name} is listed as unanalysed but has no chip to click on: {}",
robot.state()
);
}
// ── 3+4. buy one, through the seam the chip's click takes ─────────────────
let first = before[0].clone();
robot
.app_mut()
.0
.update_json(&format!(r#"{{"focus_crate":"{first}"}}"#));
robot.run(3);
assert!(
seen.lock().unwrap().contains(&vec![first.clone()]),
"the click must reach the forge as a budget of exactly [{first}]; the forge \
was asked for {:?}",
seen.lock().unwrap()
);
let after = names(&robot);
assert!(
!after.contains(&first),
"{first} was hunted, so it must leave the unanalysed list: {after:?}"
);
assert_eq!(after.len(), before.len() - 1, "one click, one crate");
// ── 5. a second click ADDS ────────────────────────────────────────────────
let second = after[0].clone();
robot
.app_mut()
.0
.update_json(&format!(r#"{{"focus_crate":"{second}"}}"#));
robot.run(3);
let mut want = vec![first.clone(), second.clone()];
want.sort();
assert!(
seen.lock().unwrap().contains(&want),
"the second click must ADD to the budget, not replace it — a replacing pane \
loses the first hunt and the operator never accumulates a picture. asked: {:?}",
seen.lock().unwrap()
);
let focus: Vec<String> = robot
.state_at("/axis_focus")
.and_then(|v| {
v.as_array().map(|a| {
a.iter()
.filter_map(|x| x.as_str().map(str::to_string))
.collect()
})
})
.unwrap_or_default();
assert_eq!(focus, want, "both hunts are held, and observable");
// ── and a row change drops them ───────────────────────────────────────────
//
// The hunts belonged to the previous assembly. Carried across, they would spend
// the next row's budget on crates from the last one.
robot
.app_mut()
.0
.update_json(&format!(r#"{{"pick_row":"{ROW}"}}"#));
robot.run(3);
assert!(
robot
.state_at("/axis_focus")
.and_then(|v| v.as_array().map(Vec::is_empty))
.unwrap_or(false),
"picking a row must drop the hunts with the rest of the axis: {}",
robot.state()
);
}
/// **A picture that has NOT landed has no road, and says nothing.**
///
/// The counterpart to the guard above: the three-state line is a claim about a graph,
/// so a frame with no graph must not carry one. Without this, a road left over from
/// the previous pick would sit above a blank frame and read as current.
#[test]
fn there_is_no_road_line_before_there_is_a_picture() {
for (what, axis) in [("working", Axis::Working), ("absent", Axis::Absent)] {
let robot = drawn(axis);
assert_eq!(
robot
.state_at("/axis_road")
.and_then(|v| v.as_str().map(str::to_string)),
None,
"a {what} axis has no road to state: {}",
robot.state()
);
for head in [HEAD_MANIFESTS, HEAD_UNLOCATED, &head_warehouse(TREE)] {
assert!(
robot.label_visibility(head).is_err(),
"a {what} axis drew the {head:?} road over a picture that is not there"
);
}
}
}