facett-graphview/tests/graph_clip.rs
//! **GRAPH-CLIP-TEST** — the start→pixel clip discipline for the graph renderer,
//! the graph twin of `facett-map3d`'s clip pipeline. Mirrors map3d's
//! `clip_pipeline.rs`: inject a REAL graph with known node/edge counts, assert REAL
//! per-stage outputs, write a PNG final-picture blob the test reads back, and prove
//! the oracle has teeth with a deliberately-unclipped (bug) variant.
//!
//! The chain the spec (`facett/.nornir/render-engine.md`) names for the graph path:
//! `layout → cull → nodes → edges → picking → composite`. Per the EMIT-DOCTRINE we
//! assert on the returned [`GraphClipReport::state_json`] (counts) for every
//! observable stage and rely on the in-function `composite` emitter (testmatrix) for
//! the can't-observe pixel oracles.
//!
//! Run (CPU lane): `cargo test -p facett-graphview --test graph_clip`
//! With the matrix: `cargo test -p facett-graphview --test graph_clip --features testmatrix`
//! GPU lane (vello seam compiled): add `--features gpu`.
use facett_graphview::{
Camera, Color, Decorations, GraphClipReport, GraphEdge, GraphModel, GraphNode,
NodeDecoration, Pos, WidgetRect, pick, render_graph_clipped,
};
// ──────────────────────────────────────────────────────────────────────────
// blob path resolution (mirror map3d's warehouse_blob_dir)
// ──────────────────────────────────────────────────────────────────────────
fn blob_dir() -> std::path::PathBuf {
match std::env::var_os("NORNIR_WAREHOUSE_ROOT") {
Some(root) => std::path::PathBuf::from(root).join("facett-graphview"),
None => std::env::temp_dir().join("facett-graphview"),
}
}
/// 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::graph_clip", check, ok, detail);
}
// ──────────────────────────────────────────────────────────────────────────
// real, deterministic graph generators with KNOWN counts
// ──────────────────────────────────────────────────────────────────────────
/// A grid of `cols × rows` chips laid out in WORLD space at `spacing`, plus a
/// chain edge along each row. Returns the model — the caller knows exactly how many
/// nodes and edges it carries (the inject side of inject-assert).
fn grid(cols: usize, rows: usize, spacing: f32) -> GraphModel {
let mut nodes = Vec::new();
for r in 0..rows {
for c in 0..cols {
let i = r * cols + c;
nodes.push(GraphNode {
id: format!("n{i}"),
label: format!("node {i}"),
fill: Color::rgb((40 + (i * 37) % 200) as u8, (60 + (i * 53) % 180) as u8, 160),
stroke: Color::WHITE,
pos: Pos::new(c as f32 * spacing, r as f32 * spacing),
});
}
}
let mut edges = Vec::new();
for r in 0..rows {
for c in 0..cols.saturating_sub(1) {
let i = r * cols + c;
edges.push(GraphEdge {
from: format!("n{i}"),
to: format!("n{}", i + 1),
color: Color::rgb(210, 210, 230),
dashed: false,
label: None,
});
}
}
GraphModel { nodes, edges }
}
// ──────────────────────────────────────────────────────────────────────────
// 1. layout / cull / nodes / edges — RETURN proves correctness (counts)
// ──────────────────────────────────────────────────────────────────────────
#[test]
fn layout_cull_nodes_edges_counts_are_exact() {
// A 4×3 grid: 12 nodes, 9 chain edges (3 per row). Camera::fit frames the whole
// graph so every chip lands inside the widget rect — nothing is culled.
let m = grid(4, 3, 240.0);
assert_eq!(m.nodes.len(), 12);
assert_eq!(m.edges.len(), 9);
let rect = WidgetRect::new(20.0, 20.0, 760.0, 560.0);
// Fit into the RECT (not the whole canvas) so the whole graph is in-rect.
let cam = Camera::fit(&m, rect.w, rect.h, 60.0);
// Re-pan so the world centre lands at the RECT centre, not the canvas centre.
let (mnx, mny, mxx, mxy) = m.world_bounds().unwrap();
let (wcx, wcy) = ((mnx + mxx) * 0.5, (mny + mxy) * 0.5);
let cam = Camera {
zoom: cam.zoom,
pan_x: rect.x + rect.w * 0.5 - wcx * cam.zoom,
pan_y: rect.y + rect.h * 0.5 - wcy * cam.zoom,
};
let (_frame, rep) =
render_graph_clipped(&m, &Decorations::default(), &cam, 800, 600, rect, Color::rgb(18, 20, 28), true);
// STAGE layout: every node projected, no NaN.
let s = rep.state_json();
assert_eq!(s["layout"]["nodes_in"], 12);
assert_eq!(s["layout"]["projected"], 12);
assert_eq!(s["layout"]["nan"], 0);
// STAGE cull: with the whole graph in-rect, nothing is culled (monotone).
assert_eq!(rep.nodes_culled, 0, "nothing culled when the graph fits the rect");
// STAGE nodes: drawn == expected (all 12 in-rect).
assert_eq!(rep.nodes_drawn, 12, "nodes.drawn == expected");
// STAGE edges: drawn == expected (all 9 in-rect).
assert_eq!(rep.edges_drawn, 9, "edges.drawn == expected");
// The cull invariant: drawn + culled == in.
assert_eq!(rep.nodes_drawn + rep.nodes_culled, m.nodes.len());
emit("counts_exact", true, &format!("nodes_drawn={} edges_drawn={}", rep.nodes_drawn, rep.edges_drawn));
}
#[test]
fn cull_drops_offscreen_nodes_monotonically() {
// A wide grid with a TIGHT rect that only the left columns fall inside.
let m = grid(8, 2, 220.0); // 16 nodes, 14 edges
// A small rect at the canvas origin; identity-ish camera so only the first
// columns of world-space chips project inside it.
let rect = WidgetRect::new(0.0, 0.0, 300.0, 460.0);
let cam = Camera { pan_x: 60.0, pan_y: 60.0, zoom: 1.0 };
let (_f, rep) =
render_graph_clipped(&m, &Decorations::default(), &cam, 1200, 480, rect, Color::rgb(18, 20, 28), true);
// Some nodes are inside, some culled — and the two partition the input exactly.
assert!(rep.nodes_drawn > 0 && rep.nodes_drawn < m.nodes.len(), "a real partition: {} of {}", rep.nodes_drawn, m.nodes.len());
assert_eq!(rep.nodes_drawn + rep.nodes_culled, m.nodes.len(), "cull is a partition (monotone)");
assert!(rep.edges_drawn <= m.edges.len(), "edges monotone (drawn ≤ in)");
emit("cull_monotone", true, &format!("drawn={} culled={} in={}", rep.nodes_drawn, rep.nodes_culled, m.nodes.len()));
}
// ──────────────────────────────────────────────────────────────────────────
// 2. picking — RETURN proves it (a probe inside a chip resolves it; outside → None)
// ──────────────────────────────────────────────────────────────────────────
#[test]
fn picking_resolves_chip_under_probe_and_respects_rect() {
let m = grid(3, 3, 240.0); // 9 nodes
let rect = WidgetRect::new(0.0, 0.0, 800.0, 800.0);
let cam = Camera { pan_x: 120.0, pan_y: 120.0, zoom: 1.0 };
// The centre of node n4 (middle of a 3×3 at spacing 240, world (240,240)) →
// screen (240*1+120, 240*1+120) = (360,360).
let picked = pick(&m, &cam, rect, 360.0, 360.0);
assert_eq!(picked, Some("n4"), "probe at a chip centre picks that node");
// A gap between chips (between rows/cols) → no node.
assert_eq!(pick(&m, &cam, rect, 360.0, 240.0), None, "probe in a gap picks nothing");
// Outside the rect → None even if a chip is there.
let tight = WidgetRect::new(0.0, 0.0, 200.0, 200.0);
assert_eq!(pick(&m, &cam, tight, 360.0, 360.0), None, "outside the rect picks nothing");
emit("picking_resolves", true, "probe→chip inside rect; gap/outside→None");
}
// ──────────────────────────────────────────────────────────────────────────
// 3. composite clip oracle + PNG final-picture blob (read back, non-blank, in-rect)
// ──────────────────────────────────────────────────────────────────────────
/// A graph deliberately laid out so chips SPRAWL well past the widget rect on every
/// side — the worst case for the scissor (the graph twin of map3d's dolled-in pose).
fn sprawling() -> (GraphModel, Camera, WidgetRect, u32, u32) {
// 5×5 grid at a moderate spacing; a rect in the middle of a big canvas sized so
// that SEVERAL chips land inside, SOME straddle the rect boundary (the real
// scissor case), and the outer ring of chips sprawls fully outside on all sides.
let m = grid(5, 5, 150.0); // 25 nodes, 20 edges
let (canvas_w, canvas_h) = (900u32, 900u32);
// A 480×480 window centred on the canvas — smaller than the ~800px projected
// graph, so the outer chips fall outside and the boundary chips straddle it.
let rect = WidgetRect::new(210.0, 210.0, 480.0, 480.0);
// Centre the world on the rect, zoom 1 so the 800×800 world overflows the 360 rect.
let (mnx, mny, mxx, mxy) = m.world_bounds().unwrap();
let (wcx, wcy) = ((mnx + mxx) * 0.5, (mny + mxy) * 0.5);
let cam = Camera {
zoom: 1.0,
pan_x: rect.x + rect.w * 0.5 - wcx,
pan_y: rect.y + rect.h * 0.5 - wcy,
};
(m, cam, rect, canvas_w, canvas_h)
}
#[test]
fn composite_clipped_has_no_ink_outside_rect_and_writes_a_nonblank_png() {
let (m, cam, rect, cw, ch) = sprawling();
let bg = Color::rgb(18, 20, 28);
let (frame, rep) = render_graph_clipped(&m, &Decorations::default(), &cam, cw, ch, rect, bg, true);
// ── the FIX invariant: nothing painted outside the rect (pixels AND geometry).
assert_eq!(rep.ink_outside_rect, 0, "FIX: no pixel ink outside the widget rect");
assert_eq!(rep.geom_ink_outside_rect, 0, "FIX: no chip-geometry vertex escapes the rect");
// It actually drew (some chips ARE in-rect).
assert!(rep.lit_px > 0, "the clipped graph is non-blank (lit_px={})", rep.lit_px);
assert!(rep.nodes_drawn > 0, "at least one chip is in-rect ({} drawn)", rep.nodes_drawn);
// ── write the FINAL PICTURE blob (mirror map3d's composite blob).
let dir = blob_dir();
std::fs::create_dir_all(&dir).expect("blob dir");
let png_path = dir.join("graph_clip_final_picture.png");
let img = image::RgbaImage::from_raw(cw, ch, frame.rgba.clone()).expect("rgba→image");
img.save(&png_path).expect("write final-picture PNG blob");
eprintln!("[composite] FINAL PICTURE blob written: {}", png_path.display());
// ── read it BACK and re-derive the oracles from the file (agent-readable proof).
let loaded = image::open(&png_path).expect("reload blob").to_rgba8();
assert_eq!(loaded.dimensions(), (cw, ch), "blob sized to the canvas");
let (mut lit, mut outside) = (0u64, 0u64);
let bg3 = [bg.r as i32, bg.g as i32, bg.b as i32];
for (x, y, p) in loaded.enumerate_pixels() {
let d = (p.0[0] as i32 - bg3[0]).abs() + (p.0[1] as i32 - bg3[1]).abs() + (p.0[2] as i32 - bg3[2]).abs();
let painted = p.0[3] != 0 && d > 6;
if painted {
lit += 1;
let (px, py) = (x as f32 + 0.5, y as f32 + 0.5);
if !rect.contains(px, py) {
outside += 1;
}
}
}
assert!(lit > 0, "the reloaded blob is non-blank (lit={lit})");
assert_eq!(outside, 0, "the reloaded blob has NO painted pixel outside the rect (outside={outside})");
emit("composite_clipped_clean", true, &format!("lit_px={} ink_outside=0 nodes_drawn={}", rep.lit_px, rep.nodes_drawn));
}
// ──────────────────────────────────────────────────────────────────────────
// 4. SENSITIVITY — the deliberately-unclipped variant goes RED, the fix GREEN.
// Proves the oracle has teeth (fail-on-bug / pass-on-fix).
// ──────────────────────────────────────────────────────────────────────────
#[test]
fn unclipped_variant_leaks_ink_the_clipped_one_does_not() {
let (m, cam, rect, cw, ch) = sprawling();
let bg = Color::rgb(18, 20, 28);
// BUG side: clip = false → no scissor → ink escapes the rect.
let (_bf, bug) = render_graph_clipped(&m, &Decorations::default(), &cam, cw, ch, rect, bg, false);
// FIX side: clip = true → scissor → no ink escapes.
let (_ff, fixed) = render_graph_clipped(&m, &Decorations::default(), &cam, cw, ch, rect, bg, true);
// The decisive contrast: the unclipped variant DOES leak, the clipped one does NOT.
assert!(bug.ink_outside_rect > 0, "BUG: the unclipped variant paints outside the rect (ink={})", bug.ink_outside_rect);
assert!(bug.geom_ink_outside_rect > 0, "BUG: the unclipped variant's geometry escapes the rect");
assert_eq!(fixed.ink_outside_rect, 0, "FIX: the clipped variant paints nothing outside the rect");
assert_eq!(fixed.geom_ink_outside_rect, 0, "FIX: the clipped geometry is confined");
// Both still draw the in-rect content (the fix doesn't blank the view).
assert!(fixed.lit_px > 0 && bug.lit_px > 0, "both variants draw the in-rect graph");
// Same drawn-node count (the cull is identical; only the scissor differs).
assert_eq!(fixed.nodes_drawn, bug.nodes_drawn, "the cull is identical; only the scissor differs");
emit(
"sensitivity_bug_red_fix_green",
bug.ink_outside_rect > 0 && fixed.ink_outside_rect == 0,
&format!("bug_ink={} fix_ink={}", bug.ink_outside_rect, fixed.ink_outside_rect),
);
}
// ──────────────────────────────────────────────────────────────────────────
// 5. decorations carried through the clipped path (rings counted as drawn nodes' overlay)
// ──────────────────────────────────────────────────────────────────────────
#[test]
fn decorated_clipped_render_is_clean_and_nonblank() {
let m = grid(3, 3, 240.0);
let rect = WidgetRect::new(10.0, 10.0, 780.0, 580.0);
let cam = Camera::fit(&m, rect.w, rect.h, 60.0);
let (mnx, mny, mxx, mxy) = m.world_bounds().unwrap();
let (wcx, wcy) = ((mnx + mxx) * 0.5, (mny + mxy) * 0.5);
let cam = Camera { zoom: cam.zoom, pan_x: rect.x + rect.w * 0.5 - wcx * cam.zoom, pan_y: rect.y + rect.h * 0.5 - wcy * cam.zoom };
let mut deco = Decorations::default();
deco.nodes.insert("n0".into(), NodeDecoration { scale: None, ring: Some(Color::rgb(240, 80, 80)), badge: None, badge_color: None });
deco.edges.push(GraphEdge { from: "n0".into(), to: "n8".into(), color: Color::rgb(255, 200, 60), dashed: false, label: None });
let (_f, rep) = render_graph_clipped(&m, &deco, &cam, 800, 600, rect, Color::rgb(18, 20, 28), true);
assert_eq!(rep.ink_outside_rect, 0, "decorated render stays inside the rect");
assert!(rep.lit_px > 0, "decorated render is non-blank");
// 3×3 grid → (3-1)·3 = 6 base chain edges; +1 emphasis = 7 drawn.
assert_eq!(rep.edges_drawn, 7, "base + emphasis edges drawn");
emit("decorated_clean", true, &format!("edges_drawn={} lit_px={}", rep.edges_drawn, rep.lit_px));
}
// ──────────────────────────────────────────────────────────────────────────
// 6. GPU LANE (behind `--features gpu`) — the vello GPU seam runs over the clipped
// geometry and the SAME clip oracle holds through the shared CPU readback. Plus
// the deliberately-unclipped GPU variant goes RED (sensitivity on the GPU arm).
// ──────────────────────────────────────────────────────────────────────────
#[cfg(feature = "gpu")]
#[test]
fn gpu_lane_clipped_render_has_no_ink_outside_rect() {
use facett_graphview::clip::render_graph_clipped_gpu;
// Probe a usable GPU (informational — the seam runs regardless; the readback is
// the shared CPU raster, so this lane is deterministic on a headless box too).
let has_gpu = facett_graphview::gpu::probe_usable_gpu();
eprintln!("[gpu] probe_usable_gpu = {has_gpu}");
let (m, cam, rect, cw, ch) = sprawling();
let bg = Color::rgb(18, 20, 28);
// FIX side on the GPU dispatch arm.
let (frame, fixed) = render_graph_clipped_gpu(&m, &Decorations::default(), &cam, cw, ch, rect, bg, true);
assert_eq!(fixed.ink_outside_rect, 0, "GPU lane: no ink outside the rect (clipped)");
assert_eq!(fixed.geom_ink_outside_rect, 0, "GPU lane: clipped geometry confined");
assert!(fixed.lit_px > 0 && fixed.nodes_drawn > 0, "GPU lane drew the in-rect graph");
// BUG side on the GPU dispatch arm — proves teeth on this lane too.
let (_bf, bug) = render_graph_clipped_gpu(&m, &Decorations::default(), &cam, cw, ch, rect, bg, false);
assert!(bug.ink_outside_rect > 0, "GPU lane sensitivity: unclipped variant leaks (ink={})", bug.ink_outside_rect);
// Write the GPU-lane final-picture blob next to the CPU one.
let dir = blob_dir();
std::fs::create_dir_all(&dir).expect("blob dir");
let png_path = dir.join("graph_clip_final_picture_gpu.png");
image::RgbaImage::from_raw(cw, ch, frame.rgba.clone())
.expect("rgba→image")
.save(&png_path)
.expect("write GPU final-picture blob");
eprintln!("[gpu] FINAL PICTURE blob written: {}", png_path.display());
emit("gpu_lane_clipped_clean", true, &format!("has_gpu={has_gpu} fix_ink=0 bug_ink={}", bug.ink_outside_rect));
}
// Bring the report type into scope for doc-completeness (used via state_json above).
#[allow(unused_imports)]
use facett_graphview as _gv;
type _R = GraphClipReport;