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

nordisk/facett

facett-graphview/tests/render_callchain.rs

//! **Render call-chain coverage** for `facett-graphview` (the LAW: every function
//! in the input→pixel chain TRACKED + VERIFIED in the test matrix).
//!
//! # The call chain (start → pixel)
//! A host hands a [`GraphModel`] (nodes laid out in world space by the caller — this
//! engine does **not** own layout; force/Sugiyama/radial are documented STUBS) plus a
//! [`Decorations`] overlay, then:
//!
//! ```text
//! GraphModel::world_bounds   ── world AABB of the chips
//!   → Camera::fit            ── center + zoom that frames the world in w×h
//!     → Camera::project      ── world → screen pixels (the affine every lane uses)
//!       → decide / probe_from_env  ── pick the Backend (CPU reference / GPU seam)
//!         ├─ render_to_rgba → cpu::render  ── vello_cpu rounded-rect chips + cubic
//!         │     edges (draw_edge → draw_edge_styled → cubic_point) + to_alpha
//!         └─ l0::lower → render_to_rgba_l0 ── nodes→MarkerInstance, edges→LineInstance
//!               through the shared facett_core Canvas (col colour map)
//! ```
//!
//! # Verification doctrine (return-vs-emit)
//! * **Assert the return** wherever it proves correctness:
//!   `world_bounds` contains every chip; `Camera::fit` centers the world AND clamps
//!   zoom; `project` is the exact affine; `lower` emits exactly nodes→quads +
//!   (edges+deco)→lines carrying the real colours & in-viewport positions; `decide`
//!   is monotone in the probe.
//! * **EMIT where the return can't prove it** (the EMIT-DOCTRINE): the *rasterized*
//!   frame is opaque bytes — its correctness ("a real graph drew, not a flat pane")
//!   is reported through `facett_core::testmatrix::emit` (testmatrix-gated) as
//!   `lit_px > 0` + a multi-colour-present check, fed into the `nornir test` matrix.
//!
//! # Matrix
//! ~1000 cases = graph sizes {10 … 100k nodes} × layouts/topologies {grid, chain,
//! fan-out, fan-in, star} × zoom {fit, 0.05, 0.5, 1, 4} × canvas {320², 640×480,
//! 1920×1080}, feeding real node/edge models and asserting at each stage.

use std::collections::HashSet;

use facett_graphview::{
    Backend, Camera, Color, Decorations, GraphEdge, GraphModel, GraphNode, NodeDecoration, Pos,
    backend::{decide, probe_from_env},
    cpu::render as cpu_render,
    l0::{lower, render_to_rgba_l0},
    render_to_rgba,
    model::{BOX_H, BOX_W},
};
use facett_core::render::prim::shape;

/// testmatrix passthrough — a no-op unless built with `--features testmatrix`.
#[allow(unused)]
fn emit(check: &str, ok: bool, detail: &str) {
    #[cfg(feature = "testmatrix")]
    facett_core::testmatrix::emit("facett-graphview", check, ok, detail);
    #[cfg(not(feature = "testmatrix"))]
    {
        let _ = (check, ok, detail);
    }
}

// ──────────────────────────────────────────────────────────────────────────
// Topology generators — real node/edge models, deterministic, parametric.
// ──────────────────────────────────────────────────────────────────────────

/// Spread `n` nodes on a grid in world space (the caller-owned layout this engine
/// renders). `edges` per the chosen topology.
fn model(n: usize, topo: Topo) -> GraphModel {
    let cols = (n as f64).sqrt().ceil().max(1.0) as usize;
    let nodes: Vec<GraphNode> = (0..n)
        .map(|i| {
            let (r, c) = (i / cols, i % cols);
            GraphNode {
                id: format!("n{i}"),
                label: format!("node {i}"),
                // Distinct kind fills so a non-blank render shows >1 colour.
                fill: Color::rgb((40 + (i * 7) % 200) as u8, (60 + (i * 13) % 180) as u8, 160),
                stroke: Color::WHITE,
                pos: Pos::new(c as f32 * (BOX_W + 40.0), r as f32 * (BOX_H + 40.0)),
            }
        })
        .collect();
    let edges = topo.edges(n);
    GraphModel { nodes, edges }
}

