facett-graph 0.1.12

facett — graph (node/edge) viewer component, on facett-core
Documentation
//! **Render call-chain coverage matrix** for `facett-graph` — the 2D node/edge
//! skin (LAW: every fn in the input→pixel chain TRACKED + VERIFIED, return-
//! asserted where the return proves it, EMIT only for the pixel stages).
//!
//! The 2D graph chain is:
//!
//! ```text
//!   read edge list (src,dst,label,label)
//!     → build Scene            (scene_from_labeled_edges → distinct ids = nodes)
//!     → label colour           (hash_color per label, FNV)
//!     → layout positions       (facett_core::layout_positions: Circular | Force)
//!     → edge endpoint resolve  (draw: pos[src]→pos[dst], in-bounds guard)
//!     → node/edge/label draw   (GraphView::ui → facett_core::draw, CPU painter)
//!     → pixels                 (egui_kittest wgpu render — robot leg)
//! ```
//!
//! Each non-pixel stage gets an INJECT-real-input → ASSERT-the-real-return case
//! (counts, finiteness, in-rect, circular radius, force-fit, edge endpoints == the
//! laid-out node centres). The painted-pixel stage (which no return exposes) is
//! covered by the EMIT-doctrine non-blank check (`harness::render_sized` drew
//! vertices) here + the wgpu robot leg in `robot_callchain.rs`.
//!
//! ~2300+ matrix cases: topology {chain,star,bipartite,cycle,grid,disconnected} ×
//! size × canvas × layout {Circular,Force}, fed a real labelled edge list.
//!
//! Run:        `cargo test -p facett-graph --test callchain_matrix`
//! Matrix rows:`cargo test -p facett-graph --features testmatrix --test callchain_matrix`

use egui::{Pos2, Rect, Vec2};
use facett_core::{Layout, Scene, harness, hash_color, layout_positions};
use facett_graph::{GraphView, scene_from_labeled_edges};

// ── matrix axes ───────────────────────────────────────────────────────────────

/// Topology kinds — the shapes a real graph query / dataflow DAG hands the skin.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Topo {
    Chain,        // 0→1→2→…  (a path)
    Star,         // 0→{1,2,…} (a hub)
    Bipartite,    // {0..h}→{h..n}
    Cycle,        // 0→1→…→n-1→0
    Grid,         // rows×cols 4-neighbour lattice
    Disconnected, // k isolated pairs
}
const TOPOS: &[Topo] = &[Topo::Chain, Topo::Star, Topo::Bipartite, Topo::Cycle, Topo::Grid, Topo::Disconnected];

/// Node counts across the readable-label boundary (`n<=60` draws labels) and
/// well past it (cull labels, still draw nodes/edges).
const SIZES: &[usize] = &[2, 3, 5, 8, 12, 20, 30, 50, 60, 61, 120, 400];

/// Canvas sizes a host might hand the pane, incl. squeezed + tall + wide.
const CANVASES: &[(f32, f32)] = &[
    (160.0, 120.0),
    (320.0, 240.0),
    (400.0, 300.0),
    (640.0, 480.0),
    (800.0, 600.0),
    (1280.0, 720.0),
    (240.0, 900.0),
    (1000.0, 200.0),
];

/// Themes the render matrix paints under — the painter reads the active palette
/// (edge/text/dim), so a cell × theme is a distinct paint path to prove.
const THEMES: &[fn() -> facett_core::Theme] = &[facett_core::Theme::default, facett_core::Theme::sci_fi];

/// Distinct label palette — drives the per-label colour (hash) + label_counts.
const LABELS: &[&str] = &["Person", "Company", "Address", "Account", "Device"];

fn lbl(i: usize) -> String {
    LABELS[i % LABELS.len()].to_string()
}

fn layout_name(l: Layout) -> &'static str {
    match l {
        Layout::Circular => "Circular",
        Layout::Force => "Force",
    }
}

