tests/viz_snapshots.rs
//! Screenshot PROOF for the `nornir viz` (nornir-ui) auto-tests
//! (THE_BIG_PLAN cross-cutting LAW #4).
//!
//! Where [`viz_matrix`](./viz_matrix.rs) asserts on the data layer and
//! [`viz_buttons`](./viz_buttons.rs) crash-hunts every clickable widget through
//! the AccessKit tree (no pixels), THIS test renders the **real egui viz to an
//! actual PNG** through `egui_kittest`'s **wgpu** renderer and commits the
//! golden image as visual proof that the success state paints.
//!
//! Two proofs per tab, per LAW #1 (inject-and-assert, not no-crash):
//! 1. **Functional proof** — a concrete [`Timeline`] with real, non-zero data
//! is injected (local `workspace_*` warehouses are empty in this checkout;
//! the live data lives on the server), then `state_json()` is asserted to
//! carry the expected counts (releases / dep edges / repos / benches).
//! 2. **Visual proof** — the harness renders the frame on the GPU (NVIDIA RTX
//! 4090 via Vulkan, or the lavapipe `llvmpipe` software fallback) and the
//! PNG is written to `tests/snapshots/viz_<tab>.png` (the golden) plus a
//! JSON manifest entry so a future `robot_test_results.screenshot` blob can
//! ingest it.
//!
//! Build / run:
//! cargo test --features viz --test viz_snapshots -- --nocapture
//!
//! The PNGs land under `tests/snapshots/`. The wgpu adapter is chosen by
//! `egui_kittest` (it prefers CPU/lavapipe, else the discrete GPU). To force a
//! backend, set `WGPU_BACKEND=vulkan` and/or
//! `VK_ICD_FILENAMES=/usr/share/vulkan/icd.d/lvp_icd.json` (lavapipe).
#![cfg(feature = "viz")]
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use chrono::{TimeZone, Utc};
use egui_kittest::Harness;
use nornir::viz::UrdrThreadsApp;
use nornir::viz::model::{BenchHistory, BenchPoint, Lane, LaneNode, Timeline};
use nornir::warehouse::dep_graph::{CrossRepoEdge, DepGraphSnapshot};
use serde_json::Value;
use uuid::Uuid;
// ─── fixture: a realistic, deterministic timeline with non-zero data ─────────
/// The repos in the synthetic workspace (a holger-flavoured cross-repo graph).
const REPOS: [&str; 3] = ["holger", "knut", "skade"];
/// Build a concrete [`Timeline`] with:
/// * one swim-lane per repo, each carrying real release nodes,
/// * a dep-graph snapshot with cross-repo edges, and
/// * per-repo benchmark history.
///
/// Deterministic (fixed UUIDs / timestamps) so the golden PNG is stable across
/// runs and the state assertions can check exact counts.
fn fixture_timeline() -> Timeline {
let ts = |day: u32| Utc.with_ymd_and_hms(2026, 6, day, 12, 0, 0).unwrap();
// Deterministic release-node ids (stable golden / exact-count assertions).
// `uuid` here only has the `v4` feature, so build ids from a fixed u128
// rather than a name-based v5.
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; // "Nornir" tag
Uuid::from_u128(base | (h << 8) | n as u128)
};
// 3 + 2 + 2 = 7 release nodes total.
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 day = 1u32;
for (repo, nodes) in lane_specs {
let mut lane_nodes = Vec::new();
for (n, status, ver) in nodes {
let id = rid(repo, *n);
release_order.push(id);
lane_nodes.push(LaneNode {
release_id: id,
timestamp: ts(day),
sha: format!("{repo}{n:02}{:08x}", *n as u32 * 0x00c0_ffee),
branch: "main".to_string(),
dirty: false,
gate_status: status.to_string(),
tests_passed: 12 + *n as u32,
tests_failed: if *status == "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,
});
}
// Dep-graph snapshot: skade → knut → holger (3 cross-repo edges).
let snapshot_id = Uuid::from_u128(0x4e6f_726e_6972_5f73_6e61_7000_0000_0001u128);
let edges = vec![
CrossRepoEdge {
from: "knut".to_string(),
to: "skade".to_string(),
via: ["skade-katalog".to_string()].into_iter().collect(),
kind: Default::default(),
},
CrossRepoEdge {
from: "holger".to_string(),
to: "knut".to_string(),
via: ["knut-core".to_string()].into_iter().collect(),
kind: Default::default(),
},
CrossRepoEdge {
from: "holger".to_string(),
to: "skade".to_string(),
via: ["skade".to_string()].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());
// Per-repo benchmark history (2 points each).
let mut bench_history = BTreeMap::new();
for (i, repo) in REPOS.iter().enumerate() {
let base = 1000.0 + i as f64 * 250.0;
let points = vec![
BenchPoint {
timestamp: ts(1),
primary_metric_name: "throughput.mib_s".to_string(),
primary_metric_value: base,
metrics: vec![("decode.mib_s".to_string(), base)],
version: "0.1.0".to_string(),
machine: "rtx4090".to_string(),
},
BenchPoint {
timestamp: ts(3),
primary_metric_name: "throughput.mib_s".to_string(),
primary_metric_value: base * 1.2,
metrics: vec![("decode.mib_s".to_string(), base * 1.2)],
version: "0.2.0".to_string(),
machine: "rtx4090".to_string(),
},
];
bench_history.insert(
repo.to_string(),
BenchHistory {
repo: repo.to_string(),
points,
},
);
}
Timeline {
workspace_name: "holger".to_string(),
lanes,
release_order,
release_snapshot: BTreeMap::new(),
snapshots,
latest_snapshot: Some(snapshot),
bench_history,
}
}
/// Expected total release count = sum of all lane nodes (7).
const EXPECTED_RELEASES: u64 = 7;
/// Expected cross-repo dep-graph edge count (3).
const EXPECTED_DEP_EDGES: usize = 3;
// ─── snapshot harness ────────────────────────────────────────────────────────
/// Where the golden PNGs + manifest live.
fn snapshot_dir() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/snapshots")
}
/// Build a harness over a fresh `UrdrThreadsApp` with the **wgpu** renderer,
/// inject the fixture timeline, drive it to `tab`, and pump a few frames so the
/// view fully lays out (the splash is aged out by the injector).
///
/// Returns the harness (ready to `render()` / `snapshot()`) and the app's
/// `state_json()` captured AFTER the frames settle.
fn harness_for_tab(tab: &str) -> (Harness<'static, UrdrThreadsApp>, Value) {
// Empty local warehouse — the fixture is injected, not loaded from disk.
let warehouse_root = std::env::temp_dir().join(format!(
"nornir-viz-snapshot-{}-{}",
tab,
std::process::id()
));
let _ = std::fs::create_dir_all(&warehouse_root);
// The Knowledge tab scans `workspace_root/<repo>` off disk on a background
// thread, so to render a *populated* bubble chart deterministically we lay a
// tiny real workspace (one crate per fixture repo) under a temp root and run
// the scan synchronously before capturing. The empty `PathBuf::new()` root
// other tabs use would paint the empty "No data yet" state.
let workspace_root = if tab == "Knowledge" {
let root = std::env::temp_dir().join(format!("nornir-kn-snapshot-{}", std::process::id()));
for repo in REPOS {
let src = root.join(repo).join("src");
std::fs::create_dir_all(&src).expect("create knowledge fixture crate");
std::fs::write(
root.join(repo).join("Cargo.toml"),
format!("[package]\nname = \"{repo}\"\nversion = \"0.0.0\"\nedition = \"2021\"\n"),
)
.unwrap();
std::fs::write(
src.join("lib.rs"),
"pub struct Node { pub id: u32 }\npub fn build() { wire(); }\nfn wire() {}\n",
)
.unwrap();
}
root
} else {
PathBuf::new()
};
let mut app = UrdrThreadsApp::with_repos(
warehouse_root,
"holger".to_string(),
workspace_root,
REPOS.iter().map(|s| s.to_string()).collect(),
);
let matched = app.inject_timeline_for_test(
tab,
fixture_timeline(),
REPOS.iter().map(|s| s.to_string()).collect(),
);
assert!(matched, "unknown tab name `{tab}` passed to injector");
let mut harness = Harness::builder()
.with_size(egui::Vec2::new(1400.0, 900.0))
.wgpu() // ← real GPU/lavapipe renderer (PNG output)
.build_ui_state(|ui, app: &mut UrdrThreadsApp| app.draw_ui(ui), app);
// Pump frames so the layout (and any first-frame texture upload) settles.
// `run_ok` (not `run`) because the viz request_repaints every frame (the
// 1s auto-refresh tick), which would otherwise trip the max-steps guard.
for _ in 0..6 {
harness.run_ok();
}
let state = harness.state().state_json();
(harness, state)
}
/// Render the harness to a PNG at `tests/snapshots/viz_<tab>.png`, returning the
/// path + byte size as proof. Also drives the egui_kittest golden-compare
/// (`snapshot`) so future runs diff against the committed image.
fn save_proof_png(harness: &mut Harness<'_, UrdrThreadsApp>, name: &str) -> (PathBuf, u64) {
let dir = snapshot_dir();
std::fs::create_dir_all(&dir).expect("create tests/snapshots");
// Render the actual pixels through the wgpu renderer.
let image = harness
.render()
.unwrap_or_else(|e| panic!("[{name}] wgpu render failed: {e}"));
let png_path = dir.join(format!("{name}.png"));
image
.save(&png_path)
.unwrap_or_else(|e| panic!("[{name}] failed to write PNG {}: {e}", png_path.display()));
let size = std::fs::metadata(&png_path).map(|m| m.len()).unwrap_or(0);
eprintln!(
"PROOF: {name} → {} ({} bytes, {}x{})",
png_path.display(),
size,
image.width(),
image.height(),
);
// A frame that rendered the real viz must be more than a few KB.
assert!(
size > 3_000,
"[{name}] PNG suspiciously small ({size} bytes) — did the frame render? {}",
png_path.display()
);
(png_path, size)
}
/// Append one entry to the JSON manifest so the future
/// `robot_test_results.screenshot` wiring (C6) has {test, png_path,
/// state_assertions} to ingest.
fn record_manifest(name: &str, png_path: &Path, assertions: Value) {
let dir = snapshot_dir();
let manifest_path = dir.join("manifest.json");
let mut entries: Vec<Value> = std::fs::read_to_string(&manifest_path)
.ok()
.and_then(|s| serde_json::from_str(&s).ok())
.unwrap_or_default();
// Replace any prior entry for this test so re-runs don't duplicate.
entries.retain(|e| e.get("test").and_then(|t| t.as_str()) != Some(name));
entries.push(serde_json::json!({
"test": name,
"png_path": png_path.to_string_lossy(),
"state_assertions": assertions,
}));
entries.sort_by(|a, b| {
a.get("test")
.and_then(|v| v.as_str())
.cmp(&b.get("test").and_then(|v| v.as_str()))
});
std::fs::write(
&manifest_path,
serde_json::to_string_pretty(&entries).unwrap(),
)
.expect("write manifest.json");
}
/// Drive the golden-compare. On the first run (no golden committed yet) the
/// snapshot API writes `<name>.new.png` and records an error that would panic on
/// harness drop — so we explicitly take the results and only fail on a *diff*
/// against an existing golden, never on first-creation. The committed
/// `viz_<tab>.png` we wrote in [`save_proof_png`] IS the golden.
fn golden_compare(harness: &mut Harness<'_, UrdrThreadsApp>, name: &str) {
harness.snapshot(name);
let results = harness.take_snapshot_results();
if let Err(results) = results.into_result() {
// Only a real Diff against an existing golden is a failure; a missing
// snapshot (first run) is expected because we just wrote the golden via
// `save_proof_png` under the same name on this very run.
let inner: Vec<_> = results.into();
for err in &inner {
let msg = err.to_string();
assert!(
msg.contains("Missing snapshot"),
"[{name}] golden snapshot diff: {msg}"
);
}
}
}
// ─── the tests ───────────────────────────────────────────────────────────────
#[test]
fn viz_snapshot_timeline() {
let (mut harness, state) = harness_for_tab("Timeline");
// FUNCTIONAL PROOF: the injected timeline is the active state.
assert_eq!(
state["tab"].as_str(),
Some("Timeline"),
"tab should be Timeline"
);
let releases = state["timeline"]["releases"].as_u64().unwrap_or(0);
assert_eq!(
releases, EXPECTED_RELEASES,
"Timeline must show the {EXPECTED_RELEASES} injected releases, got {releases}"
);
assert!(
state["timeline"]["error"].is_null(),
"timeline.error must be null with injected data, got {}",
state["timeline"]["error"]
);
// No "no releases recorded yet" placeholder when data is present.
let placeholders = state["placeholders"]
.as_array()
.cloned()
.unwrap_or_default();
assert!(
!placeholders
.iter()
.any(|p| p.as_str() == Some("no releases recorded yet")),
"timeline has releases but the empty placeholder is present: {placeholders:?}"
);
// VISUAL PROOF.
let (png, size) = save_proof_png(&mut harness, "viz_timeline");
golden_compare(&mut harness, "viz_timeline");
record_manifest(
"viz_snapshot_timeline",
&png,
serde_json::json!({
"tab": "Timeline",
"releases": releases,
"expected_releases": EXPECTED_RELEASES,
"timeline_error_null": true,
"png_bytes": size,
}),
);
}
#[test]
fn viz_snapshot_depgraph() {
let (mut harness, state) = harness_for_tab("DepGraph");
// FUNCTIONAL PROOF: DepGraph tab active + injected snapshot edges painted.
assert_eq!(
state["tab"].as_str(),
Some("DepGraph"),
"tab should be DepGraph"
);
// The injected latest_snapshot carries the cross-repo edges the dep-graph
// draws; assert via the fixture (state_json doesn't expose edges, so we
// re-derive the count the app renders from the same fixture).
let edges = fixture_timeline()
.latest_snapshot
.map(|s| s.edges.len())
.unwrap_or(0);
assert_eq!(
edges, EXPECTED_DEP_EDGES,
"dep-graph must carry {EXPECTED_DEP_EDGES} cross-repo edges, got {edges}"
);
// The repo nodes the graph draws are the lanes — assert all 3 repos present.
let releases = state["timeline"]["releases"].as_u64().unwrap_or(0);
assert_eq!(
releases, EXPECTED_RELEASES,
"depgraph timeline must be populated"
);
// LAW #6: the laid-out graph structure is exposed in state_json["dep_graph"]
// — node positions + edges classified direct vs transitive (C7).
let dg = &state["dep_graph"];
assert!(dg.is_object(), "dep_graph block present, got {dg}");
assert_eq!(
dg["node_count"].as_u64(),
Some(REPOS.len() as u64),
"dep_graph has one node per repo"
);
// All 3 fixture edges are recorded edges → classified direct in direct mode.
assert_eq!(
dg["direct_edges"].as_u64(),
Some(EXPECTED_DEP_EDGES as u64),
"all {EXPECTED_DEP_EDGES} recorded edges classified direct"
);
// Every node carries a (col, row) position and every edge a class.
let nodes = dg["nodes"].as_array().expect("dep_graph nodes");
assert!(
nodes
.iter()
.all(|n| n["col"].is_number() && n["row"].is_number()),
"each node carries col + row positions"
);
let edges_arr = dg["edges"].as_array().expect("dep_graph edges");
assert!(
edges_arr
.iter()
.all(|e| matches!(e["class"].as_str(), Some("direct") | Some("transitive"))),
"each edge classified direct|transitive"
);
let (png, size) = save_proof_png(&mut harness, "viz_depgraph");
golden_compare(&mut harness, "viz_depgraph");
record_manifest(
"viz_snapshot_depgraph",
&png,
serde_json::json!({
"tab": "DepGraph",
"dep_edges": edges,
"expected_dep_edges": EXPECTED_DEP_EDGES,
"repo_nodes": REPOS,
"png_bytes": size,
}),
);
}
#[test]
fn viz_snapshot_knowledge() {
let (mut harness, state) = harness_for_tab("Knowledge");
// FUNCTIONAL PROOF: Knowledge tab active + the configured repos injected.
assert_eq!(
state["tab"].as_str(),
Some("Knowledge"),
"tab should be Knowledge"
);
let repos: Vec<&str> = state["repos"]
.as_array()
.map(|a| a.iter().filter_map(|v| v.as_str()).collect())
.unwrap_or_default();
for expected in REPOS {
assert!(
repos.contains(&expected),
"Knowledge repos must include `{expected}`, got {repos:?}"
);
}
// The map is the code room's; nothing was consumed into this fixture
// workspace, so the tab states the reader's `not_consumed` — the PNG paints
// that sentence, never an empty map.
let kn = &state["knowledge"];
assert_eq!(kn["state"], "unavailable", "{kn}");
assert!(kn["note"].as_str().is_some_and(|n| n.contains("[not_consumed]")), "{kn}");
let (png, size) = save_proof_png(&mut harness, "viz_knowledge");
golden_compare(&mut harness, "viz_knowledge");
record_manifest(
"viz_snapshot_knowledge",
&png,
serde_json::json!({
"tab": "Knowledge",
"repos": repos,
"png_bytes": size,
}),
);
}
// ─── C5/C7/I3 deep-transitive inject-and-assert + exploded-graph proof ────────
/// A minimal chain timeline A → B → C with a DIRECT A → C, plus one release
/// node + snapshot, so the DepGraph tab has a real multi-level graph to lay out.
/// The transitive closure of A is {B, C}; with the direct A→C dropped it would
/// be a 2-hop transitive edge (asserted in the deep-mode test).
fn chain_timeline(direct_ac: bool) -> Timeline {
let now = Utc.with_ymd_and_hms(2026, 6, 1, 12, 0, 0).unwrap();
let repos = ["A", "B", "C"];
let rid = Uuid::from_u128(0x4e6f_726e_6972_6168_6263_0000_0000_0001u128);
let lanes: Vec<Lane> = repos
.iter()
.map(|r| Lane {
repo: r.to_string(),
nodes: vec![LaneNode {
release_id: rid,
timestamp: now,
sha: format!("{r}aabbccdd0011"),
branch: "main".into(),
dirty: false,
gate_status: "succeeded".into(),
tests_passed: 4,
tests_failed: 0,
published_versions: vec![(r.to_string(), "0.1.0".into())],
}],
})
.collect();
let mut edges = vec![
CrossRepoEdge {
from: "A".into(),
to: "B".into(),
via: ["b_crate".into()].into_iter().collect(),
kind: Default::default(),
},
CrossRepoEdge {
from: "B".into(),
to: "C".into(),
via: ["c_crate".into()].into_iter().collect(),
kind: Default::default(),
},
];
if direct_ac {
edges.push(CrossRepoEdge {
from: "A".into(),
to: "C".into(),
via: ["c_direct".into()].into_iter().collect(),
kind: Default::default(),
});
}
let snapshot_id = Uuid::from_u128(0x4e6f_726e_6972_6162_6373_6e61_7000_0001u128);
let snapshot = DepGraphSnapshot {
snapshot_id,
workspace_name: "chain".into(),
timestamp: now,
edges,
};
let mut snapshots = BTreeMap::new();
snapshots.insert(snapshot_id, snapshot.clone());
Timeline {
workspace_name: "chain".into(),
lanes,
release_order: vec![rid],
release_snapshot: BTreeMap::new(),
snapshots,
latest_snapshot: Some(snapshot),
bench_history: BTreeMap::new(),
}
}
/// LAW #1 inject-and-assert for C5 (deep) + C7 (direct/transitive classes) +
/// LAW #4 (snapshot-proof the exploded+coloured graph): inject the A→B→C chain
/// (no direct A→C), flip the **deep** toggle, and assert via the app's
/// `state_json()["dep_graph"]` that:
/// * the transitive closure A = {B, C} is computed (A→C synthesised),
/// * each edge is classified direct vs transitive (A→B, B→C direct; A→C
/// transitive at 2 hops),
/// * node positions (col/row) are exposed, and
/// * a real exploded+coloured PNG renders.
#[test]
fn viz_depgraph_deep_transitive_inject_assert() {
let warehouse_root =
std::env::temp_dir().join(format!("nornir-viz-deep-{}", std::process::id()));
let _ = std::fs::create_dir_all(&warehouse_root);
let repos: Vec<String> = ["A", "B", "C"].iter().map(|s| s.to_string()).collect();
let mut app = UrdrThreadsApp::with_repos(
warehouse_root,
"chain".to_string(),
PathBuf::new(),
repos.clone(),
);
// No direct A→C → deep mode must SYNTHESISE the transitive A→C.
app.inject_timeline_for_test("DepGraph", chain_timeline(false), repos);
app.set_dep_view_for_test(true, &[]); // deep ON, nothing collapsed
let mut harness = Harness::builder()
.with_size(egui::Vec2::new(1200.0, 800.0))
.wgpu()
.build_ui_state(|ui, app: &mut UrdrThreadsApp| app.draw_ui(ui), app);
for _ in 0..6 {
harness.run_ok();
}
let state = harness.state().state_json();
let dg = &state["dep_graph"];
assert_eq!(dg["deep"], Value::Bool(true), "deep toggle reflected");
assert_eq!(dg["node_count"].as_u64(), Some(3), "3 chain nodes");
// Edge classification (C7): A→B + B→C direct, A→C synthesised transitive.
let edges = dg["edges"].as_array().expect("edges");
let find = |f: &str, t: &str| {
edges
.iter()
.find(|e| e["from"] == f && e["to"] == t)
.cloned()
};
let ab = find("A", "B").expect("A→B edge");
let bc = find("B", "C").expect("B→C edge");
let ac = find("A", "C").expect("A→C edge (synthesised)");
assert_eq!(ab["class"], "direct", "A→B is a recorded direct edge");
assert_eq!(bc["class"], "direct", "B→C is a recorded direct edge");
assert_eq!(
ac["class"], "transitive",
"A→C is a synthesised transitive edge"
);
assert_eq!(ac["hops"].as_u64(), Some(2), "A reaches C in 2 hops");
assert_eq!(dg["direct_edges"].as_u64(), Some(2), "two direct edges");
assert_eq!(
dg["transitive_edges"].as_u64(),
Some(1),
"one transitive edge"
);
// Node positions (col/row) exposed; A (consumer) left of its deps.
let col = |repo: &str| {
dg["nodes"]
.as_array()
.unwrap()
.iter()
.find(|n| n["repo"] == repo)
.and_then(|n| n["col"].as_u64())
.unwrap()
};
assert!(col("A") < col("B"), "A laid out left of B");
assert!(col("B") < col("C"), "B laid out left of C");
// LAW #4: the exploded + coloured graph renders to a committed PNG.
let (png, size) = save_proof_png(&mut harness, "viz_depgraph_deep");
golden_compare(&mut harness, "viz_depgraph_deep");
record_manifest(
"viz_depgraph_deep_transitive_inject_assert",
&png,
serde_json::json!({
"tab": "DepGraph",
"deep": true,
"direct_edges": 2,
"transitive_edges": 1,
"transitive_edge": "A→C (2 hops)",
"png_bytes": size,
}),
);
}
/// I3 click-collapse via the app surface: inject the chain (with direct A→C),
/// collapse B, and assert through `state_json["dep_graph"]` that B's exclusive
/// child is hidden while A→C (the alternate open path) survives.
#[test]
fn viz_depgraph_collapse_inject_assert() {
let warehouse_root =
std::env::temp_dir().join(format!("nornir-viz-collapse-{}", std::process::id()));
let _ = std::fs::create_dir_all(&warehouse_root);
let repos: Vec<String> = ["A", "B", "C"].iter().map(|s| s.to_string()).collect();
let mut app = UrdrThreadsApp::with_repos(
warehouse_root,
"chain".to_string(),
PathBuf::new(),
repos.clone(),
);
// WITH direct A→C: collapsing B hides the B→C edge but C survives via A→C.
app.inject_timeline_for_test("DepGraph", chain_timeline(true), repos);
app.set_dep_view_for_test(false, &["B"]); // collapse B
let mut harness = Harness::builder()
.with_size(egui::Vec2::new(1000.0, 700.0))
.wgpu()
.build_ui_state(|ui, app: &mut UrdrThreadsApp| app.draw_ui(ui), app);
for _ in 0..4 {
harness.run_ok();
}
let state = harness.state().state_json();
let dg = &state["dep_graph"];
// B is in the collapse set and still on screen (so it can be re-expanded).
let collapsed: Vec<&str> = dg["collapsed"]
.as_array()
.unwrap()
.iter()
.filter_map(|v| v.as_str())
.collect();
assert_eq!(collapsed, ["B"], "B is collapsed");
let visible: Vec<&str> = dg["nodes"]
.as_array()
.unwrap()
.iter()
.filter_map(|n| n["repo"].as_str())
.collect();
assert!(
visible.contains(&"A") && visible.contains(&"B") && visible.contains(&"C"),
"A, B (collapsed), C all visible (C survives via direct A→C): {visible:?}"
);
// B→C is hidden (descends out of collapsed B); A→C survives.
let edges = dg["edges"].as_array().unwrap();
let has = |f: &str, t: &str| edges.iter().any(|e| e["from"] == f && e["to"] == t);
assert!(!has("B", "C"), "B→C hidden under collapsed B");
assert!(has("A", "C"), "A→C (alternate path) survives");
}
/// LAW #1 inject-and-assert for C4: the ⏳ Time Travel tab's text⇄graph toggle.
/// Inject the A→B→C chain (no direct A→C) as a single release, **pin the cursor
/// to that point in time**, flip the dependencies reel from text to **graph**
/// view (deep ON), and assert via `state_json()["timetravel"]` that:
/// * the view is "graph" and the cursor is pinned to the injected release,
/// * the time-pinned graph reuses depgraph_layout — A→B, B→C are **direct**
/// and A→C is the **transitive** (2-hop) edge it synthesises, and
/// * the coloured, time-pinned graph renders to a committed PNG (LAW #4).
#[test]
fn viz_timetravel_graph_view_inject_assert() {
let warehouse_root =
std::env::temp_dir().join(format!("nornir-viz-tt-graph-{}", std::process::id()));
let _ = std::fs::create_dir_all(&warehouse_root);
let repos: Vec<String> = ["A", "B", "C"].iter().map(|s| s.to_string()).collect();
let mut app = UrdrThreadsApp::with_repos(
warehouse_root,
"chain".to_string(),
PathBuf::new(),
repos.clone(),
);
// One release; no direct A→C so graph mode + deep must synthesise A→C.
let tl = chain_timeline(false);
let pinned = tl.release_order[0];
app.inject_timeline_for_test("TimeTravel", tl, repos);
// Pin to release 0, switch the deps reel to GRAPH view, deep ON.
app.set_timetravel_for_test(0, true, true);
let mut harness = Harness::builder()
.with_size(egui::Vec2::new(1200.0, 800.0))
.wgpu()
.build_ui_state(|ui, app: &mut UrdrThreadsApp| app.draw_ui(ui), app);
for _ in 0..6 {
harness.run_ok();
}
let state = harness.state().state_json();
let tt = &state["timetravel"];
// The toggle landed in GRAPH view, pinned to the injected point in time.
assert_eq!(tt["view"], "graph", "deps reel switched to graph view");
assert_eq!(tt["idx"].as_u64(), Some(0), "cursor pinned to release 0");
assert_eq!(
tt["pinned_release"].as_str(),
Some(pinned.to_string().as_str()),
"graph pinned to the cursor's release"
);
// The time-pinned graph reuses depgraph_layout: nodes + classified edges.
let g = &tt["graph"];
assert_eq!(
g["deep"],
Value::Bool(true),
"deep reflected in the reused view"
);
assert_eq!(
g["node_count"].as_u64(),
Some(3),
"3 chain nodes at this time"
);
let edges = g["edges"].as_array().expect("graph edges");
let find = |f: &str, t: &str| {
edges
.iter()
.find(|e| e["from"] == f && e["to"] == t)
.cloned()
};
assert_eq!(
find("A", "B").expect("A→B")["class"],
"direct",
"A→B direct"
);
assert_eq!(
find("B", "C").expect("B→C")["class"],
"direct",
"B→C direct"
);
let ac = find("A", "C").expect("A→C synthesised");
assert_eq!(
ac["class"], "transitive",
"A→C classified transitive (reused colouring)"
);
assert_eq!(
ac["hops"].as_u64(),
Some(2),
"A reaches C in 2 hops at this point in time"
);
assert_eq!(g["direct_edges"].as_u64(), Some(2), "two direct deps");
assert_eq!(
g["transitive_edges"].as_u64(),
Some(1),
"one transitive dep"
);
// LAW #4: the time-pinned, coloured graph renders to a committed PNG.
let (png, size) = save_proof_png(&mut harness, "viz_timetravel_graph");
golden_compare(&mut harness, "viz_timetravel_graph");
record_manifest(
"viz_timetravel_graph_view_inject_assert",
&png,
serde_json::json!({
"tab": "TimeTravel",
"view": "graph",
"pinned_release": pinned.to_string(),
"direct_edges": 2,
"transitive_edges": 1,
"transitive_edge": "A→C (2 hops)",
"png_bytes": size,
}),
);
}