tawasuyu

tawasuyu/llimphi

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

Rust 660Markdown 302TOML 157RON 312382 Definitions

⎇ main llimphi-test/tests/boundary_gradiente_imagen.rs Rust · 246 lines · 9.2 KB History Who touched what raw

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

1//! El caché de `repaint_boundary` con **gradiente e imagen** adentro.
2//!
3//! Hasta 2026-08-05 `boundary_cacheable` devolvía `false` en cuanto el subárbol
4//! tenía un degradé o una imagen, con la nota «no se hashean de forma barata».
5//! Eso dejaba al caché sin el caso que lo motivaba: el chrome estático de una
6//! app real —un sidebar, una toolbar— tiene un fondo con degradé o un ícono, y
7//! **un solo nodo con imagen descalificaba el subárbol entero**.
8//!
9//! Ampliar lo que un caché acepta es el cambio más peligroso que se le puede
10//! hacer: si el hash no cubre **todos** los campos que `paint` lee, el caché
11//! sirve píxeles viejos y nadie se entera hasta ver el cuadro mal. Por eso este
12//! archivo no prueba «anda»: prueba, campo por campo, que **cambiarlo invalida**
13//! —el caché falla— **y que lo repintado es lo nuevo**. Un hash que se olvidara
14//! de un campo pasaría un test de «anda» y fallaría éste.
15//!
16//! Certificado por texto (§8): conteos de píxeles distintos, llvmpipe.
17
18use llimphi_compositor::llimphi_layout::taffy::prelude::{length, percent, Size, Style};
19use llimphi_compositor::peniko::kurbo::Point;
20use llimphi_compositor::peniko::{Gradient, GradientKind, LinearGradientPosition};
21use llimphi_compositor::peniko::Color;
22use llimphi_compositor::{paint, paint_cached, ImageFit, RepaintCache, View};
23use llimphi_test::{default_bg, diff, with_harness, Harness, Rendered};
24
25const W: u32 = 160;
26const H: u32 = 120;
27const CLAVE: u64 = 0xB0CA;
28
29#[derive(Clone)]
30enum Msg {}
31
32fn degrade(a: Color, b: Color) -> Gradient {
33    Gradient::new_linear(Point::new(0.0, 0.0), Point::new(1.0, 1.0)).with_stops([a, b])
34}
35
36/// Imagen RGBA8 sintética: un damero de `w`×`h` con dos colores. Cada llamada
37/// construye un `Blob` nuevo — y por lo tanto un id nuevo, que es de lo que el
38/// hash se agarra.
39fn imagen(w: u32, h: u32, a: [u8; 4], b: [u8; 4]) -> llimphi_compositor::peniko::ImageBrush {
40    let mut px = Vec::with_capacity((w * h * 4) as usize);
41    for y in 0..h {
42        for x in 0..w {
43            px.extend_from_slice(if (x / 2 + y / 2) % 2 == 0 { &a } else { &b });
44        }
45    }
46    llimphi_image::from_rgba8(px, w, h)
47}
48
49/// Lo que se cachea: una tarjeta con degradé de fondo y una imagen encima.
50#[derive(Clone)]
51struct Pieza {
52    grad: Gradient,
53    img: llimphi_compositor::peniko::ImageBrush,
54    fit: ImageFit,
55}
56
57impl Default for Pieza {
58    fn default() -> Self {
59        Self {
60            grad: degrade(
61                Color::from_rgba8(40, 70, 180, 255),
62                Color::from_rgba8(200, 60, 90, 255),
63            ),
64            // No cuadrada, para que Contain y Cover den cuadros distintos.
65            img: imagen(16, 8, [250, 250, 60, 255], [20, 20, 20, 255]),
66            fit: ImageFit::Contain,
67        }
68    }
69}
70
71fn arbol(p: &Pieza, marca: u32) -> View<Msg> {
72    View::<Msg>::new(Style {
73        size: Size { width: percent(1.0_f32), height: percent(1.0_f32) },
74        flex_direction:
75            llimphi_compositor::llimphi_layout::taffy::prelude::FlexDirection::Column,
76        ..Default::default()
77    })
78    .fill(Color::from_rgba8(18, 18, 24, 255))
79    .children(vec![
80        View::<Msg>::new(Style {
81            size: Size { width: percent(1.0_f32), height: length(70.0_f32) },
82            ..Default::default()
83        })
84        .fill_gradient(p.grad.clone())
85        .radius(6.0)
86        .repaint_boundary(CLAVE)
87        .children(vec![View::<Msg>::new(Style {
88            size: Size { width: length(60.0_f32), height: length(40.0_f32) },
89            ..Default::default()
90        })
91        .image(p.img.clone())
92        .image_fit(p.fit)]),
93        // Fuera del boundary: cambia todos los cuadros, para que el resto del
94        // árbol se repinte y el boundary tenga algo que ahorrar.
95        View::<Msg>::new(Style::default()).text(
96            format!("cuadro {marca}"),
97            13.0,
98            Color::from_rgba8(255, 210, 120, 255),
99        ),
100    ])
101}
102
103fn fresco(h: &mut Harness, p: &Pieza, marca: u32) -> Rendered {
104    h.render_con(arbol(p, marca), W, H, default_bg(), |s, m, c, ts| {
105        paint(s, m, c, ts, None, None);
106    })
107}
108
109fn cacheado(h: &mut Harness, p: &Pieza, marca: u32, cache: &mut RepaintCache) -> Rendered {
110    h.render_con(arbol(p, marca), W, H, default_bg(), |s, m, c, ts| {
111        paint_cached(s, m, c, ts, None, None, cache);
112    })
113}
114
115fn distintos(a: &Rendered, b: &Rendered) -> u64 {
116    diff(a, b, 0).expect("mismo tamaño").1.differing_pixels
117}
118
119#[test]
120fn un_subarbol_con_degrade_e_imagen_ahora_se_cachea() {
121    // Antes esto daba 0 hits porque `boundary_cacheable` lo rechazaba entero.
122    with_harness(|h| {
123        let p = Pieza::default();
124        let referencia = fresco(h, &p, 1);
125        let mut cache = RepaintCache::new();
126        cacheado(h, &p, 1, &mut cache);
127        assert_eq!((cache.hits, cache.misses), (0, 1), "el primero puebla el caché");
128
129        for marca in 2..=4 {
130            let esperado = fresco(h, &p, marca);
131            let obtenido = cacheado(h, &p, marca, &mut cache);
132            assert_eq!(
133                distintos(&esperado, &obtenido),
134                0,
135                "cuadro {marca}: el degradé/imagen servidos del caché no son los frescos"
136            );
137        }
138        assert_eq!(
139            (cache.hits, cache.misses),
140            (3, 1),
141            "el subárbol con degradé e imagen tenía que acertar"
142        );
143        // Guarda del arnés: si el degradé no pintara nada, todo esto sería
144        // comparar dos fondos lisos.
145        let liso = h.render_con(
146            View::<Msg>::new(Style {
147                size: Size { width: percent(1.0_f32), height: percent(1.0_f32) },
148                ..Default::default()
149            })
150            .fill(Color::from_rgba8(18, 18, 24, 255)),
151            W,
152            H,
153            default_bg(),
154            |s, m, c, ts| paint(s, m, c, ts, None, None),
155        );
156        assert!(
157            distintos(&liso, &referencia) > 2000,
158            "el árbol de prueba casi no pinta nada; el test no probaría el caché"
159        );
160    });
161}
162
163/// Cada mutación de la pieza cacheada tiene que **fallar el caché** y devolver
164/// el contenido nuevo. Si el hash se olvidara de un campo, ese caso daría hit y
165/// píxeles viejos — el bug exacto que ampliar un caché puede introducir.
166fn exige_invalidacion(nombre: &str, mutar: impl Fn(&mut Pieza)) {
167    with_harness(|h| {
168        let base = Pieza::default();
169        // **Clonar, no reconstruir.** `Pieza::default()` arma un `Blob` nuevo
170        // cada vez, y un blob nuevo tiene id nuevo: dos `default()` difieren en
171        // la imagen aunque se mute el degradé, y entonces cualquier caso
172        // invalidaría por el id y no por lo que dice probar. Se descubrió
173        // sabotéando el hash campo por campo: sin los stops, sin la geometría,
174        // sin `image_fit` y sin el sampler, los tests seguían pasando. Sólo
175        // clonando queda una **única** diferencia por caso.
176        let mut cambiada = base.clone();
177        mutar(&mut cambiada);
178
179        let mut cache = RepaintCache::new();
180        cacheado(h, &base, 1, &mut cache);
181        assert_eq!(cache.misses, 1);
182
183        let viejo = fresco(h, &base, 1);
184        let nuevo = fresco(h, &cambiada, 1);
185        // Guarda: la mutación tiene que verse. Sin esto, un campo que no
186        // cambiara nada pasaría el test por la puerta de atrás.
187        assert!(
188            distintos(&viejo, &nuevo) > 20,
189            "{nombre}: la mutación casi no cambia el cuadro ({} px); \
190             el caso no prueba invalidación",
191            distintos(&viejo, &nuevo)
192        );
193
194        let obtenido = cacheado(h, &cambiada, 1, &mut cache);
195        assert_eq!(cache.hits, 0, "{nombre}: sirvió el subárbol viejo del caché");
196        assert_eq!(cache.misses, 2, "{nombre}: no invalidó");
197        assert_eq!(
198            distintos(&nuevo, &obtenido),
199            0,
200            "{nombre}: invalidó pero no repintó el contenido nuevo"
201        );
202    });
203}
204
205#[test]
206fn cambiar_los_colores_del_degrade_invalida() {
207    exige_invalidacion("stops", |p| {
208        p.grad = degrade(
209            Color::from_rgba8(20, 200, 80, 255),
210            Color::from_rgba8(240, 240, 60, 255),
211        );
212    });
213}
214
215#[test]
216fn cambiar_la_direccion_del_degrade_invalida() {
217    // Mismos colores, otra geometría: si el hash sólo mirara los stops, esto
218    // daría hit y el degradé quedaría apuntando para donde no va.
219    exige_invalidacion("dirección", |p| {
220        p.grad.kind = GradientKind::Linear(LinearGradientPosition {
221            start: Point::new(1.0, 0.0),
222            end: Point::new(0.0, 1.0),
223        });
224    });
225}
226
227#[test]
228fn cambiar_los_pixeles_de_la_imagen_invalida() {
229    exige_invalidacion("blob", |p| {
230        p.img = imagen(16, 8, [60, 200, 250, 255], [10, 10, 60, 255]);
231    });
232}
233
234#[test]
235fn cambiar_el_encaje_de_la_imagen_invalida() {
236    // El mismo blob, el mismo rect: lo único distinto es la transformación con
237    // la que se pinta. `image_fit` no vive en la imagen sino en el nodo, y es
238    // justo la clase de campo que un hash «de la imagen» deja afuera.
239    exige_invalidacion("image_fit", |p| p.fit = ImageFit::Cover);
240}
241
242#[test]
243fn cambiar_el_alpha_del_sampler_invalida() {
244    // El sampler cambia lo pintado sin tocar un byte del blob ni el id.
245    exige_invalidacion("sampler.alpha", |p| p.img.sampler.alpha = 0.35);
246}