tests/funnel_viz.rs
//! E1 — funnel **viz** demo/nuke/confirm-guard test (inject-and-assert).
//!
//! Builds a LOCAL `UrdrThreadsApp` on a tempdir warehouse and drives the Funnel
//! tab's demo controls through the SAME code paths the buttons invoke:
//!
//! * `🌱 Run demo` → `state_json()["funnel"]["plans"]` reflects the injected
//! DAG (plan present, nodes/edges > 0, a root is Ready),
//! * confirm-guard: ONE click arms `confirm_pending` and the repos are STILL
//! on disk (nothing deleted yet),
//! * the confirming second click → disk repos gone, but the funnel plan
//! PERSISTS in `state_json` (nuke is disk-only; the event log is kept).
//!
//! The warehouse is shaped `<tmp>/ws/.nornir/warehouse` so the demo's git-root
//! resolution lands the fake repos at `<tmp>/git/…` — inside the tempdir, so
//! the test can verify their creation/removal precisely.
#![cfg(feature = "viz")]
use std::path::PathBuf;
use nornir::viz::UrdrThreadsApp;
use serde_json::Value;
fn fake_repos_present(git_root: &PathBuf, size: usize) -> bool {
if size == 1 {
git_root
.join("funnel_fake")
.join("funnel1_fake_project")
.exists()
} else {
(1..=size).all(|i| {
git_root
.join(format!("funnel{size}_fake_projects"))
.join(format!("funnel_fake_{i}"))
.exists()
})
}
}
#[test]
fn viz_demo_then_nuke_keeps_funnel_plans() {
// The viz durable action-log sink would hold the catalog's exclusive redb
// lock for the app's lifetime, so the demo's writable `Store::open` hits
// "Database already open". Free it (the documented escape in src/viz/app.rs);
// the file/stderr action-log channels stay live. (See the same fix in
// tests/funnel_planning.rs and tests/funnel_3d.rs.)
// TODO: Audit that the environment access only happens in single-threaded code.
unsafe { std::env::set_var("NORNIR_VIZ_NO_DURABLE_ACTIONLOG", "1") };
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 git_root = tmp.path().join("git");
let mut app = UrdrThreadsApp::with_repos(
warehouse.clone(),
"viz-demo-test".to_string(),
ws.clone(),
vec![],
);
// --- 🌱 Run demo (size 3) ---
let size = 3;
app.funnel_tab_for_test()
.run_demo_for_test(size)
.expect("run demo");
assert!(
fake_repos_present(&git_root, size),
"demo must create {size} fake repos under {}",
git_root.display()
);
let s = app.state_json();
let funnel = &s["funnel"];
let plans = funnel["plans"]
.as_array()
.expect("funnel.plans is an array");
assert_eq!(plans.len(), 1, "exactly one demo plan");
let plan = &plans[0];
let nodes = plan["nodes"].as_u64().unwrap();
let edges = plan["edges"].as_u64().unwrap();
let ready = plan["ready_count"].as_u64().unwrap();
assert_eq!(nodes, size as u64, "one node per fake repo");
assert!(edges > 0, "a layered repo DAG must have dep edges");
assert!(ready >= 1, "at least one root node is Ready");
assert_eq!(
funnel["demo"]["available"].as_bool(),
Some(true),
"demo controls available in local mode"
);
// --- confirm-guard: first click arms, repos still present ---
app.funnel_tab_for_test().arm_nuke_for_test();
assert_eq!(
app.state_json()["funnel"]["demo"]["confirm_pending"].as_bool(),
Some(true),
"one click arms the confirm guard"
);
assert!(
fake_repos_present(&git_root, size),
"arming the guard must NOT delete anything"
);
// --- confirming second click: repos gone, plans persist ---
let removed = app
.funnel_tab_for_test()
.confirm_nuke_for_test()
.expect("nuke");
assert_eq!(removed.len(), size, "nuke removed all {size} fake repos");
assert!(
!fake_repos_present(&git_root, size),
"fake repos must be gone after confirmed nuke"
);
assert_eq!(
app.state_json()["funnel"]["demo"]["confirm_pending"].as_bool(),
Some(false),
"confirm guard resets after nuke"
);
// Reload the funnel from the warehouse: the plan PERSISTS (disk-only nuke).
app.funnel_tab_for_test().reload_for_test();
let s2: Value = app.state_json();
let plans2 = s2["funnel"]["plans"].as_array().expect("plans array");
assert_eq!(
plans2.len(),
1,
"funnel plan must survive a disk nuke (warehouse data kept)"
);
}