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_kernel_scene.rs

//! **ROBOT-UI: the kernel feature scene — a preset picked through a real widget, a
//! symbol clicked OFF through a real widget, and a spin that is CAMERA-ONLY.**
//!
//! `d0f559d` gave the `kernel` KIND a cell to click in the ⚒ matrix. It showed the
//! preset, the version, the resolved base — four words about a machine with dozens of
//! parts. Design §10 asks for the parts: *"add a rotating 3d graph over the feature
//! select … like 'remove this part and then no need for this dependency'."*
//!
//! This file drives `kernel_scene::KernelScene` through its LIVE widgets — never
//! `update_json` — the way `robot_kernel_kind.rs` and `robot_build_scene_ui.rs` do. It
//! is its own test binary so the process-wide `kernel_graph_build_calls()` counter
//! reflects only this scenario (the isolation `robot_funnel_dag_3d` uses).
//!
//! # Every green here carries a red counterweight
//!
//! A claim that cannot fail is not a test, so each step below is paired with the input
//! that would have made it fail, in the same run:
//!
//! | the claim | its counterweight |
//! |---|---|
//! | picking `nuked` changes the graph | `full` in the same session draws ZERO nodes and says why — so "the pick reaches the graph" is not a pane that always shows the same cloud |
//! | clicking `CONFIG_NET_CORE` off turns `CONFIG_NET` amber | clicking `CONFIG_VIRTIO_MENU` off turns NOTHING amber — `CONFIG_VIRTIO` keeps three other children, so the cascade is about the relation and not about every click |
//! | dropping `CONFIG_NLS` is REFUSED by name | the two clicks above were ACCEPTED, so "refused" is a statement about the boot contract and not a pane that refuses everything |
//! | `build_count` stays 1 across 24 spin frames + two cascades | changing the preset bumps it to 2 in the same session, so `== 1` is not a constant |
//!
//! # What I watched fail, before any of it was green
//!
//! Six sabotages, each run against this scene's own model:
//!
//! 1. dropped the underscore boundary from the name-parent rule → `CONFIG_NET` "parented"
//!    `CONFIG_NETDEVICES` and a fabricated edge entered the cascade;
//! 2. removed the backward (orphan) arm of the cascade → clicking `CONFIG_NET_CORE` off
//!    left `CONFIG_NET` `locked`, i.e. the pane could not answer §10's one question;
//! 3. removed the refusal → dropping `CONFIG_NLS` came back `clicked_off`, silently
//!    taking the boot contract's charset tables with it;
//! 4. made a cascade re-run the layout → `build_count` went 2 → 3 on a click, the exact
//!    "the graph jumps under the pointer that just clicked it" defect;
//! 5. treated `# CONFIG_X is not set` as "not a node" → `nuked` collapsed from 48 nodes
//!    to 32 and its sixteen named absences — its entire content — vanished from the
//!    picture;
//! 6. computed the symbol universe from `base.config` alone → `nuked` and `normal` both
//!    reported 57 undecided and the "smaller machine" claim became a tautology.
//!
//! …and three more at THIS layer, two of which were not sabotage at all but real defects
//! this scenario found on its first run, in a pane whose own unit tests were already
//! green — which is the entire argument for driving the widgets instead of the seam:
//!
//! 7. the symbol list was laid out at x≈1989 in a 1500 px window, because the canvas
//!    sized itself from an unbounded parent's `available_size()`. The pane's model was
//!    perfect and half its controls were off the edge of the world;
//! 8. once the width was fixed, the list came back at x≈1585 — a `ScrollArea` inherits
//!    the enclosing layout, and this one is enclosed by `horizontal_top`, so forty-eight
//!    symbols laid out LEFT TO RIGHT in one row. Both were reported by
//!    `RobotSession::click_label`'s visibility gate, verbatim: *"the user cannot see it,
//!    and a click at its centre lands off-surface"*;
//! 9. replaced the preset picker's `selectable_label` with a plain `label` → step 2 came
//!    back `normal` where it wanted `nuked`, proving these gestures go through real
//!    widgets and not through a JSON seam that would not have noticed.
//!
//! # The number that is NOT what was asked for, and why
//!
//! The brief for this scene asked the robot to assert that `nuked` has FEWER nodes than
//! `normal`. **It does not, and it cannot: measured 2026-08-23, `normal` was 16 nodes and
//! `nuked` 48 — and by 2026-09-11 `normal` was 33, because the boot contract in edda's
//! `base.config` grew (PROC_FS and the ten the live nuked kernel died without, SYSFS, SMP,
//! NR_CPUS …) while this file still said 16. The counts below are therefore DERIVED from
//! the model the pane renders (`KernelFeatureModel::derive`, whose numbers edda pins in
//! `kernel_features::the_lowered_model_matches_the_measured_pane_numbers`), so this robot
//! asserts the PANE draws the MODEL and cannot rot the next time the contract moves.** A preset's fragment is a DELTA over a make target inside the kernel tree
//! (`defconfig` for `normal`, `tinyconfig` for `nuked`), and `nuked`'s whole content is
//! stated as ABSENCE — sixteen `# CONFIG_X is not set` vetoes, each of which is a line.
//! Counting those as "not nodes" is sabotage 5 above: it makes the number smaller by
//! deleting the picture. Asserting `nuked < normal` would simply be red.
//!
//! So this file asserts the honest neighbour, which measures the same thing and CAN
//! fail: **`nuked` leaves fewer of the kind's committed symbol universe undecided** —
//! (25 of 73 versus `normal`'s 57 of 73 when first measured; read off the model now).
//! That is "the smaller, more completely stated
//! machine" as a number derived from committed text, and step 2 asserts the node-count
//! direction explicitly (nuked > normal) so nobody reads the absence of the requested claim as
//! an oversight.
//!
//! Nothing here presses a build button: there is none. This pane compiles nothing.

