tawasuyu/llimphi
Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.
chore: refresco desde el monorepo — 132 miembros, 79 widgets, la galería vuelve y licencia dual
1// =============================================================================
2// pruebas :: que el cable no pierda nada, y que pese lo que el plan prometió
3// -----------------------------------------------------------------------------
4// Dos preguntas distintas y las dos hay que contestarlas con números:
5//
6// 1. **¿Sobrevive el viaje?** Round-trip postcard de cada variante. Un IR que
7// redondea mal un radio o pierde un span de estilo no se nota hasta que
8// alguien mira la pantalla, y para entonces el bug tiene una semana.
9//
10// 2. **¿Pesa lo que dijimos?** El plan (W4) promete «11-114 KB por fotograma,
11// 7-67x menos que mandar píxeles». Eso era una estimación. Acá se mide.
12// =============================================================================
13
14use super::*;
15
16use alloc::string::{String, ToString};
17use alloc::vec;
18use kurbo::{Affine, Circle, Rect, RoundedRect, Stroke};
19use peniko::{BlendMode, Color, Compose, Fill, Mix};
20
21/// Tamaño de referencia del plan: 480x400, el mismo de la tabla de W4.
22const ANCHO: usize = 480;
23const ALTO: usize = 400;
24
25fn ida_y_vuelta(e: &EscenaWire) -> (EscenaWire, usize) {
26 let bytes = postcard::to_allocvec(e).expect("serializar");
27 let vuelta: EscenaWire = postcard::from_bytes(&bytes).expect("deserializar");
28 (vuelta, bytes.len())
29}
30
31#[test]
32fn cada_variante_sobrevive_el_viaje() {
33 let mut e = EscenaWire::new();
34
35 // Relleno sólido sobre rect — el camino barato.
36 e.fill(
37 Fill::NonZero,
38 Affine::translate((3.0, 5.0)),
39 Color::from_rgba8(18, 18, 32, 255),
40 None,
41 &Rect::new(0.0, 0.0, 100.0, 28.0),
42 );
43 // Rect redondeado, que es lo que pinta casi todo widget.
44 e.fill(
45 Fill::EvenOdd,
46 Affine::IDENTITY,
47 Color::from_rgba8(200, 30, 30, 128),
48 Some(Affine::scale(2.0)),
49 &RoundedRect::new(0.0, 0.0, 40.0, 40.0, 6.0),
50 );
51 // Forma arbitraria → se aplana a curvas.
52 e.stroke(
53 &Stroke::new(1.5),
54 Affine::IDENTITY,
55 Color::from_rgba8(0, 255, 0, 255),
56 None,
57 &Circle::new((20.0, 20.0), 10.0),
58 );
59 e.push_layer(
60 Fill::NonZero,
61 BlendMode {
62 mix: Mix::Multiply,
63 compose: Compose::DestIn,
64 },
65 0.5,
66 Affine::IDENTITY,
67 &Rect::new(0.0, 0.0, 10.0, 10.0),
68 );
69 e.push_luminance_mask_layer(Fill::NonZero, 0.25, Affine::IDENTITY, &Rect::new(0.0, 0.0, 5.0, 5.0));
70 e.pop_layer();
71 e.draw_blurred_rounded_rect(
72 Affine::IDENTITY,
73 Rect::new(0.0, 0.0, 50.0, 50.0),
74 Color::from_rgba8(0, 0, 0, 90),
75 8.0,
76 4.0,
77 );
78 e.texto(Texto {
79 cadena: "wawa · ñandú · 1280x800".to_string(),
80 estilo: EstiloTextoWire {
81 tamano_px: 14.0,
82 familia: Some("Inter".to_string()),
83 rangos: vec![RangoWire {
84 inicio: 0,
85 fin: 4,
86 estilo: EstiloRangoWire {
87 peso: Some(700.0),
88 color: Some([255, 0, 0, 255]),
89 ..Default::default()
90 },
91 }],
92 ..Default::default()
93 },
94 color: [220, 220, 240, 255],
95 ancho_max: Some(200.0),
96 xf: UbicacionWire::de(Affine::translate((10.0, 7.0))),
97 hint: true,
98 });
99
100 let (vuelta, bytes) = ida_y_vuelta(&e);
101 assert_eq!(vuelta, e, "el round-trip cambió la escena ({bytes} B)");
102 assert_eq!(vuelta.len(), 8);
103 assert_eq!(vuelta.textos(), 1);
104}
105
106#[test]
107fn el_texto_viaja_como_texto_y_no_como_glifos() {
108 // Ésta es LA prueba de W4: lo que cruza es la cadena. Si algún día alguien
109 // "optimiza" shapeando del lado de la app, este test cae — y la tabla de
110 // W4.1 dice que ahí el fotograma pasa de 71 fps a 21,8.
111 let mut e = EscenaWire::new();
112 e.texto(Texto {
113 cadena: "efficiency".to_string(), // con ligadura "ffi", que un shaper resuelve
114 estilo: EstiloTextoWire::default(),
115 color: [0, 0, 0, 255],
116 ancho_max: None,
117 xf: UbicacionWire::de(Affine::IDENTITY),
118 hint: true,
119 });
120
121 let (vuelta, _) = ida_y_vuelta(&e);
122 match &vuelta.ordenes()[0] {
123 OrdenWire::Texto(t) => {
124 assert_eq!(t.cadena, "efficiency");
125 // Sin ligadura resuelta: son 10 caracteres, no 8 glifos.
126 assert_eq!(t.cadena.chars().count(), 10);
127 }
128 otra => panic!("se esperaba texto, vino {otra:?}"),
129 }
130}
131
132#[test]
133fn el_texto_vacio_no_ocupa_una_orden() {
134 let mut e = EscenaWire::new();
135 e.texto(Texto {
136 cadena: String::new(),
137 estilo: EstiloTextoWire::default(),
138 color: [0, 0, 0, 255],
139 ancho_max: None,
140 xf: UbicacionWire::de(Affine::IDENTITY),
141 hint: false,
142 });
143 assert!(e.is_empty());
144}
145
146#[test]
147fn append_traslada_formas_y_texto_por_igual() {
148 let mut hija = EscenaWire::new();
149 hija.fill(
150 Fill::NonZero,
151 Affine::IDENTITY,
152 Color::BLACK,
153 None,
154 &Rect::new(0.0, 0.0, 10.0, 10.0),
155 );
156 hija.texto(Texto {
157 cadena: "x".to_string(),
158 estilo: EstiloTextoWire::default(),
159 color: [0, 0, 0, 255],
160 ancho_max: None,
161 xf: UbicacionWire::de(Affine::translate((1.0, 2.0))),
162 hint: false,
163 });
164
165 let mut madre = EscenaWire::new();
166 madre.append(&hija, Some(Affine::translate((100.0, 200.0))));
167
168 // La forma se traslada por la matriz…
169 match &madre.ordenes()[0] {
170 OrdenWire::Rellenar { xf, .. } => assert_eq!((xf[4], xf[5]), (100.0, 200.0)),
171 otra => panic!("{otra:?}"),
172 }
173 // …y el texto por la misma matriz, sin caso especial.
174 match &madre.ordenes()[1] {
175 OrdenWire::Texto(t) => assert_eq!(t.xf.traslacion(), (101.0, 202.0)),
176 otra => panic!("{otra:?}"),
177 }
178}
179
180#[test]
181fn la_mezcla_vuelve_a_ser_la_misma() {
182 // Los 16 x 14 modos, todos, porque el mapeo es a mano y un dedo mal puesto
183 // en el match no se nota hasta que alguien usa `ColorBurn`.
184 for mix in [
185 Mix::Normal, Mix::Multiply, Mix::Screen, Mix::Overlay, Mix::Darken,
186 Mix::Lighten, Mix::ColorDodge, Mix::ColorBurn, Mix::HardLight,
187 Mix::SoftLight, Mix::Difference, Mix::Exclusion, Mix::Hue,
188 Mix::Saturation, Mix::Color, Mix::Luminosity,
189 ] {
190 for compose in [
191 Compose::Clear, Compose::Copy, Compose::Dest, Compose::SrcOver,
192 Compose::DestOver, Compose::SrcIn, Compose::DestIn, Compose::SrcOut,
193 Compose::DestOut, Compose::SrcAtop, Compose::DestAtop, Compose::Xor,
194 Compose::Plus, Compose::PlusLighter,
195 ] {
196 let bm = BlendMode { mix, compose };
197 assert_eq!(MezclaWire::de(bm).a_peniko(), bm, "{mix:?}/{compose:?}");
198 }
199 }
200}
201
202#[test]
203fn el_degrade_conserva_sus_paradas() {
204 use peniko::{ColorStop, ColorStops, Gradient, GradientKind, LinearGradientPosition};
205 use peniko::color::DynamicColor;
206
207 let mut stops = ColorStops::new();
208 stops.push(ColorStop {
209 offset: 0.0,
210 color: DynamicColor::from_alpha_color(Color::from_rgba8(255, 0, 0, 255)),
211 });
212 stops.push(ColorStop {
213 offset: 1.0,
214 color: DynamicColor::from_alpha_color(Color::from_rgba8(0, 0, 255, 255)),
215 });
216 let g = Gradient {
217 kind: GradientKind::Linear(LinearGradientPosition::new((0.0, 0.0), (100.0, 0.0))),
218 stops,
219 ..Default::default()
220 };
221
222 let wire = PincelWire::de(&peniko::Brush::Gradient(g));
223 match &wire {
224 PincelWire::Degrade { paradas, .. } => {
225 assert_eq!(paradas.len(), 2);
226 assert_eq!(paradas[0].color, [255, 0, 0, 255]);
227 assert_eq!(paradas[1].color, [0, 0, 255, 255]);
228 }
229 otro => panic!("{otro:?}"),
230 }
231 assert!(wire.a_peniko().is_some());
232}
233
234#[test]
235fn una_imagen_no_manda_pixeles_en_la_orden() {
236 // El contrato: **la ORDEN** pesa lo mismo con una imagen de 1 KB que con una
237 // de 4 MB, porque sólo viaja la huella. Si esto cambia, el IR de escena deja
238 // de ser 7-67x más barato que mandar el framebuffer y todo W4 se cae.
239 //
240 // **Este test medía la escena ENTERA** y por eso también fijaba, sin
241 // decirlo, que los píxeles no viajaran NUNCA — y eso no era un contrato sino
242 // un agujero: el reproductor no tenía de dónde sacar la imagen y no dibujaba
243 // ninguna, así que en el explorador enjaulado toda miniatura salía en negro.
244 // Ahora los píxeles viajan **una vez por imagen y por conexión**, en la
245 // tabla de blobs y no en la orden; el invariante que W4 necesita —que el
246 // costo POR CUADRO no crezca con el tamaño de la imagen— se mide sacando los
247 // blobs ya enviados, que es exactamente lo que hace quien manda.
248 use peniko::{Blob, ImageAlphaType, ImageBrush, ImageData, ImageFormat};
249 use alloc::sync::Arc;
250
251 let grande = ImageBrush {
252 image: ImageData {
253 data: Blob::new(Arc::new(vec![7u8; 4 * 1024 * 1024])),
254 format: ImageFormat::Rgba8,
255 alpha_type: ImageAlphaType::Alpha,
256 width: 1024,
257 height: 1024,
258 },
259 sampler: Default::default(),
260 };
261
262 let mut e = EscenaWire::new();
263 e.draw_image(&grande, Affine::IDENTITY);
264 // Los 4 MB viajan UNA vez, con la escena que estrena la imagen.
265 let (_, con_blob) = ida_y_vuelta(&e);
266 assert!(
267 con_blob > 4 * 1024 * 1024,
268 "el estreno tiene que llevar los píxeles, y pesó {con_blob} B"
269 );
270 // Y el cuadro siguiente —con el anfitrión ya teniéndola— vuelve a pesar lo
271 // que pesa una huella. Esto es el invariante de W4.
272 e.retener_blobs(|_| false);
273 let (_, sin_blob) = ida_y_vuelta(&e);
274 assert!(
275 sin_blob < 64,
276 "una orden de imagen pesó {sin_blob} B — no puede llevar píxeles"
277 );
278}
279
280/// Arma una escena sintética del tamaño que mide el plan.
281///
282/// Cada widget = un rect redondeado + su etiqueta, que es la forma real de casi
283/// todo en Llimphi (botón, item de lista, celda, chip).
284fn escena_de(n: usize, con_texto: bool) -> EscenaWire {
285 let mut e = EscenaWire::new();
286 let cols = 8usize;
287 for i in 0..n {
288 let x = ((i % cols) * 58) as f64;
289 let y = ((i / cols) * 22) as f64;
290 e.fill(
291 Fill::NonZero,
292 Affine::IDENTITY,
293 Color::from_rgba8(40, 44, 60, 255),
294 None,
295 &RoundedRect::new(x, y, x + 54.0, y + 18.0, 4.0),
296 );
297 if con_texto {
298 e.texto(Texto {
299 cadena: alloc::format!("item {i}"),
300 estilo: EstiloTextoWire::default(),
301 color: [220, 220, 240, 255],
302 ancho_max: Some(54.0),
303 xf: UbicacionWire::de(Affine::translate((x + 4.0, y + 3.0))),
304 hint: true,
305 });
306 }
307 }
308 e
309}
310
311#[test]
312fn el_peso_por_fotograma_es_el_que_el_plan_prometio() {
313 // El número contra el que se compara: mandar el framebuffer entero.
314 let pixeles = ANCHO * ALTO * 4;
315
316 let mut lineas = alloc::vec::Vec::new();
317 let mut peor_kb = 0.0f64;
318 for n in [50usize, 200, 500] {
319 let e = escena_de(n, true);
320 let (vuelta, bytes) = ida_y_vuelta(&e);
321 assert_eq!(vuelta, e);
322
323 let kb = bytes as f64 / 1024.0;
324 peor_kb = peor_kb.max(kb);
325 lineas.push(alloc::format!(
326 " {n:>3} widgets :: {} órdenes ({} textos) :: {bytes:>6} B = {kb:>6.1} KB :: {:>5.1}x menos que píxeles",
327 e.len(),
328 e.textos(),
329 pixeles as f64 / bytes as f64,
330 ));
331 }
332
333 // `--nocapture` para verlo; el assert de abajo es el que gatea.
334 std::println!("\nIR de escena @{ANCHO}x{ALTO}, píxeles crudos = {pixeles} B:");
335 for l in &lineas {
336 std::println!("{l}");
337 }
338
339 // El techo del plan es 114 KB por fotograma. Si esto se pasa, el IR dejó de
340 // caber en el presupuesto con el que se eligió la arquitectura.
341 assert!(
342 peor_kb < 114.0,
343 "el fotograma más pesado dio {peor_kb:.1} KB, sobre el techo de 114 KB del plan"
344 );
345}
346
347#[test]
348fn internar_el_estilo_hace_que_el_texto_sea_barato() {
349 // La medición que corrigió el diseño. Antes de internar, el estilo entero
350 // viajaba en cada orden y el texto se llevaba 31.890 B de 64.393 —la mitad
351 // del cable, y no por las cadenas sino por repetir diecisiete campos 500
352 // veces—. La tabla de estilos convierte esa repetición en un u16.
353 let con = postcard::to_allocvec(&escena_de(500, true)).unwrap().len();
354 let sin = postcard::to_allocvec(&escena_de(500, false)).unwrap().len();
355 let costo_texto = con - sin;
356 let escena = escena_de(500, true);
357
358 std::println!(
359 "\n500 widgets :: sin texto {sin} B, con texto {con} B\n el texto agrega {costo_texto} B ({:.0}% del cable) con {} estilo(s) en tabla",
360 100.0 * costo_texto as f64 / con as f64,
361 escena.estilos().len(),
362 );
363
364 // Quinientas etiquetas del mismo rol = UN estilo. Si esto crece, alguien
365 // rompió la igualdad de `EstiloTextoWire` (un f32 NaN, un campo nuevo mal
366 // derivado) y el internado dejó de servir sin avisar.
367 assert_eq!(escena.estilos().len(), 1, "el internado no dedupló");
368
369 // Mandar esas etiquetas como glifos costaría ~500 x 10 x 12 = 60.000 B (id +
370 // x + y por glifo). Como cadenas + un estilo compartido tiene que costar
371 // bastante menos de la mitad: es el corolario en bytes de la decisión que
372 // W4.1 tomó por tiempo.
373 assert!(
374 costo_texto < 60_000 / 3,
375 "el texto costó {costo_texto} B — se esperaba muy por debajo de mandar glifos"
376 );
377}
378
379#[test]
380fn la_ubicacion_del_texto_elige_la_representacion_mas_barata() {
381 // El caso normal —traslación pura— tiene que viajar como punto, y el raro
382 // —rotación— como matriz exacta. Si la heurística se rompiera hacia el lado
383 // barato, el texto rotado se pintaría derecho SIN QUE NADA AVISE; si se
384 // rompiera hacia el caro, el fotograma engordaría un 17% por nada.
385 let traslacion = UbicacionWire::de(Affine::translate((12.0, 34.0)));
386 assert!(
387 matches!(traslacion, UbicacionWire::Punto([12.0, 34.0])),
388 "una traslación pura tiene que viajar como punto, vino {traslacion:?}"
389 );
390
391 let rotado = Affine::rotate(0.5) * Affine::translate((12.0, 34.0));
392 let ubic = UbicacionWire::de(rotado);
393 assert!(
394 matches!(ubic, UbicacionWire::Afin(_)),
395 "un afín con rotación tiene que viajar entero, vino {ubic:?}"
396 );
397 // Y exacto en f32: reconstruirlo devuelve la misma matriz.
398 let vuelta = ubic.a_kurbo().as_coeffs();
399 for (i, (v, o)) in vuelta.iter().zip(rotado.as_coeffs().iter()).enumerate() {
400 assert!((v - o).abs() < 1e-5, "coef {i}: {v} != {o}");
401 }
402
403 // Componer una traslación con otra sigue siendo un punto — si no, cada
404 // `append` de un subárbol cacheado engordaría el cable.
405 let compuesta = traslacion.componer(Affine::translate((1.0, 1.0)));
406 assert!(matches!(compuesta, UbicacionWire::Punto([13.0, 35.0])));
407
408 // Pero componer con una rotación promueve a matriz, que es lo correcto.
409 let promovida = traslacion.componer(Affine::rotate(0.3));
410 assert!(matches!(promovida, UbicacionWire::Afin(_)));
411}
412
413#[test]
414fn append_remapea_los_estilos_de_la_hija() {
415 // El bug que el internado habilita si nadie lo cuida: la hija dice «estilo
416 // 0» queriendo decir «monoespaciado», y la madre ya tiene otro en el 0. Sin
417 // remapeo, un subárbol cacheado se repinta con la tipografía de otro.
418 let cuerpo = EstiloTextoWire::default();
419 let titulo = EstiloTextoWire {
420 tamano_px: 28.0,
421 peso: 700.0,
422 ..Default::default()
423 };
424
425 let mut madre = EscenaWire::new();
426 madre.texto(Texto {
427 cadena: "madre".to_string(),
428 estilo: cuerpo.clone(),
429 color: [0, 0, 0, 255],
430 ancho_max: None,
431 xf: UbicacionWire::de(Affine::IDENTITY),
432 hint: false,
433 });
434
435 let mut hija = EscenaWire::new();
436 hija.texto(Texto {
437 cadena: "hija".to_string(),
438 estilo: titulo.clone(),
439 color: [0, 0, 0, 255],
440 ancho_max: None,
441 xf: UbicacionWire::de(Affine::IDENTITY),
442 hint: false,
443 });
444 // En la hija, el título es el índice 0.
445 match &hija.ordenes()[0] {
446 OrdenWire::Texto(t) => assert_eq!(t.estilo, 0),
447 otra => panic!("{otra:?}"),
448 }
449
450 madre.append(&hija, None);
451
452 assert_eq!(madre.estilos().len(), 2);
453 // Y en la madre pasó a ser el 1, sin dejar de significar «título».
454 match &madre.ordenes()[1] {
455 OrdenWire::Texto(t) => {
456 assert_eq!(t.estilo, 1);
457 assert_eq!(madre.estilo(t.estilo), Some(&titulo));
458 }
459 otra => panic!("{otra:?}"),
460 }
461 match &madre.ordenes()[0] {
462 OrdenWire::Texto(t) => assert_eq!(madre.estilo(t.estilo), Some(&cuerpo)),
463 otra => panic!("{otra:?}"),
464 }
465}
466
467#[test]
468fn la_medida_cruza_en_doce_bytes_exactos() {
469 // El contrato de la RESPUESTA es de tamaño fijo a propósito: el kernel la
470 // escribe sin alocar. Si alguien le suma un campo, esto lo dice antes de que
471 // el lado app lea basura desde un offset corrido.
472 let m = MedidaTexto {
473 width: 123.5,
474 full_width: 128.0,
475 height: 16.8,
476 };
477 let bytes = m.a_bytes();
478 assert_eq!(bytes.len(), MedidaTexto::BYTES);
479 assert_eq!(MedidaTexto::de_bytes(&bytes), m);
480}
481
482#[test]
483fn la_peticion_de_medida_sobrevive_el_viaje() {
484 let p = PeticionMedida {
485 cadena: "ñandú efficiency".to_string(),
486 estilo: EstiloTextoWire {
487 tamano_px: 15.0,
488 familia: Some("Inter".to_string()),
489 peso: 700.0,
490 ..Default::default()
491 },
492 ancho_max: Some(120.0),
493 };
494 let bytes = postcard::to_allocvec(&p).expect("serializar");
495 let vuelta: PeticionMedida = postcard::from_bytes(&bytes).expect("deserializar");
496 assert_eq!(vuelta, p, "la petición cambió al cruzar ({} B)", bytes.len());
497}