use super::*; #[cfg(test)] mod bloque25_harness { //! Arnés del Bloque 25 (memo, enfoque A): genera árboles con nodos `memo`, //! los muta, y verifica que el camino memoizado (`take_memo_builders` + mount //! + compute + `splice_memos`) da **exactamente el mismo `Mounted`** (rects, //! nodo a nodo en pre-orden) que el camino fresco (memo expandido inline). //! Como los memos NO tocados no re-buildean, también ejercita el reuso. use super::*; use crate::{mount, View}; use llimphi_layout::taffy::prelude::*; use llimphi_layout::LayoutTree; use core::hash::{Hash, Hasher}; struct Rng(u64); impl Rng { fn next(&mut self) -> u64 { let mut x = self.0; x ^= x << 13; x ^= x >> 7; x ^= x << 17; self.0 = x; x } fn range(&mut self, n: usize) -> usize { (self.next() % (n as u64).max(1)) as usize } } #[derive(Clone)] enum Spec { Leaf { w: f32, h: f32, text: Option }, Row(Vec), Col(Vec), /// Nodo memo de tamaño definido `w×h` envolviendo `content` (sin memos /// anidados en v1). `deps` se deriva de `content` en `build`. Memo { key: u64, w: f32, h: f32, content: Box }, } fn spec_hash(s: &Spec) -> u64 { let mut h = hasher_memo(); fn go(s: &Spec, h: &mut impl Hasher) { match s { Spec::Leaf { w, h: hh, text } => { 0u8.hash(h); w.to_bits().hash(h); hh.to_bits().hash(h); text.hash(h); } Spec::Row(k) => { 1u8.hash(h); for c in k { go(c, h); } } Spec::Col(k) => { 2u8.hash(h); for c in k { go(c, h); } } Spec::Memo { key, w, h: hh, content } => { 3u8.hash(h); key.hash(h); w.to_bits().hash(h); hh.to_bits().hash(h); go(content, h); } } } go(s, &mut h); h.finish() } fn gen(rng: &mut Rng, depth: usize, allow_memo: bool, next_key: &mut u64) -> Spec { if depth == 0 || rng.range(100) < 35 { let text = if rng.range(2) == 0 { Some(format!("n{}", rng.range(1000))) } else { None }; Spec::Leaf { w: 20.0 + rng.range(60) as f32, h: 12.0 + rng.range(30) as f32, text, } } else if allow_memo && rng.range(100) < 35 { let content = gen(rng, depth - 1, false, next_key); let key = *next_key; *next_key += 1; Spec::Memo { key, w: 120.0 + rng.range(120) as f32, h: 120.0 + rng.range(120) as f32, content: Box::new(content), } } else { let n = 1 + rng.range(3); let kids = (0..n).map(|_| gen(rng, depth - 1, allow_memo, next_key)).collect(); if rng.range(2) == 0 { Spec::Row(kids) } else { Spec::Col(kids) } } } /// Muta manteniendo la forma (cambia valores; a veces el contenido de un memo /// → su `deps` cambia → rebuild; a veces no → reuso). fn mutate(s: &mut Spec, rng: &mut Rng) { match s { Spec::Leaf { w, h, text } => match rng.range(4) { 0 => *w = 20.0 + rng.range(60) as f32, 1 => *h = 12.0 + rng.range(30) as f32, 2 => *text = Some(format!("m{}", rng.range(1000))), _ => {} }, Spec::Row(k) | Spec::Col(k) => { for c in k { mutate(c, rng); } } Spec::Memo { content, .. } => { // 50% muta el contenido (→ rebuild), 50% lo deja (→ reuso). if rng.range(2) == 0 { mutate(content, rng); } } } } fn build(s: &Spec, memoize: bool) -> View<()> { match s { Spec::Leaf { w, h, text } => { let st = Style { size: Size { width: length(*w), height: if text.is_some() { auto() } else { length(*h) }, }, flex_shrink: 0.0, ..Default::default() }; let v = View::<()>::new(st); match text { Some(t) => v.text(t.clone(), 14.0, Color::WHITE), None => v, } } Spec::Row(k) | Spec::Col(k) => { let row = matches!(s, Spec::Row(_)); View::<()>::new(Style { flex_direction: if row { FlexDirection::Row } else { FlexDirection::Column }, ..Default::default() }) .children(k.iter().map(|c| build(c, memoize)).collect()) } Spec::Memo { key, w, h, content } => { let st = Style { size: Size { width: length(*w), height: length(*h) }, flex_shrink: 0.0, ..Default::default() }; if memoize { let c = (**content).clone(); let deps = spec_hash(content); View::<()>::new(st).memo(*key, deps, move || { MEMO_BUILDS.fetch_add(1, core::sync::atomic::Ordering::Relaxed); build(&c, true) }) } else { View::<()>::new(st).children(vec![build(content, false)]) } } } } fn fake_measure( tm: &TextMeasure, known: Size>, avail: Size, ) -> Size { let chars = tm.content.chars().count().max(1) as f32; let gw = tm.size_px * 0.5; let ideal = chars * gw; let maxw = match known.width { Some(w) => w, None => match avail.width { AvailableSpace::Definite(w) => w, AvailableSpace::MaxContent => f32::INFINITY, AvailableSpace::MinContent => gw, }, }; let w = ideal.min(maxw).max(gw); let lines = if w > 0.0 { (ideal / w).ceil().max(1.0) } else { 1.0 }; Size { width: w, height: lines * tm.size_px * tm.line_height.max(1.0), } } static MEMO_BUILDS: core::sync::atomic::AtomicU64 = core::sync::atomic::AtomicU64::new(0); #[test] fn memo_igual_a_fresco_fuzz() { use core::sync::atomic::Ordering; MEMO_BUILDS.store(0, Ordering::Relaxed); let mut opportunities = 0u64; for seed in 0..40u64 { opportunities += run_seed(0xD1B5_4A32_D192_ED03 ^ seed.wrapping_mul(0x100_0193)); } let builds = MEMO_BUILDS.load(Ordering::Relaxed); // Correctitud ya asertada dentro de run_seed. Aquí probamos el REUSO: el // builder se llamó MENOS veces que oportunidades (markers·frames) → hubo // memos reusados sin reconstruir; y >0 → los cambios sí rebuildean. assert!(builds > 0, "algún memo debe rebuildear (builds={builds})"); assert!( builds < opportunities, "el reuso debe ocurrir (builds={builds} de {opportunities} oportunidades)" ); println!("[bloque25] seeds=40 builds={builds}/{opportunities} oportunidades (resto reusado); rects == fresco (±1px)"); } /// Devuelve la cantidad de oportunidades de memo (Σ markers por frame). fn run_seed(seed: u64) -> u64 { let mut rng = Rng(seed | 1); let mut next_key = 1u64; let mut spec = gen(&mut rng, 5, true, &mut next_key); let mut reg: MemoRegistry<()> = MemoRegistry::new(); let vp = (900.0, 700.0); let mut opportunities = 0u64; for frame in 0..60 { if frame > 0 { mutate(&mut spec, &mut rng); } // Fresco: memos expandidos inline. let mut ft = LayoutTree::new(); let mf = mount(&mut ft, build(&spec, false)); let cf = { let tm = &mf.text_measures; ft.compute_with_measure(mf.root, vp, |nid, k, a| match tm.get(&nid) { Some(t) => fake_measure(t, k, a), None => Size { width: 0.0, height: 0.0 }, }) .unwrap() }; // Memoizado. let mut vm = build(&spec, true); let mut pending = Vec::new(); take_memo_builders(&mut vm, &mut pending); opportunities += pending.len() as u64; let mut mt = LayoutTree::new(); let mut mm = mount(&mut mt, vm); let mut cm = { let tm = &mm.text_measures; mt.compute_with_measure(mm.root, vp, |nid, k, a| match tm.get(&nid) { Some(t) => fake_measure(t, k, a), None => Size { width: 0.0, height: 0.0 }, }) .unwrap() }; splice_memos(&mut mm, &mut cm, &pending, &mut reg, fake_measure); // Tolerancia 1px: taffy redondea el layout a píxel de forma // DEPENDIENTE de la posición absoluta (alinea bordes a la grilla). El // subárbol aislado se computa en base-0 y se offsetea, así que su // redondeo puede diferir del fresco (computado en la posición real) // por ≤1px en tamaños. Es acotado, invisible, y CONSISTENTE en una // app real (siempre se usa el camino memo, nunca se alterna con el // fresco → sin jitter). La ESTRUCTURA y las posiciones coinciden. let tol = 1.01_f32; let mut mismatch = mf.nodes.len() != mm.nodes.len(); if !mismatch { for i in 0..mf.nodes.len() { let a = cf.get(mf.nodes[i].id).unwrap(); let b = cm.get(mm.nodes[i].id).unwrap(); if (a.x - b.x).abs() > tol || (a.y - b.y).abs() > tol || (a.w - b.w).abs() > tol || (a.h - b.h).abs() > tol { mismatch = true; break; } } } if mismatch && std::env::var("MEMO_DEBUG").is_ok() { eprintln!("### seed={seed:#x} frame={frame} ###"); eprintln!("--- FRESCO ({} nodos) ---", mf.nodes.len()); for (i, n) in mf.nodes.iter().enumerate() { eprintln!(" {i}: memo_ref={:?} end={} rect={:?}", n.memo_ref, n.subtree_end, cf.get(n.id)); } eprintln!("--- MEMO ({} nodos) ---", mm.nodes.len()); for (i, n) in mm.nodes.iter().enumerate() { eprintln!(" {i}: memo_ref={:?} end={} rect={:?}", n.memo_ref, n.subtree_end, cm.get(n.id)); } panic!("dump"); } assert!(!mismatch, "seed {seed:#x} frame {frame}: memo != fresco"); } opportunities } } #[cfg(test)] mod bloque24_harness { //! Arnés de equivalencia del Bloque 24: genera árboles al azar, los muta //! (estilo, texto, estructura) y verifica que `mount_incremental` (retenido) //! da **exactamente los mismos rects** que `mount` fresco, frame a frame. //! Es la red de seguridad ANTES de cablear el layout retenido a la ruta //! caliente. La medida es falsa pero DETERMINISTA y depende del texto, así //! que si el reuso no marcara dirty un texto cambiado, los rects diferirían //! y el test fallaría. use super::*; use crate::{mount, View}; use llimphi_layout::taffy::prelude::*; use llimphi_layout::LayoutTree; struct Rng(u64); impl Rng { fn next(&mut self) -> u64 { let mut x = self.0; x ^= x << 13; x ^= x >> 7; x ^= x << 17; self.0 = x; x } fn range(&mut self, n: usize) -> usize { (self.next() % (n as u64).max(1)) as usize } } #[derive(Clone)] enum Spec { Leaf { w: f32, h: f32, text: Option }, Row(Vec), Col(Vec), } fn gen_spec(rng: &mut Rng, depth: usize) -> Spec { if depth == 0 || rng.range(100) < 40 { let text = if rng.range(2) == 0 { Some(format!("n{}", rng.range(1000))) } else { None }; Spec::Leaf { w: 20.0 + rng.range(80) as f32, h: 12.0 + rng.range(40) as f32, text, } } else { let n = 1 + rng.range(4); let kids = (0..n).map(|_| gen_spec(rng, depth - 1)).collect(); if rng.range(2) == 0 { Spec::Row(kids) } else { Spec::Col(kids) } } } /// Muta manteniendo la FORMA (child-counts) → ejercita el camino de reuso. fn mutate_style(s: &mut Spec, rng: &mut Rng) { match s { Spec::Leaf { w, h, text } => match rng.range(4) { 0 => *w = 20.0 + rng.range(80) as f32, 1 => *h = 12.0 + rng.range(40) as f32, 2 => *text = Some(format!("m{}", rng.range(1000))), _ => {} }, Spec::Row(k) | Spec::Col(k) => { for c in k { mutate_style(c, rng); } } } } /// Cambia la FORMA (agrega/quita hijos) → fuerza el camino fresco. fn mutate_structure(s: &mut Spec, rng: &mut Rng) { match s { Spec::Leaf { .. } => *s = gen_spec(rng, 3), Spec::Row(k) | Spec::Col(k) => { if k.len() > 1 && rng.range(2) == 0 { let i = rng.range(k.len()); k.remove(i); } else { let i = rng.range(k.len() + 1); k.insert(i, gen_spec(rng, 2)); } } } } fn build(s: &Spec) -> View<()> { match s { Spec::Leaf { w, h, text } => { let st = Style { size: Size { width: length(*w), height: if text.is_some() { auto() } else { length(*h) }, }, flex_shrink: 0.0, ..Default::default() }; let v = View::<()>::new(st); match text { Some(t) => v.text(t.clone(), 14.0, Color::WHITE), None => v, } } Spec::Row(k) | Spec::Col(k) => { let row = matches!(s, Spec::Row(_)); View::<()>::new(Style { flex_direction: if row { FlexDirection::Row } else { FlexDirection::Column }, ..Default::default() }) .children(k.iter().map(build).collect()) } } } /// Medida FALSA pero determinista: ancho ∝ nº de chars, alto por wrapping. /// Depende del contenido → un cambio de texto altera la medida. fn fake_measure( tmap: &hashbrown::HashMap, nid: NodeId, known: Size>, avail: Size, ) -> Size { let Some(tm) = tmap.get(&nid) else { return Size { width: 0.0, height: 0.0 }; }; let chars = tm.content.chars().count().max(1) as f32; let gw = tm.size_px * 0.5; let ideal = chars * gw; let maxw = match known.width { Some(w) => w, None => match avail.width { AvailableSpace::Definite(w) => w, AvailableSpace::MaxContent => f32::INFINITY, AvailableSpace::MinContent => gw, }, }; let w = ideal.min(maxw).max(gw); let lines = if w > 0.0 { (ideal / w).ceil().max(1.0) } else { 1.0 }; Size { width: w, height: lines * tm.size_px * tm.line_height.max(1.0), } } #[test] fn retenido_igual_a_fresco_fuzz() { // Muchas semillas → árboles y secuencias de mutación variados. let mut tot_reused = 0u32; let mut tot_fresh = 0u32; for seed in 0..40u64 { let (r, f) = run_seed(0x9E37_79B9_7F4A_7C15 ^ seed.wrapping_mul(0x1000_0001B3)); tot_reused += r; tot_fresh += f; } assert!(tot_reused > 30 * 40, "el reuso debe dominar (reused={tot_reused})"); assert!(tot_fresh > 5 * 40, "y el rebuild también (fresh={tot_fresh})"); println!("[bloque24] seeds=40 reused={tot_reused} fresh={tot_fresh} (todos los rects == fresco)"); } fn run_seed(seed: u64) -> (u32, u32) { let mut rng = Rng(seed | 1); let mut spec = gen_spec(&mut rng, 4); let mut ret_tree = LayoutTree::new(); let mut prev: Option = None; let mut reused = 0; let mut fresh = 0; for frame in 0..80 { if frame > 0 { if frame % 6 == 0 { mutate_structure(&mut spec, &mut rng); } else { mutate_style(&mut spec, &mut rng); } } // Viewport variable: prueba que el reuso recoge cambios de // available-space (resize) aunque no marque dirty (taffy re-resuelve // por cambio de espacio disponible). let vp = match frame % 4 { 0 => (800.0, 600.0), 1 => (640.0, 600.0), 2 => (900.0, 500.0), _ => (720.0, 680.0), }; // Camino fresco de referencia. let mut fresh_tree = LayoutTree::new(); let mf = mount(&mut fresh_tree, build(&spec)); let cf = { let tf = &mf.text_measures; fresh_tree .compute_with_measure(mf.root, vp, |nid, k, a| fake_measure(tf, nid, k, a)) .unwrap() }; // Camino retenido. let (mr, retained, did_reuse) = mount_incremental(&mut ret_tree, build(&spec), prev.as_ref()); let cr = { let tr = &mr.text_measures; ret_tree .compute_with_measure(mr.root, vp, |nid, k, a| fake_measure(tr, nid, k, a)) .unwrap() }; if did_reuse { reused += 1; } else { fresh += 1; } assert_eq!(mf.nodes.len(), mr.nodes.len(), "frame {frame}: nº de nodos"); for i in 0..mf.nodes.len() { let a = cf.get(mf.nodes[i].id).expect("rect fresco"); let b = cr.get(mr.nodes[i].id).expect("rect retenido"); assert!( (a.x - b.x).abs() < 1e-3 && (a.y - b.y).abs() < 1e-3 && (a.w - b.w).abs() < 1e-3 && (a.h - b.h).abs() < 1e-3, "frame {frame} nodo {i} reuse={did_reuse}: fresco {a:?} != retenido {b:?}" ); } prev = Some(retained); } (reused, fresh) } }