tawasuyu

tawasuyu/llimphi

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

Rust 660Markdown 302TOML 157RON 312382 Definiciones

⎇ main llimphi-test/tests/boundary_cache.rs Rust · 323 líneas · 13.2 KB Historia Quién tocó qué crudo

chore: refresco desde el monorepo — vello 0.9 / wgpu 29, 120 miembros d251eb32b · Sergio ·

1//! **Bloque 23 (`PLAN-NUCLEO-RETENIDO.md`) — `RepaintBoundary`: cacheado contra
2//! fresco, píxel a píxel.**
3//!
4//! El bloque quedó «HECHO (headless), falta PNG-diff en metal» desde julio, y
5//! la Iniciativa #2 del `PLAN-MOTOR-PROFUNDO` decía que ese follow-up se cierra
6//! **acá**, sin GPU física: el arnés `llimphi-test` rasteriza sobre el adapter
7//! de software y compara por número, que es la evidencia por texto que pide la
8//! Regla 8. Lo único que faltaba era una forma de pintar el mismo árbol de dos
9//! maneras — `Harness::render_con`.
10//!
11//! Lo que se prueba es una **igualdad**, y ésas se pasan solas si el arnés no
12//! está haciendo nada. Por eso cada test asevera además los contadores de
13//! `hits`/`misses`: sin eso, un `paint_cached` que nunca cacheara daría 0 px de
14//! diferencia y el test lo firmaría como correcto.
15
16use llimphi_compositor::llimphi_layout::taffy::{
17    prelude::{length, percent, Dimension, FlexDirection, Size, Style},
18    Rect,
19};
20use llimphi_compositor::peniko::Color;
21use llimphi_compositor::{paint, paint_cached, RepaintCache, Shadow, View};
22use llimphi_test::{default_bg, diff, with_harness, Harness, Rendered};
23
24const W: u32 = 320;
25const H: u32 = 240;
26
27#[derive(Clone)]
28enum Msg {}
29
30/// Un subárbol con lo que de verdad rompe un caché de rasterización: gradiente,
31/// sombra, radio, borde y texto. Un rectángulo liso pasaría cualquier cosa.
32fn tarjeta(titulo: &str, color: Color) -> View<Msg> {
33    View::<Msg>::new(Style {
34        flex_direction: FlexDirection::Column,
35        size: Size { width: percent(1.0_f32), height: length(70.0_f32) },
36        padding: Rect {
37            left: length(10.0_f32),
38            right: length(10.0_f32),
39            top: length(8.0_f32),
40            bottom: length(8.0_f32),
41        },
42        ..Default::default()
43    })
44    .fill(color)
45    .hover_fill(Color::from_rgba8(240, 240, 90, 255))
46    .radius(9.0)
47    .border(1.5, Color::from_rgba8(210, 210, 230, 255))
48    .shadow(Shadow { color: Color::from_rgba8(0, 0, 0, 120), blur: 8.0, dx: 2.0, dy: 3.0, spread: 0.0 })
49    .children(vec![
50        View::<Msg>::new(Style::default()).text(titulo, 15.0, Color::WHITE),
51        View::<Msg>::new(Style::default()).text(
52            "subtítulo con descendentes: jgpqy",
53            11.0,
54            Color::from_rgba8(230, 230, 245, 255),
55        ),
56    ])
57}
58
59/// El árbol de la prueba. `marca` cambia el texto de la tarjeta **de abajo**,
60/// que NO está dentro de un boundary — así se puede mover el árbol sin tocar el
61/// subárbol cacheado.
62fn arbol(titulo_panel: &str, marca: u32) -> View<Msg> {
63    View::<Msg>::new(Style {
64        flex_direction: FlexDirection::Column,
65        size: Size { width: percent(1.0_f32), height: percent(1.0_f32) },
66        gap: Size { width: length(0.0_f32), height: length(10.0_f32) },
67        padding: Rect {
68            left: length(14.0_f32),
69            right: length(14.0_f32),
70            top: length(14.0_f32),
71            bottom: length(14.0_f32),
72        },
73        ..Default::default()
74    })
75    .fill(Color::from_rgba8(24, 24, 32, 255))
76    .children(vec![
77        // El chrome estático: justo el caso que el Bloque 23 quiere cachear.
78        tarjeta(titulo_panel, Color::from_rgba8(60, 90, 170, 255)).repaint_boundary(0xC0FFEE),
79        View::<Msg>::new(Style {
80            size: Size { width: percent(1.0_f32), height: Dimension::auto() },
81            ..Default::default()
82        })
83        .text(format!("contador: {marca}"), 13.0, Color::from_rgba8(255, 210, 120, 255)),
84        tarjeta("panel que cambia", Color::from_rgba8(150, 60, 90, 255)),
85    ])
86}
87
88fn fresco_con(h: &mut Harness, titulo: &str, marca: u32) -> Rendered {
89    h.render_con(arbol(titulo, marca), W, H, default_bg(), |s, m, c, ts| {
90        paint(s, m, c, ts, None, None);
91    })
92}
93
94fn cacheado_con(h: &mut Harness, titulo: &str, marca: u32, cache: &mut RepaintCache) -> Rendered {
95    h.render_con(arbol(titulo, marca), W, H, default_bg(), |s, m, c, ts| {
96        paint_cached(s, m, c, ts, None, None, cache);
97    })
98}
99
100fn fresco(h: &mut Harness, marca: u32) -> Rendered {
101    fresco_con(h, "panel quieto", marca)
102}
103
104fn cacheado(h: &mut Harness, marca: u32, cache: &mut RepaintCache) -> Rendered {
105    cacheado_con(h, "panel quieto", marca, cache)
106}
107
108/// Píxeles distintos entre dos rasterizaciones, sin tolerancia de canal.
109fn distintos(a: &Rendered, b: &Rendered) -> u64 {
110    diff(a, b, 0).expect("mismo tamaño").1.differing_pixels
111}
112
113#[test]
114fn el_subarbol_servido_del_cache_da_los_mismos_pixeles_que_el_fresco() {
115    with_harness(|h| {
116        let referencia = fresco(h, 1);
117
118        let mut cache = RepaintCache::new();
119        // Primer cuadro: el boundary no está en el caché, se rasteriza y se
120        // guarda. Tiene que ser un miss, y tiene que dar lo mismo que el fresco
121        // (acá el caché no participa del resultado, sólo lo memoriza).
122        let primero = cacheado(h, 1, &mut cache);
123        assert_eq!((cache.hits, cache.misses), (0, 1), "el primer cuadro es un miss");
124        assert_eq!(distintos(&referencia, &primero), 0, "el cuadro que puebla el caché ya difiere");
125
126        // Segundo cuadro: **ahora sí** el subárbol sale del caché, trasladado.
127        // Ésta es la comparación que el Bloque 23 debía y arrastró seis meses.
128        let segundo = cacheado(h, 1, &mut cache);
129        assert_eq!((cache.hits, cache.misses), (1, 1), "el segundo cuadro tenía que acertar");
130        assert_eq!(
131            distintos(&referencia, &segundo),
132            0,
133            "el subárbol servido del caché no es idéntico al pintado fresco"
134        );
135    });
136}
137
138#[test]
139fn el_cache_sigue_acertando_aunque_el_resto_del_arbol_cambie() {
140    // El punto entero del boundary: el chrome quieto se sirve del caché
141    // mientras lo que lo rodea se repinta. Si un cambio en cualquier parte del
142    // árbol invalidara el boundary, la optimización no ahorraría nada.
143    with_harness(|h| {
144        let mut cache = RepaintCache::new();
145        cacheado(h, 1, &mut cache);
146        for marca in 2..=5 {
147            let esperado = fresco(h, marca);
148            let obtenido = cacheado(h, marca, &mut cache);
149            assert_eq!(
150                distintos(&esperado, &obtenido),
151                0,
152                "con el contador en {marca} el cuadro cacheado no coincide con el fresco"
153            );
154        }
155        assert_eq!(
156            (cache.hits, cache.misses),
157            (4, 1),
158            "el boundary tenía que acertar en los cuatro cuadros siguientes"
159        );
160    });
161}
162
163#[test]
164fn si_el_subarbol_cacheado_cambia_el_cache_falla_y_repinta_bien() {
165    // El control que le da sentido a los otros dos: un caché que devolviera
166    // siempre lo viejo los pasaría igual, porque ahí el subárbol nunca cambia.
167    // Acá cambia, y lo que se exige es que el caché **falle** y que el
168    // resultado sea el nuevo, no el de antes.
169    with_harness(|h| {
170        let mut cache = RepaintCache::new();
171        let _ = cacheado_con(h, "panel quieto", 1, &mut cache);
172        assert_eq!(cache.misses, 1);
173
174        let esperado = fresco_con(h, "panel MOVIDO", 1);
175        let anterior = fresco_con(h, "panel quieto", 1);
176        let obtenido = cacheado_con(h, "panel MOVIDO", 1, &mut cache);
177
178        assert_eq!(cache.misses, 2, "cambió el contenido del boundary: tenía que fallar");
179        assert_eq!(cache.hits, 0, "y no puede haber acertado nada");
180        assert_eq!(distintos(&esperado, &obtenido), 0, "no repintó el contenido nuevo");
181        assert!(
182            distintos(&anterior, &obtenido) > 0,
183            "devolvió el subárbol viejo: el caché está sirviendo contenido rancio"
184        );
185    });
186}
187
188// ── El puntero adentro del subárbol cacheado ─────────────────────────────
189
190/// Índice del nodo montado que abre el boundary `key`.
191fn nodo_del_boundary<Msg>(m: &llimphi_compositor::Mounted<Msg>, key: u64) -> usize {
192    m.nodes
193        .iter()
194        .position(|n| n.repaint_boundary == Some(key))
195        .expect("el árbol tiene que traer el boundary")
196}
197
198#[test]
199fn con_el_puntero_encima_el_boundary_no_se_sirve_del_cache() {
200    // `boundary_cacheable` ya se niega a cachear un subárbol con el hover
201    // adentro, y hay un test unitario que lo dice. Lo que no había es la
202    // comprobación de que eso **se vea bien**: un caché que igual sirviera lo
203    // guardado pintaría la tarjeta sin resaltar, y el usuario vería un panel
204    // que no reacciona al puntero.
205    //
206    // El oráculo no necesita ninguna imagen bendecida: es `paint` con el mismo
207    // hover.
208    with_harness(|h| {
209        let mut cache = RepaintCache::new();
210        // Un cuadro sin hover, para que el caché tenga algo guardado que servir.
211        cacheado(h, 1, &mut cache);
212        assert_eq!((cache.hits, cache.misses), (0, 1));
213
214        let sin_hover = fresco(h, 1);
215        let esperado = h.render_con(arbol("panel quieto", 1), W, H, default_bg(), |s, m, c, ts| {
216            let i = nodo_del_boundary(m, 0xC0FFEE);
217            paint(s, m, c, ts, Some(i), None);
218        });
219        let obtenido = h.render_con(arbol("panel quieto", 1), W, H, default_bg(), |s, m, c, ts| {
220            let i = nodo_del_boundary(m, 0xC0FFEE);
221            paint_cached(s, m, c, ts, Some(i), None, &mut cache);
222        });
223
224        // Guarda del arnés: si el hover no cambiara nada visible, comparar dos
225        // imágenes idénticas no probaría nada.
226        assert!(
227            distintos(&sin_hover, &esperado) > 100,
228            "el hover tiene que cambiar la tarjeta para que este test signifique algo"
229        );
230        assert_eq!(
231            distintos(&esperado, &obtenido),
232            0,
233            "con el puntero encima, el cuadro cacheado no coincide con el fresco"
234        );
235        assert_eq!(
236            cache.hits, 0,
237            "sirvió el subárbol guardado en vez de repintarlo resaltado"
238        );
239    });
240}
241
242// ── El detector que dice DÓNDE poner el boundary ─────────────────────────
243
244/// El mismo árbol pero **sin** el `repaint_boundary`: lo que escribiría alguien
245/// que todavía no sabe dónde ponerlo.
246fn arbol_pelado(marca: u32) -> View<Msg> {
247    View::<Msg>::new(Style {
248        flex_direction: FlexDirection::Column,
249        size: Size { width: percent(1.0_f32), height: percent(1.0_f32) },
250        gap: Size { width: length(0.0_f32), height: length(10.0_f32) },
251        padding: Rect {
252            left: length(14.0_f32),
253            right: length(14.0_f32),
254            top: length(14.0_f32),
255            bottom: length(14.0_f32),
256        },
257        ..Default::default()
258    })
259    .fill(Color::from_rgba8(24, 24, 32, 255))
260    .children(vec![
261        tarjeta("panel quieto", Color::from_rgba8(60, 90, 170, 255)),
262        View::<Msg>::new(Style {
263            size: Size { width: percent(1.0_f32), height: Dimension::auto() },
264            ..Default::default()
265        })
266        .text(format!("contador: {marca}"), 13.0, Color::from_rgba8(255, 210, 120, 255)),
267        tarjeta("panel que cambia", Color::from_rgba8(150, 60, 90, 255)),
268    ])
269}
270
271#[test]
272fn lo_que_el_detector_senala_da_los_mismos_pixeles_al_cablearlo() {
273    // El detector (`DetectorBoundaries`) mira el árbol cuadro a cuadro y dice
274    // qué subárboles vienen quietos. Su promesa es doble: que puesto ahí el
275    // boundary el caché **acierta**, y que lo pintado **no cambia**. Los hits
276    // los cuenta un unit test del compositor; los píxeles sólo se pueden
277    // comparar acá, que es donde hay rasterizador.
278    //
279    // Sin esto, el detector podría señalar un subárbol que el caché no sabe
280    // reproducir y nadie se enteraría hasta ver el cuadro mal en pantalla.
281    use llimphi_compositor::{mount, DetectorBoundaries};
282    use llimphi_compositor::llimphi_layout::LayoutTree;
283
284    let mut det = DetectorBoundaries::con(3, 3);
285    let mut sugerido = None;
286    for marca in 0..4u32 {
287        let mut lt = LayoutTree::new();
288        let m = mount(&mut lt, arbol_pelado(marca));
289        let c = lt.compute(m.root, (W as f32, H as f32)).expect("layout");
290        sugerido = det.observar(&m, &c, None, None).into_iter().next();
291    }
292    let s = sugerido.expect("el detector tenía que señalar la tarjeta quieta");
293    assert!(s.nodos >= 3, "señaló un subárbol trivial: {} nodos", s.nodos);
294    assert_eq!(
295        s.texto.as_deref(),
296        Some("panel quieto"),
297        "señaló otra cosa que la tarjeta que no cambia: {s:?}"
298    );
299
300    // Se cablea donde dijo —la tarjeta de arriba, que es `arbol()`— y se compara
301    // contra el pintado fresco del árbol pelado, que es el oráculo: lo que el
302    // usuario veía ANTES de cablear nada.
303    with_harness(|h| {
304        let mut cache = RepaintCache::new();
305        for marca in 0..4u32 {
306            let esperado =
307                h.render_con(arbol_pelado(marca), W, H, default_bg(), |s, m, c, ts| {
308                    paint(s, m, c, ts, None, None);
309                });
310            let obtenido = cacheado(h, marca, &mut cache);
311            assert_eq!(
312                distintos(&esperado, &obtenido),
313                0,
314                "con el contador en {marca}, cablear la sugerencia cambió lo pintado"
315            );
316        }
317        assert_eq!(
318            (cache.hits, cache.misses),
319            (3, 1),
320            "el detector prometió cache-hits y el caché no acertó"
321        );
322    });
323}