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

//! **ROBOT-UI: the OPT-OUTS, through real widgets.**
//!
//! An appliance customer must be able to say "no WireGuard" / "no DHCP" and get
//! a binary that does not CONTAIN that code. The recipe declares the axis
//! (`[[build.installer.options]]`); this file holds what the pane must do with
//! it, driven through the live AccessKit tree rather than through the model:
//!
//! * the opt-outs are real, addressable checkboxes, ticked to the DECLARATION's
//!   default (ON — an opt-out ships unless someone opts out), and labelled with
//!   the recipe's own words;
//! * unticking DHCP REVEALS the static address, and unticking WireGuard HIDES
//!   the hub declaration — the inputs a build actually needs, and no others;
//! * BYGG is shut while a revealed required input is blank or malformed, and the
//!   reason is on the screen beside it;
//! * a bad IP is refused by the shape the OPTION sharpened it to (`ipv4-cidr` /
//!   `ipv4`), although the parameter row itself only says `kind = "text"`;
//! * the feature delta is shown BEFORE the build, in the words the receipt will
//!   use afterwards.
//!
//! The scene knows no capability by name. `dhcp` and `wireguard` appear below
//! only because the staged recipe declares them, exactly as `hub_conf` does.

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

use dwarves_gui::build_forge::DeclaredForge;
use dwarves_gui::build_scene::BuildScene;

struct SceneApp(BuildScene);

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

/// The checkbox labels, as the staged recipe spells them.
const DHCP: &str = "DHCP (eth0)";
const WG: &str = "WireGuard-klient";

/// One checkout, one appliance variant, one installer that packs it and declares
/// two opt-outs: `dhcp` (whose OFF state brings a static address) and
/// `wireguard` (whose ON state allows a hub declaration). Not any product's
/// names — `orten` is nobody.
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");
    std::fs::write(
        repo.join("Cargo.toml"),
        "[package]\nname = \"orten\"\nversion = \"1.0.0\"\nedition = \"2021\"\n\n[dependencies]\n",
    )
    .expect("manifest");
    std::fs::write(
        repo.join("Cargo.lock"),
        "version = 4\n\n[[package]]\nname = \"orten\"\nversion = \"1.0.0\"\n",
    )
    .expect("lockfile");
    std::fs::write(
        repo.join(".nornir").join("build-variants.toml"),
        r#"
[build]
base_suite = "ORIGINAL"

[[build.variant]]
name = "orten-server"
role = "server"
default = true
suites = ["ORIGINAL"]
package = "orten"
bin = "orten"
kind = "exe"
no_default_features = true
features = ["server", "dhcp-client", "wg-client"]
profile = "release"

[[build.variant]]
name = "orten-installer"
role = "server"
default = false
suites = ["ORIGINAL"]
kind = "installer"

[[build.installer]]
name = "orten-installer"
variant = "orten-server"
volume_id = "ORTEN_INSTALL"

[[build.installer.params]]
name = "static_ip"
label = "IP / prefix"
kind = "text"
required = true
boot_param = "korp.env.ORTEN_IP"

[[build.installer.params]]
name = "gateway"
label = "Gateway"
kind = "text"
required = true
boot_param = "korp.env.ORTEN_GATEWAY"

[[build.installer.params]]
name = "hub_conf"
label = "Hub-deklaration"
kind = "text"
required = false
staged_file = "hub.conf"

[build.installer.fixtures]
hub_conf = "wg-hub"

[[build.installer.options]]
name = "dhcp"
label = "DHCP (eth0)"
default = true
feature = "dhcp-client"
params_when_off = ["static_ip", "gateway"]
param_kinds = { static_ip = "ipv4-cidr", gateway = "ipv4" }
forbid_symbols = ["orten_server::dhcp"]
bakes = { boot_param = "korp.env.ORTEN_NET", when_on = "eth0:dhcp", when_off = "eth0:{static_ip}:{gateway}" }

[[build.installer.options]]
name = "wireguard"
label = "WireGuard-klient"
default = true
feature = "wg-client"
params_when_on = ["hub_conf"]
forbid_symbols = ["wg_appliance_core", "boringtun"]
"#,
    )
    .expect("declaration");
    dir
}

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, 1200.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
}

/// Drive to the installer column through the real widgets, and return its name.
fn open_installer(robot: &mut RobotSession<'_, SceneApp>) -> String {
    robot
        .type_into("artefakt", "orten-server")
        .expect("the artefakt field");
    robot.click_label("sök").expect("the sök button");
    robot.run(2);
    robot
        .click_label("orten-server")
        .expect("the appliance row is a real widget");
    robot.run(2);
    let column = "installer:orten-installer".to_string();
    robot
        .click_label(&column)
        .expect("the installer column is clickable");
    robot.run(3);
    assert_eq!(robot.state_at("/target"), Some(serde_json::json!(column)));
    column
}

