gioser

tawasuyu/llimphi

Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.

Rust 660Markdown 302TOML 157RON 312382 Definiciones

⎇ c5a232cf24788ab75a850ffe81eca5a7af50e2f8 llimphi-test/src/lib.rs Rust · 419 líneas · 17.1 KB Historia Quién tocó qué crudo

chore: refresco desde el monorepo — 27 crates nuevos (anim, lottie, svg, mesh, image, layer, test, wire-view, widgets rag-sidebar/router/lazy-list/rive-button) y sync de los 95 existentes 93253cc22 · Sergio ·

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}