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

nordisk/nornir

tests/viz_release.rs

//! Inject-and-assert proof for the 🚀 **Release** view (B3 op-log + B4 lit-up
//! dep-graph) and the ℹ **About** popup (C1) — THE_BIG_PLAN LAW #1: a test that
//! feeds real input and asserts real output, never merely "didn't panic".
//!
//! B3/B4: build the app, inject a concrete `release_events` DAG (two repos, a
//! `znippy` → green dep that `holger` waits on, holger still running), select the
//! Release tab, and assert `state_json()["release"]` exposes:
//!   * the component-prefixed op-log lines (`[znippy] test end ok (1.2s)`),
//!   * each row's component / op / status, and
//!   * the per-component latest status the lit-up graph colours by
//!     (`znippy=ok`, `holger=running`).
//!
//! C1: assert `state_json()["about"]` carries SOMETHING real — the client version
//! (`CARGO_PKG_VERSION`), the workspace, the warehouse path, and the mode.
//!
//! A wgpu snapshot (LAW #4) renders the Release view to a committed PNG, matching
//! the egui_kittest 0.33 pattern in `viz_snapshots.rs`.
#![cfg(feature = "viz")]

use std::path::{Path, PathBuf};

use nornir::viz::UrdrThreadsApp;
use nornir::warehouse::release_events::{ReleaseEventRow, phase, status};

/// A deterministic two-repo release DAG: `znippy` tests green, then `holger`
/// (which depends on znippy) starts its gate and is still running. Appended out
/// of `seq` order on purpose — the tab must re-group + re-order by `(run_id, seq)`.
fn fixture_rows() -> Vec<ReleaseEventRow> {
    let run = "run-b3-fixture";
    let mk = |seq: i64,
              comp: &str,
              op: &str,
              ph: &str,
              st: &str,
              deps: Option<Vec<&str>>,
              ms: Option<i64>,
              detail: &str| {
        ReleaseEventRow {
            run_id: run.into(),
            seq,
            ts_micros: seq * 1_000,
            component: comp.into(),
            repo: comp.into(),
            op: op.into(),
            phase: ph.into(),
            status: st.into(),
            detail: detail.into(),
            depends_on: deps.map(|v| v.into_iter().map(String::from).collect()),
            elapsed_ms: ms,
        }
    };
    vec![
        // run envelope start (no component lane)
        mk(0, run, "run", phase::START, status::RUNNING, None, None, ""),
        // znippy: test start → end ok (1200ms) — a dep-graph root (Some([]))
        mk(
            2,
            "znippy",
            "test",
            phase::END,
            status::OK,
            Some(vec![]),
            Some(1200),
            "3 passed",
        ),
        mk(
            1,
            "znippy",
            "test",
            phase::START,
            status::RUNNING,
            Some(vec![]),
            None,
            "",
        ),
        // holger: gate start, still running, waits on znippy
        mk(
            3,
            "holger",
            "gate",
            phase::START,
            status::RUNNING,
            Some(vec!["znippy"]),
            None,
            "",
        ),
    ]
}

fn build_app() -> UrdrThreadsApp {
    // Empty local warehouse — the rows are injected, not read from disk.
    let warehouse_root =
        std::env::temp_dir().join(format!("nornir-viz-release-{}", std::process::id()));
    let _ = std::fs::create_dir_all(&warehouse_root);
    let mut app = UrdrThreadsApp::with_repos(
        warehouse_root,
        "holger".to_string(),
        PathBuf::new(),
        vec!["znippy".to_string(), "holger".to_string()],
    );
    app.inject_release_events_for_test(fixture_rows());
    app
}