#[derive(Clone, Copy, Debug)]
enum Topo {
    Chain,
    FanOut,
    FanIn,
    Star,
    Grid,
}

impl Topo {
    const ALL: [Topo; 5] = [Topo::Chain, Topo::FanOut, Topo::FanIn, Topo::Star, Topo::Grid];
    fn name(self) -> &'static str {
        match self {
            Topo::Chain => "chain",
            Topo::FanOut => "fanout",
            Topo::FanIn => "fanin",
            Topo::Star => "star",
            Topo::Grid => "grid",
        }
    }
    fn edge(from: usize, to: usize) -> GraphEdge {
        GraphEdge {
            from: format!("n{from}"),
            to: format!("n{to}"),
            color: Color::rgb(200, 200, 220),
            dashed: from.is_multiple_of(2),
            label: None,
        }
    }
    fn edges(self, n: usize) -> Vec<GraphEdge> {
        if n < 2 {
            return vec![];
        }
        match self {
            Topo::Chain => (0..n - 1).map(|i| Self::edge(i, i + 1)).collect(),
            // Fan-out from node 0 to the rest (high out-degree at the root).
            Topo::FanOut => (1..n).map(|i| Self::edge(0, i)).collect(),
            // Fan-in: everyone points at the sink (high in-degree at the last).
            Topo::FanIn => (0..n - 1).map(|i| Self::edge(i, n - 1)).collect(),
            // Star: hub 0 ↔ every spoke, both directions.
            Topo::Star => (1..n).flat_map(|i| [Self::edge(0, i), Self::edge(i, 0)]).collect(),
            // Grid: each node → its right + down neighbour.
            Topo::Grid => {
                let cols = (n as f64).sqrt().ceil().max(1.0) as usize;
                let mut e = Vec::new();
                for i in 0..n {
                    if (i % cols) + 1 < cols && i + 1 < n {
                        e.push(Self::edge(i, i + 1));
                    }
                    if i + cols < n {
                        e.push(Self::edge(i, i + cols));
                    }
                }
                e
            }
        }
    }
}

// ──────────────────────────────────────────────────────────────────────────
// 1. GraphModel::world_bounds — RETURN proves correctness (AABB contains chips)
// ──────────────────────────────────────────────────────────────────────────

#[test]
fn world_bounds_contains_every_chip_and_is_finite() {
    for topo in Topo::ALL {
        for &n in &[1usize, 2, 17, 256] {
            let m = model(n, topo);
            let (mnx, mny, mxx, mxy) = m.world_bounds().expect("non-empty model has bounds");
            assert!(mnx.is_finite() && mxy.is_finite(), "finite bounds");
            assert!(mnx <= mxx && mny <= mxy, "min <= max");
            let (hw, hh) = (BOX_W * 0.5, BOX_H * 0.5);
            for node in &m.nodes {
                // Every chip's extents lie inside the reported AABB.
                assert!(node.pos.x - hw >= mnx - 1e-3 && node.pos.x + hw <= mxx + 1e-3);
                assert!(node.pos.y - hh >= mny - 1e-3 && node.pos.y + hh <= mxy + 1e-3);
            }
        }
    }
    // Empty model → no bounds (the documented None contract).
    assert!(GraphModel::default().world_bounds().is_none());
    emit("world_bounds_contains_chips", true, "AABB contains every chip across 5 topologies × 4 sizes");
}

// ──────────────────────────────────────────────────────────────────────────
// 2. Camera::fit + Camera::project — RETURN proves it (centers, clamps, affine)
// ──────────────────────────────────────────────────────────────────────────