fn visible(robot: &RobotSession<'_, SceneApp>) -> Vec<String> {
    robot
        .state_at("/visible_settings")
        .and_then(|v| v.as_array().cloned())
        .unwrap_or_default()
        .iter()
        .filter_map(|v| v.as_str().map(str::to_string))
        .collect()
}

/// **Drive frames WITH WALL CLOCK until `/axis_pick` is the picture of `want`.**
///
/// ── WHY A FIXED FRAME COUNT COULD NEVER WORK HERE ─────────────────────────────
///
/// The dependency picture is derived OFF the ui thread on purpose
/// (`Forge::axis` answers `Axis::Working` first and the scene keeps asking —
/// resolving a lockfile universe is measured in tens of seconds on the real
/// constellation). `RobotSession::run(n)` pumps `n` egui frames back to back and
/// each costs microseconds, so `run(3)` is roughly **zero milliseconds of wall
/// clock** — the worker is given no time at all in which to answer.
///
/// MEASURED 2026-08-24 on oden: with the old fixed-frame read, `/axis_pick` came
/// back `null` (or the stale row-only pick from before the column click) on 5 of 5
/// runs, `/axis_note` reading `beroendegrafen räknas ut …` — i.e. the forge was
/// still `Working` every single time. Polling the SAME sequence with 200 ms
/// between reads settled on round 0 or 1 with exactly the values asserted below.
/// So the pane was right and the guard was the thing that was broken: it could
/// only ever pass by winning a sub-millisecond race, which makes its green a
/// coin-flip rather than a measurement (LAW 2).
///
/// This is `robot_build_scene_ui.rs`'s `axis_of` applied to the subject rather
/// than to the graph — that file solved this exact race first and this one did
/// not reuse it. Both loops are hand-rolled; the real cure is ONE
/// `settle_until`-shaped helper on `RobotSession` in `nornir-robotui`, which is
/// another repo.
///
/// It RETURNS the settled pick rather than asserting, and the caller `expect`s —
/// so a genuinely absent axis still reports red with the reason attached, which
/// is what a timeout has to do to be worth having.
fn axis_pick_of(
    robot: &mut RobotSession<'_, SceneApp>,
    want: &serde_json::Value,
) -> Option<serde_json::Value> {
    for _ in 0..600 {
        if let Some(p) = robot.state_at("/axis_pick") {
            if &p == want {
                return Some(p);
            }
        }
        robot.run(1);
        std::thread::sleep(std::time::Duration::from_millis(20));
    }
    None
}

/// What the picture is of once the installer column is open, spelled out so the
/// timeout message can say what it waited for.
fn installer_pick() -> serde_json::Value {
    serde_json::json!({
        "row": "orten-server",
        "target": "installer:orten-installer",
        "toggles": {"dhcp": true, "wireguard": true},
    })
}

#[test]
fn the_opt_outs_are_real_checkboxes_ticked_to_the_recipes_default() {
    let dir = staged();
    let mut robot = session(dir.path());
    open_installer(&mut robot);

    // The DECLARATION reached the scene verbatim — label and feature both.
    let rows = robot.state_at("/rows").expect("rows");
    let settings = rows.as_array().expect("rows")[..]
        .iter()
        .find(|r| r["name"] == "orten-server")
        .and_then(|r| r["targets"].as_array())
        .expect("targets")
        .iter()
        .find(|t| t["name"] == "installer:orten-installer")
        .and_then(|t| t["settings"].as_array())
        .cloned()
        .expect("the installer column carries settings");
    let dhcp = settings
        .iter()
        .find(|s| s["key"] == "dhcp")
        .expect("dhcp declared");
    assert_eq!(dhcp["option"]["label"], DHCP);
    assert_eq!(dhcp["option"]["feature"], "dhcp-client");
    let ip = settings
        .iter()
        .find(|s| s["key"] == "static_ip")
        .expect("static_ip declared");
    assert_eq!(ip["governed_by"]["option"], "dhcp");
    assert_eq!(
        ip["governed_by"]["shown_when"], false,
        "the static address belongs to DHCP's OFF state"
    );

    // Both are addressable checkboxes, and both start TICKED — an opt-out ships
    // unless somebody opts out.
    assert!(
        robot.label_visible(DHCP),
        "the dhcp opt-out has no control at all"
    );
    assert!(
        robot.label_visible(WG),
        "the wireguard opt-out has no control at all"
    );
    assert_eq!(robot.field_checked(&Target::Label(DHCP.into())), Some(true));
    assert_eq!(robot.field_checked(&Target::Label(WG.into())), Some(true));
    assert_eq!(
        robot.state_at("/feature_delta"),
        Some(serde_json::json!("features: +dhcp-client +wg-client"))
    );
}

