facett-demo/tests/attract.rs
//! **Robot-UI scenario for the AUTO-DEMO / attract mode (#82).** Red-when-broken.
//!
//! With no user interaction the active deck tab must auto-advance through the fixed
//! tour — System Map → Code Vault → Scene → OSM 3D → OSM 2D → Graphs — dwelling one
//! window on each and LOOPING forever; a simulated interaction must stop it, and it
//! must RE-ARM after another idle window. Every step drives the SHARED code path the
//! live UI runs (`DemoState::attract_step`, the same call `draw_app` makes each frame)
//! and asserts over the deck's active tab + the demo's `state_json.attract` — never
//! "didn't panic". A final `egui_kittest` frame proves the running indicator paints.
use egui_kittest::Harness;
use facett_demo::attract::{DWELL_SECS, IDLE_ARM_SECS, TOUR};
use facett_demo::DemoState;
/// Feed `secs` of IDLE (no interaction) to the attract clock in small per-frame steps,
/// returning the last tab it switched to (if any) — the shared `attract_step` path.
fn idle(demo: &mut DemoState, secs: f32) -> Option<String> {
let step = 0.25_f32;
let n = (secs / step).round() as usize;
let mut last = None;
for _ in 0..n {
if let Some(t) = demo.attract_step(step, false) {
last = Some(t.to_string());
}
}
last
}
/// The tab the demo currently shows (the deck's active facet title).
fn active(demo: &DemoState) -> String {
demo.state_json()["deck"]["active"].as_str().unwrap_or_default().to_string()
}
#[test]
fn auto_demo_advances_in_order_loops_cancels_and_rearms() {
let mut demo = DemoState::new();
// The tour is exactly the #82 order (titles, not human labels).
assert_eq!(TOUR, ["System Map", "codevault", "scene", "OSM 3D", "OSM 2D", "Graphs"]);
// Disarmed on load: no auto-advance before the idle window elapses. (Headless
// drives keep the draw_app pump OFF; here we drive `attract_step` directly.)
assert_eq!(demo.state_json()["attract"]["enabled"], false, "off for headless drives");
assert_eq!(demo.state_json()["attract"]["running"], false, "disarmed on load");
assert_eq!(idle(&mut demo, IDLE_ARM_SECS - 0.5), None, "no arm before {IDLE_ARM_SECS}s idle");
assert_eq!(demo.state_json()["attract"]["running"], false);
// Cross the idle window → arms on the FIRST tour tab (System Map).
assert_eq!(idle(&mut demo, 0.75).as_deref(), Some("System Map"), "arms on System Map");
assert_eq!(demo.state_json()["attract"]["running"], true, "auto-demo now running");
assert_eq!(active(&demo), "System Map", "the deck switched to System Map");
assert_eq!(demo.state_json()["attract"]["current_tab"], "System Map");
// Each further dwell window advances to the NEXT tour tab, in order, and the deck's
// active tab follows — then the tour LOOPS back to the top.
for expected in TOUR.iter().skip(1).chain(std::iter::once(&TOUR[0])) {
let got = idle(&mut demo, DWELL_SECS);
assert_eq!(got.as_deref(), Some(*expected), "auto-advanced to `{expected}`");
assert_eq!(&active(&demo), *expected, "deck active tab is `{expected}`");
assert_eq!(demo.state_json()["attract"]["current_tab"], *expected);
}
// We wrapped: back on System Map after the whole loop.
assert_eq!(active(&demo), "System Map", "the tour looped forever");
// ── CANCEL: a simulated real interaction stops the auto-demo immediately ──
assert_eq!(demo.attract_step(0.016, true), None, "interaction fires no auto-switch");
assert_eq!(demo.state_json()["attract"]["running"], false, "interaction cancels the auto-demo");
assert_eq!(demo.state_json()["attract"]["current_tab"], serde_json::Value::Null);
// The tab a human is on must NOT keep advancing while they interact.
let held = active(&demo);
assert_eq!(idle(&mut demo, IDLE_ARM_SECS - 0.5), None, "no premature re-arm");
assert_eq!(active(&demo), held, "the deck stays put until re-armed");
// ── RE-ARM: after another full idle window it starts over from the top ──
assert_eq!(idle(&mut demo, 0.75).as_deref(), Some("System Map"), "re-arms from the top");
let rearmed = demo.state_json()["attract"]["running"] == true;
let back_on_top = active(&demo) == "System Map";
#[cfg(feature = "testmatrix")]
facett_core::testmatrix::emit(
"facett-demo-attract",
"auto_demo_tour_loops_cancels_rearms",
rearmed && back_on_top,
&format!("rearmed={rearmed} on_system_map={back_on_top} tour_len={}", TOUR.len()),
);
assert!(rearmed, "the auto-demo re-armed after the idle window");
assert!(back_on_top, "the re-armed tour starts from System Map");
}
/// Interaction detected THROUGH the live `draw_app` render path cancels the loop: a
/// real `egui_kittest` frame with the auto-demo running, then a pointer-move event, and
/// the indicator + running flag respond exactly as the shared path dictates.
#[test]
fn running_indicator_paints_and_pointer_move_would_cancel() {
let mut demo = DemoState::new();
demo.show_about = false;
// The live binary turns this on for both native + wasm; do the same so the shared
// `draw_app` path pumps the auto-demo (and paints the indicator) in this frame.
demo.attract_enabled = true;
// Arm it headlessly first (the shared clock path).
let _ = idle(&mut demo, IDLE_ARM_SECS + 0.25);
assert_eq!(demo.state_json()["attract"]["running"], true, "armed before the frame");
assert_eq!(demo.state_json()["attract"]["enabled"], true, "the live-binary flag is on");
// A real frame through the SAME draw_app the binary uses — proves the running
// indicator (the "auto-demo · move to take control" chip) paints without panicking
// (an AccessKit view actually executes over the whole deck with the overlay up).
let mut harness = Harness::builder()
.with_size(egui::vec2(1280.0, 820.0))
.build_ui_state(|ui, demo: &mut DemoState| demo.draw_app(ui), demo);
harness.step();
// The frame rendered the deck (the auto-demo drives the real UI, not a side pane).
assert!(harness.state().attract_enabled, "the shared draw_app path ran with attract on");
}