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();
}