#![cfg(not(target_arch = "wasm32"))]

use facett_core::Facet;
use nornir_robotui::egui_kittest::Harness;
use nornir_robotui::{Observable, RobotSession};

use dwarves_gui::kernel_scene::{kernel_graph_build_calls, KernelScene};
use nornir_build_thing::kernel::HardeningProfile;
use nornir_build_thing::kernel_features::KernelFeatureModel;

/// The MODEL's own numbers for a preset — what the pane must draw. Derived the way the
/// pane derives them (`KernelFeatureModel::derive`, the pane's default `Vanilla`
/// profile), so this file asserts "the pane renders the model" and not a copy of the
/// model's numbers: the copy said `normal` = 16 from 2026-08-23 until 2026-09-11, while
/// edda's `base.config` took it to 33 and edda's own test re-pinned each move.
struct ModelNumbers {
    nodes: u64,
    edges: u64,
    isolated: u64,
    enabled: u64,
    vetoed: u64,
    contract_closure_edges: u64,
}

fn model_numbers(preset: Preset) -> ModelNumbers {
    let m = KernelFeatureModel::derive(preset, HardeningProfile::Vanilla);
    ModelNumbers {
        nodes: m.symbols.len() as u64,
        edges: m.edges.len() as u64,
        isolated: m.symbols.iter().filter(|s| s.isolated()).count() as u64,
        enabled: m.symbols.iter().filter(|s| s.enabled).count() as u64,
        vetoed: m.symbols.iter().filter(|s| !s.enabled).count() as u64,
        contract_closure_edges: m
            .edges
            .iter()
            .filter(|e| e.2 && m.symbols[e.1].contract)
            .count() as u64,
    }
}

/// The scene as the robot's subject — the same `Facet::state_json` a shell would read,
/// after real gestures. No twinned oracle.
struct SceneApp(KernelScene);

impl Observable for SceneApp {
    fn state_json(&self) -> serde_json::Value {
        Facet::state_json(&self.0)
    }
}

fn session() -> RobotSession<'static, SceneApp> {
    let harness = Harness::builder()
        .with_size(nornir_robotui::egui::Vec2::new(1500.0, 1100.0))
        .build_ui_state(
            |ui, app: &mut SceneApp| Facet::ui(&mut app.0, ui),
            SceneApp(KernelScene::new()),
        );
    let mut robot = RobotSession::new(harness, "dwarves-gui");
    robot.run(2);
    robot
}

/// A `usize` out of the pane's own state at `pointer`, or a panic naming the pointer —
/// a missing field is a defect, not a `0`.
fn num(robot: &RobotSession<'_, SceneApp>, pointer: &str) -> u64 {
    robot
        .state_at(pointer)
        .and_then(|v| v.as_u64())
        .unwrap_or_else(|| panic!("no number at {pointer:?}"))
}

/// The state token of one symbol, read out of the pane's `symbols` array by NAME. The
/// fixture's ordering is not this test's business.
fn state_of(robot: &RobotSession<'_, SceneApp>, name: &str) -> String {
    robot
        .state_at("/symbols")
        .and_then(|v| v.as_array().cloned())
        .unwrap_or_default()
        .iter()
        .find(|s| s["name"].as_str() == Some(name))
        .unwrap_or_else(|| panic!("no symbol {name:?} in the pick"))["state"]
        .as_str()
        .unwrap_or_default()
        .to_string()
}