#[test]
fn unticking_dhcp_reveals_the_static_address_and_unticking_wireguard_hides_the_hub() {
    let dir = staged();
    let mut robot = session(dir.path());
    open_installer(&mut robot);

    // ── with both ON: the hub declaration is asked for, the address is not ──
    let v = visible(&robot);
    assert!(
        v.contains(&"hub_conf".to_string()),
        "WireGuard is on, so the hub applies: {v:?}"
    );
    assert!(
        !v.contains(&"static_ip".to_string()),
        "a static address was asked for while DHCP is on — an input nothing would read: {v:?}"
    );
    assert!(
        !robot.label_visible("IP / prefix"),
        "the hidden field is on the screen anyway"
    );

    // ── untick DHCP: the address appears ────────────────────────────────────
    robot
        .click_label(DHCP)
        .expect("the dhcp checkbox is clickable");
    robot.run(3);
    assert_eq!(
        robot.field_checked(&Target::Label(DHCP.into())),
        Some(false)
    );
    let v = visible(&robot);
    assert!(
        v.contains(&"static_ip".to_string()),
        "DHCP is off and no address is asked for: {v:?}"
    );
    assert!(v.contains(&"gateway".to_string()), "{v:?}");
    assert!(
        robot.label_visible("static_ip"),
        "the revealed field is not on the screen"
    );

    // ── untick WireGuard: the hub declaration, and its fixture, go away ─────
    robot
        .click_label(WG)
        .expect("the wireguard checkbox is clickable");
    robot.run(3);
    let v = visible(&robot);
    assert!(
        !v.contains(&"hub_conf".to_string()),
        "WireGuard is off and the hub declaration is still asked for — it would ride a medium \
         whose binary cannot read it: {v:?}"
    );
    assert!(
        !robot.label_visible("hub_conf: använd fixtur (wg-hub)"),
        "the fixture affordance survived its own capability being switched off"
    );
    assert_eq!(
        robot.state_at("/feature_delta"),
        Some(serde_json::json!(
            "features: \u{2212}dhcp-client \u{2212}wg-client"
        )),
        "the delta does not follow the toggles"
    );
}

#[test]
fn bygg_is_shut_until_the_revealed_address_is_present_and_well_shaped() {
    let dir = staged();
    let mut robot = session(dir.path());
    open_installer(&mut robot);

    // With DHCP on, nothing is required and the button is armed.
    assert_eq!(robot.state_at("/can_build"), Some(serde_json::json!(true)));

    robot.click_label(DHCP).expect("the dhcp checkbox");
    robot.run(3);
    assert_eq!(
        robot.state_at("/can_build"),
        Some(serde_json::json!(false)),
        "BYGG armed with a REQUIRED static address left blank — the door would refuse it after \
         paying for a compose"
    );
    let unmet = robot.state_at("/unmet").expect("unmet");
    let keys: Vec<String> = unmet
        .as_array()
        .expect("list")
        .iter()
        .filter_map(|u| u["key"].as_str().map(str::to_string))
        .collect();
    assert_eq!(
        keys,
        vec!["static_ip", "gateway"],
        "the reason names the wrong fields: {unmet}"
    );

    // A malformed address is refused by the shape the OPTION sharpened it to,
    // although the parameter ROW says only `kind = "text"`. `type_into` APPENDS,
    // which is used deliberately below: the address is completed a keystroke at
    // a time, exactly as an operator types it.
    robot
        .type_into("static_ip", "192.168.1.50")
        .expect("the static_ip field");
    robot
        .type_into("gateway", "192.168.1.1")
        .expect("the gateway field");
    robot.run(3);
    let unmet = robot.state_at("/unmet").expect("unmet");
    assert!(
        unmet.to_string().contains("static_ip"),
        "`192.168.1.50` has no /prefix and was accepted as an interface address: {unmet}"
    );
    assert!(
        !unmet.to_string().contains("gateway"),
        "a well-formed gateway was refused alongside it: {unmet}"
    );
    assert_eq!(robot.state_at("/can_build"), Some(serde_json::json!(false)));

    // …and finishing the prefix arms it.
    robot
        .type_into("static_ip", "/24")
        .expect("the static_ip field");
    robot.run(3);
    assert_eq!(
        robot.state_at("/unmet"),
        Some(serde_json::json!([])),
        "still refusing a valid address+gateway"
    );
    assert_eq!(robot.state_at("/can_build"), Some(serde_json::json!(true)));
}

