facett-jobview/tests/robot_step_bar.rs
//! **Robot-UI drive of the collector step bar** — a REAL egui render of a real
//! `JobList`, observed through `Facet::state_json`.
//!
//! This is the headless path on purpose. Robot-UI requirement 10b (measured 2026-08-02,
//! `robot-ui-for-utvecklare.md`): the `--robot-watch` backend can put a window on screen
//! but cannot reliably manipulate widgets on oden, so a state assertion must come from
//! the headless `egui_kittest` drive. Pixels are a separate, weaker claim.
//!
//! What it proves that the pure tests cannot: the bar goes through **tessellation** with
//! its steps intact, and the string a robot reads out of `state_json` after a real frame
//! is the string the producer wrote. A `StepMeter` that decoded correctly but painted
//! nothing would pass the pure tests and fail here.
use facett_jobview::{JobEntry, JobList, JobStatus, StepCell, StepMeter, StepPhase};
/// Two collectors, mid-run — the posture `KORP_ROBOT_COLLECTOR_STEPS=midrun` drives korp
/// into, rebuilt here as plain data so facett stays free of any korp dependency.
fn two_collectors() -> Vec<JobEntry> {
let sweden = StepMeter {
generation: 1,
caption: "run #1 · step 3 of 4 · relation clip + geometry assembly".into(),
hover: "run #1 started because: not provisioned yet\n\
a convert pass 1 (node coord store) DONE\n\
b convert pass 2 (nodes/ways/relations) DONE\n\
c relation clip + geometry assembly ACTIVE\n\
d tile pyramid PENDING"
.into(),
steps: vec![
StepCell::new("a", "convert pass 1 (node coord store)", StepPhase::Done),
StepCell::new("b", "convert pass 2 (nodes/ways/relations)", StepPhase::Done),
StepCell::new("c", "relation clip + geometry assembly", StepPhase::Active),
StepCell::new("d", "tile pyramid", StepPhase::Pending),
],
};
let norway = StepMeter {
generation: 1,
caption: "run #1 · step 1 of 4 · convert pass 1 (node coord store)".into(),
hover: "run #1 started because: the previous cut failed — retrying".into(),
steps: vec![
StepCell::new("a", "convert pass 1 (node coord store)", StepPhase::Active),
StepCell::new("b", "convert pass 2 (nodes/ways/relations)", StepPhase::Pending),
StepCell::new("c", "relation clip + geometry assembly", StepPhase::Pending),
StepCell::new("d", "tile pyramid", StepPhase::Pending),
],
};
vec![
{
let mut e = JobEntry::new("map-regions/sweden", "collector", JobStatus::Running);
e.target = "Sweden".into();
e.with_steps(&sweden)
},
{
let mut e = JobEntry::new("map-regions/norway", "collector", JobStatus::Running);
e.target = "Norway".into();
e.with_steps(&norway)
},
// A plain ledger row with no steps — the two kinds must coexist in one list.
JobEntry::new("clip-2026", "clip", JobStatus::Done),
]
}
/// **The drive.** Render for real, then read the bar back out of `state_json`.
#[test]
fn the_step_bar_survives_a_real_render_and_reads_back_partial() {
let mut view = JobList::new("Jobs");
view.set_jobs(two_collectors());
let probe = facett_core::testmatrix::probe_and_emit(
"facett-jobview/step-bar",
&mut view,
&[r#"{"select":"map-regions/sweden"}"#],
);
// The rows really tessellated — a bar that decoded but painted nothing fails here.
assert!(probe.drew(), "the job tree with its step bars must tessellate to vertices");
assert!(probe.cardinality() > 0);
assert!(probe.responded(), "the JSON drive seam is live");
let jobs = probe.final_state["jobs"].as_array().expect("jobs array");
assert_eq!(jobs.len(), 3, "two collectors + one ledger row");
// ── The RED-side assertion: after a real frame, the bar still says THREE of four.
let se = jobs.iter().find(|j| j["id"] == "map-regions/sweden").expect("sweden row");
assert_eq!(se["steps"]["index"], 3, "step 3 of 4, through a real render");
assert_eq!(se["steps"]["total"], 4);
assert_eq!(se["steps"]["done"], 2);
assert_eq!(se["steps"]["complete"], false, "NOT full");
assert_eq!(
se["steps"]["caption"], "run #1 · step 3 of 4 · relation clip + geometry assembly",
"the string a robot reads is the string the producer wrote"
);
// The four steps are individually addressable, each with its own phase and its own
// hover line — this is "hovra så ser man vilket steg som är grönt", as data.
let cells = se["steps"]["steps"].as_array().unwrap();
assert_eq!(cells.len(), 4);
assert_eq!(cells[0]["phase"], "done");
assert_eq!(cells[1]["phase"], "done");
assert_eq!(cells[2]["phase"], "active");
assert_eq!(cells[3]["phase"], "pending");
assert_eq!(cells[2]["line"], "c relation clip + geometry assembly ACTIVE");
assert!(
se["steps"]["hover"].as_str().unwrap().contains("not provisioned yet"),
"the hover carries why this run started"
);
// ── Two collectors, two DIFFERENT bars. One bar per collector is the whole ask.
let no = jobs.iter().find(|j| j["id"] == "map-regions/norway").expect("norway row");
assert_eq!(no["steps"]["index"], 1, "Norway is on step 1");
assert_ne!(no["steps"]["caption"], se["steps"]["caption"], "the bars say different things");
// ── A row with no steps publishes null, not an empty bar.
let clip = jobs.iter().find(|j| j["id"] == "clip-2026").expect("ledger row");
assert!(clip["steps"].is_null());
}
/// **A restart, through a real render.** Same list, re-set with the collector begun
/// again — the run number must be what distinguishes it from lost ground, on screen.
#[test]
fn a_restart_renders_as_a_new_run_not_a_regression() {
let mut view = JobList::new("Jobs");
view.set_jobs(two_collectors());
let before = facett_core::Facet::state_json(&view);
let b = &before["jobs"].as_array().unwrap().iter()
.find(|j| j["id"] == "map-regions/sweden").unwrap()["steps"];
assert_eq!(b["index"], 3);
assert_eq!(b["generation"], 1);
// The collector found a newer planet file overnight.
let restarted = StepMeter {
generation: 2,
caption: "run #2 · step 1 of 4 · convert pass 1 (node coord store)".into(),
hover: "run #2 started because: a newer planet file appeared".into(),
steps: vec![
StepCell::new("a", "convert pass 1 (node coord store)", StepPhase::Active),
StepCell::new("b", "convert pass 2 (nodes/ways/relations)", StepPhase::Pending),
StepCell::new("c", "relation clip + geometry assembly", StepPhase::Pending),
StepCell::new("d", "tile pyramid", StepPhase::Pending),
],
};
let mut e = JobEntry::new("map-regions/sweden", "collector", JobStatus::Running);
e.target = "Sweden".into();
view.set_jobs(vec![e.with_steps(&restarted)]);
let probe = facett_core::testmatrix::probe_and_emit(
"facett-jobview/step-bar-restart",
&mut view,
&[r#"{"select":"map-regions/sweden"}"#],
);
assert!(probe.drew(), "the restarted bar renders");
let a = &probe.final_state["jobs"].as_array().unwrap()[0]["steps"];
// The bar DID go backwards on screen …
assert_eq!(a["index"], 1);
assert!(a["index"].as_u64() < b["index"].as_u64());
// … and the run number rendered alongside it is what says that is a new run.
assert_eq!(a["generation"], 2);
assert!(
a["caption"].as_str().unwrap().starts_with("run #2"),
"the run number leads the sentence a human reads: {}",
a["caption"]
);
assert!(
a["hover"].as_str().unwrap().contains("a newer planet file appeared"),
"and the reason is one hover away, so a reset bar does not read as a fault"
);
}