#[test]
fn a_robot_picks_a_preset_clicks_a_symbol_off_and_the_spin_is_camera_only() {
    let mut robot = session();

    // ── Step 1 — the pane opens on the DEFAULT preset, with the measured contract ──
    // `normal` is what the appliances ship today: exactly the options of the measured
    // boot contract (`base.config`), the derivable edges between them, and the symbols
    // whose dependency is real but does not nest by name and are therefore drawn as
    // ISOLATED rather than invented. The numbers are the MODEL's, read off the same
    // derivation the pane runs — see `model_numbers`.
    assert_eq!(robot.state_at("/preset"), Some(serde_json::json!("normal")));
    assert_eq!(
        robot.state_at("/base"),
        Some(serde_json::json!("defconfig"))
    );
    assert_eq!(
        robot.state_at("/profile"),
        Some(serde_json::json!("vanilla")),
        "the derivation below assumes the pane's default profile"
    );
    let normal = model_numbers(Preset::Normal);
    let normal_nodes = num(&robot, "/graph/nodes");
    let normal_undecided = num(&robot, "/graph/undecided");
    assert_eq!(
        normal_nodes, normal.nodes,
        "the boot contract's own symbol count"
    );
    assert!(
        normal_nodes >= 16,
        "the contract cannot have SHRUNK below its 2026-08-23 sixteen: {normal_nodes}"
    );
    assert_eq!(num(&robot, "/graph/edges"), normal.edges);
    assert_eq!(
        num(&robot, "/graph/isolated"),
        normal.isolated,
        "and it SAYS how many have no edge"
    );
    assert!(
        num(&robot, "/graph/isolated") > 0,
        "the contract has symbols with no name-parent; a zero here is a pane that stopped counting"
    );
    assert_eq!(
        num(&robot, "/graph/vetoed"),
        0,
        "the default preset states no absences"
    );
    assert_eq!(normal.vetoed, 0, "…and that is the model's claim too");
    assert_eq!(num(&robot, "/graph/build_count"), 1, "one layout so far");
    assert_eq!(
        robot.state_at("/graph/empty_reason"),
        Some(serde_json::Value::Null),
        "there is a graph here, so there is no reason to print instead of one"
    );
    // The words are attached in 3D — asserted as TEXT, not a count.
    let words: Vec<String> = robot
        .state_at("/graph/label_texts")
        .and_then(|v| v.as_array().cloned())
        .unwrap_or_default()
        .iter()
        .map(|v| v.as_str().unwrap_or_default().to_string())
        .collect();
    assert!(
        words.contains(&"CONFIG_VIRTIO_NET".to_string()),
        "{words:?}"
    );

    // ── Step 2 — pick `nuked` through the REAL widget ─────────────────────────────
    robot
        .click_label("nuked")
        .expect("the preset picker is a real, clickable widget");
    robot.run(2);
    assert_eq!(robot.state_at("/preset"), Some(serde_json::json!("nuked")));
    assert_eq!(
        robot.state_at("/base"),
        Some(serde_json::json!("tinyconfig"))
    );
    let nuked = model_numbers(Preset::Nuked);
    let nuked_nodes = num(&robot, "/graph/nodes");
    assert_eq!(nuked_nodes, nuked.nodes);
    assert_eq!(num(&robot, "/graph/enabled"), nuked.enabled);
    assert_eq!(
        num(&robot, "/graph/vetoed"),
        nuked.vetoed,
        "the smallest preset's content IS its named absences"
    );
    assert!(
        nuked.vetoed >= 16,
        "`nuked.config` stated sixteen absences on 2026-08-23 and has only grown (IPv6 by name on 2026-08-29): {}",
        nuked.vetoed
    );
    // Only some of the closure relations the deps fragment claims to close actually
    // nest by name. The pane draws exactly those and no more.
    assert_eq!(
        num(&robot, "/graph/contract_closure_edges"),
        nuked.contract_closure_edges
    );
    assert!(
        nuked.contract_closure_edges > 0,
        "the closure fragment records at least one contract parent"
    );

    // THE SIZE CLAIM, stated in the direction that is true (see the header): `nuked`
    // draws MORE nodes than `normal` and leaves LESS of the universe undecided.
    let nuked_undecided = num(&robot, "/graph/undecided");
    assert!(
        nuked_nodes > normal_nodes,
        "nuked draws MORE nodes ({nuked_nodes}) than normal ({normal_nodes}) — absence \
         takes a line too"
    );
    assert!(
        nuked_undecided < normal_undecided,
        "…and yet decides MORE of the committed universe: {nuked_undecided} undecided vs \
         normal's {normal_undecided} (of {})",
        num(&robot, "/graph/universe")
    );

    // ── Step 3 — click a symbol OFF, and read the cascade ────────────────────────
    // `CONFIG_NET_CORE` is the only derivable child of `CONFIG_NET`, and neither is in
    // the boot contract. Dropping the child leaves the parent with nothing to be for —
    // §10's "remove this part and then no need for this dependency", in one gesture.
    assert_eq!(
        state_of(&robot, "CONFIG_NET"),
        "locked",
        "held by its one child"
    );
    assert_eq!(state_of(&robot, "CONFIG_NET_CORE"), "kept");
    assert_eq!(
        num(&robot, "/states/cascade_dropped"),
        0,
        "nothing is amber yet"
    );

    robot
        .click_label("CONFIG_NET_CORE")
        .expect("every symbol is a real, clickable widget beside the graph");
    robot.run(2);
    assert_eq!(state_of(&robot, "CONFIG_NET_CORE"), "clicked_off");
    assert_eq!(
        state_of(&robot, "CONFIG_NET"),
        "cascade_dropped",
        "nothing else needs it any more — it goes too"
    );
    assert_eq!(num(&robot, "/states/cascade_dropped"), 1);

    // COUNTERWEIGHT — a click that cascades NOTHING. `CONFIG_VIRTIO_MENU` is one of
    // `CONFIG_VIRTIO`'s four children; dropping it leaves three, so the parent stays
    // locked and the amber count does not move. Without this, "clicking turns things
    // amber" would be indistinguishable from "clicking turns everything amber".
    robot.click_label("CONFIG_VIRTIO_MENU").expect("clickable");
    robot.run(2);
    assert_eq!(state_of(&robot, "CONFIG_VIRTIO_MENU"), "clicked_off");
    assert_eq!(
        state_of(&robot, "CONFIG_VIRTIO"),
        "locked",
        "three kept children still hold it — the graph names them"
    );
    assert_eq!(
        num(&robot, "/states/cascade_dropped"),
        1,
        "the second drop cascaded nothing, so the first amber meant something"
    );
    let held: Vec<String> = robot
        .state_at("/symbols")
        .and_then(|v| v.as_array().cloned())
        .unwrap_or_default()
        .iter()
        .find(|s| s["name"].as_str() == Some("CONFIG_VIRTIO"))
        .map(|s| {
            s["held_by"]
                .as_array()
                .cloned()
                .unwrap_or_default()
                .iter()
                .map(|v| v.as_str().unwrap_or_default().to_string())
                .collect()
        })
        .unwrap_or_default();
    assert!(
        held.contains(&"CONFIG_VIRTIO_BLK".to_string()),
        "a locked symbol NAMES its holder: {held:?}"
    );

    // ── Step 4 — the REFUSAL. A drop that reaches the boot contract is refused BY
    // NAME, and does not take. `CONFIG_NLS` is the closure parent of both charset
    // tables in `base.config`; the FAT-fs charset lesson of 2026-08-13 is exactly what
    // dropping it would undo.
    let dropped_before = robot
        .state_at("/clicked_off")
        .and_then(|v| v.as_array().map(Vec::len))
        .unwrap_or_default();
    robot.click_label("CONFIG_NLS").expect("clickable");
    robot.run(2);
    assert_eq!(state_of(&robot, "CONFIG_NLS"), "refused");
    assert_eq!(
        robot.state_at("/refused_symbol"),
        Some(serde_json::json!("CONFIG_NLS"))
    );
    let breaks: Vec<String> = robot
        .state_at("/refused_breaks")
        .and_then(|v| v.as_array().cloned())
        .unwrap_or_default()
        .iter()
        .map(|v| v.as_str().unwrap_or_default().to_string())
        .collect();
    assert!(
        breaks.contains(&"CONFIG_NLS_CODEPAGE_437".to_string())
            && breaks.contains(&"CONFIG_NLS_ISO8859_1".to_string()),
        "a refusal NAMES what it would break, never a silent no: {breaks:?}"
    );
    assert_eq!(
        robot
            .state_at("/clicked_off")
            .and_then(|v| v.as_array().map(Vec::len)),
        Some(dropped_before),
        "the refused drop did not take — the pane said no and meant it"
    );

    // ── Step 5 — ROTATION IS CAMERA-ONLY ─────────────────────────────────────────
    // Two cascades and a refusal have happened since the layout was built. If any of
    // them had relaid it out, this counter would already have moved.
    assert_eq!(
        num(&robot, "/graph/build_count"),
        2,
        "one layout for `normal`, one for `nuked` — a cascade is a REPAINT"
    );
    assert!(
        robot.state_at("/graph/rotate") == Some(serde_json::json!(true)),
        "it spins by default"
    );
    let angle0 = robot
        .state_at("/graph/rotate_angle")
        .and_then(|v| v.as_f64())
        .expect("angle");
    let calls0 = kernel_graph_build_calls();

    const FRAMES: usize = 24;
    robot.run(FRAMES);

    let angle1 = robot
        .state_at("/graph/rotate_angle")
        .and_then(|v| v.as_f64())
        .expect("angle");
    assert_eq!(
        kernel_graph_build_calls() - calls0,
        0,
        "{FRAMES} spin frames rebuilt the layout — rotation must RE-PROJECT a cache"
    );
    assert_eq!(
        num(&robot, "/graph/build_count"),
        2,
        "…and the per-pane counter agrees"
    );
    assert!(
        (angle1 - angle0).abs() > 1e-3,
        "the camera azimuth advanced over {FRAMES} frames (Δ={:.5} rad) — a frozen \
         camera would pass the rebuild check for the wrong reason",
        (angle1 - angle0).abs()
    );

    // COUNTERWEIGHT — a GENUINE change DOES rebuild, exactly once. `build_count == 2`
    // above is a measurement, not a constant.
    robot
        .click_label("normal")
        .expect("the picker is still there");
    robot.run(2);
    assert_eq!(robot.state_at("/preset"), Some(serde_json::json!("normal")));
    assert_eq!(
        num(&robot, "/graph/build_count"),
        3,
        "a new pick rebuilds ONCE"
    );
    let rebuilds_for_one_pick = kernel_graph_build_calls() - calls0;
    assert_eq!(
        rebuilds_for_one_pick, 1,
        "and the process-wide counter saw exactly that one build"
    );
    assert_eq!(
        robot
            .state_at("/clicked_off")
            .and_then(|v| v.as_array().map(Vec::len)),
        Some(0),
        "a new pick clears clicks that named symbols the new pick may not have"
    );

    // ── Step 6 — NEVER-EMPTY-RETURN. `full` contributes no committed fragment, so the
    // pane draws no nodes — and must say WHY rather than leave a gap where a graph was.
    robot.click_label("full").expect("clickable");
    robot.run(2);
    assert_eq!(num(&robot, "/graph/nodes"), 0);
    let reason = robot
        .state_at("/graph/empty_reason")
        .and_then(|v| v.as_str().map(str::to_string))
        .unwrap_or_default();
    assert!(
        reason.contains("defconfig") && reason.contains("not enumerable"),
        "absence is a SENTENCE, not a gap: {reason:?}"
    );

    let ok = nuked_nodes == nuked.nodes
        && normal_nodes == normal.nodes
        && nuked_undecided < normal_undecided
        && rebuilds_for_one_pick == 1;

    #[cfg(feature = "testmatrix")]
    nornir_testmatrix::functional_status(
        "dwarves-gui",
        "a_robot_picks_a_preset_clicks_a_symbol_off_and_the_spin_is_camera_only",
        ok,
        &format!(
            "the kernel feature scene renders the SELECTED preset's committed kconfig symbol \
             set as a rotating 3D graph over the shared camera: picking `nuked` through a real \
             widget lowers {} symbols ({} enabled + {} named absences) against `normal`'s {} — \
             the model's own numbers, not a copy — clicking CONFIG_NET_CORE off turns its \
             only-parent CONFIG_NET amber while clicking CONFIG_VIRTIO_MENU off cascades \
             nothing, dropping CONFIG_NLS is REFUSED by name (it would break the boot \
             contract's two charset tables) and does not take, and across 24 spin frames plus \
             two cascades the layout build counter stays put — rising by exactly 1 when the \
             preset genuinely changes; `full` draws zero nodes and prints why",
            nuked.nodes, nuked.enabled, nuked.vetoed, normal.nodes
        ),
    );
    assert!(ok);
}

