src/viz/demo.rs
//! 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 Warehouse Deck,
/// Release 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). The test/bench/coverage matrix DISPLAY now
/// lives in the dwarves forge UI, so the tour no longer visits it.
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("WarehouseDeck", "🏢 Warehouse Deck (wall of panes)");
b.tab("Release", "🚀 Release pipeline");
b.tab("Chat", "💬 Chat — local model chat + MCP wiring");
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",
"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"
);
}
}