tawasuyu/llimphi
Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.
chore: refresco desde el monorepo — 128 miembros, los 76 widgets y los 11 módulos
1//! **Red de regresión visual de los widgets canónicos, sin bendecir imágenes.**
2//!
3//! Es la tarea 4 de la Iniciativa #2 del `PLAN-MOTOR-PROFUNDO`, que pedía
4//! «goldens de un puñado de widgets canónicos». Se hace, pero **no con
5//! goldens**, y la razón está escrita en el MANUAL (§16.bis, «el golden es el
6//! ÚLTIMO recurso»): una imagen bendecida contesta «esto no cambió» pero no
7//! «esto está bien», y para bendecirla hay que mirarla — justo lo que la Regla 8
8//! pide evitar. Dejar la tarea escrita como estaba, con el MANUAL diciendo lo
9//! contrario, era pedirle a quien viniera que eligiera entre dos instrucciones.
10//!
11//! Un widget **sí** tiene oráculos, y son mejores que una foto porque afirman
12//! algo verdadero de *cualquier* render correcto, no de uno en particular:
13//!
14//! 1. **Determinismo.** Dos renders del mismo `View` dan el mismo píxel. Suena
15//! trivial y no lo es: agarra estado escondido, orden de iteración de un
16//! `HashMap`, y cualquier cosa que dependa del reloj.
17//! 2. **Traslación pura.** El mismo widget corrido `(dx, dy)` enteros da los
18//! mismos píxeles corridos. Agarra coordenadas absolutas que se colaron,
19//! rects cacheados que no siguieron al nodo, y redondeos que dependen de
20//! dónde cayó el widget.
21//! 3. **Cacheable.** Envuelto en un `repaint_boundary`, `paint_cached` da lo
22//! mismo que `paint`. `boundary_cache.rs` ya prueba el mecanismo con un árbol
23//! hecho a mano; esto lo prueba con los widgets **de verdad**, que es donde
24//! aparecería un `paint` con estado.
25//!
26//! Ninguna necesita saber cómo *debería* verse un botón. Si mañana se rediseña
27//! el botón, estos tests siguen valiendo — un golden habría que rebendecirlo, y
28//! ahí es donde un golden deja de proteger y empieza a estorbar.
29//!
30//! Certificado por texto (§8): conteos de píxeles.
31
32use llimphi_compositor::llimphi_layout::taffy::{
33 prelude::{length, FlexDirection, Position, Size, Style},
34 Rect as Insets,
35};
36use llimphi_compositor::peniko::Color;
37use llimphi_compositor::{paint, paint_cached, RepaintCache, Shadow, View};
38use llimphi_test::{default_bg, diff, with_harness, Harness, Rendered};
39
40const W: u32 = 300;
41const H: u32 = 220;
42/// La caja en la que se mete cada widget. Fija, para que moverla no lo
43/// redimensione — ver [`en`].
44const CAJA_W: f32 = 200.0;
45const CAJA_H: f32 = 100.0;
46
47#[derive(Clone, PartialEq, Debug)]
48enum Msg {
49 Toco,
50}
51
52/// Los widgets que se ponen a prueba. Se eligieron por **rasgo de pintado**, no
53/// por popularidad: entre los cinco hay gradiente, sombra, radio, borde, texto,
54/// trazo y relleno parcial. Un widget que sólo pinta un rect liso no distingue
55/// un motor sano de uno roto.
56fn canonicos() -> Vec<(&'static str, View<Msg>)> {
57 let tema = llimphi_theme::Theme::dark();
58 vec![
59 (
60 "field",
61 llimphi_widget_field::field_view(llimphi_widget_field::FieldSpec {
62 label: "Nombre".into(),
63 control: View::<Msg>::new(Style {
64 size: Size { width: length(160.0_f32), height: length(24.0_f32) },
65 ..Default::default()
66 })
67 .fill(Color::from_rgba8(40, 44, 56, 255))
68 .radius(4.0)
69 .border(1.0, Color::from_rgba8(90, 96, 112, 255)),
70 required: true,
71 helper: Some("como figura en el documento".into()),
72 error: None,
73 palette: llimphi_widget_field::FieldPalette::from_theme(&tema),
74 }),
75 ),
76 (
77 "field_error",
78 llimphi_widget_field::field_view(llimphi_widget_field::FieldSpec {
79 label: "Correo".into(),
80 control: View::<Msg>::new(Style {
81 size: Size { width: length(160.0_f32), height: length(24.0_f32) },
82 ..Default::default()
83 })
84 .fill(Color::from_rgba8(56, 32, 32, 255))
85 .radius(4.0),
86 required: false,
87 helper: None,
88 error: Some("no parece una dirección".into()),
89 palette: llimphi_widget_field::FieldPalette::from_theme(&tema),
90 }),
91 ),
92 (
93 "list",
94 llimphi_widget_list::list_view(llimphi_widget_list::ListSpec {
95 rows: vec![
96 llimphi_widget_list::ListRow {
97 label: "primera".into(),
98 selected: false,
99 on_click: Msg::Toco,
100 },
101 llimphi_widget_list::ListRow {
102 label: "segunda (activa)".into(),
103 selected: true,
104 on_click: Msg::Toco,
105 },
106 llimphi_widget_list::ListRow {
107 label: "tercera".into(),
108 selected: false,
109 on_click: Msg::Toco,
110 },
111 ],
112 total: 3,
113 caption: Some("3 entradas".into()),
114 truncated_hint: None,
115 row_height: 24.0,
116 palette: llimphi_widget_list::ListPalette::from_theme(&tema),
117 }),
118 ),
119 (
120 "progress",
121 // Envuelto en una caja con ancho: la barra posiciona su relleno con
122 // `Position::Absolute`, así que sin un ancho del que colgar sale de
123 // 0 px. Lo dijo la guarda de arnés en la primera corrida —«sólo 0 px
124 // se apartan del fondo»—, que es exactamente para lo que está.
125 View::<Msg>::new(Style {
126 size: Size { width: length(200.0_f32), height: length(8.0_f32) },
127 ..Default::default()
128 })
129 .children(vec![llimphi_widget_progress::linear_progress_view(
130 0.42,
131 Color::from_rgba8(40, 44, 56, 255),
132 Color::from_rgba8(90, 170, 240, 255),
133 8.0,
134 )]),
135 ),
136 // Una tarjeta con TODO junto: sombra, radio, borde y texto. Es el caso
137 // que más rasgos cruza y el que más chances tiene de romperse.
138 ("tarjeta", tarjeta()),
139 ]
140}
141
142fn tarjeta() -> View<Msg> {
143 View::<Msg>::new(Style {
144 flex_direction: FlexDirection::Column,
145 size: Size { width: length(180.0_f32), height: length(80.0_f32) },
146 padding: Insets {
147 left: length(10.0_f32),
148 right: length(10.0_f32),
149 top: length(8.0_f32),
150 bottom: length(8.0_f32),
151 },
152 gap: Size { width: length(0.0_f32), height: length(6.0_f32) },
153 ..Default::default()
154 })
155 .fill(Color::from_rgba8(30, 34, 44, 255))
156 .radius(10.0)
157 .border(1.0, Color::from_rgba8(70, 76, 92, 255))
158 .shadow(Shadow {
159 color: Color::from_rgba8(0, 0, 0, 150),
160 blur: 8.0,
161 dx: 2.0,
162 dy: 3.0,
163 spread: 0.0,
164 })
165 .children(vec![
166 View::<Msg>::new(Style::default()).text(
167 "Tarjeta",
168 14.0,
169 Color::from_rgba8(235, 238, 246, 255),
170 ),
171 View::<Msg>::new(Style::default()).text(
172 "con sombra y radio",
173 11.0,
174 Color::from_rgba8(160, 168, 186, 255),
175 ),
176 ])
177}
178
179/// Coloca un widget en un lienzo del tamaño del arnés, desplazado `(dx, dy)`.
180///
181/// Absoluto y no flex a propósito: lo que se quiere mover es el widget, no
182/// pedirle al layout que lo reacomode. Si el desplazamiento saliera de un `gap`
183/// o un `padding`, el layout podría redondear distinto y la comparación mediría
184/// eso en vez de lo que se busca.
185fn en(vista: View<Msg>, dx: f32, dy: f32) -> View<Msg> {
186 View::<Msg>::new(Style {
187 size: Size { width: length(W as f32), height: length(H as f32) },
188 ..Default::default()
189 })
190 .children(vec![View::<Msg>::new(Style {
191 position: Position::Absolute,
192 inset: Insets {
193 left: length(dx),
194 top: length(dy),
195 right: llimphi_compositor::llimphi_layout::taffy::prelude::auto(),
196 bottom: llimphi_compositor::llimphi_layout::taffy::prelude::auto(),
197 },
198 // **Tamaño fijo, y esto lo enseñó el test fallando.** Sin él, un widget
199 // con `width: 100 %` —`field` lo es— toma el ancho que le queda al
200 // contenedor, así que correrlo lo ANGOSTA y el texto refluye. El primer
201 // barrido lo delató con una firma que no era la de un bug de pintado:
202 // 0 px de diferencia hasta correrlo 5, y exactamente 7 px de ahí en
203 // adelante — el salto de un renglón que cambia de corte, no un glifo
204 // que se mueve. Con la caja fija, el widget se mueve sin cambiar de
205 // forma, que es lo que este test quería preguntar.
206 size: Size { width: length(CAJA_W), height: length(CAJA_H) },
207 ..Default::default()
208 })
209 .children(vec![vista])])
210}
211
212fn rinde(h: &mut Harness, vista: View<Msg>) -> Rendered {
213 h.render(vista, W, H, default_bg())
214}
215
216/// Compara `a` con `b` **corrido** `(dx, dy)` y devuelve
217/// `(píxeles distintos, peor delta de canal)`. Sólo se mira la región donde los
218/// dos existen.
219///
220/// Los dos números, y no sólo el conteo, porque distinguen las dos cosas que
221/// pueden pasar y que importan de manera muy distinta: un glifo **movido** deja
222/// diferencias de contraste pleno (~130/255), y una cobertura de antialiasing un
223/// pelo distinta deja unas pocas unidades. Con un conteo solo, las dos se ven
224/// igual.
225fn difieren_corridos(a: &Rendered, b: &Rendered, dx: u32, dy: u32) -> (usize, u8) {
226 let (mut n, mut peor) = (0usize, 0u8);
227 for y in 0..(H - dy) {
228 for x in 0..(W - dx) {
229 let ia = ((y * W + x) * 4) as usize;
230 let ib = (((y + dy) * W + (x + dx)) * 4) as usize;
231 let (pa, pb) = (&a.rgba[ia..ia + 4], &b.rgba[ib..ib + 4]);
232 if pa != pb {
233 n += 1;
234 peor = peor.max(
235 pa.iter().zip(pb.iter()).map(|(u, v)| u.abs_diff(*v)).max().unwrap_or(0),
236 );
237 }
238 }
239 }
240 (n, peor)
241}
242
243/// Píxeles que se apartan del fondo: la guarda de arnés de todos los tests de
244/// acá. Dos lienzos vacíos son idénticos, se muevan o no.
245fn pintados(r: &Rendered) -> usize {
246 let f = default_bg().to_rgba8().to_u8_array();
247 r.rgba
248 .chunks_exact(4)
249 .filter(|p| p.iter().zip(f.iter()).any(|(a, b)| a.abs_diff(*b) > 8))
250 .count()
251}
252
253/// La tolerancia es **1/255, y no 0**, y no es una rendija para que pase: es el
254/// criterio que este mismo archivo ya usa en `difieren_corridos`. Un estado
255/// escondido de verdad —un `HashMap` que itera distinto, un rect cacheado que no
256/// siguió al nodo, un glifo movido— deja **contraste pleno (~130/255)**. La
257/// cobertura de antialiasing deja unas pocas unidades.
258///
259/// Medido el 2026-08-09, porque con tolerancia 0 este test fallaba ~8% de las
260/// corridas y sólo bajo concurrencia: 4 hilos × 25 pares dieron 11 divergencias,
261/// **todas el mismo píxel (44,30), todas el canal azul, todas ±1** (43↔44), sobre
262/// un glifo del rótulo. En serie (`--test-threads=1`) no ocurre nunca. O sea que
263/// no es estado del pintado: es el rasterizador de software resolviendo la
264/// cobertura de un borde de letra distinto según cómo agende sus hilos — pese a
265/// que `Harness::new` lo llama «determinista». Con tolerancia 0 el test no
266/// afirmaba «no hay estado escondido» sino «la GPU redondea igual», que es otra
267/// cosa y no está en su poder.
268const RUIDO_AA: u8 = 1;
269
270#[test]
271fn cada_widget_se_pinta_igual_dos_veces() {
272 with_harness(|h| {
273 for (nombre, _) in canonicos() {
274 let a = rinde(h, en(uno(nombre), 20.0, 20.0));
275 let b = rinde(h, en(uno(nombre), 20.0, 20.0));
276 let px = pintados(&a);
277 println!("{nombre:12} · {px} px pintados");
278 assert!(
279 px > 100,
280 "{nombre}: sólo {px} px se apartan del fondo — no se está \
281 comparando un dibujo"
282 );
283 let (_, st) = diff(&a, &b, RUIDO_AA).expect("mismo tamaño");
284 assert_eq!(
285 st.differing_pixels, 0,
286 "{nombre}: dos renders del mismo View dieron {} px que difieren en \
287 más de {RUIDO_AA}/255 (peor delta {}) — eso ya no es antialiasing, \
288 hay estado escondido en el pintado",
289 st.differing_pixels, st.max_delta
290 );
291 }
292 });
293}
294
295#[test]
296fn correr_un_widget_lo_corre_entero_y_nada_mas() {
297 // Desplazamientos enteros: con fracciones el layout redondea a píxel y la
298 // comparación mediría el redondeo, no el widget.
299 const DX: u32 = 17;
300 const DY: u32 = 23;
301 with_harness(|h| {
302 for (nombre, _) in canonicos() {
303 let base = rinde(h, en(uno(nombre), 10.0, 10.0));
304 let corrido = rinde(h, en(uno(nombre), 10.0 + DX as f32, 10.0 + DY as f32));
305 let (n, peor) = difieren_corridos(&base, &corrido, DX, DY);
306 println!("{nombre:12} · {n} px difieren al correrlo ({DX},{DY}), peor Δ{peor}");
307 assert!(
308 pintados(&base) > 100,
309 "{nombre}: el lienzo base está casi vacío"
310 );
311 // **Cuatro de los cinco dan 0 px exactos.** El quinto es `field`, y
312 // lo que da es 7 px con Δ máximo 6/255: el mismo trazo vertical de
313 // un glifo con un pelo menos de cobertura, no un glifo en otro
314 // lugar. Aparece recién al correrlo 6 o más y de ahí en adelante es
315 // constante — o sea que el antialiasing del texto tiene dos estados,
316 // no una deriva. **No pude establecer qué cambia la fracción
317 // subpíxel del glifo ahí**, y prefiero dejarlo dicho antes que
318 // inventar la explicación: se descartó que fuera el reflujo por
319 // ancho (fijar la caja no lo movió) y que fuera un desplazamiento
320 // (sería Δ~130, no Δ6).
321 //
322 // Por eso el gate es sobre la **magnitud** y no sobre el conteo: un
323 // glifo movido un píxel da contraste pleno y sigue fallando; una
324 // cobertura de AA que difiere en unidades, no.
325 assert!(
326 peor <= 8,
327 "{nombre}: correrlo ({DX},{DY}) cambió {n} px con Δ{peor} — eso ya \
328 no es antialiasing, algo del pintado depende de la posición"
329 );
330 }
331 });
332}
333
334#[test]
335fn cada_widget_sobrevive_al_cache_de_boundary() {
336 with_harness(|h| {
337 for (nombre, _) in canonicos() {
338 let fresco = h.render_con(
339 en(uno(nombre), 20.0, 20.0).repaint_boundary(1),
340 W,
341 H,
342 default_bg(),
343 |s, m, c, ts| paint(s, m, c, ts, None, None),
344 );
345 let mut cache = RepaintCache::default();
346 // Primer pase: llena el caché. Segundo: lo usa. Se compara el
347 // SEGUNDO, que es el que tiene algo que demostrar.
348 let _ = h.render_con(
349 en(uno(nombre), 20.0, 20.0).repaint_boundary(1),
350 W,
351 H,
352 default_bg(),
353 |s, m, c, ts| paint_cached(s, m, c, ts, None, None, &mut cache),
354 );
355 let hits_antes = cache.hits;
356 let cacheado = h.render_con(
357 en(uno(nombre), 20.0, 20.0).repaint_boundary(1),
358 W,
359 H,
360 default_bg(),
361 |s, m, c, ts| paint_cached(s, m, c, ts, None, None, &mut cache),
362 );
363 println!(
364 "{nombre:12} · hits {} (antes {hits_antes}) · misses {} · cachea={}",
365 cache.hits,
366 cache.misses,
367 cachea(nombre)
368 );
369 // Sin esto el test pasa aunque el caché no acierte nunca: dos
370 // caminos que hacen lo mismo dan 0 px trivialmente. Y va en las dos
371 // direcciones: al que **no** debe cachear se le exige que no cachee,
372 // porque si un día cacheara sería el motor guardando el resultado de
373 // una closure que puede cambiar sola.
374 if cachea(nombre) {
375 assert!(
376 cache.hits > hits_antes,
377 "{nombre}: el caché no acertó en el pase que debía usarlo"
378 );
379 } else {
380 assert_eq!(
381 cache.hits, hits_antes,
382 "{nombre}: cacheó, y no debería — tiene pintor propio"
383 );
384 }
385 // Misma tolerancia y mismo motivo que en el test de determinismo:
386 // el ruido de antialiasing del rasterizador bajo concurrencia mueve
387 // 1/255 en un borde de glifo. Un caché que devuelve el rect
388 // equivocado no mueve un pelo: mueve el dibujo entero.
389 let (_, st) = diff(&fresco, &cacheado, RUIDO_AA).expect("mismo tamaño");
390 assert_eq!(
391 st.differing_pixels, 0,
392 "{nombre}: el pintado cacheado difiere del fresco en {} px por más \
393 de {RUIDO_AA}/255 (peor delta {})",
394 st.differing_pixels, st.max_delta
395 );
396 }
397 });
398}
399
400/// ¿Este widget puede vivir dentro de un `repaint_boundary`?
401///
402/// No es una propiedad del test sino del motor: `boundary_cacheable` rechaza
403/// cualquier nodo con **pintor propio** (`painter`/`gpu_painter`/`over_painter`),
404/// porque una closure puede depender de lo que quiera —el reloj, un `Arc` de
405/// afuera— y el caché no la puede hashear. Es la decisión correcta, y este test
406/// la codifica en vez de pelearla: lo que se exige es que cada widget esté del
407/// lado que le toca, así que si `field` dejara de cachear, falla.
408fn cachea(nombre: &str) -> bool {
409 // `progress` pinta un gloss con `.paint_with(...)`, así que no cachea. Se
410 // descubrió acá: la primera corrida daba «0 hits» y parecía un bug del test.
411 nombre != "progress"
412}
413
414/// Un widget por nombre. `canonicos()` los construye de nuevo cada vez porque
415/// `View` no es `Clone` (lleva closures), así que buscar por nombre es la forma
416/// de pedir «otra instancia del mismo».
417fn uno(nombre: &str) -> View<Msg> {
418 canonicos()
419 .into_iter()
420 .find(|(n, _)| *n == nombre)
421 .map(|(_, v)| v)
422 .unwrap_or_else(|| panic!("no existe el widget «{nombre}»"))
423}