nornir 0.5.3

Companion to cargo: dependency tracking, release gating, deploy, benchmarks, and documentation assembly. Project-agnostic.
//! **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::control::VizCommand;
use nornir::viz::UrdrThreadsApp;
use nornir::warehouse::iceberg::IcebergWarehouse;
use nornir::warehouse::surface_coverage::{
    append_surface_coverage, query_surface_coverage, CoverageSelector, CoverageSummary, Verdict,
};
use nornir::viz::surfaces::tab_pane_ids;
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.
const TABS: &[&str] = &[
    "Nornir", "Timeline", "DepGraph", "CallGraph", "Architecture", "Metro", "Helix", "Bloodstream",
    "Funnel", "TimeTravel", "LiveRun", "Release", "Knowledge", "Warehouse", "WarehouseDeck", "Mcp",
    "Search", "Gates", "Bench", "Test", "Leaderboard", "Security", "Dwarfs", "Holger", "Manual",
    "Claude", "Models",
];

/// 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_state(|ctx, app: &mut UrdrThreadsApp| app.draw_ui(ctx), 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_state(|ctx, app: &mut UrdrThreadsApp| app.draw_ui(ctx), 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"),
    );
}