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

nordisk/facett

facett-graphpan/tests/gpu_fallback.rs

//! **The fail-dangerous shape, pinned: opting into the GPU without a renderer must
//! fall back VERTEX FOR VERTEX — never render a blank pane.**
//!
//! This is the defect that made `Graph3D`'s instanced cloud look like "the 3D graphs
//! are missing": the cloud was proven by tests but installed by no host, and
//! `with_gpu(true)` skipped the CPU painter, so opting in rendered **blank**. Nothing
//! was red. The pixel oracle said "a frame was produced"; the state oracle said "the
//! model has nodes"; neither said "nothing was drawn because nobody installed the
//! renderer".
//!
//! The whole 2-D graph family now inherits that opt-in, so the guarantee has to be
//! structural rather than remembered. It is: a pane may only skip its CPU painter on a
//! `true` from `facett_core::render::gpu::graphcloud::cloud::draw_cloud`, and that
//! function returns `false` unless a renderer provably exists in this process.
//!
//! **This box has no cloud renderer installed** (installing one needs an `egui_wgpu`
//! `RenderState`, which a headless test does not build), so every assertion below runs
//! in exactly the state that used to render blank. `vertices` is egui's own tessellated
//! vertex count — the same number the harness reports for every facet — so "vertex for
//! vertex" is literal here, not a figure of speech.

use egui::{pos2, Color32};
use facett_core::harness::headless_render;
use facett_graphpan::{GraphEdge, GraphModel, GraphNode, GraphPanView, PaintOpts};

fn node(id: &str, x: f32, y: f32) -> GraphNode {
    GraphNode {
        id: id.into(),
        label: id.into(),
        fill: Color32::from_rgb(120, 200, 255),
        stroke: Color32::from_rgb(30, 40, 60),
        pos: pos2(x, y),
    }
}

fn edge(from: &str, to: &str) -> GraphEdge {
    GraphEdge { from: from.into(), to: to.into(), color: Color32::WHITE, dashed: false, label: None }
}

/// A grid graph big enough that a blank pane would be unmistakable in the vertex count.
fn grid(n: usize) -> GraphModel {
    let side = (n as f32).sqrt().ceil() as usize;
    let nodes: Vec<GraphNode> = (0..n)
        .map(|i| {
            let (r, c) = (i / side, i % side);
            node(&format!("n{i}"), c as f32 * 60.0 - 300.0, r as f32 * 40.0 - 200.0)
        })
        .collect();
    let edges: Vec<GraphEdge> = (0..n.saturating_sub(1))
        .map(|i| edge(&format!("n{i}"), &format!("n{}", i + 1)))
        .collect();
    GraphModel { nodes, edges }
}

/// **THE test.** `with_gpu(true)` with no renderer installed draws the identical
/// frame as `with_gpu(false)` — same vertex count, same non-blankness. If the pane ever
/// skips its CPU painter on the opt-in alone, this drops to the handful of vertices the
/// toolbar contributes and goes RED.
#[test]
fn opting_into_gpu_without_a_renderer_falls_back_vertex_for_vertex() {
    let model = grid(64);

    let mut cpu = GraphPanView::new("cpu").with_model(model.clone());
    let cpu_r = headless_render(&mut cpu);

    let mut gpu = GraphPanView::new("gpu").with_model(model).with_gpu(true);
    let gpu_r = headless_render(&mut gpu);

    assert!(cpu_r.drew(), "the control frame must draw something at all");
    assert_eq!(
        gpu_r.vertices, cpu_r.vertices,
        "GPU opt-in with no installed renderer must paint the CPU frame vertex for vertex \
         (got {} vs {})",
        gpu_r.vertices, cpu_r.vertices
    );

    // ...and the pane SAYS the GPU lane did not paint, so a host can tell the
    // difference between "fell back" and "drew on the GPU" without reading pixels.
    let st = gpu.legibility();
    assert!(!st.gpu_cloud, "gpu_cloud must report false when no renderer is installed");
    assert!(
        !facett_graphpan::legible::gpu_cloud_available(),
        "this test only means anything on a box with NO renderer installed"
    );
}

