Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/nornir

tests/viz_live_data.rs

//! Comprehensive **live-DATA** functional test for the `nornir viz`
//! (nornir-ui) tab matrix — THE_BIG_PLAN LAW #1 (feed real input, assert
//! real *rendered* output, never merely "didn't panic"), LAW #6 (the component
//! emits the actual data it drew), DoD #3 (verified against real data).
//!
//! ## Why this file exists
//!
//! The pre-existing per-tab tests (`viz_test_pane.rs`, `viz_leaderboard_pane.rs`,
//! `viz_live.rs`, `viz_release.rs`, `viz_snapshots.rs`, `funnel_viz.rs`) each
//! prove ONE tab. The `viz_matrix.rs` "render every tab headless" test only
//! asserts the state has the right *shape* (keys present) — it stays GREEN even
//! when a tab renders **empty** (no rows / no nodes / no lanes). A live audit
//! against the running server found exactly that hole:
//!
//!   * `Timeline releases:22 / lanes:0` — releases counted but **no lane mapped**,
//!   * `Test` / `DepGraph` / `Release` / `Knowledge` / `Funnel` / `LiveRun` /
//!     `Leaderboard` all rendered **empty** — yet every test was green.
//!
//! ## What this test guards
//!
//! For EVERY data-bearing tab it **seeds real data → drives the tab → asserts the
//! rendered `state_json` block is NON-EMPTY** — the actual rows / nodes / cells /
//! lanes, not just that the key exists. A future tab that renders empty (an RPC
//! that returns data the tab fails to MAP, a scoping bug, or genuinely no data)
//! turns THIS test — and therefore the `nornir test` matrix — RED.
//!
//! Seeding route per tab (whichever the codebase already supports):
//!   * **Real on-disk Iceberg warehouse** (preferred — proves the warehouse read
//!     path): `release_events` → Live Run + Release + Warehouse tables;
//!     `test_results` → the warehouse browser; `funnel_events` (via the demo
//!     seeder) → Funnel; multiple tables → the Warehouse browser table list.
//!   * **`*_for_test` injectors** (the route the codebase exposes for the
//!     timeline-derived tabs whose data is built by the release pipeline, not
//!     hand-appendable): `inject_timeline_for_test` → Timeline / DepGraph /
//!     TimeTravel; `inject_test_results_for_test` → Test grid;
//!     `inject_bakeoff_for_test` → Leaderboard; `inject_release_events_for_test`
//!     → Release; `inject_live_release_events_for_test` →  Live Run.
//!
//! The **lanes-follow-releases** guard (the `22 releases / 0 lanes` bug): the
//! timeline `state_json` exposes only the `releases` scalar (sum of lane nodes),
//! not the lane array — but the 🔗 Dep Graph lays its nodes out from
//! `timeline.lanes` (one node per lane repo). So `dep_graph.node_count > 0`
//! **proves the lanes exist and mapped**: if releases were counted but lanes were
//! empty, the dep-graph would have ZERO nodes (and be `Null`). We assert both
//! `releases > 0` AND `dep_graph.node_count == lane repo count`, which is the
//! precise inverse of the `22 / 0` symptom.
#![cfg(feature = "viz")]

use std::collections::BTreeMap;
use std::path::PathBuf;

use chrono::{TimeZone, Utc};
use nornir::viz::UrdrThreadsApp;
use nornir::viz::model::{Lane, LaneNode, Timeline};
use nornir::warehouse::agent_model_runs::{AgentModelRunRow, append_agent_model_runs};
use nornir::warehouse::dep_graph::{CrossRepoEdge, DepGraphSnapshot};
use nornir::warehouse::iceberg::IcebergWarehouse;
use nornir::warehouse::release_events::{ReleaseEventRow, append_release_events, phase, status};
use nornir::warehouse::test_results::{TestResultRow, append_test_results, status as test_status};
use serde_json::Value;
use uuid::Uuid;

const REPOS: [&str; 3] = ["holger", "knut", "skade"];

