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

nordisk/nornir

tests/viz_empty_state_refresh.rs

//! 🔁 **Per-repo pane EMPTY-STATE + AUTO-REFRESH** (LAW 2 RAGNARÖK + the
//! sticky-`loaded` fix) — the systemic regression guard for the shared
//! [`nornir::viz::repo_pane`] helper, driven through the REAL warehouse-backed
//! 🕸 Call Graph lens the user actually opens.
//!
//! The bug this guards (the owner's #1 viz pain): selecting a repo whose data isn't
//! built yet rendered a SILENT BLANK board (LAW 2 violation), and the pane only
//! updated on a click — it never re-polled when a background populate wrote the
//! rows (the sticky `loaded` latch).
//!
//! This drives the lens over a TRULY EMPTY warehouse and asserts, from
//! `state_json` (no pixels — LAW 6):
//!   1. **not_scanned** — empty + NO active job ⇒ the pane reports the
//!      `empty_state == "not_scanned"` reason ("not scanned yet — run populate /
//!      deep-scan"), NOT a silent blank board.
//!   2. **in_route** — empty + an active `workspace_populate` for this workspace ⇒
//!      the SAME oracle flips to `"in_route"` ("⏳ in route for populate…").
//!
//! (The third leg — rows landing and the lens filling without a click — needs a
//! call-graph producer; the lens's `symbol_facts`/`call_edges` read is not wired
//! in Mímir v2, so that leg is covered by the pure throttle contract below until
//! the driver feeds the lens.)
//!
//! Run: `cargo test --features viz --test viz_empty_state_refresh -- --nocapture`
#![cfg(feature = "viz")]

use egui_kittest::Harness;
use nornir::jobs::{JobRecord, kind, status};
use nornir::viz::repo_pane::{self, EmptyState};
use nornir::viz::{Timeline, UrdrThreadsApp};
use nornir::warehouse::iceberg::IcebergWarehouse;

const REPO: &str = "nornir";
const WS: &str = "nornir";

fn empty_timeline() -> Timeline {
    Timeline {
        workspace_name: REPO.to_string(),
        lanes: Vec::new(),
        release_order: Vec::new(),
        release_snapshot: Default::default(),
        snapshots: Default::default(),
        latest_snapshot: None,
        bench_history: Default::default(),
    }
}

fn populate_job(workspace: &str, st: &str) -> JobRecord {
    JobRecord {
        job_id: format!("pop-{workspace}-{st}"),
        kind: kind::WORKSPACE_POPULATE.to_string(),
        target: workspace.to_string(),
        workspace: workspace.to_string(),
        status: st.to_string(),
        ts_start_micros: 0,
        ts_end_micros: None,
        elapsed_ms: None,
        detail_json: String::new(),
        result_ref: String::new(),
        parent_id: None,
        // `node` (which fleet machine ran the job) is an additive `#[serde(default)]`
        // field on jera's JobRecord; a synthetic local fixture ran on no distributed
        // node → `None`, matching the field's documented default.
        node: None,
        // `phase` (jera's DaemonPhase read-back) is additive; a non-daemon
        // populate fixture has no live phase → `None`.
        phase: None,
        actor: None,
        actor_method: None,
    }
}

/// Build the app over `wh_root`, switched to the 🕸 Call Graph lens, and return a
/// running harness. `ensure_loaded` scans the warehouse each frame the throttle
/// allows.
fn app_on_callgraph(wh_root: std::path::PathBuf, dir: std::path::PathBuf) -> UrdrThreadsApp {
    let mut app = UrdrThreadsApp::with_repos(wh_root, WS.to_string(), dir, vec![REPO.to_string()]);
    assert!(app.inject_timeline_for_test("Architecture", empty_timeline(), vec![REPO.to_string()]));
    assert!(app.arch_set_sub_view_for_test("CallGraph"));
    app
}

/// Both tests publish to `repo_pane`'s PROCESS-WIDE active-jobs snapshot; run in
/// parallel they overwrite each other's jobs mid-assert (seen: `InRoute` read
/// back as `NotScanned`). One lock, held for each test's whole body.
static ACTIVE_JOBS: std::sync::Mutex<()> = std::sync::Mutex::new(());