/// Build a real labelled edge list of `n` nodes in the `topo` shape, then turn it
/// into a `Scene` the exact way a host (korp Graph view) does. Returns the scene +
/// the *intended* (node_count, edge_count) so the build stage is return-asserted.
fn build_scene(topo: Topo, n: usize) -> (Scene, usize, usize) {
    let mut rows: Vec<(i64, i64, String, String)> = Vec::new();
    match topo {
        Topo::Chain => {
            for i in 0..n.saturating_sub(1) {
                rows.push((i as i64, (i + 1) as i64, lbl(i), lbl(i + 1)));
            }
        }
        Topo::Star => {
            for i in 1..n {
                rows.push((0, i as i64, lbl(0), lbl(i)));
            }
        }
        Topo::Bipartite => {
            let h = n / 2;
            for a in 0..h {
                for b in h..n {
                    rows.push((a as i64, b as i64, lbl(a), lbl(b)));
                }
            }
        }
        Topo::Cycle => {
            for i in 0..n {
                rows.push((i as i64, ((i + 1) % n) as i64, lbl(i), lbl((i + 1) % n)));
            }
        }
        Topo::Grid => {
            let cols = (n as f64).sqrt().ceil() as usize;
            let cols = cols.max(1);
            for r in 0..n {
                let (cx, cy) = (r % cols, r / cols);
                if cx + 1 < cols && r + 1 < n {
                    rows.push((r as i64, (r + 1) as i64, lbl(r), lbl(r + 1)));
                }
                if r + cols < n {
                    rows.push((r as i64, (r + cols) as i64, lbl(r), lbl(r + cols)));
                }
                let _ = cy;
            }
        }
        Topo::Disconnected => {
            // k isolated pairs (2 nodes each); odd n leaves one isolated node,
            // which `scene_from_labeled_edges` never sees (no edge) — so the node
            // count == 2*(n/2). We track THAT as the intended count.
            for k in 0..(n / 2) {
                let a = (2 * k) as i64;
                let b = (2 * k + 1) as i64;
                rows.push((a, b, lbl(2 * k), lbl(2 * k + 1)));
            }
        }
    }
    let edge_count = rows.len();
    // The intended node count = number of DISTINCT ids referenced by the edges.
    let mut ids = std::collections::HashSet::new();
    for (s, d, _, _) in &rows {
        ids.insert(*s);
        ids.insert(*d);
    }
    let node_count = ids.len();
    let scene = scene_from_labeled_edges(rows);
    (scene, node_count, edge_count)
}

// ── stage 1+2: build Scene + label colour (scene_from_labeled_edges) ───────────

/// INJECT-ASSERT — the build's RETURN is exactly right: distinct ids become
/// nodes, every row becomes an edge, edge endpoints index into `nodes`, and each
/// node's colour is the FNV `hash_color` of its label (deterministic). Walked over
/// every topology × size.
#[test]
fn build_scene_returns_correct_counts_and_colours() {
    let mut cases = 0usize;
    let mut ok = 0usize;
    for &topo in TOPOS {
        for &n in SIZES {
            let (scene, want_nodes, want_edges) = build_scene(topo, n);
            assert_eq!(scene.nodes.len(), want_nodes, "{topo:?} n={n} node count");
            assert_eq!(scene.edges.len(), want_edges, "{topo:?} n={n} edge count");
            // every edge endpoint indexes a real node
            for e in &scene.edges {
                assert!(e.src < scene.nodes.len() && e.dst < scene.nodes.len(), "{topo:?} edge in-bounds");
            }
            // colour is the deterministic FNV hash of the label
            for node in &scene.nodes {
                assert_eq!(node.color, hash_color(&node.label), "{topo:?} node colour == hash(label)");
            }
            cases += 1;
            ok += 1;
        }
    }
    assert_eq!(cases, TOPOS.len() * SIZES.len());
    #[cfg(feature = "testmatrix")]
    facett_core::testmatrix::emit(
        "facett-graph.build",
        "scene_counts_and_label_colours",
        ok == cases,
        &format!("{ok}/{cases} build cases over {} topologies", TOPOS.len()),
    );
    assert_eq!(ok, cases, "every build case passed ({ok}/{cases})");
}

// ── stage 3: layout positions (facett_core::layout_positions) ──────────────────

