facett-graph3d/tests/neon_snapshot.rs
//! **Screenshot PROOF (LAW #4) for the orgasmic neon decor.**
//!
//! Renders [`Graph3D`] through egui_kittest's wgpu backend at a **pinned clock**
//! (so every neon effect — breathing, particle flow, shockwave — is a pure function
//! of the clock and the framebuffer is byte-stable) and asserts, exactly the
//! `pop_tab` pattern:
//! 1. **lit + many-shade** — `Full` decor draws a luminous, multi-hue frame (the
//! neon palette + glow orbs), not the old flat fill, and
//! 2. **the dim is real** — a **shockwave-active** frame (a node selected, its
//! subgraph lit, the rest dimmed to ~0.1α) emits **LESS total light** than the
//! unselected frame.
//!
//! Reused decor is `facett_core::render::decor` (glow/AO/particles/shockwave); the
//! CPU painter lane is wasm-clean, egui's wgpu backend rasterises it here for the PNG.
//!
//! Run: `cargo test -p facett-graph3d --test neon_snapshot`
//! Goldens: `facett-graph3d/tests/snapshots/neon_*.png`.
use egui::Color32;
use egui_kittest::Harness;
use facett_core::Facet;
use facett_graph3d::{DecorPolicy, Graph3D, Layout3D};
/// A deterministic medium graph: a ring + a few chords, distinct hues per node.
fn graph() -> Graph3D {
let nodes: Vec<(String, Color32)> =
(0..14).map(|i| (format!("n{i}"), Color32::from_rgb(0, 200, 220))).collect();
let mut edges: Vec<(usize, usize)> = (0..14).map(|i| (i, (i + 1) % 14)).collect();
edges.extend([(0, 7), (3, 10), (5, 12), (0, 5)]);
Graph3D::new(nodes, edges, Layout3D::Sphere)
}
/// `(distinct_shades, total_light)` of a frame. `distinct_shades` = number of
/// non-background colour buckets (a flat fill → tiny; a lit, neon-shaded scene →
/// many). `total_light` = Σ(r+g+b) over every pixel — the "how much light" oracle.
fn measure(img: &image::RgbaImage) -> (usize, u64) {
let mut hist = std::collections::HashMap::<u32, u32>::new();
for p in img.pixels() {
let key = ((p.0[0] as u32 >> 3) << 10) | ((p.0[1] as u32 >> 3) << 5) | (p.0[2] as u32 >> 3);
*hist.entry(key).or_default() += 1;
}
let (modal, _) = hist.iter().max_by_key(|(_, c)| **c).map(|(k, c)| (*k, *c)).unwrap();
let (mr, mg, mb) = (((modal >> 10) & 0x1f) as i32, ((modal >> 5) & 0x1f) as i32, (modal & 0x1f) as i32);
let mut shades = std::collections::HashSet::<u32>::new();
let mut light = 0u64;
for p in img.pixels() {
light += p.0[0] as u64 + p.0[1] as u64 + p.0[2] as u64;
let (r, g, b) = ((p.0[0] >> 3) as i32, (p.0[1] >> 3) as i32, (p.0[2] >> 3) as i32);
if (r - mr).abs() + (g - mg).abs() + (b - mb).abs() > 3 {
shades.insert(((r as u32) << 10) | ((g as u32) << 5) | b as u32);
}
}
(shades.len(), light)
}
fn render(g: Graph3D, name: &str) -> image::RgbaImage {
let mut view = g;
let mut harness = Harness::builder().with_size(egui::vec2(480.0, 480.0)).build_ui(move |ui| view.ui(ui));
harness.run_steps(3);
let img = harness.render().expect("wgpu render produced an image");
let out_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/snapshots");
std::fs::create_dir_all(&out_dir).unwrap();
image::DynamicImage::ImageRgba8(img.clone()).save(out_dir.join(format!("neon_{name}.png"))).ok();
img
}
#[test]
fn full_decor_is_lit_and_the_shockwave_dim_is_real() {
const CLOCK: f64 = 2.0; // pinned → deterministic neon (breathing/particles/wave)
// (a) Full decor, no selection: a lit, many-shade neon frame.
let unselected = render(graph().with_clock(CLOCK).with_decor(DecorPolicy::Full), "full_unselected");
let (shades, light_unsel) = measure(&unselected);
eprintln!("[neon] unselected: {shades} shades, {light_unsel} total light");
assert!(shades >= 8, "Full decor must paint a many-shade neon frame, got {shades}");
// (b) Same clock, node 0 selected → shockwave + lit subgraph, the rest dimmed.
let selected =
render(graph().with_clock(CLOCK).with_decor(DecorPolicy::Full).with_selected(0), "full_shockwave");
let (_, light_sel) = measure(&selected);
eprintln!("[neon] shockwave: {light_sel} total light");
assert!(
light_sel < light_unsel,
"the shockwave/dim must emit LESS total light ({light_sel}) than unselected ({light_unsel})"
);
// (c) Flat reference (decor Off) ⇒ far fewer shades than the neon frame: the
// neon palette genuinely lit the scene up.
let flat = render(graph().with_clock(CLOCK).with_decor(DecorPolicy::Off), "flat_reference");
let (flat_shades, _) = measure(&flat);
eprintln!("[neon] flat: {flat_shades} shades");
assert!(shades > flat_shades, "neon ({shades}) is richer than flat ({flat_shades})");
}