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