// ─── fixtures ────────────────────────────────────────────────────────────────

/// A concrete [`Timeline`] with per-repo swim-lanes (real release nodes), a
/// dep-graph snapshot with cross-repo edges, so Timeline / DepGraph / TimeTravel
/// all have real data to lay out. Three lanes × {3,2,2} nodes = 7 releases.
fn fixture_timeline() -> Timeline {
    let ts = |day: u32| Utc.with_ymd_and_hms(2026, 6, day, 12, 0, 0).unwrap();
    let rid = |repo: &str, n: u8| {
        let h = repo
            .bytes()
            .fold(0u128, |a, b| a.wrapping_mul(131).wrapping_add(b as u128));
        let base = 0x4e6f_726e_6972_0000_0000_0000_0000_0000u128;
        Uuid::from_u128(base | (h << 8) | n as u128)
    };
    let lane_specs: [(&str, &[(u8, &str, &str)]); 3] = [
        (
            "holger",
            &[
                (1, "succeeded", "0.3.0"),
                (2, "succeeded", "0.3.1"),
                (3, "failed_test", "0.3.2"),
            ],
        ),
        (
            "knut",
            &[(1, "succeeded", "0.5.0"), (2, "succeeded", "0.5.1")],
        ),
        (
            "skade",
            &[(1, "succeeded", "0.1.3"), (2, "succeeded", "0.1.4")],
        ),
    ];
    let mut lanes = Vec::new();
    let mut release_order = Vec::new();
    let mut release_snapshot = BTreeMap::new();
    let mut day = 1u32;
    let snapshot_id = Uuid::from_u128(0x4e6f_726e_6972_5f73_6e61_7000_0000_0001u128);
    for (repo, nodes) in lane_specs {
        let mut lane_nodes = Vec::new();
        for (n, st, ver) in nodes {
            let id = rid(repo, *n);
            release_order.push(id);
            release_snapshot.insert(id, snapshot_id);
            lane_nodes.push(LaneNode {
                release_id: id,
                timestamp: ts(day),
                sha: format!("{repo}{n:02}deadbeef"),
                branch: "main".to_string(),
                dirty: false,
                gate_status: st.to_string(),
                tests_passed: 12 + *n as u32,
                tests_failed: if *st == "failed_test" { 1 } else { 0 },
                published_versions: vec![(repo.to_string(), ver.to_string())],
            });
            day += 1;
        }
        lanes.push(Lane {
            repo: repo.to_string(),
            nodes: lane_nodes,
        });
    }
    // skade → knut → holger cross-repo edges.
    let edges = vec![
        CrossRepoEdge {
            from: "knut".into(),
            to: "skade".into(),
            via: ["skade-katalog".into()].into_iter().collect(),
            kind: Default::default(),
        },
        CrossRepoEdge {
            from: "holger".into(),
            to: "knut".into(),
            via: ["knut-core".into()].into_iter().collect(),
            kind: Default::default(),
        },
        CrossRepoEdge {
            from: "holger".into(),
            to: "skade".into(),
            via: ["skade".into()].into_iter().collect(),
            kind: Default::default(),
        },
    ];
    let snapshot = DepGraphSnapshot {
        snapshot_id,
        workspace_name: "holger".to_string(),
        timestamp: ts(3),
        edges,
    };
    let mut snapshots = BTreeMap::new();
    snapshots.insert(snapshot_id, snapshot.clone());
    Timeline {
        workspace_name: "holger".to_string(),
        lanes,
        release_order,
        release_snapshot,
        snapshots,
        latest_snapshot: Some(snapshot),
        bench_history: BTreeMap::new(),
    }
}

