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

//! **ROBOT-UI: after BYGG the selected node's menu shows, BY TYP ONLY, a buildinfo
//! and the verbs the typ bears** (the owner's ruling, 2026-08-26 — gap #7 as data).
//!
//! | claim | RED when |
//! |---|---|
//! | a LIB shows a buildinfo and NO RUN | a verb leaks onto a typ that bears none |
//! | a CONTAINER shows RUN | the container's verb is lost |
//! | an EXE's RUN offers `run.where` = container \| vm and nothing else (no host process) | a third choice appears, or the val is missing |
//! | firing RUN writes the RUN receipt — the second receipt, beside the build's | the two receipts are one |
//!
//! The typ is read off the BUILD receipt the fake forge hands back (`Receipt.target`),
//! never off the row, so the assertions here are about [`dwarves_gui::actions`]'s table
//! reached through the receipt — the road edda's `actions(kind)` takes when it lands.

use std::sync::{Arc, Mutex};

use facett_core::Facet;
use nornir_build_thing::contract::{Coordinate, Receipt, RECEIPT_VERSION};
use nornir_build_thing::variant::ArtifactKind;
use nornir_robotui::egui_kittest::Harness;
use nornir_robotui::{Observable, RobotSession};
use serde_json::{json, Value};

use dwarves_gui::build_scene::{
    Act, Axis, AxisPick, BuildScene, Forge, Menu, Outcome, Phase, Query, Report, Row, Setting,
    Target,
};

const ROW: &str = "orten";

/// A forge with one row whose one column produces `typ`, and a scripted build
/// outcome whose receipt names that typ.
struct Fake {
    typ: ArtifactKind,
    column: &'static str,
    acts: Arc<Mutex<Vec<Act>>>,
    built: Arc<Mutex<bool>>,
}

impl Forge for Fake {
    fn axis(&mut self, _pick: &AxisPick) -> Axis {
        Axis::Absent
    }
    fn resolve(&mut self, _q: &Query) -> Result<Menu, String> {
        Ok(Menu {
            coordinate: Coordinate::parse("orten-build-thing:0.1.0").unwrap(),
            how: "löst ur namnet".into(),
            rows: vec![Row {
                name: ROW.into(),
                badges: vec![],
                targets: vec![Target {
                    name: self.column.into(),
                    kind: self.typ,
                    pane: None,
                    declared: true,
                    settings: vec![Setting::fixed("kind", self.typ.as_str(), "deklarerad")],
                    variant: None,
                }],
            }],
        })
    }
    fn act(&mut self, act: Act) {
        if matches!(act, Act::Build { .. }) {
            *self.built.lock().unwrap() = true;
        }
        self.acts.lock().unwrap().push(act);
    }
    fn poll(&mut self) -> Report {
        if !*self.built.lock().unwrap() {
            return Report::default();
        }
        Report {
            phase: Some(Phase::Done),
            elapsed_ms: 1200,
            outcome: Some(Outcome {
                ok: true,
                artifact: format!("target/release/{ROW}"),
                size_bytes: Some(1_000),
                sha: Some("7c1a2b3c4d5e".into()),
                receipt: Some(Receipt {
                    receipt_version: RECEIPT_VERSION,
                    build_thing: Coordinate::parse("orten-build-thing:0.1.0:a91f4c2e").unwrap(),
                    atom: ROW.into(),
                    target: self.typ.as_str().into(),
                    settings: Default::default(),
                    inputs: Default::default(),
                }),
                proof: None,
                elapsed_ms: 1200,
                ports: vec![],
                note: None,
                applied: vec![],

                features: None,
                warehouse_line: String::new(),
                warehouse_red: false,
                warehouse_landed: false,
            }),
            live: false,
            lines: vec![],
            screen: None,
            note: None,
            menu: None,
        }
    }
}

struct App(BuildScene);
impl Observable for App {
    fn state_json(&self) -> Value {
        Facet::state_json(&self.0)
    }
}

fn built(
    typ: ArtifactKind,
    column: &'static str,
) -> (RobotSession<'static, App>, Arc<Mutex<Vec<Act>>>) {
    let acts = Arc::new(Mutex::new(Vec::new()));
    let forge = Fake {
        typ,
        column,
        acts: acts.clone(),
        built: Arc::new(Mutex::new(false)),
    };
    let scene = BuildScene::new(Box::new(forge));
    let harness = Harness::builder()
        .with_size(nornir_robotui::egui::Vec2::new(1400.0, 1000.0))
        .build_ui_state(|ui, app: &mut App| Facet::ui(&mut app.0, ui), App(scene));
    let mut robot = RobotSession::new(harness, "dwarves-gui");
    robot.run(2);
    robot.type_into("artefakt", ROW).unwrap();
    robot.click_label("sök").unwrap();
    robot.run(2);
    robot.click_label(ROW).unwrap();
    robot.run(2);
    robot.click_label(column).unwrap();
    robot.run(2);
    robot.click_label("BYGG").expect("BYGG is armed");
    robot.run(3);
    (robot, acts)
}

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