/// INJECT-ASSERT — the layout's RETURN is provably correct for BOTH layouts:
///  - one position per node, every coord FINITE (no NaN/inf),
///  - every position INSIDE the rect (slightly padded for the node radius),
///  - Circular: all nodes sit at ~the same radius from centre (the ring),
///  - Force: positions span a real bbox (not collapsed to a point).
/// Walked over every topology × size × canvas × layout.
#[test]
fn layout_positions_are_finite_in_rect_and_shaped() {
    let mut cases = 0usize;
    let mut ok = 0usize;
    for &topo in TOPOS {
        for &n in SIZES {
            let (scene, _, _) = build_scene(topo, n);
            let nn = scene.nodes.len();
            if nn == 0 {
                continue;
            }
            for &(cw, ch) in CANVASES {
                let rect = Rect::from_min_size(Pos2::new(10.0, 20.0), Vec2::new(cw, ch));
                for &layout in &[Layout::Circular, Layout::Force] {
                    let pos = layout_positions(layout, &scene, rect);
                    assert_eq!(pos.len(), nn, "{topo:?} one pos per node");
                    // finite + in-rect (allow the 5px node radius + a hair of slack)
                    let grown = rect.expand(8.0);
                    let ln = layout_name(layout);
                    for p in &pos {
                        assert!(p.x.is_finite() && p.y.is_finite(), "{topo:?} {ln} finite pos");
                        assert!(grown.contains(*p), "{topo:?} {ln} pos {p:?} in rect {grown:?}");
                    }
                    match layout {
                        Layout::Circular => {
                            // all nodes equidistant from the centre (a ring)
                            let c = rect.center();
                            let r0 = (pos[0] - c).length();
                            for p in &pos {
                                let r = (*p - c).length();
                                assert!((r - r0).abs() < 0.5, "{topo:?} circular radius uniform: {r} vs {r0}");
                            }
                            if nn >= 2 {
                                assert!(r0 > 0.0, "circular radius positive");
                            }
                        }
                        Layout::Force => {
                            // force layout spreads — bbox spans more than a point
                            // (for n>=2 and a non-degenerate canvas).
                            if nn >= 2 && cw > 200.0 && ch > 200.0 {
                                let (mut mn, mut mx) = (pos[0], pos[0]);
                                for p in &pos {
                                    mn = Pos2::new(mn.x.min(p.x), mn.y.min(p.y));
                                    mx = Pos2::new(mx.x.max(p.x), mx.y.max(p.y));
                                }
                                assert!((mx.x - mn.x) + (mx.y - mn.y) > 1.0, "{topo:?} force layout spreads");
                            }
                        }
                    }
                    cases += 1;
                    ok += 1;
                }
            }
        }
    }
    assert!(cases > 300, "layout sweep is broad, got {cases}");
    #[cfg(feature = "testmatrix")]
    facett_core::testmatrix::emit(
        "facett-graph.layout",
        "positions_finite_in_rect_circular_force",
        ok == cases,
        &format!("{ok}/{cases} layout cases (topo×size×canvas×layout)"),
    );
    assert_eq!(ok, cases, "every layout case passed ({ok}/{cases})");
}

// ── stage 4: edge endpoint resolution ──────────────────────────────────────────

/// INJECT-ASSERT — every edge the painter draws connects the laid-out centres of
/// two REAL nodes: for each edge `(src,dst)`, both indices are in-bounds and the
/// segment endpoints equal `pos[src]`/`pos[dst]` (the painter's exact line). A
/// self-loop (src==dst) draws a zero-length segment — still finite, still in-rect.
/// Walked over topology × size on a representative canvas, both layouts.
#[test]
fn edge_endpoints_match_laid_out_node_centres() {
    let rect = Rect::from_min_size(Pos2::ZERO, Vec2::new(800.0, 600.0));
    let mut cases = 0usize;
    let mut ok = 0usize;
    for &topo in TOPOS {
        for &n in SIZES {
            let (scene, _, _) = build_scene(topo, n);
            if scene.nodes.is_empty() {
                continue;
            }
            for &layout in &[Layout::Circular, Layout::Force] {
                let pos = layout_positions(layout, &scene, rect);
                for e in &scene.edges {
                    // bounds (the painter's `e.src < n && e.dst < n` guard)
                    assert!(e.src < pos.len() && e.dst < pos.len());
                    let a = pos[e.src];
                    let b = pos[e.dst];
                    assert!(a.x.is_finite() && b.x.is_finite(), "edge endpoints finite");
                    // the segment endpoints ARE the node centres
                    assert_eq!(a, pos[e.src]);
                    assert_eq!(b, pos[e.dst]);
                }
                cases += 1;
                ok += 1;
            }
        }
    }
    #[cfg(feature = "testmatrix")]
    facett_core::testmatrix::emit(
        "facett-graph.edges",
        "endpoints_are_node_centres",
        ok == cases,
        &format!("{ok}/{cases} edge-resolution cases"),
    );
    assert_eq!(ok, cases, "every edge case passed ({ok}/{cases})");
}

