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

nordisk/facett

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