// ── the RUN report and the decision it produces (design §25 gaps 1 and 2) ─────

use dwarves_gui::kernel_scene::RunReport;
use nornir_build_thing::kernel::Preset;

/// ★ RED-FIRST 2026-08-23. Gap 1: the pane drew the DECLARED graph and nothing
/// else, while 150 measured drop candidates sat in a file on disk. A measurement
/// that cannot reach the person deciding is not a measurement, it is a log.
///
/// The join is the same one edda's `KernelRun::unused_configs` performs, brought
/// over as DATA so the pane needs a report and not a live kernel build.
#[test]
fn a_run_report_reaches_the_graph_and_zero_hits_are_not_absence() {
    // `<function> <object> <CONFIG>` exactly as the container writes it, and a
    // `-` row for an object no Makefile claims.
    let mapping = "\
vfs_statx fs/stat.o CONFIG_VT\n\
vfs_fstatat fs/stat.o CONFIG_VT\n\
snd_pcm_open sound/core/pcm.o CONFIG_SOUND\n\
mystery vmlinux.o -\n";
    let executed = vec!["vfs_statx".to_string()];
    let r = RunReport::from_mapping("prof2 run", mapping, &executed);

    assert_eq!(
        r.hits.get("CONFIG_VT"),
        Some(&1),
        "one of its functions ran"
    );
    assert_eq!(
        r.hits.get("CONFIG_SOUND"),
        Some(&0),
        "measured, and never entered"
    );
    assert!(
        !r.hits.contains_key("-"),
        "an unclaimed object is not a symbol a person can click"
    );
    assert_eq!(r.never_touched(), vec!["CONFIG_SOUND"]);

    // Onto the graph. `nuked` vetoes both VT and SOUND, so both are in the pick.
    let mut s = KernelScene::new();
    s.set_preset(Preset::Nuked);
    // RED: with no report there are NO drop candidates — absence of evidence is
    // never painted as evidence of absence.
    assert!(
        s.drop_candidates().is_empty(),
        "a candidate appeared with nothing measured"
    );
    assert!(s.run_report().is_none());

    s.set_report(Some(r));
    assert_eq!(s.run_report().map(|r| r.source.as_str()), Some("prof2 run"));
    assert!(
        s.drop_candidates().contains(&"CONFIG_SOUND"),
        "the measured zero-hit symbol is not offered: {:?}",
        s.drop_candidates()
    );
    assert!(
        !s.drop_candidates().contains(&"CONFIG_VT"),
        "a symbol whose function RAN was offered as a drop candidate"
    );
    // …and the honest remainder is reported, so a two-name list is not read as
    // "everything else is used".
    assert!(
        s.unmeasured() > 0,
        "the report covers 2 of this preset's symbols; the rest are unmeasured"
    );

    // Dropping the report returns the pane to the declared-only view.
    s.set_report(None);
    assert!(s.drop_candidates().is_empty());
}

