tawasuyu/llimphi
Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.
chore: refresco desde el monorepo — vello 0.9 / wgpu 29, 120 miembros
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}