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