//! # llimphi-test — arnés golden-image headless y determinista //! //! El problema que cierra: en `PLAN-NUCLEO-RETENIDO.md` y `PARIDAD-FLUTTER.md` //! cada bloque termina con "falta verificar en metal / PNG-diff pendiente", y //! CLAUDE.md §8 prohíbe renderizar-y-mirar de rutina por costo de tokens. Este //! crate convierte esa verificación cara y subjetiva en `assert_golden!(...)`: //! barata, objetiva, en CI, certificada por **texto** ([`DiffStats`]). //! //! ## Determinismo //! //! El [`Harness`] pide un `Hal` por el adapter de **software** //! ([`llimphi_hal::Hal::new_headless(true)`]): el rasterizador llvmpipe/lavapipe //! (deviceType CPU) produce el **mismo píxel** en cualquier host y en CI, no //! atado a la GPU de quien generó el golden. Si la máquina no tiene un fallback //! adapter, cae al adapter normal y la tolerancia perceptual de [`GoldenConfig`] //! absorbe la diferencia de vendor. //! //! ## Uso típico (dentro de un test de otro crate) //! //! ```ignore //! use llimphi_test::assert_golden; //! #[test] //! fn card_con_sombra() { //! let v = mi_widget::card(...); //! assert_golden!("card_con_sombra", v, 400, 240); // tests/goldens/card_con_sombra.png //! } //! ``` //! //! Regenerar los goldens: `LLIMPHI_GOLDEN_UPDATE=1 cargo test`. //! //! ## El otro camino: consultas semánticas ([`query`]) //! //! El golden certifica **cómo se ve**. Para certificar **qué hay y qué hace** //! —"existe un botón *Guardar* habilitado", "el switch está en on"— usá //! [`query::Ui`]: monta el árbol, lo interroga por rol/label/valor y no toca //! la GPU. Sobrevive a los cambios de tema que rompen un golden, y falla con //! el árbol en texto en vez de mandarte a mirar un PNG. pub mod query; use std::cell::RefCell; use std::mem::ManuallyDrop; use std::path::Path; use llimphi_compositor::{measure_text_node, mount, paint, View}; use llimphi_hal::{wgpu, Hal}; use llimphi_layout::taffy; use llimphi_layout::LayoutTree; use llimphi_raster::peniko::Color; use llimphi_raster::{vello, Renderer}; use llimphi_text::Typesetter; const FMT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm; /// Un frame rasterizado en memoria: RGBA8 sin padding, `w*h*4` bytes. #[derive(Clone)] pub struct Rendered { pub w: u32, pub h: u32, /// RGBA8, fila mayor, `w*h*4` bytes exactos (sin el padding de wgpu). pub rgba: Vec, } impl Rendered { /// Guarda a PNG (formato inferido por la extensión). pub fn save(&self, path: &Path) -> Result<(), String> { let img = image::RgbaImage::from_raw(self.w, self.h, self.rgba.clone()) .ok_or_else(|| "buffer RGBA de tamaño inconsistente".to_string())?; img.save(path).map_err(|e| e.to_string()) } /// Carga un PNG a `Rendered` (lo normaliza a RGBA8). pub fn load(path: &Path) -> Result { let img = image::open(path).map_err(|e| e.to_string())?.to_rgba8(); Ok(Self { w: img.width(), h: img.height(), rgba: img.into_raw() }) } } /// Motor de render headless reutilizable: un `Hal` + `Renderer` + `Typesetter` /// vivos para muchos frames. Caro de construir (pide adapter), barato de reusar. pub struct Harness { hal: Hal, renderer: Renderer, ts: Typesetter, } impl Harness { /// Construye el arnés sobre el adapter de software (determinista). Ver /// [`llimphi_hal::Hal::new_headless`]. pub fn new() -> Result { let hal = pollster::block_on(Hal::new_headless(true)).map_err(|e| e.to_string())?; let renderer = Renderer::new(&hal).map_err(|e| e.to_string())?; Ok(Self { hal, renderer, ts: Typesetter::new() }) } /// Rasteriza `view` en un lienzo `w×h` sobre fondo `bg` y devuelve el RGBA8. /// Mismo pipeline que las apps reales: `mount → compute_with_measure → paint /// → Renderer::render_to_view`, más el readback de la textura a CPU. pub fn render(&mut self, view: View, w: u32, h: u32, bg: Color) -> Rendered { self.render_con(view, w, h, bg, |scene, mounted, computed, ts| { paint(scene, mounted, computed, ts, None, None); }) } /// Como [`render`](Self::render), pero **el caller decide cómo se pinta** el /// árbol ya montado y con el layout resuelto. /// /// Existe para poder comparar dos caminos de pintado sobre el **mismo** /// árbol. El caso que la motivó: certificar que `paint_cached` (Bloque 23, /// `RepaintBoundary`) da exactamente los mismos píxeles que `paint`. Eso no /// se puede escribir con `render`, que decide el pintado por vos, y era el /// «falta PNG-diff» que el Bloque 23 arrastraba — la Iniciativa #2 del /// `PLAN-MOTOR-PROFUNDO` lo listaba como cerrable acá, sin metal. pub fn render_con( &mut self, view: View, w: u32, h: u32, bg: Color, pintar: impl FnOnce( &mut vello::Scene, &llimphi_compositor::Mounted, &llimphi_compositor::llimphi_layout::ComputedLayout, &mut Typesetter, ), ) -> Rendered { let mut layout = LayoutTree::new(); let mounted = mount(&mut layout, view); let ts = &mut self.ts; let computed = { let tmap = &mounted.text_measures; layout .compute_with_measure(mounted.root, (w as f32, h as f32), |nid, known, avail| { match tmap.get(&nid) { Some(tm) => measure_text_node(ts, tm, known, avail), None => taffy::Size::ZERO, } }) .expect("compute_with_measure") }; let mut scene = vello::Scene::new(); pintar(&mut scene, &mounted, &computed, ts); let target = self.hal.device.create_texture(&wgpu::TextureDescriptor { label: Some("llimphi-test-target"), size: wgpu::Extent3d { width: w, height: h, depth_or_array_layers: 1 }, mip_level_count: 1, sample_count: 1, dimension: wgpu::TextureDimension::D2, format: FMT, usage: wgpu::TextureUsages::STORAGE_BINDING | wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC, view_formats: &[], }); let tex_view = target.create_view(&wgpu::TextureViewDescriptor::default()); self.renderer .render_to_view(&self.hal, &scene, &tex_view, w, h, bg) .expect("render_to_view"); let rgba = readback(&self.hal, &target, w, h); Rendered { w, h, rgba } } } impl Harness { /// Golden-testea contenido del backend **GPU-directo** (`GpuBatch` / /// `gpu_paint_with`), que NO pasa por el `View`/vello de [`render`](Self::render). /// Crea un target `Rgba8Unorm`, lo limpia a `bg`, y le pasa a `f` el /// `(device, queue, encoder, view, viewport)` — la misma firma que /// `View::gpu_paint_with` — para que pinte directo; después lee de vuelta. /// El caller típico arma un `GpuBatch` y hace `flush(..., LoadOp::Load)` /// (compone sobre el `bg` ya limpiado, igual que sobre lo que vello pintó en /// la app real). Cierra el hueco "el path GPU-directo no tenía cobertura de /// regresión visual" (antes sólo el smoke test "no panic"). pub fn render_gpu(&mut self, w: u32, h: u32, bg: Color, f: F) -> Rendered where F: FnOnce(&wgpu::Device, &wgpu::Queue, &mut wgpu::CommandEncoder, &wgpu::TextureView, (f32, f32)), { let target = self.hal.device.create_texture(&wgpu::TextureDescriptor { label: Some("llimphi-test-gpu-target"), size: wgpu::Extent3d { width: w, height: h, depth_or_array_layers: 1 }, mip_level_count: 1, sample_count: 1, dimension: wgpu::TextureDimension::D2, format: FMT, usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC, view_formats: &[], }); let tex_view = target.create_view(&wgpu::TextureViewDescriptor::default()); let mut enc = self .hal .device .create_command_encoder(&wgpu::CommandEncoderDescriptor { label: None }); // Pre-clear a `bg` (como haría vello en la app real antes del gpu_paint_with). let [r, g, b, a] = bg.components; { let _clear = enc.begin_render_pass(&wgpu::RenderPassDescriptor { label: Some("llimphi-test-gpu-clear"), color_attachments: &[Some(wgpu::RenderPassColorAttachment { view: &tex_view, resolve_target: None, depth_slice: None, ops: wgpu::Operations { load: wgpu::LoadOp::Clear(wgpu::Color { r: r as f64, g: g as f64, b: b as f64, a: a as f64, }), store: wgpu::StoreOp::Store, }, })], depth_stencil_attachment: None, timestamp_writes: None, occlusion_query_set: None, multiview_mask: None, }); } f(&self.hal.device, &self.hal.queue, &mut enc, &tex_view, (w as f32, h as f32)); self.hal.queue.submit(std::iter::once(enc.finish())); let rgba = readback(&self.hal, &target, w, h); Rendered { w, h, rgba } } } /// Copia la textura a un buffer mapeable, quita el padding de fila de wgpu y /// devuelve RGBA8 compacto. Patrón idéntico al `write_png` de los examples. fn readback(hal: &Hal, target: &wgpu::Texture, w: u32, h: u32) -> Vec { let unpadded = (w * 4) as usize; let align = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT as usize; let padded = unpadded.div_ceil(align) * align; let buf = hal.device.create_buffer(&wgpu::BufferDescriptor { label: Some("llimphi-test-readback"), size: (padded * h as usize) as u64, usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST, mapped_at_creation: false, }); let mut enc = hal .device .create_command_encoder(&wgpu::CommandEncoderDescriptor { label: None }); enc.copy_texture_to_buffer( wgpu::TexelCopyTextureInfo { texture: target, mip_level: 0, origin: wgpu::Origin3d::ZERO, aspect: wgpu::TextureAspect::All, }, wgpu::TexelCopyBufferInfo { buffer: &buf, layout: wgpu::TexelCopyBufferLayout { offset: 0, bytes_per_row: Some(padded as u32), rows_per_image: Some(h), }, }, wgpu::Extent3d { width: w, height: h, depth_or_array_layers: 1 }, ); hal.queue.submit(std::iter::once(enc.finish())); let slice = buf.slice(..); let (tx, rx) = std::sync::mpsc::channel(); slice.map_async(wgpu::MapMode::Read, move |r| { let _ = tx.send(r); }); let _ = hal.device.poll(wgpu::PollType::wait_indefinitely()); rx.recv().unwrap().unwrap(); let data = slice.get_mapped_range(); let mut out = Vec::with_capacity((w * h * 4) as usize); for row in 0..h as usize { let s = row * padded; out.extend_from_slice(&data[s..s + unpadded]); } drop(data); buf.unmap(); out } // ───────────────────────────── diff ───────────────────────────── /// Estadísticas de un diff, imprimibles como **texto** — esto es lo que /// certifica sin mirar el PNG (CLAUDE.md §8: "stats numéricas como texto"). #[derive(Debug, Clone, Copy, PartialEq)] pub struct DiffStats { /// Píxeles cuyo mayor delta de canal superó el umbral. pub differing_pixels: u64, pub total_pixels: u64, /// Máximo delta de canal (0-255) observado en toda la imagen. pub max_delta: u8, /// `differing_pixels / total_pixels`. pub fraction: f64, } /// Compara dos frames píxel a píxel. Un píxel "difiere" si el máximo de los /// deltas absolutos de sus cuatro canales supera `max_channel_delta`. Devuelve /// un heatmap (rojo ∝ delta sobre gris oscuro) + las stats. Los frames deben /// tener las mismas dimensiones. pub fn diff(a: &Rendered, b: &Rendered, max_channel_delta: u8) -> Result<(Rendered, DiffStats), String> { if a.w != b.w || a.h != b.h { return Err(format!("dimensiones distintas: {}×{} vs {}×{}", a.w, a.h, b.w, b.h)); } let total = (a.w * a.h) as u64; let mut differing = 0u64; let mut max_delta = 0u8; let mut heat = vec![0u8; a.rgba.len()]; for (i, (pa, pb)) in a.rgba.chunks_exact(4).zip(b.rgba.chunks_exact(4)).enumerate() { let d = (0..4).map(|c| pa[c].abs_diff(pb[c])).max().unwrap_or(0); max_delta = max_delta.max(d); let o = i * 4; if d > max_channel_delta { differing += 1; heat[o] = 255; // R ∝ presencia de diff heat[o + 1] = 30; heat[o + 2] = 30; heat[o + 3] = 255; } else { heat[o] = 20; // gris oscuro = sin diff heat[o + 1] = 20; heat[o + 2] = 20; heat[o + 3] = 255; } } let stats = DiffStats { differing_pixels: differing, total_pixels: total, max_delta, fraction: differing as f64 / total.max(1) as f64, }; Ok((Rendered { w: a.w, h: a.h, rgba: heat }, stats)) } // ───────────────────────────── golden ───────────────────────────── /// Tolerancia de comparación golden. Default pensado para el adapter de /// software: casi bit-exacto, con un colchón mínimo para versiones distintas /// de LLVM/Mesa entre la máquina que generó el golden y la que lo verifica. #[derive(Debug, Clone, Copy)] pub struct GoldenConfig { /// Delta de canal (0-255) por debajo del cual un píxel NO cuenta como diff. pub max_channel_delta: u8, /// Fracción de píxeles diferentes tolerada antes de fallar. pub max_diff_fraction: f64, } impl Default for GoldenConfig { fn default() -> Self { Self { max_channel_delta: 4, max_diff_fraction: 0.001 } } } /// Compara `rendered` contra el golden en `path`. /// /// - `LLIMPHI_GOLDEN_UPDATE=1` en el entorno → **reescribe** el golden y pasa. /// - Golden ausente y sin modo update → error (junto al golden queda el /// `.actual.png` para revisar y promover con el modo update). /// - Presente → diff con `cfg`; si supera la tolerancia, escribe `.actual.png` /// y `.diff.png` al lado y devuelve error con las [`DiffStats`]. pub fn compare_golden(path: &Path, rendered: &Rendered, cfg: &GoldenConfig) -> Result<(), String> { let update = std::env::var("LLIMPHI_GOLDEN_UPDATE").map(|v| v == "1").unwrap_or(false); if update { if let Some(dir) = path.parent() { std::fs::create_dir_all(dir).map_err(|e| e.to_string())?; } rendered.save(path)?; return Ok(()); } if !path.exists() { let actual = with_suffix(path, "actual"); let _ = rendered.save(&actual); return Err(format!( "golden ausente: {path:?}. Escribe {actual:?} para revisar; \ promovelo con LLIMPHI_GOLDEN_UPDATE=1" )); } let golden = Rendered::load(path)?; let (heat, stats) = diff(&golden, rendered, cfg.max_channel_delta)?; if stats.fraction > cfg.max_diff_fraction { let actual = with_suffix(path, "actual"); let dpng = with_suffix(path, "diff"); let _ = rendered.save(&actual); let _ = heat.save(&dpng); return Err(format!( "golden {path:?}: {} / {} px difieren (frac {:.5} > {:.5}), max_delta {}. \ Ver {actual:?} y {dpng:?}", stats.differing_pixels, stats.total_pixels, stats.fraction, cfg.max_diff_fraction, stats.max_delta )); } Ok(()) } fn with_suffix(path: &Path, suffix: &str) -> std::path::PathBuf { let stem = path.file_stem().and_then(|s| s.to_str()).unwrap_or("golden"); let parent = path.parent().unwrap_or_else(|| Path::new(".")); parent.join(format!("{stem}.{suffix}.png")) } // ───────────────────────── harness compartido ───────────────────────── thread_local! { // `ManuallyDrop`: el `Hal` envuelve una `wgpu::Instance` cuyo teardown sobre // Vulkan/llvmpipe al cerrar el hilo (destructor de TLS) paniquea —el mismo // teardown frágil que `Hal::new` documenta para GL. Un proceso de test que // sale no necesita liberar la GPU, así que NO soltamos el arnés: se leakea al // salir el proceso (barato, seguro) en vez de crashear en el destructor. static TL_HARNESS: RefCell>> = const { RefCell::new(None) }; } /// Corre `f` con el [`Harness`] del hilo actual (lo crea perezosamente). Un /// arnés por hilo evita compartir el `Typesetter`/`Hal` entre threads de test. pub fn with_harness(f: impl FnOnce(&mut Harness) -> R) -> R { TL_HARNESS.with(|h| { let mut b = h.borrow_mut(); if b.is_none() { *b = Some(ManuallyDrop::new( Harness::new().expect("crear Harness de llimphi-test"), )); } f(b.as_mut().unwrap()) }) } /// Fondo por defecto de `assert_golden!` — negro opaco. pub fn default_bg() -> Color { Color::from_rgba8(0, 0, 0, 255) } /// Rasteriza `view` con el arnés compartido y lo compara contra el golden en /// `path`. Paniquea (fallo de test) si supera la tolerancia. Ver /// [`compare_golden`]. Usar el macro [`assert_golden!`] para el caso común. pub fn assert_golden_at( path: &Path, view: View, w: u32, h: u32, bg: Color, cfg: GoldenConfig, ) { let rendered = with_harness(|hn| hn.render(view, w, h, bg)); if let Err(e) = compare_golden(path, &rendered, &cfg) { panic!("{e}"); } } /// `assert_golden!("nombre", view, w, h[, bg])` — compara contra /// `/tests/goldens/nombre.png` con la tolerancia default. El path usa el /// `CARGO_MANIFEST_DIR` del crate que invoca el macro (no el de llimphi-test). #[macro_export] macro_rules! assert_golden { ($name:expr, $view:expr, $w:expr, $h:expr) => { $crate::assert_golden!($name, $view, $w, $h, $crate::default_bg()) }; ($name:expr, $view:expr, $w:expr, $h:expr, $bg:expr) => {{ let path = ::std::path::Path::new(env!("CARGO_MANIFEST_DIR")) .join("tests") .join("goldens") .join(format!("{}.png", $name)); $crate::assert_golden_at(&path, $view, $w, $h, $bg, $crate::GoldenConfig::default()); }}; }