//! **Red de regresión visual de los widgets canónicos, sin bendecir imágenes.** //! //! Es la tarea 4 de la Iniciativa #2 del `PLAN-MOTOR-PROFUNDO`, que pedía //! «goldens de un puñado de widgets canónicos». Se hace, pero **no con //! goldens**, y la razón está escrita en el MANUAL (§16.bis, «el golden es el //! ÚLTIMO recurso»): una imagen bendecida contesta «esto no cambió» pero no //! «esto está bien», y para bendecirla hay que mirarla — justo lo que la Regla 8 //! pide evitar. Dejar la tarea escrita como estaba, con el MANUAL diciendo lo //! contrario, era pedirle a quien viniera que eligiera entre dos instrucciones. //! //! Un widget **sí** tiene oráculos, y son mejores que una foto porque afirman //! algo verdadero de *cualquier* render correcto, no de uno en particular: //! //! 1. **Determinismo.** Dos renders del mismo `View` dan el mismo píxel. Suena //! trivial y no lo es: agarra estado escondido, orden de iteración de un //! `HashMap`, y cualquier cosa que dependa del reloj. //! 2. **Traslación pura.** El mismo widget corrido `(dx, dy)` enteros da los //! mismos píxeles corridos. Agarra coordenadas absolutas que se colaron, //! rects cacheados que no siguieron al nodo, y redondeos que dependen de //! dónde cayó el widget. //! 3. **Cacheable.** Envuelto en un `repaint_boundary`, `paint_cached` da lo //! mismo que `paint`. `boundary_cache.rs` ya prueba el mecanismo con un árbol //! hecho a mano; esto lo prueba con los widgets **de verdad**, que es donde //! aparecería un `paint` con estado. //! //! Ninguna necesita saber cómo *debería* verse un botón. Si mañana se rediseña //! el botón, estos tests siguen valiendo — un golden habría que rebendecirlo, y //! ahí es donde un golden deja de proteger y empieza a estorbar. //! //! Certificado por texto (§8): conteos de píxeles. use llimphi_compositor::llimphi_layout::taffy::{ prelude::{length, FlexDirection, Position, Size, Style}, Rect as Insets, }; use llimphi_compositor::peniko::Color; use llimphi_compositor::{paint, paint_cached, RepaintCache, Shadow, View}; use llimphi_test::{default_bg, diff, with_harness, Harness, Rendered}; const W: u32 = 300; const H: u32 = 220; /// La caja en la que se mete cada widget. Fija, para que moverla no lo /// redimensione — ver [`en`]. const CAJA_W: f32 = 200.0; const CAJA_H: f32 = 100.0; #[derive(Clone, PartialEq, Debug)] enum Msg { Toco, } /// Los widgets que se ponen a prueba. Se eligieron por **rasgo de pintado**, no /// por popularidad: entre los cinco hay gradiente, sombra, radio, borde, texto, /// trazo y relleno parcial. Un widget que sólo pinta un rect liso no distingue /// un motor sano de uno roto. fn canonicos() -> Vec<(&'static str, View)> { let tema = llimphi_theme::Theme::dark(); vec![ ( "field", llimphi_widget_field::field_view(llimphi_widget_field::FieldSpec { label: "Nombre".into(), control: View::::new(Style { size: Size { width: length(160.0_f32), height: length(24.0_f32) }, ..Default::default() }) .fill(Color::from_rgba8(40, 44, 56, 255)) .radius(4.0) .border(1.0, Color::from_rgba8(90, 96, 112, 255)), required: true, helper: Some("como figura en el documento".into()), error: None, palette: llimphi_widget_field::FieldPalette::from_theme(&tema), }), ), ( "field_error", llimphi_widget_field::field_view(llimphi_widget_field::FieldSpec { label: "Correo".into(), control: View::::new(Style { size: Size { width: length(160.0_f32), height: length(24.0_f32) }, ..Default::default() }) .fill(Color::from_rgba8(56, 32, 32, 255)) .radius(4.0), required: false, helper: None, error: Some("no parece una dirección".into()), palette: llimphi_widget_field::FieldPalette::from_theme(&tema), }), ), ( "list", llimphi_widget_list::list_view(llimphi_widget_list::ListSpec { rows: vec![ llimphi_widget_list::ListRow { label: "primera".into(), selected: false, on_click: Msg::Toco, }, llimphi_widget_list::ListRow { label: "segunda (activa)".into(), selected: true, on_click: Msg::Toco, }, llimphi_widget_list::ListRow { label: "tercera".into(), selected: false, on_click: Msg::Toco, }, ], total: 3, caption: Some("3 entradas".into()), truncated_hint: None, row_height: 24.0, palette: llimphi_widget_list::ListPalette::from_theme(&tema), }), ), ( "progress", // Envuelto en una caja con ancho: la barra posiciona su relleno con // `Position::Absolute`, así que sin un ancho del que colgar sale de // 0 px. Lo dijo la guarda de arnés en la primera corrida —«sólo 0 px // se apartan del fondo»—, que es exactamente para lo que está. View::::new(Style { size: Size { width: length(200.0_f32), height: length(8.0_f32) }, ..Default::default() }) .children(vec![llimphi_widget_progress::linear_progress_view( 0.42, Color::from_rgba8(40, 44, 56, 255), Color::from_rgba8(90, 170, 240, 255), 8.0, )]), ), // Una tarjeta con TODO junto: sombra, radio, borde y texto. Es el caso // que más rasgos cruza y el que más chances tiene de romperse. ("tarjeta", tarjeta()), ] } fn tarjeta() -> View { View::::new(Style { flex_direction: FlexDirection::Column, size: Size { width: length(180.0_f32), height: length(80.0_f32) }, padding: Insets { left: length(10.0_f32), right: length(10.0_f32), top: length(8.0_f32), bottom: length(8.0_f32), }, gap: Size { width: length(0.0_f32), height: length(6.0_f32) }, ..Default::default() }) .fill(Color::from_rgba8(30, 34, 44, 255)) .radius(10.0) .border(1.0, Color::from_rgba8(70, 76, 92, 255)) .shadow(Shadow { color: Color::from_rgba8(0, 0, 0, 150), blur: 8.0, dx: 2.0, dy: 3.0, spread: 0.0, }) .children(vec![ View::::new(Style::default()).text( "Tarjeta", 14.0, Color::from_rgba8(235, 238, 246, 255), ), View::::new(Style::default()).text( "con sombra y radio", 11.0, Color::from_rgba8(160, 168, 186, 255), ), ]) } /// Coloca un widget en un lienzo del tamaño del arnés, desplazado `(dx, dy)`. /// /// Absoluto y no flex a propósito: lo que se quiere mover es el widget, no /// pedirle al layout que lo reacomode. Si el desplazamiento saliera de un `gap` /// o un `padding`, el layout podría redondear distinto y la comparación mediría /// eso en vez de lo que se busca. fn en(vista: View, dx: f32, dy: f32) -> View { View::::new(Style { size: Size { width: length(W as f32), height: length(H as f32) }, ..Default::default() }) .children(vec![View::::new(Style { position: Position::Absolute, inset: Insets { left: length(dx), top: length(dy), right: llimphi_compositor::llimphi_layout::taffy::prelude::auto(), bottom: llimphi_compositor::llimphi_layout::taffy::prelude::auto(), }, // **Tamaño fijo, y esto lo enseñó el test fallando.** Sin él, un widget // con `width: 100 %` —`field` lo es— toma el ancho que le queda al // contenedor, así que correrlo lo ANGOSTA y el texto refluye. El primer // barrido lo delató con una firma que no era la de un bug de pintado: // 0 px de diferencia hasta correrlo 5, y exactamente 7 px de ahí en // adelante — el salto de un renglón que cambia de corte, no un glifo // que se mueve. Con la caja fija, el widget se mueve sin cambiar de // forma, que es lo que este test quería preguntar. size: Size { width: length(CAJA_W), height: length(CAJA_H) }, ..Default::default() }) .children(vec![vista])]) } fn rinde(h: &mut Harness, vista: View) -> Rendered { h.render(vista, W, H, default_bg()) } /// Compara `a` con `b` **corrido** `(dx, dy)` y devuelve /// `(píxeles distintos, peor delta de canal)`. Sólo se mira la región donde los /// dos existen. /// /// Los dos números, y no sólo el conteo, porque distinguen las dos cosas que /// pueden pasar y que importan de manera muy distinta: un glifo **movido** deja /// diferencias de contraste pleno (~130/255), y una cobertura de antialiasing un /// pelo distinta deja unas pocas unidades. Con un conteo solo, las dos se ven /// igual. fn difieren_corridos(a: &Rendered, b: &Rendered, dx: u32, dy: u32) -> (usize, u8) { let (mut n, mut peor) = (0usize, 0u8); for y in 0..(H - dy) { for x in 0..(W - dx) { let ia = ((y * W + x) * 4) as usize; let ib = (((y + dy) * W + (x + dx)) * 4) as usize; let (pa, pb) = (&a.rgba[ia..ia + 4], &b.rgba[ib..ib + 4]); if pa != pb { n += 1; peor = peor.max( pa.iter().zip(pb.iter()).map(|(u, v)| u.abs_diff(*v)).max().unwrap_or(0), ); } } } (n, peor) } /// Píxeles que se apartan del fondo: la guarda de arnés de todos los tests de /// acá. Dos lienzos vacíos son idénticos, se muevan o no. fn pintados(r: &Rendered) -> usize { let f = default_bg().to_rgba8().to_u8_array(); r.rgba .chunks_exact(4) .filter(|p| p.iter().zip(f.iter()).any(|(a, b)| a.abs_diff(*b) > 8)) .count() } /// La tolerancia es **1/255, y no 0**, y no es una rendija para que pase: es el /// criterio que este mismo archivo ya usa en `difieren_corridos`. Un estado /// escondido de verdad —un `HashMap` que itera distinto, un rect cacheado que no /// siguió al nodo, un glifo movido— deja **contraste pleno (~130/255)**. La /// cobertura de antialiasing deja unas pocas unidades. /// /// Medido el 2026-08-09, porque con tolerancia 0 este test fallaba ~8% de las /// corridas y sólo bajo concurrencia: 4 hilos × 25 pares dieron 11 divergencias, /// **todas el mismo píxel (44,30), todas el canal azul, todas ±1** (43↔44), sobre /// un glifo del rótulo. En serie (`--test-threads=1`) no ocurre nunca. O sea que /// no es estado del pintado: es el rasterizador de software resolviendo la /// cobertura de un borde de letra distinto según cómo agende sus hilos — pese a /// que `Harness::new` lo llama «determinista». Con tolerancia 0 el test no /// afirmaba «no hay estado escondido» sino «la GPU redondea igual», que es otra /// cosa y no está en su poder. const RUIDO_AA: u8 = 1; #[test] fn cada_widget_se_pinta_igual_dos_veces() { with_harness(|h| { for (nombre, _) in canonicos() { let a = rinde(h, en(uno(nombre), 20.0, 20.0)); let b = rinde(h, en(uno(nombre), 20.0, 20.0)); let px = pintados(&a); println!("{nombre:12} · {px} px pintados"); assert!( px > 100, "{nombre}: sólo {px} px se apartan del fondo — no se está \ comparando un dibujo" ); let (_, st) = diff(&a, &b, RUIDO_AA).expect("mismo tamaño"); assert_eq!( st.differing_pixels, 0, "{nombre}: dos renders del mismo View dieron {} px que difieren en \ más de {RUIDO_AA}/255 (peor delta {}) — eso ya no es antialiasing, \ hay estado escondido en el pintado", st.differing_pixels, st.max_delta ); } }); } #[test] fn correr_un_widget_lo_corre_entero_y_nada_mas() { // Desplazamientos enteros: con fracciones el layout redondea a píxel y la // comparación mediría el redondeo, no el widget. const DX: u32 = 17; const DY: u32 = 23; with_harness(|h| { for (nombre, _) in canonicos() { let base = rinde(h, en(uno(nombre), 10.0, 10.0)); let corrido = rinde(h, en(uno(nombre), 10.0 + DX as f32, 10.0 + DY as f32)); let (n, peor) = difieren_corridos(&base, &corrido, DX, DY); println!("{nombre:12} · {n} px difieren al correrlo ({DX},{DY}), peor Δ{peor}"); assert!( pintados(&base) > 100, "{nombre}: el lienzo base está casi vacío" ); // **Cuatro de los cinco dan 0 px exactos.** El quinto es `field`, y // lo que da es 7 px con Δ máximo 6/255: el mismo trazo vertical de // un glifo con un pelo menos de cobertura, no un glifo en otro // lugar. Aparece recién al correrlo 6 o más y de ahí en adelante es // constante — o sea que el antialiasing del texto tiene dos estados, // no una deriva. **No pude establecer qué cambia la fracción // subpíxel del glifo ahí**, y prefiero dejarlo dicho antes que // inventar la explicación: se descartó que fuera el reflujo por // ancho (fijar la caja no lo movió) y que fuera un desplazamiento // (sería Δ~130, no Δ6). // // Por eso el gate es sobre la **magnitud** y no sobre el conteo: un // glifo movido un píxel da contraste pleno y sigue fallando; una // cobertura de AA que difiere en unidades, no. assert!( peor <= 8, "{nombre}: correrlo ({DX},{DY}) cambió {n} px con Δ{peor} — eso ya \ no es antialiasing, algo del pintado depende de la posición" ); } }); } #[test] fn cada_widget_sobrevive_al_cache_de_boundary() { with_harness(|h| { for (nombre, _) in canonicos() { let fresco = h.render_con( en(uno(nombre), 20.0, 20.0).repaint_boundary(1), W, H, default_bg(), |s, m, c, ts| paint(s, m, c, ts, None, None), ); let mut cache = RepaintCache::default(); // Primer pase: llena el caché. Segundo: lo usa. Se compara el // SEGUNDO, que es el que tiene algo que demostrar. let _ = h.render_con( en(uno(nombre), 20.0, 20.0).repaint_boundary(1), W, H, default_bg(), |s, m, c, ts| paint_cached(s, m, c, ts, None, None, &mut cache), ); let hits_antes = cache.hits; let cacheado = h.render_con( en(uno(nombre), 20.0, 20.0).repaint_boundary(1), W, H, default_bg(), |s, m, c, ts| paint_cached(s, m, c, ts, None, None, &mut cache), ); println!( "{nombre:12} · hits {} (antes {hits_antes}) · misses {} · cachea={}", cache.hits, cache.misses, cachea(nombre) ); // Sin esto el test pasa aunque el caché no acierte nunca: dos // caminos que hacen lo mismo dan 0 px trivialmente. Y va en las dos // direcciones: al que **no** debe cachear se le exige que no cachee, // porque si un día cacheara sería el motor guardando el resultado de // una closure que puede cambiar sola. if cachea(nombre) { assert!( cache.hits > hits_antes, "{nombre}: el caché no acertó en el pase que debía usarlo" ); } else { assert_eq!( cache.hits, hits_antes, "{nombre}: cacheó, y no debería — tiene pintor propio" ); } // Misma tolerancia y mismo motivo que en el test de determinismo: // el ruido de antialiasing del rasterizador bajo concurrencia mueve // 1/255 en un borde de glifo. Un caché que devuelve el rect // equivocado no mueve un pelo: mueve el dibujo entero. let (_, st) = diff(&fresco, &cacheado, RUIDO_AA).expect("mismo tamaño"); assert_eq!( st.differing_pixels, 0, "{nombre}: el pintado cacheado difiere del fresco en {} px por más \ de {RUIDO_AA}/255 (peor delta {})", st.differing_pixels, st.max_delta ); } }); } /// ¿Este widget puede vivir dentro de un `repaint_boundary`? /// /// No es una propiedad del test sino del motor: `boundary_cacheable` rechaza /// cualquier nodo con **pintor propio** (`painter`/`gpu_painter`/`over_painter`), /// porque una closure puede depender de lo que quiera —el reloj, un `Arc` de /// afuera— y el caché no la puede hashear. Es la decisión correcta, y este test /// la codifica en vez de pelearla: lo que se exige es que cada widget esté del /// lado que le toca, así que si `field` dejara de cachear, falla. fn cachea(nombre: &str) -> bool { // `progress` pinta un gloss con `.paint_with(...)`, así que no cachea. Se // descubrió acá: la primera corrida daba «0 hits» y parecía un bug del test. nombre != "progress" } /// Un widget por nombre. `canonicos()` los construye de nuevo cada vez porque /// `View` no es `Clone` (lleva closures), así que buscar por nombre es la forma /// de pedir «otra instancia del mismo». fn uno(nombre: &str) -> View { canonicos() .into_iter() .find(|(n, _)| *n == nombre) .map(|(_, v)| v) .unwrap_or_else(|| panic!("no existe el widget «{nombre}»")) }