Gunnar

nordisk/holger-egui

tests/demo_walk.rs

//! Robot-verified proof for **holger-server-ui demo mode** (`holger-server-ui --demo`).
//!
//! The demo is NOT a static assert — it (1) FILLS a dev znippy store and (2) drives
//! the REAL `HolgerUiApp` through its views via the scripted walk. Here we run the
//! real per-frame loop headlessly (a persistent `egui::Context` fed an INJECTED
//! clock via `RawInput.time`, FC-7) and assert the tour walks ≥7 distinct views,
//! cycles all 5 Tools panes, lights the 3D graph, re-themes the look, and emits a
//! state TRANSITION per beat — all read back as DATA from `state_json()`.
//!
//! Gated on `demo`, not on `gui`: the tour drives an IN-PROCESS `LocalHolger` over
//! a znippy store it fills itself, which is what the thin-client rule keeps out of
//! the shipped `gui` tier. Run this with `--features gui-embedded`.
#![cfg(feature = "demo")]

use std::collections::BTreeSet;
use std::sync::Arc;

use holger_server_ui::app::HolgerUiApp;
use holger_server_ui::data::UiData;

/// Run one real frame at injected time `t_secs` (drives `demo_tick` off the egui
/// clock, exactly as eframe would), then return the app's observable state.
fn frame(app: &mut HolgerUiApp, ctx: &egui::Context, t_secs: f64) -> serde_json::Value {
    let input = egui::RawInput { time: Some(t_secs), ..Default::default() };
    // `Context::run` hands the closure a `&Context`, not a `&mut Ui` — this line
    // was written against the older egui where it did, so it named a `ui` that has
    // not existed in this scope for as long as the file has. It never compiled, and
    // because the whole file is `#![cfg(feature = "demo")]` and nothing ever built
    // that feature, nothing ever said so. `run_ui` is the Ui-closure form this wants —
    // and as of egui 0.35 it is the only one: `Context::run` is REMOVED, not merely
    // deprecated as it was in 0.34.
    let _ = ctx.run_ui(input, |ui| app.draw_ui(ui));
    app.state_json()
}

#[test]
fn demo_walk_robot_drives_every_view_offline() {
    // (1) FILL the store — offline-safe (no network in CI), bundled fixture.
    let store_dir =
        std::env::temp_dir().join(format!("holger-demo-walk-{}", std::process::id()));
    let _ = std::fs::remove_dir_all(&store_dir);
    let fill = holger_server_ui::demo::fill_demo_store(&store_dir, false).expect("fill demo store");
    assert_eq!(fill.repos.len(), 2, "two demo repos");
    assert_eq!(fill.packages.len(), 12, "the curated set landed");

    // (2) Build the real app over the filled in-process store + arm the walk.
    let data = UiData::new(Arc::new(fill.local_holger())).expect("view-model");
    let mut app = HolgerUiApp::new(data);
    let beat_ms = 500u64;
    app.enable_demo(beat_ms);
    assert!(app.demo_active(), "demo armed");

    // The server behind the UI really is populated (repo roster has both repos).
    let start = app.state_json();
    assert_eq!(start["repo_count"].as_u64(), Some(2), "roster shows both repos");

    // ── DRIVE the real frame loop with an injected clock ──────────────────────
    let mut tabs: BTreeSet<String> = BTreeSet::new();
    let mut tools: BTreeSet<String> = BTreeSet::new();
    let mut looks: BTreeSet<String> = BTreeSet::new();
    let mut graph_lit = false;
    let mut transitions = 0usize;
    let mut last_fp = String::new();

    // Frames denser than beats (200ms vs 500ms) so each beat lands in its own
    // frame; run well past one full pass.
    let dt = 0.2_f64;
    let frames = ((beat_ms as f64 / 1000.0) * 30.0 / dt) as usize + 40;
    for i in 0..frames {
        let st = frame(&mut app, &egui::Context::default(), i as f64 * dt);
        // NOTE: a fresh Context per frame keeps egui state minimal, but the
        // injected RawInput.time still drives demo_tick deterministically.
        if let Some(t) = st["tab"].as_str() {
            tabs.insert(t.to_string());
        }
        if let Some(t) = st["tool"].as_str() {
            tools.insert(t.to_string());
        }
        if let Some(l) = st["look"].as_str() {
            looks.insert(l.to_string());
        }
        if st["graph"]["selected_node"].as_u64().is_some() {
            graph_lit = true;
        }
        let fp = format!(
            "tab={}|tool={}|look={}|repo_sel={}|gsel={}",
            st["tab"],
            st["tool"],
            st["look"],
            st["repos"]["selected"],
            st["graph"]["selected_node"],
        );
        if fp != last_fp {
            transitions += 1;
        }
        last_fp = fp;
    }

    // (a) ≥7 distinct VIEWS walked.
    assert!(
        tabs.len() >= 7,
        "the demo robot-drove ≥7 distinct tabs, got {}: {tabs:?}",
        tabs.len()
    );
    for want in ["repos", "browse", "archive", "artifact", "graph", "tools", "status"] {
        assert!(tabs.contains(want), "the tour landed on {want}: {tabs:?}");
    }

    // (b) every Tools pane was visited.
    for want in ["holger-ds", "migrate", "security", "deploy", "airgap"] {
        assert!(tools.contains(want), "the tour cycled Tool {want}: {tools:?}");
    }

    // (c) the 3D graph was lit (a node selected → glow/shockwave/dim).
    assert!(graph_lit, "the tour selected a graph node (neon glow)");

    // (d) MOTION as DATA: the look re-themed (>1 distinct) and the tour produced
    // many state transitions (it's ALIVE, not static).
    assert!(looks.len() >= 2, "the tour re-themed the look: {looks:?}");
    assert!(
        transitions >= 12,
        "the tour produced many state transitions, got {transitions}"
    );

    let _ = std::fs::remove_dir_all(&store_dir);
}