facett-graphview/tests/legibility.rs
//! **Legibility + the GPU fallback for the graph render engine.**
//!
//! Two things are pinned here, both of which were prose before:
//!
//! 1. `clip::pick` scans every node in the model on every probe. [`PickIndex`] answers
//! from a screen-space neighbourhood instead — and must return the **identical**
//! node, including where chips overlap and the tiebreak decides. A fast wrong answer
//! is worse than a slow right one, so the two are swept against each other.
//! 2. Opting into the shared instanced GPU cloud on a box with **no renderer
//! installed** must render the CPU frame vertex for vertex. That is the exact shape
//! that made `Graph3D`'s cloud paint a blank pane: proven by tests, installed by no
//! host, CPU painter skipped anyway.
use facett_core::harness::headless_render;
use facett_graphview::clip::{pick, WidgetRect};
use facett_graphview::model::{Camera, Color, GraphEdge, GraphModel, GraphNode, Pos};
use facett_graphview::{DecoratedGraphView, PickIndex};
fn model(n: usize) -> GraphModel {
let side = (n as f32).sqrt().ceil() as usize;
let nodes = (0..n)
.map(|i| {
let (r, c) = (i / side, i % side);
GraphNode {
id: format!("n{i}"),
label: format!("node-{i}"),
fill: Color::rgb(60, 90, 160),
stroke: Color::WHITE,
pos: Pos::new(c as f32 * 70.0, r as f32 * 45.0),
}
})
.collect();
let edges = (0..n.saturating_sub(1))
.map(|i| GraphEdge {
from: format!("n{i}"),
to: format!("n{}", i + 1),
color: Color::rgb(200, 200, 220),
dashed: false,
label: None,
})
.collect();
GraphModel { nodes, edges }
}
/// **The index and the scan must agree, probe for probe** — including the overlapping
/// chips where the tiebreak is the whole question.
#[test]
fn indexed_picking_is_identical_to_the_linear_scan() {
let m = model(900);
let cam = Camera { pan_x: 20.0, pan_y: 15.0, zoom: 0.4 };
let rect = WidgetRect::new(0.0, 0.0, 900.0, 700.0);
let erect = egui::Rect::from_min_size(egui::pos2(0.0, 0.0), egui::vec2(900.0, 700.0));
let mut idx = PickIndex::default();
idx.ensure(&m, &cam);
let (mut hits, mut misses) = (0usize, 0usize);
for k in 0..800 {
let p = egui::pos2(((k * 11) % 900) as f32, ((k * 17) % 700) as f32);
let want = pick(&m, &cam, rect, p.x, p.y);
let got = idx.pick(&cam, erect, p).map(|i| m.nodes[i].id.as_str());
assert_eq!(got, want, "probe {p:?}: index {got:?} vs scan {want:?}");
if want.is_some() {
hits += 1;
} else {
misses += 1;
}
}
assert!(hits > 50, "the sweep must hit real chips ({hits})");
assert!(misses > 20, "and must miss ({misses})");
}
/// **THE claim, as data.** One pick touches a neighbourhood; the scan it replaced
/// touched the whole model. Asserted over a 100× dataset growth so the number cannot
/// quietly start tracking `n` again.
#[test]
fn pick_cost_tracks_the_neighbourhood_not_the_model() {
let mut seen = Vec::new();
for n in [900usize, 10_000, 90_000] {
let m = model(n);
let cam = Camera { pan_x: 0.0, pan_y: 0.0, zoom: 1.0 };
let erect = egui::Rect::from_min_size(egui::pos2(0.0, 0.0), egui::vec2(100_000.0, 100_000.0));
let mut idx = PickIndex::default();
idx.ensure(&m, &cam);
// A probe on a real chip, well inside the lattice.
let (cx, cy) = cam.project(m.nodes[n / 2].pos);
assert!(idx.pick(&cam, erect, egui::pos2(cx, cy)).is_some(), "n={n}: the probe must hit");
assert!(
idx.last_candidates <= 128,
"n={n}: one pick distance-tested {} nodes",
idx.last_candidates
);
seen.push((n, idx.last_candidates));
}
let (n0, c0) = seen[0];
let (n1, c1) = *seen.last().unwrap();
assert!(
c1 <= c0 * 2,
"model {n0} -> {n1} (×{}) but pick cost {c0} -> {c1}",
n1 / n0
);
}
/// GPU opt-in with **no installed renderer** paints the CPU frame vertex for vertex.
#[test]
fn gpu_optin_without_a_renderer_falls_back_vertex_for_vertex() {
let m = model(100);
let mut cpu = DecoratedGraphView::new("cpu").with_model(m.clone());
let mut gpu = DecoratedGraphView::new("gpu").with_model(m).with_gpu(true);
let c = headless_render(&mut cpu);
let g = headless_render(&mut gpu);
assert!(c.drew(), "the control frame draws");
assert_eq!(
g.vertices, c.vertices,
"no renderer installed ⇒ the CPU frame, vertex for vertex ({} vs {})",
g.vertices, c.vertices
);
assert_eq!(
g.state["legibility"]["gpu_cloud"],
serde_json::json!(false),
"and the pane says so"
);
// Not two blanks agreeing: an empty pane is visibly smaller.
let mut empty = DecoratedGraphView::new("empty");
assert!(g.vertices > headless_render(&mut empty).vertices * 3);
}
/// Legibility ON dissolves a label pile and publishes the numbers; OFF is the
/// historical frame.
#[test]
fn legibility_on_suppresses_overplot_and_off_is_unchanged() {
// 600 chips crammed together so their labels cannot all be readable.
let mut m = model(600);
for (i, n) in m.nodes.iter_mut().enumerate() {
n.pos = Pos::new(((i * 17) % 300) as f32, ((i * 29) % 220) as f32);
}
let mut plain = DecoratedGraphView::new("plain").with_model(m.clone());
let plain_r = headless_render(&mut plain);
let mut plain2 = DecoratedGraphView::new("plain2").with_model(m.clone());
assert_eq!(
headless_render(&mut plain2).vertices,
plain_r.vertices,
"the default pane is deterministic"
);
let mut legible = DecoratedGraphView::new("legible").with_model(m).with_legibility(true);
let r = headless_render(&mut legible);
let lg = &r.state["legibility"];
assert!(r.drew());
assert!(
lg["labels_suppressed"].as_u64().unwrap() > 0,
"a 600-label pile must suppress losers: {lg}"
);
assert!(lg["labels_placed"].as_u64().unwrap() > 0, "and keep winners: {lg}");
assert!(
r.vertices < plain_r.vertices,
"fewer labels drawn ⇒ fewer vertices ({} vs {})",
r.vertices,
plain_r.vertices
);
}