#[test]
fn release_view_exposes_op_rows_and_component_status() {
    let app = build_app();
    let state = app.state_json();

    // The Release tab is selected and present in the tab set.
    assert_eq!(
        state["tab"].as_str(),
        Some("Release"),
        "tab must be Release"
    );
    let tabs: Vec<&str> = state["all_tabs"]
        .as_array()
        .unwrap()
        .iter()
        .filter_map(|t| t.as_str())
        .collect();
    assert!(
        tabs.contains(&"Release"),
        "Release must be in all_tabs; got {tabs:?}"
    );

    let rel = &state["release"];
    assert!(
        rel["error"].is_null(),
        "release.error must be null, got {}",
        rel["error"]
    );
    assert_eq!(rel["runs"].as_u64(), Some(1), "one run injected");
    assert_eq!(rel["selected_run"].as_str(), Some("run-b3-fixture"));

    // B3 — the op-log rows: component-prefixed line + component/op/status fields.
    let rows = rel["rows"].as_array().expect("release.rows array");
    assert_eq!(
        rows.len(),
        4,
        "all 4 injected events surfaced (re-grouped), got {}",
        rows.len()
    );

    // Re-grouped by (run_id, seq): the run envelope first, then znippy start,
    // znippy end ok, holger gate start.
    let lines: Vec<&str> = rows.iter().filter_map(|r| r["line"].as_str()).collect();
    assert!(
        lines
            .iter()
            .any(|l| *l == "[znippy] test end ok (1.2s) — 3 passed"),
        "component-prefixed op-log line missing; got {lines:?}"
    );
    assert!(
        lines.iter().any(|l| *l == "[holger] gate start running"),
        "holger gate-start line missing; got {lines:?}"
    );

    // Each row carries the structured fields the lit-up graph + assertions need.
    let znippy_end = rows
        .iter()
        .find(|r| r["component"] == "znippy" && r["phase"] == "end")
        .expect("znippy end row");
    assert_eq!(znippy_end["op"], "test");
    assert_eq!(znippy_end["status"], "ok");

    // B4 — per-component LATEST status (what the graph colours by): znippy went
    // running→ok so it's ok; holger is still running; the run envelope is NOT a
    // node (excluded).
    let cs = &rel["component_status"];
    assert_eq!(
        cs["znippy"].as_str(),
        Some("ok"),
        "znippy lights green (ok)"
    );
    assert_eq!(
        cs["holger"].as_str(),
        Some("running"),
        "holger pulses amber (running)"
    );
    assert!(
        cs.get("run-b3-fixture").is_none(),
        "run envelope is not a graph node"
    );
}

#[test]
fn about_popup_exposes_client_version_endpoint_workspace_warehouse() {
    let app = build_app();
    let state = app.state_json();
    let about = &state["about"];

    // Client version is the real crate version, never a placeholder.
    assert_eq!(
        about["client_version"].as_str(),
        Some(env!("CARGO_PKG_VERSION")),
        "About must show the real client CARGO_PKG_VERSION"
    );
    assert_eq!(
        about["mode"].as_str(),
        Some("local"),
        "local-warehouse build"
    );
    assert_eq!(
        about["workspace"].as_str(),
        Some("holger"),
        "selected workspace shown"
    );
    // The warehouse path is a real, non-empty path in local mode.
    let wh = about["warehouse"].as_str().unwrap_or("");
    assert!(
        wh.contains("nornir-viz-release-"),
        "warehouse path must be the injected root, got {wh:?}"
    );
    // Connect status is the real local-mode status string.
    assert_eq!(about["connect_status"].as_str(), Some("local (no server)"));
}

// ─── wgpu snapshot (LAW #4) ──────────────────────────────────────────────────

