tawasuyu/llimphi
Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.
1//! # llimphi-test — arnés golden-image headless y determinista
2//!
3//! El problema que cierra: en `PLAN-NUCLEO-RETENIDO.md` y `PARIDAD-FLUTTER.md`
4//! cada bloque termina con "falta verificar en metal / PNG-diff pendiente", y
5//! CLAUDE.md §8 prohíbe renderizar-y-mirar de rutina por costo de tokens. Este
6//! crate convierte esa verificación cara y subjetiva en `assert_golden!(...)`:
7//! barata, objetiva, en CI, certificada por **texto** ([`DiffStats`]).
8//!
9//! ## Determinismo
10//!
11//! El [`Harness`] pide un `Hal` por el adapter de **software**
12//! ([`llimphi_hal::Hal::new_headless(true)`]): el rasterizador llvmpipe/lavapipe
13//! (deviceType CPU) produce el **mismo píxel** en cualquier host y en CI, no
14//! atado a la GPU de quien generó el golden. Si la máquina no tiene un fallback
15//! adapter, cae al adapter normal y la tolerancia perceptual de [`GoldenConfig`]
16//! absorbe la diferencia de vendor.
17//!
18//! ## Uso típico (dentro de un test de otro crate)
19//!
20//! ```ignore
21//! use llimphi_test::assert_golden;
22//! #[test]
23//! fn card_con_sombra() {
24//! let v = mi_widget::card(...);
25//! assert_golden!("card_con_sombra", v, 400, 240); // tests/goldens/card_con_sombra.png
26//! }
27//! ```
28//!
29//! Regenerar los goldens: `LLIMPHI_GOLDEN_UPDATE=1 cargo test`.
30
31use std::cell::RefCell;
32use std::mem::ManuallyDrop;
33use std::path::Path;
34
35use llimphi_compositor::{measure_text_node, mount, paint, View};
36use llimphi_hal::{wgpu, Hal};
37use llimphi_layout::taffy;
38use llimphi_layout::LayoutTree;
39use llimphi_raster::peniko::Color;
40use llimphi_raster::{vello, Renderer};
41use llimphi_text::Typesetter;
42
43const FMT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm;
44
45/// Un frame rasterizado en memoria: RGBA8 sin padding, `w*h*4` bytes.
46#[derive(Clone)]
47pub struct Rendered {
48 pub w: u32,
49 pub h: u32,
50 /// RGBA8, fila mayor, `w*h*4` bytes exactos (sin el padding de wgpu).
51 pub rgba: Vec<u8>,
52}
53
54impl Rendered {
55 /// Guarda a PNG (formato inferido por la extensión).
56 pub fn save(&self, path: &Path) -> Result<(), String> {
57 let img = image::RgbaImage::from_raw(self.w, self.h, self.rgba.clone())
58 .ok_or_else(|| "buffer RGBA de tamaño inconsistente".to_string())?;
59 img.save(path).map_err(|e| e.to_string())
60 }
61
62 /// Carga un PNG a `Rendered` (lo normaliza a RGBA8).
63 pub fn load(path: &Path) -> Result<Self, String> {
64 let img = image::open(path).map_err(|e| e.to_string())?.to_rgba8();
65 Ok(Self { w: img.width(), h: img.height(), rgba: img.into_raw() })
66 }
67}
68
69/// Motor de render headless reutilizable: un `Hal` + `Renderer` + `Typesetter`
70/// vivos para muchos frames. Caro de construir (pide adapter), barato de reusar.
71pub struct Harness {
72 hal: Hal,
73 renderer: Renderer,
74 ts: Typesetter,
75}
76
77impl Harness {
78 /// Construye el arnés sobre el adapter de software (determinista). Ver
79 /// [`llimphi_hal::Hal::new_headless`].
80 pub fn new() -> Result<Self, String> {
81 let hal = pollster::block_on(Hal::new_headless(true)).map_err(|e| e.to_string())?;
82 let renderer = Renderer::new(&hal).map_err(|e| e.to_string())?;
83 Ok(Self { hal, renderer, ts: Typesetter::new() })
84 }
85
86 /// Rasteriza `view` en un lienzo `w×h` sobre fondo `bg` y devuelve el RGBA8.
87 /// Mismo pipeline que las apps reales: `mount → compute_with_measure → paint
88 /// → Renderer::render_to_view`, más el readback de la textura a CPU.
89 pub fn render<Msg: Clone>(&mut self, view: View<Msg>, w: u32, h: u32, bg: Color) -> Rendered {
90 let mut layout = LayoutTree::new();
91 let mounted = mount(&mut layout, view);
92 let ts = &mut self.ts;
93 let computed = {
94 let tmap = &mounted.text_measures;
95 layout
96 .compute_with_measure(mounted.root, (w as f32, h as f32), |nid, known, avail| {
97 match tmap.get(&nid) {
98 Some(tm) => measure_text_node(ts, tm, known, avail),
99 None => taffy::Size::ZERO,
100 }
101 })
102 .expect("compute_with_measure")
103 };
104 let mut scene = vello::Scene::new();
105 paint(&mut scene, &mounted, &computed, ts, None, None);
106
107 let target = self.hal.device.create_texture(&wgpu::TextureDescriptor {
108 label: Some("llimphi-test-target"),
109 size: wgpu::Extent3d { width: w, height: h, depth_or_array_layers: 1 },
110 mip_level_count: 1,
111 sample_count: 1,
112 dimension: wgpu::TextureDimension::D2,
113 format: FMT,
114 usage: wgpu::TextureUsages::STORAGE_BINDING
115 | wgpu::TextureUsages::RENDER_ATTACHMENT
116 | wgpu::TextureUsages::COPY_SRC,
117 view_formats: &[],
118 });
119 let tex_view = target.create_view(&wgpu::TextureViewDescriptor::default());
120 self.renderer
121 .render_to_view(&self.hal, &scene, &tex_view, w, h, bg)
122 .expect("render_to_view");
123 let rgba = readback(&self.hal, &target, w, h);
124 Rendered { w, h, rgba }
125 }
126}
127
128impl Harness {
129 /// Golden-testea contenido del backend **GPU-directo** (`GpuBatch` /
130 /// `gpu_paint_with`), que NO pasa por el `View`/vello de [`render`](Self::render).
131 /// Crea un target `Rgba8Unorm`, lo limpia a `bg`, y le pasa a `f` el
132 /// `(device, queue, encoder, view, viewport)` — la misma firma que
133 /// `View::gpu_paint_with` — para que pinte directo; después lee de vuelta.
134 /// El caller típico arma un `GpuBatch` y hace `flush(..., LoadOp::Load)`
135 /// (compone sobre el `bg` ya limpiado, igual que sobre lo que vello pintó en
136 /// la app real). Cierra el hueco "el path GPU-directo no tenía cobertura de
137 /// regresión visual" (antes sólo el smoke test "no panic").
138 pub fn render_gpu<F>(&mut self, w: u32, h: u32, bg: Color, f: F) -> Rendered
139 where
140 F: FnOnce(&wgpu::Device, &wgpu::Queue, &mut wgpu::CommandEncoder, &wgpu::TextureView, (f32, f32)),
141 {
142 let target = self.hal.device.create_texture(&wgpu::TextureDescriptor {
143 label: Some("llimphi-test-gpu-target"),
144 size: wgpu::Extent3d { width: w, height: h, depth_or_array_layers: 1 },
145 mip_level_count: 1,
146 sample_count: 1,
147 dimension: wgpu::TextureDimension::D2,
148 format: FMT,
149 usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
150 view_formats: &[],
151 });
152 let tex_view = target.create_view(&wgpu::TextureViewDescriptor::default());
153 let mut enc = self
154 .hal
155 .device
156 .create_command_encoder(&wgpu::CommandEncoderDescriptor { label: None });
157 // Pre-clear a `bg` (como haría vello en la app real antes del gpu_paint_with).
158 let [r, g, b, a] = bg.components;
159 {
160 let _clear = enc.begin_render_pass(&wgpu::RenderPassDescriptor {
161 label: Some("llimphi-test-gpu-clear"),
162 color_attachments: &[Some(wgpu::RenderPassColorAttachment {
163 view: &tex_view,
164 resolve_target: None,
165 depth_slice: None,
166 ops: wgpu::Operations {
167 load: wgpu::LoadOp::Clear(wgpu::Color {
168 r: r as f64,
169 g: g as f64,
170 b: b as f64,
171 a: a as f64,
172 }),
173 store: wgpu::StoreOp::Store,
174 },
175 })],
176 depth_stencil_attachment: None,
177 timestamp_writes: None,
178 occlusion_query_set: None,
179 });
180 }
181 f(&self.hal.device, &self.hal.queue, &mut enc, &tex_view, (w as f32, h as f32));
182 self.hal.queue.submit(std::iter::once(enc.finish()));
183 let rgba = readback(&self.hal, &target, w, h);
184 Rendered { w, h, rgba }
185 }
186}
187
188/// Copia la textura a un buffer mapeable, quita el padding de fila de wgpu y
189/// devuelve RGBA8 compacto. Patrón idéntico al `write_png` de los examples.
190fn readback(hal: &Hal, target: &wgpu::Texture, w: u32, h: u32) -> Vec<u8> {
191 let unpadded = (w * 4) as usize;
192 let align = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT as usize;
193 let padded = unpadded.div_ceil(align) * align;
194 let buf = hal.device.create_buffer(&wgpu::BufferDescriptor {
195 label: Some("llimphi-test-readback"),
196 size: (padded * h as usize) as u64,
197 usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
198 mapped_at_creation: false,
199 });
200 let mut enc = hal
201 .device
202 .create_command_encoder(&wgpu::CommandEncoderDescriptor { label: None });
203 enc.copy_texture_to_buffer(
204 wgpu::TexelCopyTextureInfo {
205 texture: target,
206 mip_level: 0,
207 origin: wgpu::Origin3d::ZERO,
208 aspect: wgpu::TextureAspect::All,
209 },
210 wgpu::TexelCopyBufferInfo {
211 buffer: &buf,
212 layout: wgpu::TexelCopyBufferLayout {
213 offset: 0,
214 bytes_per_row: Some(padded as u32),
215 rows_per_image: Some(h),
216 },
217 },
218 wgpu::Extent3d { width: w, height: h, depth_or_array_layers: 1 },
219 );
220 hal.queue.submit(std::iter::once(enc.finish()));
221 let slice = buf.slice(..);
222 let (tx, rx) = std::sync::mpsc::channel();
223 slice.map_async(wgpu::MapMode::Read, move |r| {
224 let _ = tx.send(r);
225 });
226 let _ = hal.device.poll(wgpu::PollType::wait_indefinitely());
227 rx.recv().unwrap().unwrap();
228 let data = slice.get_mapped_range();
229 let mut out = Vec::with_capacity((w * h * 4) as usize);
230 for row in 0..h as usize {
231 let s = row * padded;
232 out.extend_from_slice(&data[s..s + unpadded]);
233 }
234 drop(data);
235 buf.unmap();
236 out
237}
238
239// ───────────────────────────── diff ─────────────────────────────
240
241/// Estadísticas de un diff, imprimibles como **texto** — esto es lo que
242/// certifica sin mirar el PNG (CLAUDE.md §8: "stats numéricas como texto").
243#[derive(Debug, Clone, Copy, PartialEq)]
244pub struct DiffStats {
245 /// Píxeles cuyo mayor delta de canal superó el umbral.
246 pub differing_pixels: u64,
247 pub total_pixels: u64,
248 /// Máximo delta de canal (0-255) observado en toda la imagen.
249 pub max_delta: u8,
250 /// `differing_pixels / total_pixels`.
251 pub fraction: f64,
252}
253
254/// Compara dos frames píxel a píxel. Un píxel "difiere" si el máximo de los
255/// deltas absolutos de sus cuatro canales supera `max_channel_delta`. Devuelve
256/// un heatmap (rojo ∝ delta sobre gris oscuro) + las stats. Los frames deben
257/// tener las mismas dimensiones.
258pub fn diff(a: &Rendered, b: &Rendered, max_channel_delta: u8) -> Result<(Rendered, DiffStats), String> {
259 if a.w != b.w || a.h != b.h {
260 return Err(format!("dimensiones distintas: {}×{} vs {}×{}", a.w, a.h, b.w, b.h));
261 }
262 let total = (a.w * a.h) as u64;
263 let mut differing = 0u64;
264 let mut max_delta = 0u8;
265 let mut heat = vec![0u8; a.rgba.len()];
266 for (i, (pa, pb)) in a.rgba.chunks_exact(4).zip(b.rgba.chunks_exact(4)).enumerate() {
267 let d = (0..4).map(|c| pa[c].abs_diff(pb[c])).max().unwrap_or(0);
268 max_delta = max_delta.max(d);
269 let o = i * 4;
270 if d > max_channel_delta {
271 differing += 1;
272 heat[o] = 255; // R ∝ presencia de diff
273 heat[o + 1] = 30;
274 heat[o + 2] = 30;
275 heat[o + 3] = 255;
276 } else {
277 heat[o] = 20; // gris oscuro = sin diff
278 heat[o + 1] = 20;
279 heat[o + 2] = 20;
280 heat[o + 3] = 255;
281 }
282 }
283 let stats = DiffStats {
284 differing_pixels: differing,
285 total_pixels: total,
286 max_delta,
287 fraction: differing as f64 / total.max(1) as f64,
288 };
289 Ok((Rendered { w: a.w, h: a.h, rgba: heat }, stats))
290}
291
292// ───────────────────────────── golden ─────────────────────────────
293
294/// Tolerancia de comparación golden. Default pensado para el adapter de
295/// software: casi bit-exacto, con un colchón mínimo para versiones distintas
296/// de LLVM/Mesa entre la máquina que generó el golden y la que lo verifica.
297#[derive(Debug, Clone, Copy)]
298pub struct GoldenConfig {
299 /// Delta de canal (0-255) por debajo del cual un píxel NO cuenta como diff.
300 pub max_channel_delta: u8,
301 /// Fracción de píxeles diferentes tolerada antes de fallar.
302 pub max_diff_fraction: f64,
303}
304
305impl Default for GoldenConfig {
306 fn default() -> Self {
307 Self { max_channel_delta: 4, max_diff_fraction: 0.001 }
308 }
309}
310
311/// Compara `rendered` contra el golden en `path`.
312///
313/// - `LLIMPHI_GOLDEN_UPDATE=1` en el entorno → **reescribe** el golden y pasa.
314/// - Golden ausente y sin modo update → error (junto al golden queda el
315/// `.actual.png` para revisar y promover con el modo update).
316/// - Presente → diff con `cfg`; si supera la tolerancia, escribe `.actual.png`
317/// y `.diff.png` al lado y devuelve error con las [`DiffStats`].
318pub fn compare_golden(path: &Path, rendered: &Rendered, cfg: &GoldenConfig) -> Result<(), String> {
319 let update = std::env::var("LLIMPHI_GOLDEN_UPDATE").map(|v| v == "1").unwrap_or(false);
320 if update {
321 if let Some(dir) = path.parent() {
322 std::fs::create_dir_all(dir).map_err(|e| e.to_string())?;
323 }
324 rendered.save(path)?;
325 return Ok(());
326 }
327 if !path.exists() {
328 let actual = with_suffix(path, "actual");
329 let _ = rendered.save(&actual);
330 return Err(format!(
331 "golden ausente: {path:?}. Escribe {actual:?} para revisar; \
332 promovelo con LLIMPHI_GOLDEN_UPDATE=1"
333 ));
334 }
335 let golden = Rendered::load(path)?;
336 let (heat, stats) = diff(&golden, rendered, cfg.max_channel_delta)?;
337 if stats.fraction > cfg.max_diff_fraction {
338 let actual = with_suffix(path, "actual");
339 let dpng = with_suffix(path, "diff");
340 let _ = rendered.save(&actual);
341 let _ = heat.save(&dpng);
342 return Err(format!(
343 "golden {path:?}: {} / {} px difieren (frac {:.5} > {:.5}), max_delta {}. \
344 Ver {actual:?} y {dpng:?}",
345 stats.differing_pixels, stats.total_pixels, stats.fraction, cfg.max_diff_fraction, stats.max_delta
346 ));
347 }
348 Ok(())
349}
350
351fn with_suffix(path: &Path, suffix: &str) -> std::path::PathBuf {
352 let stem = path.file_stem().and_then(|s| s.to_str()).unwrap_or("golden");
353 let parent = path.parent().unwrap_or_else(|| Path::new("."));
354 parent.join(format!("{stem}.{suffix}.png"))
355}
356
357// ───────────────────────── harness compartido ─────────────────────────
358
359thread_local! {
360 // `ManuallyDrop`: el `Hal` envuelve una `wgpu::Instance` cuyo teardown sobre
361 // Vulkan/llvmpipe al cerrar el hilo (destructor de TLS) paniquea —el mismo
362 // teardown frágil que `Hal::new` documenta para GL. Un proceso de test que
363 // sale no necesita liberar la GPU, así que NO soltamos el arnés: se leakea al
364 // salir el proceso (barato, seguro) en vez de crashear en el destructor.
365 static TL_HARNESS: RefCell<Option<ManuallyDrop<Harness>>> = const { RefCell::new(None) };
366}
367
368/// Corre `f` con el [`Harness`] del hilo actual (lo crea perezosamente). Un
369/// arnés por hilo evita compartir el `Typesetter`/`Hal` entre threads de test.
370pub fn with_harness<R>(f: impl FnOnce(&mut Harness) -> R) -> R {
371 TL_HARNESS.with(|h| {
372 let mut b = h.borrow_mut();
373 if b.is_none() {
374 *b = Some(ManuallyDrop::new(
375 Harness::new().expect("crear Harness de llimphi-test"),
376 ));
377 }
378 f(b.as_mut().unwrap())
379 })
380}
381
382/// Fondo por defecto de `assert_golden!` — negro opaco.
383pub fn default_bg() -> Color {
384 Color::from_rgba8(0, 0, 0, 255)
385}
386
387/// Rasteriza `view` con el arnés compartido y lo compara contra el golden en
388/// `path`. Paniquea (fallo de test) si supera la tolerancia. Ver
389/// [`compare_golden`]. Usar el macro [`assert_golden!`] para el caso común.
390pub fn assert_golden_at<Msg: Clone>(
391 path: &Path,
392 view: View<Msg>,
393 w: u32,
394 h: u32,
395 bg: Color,
396 cfg: GoldenConfig,
397) {
398 let rendered = with_harness(|hn| hn.render(view, w, h, bg));
399 if let Err(e) = compare_golden(path, &rendered, &cfg) {
400 panic!("{e}");
401 }
402}
403
404/// `assert_golden!("nombre", view, w, h[, bg])` — compara contra
405/// `<crate>/tests/goldens/nombre.png` con la tolerancia default. El path usa el
406/// `CARGO_MANIFEST_DIR` del crate que invoca el macro (no el de llimphi-test).
407#[macro_export]
408macro_rules! assert_golden {
409 ($name:expr, $view:expr, $w:expr, $h:expr) => {
410 $crate::assert_golden!($name, $view, $w, $h, $crate::default_bg())
411 };
412 ($name:expr, $view:expr, $w:expr, $h:expr, $bg:expr) => {{
413 let path = ::std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
414 .join("tests")
415 .join("goldens")
416 .join(format!("{}.png", $name));
417 $crate::assert_golden_at(&path, $view, $w, $h, $bg, $crate::GoldenConfig::default());
418 }};
419}