Gunnar

nordisk/holger-egui

src/scenarios.rs

//! **`robot-remote`** — the per-tab robot **scenario library** for holger.
//!
//! The North-Star autotest fleet drives each app member WIRE-REMOTE over
//! [`cmd_json`](nornir_robotui::cmd_json). Until now the fleet's `RobotDriveMember`
//! had no per-tab script for holger, so it fell back to a bare liveness probe
//! (ping + read_state). This module closes that gap — mirroring dwarves'
//! `dwarves-demo/src/scenarios.rs`: it records a REAL
//! [`Scenario`](nornir_robotui::Scenario) per holger operator tab — SELECT the tab,
//! perform the meaningful interactions it supports, and ASSERT over the tab's OWN
//! `state_json` that the expected state changed.
//!
//! ## REUSE (no second vocabulary, no second oracle)
//! * The scenarios are built from the ONE robotui vocabulary
//!   ([`Action`](nornir_robotui::Action) / [`Assert`](nornir_robotui::Assert) /
//!   [`Step`](nornir_robotui::Step) / [`Scenario`](nornir_robotui::Scenario)) the
//!   fleet already carries — the SAME `Step` wire form a
//!   [`CmdJsonClient`](nornir_robotui::cmd_json::CmdJsonClient) drives. Nothing new
//!   is invented.
//! * Every assertion reads the tab's EXISTING `state_json` oracle (the shell's
//!   `/tab` active-tab id + each pane's own folded sub-state under its own key), or
//!   the harness'es own [`Assert::Drew`](nornir_robotui::Assert::Drew) — no twinned
//!   per-tab oracle. holger's active-tab value is the tab's stable `slot_id` (a
//!   lower-cased id), while the tab-bar button a robot CLICKS renders the tab's
//!   display `label()`, so each scenario carries both (SELECT clicks the label and
//!   asserts the `slot_id` moved).
//!
//! ## Consumable as DATA
//! [`holger_tab_scenarios`] returns a `Vec<Scenario>` (serde-serializable to the
//! SAME `Step` wire form the fleet uses under the `wire` feature), so the fleet's
//! `RobotDriveMember` can LOAD holger's scenarios by member name
//! (`<dir>/holger.scenarios.json`) and drive them over the socket
//! [`serve_robot_remote`](crate::robot_remote::serve_robot_remote) mounts.
//! [`holger_tab_scenario_library`] carries the same data plus per-tab metadata (the
//! tab id + label + an HONEST `probe_only` flag for tabs that can't yet be
//! meaningfully driven headless beyond a select + render + identity assertion).

use nornir_robotui::{Action, Assert, Scenario, Step};
use serde_json::json;

/// One holger operator tab's recorded scenario plus its metadata.
///
/// `scenario.suite` carries the tab's `slot_id`, so a consumer that only wants the
/// wire data can take [`Self::scenario`] and still know which tab it drives.
#[derive(Clone, Debug)]
pub struct HolgerTabScenario {
    /// The stable scene-slot id of the tab this scenario drives — the value holger's
    /// `state_json` reports at `/tab` (and the entry in the shell's `/tabs` roster).
    pub tab: &'static str,
    /// The tab-bar button's display label — what the TabView renders and what
    /// [`Action::ClickLabel`](nornir_robotui::Action::ClickLabel) addresses (e.g.
    /// the `signin` tab is clicked as `"Sign in"`).
    pub label: &'static str,
    /// HONEST flag: `true` when this tab has no safe headless interaction beyond
    /// selecting it + asserting it rendered its identity (a select + render probe,
    /// not a fabricated interaction). `false` when the scenario drives a real,
    /// state-changing interaction the tab supports today. (Within-pane graph/grid
    /// nodes become addressable once facett node-addressing lands — a later upgrade;
    /// `probe_only` is correct for those now.)
    pub probe_only: bool,
    /// The recorded steps (SELECT → interact → assert-over-`state_json`).
    pub scenario: Scenario,
}

/// Sugar: the SELECT step every scenario opens with — click the tab's display label
/// and assert the shell's own `/tab` oracle moved to the tab's `slot_id`. This is the
/// tab's identity over the shell oracle (reused, not twinned) AND a real state change
/// (a different tab was active). The same locate+click the existing wire-remote mount
/// test uses.
fn select(label: &str, slot: &str) -> Step {
    Step::new(format!("select the {label} tab"))
        .action(Action::ClickLabel(label.to_string()))
        .assert(Assert::State {
            pointer: "/tab".to_string(),
            expected: json!(slot),
        })
}

