dwarves-gui/tests/robot_build_scene_ui.rs
//! **ROBOT-UI: the ⚒ scene driven through its REAL widgets, not its JSON seam.**
//!
//! A UI claim in this tree has shipped broken three times behind a passing test, and
//! every one of those tests drove the pane's `update_json` rather than the pane. So this
//! file never calls `update_json`: it mounts the scene in a real egui frame and TYPES
//! into the artifact field, CLICKS the search button, CLICKS a row, CLICKS a column —
//! resolving every one of them through the live AccessKit tree, the same way a human's
//! pointer resolves them.
//!
//! The constellation is STAGED on disk by this file, so the assertions are about what
//! the scene rendered rather than about which checkouts this machine happens to hold.
//! Nothing here presses BYGG: that would start a real `cargo build`.
use facett_core::Facet;
use nornir_robotui::egui_kittest::Harness;
use nornir_robotui::{Observable, RobotSession};
use dwarves_gui::build_forge::DeclaredForge;
use dwarves_gui::build_scene::BuildScene;
/// The scene as the robot's subject — the same `Facet::state_json` the shell exposes,
/// read back after real gestures. No twinned oracle.
struct SceneApp(BuildScene);
impl Observable for SceneApp {
fn state_json(&self) -> serde_json::Value {
Facet::state_json(&self.0)
}
}
/// Stage a constellation with ONE checkout declaring two atoms and two delivery kinds.
///
/// Deliberately not any product's names: the scene under test must not contain one, and
/// a fixture that spelled a real atom would make that guarantee unfalsifiable here.
fn staged() -> tempfile::TempDir {
let dir = tempfile::tempdir().expect("tempdir");
let repo = dir.path().join("orten");
std::fs::create_dir_all(repo.join(".nornir")).expect("mkdir");
// A REAL crate graph, because the dependency picture is derived from manifests
// and a lockfile rather than described here. Three optional deps behind three
// features, one each. Nothing below hand-writes what the resolver should
// conclude from that.
//
// `gpu` is deliberately the one NO atom builds: it is the open switch, and an
// open switch is OFF until somebody turns it on. That is why it never appears on
// a dependency axis — and it is also what keeps the two subjects this file tests
// from sharing a name on screen.
std::fs::write(
repo.join("Cargo.toml"),
r#"[workspace.package]
version = "2.4.1"
[workspace]
members = ["paint", "wire", "shade"]
[package]
name = "orten"
version = "2.4.1"
edition = "2021"
[features]
gpu = ["dep:paint"]
client = ["dep:wire"]
tune = ["dep:shade"]
[dependencies]
paint = { path = "paint", version = "0.1.0", optional = true }
wire = { path = "wire", version = "0.1.0", optional = true }
shade = { path = "shade", version = "0.1.0", optional = true }
"#,
)
.expect("manifest");
for leaf in ["paint", "wire", "shade"] {
std::fs::create_dir_all(repo.join(leaf).join("src")).expect("mkdir leaf");
std::fs::write(
repo.join(leaf).join("Cargo.toml"),
format!(
"[package]\nname = \"{leaf}\"\nversion = \"0.1.0\"\nedition = \"2021\"\n\n\
[dependencies]\n"
),
)
.expect("leaf manifest");
std::fs::write(repo.join(leaf).join("src/lib.rs"), "").expect("leaf src");
}
std::fs::write(
repo.join("Cargo.lock"),
r#"version = 4
[[package]]
name = "orten"
version = "2.4.1"
dependencies = [
"paint",
"shade",
"wire",
]
[[package]]
name = "paint"
version = "0.1.0"
[[package]]
name = "shade"
version = "0.1.0"
[[package]]
name = "wire"
version = "0.1.0"
"#,
)
.expect("lockfile");
std::fs::write(
repo.join(".nornir").join("build-variants.toml"),
r#"
[build]
base_suite = "ORIGINAL"
# The repo's feature SPACE. One atom below leaves `gpu` open and the other does
# not, out of this same space — which is what makes the absence on that other row
# a decision rather than a feature nothing here has ever heard of.
offers = ["gpu", "client", "tune"]
[[build.variant]]
name = "orten-server"
package = "orten"
bin = "orten-server"
role = "server"
default = true
suites = ["ORIGINAL"]
kind = "exe"
features = ["client"]
[[build.variant]]
name = "orten-image"
package = "orten"
role = "server"
default = false
suites = ["ORIGINAL", "MVP"]
kind = "container"
# The [[build.container]] below is bound BY NAME, and the loader refuses a
# `kind = "container"` row that does not say which — matching names is not the
# same as declaring the edge. Without this line the whole file is rejected at
# load, the scene draws an empty space, and every row-level assertion in this
# module reads as "the recipe declares nothing" (lane AU, 2026-08-30).
container_ref = "orten-image"
features = ["client", "tune"]
open = ["gpu"]
# An atom that builds with NO features at all. There is no feature axis to click
# on such a row, and saying that in words is the point: a blank space where a graph
# should be reads exactly like a graph that could not be derived.
[[build.variant]]
name = "orten-tool"
package = "orten"
bin = "orten-tool"
role = "tool"
default = true
suites = ["ORIGINAL"]
kind = "exe"
[[build.container]]
name = "orten-image"
base = "scratch"
expose = [8080]
"#,
)
.expect("declaration");
dir
}
/// Mount the scene over a staged constellation in a real egui frame.
fn session(root: &std::path::Path) -> RobotSession<'_, SceneApp> {
let scene = BuildScene::new(Box::new(DeclaredForge::at(root.to_path_buf())));
let harness = Harness::builder()
.with_size(nornir_robotui::egui::Vec2::new(1400.0, 1000.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(2);
robot
}
/// **The whole flow, through real widgets.**
///
/// Every step is watched red by the step before it: the matrix is asserted absent
/// before the search, the column absent before it is clicked, `can_build` false before
/// both selections are made. So none of the greens can be a constant.
#[test]
fn a_robot_types_an_artifact_name_and_clicks_its_way_to_an_armed_build() {
let dir = staged();
let mut robot = session(dir.path());
// RED — nothing is resolved before anything is typed.
assert_eq!(
robot
.state_at("/rows")
.and_then(|v| v.as_array().map(Vec::len)),
Some(0)
);
assert_eq!(robot.state_at("/can_build"), Some(serde_json::json!(false)));
// Step 1 — TYPE into the real field, resolved by its AccessKit label.
robot
.type_into("artefakt", "orten-server")
.expect("the artefakt field is reachable");
assert_eq!(
robot.state_at("/artifact"),
Some(serde_json::json!("orten-server")),
"the text never reached the real TextEdit"
);
// Step 2 — CLICK the real search button.
robot
.click_label("sök")
.expect("the sök button is reachable");
robot.run(2);
assert_eq!(
robot.state_at("/recipe"),
Some(serde_json::json!("orten-build-thing:2.4.1")),
"the search did not resolve the staged declaration"
);
let rows = robot
.state_at("/rows")
.and_then(|v| v.as_array().map(Vec::len));
assert_eq!(
rows,
Some(3),
"the home matrix is not the three declared variants"
);
// The typed name matched a row, so it is already picked — but the column is not.
assert_eq!(
robot.state_at("/row"),
Some(serde_json::json!("orten-server"))
);
assert_eq!(robot.state_at("/target"), Some(serde_json::Value::Null));
assert_eq!(robot.state_at("/can_build"), Some(serde_json::json!(false)));
assert_eq!(robot.state_at("/pane"), Some(serde_json::Value::Null));
// Step 3 — CLICK the other row, and watch the pick move.
robot
.click_label("orten-image")
.expect("the second row is a real, clickable widget");
robot.run(2);
assert_eq!(
robot.state_at("/row"),
Some(serde_json::json!("orten-image"))
);
// …and back, because the exe column is the one with a pane worth asserting.
robot
.click_label("orten-server")
.expect("the first row is clickable too");
robot.run(2);
assert_eq!(
robot.state_at("/row"),
Some(serde_json::json!("orten-server"))
);
// Step 4 — CLICK the column. The strip is the same on every row, so `container` is
// present here and greyed; `exe` is the one this row declares.
robot
.click_label("exe")
.expect("the exe column is a real, clickable widget");
robot.run(2);
assert_eq!(robot.state_at("/target"), Some(serde_json::json!("exe")));
assert_eq!(
robot.state_at("/pane"),
Some(serde_json::json!("exec")),
"an exe did not open the Exec pane"
);
assert_eq!(
robot.state_at("/can_build"),
Some(serde_json::json!(true)),
"a row and a column are picked and BYGG is still not armed"
);
}
/// A column the recipe does not declare is rendered and DISABLED — clicking it changes
/// nothing. The row that does declare it picks it, so the refusal is about the cell and
/// not about the widget being missing.
#[test]
fn an_undeclared_column_is_rendered_disabled_and_a_click_does_nothing() {
let dir = staged();
let mut robot = session(dir.path());
robot.type_into("artefakt", "orten-server").expect("field");
robot.click_label("sök").expect("button");
robot.run(2);
// The container column exists on this row — the strip is the matrix — but this row
// does not declare it.
let strip: Vec<String> = robot
.state_at("/rows/0/targets")
.and_then(|v| v.as_array().cloned())
.unwrap_or_default()
.iter()
.map(|t| t["name"].as_str().unwrap_or_default().to_string())
.collect();
assert_eq!(
strip,
vec!["exe", "container"],
"the column strip is not the declaration"
);
assert_eq!(
robot.state_at("/rows/0/targets/1/declared"),
Some(serde_json::json!(false))
);
// A click on the disabled cell leaves the selection alone.
let _ = robot.click_label("container");
robot.run(2);
assert_eq!(
robot.state_at("/target"),
Some(serde_json::Value::Null),
"an undeclared column was selected by clicking it"
);
// RED — the SAME widget on the row that DOES declare it selects normally, so the
// refusal above is about the cell rather than about a dead button.
robot.click_label("orten-image").expect("the second row");
robot.run(2);
assert_eq!(
robot.state_at("/rows/1/targets/1/declared"),
Some(serde_json::json!(true))
);
robot
.click_label("container")
.expect("the container column");
robot.run(2);
assert_eq!(
robot.state_at("/target"),
Some(serde_json::json!("container"))
);
assert_eq!(
robot.state_at("/pane"),
Some(serde_json::json!("log")),
"a container did not open the Log pane"
);
}
/// The suite badges the declaration carries are on screen, verbatim.
///
/// One atom is in `ORIGINAL` only and the other in `ORIGINAL` and `MVP`, so a renderer
/// that dropped, added or sorted a suite fails on one of the two.
#[test]
fn the_suite_badges_on_screen_are_the_ones_the_declaration_carries() {
let dir = staged();
let mut robot = session(dir.path());
robot.type_into("artefakt", "orten").expect("field");
robot.click_label("sök").expect("button");
robot.run(2);
let badges = |i: usize| -> Vec<String> {
robot
.state_at(&format!("/rows/{i}/badges"))
.and_then(|v| v.as_array().cloned())
.unwrap_or_default()
.iter()
.map(|b| b.as_str().unwrap_or_default().to_string())
.collect()
};
assert_eq!(
badges(0).first().map(String::as_str),
Some("ORIGINAL"),
"the first atom's suite badge is not what it declares"
);
assert!(
!badges(0).contains(&"MVP".to_string()),
"a suite it does not declare appeared"
);
assert_eq!(
&badges(1)[..2],
&["ORIGINAL".to_string(), "MVP".to_string()],
"the two-suite atom lost or reordered a suite"
);
}
/// **A setting belongs to the ROW.** One atom leaves `gpu` open; its sibling does
/// not, and on that sibling there is no `gpu` control on screen in ANY state — not
/// enabled, not greyed, not present.
///
/// Greying it there would be the same error as inventing a column nobody declared.
/// The column strip is a MAP, so a cell a row lacks is drawn greyed on purpose; the
/// settings list is the ARGV this build will run with, and a switch that cannot
/// honestly be flipped has no place in it.
///
/// Watched failing three ways before the absence is trusted:
///
/// 1. the repo's own space DOES contain `gpu`, so the sibling's silence is a
/// declaration and not a typo;
/// 2. the assertion FINDS the control on the row that declares it — a query that
/// can never find anything cannot report absence;
/// 3. the control is a real, addressable CHECKBOX that flips a real value, so it
/// is a widget and not a word.
#[test]
fn a_gpu_switch_is_drawn_only_on_the_row_whose_recipe_declares_it() {
use nornir_robotui::Target;
let dir = staged();
let mut robot = session(dir.path());
robot.type_into("artefakt", "orten").expect("field");
robot.click_label("sök").expect("button");
robot.run(2);
// RED 1 — the space contains `gpu`. Without this the absence below could just
// be a feature this fixture never mentions.
let declared = std::fs::read_to_string(dir.path().join("orten/.nornir/build-variants.toml"))
.expect("the staged declaration");
assert!(
declared.contains("offers = [\"gpu\", \"client\", \"tune\"]"),
"the fixture must OFFER gpu, or the row-level absence proves nothing"
);
// The row that leaves it open. Its only declared cell is the container.
robot
.click_label("orten-image")
.expect("the row that opens gpu");
robot.run(2);
robot.click_label("container").expect("its declared column");
robot.run(2);
assert_eq!(
robot.state_at("/target"),
Some(serde_json::json!("container"))
);
// RED 2 + 3 — the control is FOUND, and it is a checkbox with a real state.
assert!(
robot.label_visible("gpu"),
"the row that declares gpu open has no gpu control at all — the query below \
could then never report anything"
);
assert_eq!(
robot.field_checked(&Target::Label("gpu".into())),
Some(false),
"the gpu control is not an addressable checkbox with a state"
);
robot
.click_label("gpu")
.expect("the gpu checkbox is clickable");
robot.run(2);
assert_eq!(
robot.field_checked(&Target::Label("gpu".into())),
Some(true),
"clicking the checkbox did not change the WIDGET's state"
);
assert_eq!(
robot.state_at("/settings/gpu"),
Some(serde_json::json!("true")),
"the flip never reached the settings the build would be given"
);
// GREEN — and it means something, because of the three above. The sibling row
// declares nothing open, so there is no gpu control on it in any state.
robot.click_label("orten-server").expect("the cpu-only row");
robot.run(2);
robot.click_label("exe").expect("its declared column");
robot.run(2);
assert_eq!(robot.state_at("/target"), Some(serde_json::json!("exe")));
assert!(
!robot.label_visible("gpu"),
"a gpu control was drawn on a row whose recipe does not declare it — a \
switch nobody can honestly flip is the same defect as an invented column"
);
assert_eq!(
robot.field_checked(&Target::Label("gpu".into())),
None,
"a gpu toggle is still addressable on the cpu-only row"
);
let keys: Vec<String> = robot
.state_at("/rows/0/targets/0/settings")
.and_then(|v| v.as_array().cloned())
.unwrap_or_default()
.iter()
.map(|s| s["key"].as_str().unwrap_or_default().to_string())
.collect();
assert!(
!keys.iter().any(|k| k == "gpu"),
"the cpu-only cell resolved a gpu setting: {keys:?}"
);
}
/// Wait for the forge's background axis worker to land, stepping real frames.
///
/// Returns the pane's state, or `None` if it never arrived — the caller asserts,
/// so a timeout can never read as "the graph is empty".
fn axis_of(robot: &mut RobotSession<'_, SceneApp>) -> Option<serde_json::Value> {
for _ in 0..600 {
robot.run(1);
if let Some(a) = robot.state_at("/axis") {
if !a.is_null() {
return Some(a);
}
}
std::thread::sleep(std::time::Duration::from_millis(20));
}
None
}
/// **The pizza axis, both directions, BEFORE the build.**
///
/// Click a package away and see which feature goes dark; switch a feature off and
/// see which packages vanish. Both come off the ONE model the pane holds, and this
/// test asserts they agree on the same fact read from both ends — if they ever
/// drift, one of them is lying.
///
/// Nothing here is hand-written data: the axis is derived by the real resolver from
/// the real manifests and lockfile the fixture stages, so an assertion below can be
/// wrong about the crate graph and it will say so.
#[test]
fn both_directions_of_the_dependency_axis_are_live_before_the_build() {
let dir = staged();
let mut robot = session(dir.path());
robot.type_into("artefakt", "orten").expect("field");
robot.click_label("sök").expect("button");
robot.run(2);
// RED — no row is picked, so there is no axis to be seen. A picture that was
// already there could not be attributed to the pick below.
assert_eq!(robot.state_at("/row"), Some(serde_json::Value::Null));
assert_eq!(robot.state_at("/axis"), Some(serde_json::Value::Null));
robot
.click_label("orten-image")
.expect("the row with both features in play");
robot.run(2);
let axis = axis_of(&mut robot).expect(
"the dependency axis never arrived — the forge derives one for this row, so \
this is a broken edge and NOT an empty graph",
);
// The clickable universe is THIS row's features: the one it enables and the one
// it leaves open. Not the repo's space, and not the package's whole `[features]`.
let features: Vec<String> = axis["features"]
.as_array()
.expect("a feature list")
.iter()
.map(|f| f.as_str().unwrap_or_default().to_string())
.collect();
assert_eq!(
features,
vec!["client", "tune"],
"Φ is not the features this row builds with"
);
assert!(
!features.contains(&"gpu".to_string()),
"the row's OPEN switch is on its dependency axis — an open feature is OFF \
until somebody turns it on, so a package drawn as present because of it \
would be a wrong graph, not a partial one: {features:?}"
);
let present = |a: &serde_json::Value| -> Vec<String> {
a["present"]
.as_array()
.cloned()
.unwrap_or_default()
.iter()
.map(|c| c.as_str().unwrap_or_default().to_string())
.collect()
};
// ── buy the two hunts this test is about ──────────────────────────────────
//
// The pane derives with a budget of ZERO: the minimal-cause hunt costs 10-16 s
// per crate and a real pick has 98 removable ones, so nothing is analysed until
// somebody asks (see `feature_axis_source::DerivedAxis`). `shade` and `wire` are
// REMOVABLE, which is exactly the band that costs something — until they are
// asked for they sit on the unanalysed list, named on the pane, and NOT on the
// graph.
//
// Bought through `focus_crate`, the same seam the pane's chips click. Two calls
// in a row is deliberate: the crates waiting to be hunted survive a re-derive, so
// a second can be queued before the first has landed.
robot.harness_mut().state_mut().0.focus_crate("shade");
robot.harness_mut().state_mut().0.focus_crate("wire");
let axis = axis_of(&mut robot).expect("the axis re-derives once the hunts are bought");
// RED — both packages are in the build to start with, so their departure below
// is something that HAPPENED rather than something that was never there.
let start = present(&axis);
assert!(
start.contains(&"shade".to_string()),
"shade is not in the build: {start:?}"
);
assert!(
start.contains(&"wire".to_string()),
"wire is not in the build: {start:?}"
);
assert!(
!start.contains(&"paint".to_string()),
"paint is only reachable through the feature this row does NOT build, so a \
graph that has it is a graph of some other build: {start:?}"
);
// ── Direction 1: switch a FEATURE off, watch a PACKAGE vanish ────────────
// A real AccessKit click on the chip, exactly as a pointer resolves it.
robot
.click_label("tune")
.expect("the tune chip on the axis is clickable");
robot.run(2);
let a1 = robot.state_at("/axis").expect("axis");
let after = present(&a1);
assert!(
!after.contains(&"shade".to_string()),
"turning tune off left the package it was holding up in the build: {after:?}"
);
assert!(
after.contains(&"wire".to_string()),
"turning tune off took a package it never pulled: {after:?}"
);
// ── Direction 2: click a PACKAGE away, watch the FEATURE go dark ─────────
// The graph's nodes are painted, not AccessKit widgets, so this goes through
// the scene's own routing — which is the SAME call `draw_graph`'s `clicked_node`
// makes. There is no second path here for a test to take.
robot.harness_mut().state_mut().0.click_axis("crate:wire");
robot.run(2);
let a2 = robot.state_at("/axis").expect("axis");
let disabled: Vec<String> = a2["disabled"]
.as_array()
.cloned()
.unwrap_or_default()
.iter()
.map(|f| f.as_str().unwrap_or_default().to_string())
.collect();
assert!(
disabled.contains(&"client".to_string()),
"clicking the package away did not turn off the feature that pulls it: {disabled:?}"
);
// ── ONE MODEL. The two directions must agree on the same fact ────────────
// The feature strip says `client` is off; the package list must say `wire` is
// gone. Two tables would let those two answers differ, which is exactly the
// property this pane exists to not have.
let now = present(&a2);
assert!(
!now.contains(&"wire".to_string()),
"the feature side says client is off and the package side still has wire — \
the two directions have drifted: {now:?}"
);
assert!(
!now.contains(&"shade".to_string()),
"tune is still off, so shade must still be gone: {now:?}"
);
// The consequence is stated in words, so an operator sees the COST and not just
// a changed picture.
let words = a2["consequence"].as_str().unwrap_or_default();
assert!(
words.contains("2 of 2 features off"),
"the pane does not say what the clicks cost: {words:?}"
);
// The whole picture is ABOVE the build button, and the button is still armed —
// this is a view of what a build would be, not a step in making one.
robot.click_label("container").expect("the declared column");
robot.run(2);
assert_eq!(robot.state_at("/can_build"), Some(serde_json::json!(true)));
}
/// **The axis belongs to the row too.** One row builds with one feature and its
/// sibling with two, out of the same repo and the same manifests, so each gets its
/// own clickable universe rather than the repo's.
#[test]
fn a_rows_dependency_axis_is_its_own_features_and_no_others() {
let dir = staged();
let mut robot = session(dir.path());
robot.type_into("artefakt", "orten").expect("field");
robot.click_label("sök").expect("button");
robot.run(2);
// The typed name matched no row, so pick the one-feature atom outright.
robot
.click_label("orten-server")
.expect("the one-feature row");
robot.run(2);
let axis =
axis_of(&mut robot).expect("this row builds with one feature and therefore has an axis");
let features: Vec<String> = axis["features"]
.as_array()
.expect("a feature list")
.iter()
.map(|f| f.as_str().unwrap_or_default().to_string())
.collect();
assert_eq!(
features,
vec!["client"],
"this row's axis offers a feature it does not build with"
);
// RED — the sibling row DOES get `tune` on its axis, out of the same repo and
// the same manifests. So the absence above is the row's, not the fixture's.
robot.click_label("orten-image").expect("the sibling row");
robot.run(2);
let axis = axis_of(&mut robot).expect("the sibling row's axis");
let features: Vec<String> = axis["features"]
.as_array()
.expect("a feature list")
.iter()
.map(|f| f.as_str().unwrap_or_default().to_string())
.collect();
assert!(
features.contains(&"tune".to_string()),
"the row that builds with tune did not get it on its axis: {features:?}"
);
}
/// **No axis is three different sentences, and a blank space is none of them.**
///
/// A row that builds with no features has nothing to click, and the scene SAYS so
/// instead of leaving the space where a graph would be empty — an empty graph reads
/// identically to one that could not be derived, which is the failure the whole
/// axis-source module exists to make impossible.
#[test]
fn a_row_with_no_features_says_why_there_is_no_graph() {
let dir = staged();
let mut robot = session(dir.path());
robot.type_into("artefakt", "orten").expect("field");
robot.click_label("sök").expect("button");
robot.run(2);
robot
.click_label("orten-tool")
.expect("the atom that builds with no features");
// The answer is settled without a worker, so a couple of frames is all it takes;
// the loop is here so a slow machine cannot turn a verdict into a flake.
let mut note = None;
for _ in 0..50 {
robot.run(1);
if let Some(n) = robot
.state_at("/axis_note")
.and_then(|v| v.as_str().map(str::to_string))
{
note = Some(n);
break;
}
}
let note = note.expect("the scene left the axis silent instead of saying why");
assert!(
note.contains("orten-tool") && note.contains("utan features"),
"the reason must name its own subject and the reason: {note:?}"
);
assert_eq!(
robot.state_at("/axis"),
Some(serde_json::Value::Null),
"a row with nothing to click still produced a graph"
);
// RED — the sibling row DOES get a graph and NO note, so the pair above is
// about this row rather than about a scene that never draws one.
robot.click_label("orten-image").expect("the sibling row");
robot.run(2);
assert!(
axis_of(&mut robot).is_some(),
"the sibling row has features and must get a graph"
);
assert_eq!(
robot.state_at("/axis_note"),
Some(serde_json::Value::Null),
"a row WITH a graph is also carrying a reason there is none"
);
}