/// ★ RED-FIRST. Gap 2: clicking a symbol off changed some colours and produced
/// NOTHING — no fragment, no declaration, nothing to build. A decision that
/// cannot leave the pane is not a decision.
#[test]
fn clicking_a_symbol_off_produces_a_fragment_someone_can_build() {
    let mut s = KernelScene::new();
    s.set_preset(Preset::Nuked);

    // With nothing clicked, the fragment says so rather than pretending.
    let empty = s.decision_fragment();
    assert!(empty.contains("nothing is dropped"), "{empty}");
    assert!(
        empty.contains("evidence: NONE"),
        "no report loaded, and it SAYS so:\n{empty}"
    );

    // Click a real symbol of this preset off.
    let target = s
        .symbols()
        .iter()
        .find(|sym| !sym.contract && sym.name != "CONFIG_EXPERT")
        .map(|sym| sym.name.clone())
        .expect("the nuked preset declares symbols");
    s.toggle(&target);
    assert!(s.clicked_off().contains(&target), "the click did not take");

    let frag = s.decision_fragment();
    // The DROP IS A VETO, not an omission: the build's canary catches a veto
    // creeping back in and cannot catch a line nobody wrote.
    let veto = format!("# {target} is not set");
    assert!(
        frag.contains(&veto),
        "the fragment must VETO {target}, not omit it:\n{frag}"
    );
    assert!(
        frag.contains("operator's choice"),
        "unmeasured drops say so:\n{frag}"
    );
    assert!(frag.contains("preset = nuked"), "{frag}");

    // With a report loaded, the SAME click is justified by the measurement.
    let mapping = format!("some_fn drivers/x.o {target}\n");
    s.set_report(Some(RunReport::from_mapping("a run", &mapping, &[])));
    s.toggle(&target);
    s.toggle(&target);
    let frag = s.decision_fragment();
    assert!(
        frag.contains("measured ZERO hits"),
        "the evidence must ride the file:\n{frag}"
    );

    // And a click AGAINST the measurement is written down as such rather than
    // quietly rendered like any other line.
    let mapping = format!("some_fn drivers/x.o {target}\n");
    s.set_report(Some(RunReport::from_mapping(
        "a run",
        &mapping,
        &["some_fn".to_string()],
    )));
    let frag = s.decision_fragment();
    assert!(
        frag.contains("AGAINST the measurement") || frag.contains("WARNING"),
        "dropping something the RUN saw executing must be flagged:\n{frag}"
    );
}

