nornir 0.5.1

Companion to cargo: dependency tracking, release gating, deploy, benchmarks, and documentation assembly. Project-agnostic.
//! Demo mode — a **hands-free guided tour** that ROBOT-DRIVES the live viz
//! through every tab via the EXISTING control channel ([`super::control`]),
//! with pauses between beats so the motion (tab-switch repaint, graph
//! pan/zoom, 3D call-graph + funnel spin, palette re-skin/glow) actually plays.
//!
//! It is the *drive-half* of the robot-UI-tester loop wired to a script: each
//! beat is one [`VizCommand`](super::control::VizCommand) the demo writes to the
//! control channel (`$NORNIR_VIZ_CMD`) on a timer; the running viz polls + applies
//! it next frame ([`super::app::UrdrThreadsApp::poll_control_channel`]) exactly as
//! if a human (or the `viz.click` MCP tool) had clicked. The result shows up in
//! the next `$NORNIR_VIZ_STATE` dump — so the tour is robot-VERIFIED, not just
//! visual (see `tests/viz_demo.rs`).
//!
//! **FC-7 determinism:** time is *injected*. The driver never reads a wall clock
//! itself — [`DemoDriver::due`] takes the elapsed-ms as an argument. The live
//! update loop passes a real `Instant::elapsed()`; the headless test passes
//! synthetic values, so the scripted schedule is fully deterministic.

use super::control::{VizCommand, VizField};

/// One scripted beat of the tour: once `at_ms` of demo time has elapsed, the
/// demo emits `cmd` (a real control-channel command) and logs `label`.
#[derive(Clone, Debug)]
pub struct DemoStep {
    /// Demo-relative time (ms since the tour started) at which this beat fires.
    pub at_ms: u64,
    /// Human-readable caption for the beat (shown in the action trail).
    pub label: String,
    /// The control-channel command this beat issues — a real `viz.click`.
    pub cmd: VizCommand,
}

/// The scripted-tour driver. Holds the beats + a cursor into them; emits each
/// due beat exactly once. Pure (no I/O, no clock) so it is trivially testable —
/// the app wraps it with a real `Instant`, a test feeds it synthetic elapsed-ms.
#[derive(Clone, Debug)]
pub struct DemoDriver {
    steps: Vec<DemoStep>,
    next: usize,
}

impl DemoDriver {
    /// Wrap an explicit script.
    pub fn new(steps: Vec<DemoStep>) -> Self {
        Self { steps, next: 0 }
    }

    /// The default showcase tour: walk every tab the demo brief names —
    /// Arch/Metro, the dep + call graphs, Knowledge, Funnel (with a live
    /// `run_demo` click so its 3D DAG fills + spins), the Test matrix, the
    /// Warehouse Deck, Release, Leaderboard and Security — re-skinning the whole
    /// viz twice so the palette glow plays, and returning home. `beat_ms` is the
    /// pause between beats (so motion settles before the next click).
    pub fn tour(beat_ms: u64) -> Self {
        let mut b = TourBuilder::new(beat_ms);
        b.tab("Nornir", "home: server + workspace lifecycle");
        b.tab("Architecture", "🏛 Architecture wiring board");
        b.tab("Metro", "🚇 Metro coverage transit lines");
        b.tab("DepGraph", "🔗 Dep graph (pan/zoom)");
        b.tab("CallGraph", "🕸 Call graph 3D (auto-spin)");
        b.tab("Knowledge", "📚 Knowledge index");
        b.palette("cyberpunk-neon", "🎨 re-skin → cyberpunk-neon (glow)");
        b.tab("Funnel", "🗂 Funnel DAG");
        b.set_field("funnel.demo_size", "8", "Funnel: size the demo DAG");
        b.click("funnel.run_demo", "Funnel: ▶ run the 3D demo (fills + spins)");
        b.tab("Test", "🧪 Test matrix");
        b.tab("WarehouseDeck", "🏢 Warehouse Deck (wall of panes)");
        b.tab("Release", "🚀 Release pipeline");
        b.tab("Leaderboard", "🏆 Leaderboard");
        b.palette("nordic-aurora", "🎨 re-skin → nordic-aurora");
        b.tab("Security", "🛡 Security");
        b.tab("Nornir", "↩ back home");
        Self::new(b.steps)
    }

    /// Total beats in the script.
    pub fn len(&self) -> usize {
        self.steps.len()
    }

