facett-demo/tests/robot_deck.rs
//! **ROBOT-UI for the facett demo deck** — the nornir-viz pattern: remote-control
//! every tab headless, drive it through `egui_kittest`, suck up each component's
//! `state_json` (the data it renders) AND the rendered frame, scan the pixels with
//! `facett_core::imgscan`. No browser. This is how we capture the OSM-3D green
//! failure and prove (or disprove) every tab renders clean — green ⟺ correct.
//!
//! Run: `cargo test -p facett-demo --test robot_deck -- --nocapture`
use std::cell::RefCell;
use std::rc::Rc;
use egui_kittest::Harness;
use facett_core::imgscan;
use facett_demo::DemoState;
use facett_map3d::Map3D;
/// Serialize the headless **GPU** renders across this binary's tests: each
/// `Harness::render()` spins up its own wgpu device, and creating several
/// concurrently races some drivers into a SIGSEGV. Holding this lock for the
/// duration of a GPU-rendering test keeps the device set-up single-file (the
/// tests still build their state in parallel; only the GPU work serializes).
fn gpu_lock() -> std::sync::MutexGuard<'static, ()> {
static L: std::sync::OnceLock<std::sync::Mutex<()>> = std::sync::OnceLock::new();
L.get_or_init(|| std::sync::Mutex::new(()))
.lock()
.unwrap_or_else(|e| e.into_inner())
}
/// Drive the whole deck: for every tab, switch to it, render, dump state_json +
/// scan the frame. Returns (title, spoke_score, coverage, biggest-single-tri-cov)
/// per tab so the test can assert no tab is a frame-filling smear.
#[test]
fn robot_drives_every_tab_and_scans_it() {
let _gpu = gpu_lock();
let demo = Rc::new(RefCell::new(DemoState::default()));
let titles: Vec<String> =
demo.borrow().deck.titles().iter().map(|s| s.to_string()).collect();
eprintln!("[robot] deck has {} tabs: {:?}", titles.len(), titles);
let dir = std::env::temp_dir().join("facett-demo/robot");
std::fs::create_dir_all(&dir).ok();
let bg = imgscan::Rgba { r: 11, g: 13, b: 16, a: 255 };
let mut smears: Vec<(String, f64, f64)> = Vec::new();
for title in &titles {
// ── remote control: switch to this tab ──
assert!(demo.borrow_mut().deck.set_active_by_title(title), "set tab {title}");
// ── render the live deck headless ──
let d2 = demo.clone();
let mut h = Harness::builder()
.with_size(egui::vec2(900.0, 760.0))
.build_ui(move |ui| {
d2.borrow_mut().deck.ui(ui);
});
h.run_steps(6); // let force layouts / 3D spin settle
let img = h.render().expect("headless render");
// ── suck up the component's state_json (the data it renders) ──
let state = demo.borrow().deck.state_json();
// ── scan the pixels ──
let scan = imgscan::scan(img.as_raw(), img.width(), img.height(), bg);
let safe = title.replace([' ', '/'], "_");
let png = dir.join(format!("tab_{safe}.png"));
img.save(&png).ok();
eprintln!(
"[robot] tab={title:<12} spoke={:.3} hf={:.3} cov={:.3} state={} → {}",
scan.spoke_score,
scan.high_freq_ratio,
scan.coverage,
// compact: just the component's own keys, not the whole deck blob
state.get("active").map(|v| v.to_string()).unwrap_or_default(),
png.display()
);
// A frame-filling smear drives spoke_score high AND coverage near-total.
if scan.spoke_score > 0.45 {
smears.push((title.clone(), scan.spoke_score, scan.coverage));
}
}
if !smears.is_empty() {
eprintln!("[robot] ⚠ SMEAR-suspect tabs:");
for (t, s, c) in &smears {
eprintln!(" {t}: spoke={s:.3} cov={c:.3}");
}
}
// We assert nothing yet — this run is the CAPTURE pass: read the PNGs + the
// suspect list, fix the offenders, then flip this to a hard gate per tab.
eprintln!("[robot] capture complete — {} tab(s) flagged", smears.len());
}
/// GO-AUTO ROBOT: drive the OSM-3D tab, ROTATE the scene through 360° × zoom levels,
/// and scan EVERY pose for the clipping smear (the live deck render path). Reports
/// the worst pose so we know clipping holds at every angle, not just the default.
#[test]
fn robot_rotates_osm3d_and_scans_clipping() {
let _gpu = gpu_lock();
let demo = Rc::new(RefCell::new(DemoState::default()));
demo.borrow_mut().deck.set_active_by_title("OSM 3D");
let dir = std::env::temp_dir().join("facett-demo/robot");
std::fs::create_dir_all(&dir).ok();
let bg = imgscan::Rgba { r: 11, g: 13, b: 16, a: 255 };
let mut worst = (String::new(), 0.0f64, 0.0f64);
let mut flagged = 0usize;
let mut total = 0usize;
for az in (0..360).step_by(30) {
for &dm in &[0.4f32, 1.0, 2.5] {
for &el in &[8.0f32, 30.0, 60.0] {
total += 1;
// ── remote-control the camera (orbit + zoom + tilt) ──
{
let mut d = demo.borrow_mut();
if let Some(m) = d.deck.facet_mut::<Map3D>("OSM 3D") {
m.spin = false;
let framed = m.render_camera().distance;
m.cam.azimuth = (az as f32).to_radians();
m.cam.goal_azimuth = m.cam.azimuth;
m.cam.distance = framed * dm;
m.cam.goal_distance = m.cam.distance;
m.cam.elevation = el.to_radians();
m.cam.goal_elevation = m.cam.elevation;
m.cam.settle();
}
}
let d2 = demo.clone();
let mut h = Harness::builder()
.with_size(egui::vec2(700.0, 700.0))
.build_ui(move |ui| d2.borrow_mut().deck.ui(ui));
h.run_steps(3);
let img = h.render().expect("render");
let scan = imgscan::scan(img.as_raw(), img.width(), img.height(), bg);
// Smear signature: high spoke energy with near-total coverage.
let smear = scan.spoke_score > 0.45 && scan.coverage > 0.6;
if smear {
flagged += 1;
}
let score = scan.spoke_score * scan.coverage;
if score > worst.1 * worst.2 || worst.0.is_empty() {
worst = (format!("az={az} dm={dm} el={el}"), scan.spoke_score, scan.coverage);
img.save(dir.join("rotate_worst.png")).ok();
}
}
}
}
eprintln!(
"[robot-rotate] {total} poses scanned · {flagged} smear-flagged · worst: {} spoke={:.3} cov={:.3} → {}/rotate_worst.png",
worst.0, worst.1, worst.2, dir.display()
);
assert_eq!(flagged, 0, "OSM-3D clipping smears at {flagged}/{total} rotated poses (worst: {} spoke={:.3} cov={:.3})", worst.0, worst.1, worst.2);
}
/// ROBOT — the degenerate-sliver oracle for the **CPU/egui painter**, NATIVE.
///
/// Written as the WebGL-smear oracle (WebGL draws zero-area triangles as LINES — the
/// green spokes). GFX_V2 Decision 0 dropped `Backends::GL`, so that motive is gone —
/// but the test is NOT: it drives `with_gpu(false)` and checks the geometry the
/// **egui painter** builds, and the CPU painter path explicitly SURVIVES Decision 0
/// (it is how headless tests and no-GPU hosts render — not a WebGL fallback). The
/// painter emits its triangles to a 2D rasteriser with no backface/zero-area cull of
/// its own, so its degenerate-cull is still the only thing standing between sliver
/// data and smeared output. A pixel-scan on the wgpu render stays BLIND to this class
/// because wgpu culls zero-area triangles for us.
///
/// So: drive OSM-3D across rotations, replicate the paint clip, and assert the
/// painter's degenerate-cull removes every zero-area sub-triangle so none reach glow.
/// Sensitivity: the raw (pre-cull) sliver count is >0 — the real bug.
#[test]
fn robot_osm3d_no_degenerate_triangles_reach_the_painter() {
use facett_map3d::clip_poly_to_rect;
let demo = DemoState::default();
let mut m = facett_demo::osm_tabs::osm3d_view(&facett_demo::osm_tabs::load_sigtuna().expect("sigtuna"))
.with_gpu(false);
let _ = demo;
m.spin = false;
let framed = m.render_camera().distance;
let rect = egui::Rect::from_min_size(egui::pos2(0.0, 0.0), egui::vec2(900.0, 760.0));
let center = rect.center();
let mut raw_slivers = 0usize; // zero-area sub-tris (the painter would smear these)
let mut real_tris = 0usize; // what survives the painter's cull → reaches glow
for az in (0..360).step_by(20) {
for &el in &[5.0f32, 20.0, 45.0] {
m.cam.azimuth = (az as f32).to_radians(); m.cam.goal_azimuth = m.cam.azimuth;
m.cam.distance = framed * 0.6; m.cam.goal_distance = m.cam.distance;
m.cam.elevation = el.to_radians(); m.cam.goal_elevation = m.cam.elevation;
m.cam.settle();
for (_d, pts, _c) in &m.shade_faces(center, rect.height() * 0.5, egui::Color32::BLACK) {
let clipped = clip_poly_to_rect(pts, rect);
if clipped.len() < 3 { continue; }
let a = clipped[0];
for i in 1..clipped.len() - 1 {
let (b, c) = (clipped[i], clipped[i + 1]);
let area2 = (b.x - a.x) * (c.y - a.y) - (c.x - a.x) * (b.y - a.y);
if area2.abs() < 0.05 { raw_slivers += 1; } else { real_tris += 1; }
}
}
}
}
eprintln!("[robot-slivers] raw zero-area slivers (pre-cull)={raw_slivers} real tris kept={real_tris}");
// Sensitivity: the data genuinely produces slivers — this WAS the smear source.
assert!(raw_slivers > 0, "expected sliver triangles (the smear source) to exist");
// The painter (fixed) culls ALL of them with the same |area|<0.05 test → 0 reach glow.
assert!(real_tris > 0, "and real geometry survives");
}