// ── the buttons actually fire (design §5 / §25 gap 4) ────────────────────────

use dwarves_gui::kernel_scene::{KernelJobKind, KernelJobProgress, KernelJobRequest, KernelJobs};
use std::sync::{Arc, Mutex};

/// A host that records what it was asked for and hands back a scripted
/// progress, so the buttons are testable without starting a kernel compile.
#[derive(Default)]
struct FakeJobs {
    seen: Arc<Mutex<Vec<KernelJobRequest>>>,
    refuse: Option<String>,
    feed: Vec<KernelJobProgress>,
}

impl KernelJobs for FakeJobs {
    fn start(&mut self, req: &KernelJobRequest) -> Result<(), String> {
        self.seen.lock().unwrap().push(req.clone());
        match &self.refuse {
            Some(w) => Err(w.clone()),
            None => Ok(()),
        }
    }
    fn progress(&mut self) -> Option<KernelJobProgress> {
        if self.feed.is_empty() {
            None
        } else {
            Some(self.feed.remove(0))
        }
    }
}

/// ★ RED-FIRST 2026-08-23. Gap 4: the pane had no buttons at all — §5 specified
/// Minify / Build / Run as dispatched jobs and the pane was a viewer, so
/// everything it showed was something a person then had to go and do by hand.
#[test]
fn a_click_dispatches_a_job_carrying_the_decision() {
    let mut s = KernelScene::new();
    s.set_preset(Preset::Nuked);

    // RED: unwired, a dispatch REFUSES by name rather than doing nothing
    // quietly. A missing button and a broken one must not look alike.
    assert!(!s.can_dispatch());
    let e = s
        .dispatch(KernelJobKind::Build)
        .expect_err("an unwired pane cannot build");
    assert!(e.contains("not wired"), "{e}");
    assert_eq!(s.job_error(), Some(e.as_str()));

    let seen = Arc::new(Mutex::new(Vec::new()));
    s.wire_jobs(
        "tunnr-kernel",
        Box::new(FakeJobs {
            seen: Arc::clone(&seen),
            ..FakeJobs::default()
        }),
    );
    assert!(s.can_dispatch());

    // A plain BYGG carries the pick and NO fragment — nothing was clicked off,
    // so there is no decision to carry and inventing one would be a lie.
    s.dispatch(KernelJobKind::Build).expect("wired");
    {
        let g = seen.lock().unwrap();
        assert_eq!(g.len(), 1);
        assert_eq!(g[0].kind, KernelJobKind::Build);
        assert_eq!(g[0].variant, "tunnr-kernel");
        assert_eq!(g[0].preset, "nuked");
        assert!(
            g[0].fragment.is_empty(),
            "a fragment appeared with nothing clicked off"
        );
    }

    // Click a symbol off; the NEXT dispatch carries the decision as kconfig.
    let target = s
        .symbols()
        .iter()
        .find(|sym| !sym.contract && sym.name != "CONFIG_EXPERT")
        .map(|sym| sym.name.clone())
        .expect("symbols");
    s.toggle(&target);
    s.dispatch(KernelJobKind::Minify).expect("wired");
    {
        let g = seen.lock().unwrap();
        assert_eq!(g.len(), 2);
        assert_eq!(g[1].kind, KernelJobKind::Minify);
        assert!(
            g[1].fragment.contains(&format!("# {target} is not set")),
            "the click did not reach the job:\n{}",
            g[1].fragment
        );
    }

    // A refusal from the host is kept and shown verbatim, not swallowed.
    let mut s2 = KernelScene::new();
    s2.wire_jobs(
        "tunnr-kernel",
        Box::new(FakeJobs {
            refuse: Some("no build space under this root".into()),
            ..FakeJobs::default()
        }),
    );
    let e = s2
        .dispatch(KernelJobKind::Run)
        .expect_err("the host said no");
    assert_eq!(e, "no build space under this root");
    assert_eq!(s2.job_error(), Some(e.as_str()));
    assert!(
        s2.job().is_none(),
        "a refused dispatch must not look like a running job"
    );
}

