tawasuyu

tawasuyu/llimphi

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

Rust 660Markdown 302TOML 157RON 312382 Sutichaykuna

⎇ main llimphi-test/tests/widgets_invariantes.rs Rust · 423 siq'ikuna · 18.2 KB Ñawpa willay Pi imata llamkarqan ch'uya

chore: refresco desde el monorepo — 128 miembros, los 76 widgets y los 11 módulos b3f12e136 · Sergio ·

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}