#[test]
fn camera_fit_centers_world_and_clamps_zoom() {
    for topo in Topo::ALL {
        for &n in &[1usize, 9, 100, 5000] {
            for &(w, h) in &[(320.0f32, 320.0f32), (640.0, 480.0), (1920.0, 1080.0)] {
                let m = model(n, topo);
                let cam = Camera::fit(&m, w, h, 40.0);
                // Zoom is clamped to the engine's [0.05, 4.0] window.
                assert!(cam.zoom >= 0.05 && cam.zoom <= 4.0, "zoom clamp: {}", cam.zoom);
                if let Some((mnx, mny, mxx, mxy)) = m.world_bounds() {
                    let (wcx, wcy) = ((mnx + mxx) * 0.5, (mny + mxy) * 0.5);
                    let (sx, sy) = cam.project(Pos::new(wcx, wcy));
                    // The world centre projects to the viewport centre (the ⊙ fit).
                    assert!((sx - w * 0.5).abs() < 1.0, "x centered n={n} got {sx}");
                    assert!((sy - h * 0.5).abs() < 1.0, "y centered n={n} got {sy}");
                }
            }
        }
    }
    emit("camera_fit_centers_and_clamps", true, "fit centers world + zoom∈[0.05,4] across sizes/topologies");
}

#[test]
fn project_is_the_exact_affine() {
    let cam = Camera { pan_x: 12.5, pan_y: -7.0, zoom: 2.5 };
    for &(x, y) in &[(0.0f32, 0.0f32), (100.0, -50.0), (-300.0, 220.0)] {
        let (sx, sy) = cam.project(Pos::new(x, y));
        assert!((sx - (x * 2.5 + 12.5)).abs() < 1e-4, "x affine");
        assert!((sy - (y * 2.5 - 7.0)).abs() < 1e-4, "y affine");
    }
    // Empty model → default camera (identity-ish).
    let def = Camera::fit(&GraphModel::default(), 100.0, 100.0, 10.0);
    assert_eq!((def.pan_x, def.pan_y, def.zoom), (0.0, 0.0, 1.0));
    emit("project_affine_exact", true, "project = p*zoom+pan to 1e-4");
}

// ──────────────────────────────────────────────────────────────────────────
// 3. backend::decide + probe_from_env — RETURN proves it (monotone pick)
// ──────────────────────────────────────────────────────────────────────────

#[test]
fn decide_is_monotone_in_the_probe() {
    use facett_graphview::GpuProbe;
    // CPU-only / forced-CPU always → VelloCpu (the reference, never flaky).
    assert_eq!(decide(GpuProbe::cpu_only()), Backend::VelloCpu);
    assert_eq!(decide(GpuProbe { usable_gpu: true, force_cpu: true }), Backend::VelloCpu);
    // No GPU present → CPU regardless of the feature.
    assert_eq!(decide(GpuProbe { usable_gpu: false, force_cpu: false }), Backend::VelloCpu);
    // GPU present + not forced: GPU iff facett-core compiled the GPU arm in. Key
    // off core's actual state (workspace feature-unification can light up
    // facett-core/wgpu independently of this crate's own `gpu` feature), not this
    // crate's `gpu` cfg.
    let got = decide(GpuProbe { usable_gpu: true, force_cpu: false });
    assert_eq!(got.is_gpu(), facett_graphview::backend::gpu_arm_compiled());
    // probe_from_env honours an explicit usable flag (the env shim is process-wide,
    // so we assert the non-env construction here — the env override is covered in
    // the unit test `graphview_env_override_still_forces_cpu`).
    let p = probe_from_env(false);
    assert_eq!(decide(p), Backend::VelloCpu, "no gpu ⇒ cpu");
    emit("decide_monotone", true, "cpu-only/forced/no-gpu→CPU; gpu+unforced→feature-gated");
}

// ──────────────────────────────────────────────────────────────────────────
// 4. l0::lower — RETURN proves it (counts = nodes/edges, colours & coords carried)
// ──────────────────────────────────────────────────────────────────────────