#[test]
fn viz_snapshot_release() {
    use egui_kittest::Harness;

    let mut app = build_app();
    // Re-inject after construction (build_app already injected, but the harness
    // takes ownership) — keep the same fixture so the PNG is deterministic.
    app.inject_release_events_for_test(fixture_rows());

    let mut harness = Harness::builder()
        .with_size(egui::Vec2::new(1400.0, 900.0))
        .wgpu()
        .build_ui_state(|ui, app: &mut UrdrThreadsApp| app.draw_ui(ui), app);

    for _ in 0..6 {
        harness.run_ok();
    }

    // FUNCTIONAL PROOF on the rendered state.
    let state = harness.state().state_json();
    assert_eq!(state["tab"].as_str(), Some("Release"));
    assert_eq!(
        state["release"]["component_status"]["znippy"].as_str(),
        Some("ok")
    );

    // VISUAL PROOF.
    let dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/snapshots");
    std::fs::create_dir_all(&dir).expect("create tests/snapshots");
    let image = harness.render().expect("wgpu render of Release view");
    let png_path = dir.join("viz_release.png");
    image.save(&png_path).expect("write viz_release.png");
    let size = std::fs::metadata(&png_path).map(|m| m.len()).unwrap_or(0);
    eprintln!("PROOF: viz_release → {} ({size} bytes)", png_path.display());
    assert!(
        size > 3_000,
        "Release PNG suspiciously small ({size} bytes) — did it render?"
    );

    // Golden compare (first run writes the golden via save above; only a real
    // diff against an existing committed golden fails — mirrors viz_snapshots.rs).
    harness.snapshot("viz_release");
    if let Err(results) = harness.take_snapshot_results().into_result() {
        let inner: Vec<_> = results.into();
        for err in &inner {
            let msg = err.to_string();
            assert!(
                msg.contains("Missing snapshot"),
                "[viz_release] golden diff: {msg}"
            );
        }
    }
}

// ════════════════════════════════════════════════════════════════════════════
// REGRESSION (task #20 — redb lock-tolerance): a held writer must NOT hide
// committed rows from the viz read path.
//
// The fat viz keeps a durable action-log warehouse handle open for the whole
// app lifetime. Before the fix it `open()`ed the SAME data warehouse the data
// tabs read, holding redb's single-writer lock; every `open_read_only()` then
// fell into the degraded copy-aside snapshot path, which can read mid-write /
// stale state → the 🚀 Release tab showed 0 rows even though the warehouse held
// the seeded events (the original `viz_surface` fat-parity failure).
//
// This test pins the invariant at the warehouse seam (no GUI needed): hold a
// writer handle on a warehouse, seed `release_events`, then read through the
// EXACT viz read path (`open_read_only` + `query_release_events(All)`) and assert
// every committed row comes back.
// ════════════════════════════════════════════════════════════════════════════
#[test]
fn held_writer_does_not_hide_release_rows_from_viz_read_path() {
    use nornir::warehouse::iceberg::IcebergWarehouse;
    use nornir::warehouse::release_events::{
        EventSelector, append_release_events, query_release_events,
    };

    let root = std::env::temp_dir().join(format!(
        "nornir-viz-release-held-{}-{}",
        std::process::id(),
        uuid::Uuid::new_v4()
    ));
    let _ = std::fs::remove_dir_all(&root);
    std::fs::create_dir_all(&root).unwrap();

    // Open a writer and KEEP it for the whole test — this is the action-log
    // durable sink's exclusive handle (or a live nornir-server). Seed the rows
    // through this same writer so they're genuinely committed.
    let writer = IcebergWarehouse::open(&root).expect("open writer warehouse");
    let rows = fixture_rows();
    writer
        .block_on(append_release_events(&writer, &rows))
        .expect("seed release_events through the held writer");
    // The writer stays alive past the read below (NOT dropped), reproducing the
    // app-lifetime hold that triggered the bug.

    // The viz read path: lock-tolerant read-only open + the All selector, exactly
    // as `release_tab::load()` (Src::Local) does.
    let wh = IcebergWarehouse::open_read_only(&root)
        .expect("open_read_only must succeed even while a writer holds the lock");
    // The writer is held, so the read MUST be the degraded copy-aside snapshot
    // path (not a plain exclusive open) — that's the exact path the fat viz hits.
    assert!(
        wh.is_snapshot(),
        "expected the lock-tolerant copy-aside snapshot path while a writer is held",
    );
    let read = wh
        .block_on(query_release_events(&wh, &EventSelector::All))
        .expect("query_release_events while a writer is held");

    assert_eq!(
        read.len(),
        rows.len(),
        "viz read path saw {} of {} committed release_events while a writer held the \
         warehouse — the copy-aside snapshot must reflect committed state, not 0 rows",
        read.len(),
        rows.len(),
    );

    drop(writer);
    let _ = std::fs::remove_dir_all(&root);
}