/// The blank-pane failure mode, demonstrated. A frame with a real graph in it carries
/// far more vertices than an empty pane — so the equality above is not two zeros
/// agreeing with each other.
#[test]
fn the_control_proves_a_blank_pane_would_look_different() {
    let mut empty = GraphPanView::new("empty");
    let empty_r = headless_render(&mut empty);

    let mut full = GraphPanView::new("full").with_model(grid(64)).with_gpu(true);
    let full_r = headless_render(&mut full);

    assert!(
        full_r.vertices > empty_r.vertices * 3,
        "a 64-node graph must dwarf an empty pane ({} vs {}) — otherwise the \
         vertex-for-vertex assertion could pass on two blanks",
        full_r.vertices,
        empty_r.vertices
    );
}

/// Legibility off is bit-identical to the historical canvas — that is what keeps every
/// existing golden safe while the cures ship.
#[test]
fn legibility_off_is_the_historical_frame() {
    let model = grid(36);
    let mut a = GraphPanView::new("a").with_model(model.clone());
    let mut b = GraphPanView::new("b").with_model(model).with_paint_opts(PaintOpts::default());
    assert_eq!(headless_render(&mut a).vertices, headless_render(&mut b).vertices);
}

/// **Legibility ON dissolves the label pile**, and the state publishes it as data.
/// 400 nodes crammed into one screen: every label cannot be readable, so some must be
/// suppressed — and the ones that survive must not share a pixel.
#[test]
fn legibility_on_suppresses_overplotted_labels_and_the_survivors_are_disjoint() {
    // A deliberate pile: 400 chips in a 400×300 patch.
    let nodes: Vec<GraphNode> = (0..400)
        .map(|i| {
            node(
                &format!("node-{i}"),
                ((i * 17) % 400) as f32 - 200.0,
                ((i * 29) % 300) as f32 - 150.0,
            )
        })
        .collect();
    let model = GraphModel { nodes, edges: Vec::new() };

    let mut v = GraphPanView::new("legible").with_model(model).with_legibility(true);
    let r = headless_render(&mut v);
    let lg = &r.state["legibility"];
    assert!(r.drew());
    assert!(
        lg["labels_suppressed"].as_u64().unwrap() > 0,
        "a 400-label pile must actually suppress losers: {lg}"
    );
    assert!(
        lg["labels_placed"].as_u64().unwrap() > 0,
        "and must still draw the winners: {lg}"
    );
    assert_eq!(
        lg["labels_placed"].as_u64().unwrap() + lg["labels_suppressed"].as_u64().unwrap(),
        lg["labels_considered"].as_u64().unwrap(),
        "every considered label is either placed or suppressed — none vanish silently"
    );
}

/// **Picking uses the spatial index and gets the SAME answer as the linear scan.**
/// A fast wrong answer is worse than a slow right one, so this compares them directly
/// over a sweep of probes, including misses.
#[test]
fn indexed_picking_agrees_with_the_linear_scan_everywhere() {
    use facett_graphpan::{PickIndex, BOX_H, BOX_W};
    let model = grid(400);
    let origin = pos2(400.0, 300.0);
    let zoom = 0.35_f32;

    // The reference: the exact rect test `hit_test` performs, written out here so the
    // comparison does not depend on the private function staying public.
    let brute = |p: egui::Pos2| -> Option<usize> {
        let half = egui::vec2(BOX_W, BOX_H) * 0.5 * zoom;
        model.nodes.iter().position(|n| {
            let c = origin + n.pos.to_vec2() * zoom;
            (p.x - c.x).abs() <= half.x && (p.y - c.y).abs() <= half.y
        })
    };

    let mut idx = PickIndex::default();
    idx.ensure(&model, origin, zoom);
    let mut hits = 0usize;
    let mut misses = 0usize;
    for k in 0..600 {
        let p = pos2(((k * 13) % 800) as f32, ((k * 7) % 600) as f32);
        let got = idx.pick(p, zoom);
        let want = brute(p);
        assert_eq!(got, want, "probe {p:?}: index {got:?} vs scan {want:?}");
        if want.is_some() {
            hits += 1;
        } else {
            misses += 1;
        }
    }
    assert!(hits > 20, "the sweep must actually hit chips ({hits} hits)");
    assert!(misses > 20, "and must actually miss ({misses} misses)");
    // THE claim, as data: one pick tested a neighbourhood, not the dataset.
    assert!(
        idx.last_candidates <= 64,
        "one pick distance-tested {} of {} nodes",
        idx.last_candidates,
        model.nodes.len()
    );
}