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