tawasuyu

tawasuyu/llimphi

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

Rust 660Markdown 302TOML 157RON 312382 Definiciones

⎇ 168cd187519c0ad575d3ce63baee2f164c38d9d1 llimphi-test/src/lib.rs Rust · 457 líneas · 18.7 KB Historia Quién tocó qué crudo

chore: refresco desde el monorepo — vello 0.9 / wgpu 29, 120 miembros d251eb32b · 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//!
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}