Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/dwarves

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"
    );
}