/// `test_results` rows: 3 repos × multiple aspects × 1 run each — a populated
/// grid (repos>0, aspects>0, rows>0, with at least one red cell).
fn fixture_test_rows() -> Vec<TestResultRow> {
    let mk = |repo: &str, aspect: &str, name: &str, st: &str, metric: f64| TestResultRow {
        run_id: format!("{repo}-r1"),
        repo: repo.into(),
        suite: repo.into(),
        test_name: name.into(),
        status: st.into(),
        duration_ms: 4.0,
        ts_micros: 300,
        message: String::new(),
        aspect: aspect.into(),
        metric,
    };
    vec![
        mk("holger", "build", "build", test_status::PASS, 0.0),
        mk("holger", "unit", "a", test_status::PASS, 0.0),
        mk("holger", "coverage", "cov", test_status::PASS, 88.0),
        mk("knut", "build", "build", test_status::PASS, 0.0),
        mk("knut", "unit", "b", test_status::FAIL, 0.0),
        mk("knut", "coverage", "cov", test_status::PASS, 70.0),
        mk("skade", "build", "build", test_status::PASS, 0.0),
        mk("skade", "unit", "c", test_status::PASS, 0.0),
        mk("skade", "coverage", "cov", test_status::PASS, 92.0),
    ]
}

/// A deterministic one-run release DAG (run envelope + a test + a gate), so the
/// Release op-log + Live Run pane both have real rows to render.
fn fixture_release_rows(run: &str) -> Vec<ReleaseEventRow> {
    let mk = |seq: i64, comp: &str, op: &str, ph: &str, st: &str, ms: Option<i64>, detail: &str| {
        ReleaseEventRow {
            run_id: run.into(),
            seq,
            ts_micros: 1_000_000 + seq * 1_000,
            component: comp.into(),
            repo: comp.into(),
            op: op.into(),
            phase: ph.into(),
            status: st.into(),
            detail: detail.into(),
            depends_on: None,
            elapsed_ms: ms,
        }
    };
    vec![
        mk(0, run, "run", phase::START, status::RUNNING, None, "holger"),
        mk(1, "znippy", "test", phase::START, status::RUNNING, None, ""),
        mk(
            2,
            "znippy",
            "test",
            phase::END,
            status::OK,
            Some(1200),
            "3 passed",
        ),
        mk(3, "holger", "gate", phase::START, status::RUNNING, None, ""),
    ]
}

/// A bake-off `agent_model_runs` run across three models, so the Leaderboard has
/// a ranked board to render.
fn fixture_bakeoff_rows() -> Vec<AgentModelRunRow> {
    let mk = |model: &str, tps: f64, score: f64, ok: bool| AgentModelRunRow {
        run_id: "bake-1".into(),
        ts_micros: 200,
        agent: "local-llm".into(),
        model: model.into(),
        prompt_id: "capital-fr".into(),
        prompt: "Capital of France?".into(),
        output: "Paris".into(),
        latency_ms: 100.0,
        tokens_in: 12,
        tokens_out: 18,
        tokens_per_s: tps,
        score,
        ok,
        error: None,
        cost_usd: 0.0,
        mcp_tool_calls: 0,
    };
    vec![
        mk("beta", 300.0, 0.9, true),
        mk("alpha", 100.0, 0.9, true),
        mk("gamma", 500.0, 0.5, true),
    ]
}

fn build_app(warehouse_root: PathBuf) -> UrdrThreadsApp {
    UrdrThreadsApp::with_repos(
        warehouse_root,
        "holger".to_string(),
        PathBuf::new(),
        REPOS.iter().map(|s| s.to_string()).collect(),
    )
}

fn tmp_warehouse(tag: &str) -> PathBuf {
    let root =
        std::env::temp_dir().join(format!("nornir-viz-livedata-{tag}-{}", std::process::id()));
    let _ = std::fs::remove_dir_all(&root);
    std::fs::create_dir_all(&root).unwrap();
    root
}

// ─── per-tab non-empty guards ────────────────────────────────────────────────