/// LAW 2 RAGNARÖK + the in_route/not_scanned classifier, end to end.
#[test]
fn callgraph_empty_state_names_reason() {
    let _jobs = ACTIVE_JOBS.lock().unwrap_or_else(|e| e.into_inner());
    let pid = std::process::id();
    let dir = std::env::temp_dir().join(format!("nornir-empty-refresh-{pid}"));
    let _ = std::fs::remove_dir_all(&dir);
    let wh_root = dir.join("warehouse");
    std::fs::create_dir_all(&wh_root).unwrap();
    // TODO: Audit that the environment access only happens in single-threaded code.
    unsafe { std::env::set_var("NORNIR_VIZ_NO_DURABLE_ACTIONLOG", "1") };

    // Start from a KNOWN-empty jobs snapshot (the global is process-wide).
    repo_pane::clear_active_jobs_for_test();

    // The warehouse exists but is EMPTY — no symbol_facts / call_edges yet. This is
    // exactly "selected a repo whose data isn't built yet."
    {
        let _wh = IcebergWarehouse::open(&wh_root).expect("create empty warehouse");
    }

    let app = app_on_callgraph(wh_root.clone(), dir.clone());
    let mut h = Harness::builder()
        .with_size(egui::Vec2::new(1400.0, 900.0))
        .build_ui_state(|ui, app: &mut UrdrThreadsApp| app.draw_ui(ui), app);
    for _ in 0..4 {
        h.run_ok();
    }

    // EVERY per-repo pane the shared helper was applied to publishes the
    // `empty_state` key as one of the three valid reasons — the same predicate the
    // `viz_functional_matrix` `repo_pane_empty_state` row asserts, proven here
    // WITHOUT the heavyweight server fixture so the wiring is guarded regardless.
    {
        let top = h.state().state_json();
        let valid = |v: &serde_json::Value| {
            matches!(
                v.as_str(),
                Some("populated") | Some("in_route") | Some("not_scanned")
            )
        };
        for slice in ["callgraph", "architecture"] {
            assert!(
                valid(&top[slice]["empty_state"]),
                "pane `{slice}` must publish a LAW-2 empty_state reason, got {}",
                top[slice]["empty_state"]
            );
        }
        // The 🗺 Knowledge pane reads the code room: with nothing consumed its
        // reason is the reader's stated refusal (`unavailable` + the note) — or
        // `not_loaded` before its first draw — which is just as far from a
        // silent blank board as an empty_state reason.
        let kn = &top["knowledge"];
        assert!(
            valid(&kn["empty_state"]) || kn["state"] == "not_loaded" || (kn["state"] == "unavailable" && kn["note"].is_string()),
            "pane `knowledge` must publish a LAW-2 reason (empty_state, not_loaded, or unavailable + note), got {kn}"
        );
        // ⚠ `test` used to be the fourth entry above. `ce553b9` CUT the 🧪 Test
        // tab, so `state_json` has no `test` slice and that assertion could only
        // read `null` — it had been wrong since the cut and could not say so,
        // because the egui realign (`833b639`) left this target uncompilable and
        // `cargo test` therefore never listed it (MEASURED 2026-08-22: 26 of
        // nornir's 98 test targets in that state).
        //
        // It is asserted GONE rather than merely deleted, so a re-added 🧪 pane
        // that forgets the shared empty-state helper lands here as a RED instead
        // of quietly rejoining the blank-board class this file exists to ban.
        assert!(
            top.get("test").is_none() || top["test"].is_null(),
            "a `test` slice is back in state_json — put it in the loop above so it \
             must publish a LAW-2 empty_state reason like its siblings, got {}",
            top["test"]
        );
    }

    // (1) EMPTY + NO JOB ⇒ "not_scanned" — NOT a silent blank board (RAGNARÖK).
    let cg = h.state().state_json()["callgraph"].clone();
    assert_eq!(
        cg["node_count"].as_u64().unwrap_or(99),
        0,
        "warehouse is empty: {cg}"
    );
    assert_eq!(
        cg["empty_state"], "not_scanned",
        "empty + no job ⇒ the pane names 'not scanned yet', not a blank board: {cg}"
    );
    // The classifier produces the LAW-2 message (the string the pane renders).
    assert!(
        EmptyState::NotScanned
            .message(REPO)
            .unwrap()
            .contains("not scanned yet")
    );

    // (2) EMPTY + an ACTIVE populate for this workspace ⇒ the SAME oracle flips to
    //     "in_route" ("⏳ in route for populate…"). Prove the empty-state is
    //     reasoned, not constant.
    repo_pane::publish_active_jobs(&[populate_job(WS, status::RUNNING)]);
    // The app's OWN jobs poller (`nornir_tab::poll_active_jobs`, driven every
    // frame from `draw_ui`) is the REAL wiring that feeds `repo_pane`'s snapshot
    // in production: it republishes from the local `jobs.redb` every ~1.5s. Seed
    // the running populate into that store too, so when the poller re-fires during
    // the (slow, real-I/O) warehouse seed below it re-publishes the job as ACTIVE
    // instead of clobbering the in-memory snapshot with an empty local read (an
    // empty `jobs.redb` lists `Ok(vec![])`, not an error). Relying on
    // `publish_active_jobs` alone persisting was a mirage — the poller overwrites
    // it — which made the auto-refresh step flake on a slow multi-repo disk.
    {
        let store = nornir::jobs::JobStore::open(&wh_root).expect("open jobs store");
        store
            .submit(&populate_job(WS, status::RUNNING))
            .expect("seed running populate job");
    }
    for _ in 0..2 {
        h.run_ok();
    }
    let cg = h.state().state_json()["callgraph"].clone();
    assert_eq!(
        cg["empty_state"], "in_route",
        "empty + active populate ⇒ '⏳ in route for populate…': {cg}"
    );
    assert!(
        EmptyState::InRoute
            .message(REPO)
            .unwrap()
            .contains("in route for populate")
    );

    nornir_testmatrix::functional_status(
        "viz-repo-pane",
        "callgraph_empty_state_names_reason",
        cg["empty_state"] == "in_route",
        "not_scanned→in_route, reasoned (never a silent blank board)",
    );

    repo_pane::clear_active_jobs_for_test();
    let _ = std::fs::remove_dir_all(&dir);
}

