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