/// 🗓 Timeline + 🔗 Dep Graph + ⏳ Time Travel: inject a real multi-lane timeline
/// with a dep-graph snapshot, then assert the RENDERED data is non-empty.
///
/// This is the `22 releases / 0 lanes` guard: we assert both `releases > 0` AND
/// that the dep-graph — which lays its nodes out FROM `timeline.lanes` (one node
/// per lane repo) — has `node_count == lane repo count`. If releases were counted
/// but lanes were empty (the live bug), `dep_graph` would be `Null`/0 nodes.
#[test]
fn timeline_depgraph_timetravel_render_non_empty() {
    let mut app = build_app(tmp_warehouse("tl"));
    let tl = fixture_timeline();
    let expected_releases: u64 = tl.lanes.iter().map(|l| l.nodes.len() as u64).sum();
    let pinned = tl.release_order[0];
    let matched = app.inject_timeline_for_test(
        "Timeline",
        tl,
        REPOS.iter().map(|s| s.to_string()).collect(),
    );
    assert!(matched, "Timeline must be a known tab");

    let state = app.state_json();

    // Timeline: releases mapped into lanes (the bug is releases>0 but lanes==0).
    assert_eq!(state["tab"].as_str(), Some("Timeline"));
    let releases = state["timeline"]["releases"].as_u64().unwrap_or(0);
    assert!(
        state["timeline"]["error"].is_null(),
        "timeline.error must be null, got {}",
        state["timeline"]["error"]
    );
    assert!(
        releases > 0,
        "Timeline must render releases, got {releases}"
    );
    assert_eq!(
        releases, expected_releases,
        "every injected release surfaced"
    );
    // The empty placeholder must NOT be present when data is present.
    let placeholders: Vec<&str> = state["placeholders"]
        .as_array()
        .unwrap()
        .iter()
        .filter_map(|p| p.as_str())
        .collect();
    assert!(
        !placeholders.contains(&"no releases recorded yet"),
        "timeline has releases but the empty placeholder is present: {placeholders:?}"
    );

    // Dep Graph: nodes laid out FROM the lanes — this is the lanes-follow-releases
    // proof. node_count==0 would mean lanes were empty (the `0 lanes` symptom).
    let dg = &state["dep_graph"];
    assert!(
        dg.is_object(),
        "dep_graph must be a non-null object (lanes present), got {dg}"
    );
    let node_count = dg["node_count"].as_u64().unwrap_or(0);
    assert!(
        node_count > 0,
        "dep_graph must have nodes (lanes mapped), got {node_count}"
    );
    assert_eq!(
        node_count,
        REPOS.len() as u64,
        "one dep-graph node per lane repo (lanes followed releases)"
    );
    let nodes = dg["nodes"].as_array().expect("dep_graph nodes array");
    assert!(!nodes.is_empty(), "dep_graph nodes array non-empty");
    let edges = dg["edges"].as_array().expect("dep_graph edges array");
    assert!(
        !edges.is_empty(),
        "dep_graph must render the cross-repo edges, got {}",
        edges.len()
    );
    assert!(
        dg["direct_edges"].as_u64().unwrap_or(0) > 0,
        "at least one direct edge classified"
    );

    // Time Travel: pin the cursor, switch to graph view, assert the time-pinned
    // graph is rendered with the lanes' nodes.
    app.set_timetravel_for_test(0, true, true);
    let tt = &app.state_json()["timetravel"];
    assert_eq!(
        tt["view"].as_str(),
        Some("graph"),
        "deps reel in graph view"
    );
    assert!(
        tt["release_count"].as_u64().unwrap_or(0) > 0,
        "time travel has releases"
    );
    assert_eq!(
        tt["pinned_release"].as_str(),
        Some(pinned.to_string().as_str()),
        "cursor pinned"
    );
    let ttg = &tt["graph"];
    assert!(ttg.is_object(), "time-pinned graph rendered, got {ttg}");
    assert!(
        ttg["node_count"].as_u64().unwrap_or(0) > 0,
        "time-pinned graph has nodes"
    );
}