/// **A lib: buildinfo, no RUN.**
#[test]
fn a_lib_shows_a_buildinfo_and_no_run() {
    let (robot, _) = built(ArtifactKind::Lib, "lib");
    let info = robot
        .state_at("/menu/buildinfo")
        .expect("buildinfo after a build");
    assert_eq!(
        info["typ"],
        json!("lib"),
        "the typ is read off the receipt: {info}"
    );
    assert!(
        info["receipt"]
            .as_str()
            .is_some_and(|r| r.contains("orten-build-thing")),
        "the receipt, readable: {info}"
    );
    assert!(
        verbs(&robot).is_empty(),
        "a lib bears no verb: {:?}",
        verbs(&robot)
    );
    assert!(!robot.label_visible("RUN"), "RUN must not appear for a lib");
    assert!(!robot.label_visible("BOOT"));
    assert_eq!(robot.state_at("/menu/actions/where"), Some(Value::Null));
}

/// **A container: RUN.**
#[test]
fn a_container_shows_run() {
    let (mut robot, acts) = built(ArtifactKind::Container, "container");
    assert_eq!(verbs(&robot), vec!["RUN"]);
    assert!(robot.label_visible("RUN"));
    assert_eq!(
        robot.state_at("/menu/actions/where"),
        Some(Value::Null),
        "a container has no `where`: it runs through draupnir"
    );
    robot.click_label("RUN").unwrap();
    robot.run(2);
    assert!(
        acts.lock().unwrap().iter().any(|a| matches!(a, Act::Start)),
        "RUN on a container starts it: {:?}",
        acts.lock().unwrap()
    );
    let rr = robot
        .state_at("/run_receipt")
        .expect("the RUN receipt — the second receipt");
    assert_eq!(rr["verb"], json!("RUN"));
    assert_eq!(rr["typ"], json!("container"));
    assert_eq!(
        rr["artifact_sha"],
        json!("7c1a2b3c4d5e"),
        "the two receipts pair on the artifact's sha"
    );
}

/// **An exe: RUN, with `run.where` = container | vm and nothing else.** A host
/// process is not an option; the vm road is named, not faked.
#[test]
fn an_exes_run_offers_container_or_vm_and_nothing_else() {
    let (mut robot, acts) = built(ArtifactKind::Exe, "exe");
    assert_eq!(verbs(&robot), vec!["RUN"]);
    let w = robot
        .state_at("/menu/actions/where")
        .expect("the where val");
    assert_eq!(w["path"], json!("run.where"));
    assert_eq!(
        w["choices"],
        json!(["container", "vm"]),
        "exactly two places, no host process"
    );
    assert_eq!(
        robot.state_at("/menu/actions/run_where"),
        Some(json!("container")),
        "container is the default"
    );
    assert!(robot.label_visible("container") && robot.label_visible("vm"));
    assert!(!robot.label_visible("host") && !robot.label_visible("värd"));

    // vm: offered, not wired — a named note, no act.
    robot.click_label("vm").unwrap();
    robot.run(2);
    robot.click_label("RUN").unwrap();
    robot.run(2);
    assert!(
        acts.lock()
            .unwrap()
            .iter()
            .all(|a| !matches!(a, Act::Run(_))),
        "a vm run must not run on the host"
    );
    assert!(robot
        .state_at("/menu/run_note")
        .and_then(|v| v.as_str().map(|s| s.contains("vm")))
        .unwrap_or(false));

    // container: the act fires, and the RUN receipt says where.
    robot.click_label("container").unwrap();
    robot.run(2);
    robot.click_label("RUN").unwrap();
    robot.run(2);
    assert!(
        acts.lock()
            .unwrap()
            .iter()
            .any(|a| matches!(a, Act::Run(_))),
        "RUN in a container runs the artifact: {:?}",
        acts.lock().unwrap()
    );
    let rr = robot.state_at("/run_receipt").expect("the RUN receipt");
    assert_eq!(rr["where"], json!("container"));
    assert_eq!(rr["typ"], json!("exe"));
    // The surfaces the console draws for an exe: log + exit code, as data.
    assert_eq!(
        robot.state_at("/menu/actions/surfaces"),
        Some(json!(["log", "exit_code"]))
    );
}

/// **A medium boots** — kernel, iso, installer: BOOT, serial + framebuffer.
#[test]
fn a_medium_boots_with_serial_and_framebuffer() {
    for (typ, col) in [
        (ArtifactKind::Installer, "installer"),
        (ArtifactKind::Kernel, "kernel"),
    ] {
        let (robot, _) = built(typ, col);
        assert_eq!(verbs(&robot), vec!["BOOT"], "{typ:?}");
        assert!(!robot.label_visible("RUN"), "{typ:?} bears BOOT, not RUN");
        assert_eq!(
            robot.state_at("/menu/actions/surfaces"),
            Some(json!(["serial", "framebuffer"]))
        );
    }
}