dwarves-gui/tests/robot_step_console.rs
//! **ROBOT-UI: the step console renders all FIVE step states from a fixture DAG,
//! without a forge** (wave 3, gap #4).
//!
//! | claim | RED when |
//! |---|---|
//! | the hosted console leaf says, by name, that nothing is planned | an empty console that reads like a finished one |
//! | a staged plan draws every step in DAG order with its state's own line | two states share a line, or a step is dropped |
//! | `queued · running in container <image@digest> · cached from ledger · done with receipt sha+fragment · refused: <reason>` are each ON SCREEN | a state is only in the data |
//! | the expectation (cold / cached) and its source are drawn BEFORE any run | a figure without a source |
//!
//! The plan is DATA through the shipped `update_json` door (`stage_steps`), so no
//! forge, no runner, no container is needed to prove the console tells the truth about
//! each state. RED BEFORE GREEN: on the parent commit the console leaf had no step
//! table and `stage_steps` was not a message.
#![cfg(not(target_arch = "wasm32"))]
use facett_core::Facet;
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, Query, Report};
use dwarves_gui::build_steps::{Expectation, Step, StepPlan, StepRole, StepState};
use dwarves_gui::build_thing_scene::CONSOLE;
use dwarves_gui::tabbed::LeafPanel;
use nornir_build_thing::variant::ArtifactKind;
/// A forge that resolves nothing and runs nothing — the console must not need one.
struct NoForge;
impl Forge for NoForge {
fn axis(&mut self, _pick: &AxisPick) -> Axis {
Axis::Absent
}
fn resolve(&mut self, _q: &Query) -> Result<Menu, String> {
Err("ingen smedja i den här roboten".into())
}
fn act(&mut self, _act: Act) {}
fn poll(&mut self) -> Report {
Report::default()
}
}
struct App(BuildScene);
impl Observable for App {
fn state_json(&self) -> Value {
Facet::state_json(&self.0)
}
}
/// The console LEAF, hosted — the shape the ⚒ tab mounts it in.
fn console_leaf() -> RobotSession<'static, App> {
let scene = BuildScene::new(Box::new(NoForge)).hosted();
let harness = Harness::builder()
.with_size(nornir_robotui::egui::Vec2::new(2400.0, 1200.0))
.build_ui_state(
|ui, app: &mut App| {
app.0.ui_leaf(CONSOLE, ui);
},
App(scene),
);
let mut robot = RobotSession::new(harness, "dwarves-gui");
robot.run(2);
robot
}
/// The fixture DAG: five steps, one per state, donor-first with the consumer last.
fn fixture() -> StepPlan {
let mut step =
|coordinate: &str, role: StepRole, typ: ArtifactKind, state: StepState, ms: u64| {
let mut s = Step::queued(coordinate, role, typ, vec![format!("--build {coordinate}")]);
s.state = state;
s.elapsed_ms = ms;
s
};
StepPlan {
product: Some("orten".into()),
variant: "orten-installer".into(),
edge_path: "kernel".into(),
coordinate: "tunnr/tunnr-kernel".into(),
cli: "dwarves buildthing orten --variant orten-installer --build kernel --opt kernel.preset=nuked".into(),
steps: vec![
step("tunnr/tunnr-kernel", StepRole::Donor, ArtifactKind::Kernel, StepState::Cached { key: "3f9a1c2b4d5e6f708192a3b4c5d6e7f8".into() }, 145_000),
step("edda/edda-fragments", StepRole::Donor, ArtifactKind::Lib, StepState::Done { sha: "c9a4fc6c1122334455667788".into(), fragment: Some("d437b9195566778899aabbcc".into()) }, 323_000),
step("orten/orten-toolchain", StepRole::Donor, ArtifactKind::Container, StepState::Running { image: "nordisk-cargo-toolchain@sha256:9e8d7c6b5a4f".into() }, 61_000),
step("orten/orten-ui-wasm", StepRole::Donor, ArtifactKind::Wasm, StepState::Refused { why: "bilden saknar std för wasm32 — rustup target add wasm32-unknown-unknown i toolchain-containern".into() }, 0),
step("orten-installer", StepRole::Consumer, ArtifactKind::Installer, StepState::Queued, 0),
],
settings: Default::default(),
params: Vec::new(),
}
}
/// **Nothing planned ⇒ the console says so.** Never a blank pane, never a spinner.
#[test]
fn an_empty_console_says_nothing_is_planned() {
let robot = console_leaf();
assert_eq!(robot.state_at("/steps"), Some(Value::Null));
assert!(robot.label_visible(
"stegkonsol: ingen kant vald — BYGG <kant> i nodens meny planerar DAG:en här"
));
}
/// **Five states, five lines, all on screen, in DAG order — from data, without a forge.**
#[test]
fn the_console_renders_all_five_step_states_from_a_fixture_dag() {
let mut robot = console_leaf();
let plan = fixture();
let msg = json!({ "stage_steps": serde_json::to_value(&plan).unwrap() }).to_string();
robot.harness_mut().state_mut().0.update_json(&msg);
robot.run(3);
let steps = robot.state_at("/steps").expect("the plan is staged");
assert_eq!(steps["cli"], json!(plan.cli));
assert!(
robot.label_visible(&plan.cli),
"the CLI line is on the console"
);
let rows = steps["steps"].as_array().expect("steps");
assert_eq!(rows.len(), 5);
let words: Vec<&str> = rows.iter().map(|r| r["state"].as_str().unwrap()).collect();
assert_eq!(
words,
["cached", "done", "running", "refused", "queued"],
"every state, DAG order kept"
);
assert_eq!(rows[4]["role"], json!("konsument"), "the consumer is last");
assert!(
rows[..4].iter().all(|r| r["role"] == json!("donator")),
"donors first"
);
// Every state's own line, on screen — read off the state so the assertion is on
// the words the console draws, not on words this test invented.
for (i, s) in plan.steps.iter().enumerate() {
let line = s.state.line();
assert_eq!(rows[i]["line"], json!(line));
assert!(
robot.label_visible(&line),
"step {i} `{}` draws its state line: {line}",
s.coordinate
);
}
assert!(robot.label_visible("köad"));
assert!(robot.label_visible("kör i container nordisk-cargo-toolchain@sha256:9e8d7c6b5a4f"));
assert!(robot.label_visible("cachad ur ledgern 3f9a1c2b4d5e…"));
assert!(robot.label_visible("klar · kvitto sha c9a4fc6c1122… · fragment d437b9195566…"));
assert!(robot.label_visible("vägrad: bilden saknar std för wasm32 — rustup target add wasm32-unknown-unknown i toolchain-containern"));
// The state's data beside the word.
assert_eq!(
rows[0]["detail"]["key"],
json!("3f9a1c2b4d5e6f708192a3b4c5d6e7f8")
);
assert_eq!(rows[1]["detail"]["sha"], json!("c9a4fc6c1122334455667788"));
assert_eq!(
rows[1]["detail"]["fragment"],
json!("d437b9195566778899aabbcc")
);
assert_eq!(
rows[2]["detail"]["image"],
json!("nordisk-cargo-toolchain@sha256:9e8d7c6b5a4f")
);
assert!(rows[3]["detail"]["why"]
.as_str()
.unwrap()
.contains("wasm32"));
assert_eq!(rows[4]["detail"], Value::Null);
// Elapsed as the fixture states it; the console's own clock never started.
assert_eq!(rows[0]["elapsed_ms"], json!(145_000));
assert!(robot.label_visible("145.0s"));
assert_eq!(steps["elapsed_ms"], json!(0));
// The expectation, drawn before any run, with its source — by typ.
let kernel = Expectation::for_typ(ArtifactKind::Kernel);
assert_eq!(rows[0]["expect"]["cold"], json!(kernel.cold));
assert_eq!(rows[0]["expect"]["cached"], json!(kernel.cached));
assert!(robot.label_visible(&format!("{} / {}", kernel.cold, kernel.cached)));
assert!(
robot.label_visible(&kernel.source),
"the expectation names its source on screen"
);
assert!(
kernel.cached.contains("145 s"),
"the plan's own figure: {}",
kernel.cached
);
assert_eq!(steps["receipt"], Value::Null, "no verb fired ⇒ no receipt");
}
/// A message that is not a plan is refused by name and stages nothing.
#[test]
fn a_message_that_is_not_a_plan_stages_nothing() {
let mut robot = console_leaf();
robot
.harness_mut()
.state_mut()
.0
.update_json(r#"{"stage_steps": {"steps": "nej"}}"#);
robot.run(2);
assert_eq!(robot.state_at("/steps"), Some(Value::Null));
assert!(
robot.state_at("/menu/note").is_none_or(|v| v.is_null())
|| robot.state_at("/menu").is_some()
);
}