//! El caché de `repaint_boundary` con **gradiente e imagen** adentro. //! //! Hasta 2026-08-05 `boundary_cacheable` devolvía `false` en cuanto el subárbol //! tenía un degradé o una imagen, con la nota «no se hashean de forma barata». //! Eso dejaba al caché sin el caso que lo motivaba: el chrome estático de una //! app real —un sidebar, una toolbar— tiene un fondo con degradé o un ícono, y //! **un solo nodo con imagen descalificaba el subárbol entero**. //! //! Ampliar lo que un caché acepta es el cambio más peligroso que se le puede //! hacer: si el hash no cubre **todos** los campos que `paint` lee, el caché //! sirve píxeles viejos y nadie se entera hasta ver el cuadro mal. Por eso este //! archivo no prueba «anda»: prueba, campo por campo, que **cambiarlo invalida** //! —el caché falla— **y que lo repintado es lo nuevo**. Un hash que se olvidara //! de un campo pasaría un test de «anda» y fallaría éste. //! //! Certificado por texto (§8): conteos de píxeles distintos, llvmpipe. use llimphi_compositor::llimphi_layout::taffy::prelude::{length, percent, Size, Style}; use llimphi_compositor::peniko::kurbo::Point; use llimphi_compositor::peniko::{Gradient, GradientKind, LinearGradientPosition}; use llimphi_compositor::peniko::Color; use llimphi_compositor::{paint, paint_cached, ImageFit, RepaintCache, View}; use llimphi_test::{default_bg, diff, with_harness, Harness, Rendered}; const W: u32 = 160; const H: u32 = 120; const CLAVE: u64 = 0xB0CA; #[derive(Clone)] enum Msg {} fn degrade(a: Color, b: Color) -> Gradient { Gradient::new_linear(Point::new(0.0, 0.0), Point::new(1.0, 1.0)).with_stops([a, b]) } /// Imagen RGBA8 sintética: un damero de `w`×`h` con dos colores. Cada llamada /// construye un `Blob` nuevo — y por lo tanto un id nuevo, que es de lo que el /// hash se agarra. fn imagen(w: u32, h: u32, a: [u8; 4], b: [u8; 4]) -> llimphi_compositor::peniko::ImageBrush { let mut px = Vec::with_capacity((w * h * 4) as usize); for y in 0..h { for x in 0..w { px.extend_from_slice(if (x / 2 + y / 2) % 2 == 0 { &a } else { &b }); } } llimphi_image::from_rgba8(px, w, h) } /// Lo que se cachea: una tarjeta con degradé de fondo y una imagen encima. #[derive(Clone)] struct Pieza { grad: Gradient, img: llimphi_compositor::peniko::ImageBrush, fit: ImageFit, } impl Default for Pieza { fn default() -> Self { Self { grad: degrade( Color::from_rgba8(40, 70, 180, 255), Color::from_rgba8(200, 60, 90, 255), ), // No cuadrada, para que Contain y Cover den cuadros distintos. img: imagen(16, 8, [250, 250, 60, 255], [20, 20, 20, 255]), fit: ImageFit::Contain, } } } fn arbol(p: &Pieza, marca: u32) -> View { View::::new(Style { size: Size { width: percent(1.0_f32), height: percent(1.0_f32) }, flex_direction: llimphi_compositor::llimphi_layout::taffy::prelude::FlexDirection::Column, ..Default::default() }) .fill(Color::from_rgba8(18, 18, 24, 255)) .children(vec![ View::::new(Style { size: Size { width: percent(1.0_f32), height: length(70.0_f32) }, ..Default::default() }) .fill_gradient(p.grad.clone()) .radius(6.0) .repaint_boundary(CLAVE) .children(vec![View::::new(Style { size: Size { width: length(60.0_f32), height: length(40.0_f32) }, ..Default::default() }) .image(p.img.clone()) .image_fit(p.fit)]), // Fuera del boundary: cambia todos los cuadros, para que el resto del // árbol se repinte y el boundary tenga algo que ahorrar. View::::new(Style::default()).text( format!("cuadro {marca}"), 13.0, Color::from_rgba8(255, 210, 120, 255), ), ]) } fn fresco(h: &mut Harness, p: &Pieza, marca: u32) -> Rendered { h.render_con(arbol(p, marca), W, H, default_bg(), |s, m, c, ts| { paint(s, m, c, ts, None, None); }) } fn cacheado(h: &mut Harness, p: &Pieza, marca: u32, cache: &mut RepaintCache) -> Rendered { h.render_con(arbol(p, marca), W, H, default_bg(), |s, m, c, ts| { paint_cached(s, m, c, ts, None, None, cache); }) } fn distintos(a: &Rendered, b: &Rendered) -> u64 { diff(a, b, 0).expect("mismo tamaño").1.differing_pixels } #[test] fn un_subarbol_con_degrade_e_imagen_ahora_se_cachea() { // Antes esto daba 0 hits porque `boundary_cacheable` lo rechazaba entero. with_harness(|h| { let p = Pieza::default(); let referencia = fresco(h, &p, 1); let mut cache = RepaintCache::new(); cacheado(h, &p, 1, &mut cache); assert_eq!((cache.hits, cache.misses), (0, 1), "el primero puebla el caché"); for marca in 2..=4 { let esperado = fresco(h, &p, marca); let obtenido = cacheado(h, &p, marca, &mut cache); assert_eq!( distintos(&esperado, &obtenido), 0, "cuadro {marca}: el degradé/imagen servidos del caché no son los frescos" ); } assert_eq!( (cache.hits, cache.misses), (3, 1), "el subárbol con degradé e imagen tenía que acertar" ); // Guarda del arnés: si el degradé no pintara nada, todo esto sería // comparar dos fondos lisos. let liso = h.render_con( View::::new(Style { size: Size { width: percent(1.0_f32), height: percent(1.0_f32) }, ..Default::default() }) .fill(Color::from_rgba8(18, 18, 24, 255)), W, H, default_bg(), |s, m, c, ts| paint(s, m, c, ts, None, None), ); assert!( distintos(&liso, &referencia) > 2000, "el árbol de prueba casi no pinta nada; el test no probaría el caché" ); }); } /// Cada mutación de la pieza cacheada tiene que **fallar el caché** y devolver /// el contenido nuevo. Si el hash se olvidara de un campo, ese caso daría hit y /// píxeles viejos — el bug exacto que ampliar un caché puede introducir. fn exige_invalidacion(nombre: &str, mutar: impl Fn(&mut Pieza)) { with_harness(|h| { let base = Pieza::default(); // **Clonar, no reconstruir.** `Pieza::default()` arma un `Blob` nuevo // cada vez, y un blob nuevo tiene id nuevo: dos `default()` difieren en // la imagen aunque se mute el degradé, y entonces cualquier caso // invalidaría por el id y no por lo que dice probar. Se descubrió // sabotéando el hash campo por campo: sin los stops, sin la geometría, // sin `image_fit` y sin el sampler, los tests seguían pasando. Sólo // clonando queda una **única** diferencia por caso. let mut cambiada = base.clone(); mutar(&mut cambiada); let mut cache = RepaintCache::new(); cacheado(h, &base, 1, &mut cache); assert_eq!(cache.misses, 1); let viejo = fresco(h, &base, 1); let nuevo = fresco(h, &cambiada, 1); // Guarda: la mutación tiene que verse. Sin esto, un campo que no // cambiara nada pasaría el test por la puerta de atrás. assert!( distintos(&viejo, &nuevo) > 20, "{nombre}: la mutación casi no cambia el cuadro ({} px); \ el caso no prueba invalidación", distintos(&viejo, &nuevo) ); let obtenido = cacheado(h, &cambiada, 1, &mut cache); assert_eq!(cache.hits, 0, "{nombre}: sirvió el subárbol viejo del caché"); assert_eq!(cache.misses, 2, "{nombre}: no invalidó"); assert_eq!( distintos(&nuevo, &obtenido), 0, "{nombre}: invalidó pero no repintó el contenido nuevo" ); }); } #[test] fn cambiar_los_colores_del_degrade_invalida() { exige_invalidacion("stops", |p| { p.grad = degrade( Color::from_rgba8(20, 200, 80, 255), Color::from_rgba8(240, 240, 60, 255), ); }); } #[test] fn cambiar_la_direccion_del_degrade_invalida() { // Mismos colores, otra geometría: si el hash sólo mirara los stops, esto // daría hit y el degradé quedaría apuntando para donde no va. exige_invalidacion("dirección", |p| { p.grad.kind = GradientKind::Linear(LinearGradientPosition { start: Point::new(1.0, 0.0), end: Point::new(0.0, 1.0), }); }); } #[test] fn cambiar_los_pixeles_de_la_imagen_invalida() { exige_invalidacion("blob", |p| { p.img = imagen(16, 8, [60, 200, 250, 255], [10, 10, 60, 255]); }); } #[test] fn cambiar_el_encaje_de_la_imagen_invalida() { // El mismo blob, el mismo rect: lo único distinto es la transformación con // la que se pinta. `image_fit` no vive en la imagen sino en el nodo, y es // justo la clase de campo que un hash «de la imagen» deja afuera. exige_invalidacion("image_fit", |p| p.fit = ImageFit::Cover); } #[test] fn cambiar_el_alpha_del_sampler_invalida() { // El sampler cambia lo pintado sin tocar un byte del blob ni el id. exige_invalidacion("sampler.alpha", |p| p.img.sampler.alpha = 0.35); }