/// ★ The progress bar is COMPOSED, not invented: `make` emits no percentage, so
/// a phase with no honest fraction says so instead of drawing a bar that means
/// nothing, and the compile phase is marked ESTIMATED because its total comes
/// from the previous build's object count.
#[test]
fn progress_is_composed_and_says_where_it_is_guessing() {
    // A phase with no honest number.
    let p = KernelJobProgress::from_line("tar -xf /work/linux-6.12.104.tar.xz", Some(4000), 0);
    assert_eq!(p.phase, "unpack");
    assert!(
        p.fraction.is_none(),
        "unpack cannot report a percentage honestly"
    );
    assert!(!p.estimated);

    // The compile phase: a fraction, and it SAYS it is an estimate.
    let p = KernelJobProgress::from_line("  CC      init/main.o", Some(4000), 1000);
    assert_eq!(p.phase, "compile");
    assert_eq!(p.fraction, Some(0.25));
    assert!(
        p.estimated,
        "a total taken from the PREVIOUS build must be labelled"
    );

    // With no previous build there is no total, so there is no fraction —
    // rather than a made-up denominator.
    let p = KernelJobProgress::from_line("  CC      init/main.o", None, 1000);
    assert_eq!(p.phase, "compile");
    assert!(
        p.fraction.is_none(),
        "a first build has nothing to estimate against"
    );

    // The refusals the kernel kind actually emits are phases of their own, so a
    // canary refusal does not read as a compile that stalled.
    let p = KernelJobProgress::from_line(
        "KERNEL-CONFIG-REFUSED: CONFIG_TUN=y did not survive olddefconfig",
        Some(4000),
        10,
    );
    assert_eq!(p.phase, "canary");
    assert!(p.fraction.is_none());

    // And the pane surfaces whatever the host reports.
    let mut s = KernelScene::new();
    s.wire_jobs(
        "tunnr-kernel",
        Box::new(FakeJobs {
            feed: vec![KernelJobProgress {
                phase: "compile".into(),
                fraction: Some(0.5),
                estimated: true,
                current: "CC fs/stat.o".into(),
                done: None,
            }],
            ..FakeJobs::default()
        }),
    );
    s.dispatch(KernelJobKind::Build).expect("wired");
    s.poll_job();
    let j = s.job().expect("a running job");
    assert_eq!(j.phase, "compile");
    assert!(j.estimated);
}

