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