/// 🧪 Test grid: inject a real `test_results` matrix, assert the rendered grid is
/// non-empty (repos>0, aspects>0, rows>0).
/// 🚀 Release op-log: inject real `release_events`, assert the rendered op rows
/// are non-empty.
#[test]
fn release_oplog_renders_non_empty() {
    let mut app = build_app(tmp_warehouse("rel"));
    app.inject_release_events_for_test(fixture_release_rows("run-rel"));

    let state = app.state_json();
    assert_eq!(state["tab"].as_str(), Some("Release"));
    let rel = &state["release"];
    assert!(
        rel["error"].is_null(),
        "release.error must be null, got {}",
        rel["error"]
    );
    assert!(
        rel["runs"].as_u64().unwrap_or(0) > 0,
        "release must render >=1 run"
    );
    let rows = rel["rows"].as_array().expect("release.rows array");
    assert!(
        !rows.is_empty(),
        "Release op-log must render rows, got {}",
        rows.len()
    );
    let comp_status = rel["component_status"]
        .as_object()
        .expect("component_status map");
    assert!(
        !comp_status.is_empty(),
        "per-component status must be rendered, got {comp_status:?}"
    );
}

/// 📡 Live Run: inject real `release_events`, assert the hydrated run + rendered
/// event lines are non-empty.
#[test]
fn live_run_renders_non_empty() {
    let mut app = build_app(tmp_warehouse("live-inject"));
    app.inject_live_release_events_for_test(fixture_release_rows("run-live"));

    let state = app.state_json();
    assert_eq!(state["tab"].as_str(), Some("LiveRun"));
    let live = &state["live"];
    assert!(
        live["error"].is_null(),
        "live.error must be null, got {}",
        live["error"]
    );
    assert_eq!(
        live["hydrated_run_id"].as_str(),
        Some("run-live"),
        "a run hydrated"
    );
    assert_eq!(live["run_id"].as_str(), Some("run-live"));
    assert!(
        live["events"].as_u64().unwrap_or(0) > 0,
        "live must render events, got {}",
        live["events"]
    );
    let lines = live["lines"].as_array().expect("live.lines array");
    assert!(
        !lines.is_empty(),
        "Live Run must render event lines, got {}",
        lines.len()
    );
}

/// 📡 Live Run + 🚀 Release + 🗂 Warehouse, all from a **real on-disk warehouse**
/// (the preferred route — proves the warehouse read path, not just injection).
/// Seeds `release_events` + `test_results` + `agent_model_runs` to a temp Iceberg
/// warehouse, points the app at it, and asserts:
///   * Live Run hydrates from the warehouse (non-empty run + lines),
///   * the Warehouse browser lists the seeded tables (tables>0).
#[test]
fn warehouse_backed_tabs_render_non_empty() {
    let warehouse_root = tmp_warehouse("wh");
    let wh = IcebergWarehouse::open(&warehouse_root).expect("open warehouse");
    wh.block_on(append_release_events(&wh, &fixture_release_rows("run-wh")))
        .expect("append release_events");
    // test_results is partitioned by `repo` — single-partition appends only, so
    // seed one batch per repo (still a real on-disk table for the browser to list).
    for repo in REPOS {
        let rows: Vec<TestResultRow> = fixture_test_rows()
            .into_iter()
            .filter(|r| r.repo == repo)
            .collect();
        wh.block_on(append_test_results(&wh, &rows))
            .expect("append test_results");
    }
    wh.block_on(append_agent_model_runs(&wh, &fixture_bakeoff_rows()))
        .expect("append agent_model_runs");
    drop(wh); // release the writer lock so the read-only hydrate opens cleanly.

    let mut app = build_app(warehouse_root.clone());

    // 📡 Live Run, hydrated from the REAL warehouse release_events table.
    app.live_hydrate_from_warehouse_for_test();
    let live = &app.state_json()["live"];
    assert_eq!(
        live["hydrated_from_warehouse"],
        Value::Bool(true),
        "Live Run hydrated from warehouse"
    );
    assert!(
        live["error"].is_null(),
        "warehouse hydrate must succeed, got {}",
        live["error"]
    );
    assert_eq!(
        live["hydrated_run_id"].as_str(),
        Some("run-wh"),
        "run read back from warehouse"
    );
    assert!(
        live["events"].as_u64().unwrap_or(0) > 0,
        "warehouse-sourced events non-empty"
    );
    assert!(
        !live["lines"].as_array().unwrap().is_empty(),
        "warehouse-sourced lines non-empty"
    );

    // 🗂 Warehouse browser: drive every tab headless (the Warehouse draw lists the
    // tables), then assert the seeded tables surface (tables > 0).
    app.render_all_tabs_headless();
    let wh_state = app.state_json();
    let tables = wh_state["warehouse_tables"]
        .as_array()
        .expect("warehouse_tables array");
    assert!(
        !tables.is_empty(),
        "Warehouse browser must list the seeded tables, got {tables:?}"
    );
    let names: Vec<&str> = tables.iter().filter_map(|t| t.as_str()).collect();
    assert!(
        names.iter().any(|n| n.contains("release_events")),
        "release_events table must be listed; got {names:?}"
    );

    let _ = std::fs::remove_dir_all(&warehouse_root);
}

