tawasuyu

tawasuyu/llimphi

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

Rust 614Markdown 274TOML 149RON 310606 Definitions

⎇ main llimphi-test/src/lib.rs Rust · 463 lines · 19.0 KB History Who touched what raw

chore: refresco desde el monorepo (pin tawasuyu.git v0.3.0) b575c63f1 · 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
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}