gioser

tawasuyu/llimphi

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

Rust 229TOML 101Markdown 82Shell 24052 Sutichaykuna

⎇ main llimphi-wire-escena/src/pruebas.rs Rust · 497 siq'ikuna · 17.7 KB Ñawpa willay Pi imata llamkarqan ch'uya

chore: refresco desde el monorepo — 132 miembros, 79 widgets, la galería vuelve y licencia dual d8edd9a9c · Sergio ·

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}