/// The pure classifier + throttle contract (no warehouse) — the shared helper's
/// API the panes call, swept across its outcome classes (LAW 3 in miniature).
#[test]
fn shared_helper_classifier_and_throttle_contract() {
    use std::time::{Duration, Instant};
    let _jobs = ACTIVE_JOBS.lock().unwrap_or_else(|e| e.into_inner());
    repo_pane::clear_active_jobs_for_test();

    // classify: populated short-circuits regardless of jobs.
    assert_eq!(
        repo_pane::classify_empty(false, WS, REPO),
        EmptyState::Populated
    );
    // empty + no job ⇒ not_scanned.
    assert_eq!(
        repo_pane::classify_empty(true, WS, REPO),
        EmptyState::NotScanned
    );
    // empty + a whole-workspace populate ⇒ in_route for every member.
    repo_pane::publish_active_jobs(&[populate_job(WS, status::RUNNING)]);
    assert_eq!(
        repo_pane::classify_empty(true, WS, REPO),
        EmptyState::InRoute
    );
    assert_eq!(
        repo_pane::classify_empty(true, WS, "other-member"),
        EmptyState::InRoute
    );
    // a DONE populate is not active ⇒ back to not_scanned.
    repo_pane::publish_active_jobs(&[populate_job(WS, status::DONE)]);
    assert_eq!(
        repo_pane::classify_empty(true, WS, REPO),
        EmptyState::NotScanned
    );

    // throttle: never-fetched fetches; populated latches; empty+job refetches.
    assert!(repo_pane::should_refetch(false, None, false, false));
    assert!(!repo_pane::should_refetch(
        true,
        Some(Instant::now()),
        false,
        true
    ));
    assert!(repo_pane::should_refetch(
        true,
        Some(Instant::now()),
        true,
        true
    ));
    assert!(!repo_pane::should_refetch(
        true,
        Some(Instant::now()),
        true,
        false
    ));
    let old = Instant::now() - (repo_pane::REFETCH_EVERY + Duration::from_millis(50));
    assert!(repo_pane::should_refetch(true, Some(old), true, false));

    repo_pane::clear_active_jobs_for_test();
}