tests/atom_coverage.rs
//! **S3 — atom-granularity `surface_coverage` rows + the `button_registry` drift
//! seam.** This builds on S2's atom-walk (`nornir_robotui::error_atoms`): where
//! S2 only asserted "no error atom leaked", S3 makes the walk EMIT what it saw.
//!
//! Three things, all against the REAL viz driven through a kittest `Harness` over
//! an EMPTY remote `nornir-server` (the same fresh-server state S2 uses):
//!
//! 1. **Atom rows -> `surface_coverage`** — `nornir_robotui::all_atoms` discovers
//! every interactive/labelled atom per tab; `nornir::autonom::atom_coverage_rows`
//! maps each to a `CoverageRow` (`kind="ui_atom"`, `id="<tab>/<label>"`,
//! `surface_key="ui_atom:<tab>/<label>@na"`, `covered <=> the tab RAN && its
//! state was CLEAN`). We append them to a temp Iceberg warehouse and READ them
//! back (write->read, LAW 8).
//!
//! 2. **autonom counts the atom rows** — `CoverageSummary::from_rows` (the same
//! roll-up the gate / `test_coverage` tool use) counts the persisted `ui_atom`
//! rows as surface nodes, so the completeness gate now covers rendered atoms,
//! not just whole tabs. `enumerate_surface_with_atoms` likewise folds them into
//! the discovered `Surface`.
//!
//! 3. **atoms <-> `button_registry` drift GATE** — every hand-maintained
//! `button_registry` station must correspond to an atom the viz actually
//! renders in its tab (else the metro map would draw a station for a button
//! that doesn't paint). This is the seam toward auto-deriving the registry; for
//! now it's a drift gate. The matching rule is documented at the test.
//!
//! Run:
//! cargo test --features "viz,server,testmatrix" --test atom_coverage -- --test-threads=1
#![cfg(all(feature = "viz", feature = "server"))]
use std::collections::BTreeMap;
use std::net::TcpListener;
use std::path::PathBuf;
use std::process::{Child, Command};
use std::time::{Duration, Instant};
use egui_kittest::Harness;
use nornir::autonom::atom_coverage_rows;
use nornir::viz::UrdrThreadsApp;
use nornir::viz::control::VizCommand;
use nornir::viz::surfaces::tab_pane_ids;
use nornir::warehouse::iceberg::IcebergWarehouse;
use nornir::warehouse::surface_coverage::{
CoverageSelector, CoverageSummary, Verdict, append_surface_coverage, query_surface_coverage,
};
use nornir_robotui::{all_atoms, error_atoms};
use nornir_testmatrix::discover::DiscoveredAtom;
use uuid::Uuid;
// The walk tabs (capitalized `Tab` names) — same list S2 walks. Must equal
// `surfaces::tab_pane_ids()` in order; `atom_coverage_tabs_match_pane_registry` holds
// it to that. 💬 Chat was missing here after it was added to `Tab::ALL`, which made
// that gate RED for a tab the walk simply never visited.
const TABS: &[&str] = &[
"Nornir",
"Timeline",
"DepGraph",
"CallGraph",
"Architecture",
"Metro",
"Helix",
"Bloodstream",
"Funnel",
"TimeTravel",
"LiveRun",
"Release",
"Knowledge",
"Warehouse",
"WarehouseDeck",
"Mcp",
"Search",
"Gates",
"Chat",
"Security",
"Dwarves",
"Holger",
"Manual",
"Claude",
"Models",
// 🎛 Settings — the preferences pane and the ONE home of the viz's look & feel
// (the app-wide facett palette picker, moved off the root top bar).
"Settings",
];
/// Map a `button_registry` tab section key (lowercase: `test`/`bench`/`nornir`/...)
/// to the walk's capitalized `Tab` name. The registry only references a handful of
/// tabs; an unknown key falls back to capitalizing the first letter.
fn registry_tab_to_walk_tab(section: &str) -> String {
match section {
"test" => "Test",
"bench" => "Bench",
"release" => "Release",
"nornir" => "Nornir",
other => {
let mut c = other.chars();
return match c.next() {
Some(f) => f.to_uppercase().collect::<String>() + c.as_str(),
None => String::new(),
};
}
}
.to_string()
}
fn free_port() -> u16 {
TcpListener::bind("127.0.0.1:0")
.unwrap()
.local_addr()
.unwrap()
.port()
}
struct EmptyServer {
child: Child,
root: PathBuf,
endpoint: String,
token: String,
}
impl Drop for EmptyServer {
fn drop(&mut self) {
let _ = self.child.kill();
let _ = self.child.wait();
let _ = std::fs::remove_dir_all(&self.root);
}
}
fn spawn_empty_server() -> EmptyServer {
let token = "atom-coverage-token-0123456789abcd";
let port = free_port();
let endpoint = format!("http://127.0.0.1:{port}");
let root = std::env::temp_dir().join(format!(
"nornir-atom-cov-{}-{}",
std::process::id(),
Uuid::new_v4()
));
let _ = std::fs::remove_dir_all(&root);
let server_home = root.join("home");
std::fs::create_dir_all(server_home.join(".nornir").join("workspaces")).unwrap();
let child = Command::new(env!("CARGO_BIN_EXE_nornir-server"))
.env("NORNIR_SERVER_ADDR", format!("127.0.0.1:{port}"))
.env("NORNIR_SERVER_TOKEN", token)
.env("HOME", &server_home)
.env("NORNIR_POLL", "3600s")
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.spawn()
.expect("spawn nornir-server");
let srv = EmptyServer {
child,
root,
endpoint: endpoint.clone(),
token: token.to_string(),
};
let deadline = Instant::now() + Duration::from_secs(45);
loop {
if nornir::viz::remote::list_workspaces(&endpoint, token).is_ok() {
break;
}
assert!(Instant::now() < deadline, "empty server never came up");
std::thread::sleep(Duration::from_millis(200));
}
srv
}
fn remote_empty_app(srv: &EmptyServer) -> UrdrThreadsApp {
UrdrThreadsApp::with_remote(
srv.endpoint.clone(),
srv.token.clone(),
String::new(),
PathBuf::new(),
Vec::new(),
)
}
/// Drive every tab and harvest the discovered atoms per tab as [`DiscoveredAtom`]s
/// (`ran = true` since the tab rendered; `clean = error_atoms` was empty in that
/// state). Returns the flat atom batch + a per-tab label index for the drift check.
fn walk_atoms(
harness: &mut Harness<'_, UrdrThreadsApp>,
) -> (Vec<DiscoveredAtom>, BTreeMap<String, Vec<String>>) {
let mut batch: Vec<DiscoveredAtom> = Vec::new();
let mut by_tab: BTreeMap<String, Vec<String>> = BTreeMap::new();
for tab in TABS {
harness.state_mut().apply_command(&VizCommand {
tab: Some((*tab).to_string()),
..Default::default()
});
for _ in 0..4 {
let _ = harness.run_ok();
}
let clean = error_atoms(harness).is_empty();
let atoms = all_atoms(harness);
let labels = by_tab.entry((*tab).to_string()).or_default();
for a in atoms {
labels.push(a.text.clone());
batch.push(DiscoveredAtom {
tab: (*tab).to_string(),
label: a.text,
role: a.role,
ran: true,
clean,
});
}
}
(batch, by_tab)
}
#[test]
fn atom_rows_write_read_and_autonom_counts_them() {
let srv = spawn_empty_server();
let mut harness: Harness<'_, UrdrThreadsApp> = Harness::builder()
.with_size(egui::Vec2::new(1400.0, 1000.0))
.build_ui_state(
|ui, app: &mut UrdrThreadsApp| app.draw_ui(ui),
remote_empty_app(&srv),
);
let (atoms, _by_tab) = walk_atoms(&mut harness);
assert!(
!atoms.is_empty(),
"the walk discovered atoms across the tabs"
);
// -- 1. PRODUCE atom rows + -- 2. write -> read (LAW 8) -------------------
let dir = tempfile::tempdir().unwrap();
let wh = IcebergWarehouse::open(dir.path()).unwrap();
let run_id = format!("atom-run-{}", Uuid::new_v4());
let ts = chrono::Utc::now().timestamp_micros();
let rows = atom_coverage_rows(&run_id, "nornir", &atoms, ts);
assert!(
!rows.is_empty(),
"produced one ui_atom row per discovered atom"
);
for r in &rows {
assert_eq!(r.kind, "ui_atom", "row kind tags the atom surface");
assert!(r.id.contains('/'), "id is `<tab>/<label>`: {}", r.id);
assert_eq!(
r.surface_key,
format!("ui_atom:{}@na", r.id),
"surface_key follows the SurfaceNode::key_str convention",
);
}
wh.block_on(append_surface_coverage(&wh, &rows)).unwrap();
let back = wh
.block_on(query_surface_coverage(
&wh,
&CoverageSelector::Run(run_id.clone()),
))
.unwrap();
assert_eq!(
back.len(),
rows.len(),
"every atom row read back from the warehouse"
);
assert!(
back.iter().all(|r| r.kind == "ui_atom"),
"the read-back rows are ui_atom rows"
);
let covered = back
.iter()
.filter(|r| r.verdict() == Verdict::Covered)
.count();
let missing = back
.iter()
.filter(|r| r.verdict() == Verdict::Missing)
.count();
assert!(covered >= 1, "at least one atom in a clean tab is covered");
assert_eq!(
missing, 0,
"no atom is missing — every walked tab is error-atom-clean (S2 green)",
);
// -- 2b. autonom COUNTS the atom rows -------------------------------------
let summary = CoverageSummary::from_rows(&back);
assert_eq!(
summary.total,
back.len(),
"the gate roll-up counts every ui_atom row"
);
assert_eq!(
summary.covered, covered,
"covered atoms counted by the gate summary"
);
assert!(
summary.green,
"no missing atom => the atom slice of the gate is green"
);
}
/// **DRIFT GATE: every `button_registry` station must be a real rendered atom.**
///
/// For each hand-maintained `button_registry` entry, its button must correspond to
/// an atom the viz actually paints in that entry's tab — else the metro map draws a
/// station for a button that doesn't render (the drift the registry can silently
/// rot into). This is the seam toward auto-deriving `button_registry` from the
/// walk; for now it fails on drift.
///
/// MATCHING RULE (lenient, documented): a registry button `id` is snake_case
/// (`run_full_matrix`, `add_workspace`) while the rendered atom carries a decorated
/// human label (`"Run full matrix"`, `"Add workspace"` with icons). We tokenize
/// both — the id by `_`, the atom text by non-alphanumeric boundaries, both
/// lowercased — and consider the button FOUND iff **at least one significant id
/// token (len >= 3) appears among that tab's atom tokens**. This tolerates the
/// icon/casing/extra-word differences that are legitimate (`server_status` ->
/// "status"), while still catching a registry entry whose button no longer renders
/// at all in its tab.
///
/// The reverse direction (a walked button absent from the registry) is NOT a gate:
/// `button_registry` is intentionally a CURATED subset (only metro-mapped buttons),
/// not every button — `reload`/`cancel`/`close` legitimately aren't in it.
/// S3c DRIFT: this file's hand-walked `TABS` list must stay equal to the PANE
/// DISCOVERY REGISTRY's top-level pane ids (`surfaces::tab_pane_ids`) — so the atom
/// coverage walk can never silently fall behind the registry the atom-walk drives.
#[test]
fn atom_coverage_tabs_match_pane_registry() {
let from_registry: Vec<String> = tab_pane_ids().iter().map(|s| s.to_string()).collect();
let from_const: Vec<String> = TABS.iter().map(|s| s.to_string()).collect();
assert_eq!(
from_const, from_registry,
"atom_coverage TABS drifted from surfaces::tab_pane_ids() — the registry is \
the canonical surface enumerator (S3c); update TABS to match"
);
}
#[test]
fn button_registry_stations_are_rendered_atoms() {
let srv = spawn_empty_server();
let mut harness: Harness<'_, UrdrThreadsApp> = Harness::builder()
.with_size(egui::Vec2::new(1400.0, 1000.0))
.build_ui_state(
|ui, app: &mut UrdrThreadsApp| app.draw_ui(ui),
remote_empty_app(&srv),
);
let (_atoms, by_tab) = walk_atoms(&mut harness);
let tab_tokens = |walk_tab: &str| -> std::collections::BTreeSet<String> {
by_tab
.get(walk_tab)
.into_iter()
.flatten()
.flat_map(|label| {
label
.split(|c: char| !c.is_alphanumeric())
.filter(|t| !t.is_empty())
.map(|t| t.to_lowercase())
})
.collect()
};
let mut drift: Vec<String> = Vec::new();
for btn in nornir::arch::metro::button_registry() {
let walk_tab = registry_tab_to_walk_tab(btn.tab);
let tokens = tab_tokens(&walk_tab);
let id_tokens: Vec<String> = btn
.id
.split('_')
.filter(|t| t.len() >= 3)
.map(|t| t.to_lowercase())
.collect();
let found = id_tokens.iter().any(|t| tokens.contains(t));
if !found {
drift.push(format!(
"button `{}` (tab `{}` -> walk `{}`, rpc `{}`) has NO matching rendered atom \
(id tokens {:?} not in tab atom tokens)",
btn.id, btn.tab, walk_tab, btn.rpc, id_tokens
));
}
}
assert!(
drift.is_empty(),
"button_registry drift — these registry stations don't correspond to any \
rendered atom (the metro map would draw a phantom station):\n{}",
drift.join("\n"),
);
}