tawasuyu/llimphi
Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.
chore: refresco desde el monorepo (pin tawasuyu.git v0.3.0)
1// =============================================================================
2// pruebas :: certificación por PÍXELES, no por aserción
3// -----------------------------------------------------------------------------
4// Todo lo que se afirma acá se mide contando píxeles del pixmap resultante y
5// comparando números — nada de mirar PNGs (regla 8 del repo: renderizar y
6// mirar cuesta tokens; la evidencia puede ser texto). Cada test imprime lo que
7// midió cuando falla, así que un rojo dice QUÉ salió y no sólo que salió mal.
8// =============================================================================
9
10use alloc::vec;
11
12use vello_cpu::kurbo::{Affine, Circle, Rect, RoundedRect, Stroke};
13use vello_cpu::peniko::color::{AlphaColor, PremulRgba8, Srgb};
14use vello_cpu::peniko::{BlendMode, Fill};
15
16use crate::escena::Escena;
17use crate::lienzo::{volcar, FormatoPixel};
18use crate::pintor::Pintor;
19
20const ROJO: AlphaColor<Srgb> = AlphaColor::from_rgba8(255, 0, 0, 255);
21const VERDE: AlphaColor<Srgb> = AlphaColor::from_rgba8(0, 255, 0, 255);
22
23/// Pinta una escena sobre un lienzo de `w`x`h` y devuelve los píxeles.
24fn pintar(w: u16, h: u16, escena: &Escena) -> vello_cpu::Pixmap {
25 let mut pintor = Pintor::new(w, h);
26 let mut lienzo = pintor.lienzo_nuevo();
27 pintor.pintar(escena, &mut lienzo);
28 lienzo
29}
30
31fn en(p: &vello_cpu::Pixmap, x: u16, y: u16) -> PremulRgba8 {
32 p.data()[y as usize * p.width() as usize + x as usize]
33}
34
35/// Cuántos píxeles no son totalmente transparentes.
36fn cubiertos(p: &vello_cpu::Pixmap) -> usize {
37 p.data().iter().filter(|c| c.a > 0).count()
38}
39
40#[test]
41fn rellenar_rectangulo_da_los_pixeles_exactos() {
42 let mut e = Escena::new();
43 e.fill(
44 Fill::NonZero,
45 Affine::IDENTITY,
46 ROJO,
47 None,
48 &Rect::new(10.0, 10.0, 30.0, 20.0),
49 );
50 let p = pintar(64, 64, &e);
51
52 // 20x10 = 200 píxeles exactos: bordes rectos y alineados a la grilla, así
53 // que no hay antialiasing que ensucie la cuenta.
54 assert_eq!(cubiertos(&p), 200, "área cubierta");
55 let dentro = en(&p, 15, 15);
56 assert_eq!(
57 (dentro.r, dentro.g, dentro.b, dentro.a),
58 (255, 0, 0, 255),
59 "el interior debe ser rojo opaco"
60 );
61 assert_eq!(en(&p, 5, 5).a, 0, "fuera del rect no se toca nada");
62}
63
64#[test]
65fn la_esquina_redondeada_produce_antialiasing() {
66 let mut e = Escena::new();
67 e.fill(
68 Fill::NonZero,
69 Affine::IDENTITY,
70 ROJO,
71 None,
72 &RoundedRect::new(8.0, 8.0, 56.0, 56.0, 12.0),
73 );
74 let p = pintar(64, 64, &e);
75
76 // Un borde curvo rasterizado con AA deja píxeles a medio cubrir. Si el
77 // rasterizador cayera en un camino sin AA, este conteo sería CERO — que es
78 // justo la regresión que este test existe para atrapar.
79 let parciales = p.data().iter().filter(|c| c.a > 0 && c.a < 255).count();
80 assert!(
81 parciales > 20,
82 "esperaba antialiasing en las curvas, hubo {parciales} píxeles parciales"
83 );
84 // La esquina misma queda fuera del redondeo.
85 assert_eq!(en(&p, 8, 8).a, 0, "la esquina se recorta por el radio");
86 assert_eq!(en(&p, 32, 32).a, 255, "el centro está lleno");
87}
88
89#[test]
90fn la_capa_de_recorte_corta_lo_que_sobra() {
91 let mut e = Escena::new();
92 // Capa recortada a la mitad izquierda...
93 e.push_layer(
94 Fill::NonZero,
95 BlendMode::default(),
96 1.0,
97 Affine::IDENTITY,
98 &Rect::new(0.0, 0.0, 32.0, 64.0),
99 );
100 // ...y adentro, un relleno que cubre TODO el lienzo.
101 e.fill(
102 Fill::NonZero,
103 Affine::IDENTITY,
104 ROJO,
105 None,
106 &Rect::new(0.0, 0.0, 64.0, 64.0),
107 );
108 e.pop_layer();
109 let p = pintar(64, 64, &e);
110
111 assert_eq!(cubiertos(&p), 32 * 64, "sólo debe sobrevivir la mitad izquierda");
112 assert_eq!(en(&p, 10, 32).a, 255, "izquierda pintada");
113 assert_eq!(en(&p, 50, 32).a, 0, "derecha recortada");
114}
115
116#[test]
117fn la_capa_con_alfa_atenua_lo_de_adentro() {
118 let mut e = Escena::new();
119 e.push_layer(
120 Fill::NonZero,
121 BlendMode::default(),
122 0.5,
123 Affine::IDENTITY,
124 &Rect::new(0.0, 0.0, 64.0, 64.0),
125 );
126 e.fill(
127 Fill::NonZero,
128 Affine::IDENTITY,
129 ROJO,
130 None,
131 &Rect::new(0.0, 0.0, 64.0, 64.0),
132 );
133 e.pop_layer();
134 let p = pintar(64, 64, &e);
135
136 let c = en(&p, 32, 32);
137 assert!(
138 (120..=135).contains(&c.a),
139 "alfa 0,5 sobre opaco debería dar ~128, dio {}",
140 c.a
141 );
142}
143
144#[test]
145fn append_traslada_la_escena_hija() {
146 // Hija: un cuadrado de 10x10 en el origen.
147 let mut hija = Escena::new();
148 hija.fill(
149 Fill::NonZero,
150 Affine::IDENTITY,
151 VERDE,
152 None,
153 &Rect::new(0.0, 0.0, 10.0, 10.0),
154 );
155
156 let mut madre = Escena::new();
157 madre.append(&hija, Some(Affine::translate((20.0, 30.0))));
158 let p = pintar(64, 64, &madre);
159
160 assert_eq!(cubiertos(&p), 100, "el área no cambia al trasladar");
161 assert_eq!(en(&p, 25, 35).a, 255, "el cuadrado viajó a (20,30)");
162 assert_eq!(en(&p, 5, 5).a, 0, "y ya no está en el origen");
163}
164
165#[test]
166fn append_sin_transformacion_es_concatenar() {
167 let mut hija = Escena::new();
168 hija.fill(
169 Fill::NonZero,
170 Affine::IDENTITY,
171 VERDE,
172 None,
173 &Rect::new(0.0, 0.0, 10.0, 10.0),
174 );
175 let mut madre = Escena::new();
176 madre.fill(
177 Fill::NonZero,
178 Affine::IDENTITY,
179 ROJO,
180 None,
181 &Rect::new(20.0, 20.0, 30.0, 30.0),
182 );
183 madre.append(&hija, None);
184
185 assert_eq!(madre.len(), 2, "dos órdenes, sin envoltorios");
186 let p = pintar(64, 64, &madre);
187 assert_eq!(en(&p, 5, 5).g, 255, "la hija quedó donde estaba");
188 assert_eq!(en(&p, 25, 25).r, 255, "y la madre también");
189}
190
191#[test]
192fn append_compone_la_transformacion_por_izquierda() {
193 // La hija ya viene escalada x2; la madre la traslada. El orden importa: si
194 // se compusiera al revés, la traslación saldría escalada al doble.
195 let mut hija = Escena::new();
196 hija.fill(
197 Fill::NonZero,
198 Affine::scale(2.0),
199 VERDE,
200 None,
201 &Rect::new(0.0, 0.0, 5.0, 5.0),
202 );
203 let mut madre = Escena::new();
204 madre.append(&hija, Some(Affine::translate((10.0, 10.0))));
205 let p = pintar(64, 64, &madre);
206
207 // 5x5 escalado x2 = 10x10, trasladado a (10,10) => ocupa [10,20).
208 assert_eq!(cubiertos(&p), 100, "10x10 tras el escalado");
209 assert_eq!(en(&p, 11, 11).a, 255, "arranca en (10,10)");
210 assert_eq!(en(&p, 21, 21).a, 0, "y termina en (20,20)");
211}
212
213#[test]
214fn el_trazo_dibuja_contorno_y_no_relleno() {
215 let mut e = Escena::new();
216 e.stroke(
217 &Stroke::new(4.0),
218 Affine::IDENTITY,
219 ROJO,
220 None,
221 &Rect::new(16.0, 16.0, 48.0, 48.0),
222 );
223 let p = pintar(64, 64, &e);
224
225 assert_eq!(en(&p, 32, 32).a, 0, "el centro queda hueco");
226 assert_eq!(en(&p, 32, 16).a, 255, "el borde de arriba está trazado");
227}
228
229#[test]
230fn el_rect_difuso_degrada_hacia_afuera() {
231 let mut e = Escena::new();
232 e.draw_blurred_rounded_rect(
233 Affine::IDENTITY,
234 Rect::new(20.0, 20.0, 44.0, 44.0),
235 ROJO,
236 6.0,
237 5.0,
238 );
239 let p = pintar(64, 64, &e);
240
241 let centro = en(&p, 32, 32).a;
242 let borde = en(&p, 32, 19).a;
243 let lejos = en(&p, 32, 4).a;
244 assert!(centro > 200, "el centro de la sombra es sólido ({centro})");
245 assert!(
246 borde > 0 && borde < centro,
247 "el borde difumina: centro={centro} borde={borde}"
248 );
249 assert!(lejos < borde, "y se apaga con la distancia ({lejos})");
250}
251
252#[test]
253fn una_forma_arbitraria_se_aplana_a_camino() {
254 // Un círculo no es Rect ni RoundedRect: fuerza la rama `Forma::Camino`.
255 let mut e = Escena::new();
256 e.fill(
257 Fill::NonZero,
258 Affine::IDENTITY,
259 ROJO,
260 None,
261 &Circle::new((32.0, 32.0), 16.0),
262 );
263 let p = pintar(64, 64, &e);
264
265 // πr² = π*256 ≈ 804. Con AA el conteo de «algo cubierto» queda algo por
266 // encima; la banda es generosa a propósito, lo que se certifica es que
267 // rasterizó un disco y no un cuadrado (1024) ni nada (0).
268 let n = cubiertos(&p);
269 assert!(
270 (750..=950).contains(&n),
271 "un disco de r=16 debería cubrir ~804 px, cubrió {n}"
272 );
273 assert_eq!(en(&p, 32, 32).a, 255, "el centro del disco está lleno");
274 assert_eq!(en(&p, 2, 2).a, 0, "las esquinas no");
275}
276
277#[test]
278fn reset_vacia_la_escena() {
279 let mut e = Escena::new();
280 e.fill(
281 Fill::NonZero,
282 Affine::IDENTITY,
283 ROJO,
284 None,
285 &Rect::new(0.0, 0.0, 10.0, 10.0),
286 );
287 assert_eq!(e.len(), 1);
288 e.reset();
289 assert!(e.is_empty(), "reset deja la escena vacía");
290 assert_eq!(cubiertos(&pintar(32, 32, &e)), 0, "y no pinta nada");
291}
292
293#[test]
294fn el_volcado_respeta_stride_y_orden_de_canales() {
295 let mut e = Escena::new();
296 e.fill(
297 Fill::NonZero,
298 Affine::IDENTITY,
299 ROJO,
300 None,
301 &Rect::new(0.0, 0.0, 4.0, 4.0),
302 );
303 let p = pintar(4, 4, &e);
304
305 // Framebuffer más ancho que el pixmap: 8 px de stride para 4 de contenido.
306 // Es el caso real —el firmware alinea las filas— y el que produce la imagen
307 // en diagonal si uno confunde stride con ancho.
308 const STRIDE: usize = 8 * 4;
309 let mut fb = vec![0u8; STRIDE * 4];
310 let filas = volcar(&p, &mut fb, STRIDE, FormatoPixel::Bgra8);
311 assert_eq!(filas, 4, "cuatro filas volcadas");
312
313 // Primer píxel en BGRA: azul=0, verde=0, rojo=255.
314 assert_eq!(&fb[0..4], &[0, 0, 255, 255], "BGRA del rojo");
315 // La zona de relleno del stride (píxeles 4..8 de la fila 0) queda intacta.
316 assert_eq!(&fb[16..32], &[0u8; 16], "el relleno del stride no se pisa");
317 // Segunda fila arranca en STRIDE, no en 4*4.
318 assert_eq!(&fb[STRIDE..STRIDE + 4], &[0, 0, 255, 255], "fila 2 en su lugar");
319
320 // El mismo pixmap en RGBA invierte los dos extremos.
321 let mut fb2 = vec![0u8; STRIDE * 4];
322 volcar(&p, &mut fb2, STRIDE, FormatoPixel::Rgba8);
323 assert_eq!(&fb2[0..4], &[255, 0, 0, 255], "RGBA del rojo");
324}
325
326#[test]
327fn el_volcado_recorta_en_vez_de_entrar_en_panico() {
328 let mut e = Escena::new();
329 e.fill(
330 Fill::NonZero,
331 Affine::IDENTITY,
332 ROJO,
333 None,
334 &Rect::new(0.0, 0.0, 16.0, 16.0),
335 );
336 let p = pintar(16, 16, &e);
337
338 // Framebuffer de 8x8 para un pixmap de 16x16: no entra ni a lo ancho ni a
339 // lo alto. Debe copiar la intersección y seguir viviendo.
340 const STRIDE: usize = 8 * 4;
341 let mut fb = vec![0u8; STRIDE * 8];
342 let filas = volcar(&p, &mut fb, STRIDE, FormatoPixel::Bgra8);
343 assert_eq!(filas, 8, "se copian las filas que entran");
344 assert_eq!(&fb[0..4], &[0, 0, 255, 255], "y lo copiado es correcto");
345}
346
347#[test]
348fn el_volcado_pisa_con_negro_lo_que_la_escena_no_cubrio() {
349 // El defecto que esto congela salió en QEMU, no en un test: el cromo de
350 // arranque de wawa armaba una cáscara del ANCHO DE LA PANTALLA para
351 // reproducir un fixture de 320 px, y los 960 px sobrantes de cada fila
352 // aparecían como un rectángulo NEGRO sobre el escritorio — permanente.
353 //
354 // Ninguna estadística del kernel lo veía: cuenta píxeles con `a > 0`, y
355 // los sobrantes tienen `a == 0`. O sea que el log de arranque decía
356 // exactamente lo mismo con el slab y sin él. Lo único que lo delata es
357 // mirar el destino DESPUÉS del volcado, que es lo que hace este test.
358 let mut e = Escena::new();
359 e.fill(
360 Fill::NonZero,
361 Affine::IDENTITY,
362 VERDE,
363 None,
364 // La escena cubre la mitad izquierda y nada más.
365 &Rect::new(0.0, 0.0, 4.0, 8.0),
366 );
367 let p = pintar(8, 8, &e);
368
369 const STRIDE: usize = 8 * 4;
370 // El destino arranca ROJO: si `volcar` respetara lo de abajo donde la
371 // escena no pintó, la mitad derecha seguiría roja.
372 let mut fb = vec![0u8; STRIDE * 8];
373 for px in fb.chunks_exact_mut(4) {
374 px.copy_from_slice(&[0, 0, 255, 255]); // BGRA del rojo
375 }
376 volcar(&p, &mut fb, STRIDE, FormatoPixel::Bgra8);
377
378 // Lo cubierto sale verde, como corresponde.
379 assert_eq!(&fb[0..4], &[0, 255, 0, 255], "la mitad pintada sale verde");
380
381 // Y lo NO cubierto sale NEGRO OPACO, no rojo: el alfa se descartó.
382 let derecha = &fb[4 * 4..5 * 4];
383 assert_eq!(
384 derecha,
385 &[0, 0, 0, 255],
386 "el sobrante debe salir negro opaco — si esto cambia a rojo es que \
387 `volcar` empezó a componer, y entonces hay que revisar a todos los \
388 llamadores que dependen de que PISE"
389 );
390
391 // Y el conteo que el kernel usa NO lo ve: los sobrantes tienen a == 0.
392 assert_eq!(
393 cubiertos(&p),
394 4 * 8,
395 "en el pixmap sólo hay 32 px con alfa — los 32 que salieron negros no \
396 los cuenta nadie"
397 );
398}