facett-graphview 0.1.11

facett — a vello-backed, domain-agnostic scalable 2D graph render engine. Runtime-selects vello (GPU/wgpu) when a usable GPU exists, vello_cpu (multithreaded SIMD) as the no-GPU fallback. The eventual home for every graph surface (dep/arch/release dashboards, korp, graph-DB browsing).
Documentation
//! **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;