src/viz/uiplane_plan.rs
//! # nornir-viz as a `UiPlan` — the LAW 9 worked example (the reference impl)
//!
//! This is nornir *applying* the generic
//! [`nornir_testmatrix::uiplane`](nornir_testmatrix::uiplane) primitive to ITS OWN
//! UI — the dogfood that proves the harness. The generic core (planes / plan /
//! `PlaneDriver` / `RobotPlan::walk`) lives in the framework crate and knows
//! nothing about viz; here we:
//!
//! 1. **Model the viz as a [`UiPlan`]** ([`viz_plan`]): one **plane per
//! [`Tab`](crate::viz)** (read from the app's canonical `state_json["all_tabs"]`
//! enumerator, never hand-listed), with a transition `tab-strip click` from the
//! boot tab to every other tab (the tab-strip is fully connected from the start
//! plane — every tab is one click away). Each plane declares ONE data surface:
//! its `state_json` pane block, which must be PRESENT and have RAN (populated).
//!
//! 2. **Drive it with a native [`PlaneDriver`]** ([`VizPlaneDriver`]) over the live
//! [`UrdrThreadsApp`](crate::viz::UrdrThreadsApp): a transition is a real
//! `apply_command(VizCommand{tab})` (the SAME control-channel click `viz.click`
//! uses), `current_plane()` reads back `state_json["tab"]`, and present/ran read
//! the per-tab pane block — emitted data, never pixels (LAW 6).
//!
//! The walk then proves every tab is **reachable + its pane RAN** (real data, not
//! empty — ties RAGNARÖK / LAW 2). A tab whose pane block is absent or empty after
//! navigation is a RED LAW 9 row; an orphan tab (no transition) is RED.
//!
//! The **wasm/deployed seam** for LAW 1 is defined (not implemented here) in
//! [`crate::viz::uiplane_plan::wasm`] — a [`PlaneDriver`] over a headless browser
//! that reads `window.__facett_state()`; the facett-demo agent wires the JS hook,
//! and the SAME [`viz_plan`] (well, facett-demo's deck plan) runs against it.
use std::collections::BTreeSet;
use eframe::egui;
use nornir_testmatrix::uiplane::{PlaneDriver, Transition, UiPlan, UiPlane, UiSurface};
use super::UrdrThreadsApp;
use super::control::VizCommand;
/// The `state_json` pane-block key each tab populates — the surface that must be
/// PRESENT and have RAN once the tab plane is reached. The tuple is
/// `(tab-debug-name, pane-block-key)`. The tab-debug-name is the SAME id the app's
/// `state_json["tab"]` / `all_tabs` use and that `apply_command` resolves; the
/// pane-block-key is the top-level `state_json` key whose presence + non-emptiness
/// proves the pane ran.
///
/// Kept in lock-step with `Tab::label` / the `state_json` builder in `app.rs` — a
/// tab whose block key is wrong shows up immediately as a RED `ReachableAbsent`
/// walk row, so this map cannot silently drift.
pub const TAB_PANE_BLOCK: &[(&str, &str)] = &[
("Nornir", "nornir"),
("Timeline", "timeline"),
("DepGraph", "dep_graph"),
("CallGraph", "callgraph"),
("Architecture", "architecture"),
("Funnel", "funnel"),
("TimeTravel", "timetravel"),
("LiveRun", "live"),
("Release", "release"),
("Knowledge", "knowledge"),
("Warehouse", "warehouse"),
("Mcp", "mcp"),
("Search", "search"),
("Gates", "gates"),
("Bench", "bench"),
("Test", "test"),
("Leaderboard", "leaderboard"),
("Security", "security"),
("Holger", "holger"),
("Manual", "manual"),
// The 7 tabs whose panes were reachable-but-surfaceless: each emits a
// top-level `state_json` block, so declare the surface so the uiplane walk
// actually runs a row for it (they were silent holes before).
("Metro", "metro"),
("Helix", "helix"),
("Bloodstream", "bloodstream"),
("WarehouseDeck", "warehouse_deck"),
("Claude", "claude"),
("Models", "models"),
// ⚒ Dwarves (N5) — the merged build-runner + CI-forge tab. Its own
// `state_json["dwarves"]` block (the active pane + both sub-slices) is the
// surface the LAW-9 walk runs a green cell for; the former Build/Brokkr blocks
// are still emitted at the top level for back-compat.
("Dwarves", "dwarves"),
];
/// The transition-action prefix for a tab-strip click — the native driver parses
/// `"click_tab:<TabName>"` and routes it to `apply_command`.
pub const CLICK_TAB: &str = "click_tab:";
/// The surface id used for a tab's data pane (one per plane). Stable so the driver
/// reports present/ran under the same id.
fn pane_surface_id(pane_key: &str) -> String {
format!("pane:{pane_key}")
}
/// Build the viz [`UiPlan`] from the canonical tab list `all_tabs` (the debug
/// names from the running app's `state_json["all_tabs"]`) and the boot tab `start`
/// (the app's `state_json["tab"]`). Discovery-contract: the plane set is READ from
/// the running app, never hard-coded — so a tab added to `Tab::ALL` automatically
/// becomes a plane the walk must reach.
///
/// Topology: the tab strip is reachable from the start plane in one click each, so
/// we add a `start → tab` transition for every other tab. (A real deck with nested
/// dialogs would add deeper edges; the tab strip is flat by construction.)
pub fn viz_plan(all_tabs: &[String], start: &str) -> UiPlan {
let block_of = |tab: &str| {
TAB_PANE_BLOCK
.iter()
.find(|(t, _)| *t == tab)
.map(|(_, b)| *b)
};
let mut plan = UiPlan::new(start.to_string());
for tab in all_tabs {
let mut plane = UiPlane::new(tab.clone());
if let Some(block) = block_of(tab) {
// The pane block is a data surface that must be present + ran.
plane = plane.surface(
UiSurface::new(pane_surface_id(block)).with_label(format!("{tab} pane ({block})")),
);
}
plan = plan.plane(plane);
}
// Tab strip: every tab is one click from the start plane.
for tab in all_tabs {
if tab != start {
plan = plan.transition(Transition::new(
start.to_string(),
tab.clone(),
format!("{CLICK_TAB}{tab}"),
));
}
}
plan
}
/// A native [`PlaneDriver`] over the live [`UrdrThreadsApp`]. Drives tab
/// transitions through the app's REAL control channel (`apply_command`) and reads
/// plane / present / ran from `state_json` — the exact emitted data the operator's
/// `viz.click` / `viz.state` MCP tools read. No pixels (LAW 6).
pub struct VizPlaneDriver<'a> {
app: &'a mut UrdrThreadsApp,
/// How many headless frames to settle after each transition (the pane block is
/// recomputed on render; a couple of frames lets it populate, esp. thin mode).
settle_frames: usize,
}
impl<'a> VizPlaneDriver<'a> {
/// Wrap a live app; `settle_frames` headless renders are run after each
/// transition so the destination pane populates before we read it.
pub fn new(app: &'a mut UrdrThreadsApp, settle_frames: usize) -> Self {
Self { app, settle_frames }
}
/// Settle by rendering the CURRENT tab a few frames — NOT
/// `render_all_tabs_headless`, which loops `Tab::ALL` and would leave
/// `self.tab` on the last tab, clobbering the transition we just applied. A
/// single-tab `draw_ui` frame preserves the active plane while letting the
/// pane recompute / fire its render-side emitters.
fn settle(&mut self) {
for _ in 0..self.settle_frames.max(1) {
let ctx = egui::Context::default();
// egui 0.36 debug-asserts on a FullOutput dropped with unapplied texture
// deltas; nothing is uploaded headless, so they are cleared (2026-10-03).
ctx.run_ui(egui::RawInput::default(), |ui| self.app.draw_ui(ui)).textures_delta.clear();
}
}
/// Read the per-tab pane block under the current plane and decide present/ran.
/// PRESENT iff the `state_json` pane block exists and is not JSON `null`; RAN
/// iff it carries real populated data (a non-empty object/array, or a scalar) —
/// a present-but-empty block is present-not-ran (RAGNARÖK: empty ≠ ran).
fn pane_state(&mut self) -> (BTreeSet<String>, BTreeSet<String>) {
let mut present = BTreeSet::new();
let mut ran = BTreeSet::new();
let state = self.app.state_json();
let tab = state
.get("tab")
.and_then(|v| v.as_str())
.unwrap_or_default()
.to_string();
if let Some(block) = TAB_PANE_BLOCK
.iter()
.find(|(t, _)| *t == tab)
.map(|(_, b)| *b)
{
let id = pane_surface_id(block);
match state.get(block) {
None | Some(serde_json::Value::Null) => {} // absent
Some(v) => {
present.insert(id.clone());
if pane_block_ran(v) {
ran.insert(id);
}
}
}
}
(present, ran)
}
}
/// Did a pane block actually RUN (populate), or is it an empty stub? An object
/// with ≥1 key, an array with ≥1 element, or any non-null scalar counts as ran; an
/// empty object/array is present-but-not-ran (RAGNARÖK — empty ≠ ran). This is the
/// LAW 2 hook: a blank pane after navigation is a RED `ReachableNotRan` row, not a
/// silent green.
pub fn pane_block_ran(v: &serde_json::Value) -> bool {
match v {
serde_json::Value::Null => false,
serde_json::Value::Object(m) => !m.is_empty(),
serde_json::Value::Array(a) => !a.is_empty(),
// A bare scalar (bool/number/string) the pane emitted is real data.
_ => true,
}
}
impl PlaneDriver for VizPlaneDriver<'_> {
fn current_plane(&mut self) -> String {
self.app
.state_json()
.get("tab")
.and_then(|v| v.as_str())
.unwrap_or_default()
.to_string()
}
fn apply(&mut self, action: &str) -> Result<(), String> {
let tab = action
.strip_prefix(CLICK_TAB)
.ok_or_else(|| format!("VizPlaneDriver: unknown action '{action}'"))?;
// The REAL control-channel click (parity with viz.click / the tab strip).
self.app.apply_command(&VizCommand {
tab: Some(tab.to_string()),
..Default::default()
});
self.settle();
Ok(())
}
fn surfaces_present(&mut self) -> BTreeSet<String> {
self.pane_state().0
}
fn surfaces_ran(&mut self) -> BTreeSet<String> {
self.pane_state().1
}
}
/// The **wasm / deployed-artifact driver seam** for LAW 1 (SHIPPED ≠ TESTED).
///
/// The native [`VizPlaneDriver`] drives the in-process app; LAW 1 demands the SAME
/// plan also runs against the **shipped wasm bundle** a user loads. This module
/// DEFINES the seam (the trait the headless-browser backend implements) so it
/// plugs in unchanged — the facett-demo agent is building the JS hook
/// (`window.__facett_state()` + `window.__facett_click(action)`) in parallel; this
/// is the Rust side that consumes it.
pub mod wasm {
use std::collections::BTreeSet;
use nornir_testmatrix::uiplane::PlaneDriver;
/// The browser/JS bridge a deployed-wasm driver needs — implemented by a
/// headless-browser harness (e.g. a `fantoccini`/`chromedriver` or `wasm-pack
/// test --headless` backend) that evaluates JS against the LOADED `*.wasm`
/// bundle the deploy serves.
///
/// The facett-demo wasm build exposes (the parallel agent's job):
/// - `window.__facett_state()` → the app's `state_json` (same shape as native),
/// - `window.__facett_click(action)` → performs a transition action and settles.
///
/// This trait is the thin Rust seam over those two JS calls; a concrete impl
/// `eval`s them via the browser driver. Defined here so [`WasmPlaneDriver`]
/// (and thus `RobotPlan::walk`) is ready the moment the hook lands.
pub trait BrowserBridge {
/// Evaluate `window.__facett_state()` and return the parsed JSON. Err on a
/// driver / navigation failure (a load error is itself a LAW 1 finding —
/// the shipped bundle didn't come up).
fn eval_state(&mut self) -> Result<serde_json::Value, String>;
/// Evaluate `window.__facett_click(action)` (perform a transition) and wait
/// for the app to settle (one animation frame / a state-stable poll).
fn eval_click(&mut self, action: &str) -> Result<(), String>;
}
/// A [`PlaneDriver`] over the SHIPPED wasm bundle, via a [`BrowserBridge`]. It
/// reads plane / present / ran from `window.__facett_state()` and drives
/// transitions through `window.__facett_click(action)` — the SAME `UiPlan` the
/// native driver runs, now proving the DEPLOYED artifact (LAW 1).
///
/// The present/ran extraction reuses the SAME pane-block logic as native
/// ([`super::pane_block_ran`]) against the wasm state, so native↔wasm verdicts
/// are computed identically — only the transport differs. The `pane_block` /
/// `surface_id` closures are injected so this is reusable for any deck (the
/// facett-demo deck supplies its own plane→block map).
pub struct WasmPlaneDriver<B: BrowserBridge> {
bridge: B,
/// Maps a plane id → its `state_json` pane-block key (the facett-demo deck
/// or the viz tab map; see [`super::TAB_PANE_BLOCK`]).
block_of: fn(&str) -> Option<&'static str>,
/// Builds the surface id from a pane-block key (parity with native).
surface_id: fn(&str) -> String,
}
impl<B: BrowserBridge> WasmPlaneDriver<B> {
/// Construct over a browser bridge + the deck's plane→block resolver +
/// surface-id builder (the native viz passes [`super::TAB_PANE_BLOCK`]'s
/// lookup and `pane:<block>` builder; facett-demo passes its own).
pub fn new(
bridge: B,
block_of: fn(&str) -> Option<&'static str>,
surface_id: fn(&str) -> String,
) -> Self {
Self {
bridge,
block_of,
surface_id,
}
}
fn pane_state(&mut self) -> (BTreeSet<String>, BTreeSet<String>) {
let mut present = BTreeSet::new();
let mut ran = BTreeSet::new();
let Ok(state) = self.bridge.eval_state() else {
return (present, ran); // bridge error ⇒ nothing present (RED downstream)
};
let tab = state
.get("tab")
.and_then(|v| v.as_str())
.unwrap_or_default();
if let Some(block) = (self.block_of)(tab) {
let id = (self.surface_id)(block);
if let Some(v) = state.get(block) {
if !v.is_null() {
present.insert(id.clone());
if super::pane_block_ran(v) {
ran.insert(id);
}
}
}
}
(present, ran)
}
}
impl<B: BrowserBridge> PlaneDriver for WasmPlaneDriver<B> {
fn current_plane(&mut self) -> String {
self.bridge
.eval_state()
.ok()
.and_then(|s| s.get("tab").and_then(|v| v.as_str()).map(String::from))
.unwrap_or_default()
}
fn apply(&mut self, action: &str) -> Result<(), String> {
self.bridge.eval_click(action)
}
fn surfaces_present(&mut self) -> BTreeSet<String> {
self.pane_state().0
}
fn surfaces_ran(&mut self) -> BTreeSet<String> {
self.pane_state().1
}
}
/// The viz deck's plane→block resolver (over [`super::TAB_PANE_BLOCK`]) — the
/// `block_of` a [`WasmPlaneDriver`] for the nornir-viz wasm bundle uses.
pub fn viz_block_of(tab: &str) -> Option<&'static str> {
super::TAB_PANE_BLOCK
.iter()
.find(|(t, _)| *t == tab)
.map(|(_, b)| *b)
}
}
#[cfg(test)]
mod tests {
use super::*;
use nornir_testmatrix::uiplane::{RobotPlan, SurfaceVerdict};
/// A tiny stand-in `all_tabs` (subset of the real list) for plan-shape tests
/// that don't need a full app.
fn sample_tabs() -> Vec<String> {
["Nornir", "Bench", "Test"]
.iter()
.map(|s| s.to_string())
.collect()
}
#[test]
fn plan_makes_a_plane_per_tab_and_a_click_from_start() {
let plan = viz_plan(&sample_tabs(), "Nornir");
assert_eq!(plan.planes.len(), 3, "one plane per tab");
// start has an edge to every OTHER tab.
assert_eq!(
plan.transitions.len(),
2,
"Bench + Test reachable from Nornir"
);
assert!(
plan.orphan_planes().is_empty(),
"every tab reachable from start"
);
assert!(
plan.dangling_transitions().is_empty(),
"every edge lands on a declared plane"
);
// each non-Manual tab declares its pane surface.
let bench = plan.planes.get("Bench").unwrap();
assert!(
bench.surfaces.contains_key(&pane_surface_id("bench")),
"Bench pane surface declared"
);
}
#[test]
fn pane_block_ran_distinguishes_populated_from_empty() {
// RAGNARÖK: an empty object/array is present-but-NOT-ran.
assert!(
!pane_block_ran(&serde_json::json!({})),
"empty object ≠ ran"
);
assert!(!pane_block_ran(&serde_json::json!([])), "empty array ≠ ran");
assert!(!pane_block_ran(&serde_json::Value::Null), "null ≠ ran");
assert!(
pane_block_ran(&serde_json::json!({"rows": 3})),
"populated object = ran"
);
assert!(
pane_block_ran(&serde_json::json!([1, 2])),
"non-empty array = ran"
);
assert!(
pane_block_ran(&serde_json::json!(42)),
"a scalar emit = ran"
);
}
/// A wasm `BrowserBridge` test double: a scripted state + a click that switches
/// the `tab` field — proves the wasm seam runs the SAME walk logic offline.
struct FakeBrowser {
tab: String,
// plane → its pane-block value the "shipped wasm" would report.
blocks: std::collections::BTreeMap<String, serde_json::Value>,
}
impl wasm::BrowserBridge for FakeBrowser {
fn eval_state(&mut self) -> Result<serde_json::Value, String> {
let mut obj = serde_json::Map::new();
obj.insert("tab".into(), serde_json::Value::String(self.tab.clone()));
// expose the current tab's block under viz_block_of(tab).
if let Some(block) = wasm::viz_block_of(&self.tab) {
if let Some(v) = self.blocks.get(&self.tab) {
obj.insert(block.to_string(), v.clone());
}
}
Ok(serde_json::Value::Object(obj))
}
fn eval_click(&mut self, action: &str) -> Result<(), String> {
let tab = action
.strip_prefix(CLICK_TAB)
.ok_or_else(|| format!("bad action {action}"))?;
self.tab = tab.to_string();
Ok(())
}
}
#[test]
fn wasm_seam_walks_the_same_plan_green_when_shipped_panes_populate() {
// The DEPLOYED-artifact driver (LAW 1) runs the SAME viz_plan; here the
// shipped wasm reports populated panes → GREEN.
let tabs = sample_tabs();
let plan = viz_plan(&tabs, "Nornir");
let mut blocks = std::collections::BTreeMap::new();
blocks.insert("Nornir".to_string(), serde_json::json!({"server": "up"}));
blocks.insert("Bench".to_string(), serde_json::json!({"series": [1, 2]}));
blocks.insert("Test".to_string(), serde_json::json!({"runs": 4}));
let bridge = FakeBrowser {
tab: "Nornir".into(),
blocks,
};
let mut driver =
wasm::WasmPlaneDriver::new(bridge, wasm::viz_block_of, |b| pane_surface_id(b));
let report = RobotPlan::walk(&plan, &mut driver);
assert!(
report.is_green(),
"shipped wasm panes populate → GREEN: {}",
report.summary()
);
assert_eq!(
report.reached_planes.len(),
3,
"every tab reached in the wasm bundle"
);
}
#[test]
fn wasm_seam_goes_red_when_a_shipped_pane_is_blank() {
// SENSITIVITY (LAW 1 + LAW 2): the shipped wasm reaches Bench but its pane
// is EMPTY → a RED ReachableNotRan row — the exact "green natively, blank in
// the shipped bundle" catch.
let tabs = sample_tabs();
let plan = viz_plan(&tabs, "Nornir");
let mut blocks = std::collections::BTreeMap::new();
blocks.insert("Nornir".to_string(), serde_json::json!({"server": "up"}));
blocks.insert("Bench".to_string(), serde_json::json!({})); // BLANK in the shipped wasm
blocks.insert("Test".to_string(), serde_json::json!({"runs": 4}));
let bridge = FakeBrowser {
tab: "Nornir".into(),
blocks,
};
let mut driver =
wasm::WasmPlaneDriver::new(bridge, wasm::viz_block_of, |b| pane_surface_id(b));
let report = RobotPlan::walk(&plan, &mut driver);
assert!(!report.is_green(), "blank shipped pane → RED");
let bench = report
.rows
.iter()
.find(|r| r.plane == "Bench")
.expect("Bench row present");
assert_eq!(
bench.verdict,
SurfaceVerdict::ReachableNotRan,
"blank pane = present-not-ran"
);
}
}