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