    /// `true` when the script has no beats.
    pub fn is_empty(&self) -> bool {
        self.steps.is_empty()
    }

    /// `true` once every beat in this pass has been emitted. A live demo window
    /// loops by [`rewind`](Self::rewind)ing on a rebased clock; the deterministic
    /// test uses this to stop after one pass.
    pub fn is_done(&self) -> bool {
        self.next >= self.steps.len()
    }

    /// How long (ms) the full one-pass script runs — the `at_ms` of the last beat.
    pub fn duration_ms(&self) -> u64 {
        self.steps.last().map(|s| s.at_ms).unwrap_or(0)
    }

    /// The distinct tab names the tour visits, in first-seen order — for the
    /// headless coverage assertion ("drives ≥N distinct views").
    pub fn tabs(&self) -> Vec<String> {
        let mut seen: Vec<String> = Vec::new();
        for s in &self.steps {
            if let Some(t) = &s.cmd.tab {
                if !seen.iter().any(|x| x == t) {
                    seen.push(t.clone());
                }
            }
        }
        seen
    }

    /// **FC-7, time injected.** Return the next beat that is due at `elapsed_ms`
    /// (advancing the cursor) or `None` if nothing is due yet / the pass is done.
    /// At most one beat is returned per call so a caller that writes to the
    /// consume-once channel never clobbers an unconsumed command. Looping is the
    /// caller's job (rebase its clock + [`rewind`](Self::rewind) on `is_done`).
    pub fn due(&mut self, elapsed_ms: u64) -> Option<DemoStep> {
        let step = self.steps.get(self.next)?;
        if elapsed_ms >= step.at_ms {
            let out = step.clone();
            self.next += 1;
            Some(out)
        } else {
            None
        }
    }

    /// Reset the cursor to the top (for a looped restart on a fresh clock).
    pub fn rewind(&mut self) {
        self.next = 0;
    }
}

/// Small helper that assigns each beat a cumulative `at_ms` so the tour script
/// reads as a simple sequence.
struct TourBuilder {
    beat_ms: u64,
    t: u64,
    steps: Vec<DemoStep>,
}

impl TourBuilder {
    fn new(beat_ms: u64) -> Self {
        // First beat fires one interval in, so the window is up before the tour
        // starts clicking.
        Self { beat_ms: beat_ms.max(1), t: beat_ms.max(1), steps: Vec::new() }
    }
    fn push(&mut self, label: &str, cmd: VizCommand) {
        self.steps.push(DemoStep { at_ms: self.t, label: label.to_string(), cmd });
        self.t += self.beat_ms;
    }
    fn tab(&mut self, name: &str, label: &str) {
        self.push(label, VizCommand { tab: Some(name.to_string()), ..Default::default() });
    }
    fn palette(&mut self, name: &str, label: &str) {
        self.push(label, VizCommand { palette: Some(name.to_string()), ..Default::default() });
    }
    fn set_field(&mut self, name: &str, value: &str, label: &str) {
        self.push(
            label,
            VizCommand {
                set_field: Some(VizField { name: name.to_string(), value: value.to_string() }),
                ..Default::default()
            },
        );
    }
    fn click(&mut self, key: &str, label: &str) {
        self.push(label, VizCommand { click_id: Some(key.to_string()), ..Default::default() });
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn tour_walks_every_named_view() {
        let d = DemoDriver::tour(2000);
        let tabs = d.tabs();
        // The demo brief's named tabs are all present.
        for want in ["Architecture", "Metro", "Knowledge", "Funnel", "Test", "WarehouseDeck", "Release", "Security"] {
            assert!(tabs.iter().any(|t| t == want), "tour visits {want}: {tabs:?}");
        }
        assert!(tabs.len() >= 7, "tour walks ≥7 distinct views, got {}", tabs.len());
    }

    #[test]
    fn due_is_time_injected_and_fires_once_per_beat() {
        let mut d = DemoDriver::tour(1000);
        let n = d.len();
        // Before the first beat is due, nothing fires.
        assert!(d.due(0).is_none());
        // Stepping the injected clock to the end yields exactly `n` beats, in order.
        let mut fired = 0usize;
        for ms in (0..=d.duration_ms() + 10).step_by(50) {
            while let Some(_step) = d.due(ms) {
                fired += 1;
            }
        }
        assert_eq!(fired, n, "every beat fires exactly once across the run");
        assert!(d.is_done(), "a once() tour reports done after the last beat");
    }
}