/// LAW 2 on the shape check: a comma makes it a LIST, and an interface takes
/// exactly one address. Without this the `ipv4-cidr` arm would be
/// indistinguishable from the `cidr-list` it is the singular of.
#[test]
fn an_interface_address_takes_exactly_one_cidr() {
    let dir = staged();
    let mut robot = session(dir.path());
    open_installer(&mut robot);
    robot.click_label(DHCP).expect("the dhcp checkbox");
    robot.run(3);
    robot
        .type_into("gateway", "192.168.1.1")
        .expect("the gateway field");
    robot
        .type_into("static_ip", "192.168.1.50/24, 10.0.0.1/8")
        .expect("the static_ip field");
    robot.run(3);
    let unmet = robot.state_at("/unmet").expect("unmet").to_string();
    assert!(
        unmet.contains("static_ip"),
        "a two-address list was accepted: {unmet}"
    );
    assert!(
        unmet.contains("exakt en adress"),
        "the refusal does not say WHY a list is wrong here: {unmet}"
    );
}

/// **The picture follows the toggles — and stops flickering.**
///
/// Two defects, one cause. MEASURED 2026-08-22: the dependency picture was
/// derived from the picked ROW's own cargo features, so
///
/// * an installer row, which declares none because the thing it builds is the
///   appliance it packs, settled on `bygger utan features` and drew NOTHING —
///   while the app row beside it drew fine. Moving between them is what "the
///   picture sometimes flickers in" was;
/// * and the features were the DECLARED ones, so a build about to be made
///   without WireGuard was pictured with its WireGuard parts.
///
/// So the pick carries row + column + toggles, and the scene re-asks when any
/// of them moves — and only then.
#[test]
fn the_dependency_picture_is_of_the_packed_variant_and_follows_the_toggles() {
    let dir = staged();
    let mut robot = session(dir.path());
    open_installer(&mut robot);

    // The forge derives this OFF the ui thread, so the read has to be given wall
    // clock rather than frames — see `axis_pick_of`. `want` carries all three
    // fields the assertions below check, so a timeout says which of them never
    // arrived instead of just "null".
    let want = installer_pick();
    let pick = axis_pick_of(&mut robot, &want).unwrap_or_else(|| {
        panic!(
            "the dependency picture never became one of the packed variant. wanted {want}, \
             the scene last said {:?} with note {:?}",
            robot.state_at("/axis_pick"),
            robot.state_at("/axis_note"),
        )
    });
    assert_eq!(pick["row"], "orten-server");
    assert_eq!(
        pick["target"], "installer:orten-installer",
        "the picture is keyed on the row alone, so an installer column cannot change it"
    );
    assert_eq!(
        pick["toggles"],
        serde_json::json!({"dhcp": true, "wireguard": true}),
        "the toggles in force are not part of what the picture is of: {pick}"
    );

    // ── STABLE: more frames do not move it ────────────────────────────────
    robot.run(6);
    assert_eq!(
        robot.state_at("/axis_pick"),
        Some(pick.clone()),
        "the picture's subject changed with nothing but the passage of frames — that is the \
         flicker"
    );

    // ── FOLLOWS: unticking WireGuard makes it a picture of a different build ──
    robot.click_label(WG).expect("the wireguard checkbox");
    let want_after = serde_json::json!({
        "row": "orten-server",
        "target": "installer:orten-installer",
        "toggles": {"dhcp": true, "wireguard": false},
    });
    let after = axis_pick_of(&mut robot, &want_after).unwrap_or_else(|| {
        panic!(
            "the picture never followed the tick. wanted {want_after}, the scene last said \
             {:?} with note {:?}",
            robot.state_at("/axis_pick"),
            robot.state_at("/axis_note"),
        )
    });
    assert_eq!(after["row"], "orten-server", "the row did not change");
    assert_eq!(
        after["toggles"],
        serde_json::json!({"dhcp": true, "wireguard": false}),
        "the picture did not follow the tick: {after}"
    );
    assert_ne!(
        after, pick,
        "the same picture is being shown for two different builds"
    );

    // …and it settles again rather than oscillating.
    robot.run(6);
    assert_eq!(robot.state_at("/axis_pick"), Some(after));
}