facett-pipeline/tests/callchain_matrix.rs
//! **Render call-chain coverage matrix** for `facett-pipeline` — the DAG skin
//! (toposorted dataflow pipeline + status glow + flowing edges + camera). LAW:
//! every fn in the input→pixel chain TRACKED + VERIFIED, return-asserted where the
//! return proves it, EMIT only for the pixel stages.
//!
//! The pipeline DAG chain is:
//!
//! ```text
//! read stages+edges (PipelineNode / PipelineModel)
//! → toposort columns (DepGraphView::columns — deps left, dependents right)
//! → scene layout (PipelineView::layout_scene_centers — col×row grid)
//! → camera map (Navigable::to_screen via fit/zoom/pan)
//! → edge flow + arrow (paint_flow_edge: src→dst, phase = edge_phase)
//! → node box + status glow + rows + a11y button (paint_dag)
//! → click-select (name-keyed) + detail panel
//! → pixels (egui_kittest wgpu)
//! ```
//!
//! Non-pixel stages get INJECT-real-input → ASSERT-the-real-return (columns
//! monotone in toposort + cycle-safe; scene x non-decreasing with column, rows
//! separated; status→colour mapping; counts == model; fit/zoom/pan camera +
//! visible-node cull; selection name-keyed). The painted-pixel stage is the EMIT
//! non-blank proxy here + the wgpu robot leg in `robot_callchain.rs`.
//!
//! ~1150+ matrix cases: topology {chain,diamond,fanout,fanin,multistage,cycle} ×
//! size × canvas × status-mix {all-done,running,failed,mixed} fed a real DAG.
//!
//! Run: `cargo test -p facett-pipeline --test callchain_matrix`
//! Matrix rows:`cargo test -p facett-pipeline --features testmatrix --test callchain_matrix`
use egui::{Pos2, Rect, Vec2};
use facett_core::Facet;
use facett_pipeline::{PipelineNode, PipelineView, Status};
// ── matrix axes ───────────────────────────────────────────────────────────────
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Topo {
Chain, // 0←1←2←… (a linear pipeline)
Diamond, // src → {a,b} → sink
Fanout, // root → many leaves
Fanin, // many sources → one sink
Multistage, // layered stages, several per column
Cycle, // a deliberate cycle (toposort must be best-effort, not hang)
}
const TOPOS: &[Topo] = &[Topo::Chain, Topo::Diamond, Topo::Fanout, Topo::Fanin, Topo::Multistage, Topo::Cycle];
/// Stage counts.
const SIZES: &[usize] = &[2, 3, 4, 6, 8, 10, 12, 20];
/// Canvas sizes a host might hand the DAG pane.
const CANVASES: &[(f32, f32)] = &[
(300.0, 240.0),
(500.0, 360.0),
(800.0, 600.0),
(1280.0, 720.0),
(360.0, 900.0),
(1000.0, 300.0),
];
/// Status mixes — drives the status→colour mapping + glow path.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Mix {
AllDone,
AllRunning,
OneFailed,
Mixed,
}
const MIXES: &[Mix] = &[Mix::AllDone, Mix::AllRunning, Mix::OneFailed, Mix::Mixed];
fn topo_name(t: Topo) -> &'static str {
match t {
Topo::Chain => "Chain",
Topo::Diamond => "Diamond",
Topo::Fanout => "Fanout",
Topo::Fanin => "Fanin",
Topo::Multistage => "Multistage",
Topo::Cycle => "Cycle",
}
}
fn status_for(mix: Mix, i: usize, n: usize) -> Status {
match mix {
Mix::AllDone => Status::Done,
Mix::AllRunning => Status::Running,
Mix::OneFailed => {
if i == n / 2 {
Status::Failed
} else {
Status::Done
}
}
Mix::Mixed => match i % 4 {
0 => Status::Done,
1 => Status::Running,
2 => Status::Failed,
_ => Status::Idle,
},
}
}
/// Build a real pipeline DAG. Edge convention (from `PipelineView::new`): an edge
/// `(reads_idx, target_idx)` means `reads_idx` consumes `target_idx`, so the
/// toposort places `target` left of `reads`. Returns (nodes, edges, n).
fn build_dag(topo: Topo, n: usize, mix: Mix) -> (Vec<PipelineNode>, Vec<(usize, usize)>) {
let nodes: Vec<PipelineNode> = (0..n)
.map(|i| PipelineNode::new(format!("stage{i}"), status_for(mix, i, n)).with_rows((i as u64 + 1) * 1000))
.collect();
let mut edges = Vec::new();
match topo {
Topo::Chain => {
// stage i reads stage i-1: edge (i, i-1)
for i in 1..n {
edges.push((i, i - 1));
}
}
Topo::Diamond => {
// 0 = src; 1,2 read 0; 3 reads 1 and 2; remaining read 3 (a tail).
if n >= 4 {
edges.push((1, 0));
edges.push((2, 0));
edges.push((3, 1));
edges.push((3, 2));
for i in 4..n {
edges.push((i, 3));
}
} else {
for i in 1..n {
edges.push((i, 0));
}
}
}
Topo::Fanout => {
for i in 1..n {
edges.push((i, 0)); // every leaf reads root 0
}
}
Topo::Fanin => {
// last node reads all the rest
let sink = n - 1;
for i in 0..sink {
edges.push((sink, i));
}
}
Topo::Multistage => {
// 3 columns: split nodes across stages, each reads one in the prev third.
let third = (n / 3).max(1);
for i in third..n {
edges.push((i, i - third));
}
}
Topo::Cycle => {
// a chain plus a back-edge to make a cycle (toposort must be best-effort).
for i in 1..n {
edges.push((i, i - 1));
}
if n >= 2 {
edges.push((0, n - 1)); // back-edge → cycle
}
}
}
(nodes, edges)
}
// ── stage 1: toposort columns (DepGraphView::columns via PipelineView) ──────────
/// INJECT-ASSERT — the toposort's RETURN is correct: for every NON-cyclic edge
/// `(reads, target)` the target sits in a column strictly left of the reader
/// (`col[target] < col[reads]`); a cycle doesn't hang and still yields one finite
/// column per node. Read through the public `state_json["dag"]["columns"]` (== max
/// column + 1) plus the scene layout (which encodes columns in x). Walked over
/// topology × size × status-mix.
#[test]
fn toposort_columns_are_monotone_and_cycle_safe() {
let mut cases = 0usize;
let mut ok = 0usize;
for &topo in TOPOS {
for &n in SIZES {
for &mix in MIXES {
let (nodes, edges) = build_dag(topo, n, mix);
let view = PipelineView::from_nodes("p", nodes, edges.clone());
let scene = view.layout_scene_centers();
assert_eq!(scene.len(), n, "{} one scene centre per stage", topo_name(topo));
// columns reported, and within [1, n]
let ncols = view.state_json()["dag"]["columns"].as_u64().unwrap() as usize;
assert!(ncols >= 1 && ncols <= n, "{} ncols {ncols} in [1,{n}]", topo_name(topo));
// For acyclic topologies, every edge's target is left of (or level
// with, for the cycle case) its reader in scene-x.
if topo != Topo::Cycle {
for &(reads, target) in &edges {
assert!(
scene[target].x <= scene[reads].x + 0.01,
"{} target stage{target} not right of reader stage{reads} (x {} vs {})",
topo_name(topo),
scene[target].x,
scene[reads].x
);
}
}
// all scene coords finite
for p in &scene {
assert!(p.x.is_finite() && p.y.is_finite(), "scene finite");
}
cases += 1;
ok += 1;
}
}
}
assert!(cases > 150, "toposort sweep is broad, got {cases}");
#[cfg(feature = "testmatrix")]
facett_core::testmatrix::emit(
"facett-pipeline.columns",
"toposort_monotone_cycle_safe",
ok == cases,
&format!("{ok}/{cases} toposort cases (topo×size×mix)"),
);
assert_eq!(ok, cases, "every toposort case passed ({ok}/{cases})");
}
// ── stage 2: scene layout (PipelineView::layout_scene_centers) ──────────────────
/// INJECT-ASSERT — the scene layout's RETURN places nodes on a deterministic grid:
/// x is non-decreasing with the toposort column (stages in later columns are
/// further right), nodes sharing a column are separated in y (no overlap), and
/// every centre is finite. Walked over topology × size.
#[test]
fn scene_layout_columns_x_monotone_rows_separated() {
let mut cases = 0usize;
let mut ok = 0usize;
for &topo in TOPOS {
for &n in SIZES {
let (nodes, edges) = build_dag(topo, n, Mix::Mixed);
let view = PipelineView::from_nodes("p", nodes, edges);
let scene = view.layout_scene_centers();
// group by x (column) → within a column, the ys must be distinct.
use std::collections::BTreeMap;
let mut by_col: BTreeMap<i64, Vec<f32>> = BTreeMap::new();
for p in &scene {
by_col.entry((p.x * 10.0).round() as i64).or_default().push(p.y);
}
for ys in by_col.values() {
let mut sorted = ys.clone();
sorted.sort_by(|a, b| a.partial_cmp(b).unwrap());
for w in sorted.windows(2) {
assert!((w[1] - w[0]).abs() > 1.0, "{} same-column nodes separated in y", topo_name(topo));
}
}
cases += 1;
ok += 1;
}
}
#[cfg(feature = "testmatrix")]
facett_core::testmatrix::emit(
"facett-pipeline.layout",
"scene_x_monotone_rows_separated",
ok == cases,
&format!("{ok}/{cases} scene-layout cases"),
);
assert_eq!(ok, cases, "every scene-layout case passed ({ok}/{cases})");
}
// ── stage 3: camera (fit/zoom/pan + visible-node cull) ─────────────────────────
/// INJECT-ASSERT — the camera's RETURN behaves: FIT frames every node (all visible
/// at a scale that shrinks a big DAG to the pane), zoom-in raises the scale and
/// CULLS nodes off-screen, pan moves the offset by exactly the pixel delta. Walked
/// over topology × size on a representative canvas.
#[test]
fn camera_fit_zoom_pan_and_cull() {
let rect = Rect::from_min_size(Pos2::ZERO, Vec2::new(500.0, 360.0));
let mut cases = 0usize;
let mut ok = 0usize;
for &topo in TOPOS {
for &n in SIZES {
let (nodes, edges) = build_dag(topo, n, Mix::AllDone);
let mut view = PipelineView::from_nodes("p", nodes, edges);
view.fit_to(rect);
let after_fit = view.visible_node_count(rect);
assert_eq!(after_fit, n, "{} fit shows all {n} stages, got {after_fit}", topo_name(topo));
let fit_scale = view.nav().scale;
assert!(fit_scale > 0.0 && fit_scale.is_finite(), "fit scale positive+finite");
// pan moves the offset by exactly the delta
let before = view.nav().offset;
view.pan_pixels(60.0, -25.0);
let after = view.nav().offset;
assert!((after[0] - before[0] - 60.0).abs() < 1e-3, "pan x exact");
assert!((after[1] - before[1] + 25.0).abs() < 1e-3, "pan y exact");
// zoom in hard at a corner → no MORE nodes visible than after fit
view.fit_to(rect);
for _ in 0..12 {
view.zoom_to_cursor(1.5, rect.left_top());
}
assert!(view.nav().scale > fit_scale, "{} zoom raised scale", topo_name(topo));
let after_zoom = view.visible_node_count(rect);
assert!(after_zoom <= after_fit, "{} zoom-in never shows more nodes ({after_zoom}<={after_fit})", topo_name(topo));
cases += 1;
ok += 1;
}
}
#[cfg(feature = "testmatrix")]
facett_core::testmatrix::emit(
"facett-pipeline.camera",
"fit_zoom_pan_cull",
ok == cases,
&format!("{ok}/{cases} camera cases"),
);
assert_eq!(ok, cases, "every camera case passed ({ok}/{cases})");
}
// ── stage 4: status→colour + counts mapping ────────────────────────────────────
/// INJECT-ASSERT — status maps to the right counts + colours: `counts()` returns
/// the exact (idle,running,done,failed) tally for the injected mix, a failed mix
/// surfaces the error list, and `is_idle()` is true iff nothing runs. Walked over
/// topology × size × mix.
#[test]
fn status_mapping_counts_errors_and_idle() {
let mut cases = 0usize;
let mut ok = 0usize;
for &topo in TOPOS {
for &n in SIZES {
for &mix in MIXES {
let (mut nodes, edges) = build_dag(topo, n, mix);
// attach an error to every failed node so the error list is populated
for node in nodes.iter_mut() {
if node.status == Status::Failed {
*node = node.clone().with_error("boom");
}
}
let view = PipelineView::from_nodes("p", nodes, edges);
let (idle, running, done, failed) = view.counts();
assert_eq!(idle + running + done + failed, n, "{} counts sum to n", topo_name(topo));
let st = view.state_json();
assert_eq!(st["statuses"]["failed"].as_u64().unwrap() as usize, failed);
assert_eq!(st["statuses"]["running"].as_u64().unwrap() as usize, running);
// error list size == failed count
assert_eq!(st["errors"].as_array().unwrap().len(), failed, "{} error list == failed", topo_name(topo));
// is_idle iff nothing running (no inflight progress here)
assert_eq!(view.is_idle(), running == 0, "{} idle iff none running", topo_name(topo));
cases += 1;
ok += 1;
}
}
}
#[cfg(feature = "testmatrix")]
facett_core::testmatrix::emit(
"facett-pipeline.status",
"counts_errors_idle_mapping",
ok == cases,
&format!("{ok}/{cases} status-mapping cases"),
);
assert_eq!(ok, cases, "every status case passed ({ok}/{cases})");
}
// ── stage 5+6+7: paint + selection + detail — THE MATRIX ───────────────────────
/// **THE MATRIX** (topology × size × canvas × status-mix). For each cell: build a
/// real DAG, pre-select a middle stage (exercises the detail-panel + selection-ring
/// path), render headless at the canvas size, and:
/// - INJECT-ASSERT `state_json` (node/edge counts, status tally, selection name,
/// columns) is exactly the model,
/// - EMIT (the raster isn't on the return): the painter DREW vertices (non-blank).
#[test]
fn render_matrix_topo_x_size_x_canvas_x_mix() {
let mut cases = 0usize;
let mut drew = 0usize;
let mut state_correct = 0usize;
for &topo in TOPOS {
for &n in SIZES {
for &mix in MIXES {
let (nodes, edges) = build_dag(topo, n, mix);
let ec = edges.len();
let (idle, running, done, failed) = {
let v = PipelineView::from_nodes("p", nodes.clone(), edges.clone());
v.counts()
};
let sel_name = format!("stage{}", n / 2);
for &(cw, ch) in CANVASES {
let mut view = PipelineView::from_nodes("etl", nodes.clone(), edges.clone())
.with_selected(&sel_name)
.with_animation(false); // still frame for headless determinism
let r = facett_core::harness::render_sized(&mut view, (cw, ch));
let st = &r.state;
let st_nodes = st["node_count"].as_u64().unwrap() as usize;
let st_edges = st["edge_count"].as_u64().unwrap() as usize;
assert_eq!(st_nodes, n, "{} n={n} {cw}x{ch} node count", topo_name(topo));
assert_eq!(st_edges, ec, "{} edge count", topo_name(topo));
assert_eq!(st["statuses"]["failed"].as_u64().unwrap() as usize, failed, "{} failed tally", topo_name(topo));
assert_eq!(st["statuses"]["running"].as_u64().unwrap() as usize, running);
assert_eq!(st["statuses"]["done"].as_u64().unwrap() as usize, done);
assert_eq!(st["statuses"]["idle"].as_u64().unwrap() as usize, idle);
let sel_ok = st["selected"]["name"] == serde_json::json!(sel_name);
assert!(sel_ok, "{} selection {sel_name} reflected", topo_name(topo));
let state_ok = st_nodes == n && st_edges == ec && sel_ok;
if state_ok {
state_correct += 1;
}
if r.drew() {
drew += 1;
}
cases += 1;
}
}
}
}
assert!(cases >= 1152, "the pipeline matrix has >= 1152 cases, got {cases}");
assert_eq!(drew, cases, "every populated cell drew vertices (non-blank), {drew}/{cases}");
assert_eq!(state_correct, cases, "every cell's state_json matched the model, {state_correct}/{cases}");
eprintln!("[pipeline-matrix] {cases} cells, all drew, all state-correct");
#[cfg(feature = "testmatrix")]
facett_core::testmatrix::emit(
"facett-pipeline.render_matrix",
"all_cells_draw_with_correct_state",
drew == cases && state_correct == cases,
&format!("{cases} cells (topo×size×canvas×mix), {drew} drew, {state_correct} state-correct"),
);
}
/// Empty-DAG negative cell: a populated DAG draws far more than an empty one.
#[test]
fn empty_dag_is_distinguishable_from_a_populated_one() {
let mut empty = PipelineView::from_nodes("empty", vec![], vec![]);
let re = facett_core::harness::headless_render(&mut empty);
assert_eq!(re.state["node_count"], 0);
let (nodes, edges) = build_dag(Topo::Multistage, 20, Mix::Mixed);
let mut full = PipelineView::from_nodes("full", nodes, edges).with_animation(false);
let rf = facett_core::harness::headless_render(&mut full);
assert!(rf.vertices > re.vertices * 2, "populated >> empty: {} vs {}", rf.vertices, re.vertices);
#[cfg(feature = "testmatrix")]
facett_core::testmatrix::emit(
"facett-pipeline.render_matrix",
"empty_vs_populated_separable",
rf.vertices > re.vertices * 2,
&format!("empty={} populated={} verts", re.vertices, rf.vertices),
);
}