#[test]
fn lower_counts_equal_nodes_and_edges_with_real_attrs() {
    for topo in Topo::ALL {
        for &n in &[2usize, 10, 64, 400] {
            let m = model(n, topo);
            let cam = Camera::fit(&m, 1280.0, 800.0, 40.0);
            // Decorate every 5th node with a ring → extra ring quads.
            let mut deco = Decorations::default();
            let mut rings = 0;
            for (i, node) in m.nodes.iter().enumerate() {
                if i % 5 == 0 {
                    deco.nodes.insert(
                        node.id.clone(),
                        NodeDecoration { scale: None,  ring: Some(Color::rgb(240, 80, 80)), badge: None, badge_color: None },
                    );
                    rings += 1;
                }
            }
            // One emphasis edge over the base set.
            if n >= 2 {
                deco.edges.push(Topo::edge(0, 1));
            }
            let (quads, lines) = lower(&m, &deco, &cam);
            // One marker quad per node + one ring quad per decorated node.
            assert_eq!(quads.len(), n + rings, "quads = nodes + rings (n={n} topo={})", topo.name());
            // One line per base edge + one per emphasis edge (endpoints both present).
            let want_lines = m.edges.len() + deco.edges.len();
            assert_eq!(lines.len(), want_lines, "lines = base+emphasis edges");
            // Node markers are rounded squares carrying the real fill colour.
            assert_eq!(quads[0].shape, shape::SQUARE);
            let want = [
                m.nodes[0].fill.r as f32 / 255.0,
                m.nodes[0].fill.g as f32 / 255.0,
                m.nodes[0].fill.b as f32 / 255.0,
                1.0,
            ];
            assert!((quads[0].color[0] - want[0]).abs() < 1e-6, "fill carried");
            // Edge lines carry the real edge colour.
            if let Some(l) = lines.first() {
                assert!((l.color[0] - 200.0 / 255.0).abs() < 1e-6, "edge colour carried");
            }
        }
    }
    emit("lower_counts_and_attrs", true, "quads=nodes+rings, lines=edges+emphasis, colours carried");
}

// ──────────────────────────────────────────────────────────────────────────
// 5. RASTER lanes — the return is opaque bytes ⇒ EMIT the non-blank oracle.
//    Covers render_to_rgba → cpu::render (to_alpha, draw_edge, draw_edge_styled,
//    cubic_point) AND render_to_rgba_l0 (col, the core Canvas).
// ──────────────────────────────────────────────────────────────────────────

/// Distinct-colour count up to a cap (the "real graph, not a flat pane" oracle).
fn distinct_colours(rgba: &[u8], cap: usize) -> usize {
    let mut seen = HashSet::new();
    for px in rgba.as_chunks::<4>().0 {
        seen.insert([px[0], px[1], px[2]]);
        if seen.len() > cap {
            break;
        }
    }
    seen.len()
}

#[test]
fn cpu_reference_lane_renders_nonblank() {
    let bg = Color::rgb(18, 20, 28);
    let backend = decide(facett_graphview::GpuProbe::cpu_only());
    for topo in Topo::ALL {
        for &n in &[2usize, 12, 80] {
            let m = model(n, topo);
            let cam = Camera::fit(&m, 640.0, 480.0, 40.0);
            let frame = cpu_render(&m, &Decorations::default(), &cam, 640, 480, bg);
            assert_eq!(frame.rgba.len(), 640 * 480 * 4);
            // Lit oracle: a non-background alpha exists (vello_cpu fills alpha=255).
            let lit = frame.rgba.as_chunks::<4>().0.iter().filter(|p| p[3] != 0).count();
            let colours = distinct_colours(&frame.rgba, 8);
            let ok = lit > 0 && colours > 3;
            assert!(ok, "cpu lane non-blank n={n} topo={} lit={lit} colours={colours}", topo.name());
            // The dispatch entry point routes to the same pixels.
            let via = render_to_rgba(backend, &m, &Decorations::default(), &cam, 640, 480, bg);
            assert_eq!(via.rgba, frame.rgba, "render_to_rgba == cpu::render on the CPU arm");
            emit(
                "cpu_render_nonblank",
                ok,
                &format!("topo={} n={n} lit_px={lit} distinct_colours={colours}", topo.name()),
            );
        }
    }
}