/// Sugar: an assertion-only step proving the selected tab's pane actually drew (not a
/// blank pane) — the harness'es own `Drew` oracle, reused, no per-tab twin.
fn drew(label: &str) -> Step {
    Step::new(format!("the {label} pane rendered (not blank)")).assert(Assert::Drew)
}

/// Sugar: an assertion-only step proving the tab folded its OWN identity into
/// `state_json` — reuses the pane's existing sub-oracle at `pointer` (RFC-6901),
/// asserting a stable field-name `needle` is present in its serialized shape.
fn identity(label: &str, pointer: &str, needle: &str) -> Step {
    Step::new(format!("the {label} pane folded its own state_json identity")).assert(
        Assert::StateContains {
            pointer: pointer.to_string(),
            needle: needle.to_string(),
        },
    )
}

/// The recorded per-tab scenario library — one [`HolgerTabScenario`] per registered
/// holger operator tab, in tab-bar order (the shell's `TAB_ORDER`). The holger-side
/// drive test drives every scenario here against the LIVE mounted shell, so a
/// removed/renamed tab or a stale oracle goes RED there.
pub fn holger_tab_scenario_library() -> Vec<HolgerTabScenario> {
    // A tab whose only safe headless drive today is select + render + identity.
    let probe = |slot: &'static str, label: &'static str, pointer: &str, needle: &str| {
        HolgerTabScenario {
            tab: slot,
            label,
            probe_only: true,
            scenario: Scenario::new(slot.to_string()).steps([
                select(label, slot),
                drew(label),
                identity(label, pointer, needle),
            ]),
        }
    };

    vec![
        // ── Status — the server-health grid (status/version/uptime). Auto-loaded at
        // startup, so a ⟳ refresh is idempotent → probe-only. ──
        probe("status", "Status", "/status", "uptime_seconds"),
        // ── Repos — the repository picker. Row selection collides with the facett
        // Table cell (not label-addressable today), and ⟳ refresh is idempotent →
        // probe-only. ──
        probe("repos", "Repos", "/repos", "selected"),
        // ── Browse — the artifact listing. Auto-loads on select → probe-only. ──
        probe("browse", "Browse", "/browse", "next_page_token"),
        // ── Archive — the znippy file tree + stats. Auto-loads on select → probe-only. ──
        probe("archive", "Archive", "/archive", "archive_path"),
        // ── Artifact — the fetch pane. A meaningful fetch needs a typed query set
        // through the host seam, not a label click → probe-only. ──
        probe("artifact", "Artifact", "/artifact", "content_type"),
        // ── Graph — the neon 3D repo graph (built on select). A within-pane NODE
        // click needs facett node-addressing (a later upgrade) → probe-only. ──
        probe("graph", "Graph", "/graph", "palette"),
        // ── Helix — the time-helix DAG (wound on select). Within-pane node
        // addressing pending → probe-only. ──
        probe("helix", "Helix", "/helix", "coil_count"),
        // ── Lineage — the live cache-fill facet. A REAL within-pane drive: toggle the
        // render mode between the rotating 3D fill and the time-helix companion by
        // their plain-text labels and watch the pane's own `/lineage_mode` oracle
        // flip, then restore the shipped default (no residue). ──
        HolgerTabScenario {
            tab: "lineage",
            label: "Lineage",
            probe_only: false,
            scenario: Scenario::new("lineage".to_string()).steps([
                select("Lineage", "lineage"),
                drew("Lineage"),
                identity("Lineage", "/lineage", "total_events"),
                // The tab opens on the rotating 3D fill — assert the starting oracle.
                Step::new("Lineage opens on the rotating 3D fill").assert(Assert::State {
                    pointer: "/lineage_mode".to_string(),
                    expected: json!("graph3d"),
                }),
                // Click the plain-text `🌀 time helix` toggle → the render mode flips.
                Step::new("toggle the time-helix companion → mode == helix")
                    .action(Action::ClickLabel("🌀 time helix".to_string()))
                    .assert(Assert::State {
                        pointer: "/lineage_mode".to_string(),
                        expected: json!("helix"),
                    }),
                // Click `◉ 3D fill` back → restore the shipped default (no residue).
                Step::new("back to the 3D fill → mode == graph3d (no residue)")
                    .action(Action::ClickLabel("◉ 3D fill".to_string()))
                    .assert(Assert::State {
                        pointer: "/lineage_mode".to_string(),
                        expected: json!("graph3d"),
                    }),
            ]),
        },
        // ── Tools — the operator tool menu. A REAL within-pane drive: pick the
        // Móðguðr security function by its plain-text menu label and watch the pane's
        // own `/tool` oracle change, then restore the shipped default (holger-ds). ──
        HolgerTabScenario {
            tab: "tools",
            label: "Tools",
            probe_only: false,
            scenario: Scenario::new("tools".to_string()).steps([
                select("Tools", "tools"),
                drew("Tools"),
                identity("Tools", "/tools_pane", "pane"),
                // The tab opens on the holger-ds function — assert the starting oracle.
                Step::new("Tools opens on the holger-ds function").assert(Assert::State {
                    pointer: "/tool".to_string(),
                    expected: json!("holger-ds"),
                }),
                // Click the `3 · security · Móðguðr` menu label → the function changes.
                Step::new("select the security · Móðguðr function → tool == security")
                    .action(Action::ClickLabel("3 · security · Móðguðr".to_string()))
                    .assert(Assert::State {
                        pointer: "/tool".to_string(),
                        expected: json!("security"),
                    }),
                // Click `1 · holger-ds` back → restore the shipped default (no residue).
                Step::new("select the holger-ds function back → tool == holger-ds (no residue)")
                    .action(Action::ClickLabel("1 · holger-ds".to_string()))
                    .assert(Assert::State {
                        pointer: "/tool".to_string(),
                        expected: json!("holger-ds"),
                    }),
            ]),
        },
        // ── Upload — the write gate (reflects the selected repo's writability). The
        // gate is driven by repo selection (a Table row, not label-addressable today)
        // → probe-only. ──
        probe("upload", "Upload", "/upload", "writable"),
        // ── Sign in — the shared device-code login pane. A real sign-in needs an IdP
        // + network (the device grant), unsafe headless → probe-only. Clicked as its
        // display label "Sign in". ──
        probe("signin", "Sign in", "/login", "state"),
        // ── Settings — the ⚙ look & feel pane, the ONE home of the unified facett
        // preset picker (it absorbed the retired root "Look" menu). A REAL within-pane
        // drive: click a preset by its plain name label and watch the shell's OWN
        // `/look` oracle switch, then restore the shipped `dark` default (no residue).
        // Without this entry the shell grew a twelfth tab the recorded library never
        // drove — the Settings pane asserted NOTHING over the wire lane. ──
        HolgerTabScenario {
            tab: "settings",
            label: "Settings",
            probe_only: false,
            scenario: Scenario::new("settings".to_string()).steps([
                select("Settings", "settings"),
                drew("Settings"),
                // The shell ships on the `dark` preset — assert the starting oracle.
                Step::new("Settings opens on the shipped dark look").assert(Assert::State {
                    pointer: "/look".to_string(),
                    expected: json!("dark"),
                }),
                // Click the `light` preset label → the whole shell re-themes.
                Step::new("pick the light preset → look == light")
                    .action(Action::ClickLabel("light".to_string()))
                    .assert(Assert::State {
                        pointer: "/look".to_string(),
                        expected: json!("light"),
                    }),
                // Click `dark` back → restore the shipped default (no residue).
                Step::new("back to the dark preset → look == dark (no residue)")
                    .action(Action::ClickLabel("dark".to_string()))
                    .assert(Assert::State {
                        pointer: "/look".to_string(),
                        expected: json!("dark"),
                    }),
            ]),
        },
    ]
}

/// The recorded scenarios as pure wire DATA — one [`Scenario`] per holger operator
/// tab, each `suite` carrying the tab's `slot_id`. Serde-serializable to the SAME
/// `Step` wire form the fleet's [`CmdJsonClient`](nornir_robotui::cmd_json::CmdJsonClient)
/// drives, so the fleet's `RobotDriveMember` can LOAD holger's per-tab scenarios by
/// member name and drive them over the mounted socket.
pub fn holger_tab_scenarios() -> Vec<Scenario> {
    holger_tab_scenario_library()
        .into_iter()
        .map(|t| t.scenario)
        .collect()
}