/// 🗂 Funnel: seed real `funnel_events` via the demo seeder on a **real on-disk
/// warehouse** (the route the codebase exposes — the funnel DAG is materialised
/// by `run_demo`, not hand-appendable), then assert the rendered funnel plan is
/// non-empty (plan_count>0, nodes>0).
#[test]
fn funnel_renders_non_empty() {
    // The demo's git-root resolution wants `<tmp>/ws/.nornir/warehouse` so the
    // fake repos land under `<tmp>/git/…` (mirrors funnel_viz.rs).
    let tmp = tempfile::tempdir().unwrap();
    let ws = tmp.path().join("ws");
    let warehouse = ws.join(".nornir").join("warehouse");
    std::fs::create_dir_all(&warehouse).unwrap();

    let mut app =
        UrdrThreadsApp::with_repos(warehouse, "funnel-livedata".to_string(), ws.clone(), vec![]);
    let size = 3;
    app.funnel_tab_for_test()
        .run_demo_for_test(size)
        .expect("seed funnel demo");

    let state = app.state_json();
    let funnel = &state["funnel"];
    assert!(
        funnel["error"].is_null(),
        "funnel.error must be null, got {}",
        funnel["error"]
    );
    assert!(
        funnel["plan_count"].as_u64().unwrap_or(0) > 0,
        "Funnel must render a plan, got plan_count 0"
    );
    let plans = funnel["plans"].as_array().expect("funnel.plans array");
    assert!(
        !plans.is_empty(),
        "Funnel must render plans, got {}",
        plans.len()
    );
    let plan = &plans[0];
    assert!(
        plan["nodes"].as_u64().unwrap_or(0) > 0,
        "funnel plan must render nodes, got {}",
        plan["nodes"]
    );
    assert_eq!(
        plan["nodes"].as_u64(),
        Some(size as u64),
        "one node per seeded repo"
    );
    assert!(
        plan["edges"].as_u64().unwrap_or(0) > 0,
        "funnel DAG must render dep edges"
    );
    assert!(
        plan["ready_count"].as_u64().unwrap_or(0) > 0,
        "at least one node is Ready"
    );
}

/// 📚 Knowledge: the map is the code room's. A workspace nothing was consumed
/// into renders the reader's `not_consumed` — never `crates: 0`, which would
/// read as an empty workspace.
#[test]
fn knowledge_renders_not_consumed_never_an_empty_map() {
    let warehouse_root = tmp_warehouse("kn-wh");
    let mut app = UrdrThreadsApp::with_repos(
        warehouse_root,
        "holger".to_string(),
        std::env::temp_dir(),
        REPOS.iter().map(|s| s.to_string()).collect(),
    );
    app.knowledge_load_for_test();
    let kn = &app.state_json()["knowledge"];
    assert_eq!(kn["state"], "unavailable", "{kn}");
    assert!(kn["note"].as_str().is_some_and(|n| n.contains("[not_consumed]")), "{kn}");
    assert!(kn.get("crates").is_none(), "no empty crate count: {kn}");
}