Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/facett

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