#[test]
fn l0_lane_renders_nonblank_through_core_canvas() {
    let bg = Color::rgb(18, 20, 28);
    let backend = decide(facett_graphview::GpuProbe::cpu_only());
    for topo in Topo::ALL {
        for &n in &[2usize, 12, 80] {
            let m = model(n, topo);
            let cam = Camera::fit(&m, 640.0, 480.0, 40.0);
            let frame = render_to_rgba_l0(backend, &m, &Decorations::default(), &cam, 640, 480, bg);
            assert_eq!(frame.rgba.len(), 640 * 480 * 4);
            let lit = frame.lit_px();
            let colours = distinct_colours(&frame.rgba, 8);
            let ok = lit > 0 && colours > 3;
            assert!(ok, "l0 lane non-blank n={n} topo={} lit={lit} colours={colours}", topo.name());
            emit(
                "l0_render_nonblank",
                ok,
                &format!("topo={} n={n} lit_px={lit} distinct_colours={colours}", topo.name()),
            );
        }
    }
}

// ──────────────────────────────────────────────────────────────────────────
// 6. THE ~1000-CASE MATRIX — sizes × topologies × zoom × canvas, asserting at
//    each stage (bounds → fit → project → lower counts → raster lit), with one
//    emit per stage. Big sizes (100k) exercise the lower path at scale; the
//    raster is sampled at smaller sizes to keep the suite fast.
// ──────────────────────────────────────────────────────────────────────────

#[test]
fn matrix_thousand_cases_assert_each_stage() {
    let sizes: &[usize] = &[
        10, 25, 50, 100, 250, 500, 750, 1_000, 2_500, 5_000, 10_000, 25_000, 50_000, 75_000,
        100_000,
    ];
    let zooms: &[Option<f32>] = &[None, Some(0.05), Some(0.5), Some(1.0), Some(4.0)]; // None = fit
    let canvases: &[(u32, u32)] = &[(320, 320), (640, 480), (1920, 1080)];
    let bg = Color::rgb(18, 20, 28);

    let mut cases = 0usize;
    let mut lower_ok_all = true;
    let mut raster_ok_all = true;
    let mut bounds_ok_all = true;
    let mut centered_ok_all = true;

    for topo in Topo::ALL {
        for &n in sizes {
            let m = model(n, topo);
            // Stage A: world_bounds contains the chips (cheap, every case).
            let (mnx, mny, mxx, mxy) = m.world_bounds().unwrap();
            bounds_ok_all &= mnx <= mxx && mny <= mxy && mnx.is_finite();

            for &zoom in zooms {
                for &(w, h) in canvases {
                    cases += 1;
                    // Stage B: camera (fit or explicit zoom centred on the world).
                    let cam = match zoom {
                        None => Camera::fit(&m, w as f32, h as f32, 40.0),
                        Some(z) => {
                            let (cx, cy) = ((mnx + mxx) * 0.5, (mny + mxy) * 0.5);
                            Camera {
                                zoom: z,
                                pan_x: w as f32 * 0.5 - cx * z,
                                pan_y: h as f32 * 0.5 - cy * z,
                            }
                        }
                    };
                    // Stage C: project the world centre → viewport centre.
                    let (cx, cy) = ((mnx + mxx) * 0.5, (mny + mxy) * 0.5);
                    let (sx, sy) = cam.project(Pos::new(cx, cy));
                    centered_ok_all &= (sx - w as f32 * 0.5).abs() < 1.5 && (sy - h as f32 * 0.5).abs() < 1.5;

                    // Stage D: lower — counts must equal nodes + edges (every case,
                    // including 100k: this is the scalable, allocation-only path).
                    let (quads, lines) = lower(&m, &Decorations::default(), &cam);
                    lower_ok_all &= quads.len() == n && lines.len() == m.edges.len();

                    // Stage E: raster lit oracle — sampled on the smaller graphs +
                    // smaller canvases to bound runtime (rasterizing 100k×1080p is a
                    // bench, not a unit). The lowering above already proved scale.
                    if n <= 1_000 && w <= 640 {
                        let frame = render_to_rgba_l0(Backend::VelloCpu, &m, &Decorations::default(), &cam, w, h, bg);
                        // At zoom 0.05 a big graph can shrink below a pixel → allow
                        // "lit OR everything culled off-canvas" but require the buffer
                        // shape is correct. The non-blank proof is the dedicated
                        // `l0_lane_renders_nonblank` test at fit zoom; here we assert
                        // the buffer contract holds at every zoom.
                        raster_ok_all &= frame.rgba.len() == (w * h * 4) as usize;
                    }
                }
            }
        }
    }

    assert!(cases >= 1000, "matrix must run ≥1000 cases, ran {cases}");
    assert!(bounds_ok_all, "every world_bounds finite + ordered");
    assert!(centered_ok_all, "every camera centres the world");
    assert!(lower_ok_all, "every lower: quads==nodes && lines==edges");
    assert!(raster_ok_all, "every sampled raster has the right buffer shape");

    emit("matrix_cases", true, &format!("ran {cases} cases (5 topo × 9 sizes × 5 zoom × 3 canvas)"));
    emit("matrix_bounds_stage", bounds_ok_all, "world_bounds finite+ordered every case");
    emit("matrix_camera_stage", centered_ok_all, "camera centres world every case");
    emit("matrix_lower_stage", lower_ok_all, "lower counts == nodes/edges every case (to 100k)");
    emit("matrix_raster_stage", raster_ok_all, "raster buffer contract holds every sampled case");
}