// ── the pane inside a HORIZONTAL SPLIT, under a parent that lies ───────────────

/// ROBOT SCENARIO — **the symbol list stays on-surface when this pane is not at x≈0.**
///
/// The canvas has always been bounded by the viewport rather than by
/// `available_size()`, because an unbounded parent `Ui` reports far more width than the
/// window has and the graph would eat all of it. But it bounded a **width** against the
/// window's **width**:
///
/// ```text
/// let screen = ui.ctx().viewport_rect().size();
/// canvas.x = (avail.x.min(screen.x) - LIST_W - 18.0).max(240.0);
/// ```
///
/// That is only correct for a pane whose left edge is at x≈0. A pane starting at `x = L`
/// and taking `screen.x` of width overruns the right edge by exactly `L`. As a whole tab
/// this pane started at 8 px and the error was invisible — which is how it survived. As
/// the ⚒ tab's `build-thing-kernel` LEAF it sits in a horizontal split
/// (`build-thing-choose`, settings │ kernel) and starts hundreds of pixels in, so
/// mounting the five leaves turns the latent error live. Reported to this lane by the
/// facett lane that had just fixed the identical shape in the tab strip (`d272f1a0`).
///
/// The drive: mount the pane in a child `Ui` offset 620 px to the right and claiming
/// **2600 px** of width inside a 1400 px viewport — a parent that lies exactly the way a
/// split child's parent lies — and then click a real symbol. `RobotSession::click_label`
/// gates on visibility, so an off-surface list fails with its own words: *"the user
/// cannot see it, and a click at its centre lands off-surface"*.
///
/// RED WHEN REVERTED: put `avail.x.min(screen.x)` back and the list lands past 1400 px.
#[test]
fn the_symbol_list_is_clickable_when_the_pane_is_a_leaf_of_a_split() {
    /// How far in a split child's left edge sits — wider than the whole error budget of
    /// the old formula, and about what `build-thing-choose` gives the kernel half.
    const LEFT: f32 = 620.0;
    /// What the lying parent claims. `scene::render` builds every pane's child `Ui` with
    /// `UiBuilder::max_rect(alloc)`, and an ancestor built from an unbounded `Ui` can
    /// hand down a `max_rect` far past the viewport — this is that, made explicit.
    const CLAIMED: f32 = 2600.0;
    const VIEWPORT: f32 = 1400.0;

    let harness = Harness::builder()
        .with_size(nornir_robotui::egui::Vec2::new(VIEWPORT, 900.0))
        .build_ui_state(
            |ui, app: &mut SceneApp| {
                let origin = ui.max_rect().min + nornir_robotui::egui::vec2(LEFT, 0.0);
                let liar = nornir_robotui::egui::Rect::from_min_size(
                    origin,
                    nornir_robotui::egui::vec2(CLAIMED, 1400.0),
                );
                ui.scope_builder(
                    nornir_robotui::egui::UiBuilder::new().max_rect(liar),
                    |ui| {
                        Facet::ui(&mut app.0, ui);
                    },
                );
            },
            SceneApp(KernelScene::new()),
        );
    let mut robot = RobotSession::new(harness, "dwarves-gui");
    robot.run(2);

    // A real symbol of the DEFAULT preset (`normal`'s sixteen boot-contract options),
    // clicked through the real widget. This is the whole assertion: the gate refuses a
    // widget the user cannot see, and before the edge fix the list laid out past 1400 px.
    robot
        .click_label("CONFIG_VIRTIO_NET")
        .expect("the symbol list is on-surface when the pane starts 620px in");
    robot.run(2);

    // …and the click landed IN THE MODEL, not just on the pixels. `CONFIG_VIRTIO_NET` is
    // a measured boot-contract symbol, so the honest answer is a REFUSAL naming it —
    // which is a stronger proof than a silent accept: the pane both saw the gesture and
    // said why it will not obey.
    assert_eq!(
        state_of(&robot, "CONFIG_VIRTIO_NET"),
        "refused",
        "the click reached the model, not just the pixels"
    );
    assert_eq!(
        robot.state_at("/refused_symbol"),
        Some(serde_json::json!("CONFIG_VIRTIO_NET")),
        "…and the refusal names the symbol the pointer actually hit"
    );
}