//! **Bloque 23 (`PLAN-NUCLEO-RETENIDO.md`) — `RepaintBoundary`: cacheado contra //! fresco, píxel a píxel.** //! //! El bloque quedó «HECHO (headless), falta PNG-diff en metal» desde julio, y //! la Iniciativa #2 del `PLAN-MOTOR-PROFUNDO` decía que ese follow-up se cierra //! **acá**, sin GPU física: el arnés `llimphi-test` rasteriza sobre el adapter //! de software y compara por número, que es la evidencia por texto que pide la //! Regla 8. Lo único que faltaba era una forma de pintar el mismo árbol de dos //! maneras — `Harness::render_con`. //! //! Lo que se prueba es una **igualdad**, y ésas se pasan solas si el arnés no //! está haciendo nada. Por eso cada test asevera además los contadores de //! `hits`/`misses`: sin eso, un `paint_cached` que nunca cacheara daría 0 px de //! diferencia y el test lo firmaría como correcto. use llimphi_compositor::llimphi_layout::taffy::{ prelude::{length, percent, Dimension, FlexDirection, Size, Style}, Rect, }; use llimphi_compositor::peniko::Color; use llimphi_compositor::{paint, paint_cached, RepaintCache, Shadow, View}; use llimphi_test::{default_bg, diff, with_harness, Harness, Rendered}; const W: u32 = 320; const H: u32 = 240; #[derive(Clone)] enum Msg {} /// Un subárbol con lo que de verdad rompe un caché de rasterización: gradiente, /// sombra, radio, borde y texto. Un rectángulo liso pasaría cualquier cosa. fn tarjeta(titulo: &str, color: Color) -> View { View::::new(Style { flex_direction: FlexDirection::Column, size: Size { width: percent(1.0_f32), height: length(70.0_f32) }, padding: Rect { left: length(10.0_f32), right: length(10.0_f32), top: length(8.0_f32), bottom: length(8.0_f32), }, ..Default::default() }) .fill(color) .hover_fill(Color::from_rgba8(240, 240, 90, 255)) .radius(9.0) .border(1.5, Color::from_rgba8(210, 210, 230, 255)) .shadow(Shadow { color: Color::from_rgba8(0, 0, 0, 120), blur: 8.0, dx: 2.0, dy: 3.0, spread: 0.0 }) .children(vec![ View::::new(Style::default()).text(titulo, 15.0, Color::WHITE), View::::new(Style::default()).text( "subtítulo con descendentes: jgpqy", 11.0, Color::from_rgba8(230, 230, 245, 255), ), ]) } /// El árbol de la prueba. `marca` cambia el texto de la tarjeta **de abajo**, /// que NO está dentro de un boundary — así se puede mover el árbol sin tocar el /// subárbol cacheado. fn arbol(titulo_panel: &str, marca: u32) -> View { View::::new(Style { flex_direction: FlexDirection::Column, size: Size { width: percent(1.0_f32), height: percent(1.0_f32) }, gap: Size { width: length(0.0_f32), height: length(10.0_f32) }, padding: Rect { left: length(14.0_f32), right: length(14.0_f32), top: length(14.0_f32), bottom: length(14.0_f32), }, ..Default::default() }) .fill(Color::from_rgba8(24, 24, 32, 255)) .children(vec![ // El chrome estático: justo el caso que el Bloque 23 quiere cachear. tarjeta(titulo_panel, Color::from_rgba8(60, 90, 170, 255)).repaint_boundary(0xC0FFEE), View::::new(Style { size: Size { width: percent(1.0_f32), height: Dimension::auto() }, ..Default::default() }) .text(format!("contador: {marca}"), 13.0, Color::from_rgba8(255, 210, 120, 255)), tarjeta("panel que cambia", Color::from_rgba8(150, 60, 90, 255)), ]) } fn fresco_con(h: &mut Harness, titulo: &str, marca: u32) -> Rendered { h.render_con(arbol(titulo, marca), W, H, default_bg(), |s, m, c, ts| { paint(s, m, c, ts, None, None); }) } fn cacheado_con(h: &mut Harness, titulo: &str, marca: u32, cache: &mut RepaintCache) -> Rendered { h.render_con(arbol(titulo, marca), W, H, default_bg(), |s, m, c, ts| { paint_cached(s, m, c, ts, None, None, cache); }) } fn fresco(h: &mut Harness, marca: u32) -> Rendered { fresco_con(h, "panel quieto", marca) } fn cacheado(h: &mut Harness, marca: u32, cache: &mut RepaintCache) -> Rendered { cacheado_con(h, "panel quieto", marca, cache) } /// Píxeles distintos entre dos rasterizaciones, sin tolerancia de canal. fn distintos(a: &Rendered, b: &Rendered) -> u64 { diff(a, b, 0).expect("mismo tamaño").1.differing_pixels } #[test] fn el_subarbol_servido_del_cache_da_los_mismos_pixeles_que_el_fresco() { with_harness(|h| { let referencia = fresco(h, 1); let mut cache = RepaintCache::new(); // Primer cuadro: el boundary no está en el caché, se rasteriza y se // guarda. Tiene que ser un miss, y tiene que dar lo mismo que el fresco // (acá el caché no participa del resultado, sólo lo memoriza). let primero = cacheado(h, 1, &mut cache); assert_eq!((cache.hits, cache.misses), (0, 1), "el primer cuadro es un miss"); assert_eq!(distintos(&referencia, &primero), 0, "el cuadro que puebla el caché ya difiere"); // Segundo cuadro: **ahora sí** el subárbol sale del caché, trasladado. // Ésta es la comparación que el Bloque 23 debía y arrastró seis meses. let segundo = cacheado(h, 1, &mut cache); assert_eq!((cache.hits, cache.misses), (1, 1), "el segundo cuadro tenía que acertar"); assert_eq!( distintos(&referencia, &segundo), 0, "el subárbol servido del caché no es idéntico al pintado fresco" ); }); } #[test] fn el_cache_sigue_acertando_aunque_el_resto_del_arbol_cambie() { // El punto entero del boundary: el chrome quieto se sirve del caché // mientras lo que lo rodea se repinta. Si un cambio en cualquier parte del // árbol invalidara el boundary, la optimización no ahorraría nada. with_harness(|h| { let mut cache = RepaintCache::new(); cacheado(h, 1, &mut cache); for marca in 2..=5 { let esperado = fresco(h, marca); let obtenido = cacheado(h, marca, &mut cache); assert_eq!( distintos(&esperado, &obtenido), 0, "con el contador en {marca} el cuadro cacheado no coincide con el fresco" ); } assert_eq!( (cache.hits, cache.misses), (4, 1), "el boundary tenía que acertar en los cuatro cuadros siguientes" ); }); } #[test] fn si_el_subarbol_cacheado_cambia_el_cache_falla_y_repinta_bien() { // El control que le da sentido a los otros dos: un caché que devolviera // siempre lo viejo los pasaría igual, porque ahí el subárbol nunca cambia. // Acá cambia, y lo que se exige es que el caché **falle** y que el // resultado sea el nuevo, no el de antes. with_harness(|h| { let mut cache = RepaintCache::new(); let _ = cacheado_con(h, "panel quieto", 1, &mut cache); assert_eq!(cache.misses, 1); let esperado = fresco_con(h, "panel MOVIDO", 1); let anterior = fresco_con(h, "panel quieto", 1); let obtenido = cacheado_con(h, "panel MOVIDO", 1, &mut cache); assert_eq!(cache.misses, 2, "cambió el contenido del boundary: tenía que fallar"); assert_eq!(cache.hits, 0, "y no puede haber acertado nada"); assert_eq!(distintos(&esperado, &obtenido), 0, "no repintó el contenido nuevo"); assert!( distintos(&anterior, &obtenido) > 0, "devolvió el subárbol viejo: el caché está sirviendo contenido rancio" ); }); } // ── El puntero adentro del subárbol cacheado ───────────────────────────── /// Índice del nodo montado que abre el boundary `key`. fn nodo_del_boundary(m: &llimphi_compositor::Mounted, key: u64) -> usize { m.nodes .iter() .position(|n| n.repaint_boundary == Some(key)) .expect("el árbol tiene que traer el boundary") } #[test] fn con_el_puntero_encima_el_boundary_no_se_sirve_del_cache() { // `boundary_cacheable` ya se niega a cachear un subárbol con el hover // adentro, y hay un test unitario que lo dice. Lo que no había es la // comprobación de que eso **se vea bien**: un caché que igual sirviera lo // guardado pintaría la tarjeta sin resaltar, y el usuario vería un panel // que no reacciona al puntero. // // El oráculo no necesita ninguna imagen bendecida: es `paint` con el mismo // hover. with_harness(|h| { let mut cache = RepaintCache::new(); // Un cuadro sin hover, para que el caché tenga algo guardado que servir. cacheado(h, 1, &mut cache); assert_eq!((cache.hits, cache.misses), (0, 1)); let sin_hover = fresco(h, 1); let esperado = h.render_con(arbol("panel quieto", 1), W, H, default_bg(), |s, m, c, ts| { let i = nodo_del_boundary(m, 0xC0FFEE); paint(s, m, c, ts, Some(i), None); }); let obtenido = h.render_con(arbol("panel quieto", 1), W, H, default_bg(), |s, m, c, ts| { let i = nodo_del_boundary(m, 0xC0FFEE); paint_cached(s, m, c, ts, Some(i), None, &mut cache); }); // Guarda del arnés: si el hover no cambiara nada visible, comparar dos // imágenes idénticas no probaría nada. assert!( distintos(&sin_hover, &esperado) > 100, "el hover tiene que cambiar la tarjeta para que este test signifique algo" ); assert_eq!( distintos(&esperado, &obtenido), 0, "con el puntero encima, el cuadro cacheado no coincide con el fresco" ); assert_eq!( cache.hits, 0, "sirvió el subárbol guardado en vez de repintarlo resaltado" ); }); } // ── El detector que dice DÓNDE poner el boundary ───────────────────────── /// El mismo árbol pero **sin** el `repaint_boundary`: lo que escribiría alguien /// que todavía no sabe dónde ponerlo. fn arbol_pelado(marca: u32) -> View { View::::new(Style { flex_direction: FlexDirection::Column, size: Size { width: percent(1.0_f32), height: percent(1.0_f32) }, gap: Size { width: length(0.0_f32), height: length(10.0_f32) }, padding: Rect { left: length(14.0_f32), right: length(14.0_f32), top: length(14.0_f32), bottom: length(14.0_f32), }, ..Default::default() }) .fill(Color::from_rgba8(24, 24, 32, 255)) .children(vec![ tarjeta("panel quieto", Color::from_rgba8(60, 90, 170, 255)), View::::new(Style { size: Size { width: percent(1.0_f32), height: Dimension::auto() }, ..Default::default() }) .text(format!("contador: {marca}"), 13.0, Color::from_rgba8(255, 210, 120, 255)), tarjeta("panel que cambia", Color::from_rgba8(150, 60, 90, 255)), ]) } #[test] fn lo_que_el_detector_senala_da_los_mismos_pixeles_al_cablearlo() { // El detector (`DetectorBoundaries`) mira el árbol cuadro a cuadro y dice // qué subárboles vienen quietos. Su promesa es doble: que puesto ahí el // boundary el caché **acierta**, y que lo pintado **no cambia**. Los hits // los cuenta un unit test del compositor; los píxeles sólo se pueden // comparar acá, que es donde hay rasterizador. // // Sin esto, el detector podría señalar un subárbol que el caché no sabe // reproducir y nadie se enteraría hasta ver el cuadro mal en pantalla. use llimphi_compositor::{mount, DetectorBoundaries}; use llimphi_compositor::llimphi_layout::LayoutTree; let mut det = DetectorBoundaries::con(3, 3); let mut sugerido = None; for marca in 0..4u32 { let mut lt = LayoutTree::new(); let m = mount(&mut lt, arbol_pelado(marca)); let c = lt.compute(m.root, (W as f32, H as f32)).expect("layout"); sugerido = det.observar(&m, &c, None, None).into_iter().next(); } let s = sugerido.expect("el detector tenía que señalar la tarjeta quieta"); assert!(s.nodos >= 3, "señaló un subárbol trivial: {} nodos", s.nodos); assert_eq!( s.texto.as_deref(), Some("panel quieto"), "señaló otra cosa que la tarjeta que no cambia: {s:?}" ); // Se cablea donde dijo —la tarjeta de arriba, que es `arbol()`— y se compara // contra el pintado fresco del árbol pelado, que es el oráculo: lo que el // usuario veía ANTES de cablear nada. with_harness(|h| { let mut cache = RepaintCache::new(); for marca in 0..4u32 { let esperado = h.render_con(arbol_pelado(marca), W, H, default_bg(), |s, m, c, ts| { paint(s, m, c, ts, None, None); }); let obtenido = cacheado(h, marca, &mut cache); assert_eq!( distintos(&esperado, &obtenido), 0, "con el contador en {marca}, cablear la sugerencia cambió lo pintado" ); } assert_eq!( (cache.hits, cache.misses), (3, 1), "el detector prometió cache-hits y el caché no acertó" ); }); }