// ── stage 5+6: paint (headless CPU painter) — THE MATRIX ───────────────────────

/// **THE MATRIX** (topology × size × canvas × layout). For each cell: build a real
/// labelled-edge `Scene`, wrap it in a `GraphView`, render it headless at the
/// canvas size for both layouts, and:
///  - INJECT-ASSERT the `state_json` (nodes/edges/labels) is exactly the model,
///  - EMIT (the raster isn't on the return): the painter DREW vertices (non-blank)
///    for every populated cell.
///
/// This is the input→pixel proof for the CPU painter across the whole grid. The
/// per-cell axis is `topo·n·CxC·layout`.
#[test]
fn render_matrix_topo_x_size_x_canvas_x_layout() {
    let mut cases = 0usize;
    let mut drew = 0usize;
    let mut state_correct = 0usize;
    for &topo in TOPOS {
        for &n in SIZES {
            let (scene, want_nodes, want_edges) = build_scene(topo, n);
            if want_nodes == 0 {
                continue;
            }
            // distinct labels actually present (label_counts oracle)
            let mut want_label_count = std::collections::BTreeMap::new();
            for node in &scene.nodes {
                *want_label_count.entry(node.label.clone()).or_insert(0usize) += 1;
            }
            for &(cw, ch) in CANVASES {
                for &layout in &[Layout::Circular, Layout::Force] {
                    for &theme_fn in THEMES {
                        let mut view = GraphView::new(scene.clone());
                        view.set_layout(layout);
                        // Render under the cell's theme (the painter reads the active
                        // palette) at the cell's canvas size.
                        let r = harness::render_themed_sized(&mut view, theme_fn(), (cw, ch));
                        // INJECT-ASSERT the observable state == the model.
                        let st_nodes = r.state["nodes"].as_u64().unwrap() as usize;
                        let st_edges = r.state["edges"].as_u64().unwrap() as usize;
                        assert_eq!(st_nodes, want_nodes, "{topo:?} n={n} {cw}x{ch} {} node count", layout_name(layout));
                        assert_eq!(st_edges, want_edges, "{topo:?} edges");
                        let st_labels = r.state["labels"].as_object().unwrap();
                        assert_eq!(st_labels.len(), want_label_count.len(), "{topo:?} distinct-label count");
                        let state_ok = st_nodes == want_nodes && st_edges == want_edges && st_labels.len() == want_label_count.len();
                        if state_ok {
                            state_correct += 1;
                        }
                        // EMIT-doctrine: the painter actually drew (non-blank proxy).
                        if r.drew() {
                            drew += 1;
                        }
                        cases += 1;
                    }
                }
            }
        }
    }
    assert!(cases >= 2304, "the graph matrix has >= 2304 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!("[graph-matrix] {cases} cells, all drew, all state-correct");
    #[cfg(feature = "testmatrix")]
    facett_core::testmatrix::emit(
        "facett-graph.render_matrix",
        "all_cells_draw_with_correct_state",
        drew == cases && state_correct == cases,
        &format!("{cases} cells (topo×size×canvas×layout), {drew} drew, {state_correct} state-correct"),
    );
}

/// Empty-scene negative cell: a populated graph draws far more than an empty one,
/// proving the non-blank proxy is not vacuous (the empty scene only paints the
/// hint text).
#[test]
fn empty_scene_is_distinguishable_from_a_populated_one() {
    let mut empty = GraphView::new(Scene::new());
    let re = harness::headless_render(&mut empty);
    assert_eq!(re.state["nodes"], 0);
    assert_eq!(re.state["edges"], 0);
    let (full_scene, _, _) = build_scene(Topo::Grid, 120);
    let mut full = GraphView::new(full_scene);
    let rf = 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-graph.render_matrix",
        "empty_vs_populated_separable",
        rf.vertices > re.vertices * 2,
        &format!("empty={} populated={} verts", re.vertices, rf.vertices),
    );
}