// ──────────────────────────────────────────────────────────────────────────
// 7. model.rs leaf functions — Color::dim + the egui Color32 round-trip.
// ──────────────────────────────────────────────────────────────────────────

#[test]
fn color_dim_and_egui_roundtrip() {
    let c = Color::rgba(200, 100, 50, 200);
    let dim = c.dim(0.5);
    assert_eq!((dim.r, dim.g, dim.b), (200, 100, 50), "rgb preserved");
    assert_eq!(dim.a, 100, "alpha halved");
    // Color32 round-trip (the drop-in seam for nornir's draw_graph). egui stores
    // colours premultiplied, so an *opaque* colour round-trips exactly; a
    // translucent one is only stable to ±1 LSB per channel (the un-premultiply
    // rounding). Assert the exact contract on opaque + the ±1 bound on translucent.
    let opaque = Color::rgb(200, 100, 50);
    let back_opaque: Color = egui::Color32::from(opaque).into();
    assert_eq!(back_opaque, opaque, "opaque Color ↔ Color32 round-trips exactly");
    let back: Color = egui::Color32::from(c).into();
    assert!(
        (back.r as i32 - c.r as i32).abs() <= 1
            && (back.g as i32 - c.g as i32).abs() <= 1
            && (back.b as i32 - c.b as i32).abs() <= 1
            && back.a == c.a,
        "translucent Color ↔ Color32 round-trips within ±1 LSB (premultiply), got {back:?}"
    );
    emit("color_dim_and_roundtrip", true, "dim halves alpha; Color32 round-trips");
}

// ──────────────────────────────────────────────────────────────────────────
// 8. HEADLESS "robot" surface drive — graphview is a raw render ENGINE (no egui
//    Facet of its own), so its surface is the rasterized frame consumed by a host
//    as an egui texture. Drive the full chain to an `egui::ColorImage` (the real
//    downstream handoff into nornir's draw_graph / korp) and assert it is a valid,
//    non-blank texture — the engine's analogue of an egui_kittest snapshot.
// ──────────────────────────────────────────────────────────────────────────

#[test]
fn frame_becomes_a_nonblank_egui_texture() {
    let m = model(40, Topo::Star);
    let cam = Camera::fit(&m, 512.0, 512.0, 40.0);
    let frame = render_to_rgba_l0(Backend::VelloCpu, &m, &Decorations::default(), &cam, 512, 512, Color::rgb(18, 20, 28));
    // The exact handoff a host makes: straight RGBA8 → egui ColorImage.
    let img = egui::ColorImage::from_rgba_unmultiplied([512, 512], &frame.rgba);
    assert_eq!(img.size, [512, 512], "texture sized to the canvas");
    assert_eq!(img.pixels.len(), 512 * 512, "one pixel per texel");
    // Non-blank: the texture carries more than the background colour.
    let bg = egui::Color32::from_rgb(18, 20, 28);
    let non_bg = img.pixels.iter().filter(|&&p| p != bg).count();
    let ok = non_bg > 0;
    assert!(ok, "the egui texture is non-blank ({non_bg} non-bg texels)");
    emit("frame_to_egui_texture_nonblank", ok, &format!("non_bg_texels={non_bg} of {}", 512 * 512));
}