// ============================================================================= // pruebas :: que el cable no pierda nada, y que pese lo que el plan prometió // ----------------------------------------------------------------------------- // Dos preguntas distintas y las dos hay que contestarlas con números: // // 1. **¿Sobrevive el viaje?** Round-trip postcard de cada variante. Un IR que // redondea mal un radio o pierde un span de estilo no se nota hasta que // alguien mira la pantalla, y para entonces el bug tiene una semana. // // 2. **¿Pesa lo que dijimos?** El plan (W4) promete «11-114 KB por fotograma, // 7-67x menos que mandar píxeles». Eso era una estimación. Acá se mide. // ============================================================================= use super::*; use alloc::string::{String, ToString}; use alloc::vec; use kurbo::{Affine, Circle, Rect, RoundedRect, Stroke}; use peniko::{BlendMode, Color, Compose, Fill, Mix}; /// Tamaño de referencia del plan: 480x400, el mismo de la tabla de W4. const ANCHO: usize = 480; const ALTO: usize = 400; fn ida_y_vuelta(e: &EscenaWire) -> (EscenaWire, usize) { let bytes = postcard::to_allocvec(e).expect("serializar"); let vuelta: EscenaWire = postcard::from_bytes(&bytes).expect("deserializar"); (vuelta, bytes.len()) } #[test] fn cada_variante_sobrevive_el_viaje() { let mut e = EscenaWire::new(); // Relleno sólido sobre rect — el camino barato. e.fill( Fill::NonZero, Affine::translate((3.0, 5.0)), Color::from_rgba8(18, 18, 32, 255), None, &Rect::new(0.0, 0.0, 100.0, 28.0), ); // Rect redondeado, que es lo que pinta casi todo widget. e.fill( Fill::EvenOdd, Affine::IDENTITY, Color::from_rgba8(200, 30, 30, 128), Some(Affine::scale(2.0)), &RoundedRect::new(0.0, 0.0, 40.0, 40.0, 6.0), ); // Forma arbitraria → se aplana a curvas. e.stroke( &Stroke::new(1.5), Affine::IDENTITY, Color::from_rgba8(0, 255, 0, 255), None, &Circle::new((20.0, 20.0), 10.0), ); e.push_layer( Fill::NonZero, BlendMode { mix: Mix::Multiply, compose: Compose::DestIn, }, 0.5, Affine::IDENTITY, &Rect::new(0.0, 0.0, 10.0, 10.0), ); e.push_luminance_mask_layer(Fill::NonZero, 0.25, Affine::IDENTITY, &Rect::new(0.0, 0.0, 5.0, 5.0)); e.pop_layer(); e.draw_blurred_rounded_rect( Affine::IDENTITY, Rect::new(0.0, 0.0, 50.0, 50.0), Color::from_rgba8(0, 0, 0, 90), 8.0, 4.0, ); e.texto(Texto { cadena: "wawa · ñandú · 1280x800".to_string(), estilo: EstiloTextoWire { tamano_px: 14.0, familia: Some("Inter".to_string()), rangos: vec![RangoWire { inicio: 0, fin: 4, estilo: EstiloRangoWire { peso: Some(700.0), color: Some([255, 0, 0, 255]), ..Default::default() }, }], ..Default::default() }, color: [220, 220, 240, 255], ancho_max: Some(200.0), xf: UbicacionWire::de(Affine::translate((10.0, 7.0))), hint: true, }); let (vuelta, bytes) = ida_y_vuelta(&e); assert_eq!(vuelta, e, "el round-trip cambió la escena ({bytes} B)"); assert_eq!(vuelta.len(), 8); assert_eq!(vuelta.textos(), 1); } #[test] fn el_texto_viaja_como_texto_y_no_como_glifos() { // Ésta es LA prueba de W4: lo que cruza es la cadena. Si algún día alguien // "optimiza" shapeando del lado de la app, este test cae — y la tabla de // W4.1 dice que ahí el fotograma pasa de 71 fps a 21,8. let mut e = EscenaWire::new(); e.texto(Texto { cadena: "efficiency".to_string(), // con ligadura "ffi", que un shaper resuelve estilo: EstiloTextoWire::default(), color: [0, 0, 0, 255], ancho_max: None, xf: UbicacionWire::de(Affine::IDENTITY), hint: true, }); let (vuelta, _) = ida_y_vuelta(&e); match &vuelta.ordenes()[0] { OrdenWire::Texto(t) => { assert_eq!(t.cadena, "efficiency"); // Sin ligadura resuelta: son 10 caracteres, no 8 glifos. assert_eq!(t.cadena.chars().count(), 10); } otra => panic!("se esperaba texto, vino {otra:?}"), } } #[test] fn el_texto_vacio_no_ocupa_una_orden() { let mut e = EscenaWire::new(); e.texto(Texto { cadena: String::new(), estilo: EstiloTextoWire::default(), color: [0, 0, 0, 255], ancho_max: None, xf: UbicacionWire::de(Affine::IDENTITY), hint: false, }); assert!(e.is_empty()); } #[test] fn append_traslada_formas_y_texto_por_igual() { let mut hija = EscenaWire::new(); hija.fill( Fill::NonZero, Affine::IDENTITY, Color::BLACK, None, &Rect::new(0.0, 0.0, 10.0, 10.0), ); hija.texto(Texto { cadena: "x".to_string(), estilo: EstiloTextoWire::default(), color: [0, 0, 0, 255], ancho_max: None, xf: UbicacionWire::de(Affine::translate((1.0, 2.0))), hint: false, }); let mut madre = EscenaWire::new(); madre.append(&hija, Some(Affine::translate((100.0, 200.0)))); // La forma se traslada por la matriz… match &madre.ordenes()[0] { OrdenWire::Rellenar { xf, .. } => assert_eq!((xf[4], xf[5]), (100.0, 200.0)), otra => panic!("{otra:?}"), } // …y el texto por la misma matriz, sin caso especial. match &madre.ordenes()[1] { OrdenWire::Texto(t) => assert_eq!(t.xf.traslacion(), (101.0, 202.0)), otra => panic!("{otra:?}"), } } #[test] fn la_mezcla_vuelve_a_ser_la_misma() { // Los 16 x 14 modos, todos, porque el mapeo es a mano y un dedo mal puesto // en el match no se nota hasta que alguien usa `ColorBurn`. for mix in [ Mix::Normal, Mix::Multiply, Mix::Screen, Mix::Overlay, Mix::Darken, Mix::Lighten, Mix::ColorDodge, Mix::ColorBurn, Mix::HardLight, Mix::SoftLight, Mix::Difference, Mix::Exclusion, Mix::Hue, Mix::Saturation, Mix::Color, Mix::Luminosity, ] { for compose in [ Compose::Clear, Compose::Copy, Compose::Dest, Compose::SrcOver, Compose::DestOver, Compose::SrcIn, Compose::DestIn, Compose::SrcOut, Compose::DestOut, Compose::SrcAtop, Compose::DestAtop, Compose::Xor, Compose::Plus, Compose::PlusLighter, ] { let bm = BlendMode { mix, compose }; assert_eq!(MezclaWire::de(bm).a_peniko(), bm, "{mix:?}/{compose:?}"); } } } #[test] fn el_degrade_conserva_sus_paradas() { use peniko::{ColorStop, ColorStops, Gradient, GradientKind, LinearGradientPosition}; use peniko::color::DynamicColor; let mut stops = ColorStops::new(); stops.push(ColorStop { offset: 0.0, color: DynamicColor::from_alpha_color(Color::from_rgba8(255, 0, 0, 255)), }); stops.push(ColorStop { offset: 1.0, color: DynamicColor::from_alpha_color(Color::from_rgba8(0, 0, 255, 255)), }); let g = Gradient { kind: GradientKind::Linear(LinearGradientPosition::new((0.0, 0.0), (100.0, 0.0))), stops, ..Default::default() }; let wire = PincelWire::de(&peniko::Brush::Gradient(g)); match &wire { PincelWire::Degrade { paradas, .. } => { assert_eq!(paradas.len(), 2); assert_eq!(paradas[0].color, [255, 0, 0, 255]); assert_eq!(paradas[1].color, [0, 0, 255, 255]); } otro => panic!("{otro:?}"), } assert!(wire.a_peniko().is_some()); } #[test] fn una_imagen_no_manda_pixeles_en_la_orden() { // El contrato: **la ORDEN** pesa lo mismo con una imagen de 1 KB que con una // de 4 MB, porque sólo viaja la huella. Si esto cambia, el IR de escena deja // de ser 7-67x más barato que mandar el framebuffer y todo W4 se cae. // // **Este test medía la escena ENTERA** y por eso también fijaba, sin // decirlo, que los píxeles no viajaran NUNCA — y eso no era un contrato sino // un agujero: el reproductor no tenía de dónde sacar la imagen y no dibujaba // ninguna, así que en el explorador enjaulado toda miniatura salía en negro. // Ahora los píxeles viajan **una vez por imagen y por conexión**, en la // tabla de blobs y no en la orden; el invariante que W4 necesita —que el // costo POR CUADRO no crezca con el tamaño de la imagen— se mide sacando los // blobs ya enviados, que es exactamente lo que hace quien manda. use peniko::{Blob, ImageAlphaType, ImageBrush, ImageData, ImageFormat}; use alloc::sync::Arc; let grande = ImageBrush { image: ImageData { data: Blob::new(Arc::new(vec![7u8; 4 * 1024 * 1024])), format: ImageFormat::Rgba8, alpha_type: ImageAlphaType::Alpha, width: 1024, height: 1024, }, sampler: Default::default(), }; let mut e = EscenaWire::new(); e.draw_image(&grande, Affine::IDENTITY); // Los 4 MB viajan UNA vez, con la escena que estrena la imagen. let (_, con_blob) = ida_y_vuelta(&e); assert!( con_blob > 4 * 1024 * 1024, "el estreno tiene que llevar los píxeles, y pesó {con_blob} B" ); // Y el cuadro siguiente —con el anfitrión ya teniéndola— vuelve a pesar lo // que pesa una huella. Esto es el invariante de W4. e.retener_blobs(|_| false); let (_, sin_blob) = ida_y_vuelta(&e); assert!( sin_blob < 64, "una orden de imagen pesó {sin_blob} B — no puede llevar píxeles" ); } /// Arma una escena sintética del tamaño que mide el plan. /// /// Cada widget = un rect redondeado + su etiqueta, que es la forma real de casi /// todo en Llimphi (botón, item de lista, celda, chip). fn escena_de(n: usize, con_texto: bool) -> EscenaWire { let mut e = EscenaWire::new(); let cols = 8usize; for i in 0..n { let x = ((i % cols) * 58) as f64; let y = ((i / cols) * 22) as f64; e.fill( Fill::NonZero, Affine::IDENTITY, Color::from_rgba8(40, 44, 60, 255), None, &RoundedRect::new(x, y, x + 54.0, y + 18.0, 4.0), ); if con_texto { e.texto(Texto { cadena: alloc::format!("item {i}"), estilo: EstiloTextoWire::default(), color: [220, 220, 240, 255], ancho_max: Some(54.0), xf: UbicacionWire::de(Affine::translate((x + 4.0, y + 3.0))), hint: true, }); } } e } #[test] fn el_peso_por_fotograma_es_el_que_el_plan_prometio() { // El número contra el que se compara: mandar el framebuffer entero. let pixeles = ANCHO * ALTO * 4; let mut lineas = alloc::vec::Vec::new(); let mut peor_kb = 0.0f64; for n in [50usize, 200, 500] { let e = escena_de(n, true); let (vuelta, bytes) = ida_y_vuelta(&e); assert_eq!(vuelta, e); let kb = bytes as f64 / 1024.0; peor_kb = peor_kb.max(kb); lineas.push(alloc::format!( " {n:>3} widgets :: {} órdenes ({} textos) :: {bytes:>6} B = {kb:>6.1} KB :: {:>5.1}x menos que píxeles", e.len(), e.textos(), pixeles as f64 / bytes as f64, )); } // `--nocapture` para verlo; el assert de abajo es el que gatea. std::println!("\nIR de escena @{ANCHO}x{ALTO}, píxeles crudos = {pixeles} B:"); for l in &lineas { std::println!("{l}"); } // El techo del plan es 114 KB por fotograma. Si esto se pasa, el IR dejó de // caber en el presupuesto con el que se eligió la arquitectura. assert!( peor_kb < 114.0, "el fotograma más pesado dio {peor_kb:.1} KB, sobre el techo de 114 KB del plan" ); } #[test] fn internar_el_estilo_hace_que_el_texto_sea_barato() { // La medición que corrigió el diseño. Antes de internar, el estilo entero // viajaba en cada orden y el texto se llevaba 31.890 B de 64.393 —la mitad // del cable, y no por las cadenas sino por repetir diecisiete campos 500 // veces—. La tabla de estilos convierte esa repetición en un u16. let con = postcard::to_allocvec(&escena_de(500, true)).unwrap().len(); let sin = postcard::to_allocvec(&escena_de(500, false)).unwrap().len(); let costo_texto = con - sin; let escena = escena_de(500, true); std::println!( "\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", 100.0 * costo_texto as f64 / con as f64, escena.estilos().len(), ); // Quinientas etiquetas del mismo rol = UN estilo. Si esto crece, alguien // rompió la igualdad de `EstiloTextoWire` (un f32 NaN, un campo nuevo mal // derivado) y el internado dejó de servir sin avisar. assert_eq!(escena.estilos().len(), 1, "el internado no dedupló"); // Mandar esas etiquetas como glifos costaría ~500 x 10 x 12 = 60.000 B (id + // x + y por glifo). Como cadenas + un estilo compartido tiene que costar // bastante menos de la mitad: es el corolario en bytes de la decisión que // W4.1 tomó por tiempo. assert!( costo_texto < 60_000 / 3, "el texto costó {costo_texto} B — se esperaba muy por debajo de mandar glifos" ); } #[test] fn la_ubicacion_del_texto_elige_la_representacion_mas_barata() { // El caso normal —traslación pura— tiene que viajar como punto, y el raro // —rotación— como matriz exacta. Si la heurística se rompiera hacia el lado // barato, el texto rotado se pintaría derecho SIN QUE NADA AVISE; si se // rompiera hacia el caro, el fotograma engordaría un 17% por nada. let traslacion = UbicacionWire::de(Affine::translate((12.0, 34.0))); assert!( matches!(traslacion, UbicacionWire::Punto([12.0, 34.0])), "una traslación pura tiene que viajar como punto, vino {traslacion:?}" ); let rotado = Affine::rotate(0.5) * Affine::translate((12.0, 34.0)); let ubic = UbicacionWire::de(rotado); assert!( matches!(ubic, UbicacionWire::Afin(_)), "un afín con rotación tiene que viajar entero, vino {ubic:?}" ); // Y exacto en f32: reconstruirlo devuelve la misma matriz. let vuelta = ubic.a_kurbo().as_coeffs(); for (i, (v, o)) in vuelta.iter().zip(rotado.as_coeffs().iter()).enumerate() { assert!((v - o).abs() < 1e-5, "coef {i}: {v} != {o}"); } // Componer una traslación con otra sigue siendo un punto — si no, cada // `append` de un subárbol cacheado engordaría el cable. let compuesta = traslacion.componer(Affine::translate((1.0, 1.0))); assert!(matches!(compuesta, UbicacionWire::Punto([13.0, 35.0]))); // Pero componer con una rotación promueve a matriz, que es lo correcto. let promovida = traslacion.componer(Affine::rotate(0.3)); assert!(matches!(promovida, UbicacionWire::Afin(_))); } #[test] fn append_remapea_los_estilos_de_la_hija() { // El bug que el internado habilita si nadie lo cuida: la hija dice «estilo // 0» queriendo decir «monoespaciado», y la madre ya tiene otro en el 0. Sin // remapeo, un subárbol cacheado se repinta con la tipografía de otro. let cuerpo = EstiloTextoWire::default(); let titulo = EstiloTextoWire { tamano_px: 28.0, peso: 700.0, ..Default::default() }; let mut madre = EscenaWire::new(); madre.texto(Texto { cadena: "madre".to_string(), estilo: cuerpo.clone(), color: [0, 0, 0, 255], ancho_max: None, xf: UbicacionWire::de(Affine::IDENTITY), hint: false, }); let mut hija = EscenaWire::new(); hija.texto(Texto { cadena: "hija".to_string(), estilo: titulo.clone(), color: [0, 0, 0, 255], ancho_max: None, xf: UbicacionWire::de(Affine::IDENTITY), hint: false, }); // En la hija, el título es el índice 0. match &hija.ordenes()[0] { OrdenWire::Texto(t) => assert_eq!(t.estilo, 0), otra => panic!("{otra:?}"), } madre.append(&hija, None); assert_eq!(madre.estilos().len(), 2); // Y en la madre pasó a ser el 1, sin dejar de significar «título». match &madre.ordenes()[1] { OrdenWire::Texto(t) => { assert_eq!(t.estilo, 1); assert_eq!(madre.estilo(t.estilo), Some(&titulo)); } otra => panic!("{otra:?}"), } match &madre.ordenes()[0] { OrdenWire::Texto(t) => assert_eq!(madre.estilo(t.estilo), Some(&cuerpo)), otra => panic!("{otra:?}"), } } #[test] fn la_medida_cruza_en_doce_bytes_exactos() { // El contrato de la RESPUESTA es de tamaño fijo a propósito: el kernel la // escribe sin alocar. Si alguien le suma un campo, esto lo dice antes de que // el lado app lea basura desde un offset corrido. let m = MedidaTexto { width: 123.5, full_width: 128.0, height: 16.8, }; let bytes = m.a_bytes(); assert_eq!(bytes.len(), MedidaTexto::BYTES); assert_eq!(MedidaTexto::de_bytes(&bytes), m); } #[test] fn la_peticion_de_medida_sobrevive_el_viaje() { let p = PeticionMedida { cadena: "ñandú efficiency".to_string(), estilo: EstiloTextoWire { tamano_px: 15.0, familia: Some("Inter".to_string()), peso: 700.0, ..Default::default() }, ancho_max: Some(120.0), }; let bytes = postcard::to_allocvec(&p).expect("serializar"); let vuelta: PeticionMedida = postcard::from_bytes(&bytes).expect("deserializar"); assert_eq!(vuelta, p, "la petición cambió al cruzar ({} B)", bytes.len()); }