tawasuyu

tawasuyu/llimphi

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

Rust 660Markdown 302TOML 157RON 312382 Sutichaykuna

⎇ main llimphi-compositor/src/render/pruebas_bloques.rs Rust · 547 siq'ikuna · 19.8 KB Ñawpa willay Pi imata llamkarqan ch'uya

chore: refresco desde el monorepo — 128 miembros, los 76 widgets y los 11 módulos b3f12e136 · Sergio ·

1use super::*;
2
3#[cfg(test)]
4mod bloque25_harness {
5    //! Arnés del Bloque 25 (memo, enfoque A): genera árboles con nodos `memo`,
6    //! los muta, y verifica que el camino memoizado (`take_memo_builders` + mount
7    //! + compute + `splice_memos`) da **exactamente el mismo `Mounted`** (rects,
8    //! nodo a nodo en pre-orden) que el camino fresco (memo expandido inline).
9    //! Como los memos NO tocados no re-buildean, también ejercita el reuso.
10    use super::*;
11    use crate::{mount, View};
12    use llimphi_layout::taffy::prelude::*;
13    use llimphi_layout::LayoutTree;
14    use core::hash::{Hash, Hasher};
15
16    struct Rng(u64);
17    impl Rng {
18        fn next(&mut self) -> u64 {
19            let mut x = self.0;
20            x ^= x << 13;
21            x ^= x >> 7;
22            x ^= x << 17;
23            self.0 = x;
24            x
25        }
26        fn range(&mut self, n: usize) -> usize {
27            (self.next() % (n as u64).max(1)) as usize
28        }
29    }
30
31    #[derive(Clone)]
32    enum Spec {
33        Leaf { w: f32, h: f32, text: Option<String> },
34        Row(Vec<Spec>),
35        Col(Vec<Spec>),
36        /// Nodo memo de tamaño definido `w×h` envolviendo `content` (sin memos
37        /// anidados en v1). `deps` se deriva de `content` en `build`.
38        Memo { key: u64, w: f32, h: f32, content: Box<Spec> },
39    }
40
41    fn spec_hash(s: &Spec) -> u64 {
42        let mut h = hasher_memo();
43        fn go(s: &Spec, h: &mut impl Hasher) {
44            match s {
45                Spec::Leaf { w, h: hh, text } => {
46                    0u8.hash(h);
47                    w.to_bits().hash(h);
48                    hh.to_bits().hash(h);
49                    text.hash(h);
50                }
51                Spec::Row(k) => {
52                    1u8.hash(h);
53                    for c in k {
54                        go(c, h);
55                    }
56                }
57                Spec::Col(k) => {
58                    2u8.hash(h);
59                    for c in k {
60                        go(c, h);
61                    }
62                }
63                Spec::Memo { key, w, h: hh, content } => {
64                    3u8.hash(h);
65                    key.hash(h);
66                    w.to_bits().hash(h);
67                    hh.to_bits().hash(h);
68                    go(content, h);
69                }
70            }
71        }
72        go(s, &mut h);
73        h.finish()
74    }
75
76    fn gen(rng: &mut Rng, depth: usize, allow_memo: bool, next_key: &mut u64) -> Spec {
77        if depth == 0 || rng.range(100) < 35 {
78            let text = if rng.range(2) == 0 {
79                Some(format!("n{}", rng.range(1000)))
80            } else {
81                None
82            };
83            Spec::Leaf {
84                w: 20.0 + rng.range(60) as f32,
85                h: 12.0 + rng.range(30) as f32,
86                text,
87            }
88        } else if allow_memo && rng.range(100) < 35 {
89            let content = gen(rng, depth - 1, false, next_key);
90            let key = *next_key;
91            *next_key += 1;
92            Spec::Memo {
93                key,
94                w: 120.0 + rng.range(120) as f32,
95                h: 120.0 + rng.range(120) as f32,
96                content: Box::new(content),
97            }
98        } else {
99            let n = 1 + rng.range(3);
100            let kids = (0..n).map(|_| gen(rng, depth - 1, allow_memo, next_key)).collect();
101            if rng.range(2) == 0 {
102                Spec::Row(kids)
103            } else {
104                Spec::Col(kids)
105            }
106        }
107    }
108
109    /// Muta manteniendo la forma (cambia valores; a veces el contenido de un memo
110    /// → su `deps` cambia → rebuild; a veces no → reuso).
111    fn mutate(s: &mut Spec, rng: &mut Rng) {
112        match s {
113            Spec::Leaf { w, h, text } => match rng.range(4) {
114                0 => *w = 20.0 + rng.range(60) as f32,
115                1 => *h = 12.0 + rng.range(30) as f32,
116                2 => *text = Some(format!("m{}", rng.range(1000))),
117                _ => {}
118            },
119            Spec::Row(k) | Spec::Col(k) => {
120                for c in k {
121                    mutate(c, rng);
122                }
123            }
124            Spec::Memo { content, .. } => {
125                // 50% muta el contenido (→ rebuild), 50% lo deja (→ reuso).
126                if rng.range(2) == 0 {
127                    mutate(content, rng);
128                }
129            }
130        }
131    }
132
133    fn build(s: &Spec, memoize: bool) -> View<()> {
134        match s {
135            Spec::Leaf { w, h, text } => {
136                let st = Style {
137                    size: Size {
138                        width: length(*w),
139                        height: if text.is_some() { auto() } else { length(*h) },
140                    },
141                    flex_shrink: 0.0,
142                    ..Default::default()
143                };
144                let v = View::<()>::new(st);
145                match text {
146                    Some(t) => v.text(t.clone(), 14.0, Color::WHITE),
147                    None => v,
148                }
149            }
150            Spec::Row(k) | Spec::Col(k) => {
151                let row = matches!(s, Spec::Row(_));
152                View::<()>::new(Style {
153                    flex_direction: if row {
154                        FlexDirection::Row
155                    } else {
156                        FlexDirection::Column
157                    },
158                    ..Default::default()
159                })
160                .children(k.iter().map(|c| build(c, memoize)).collect())
161            }
162            Spec::Memo { key, w, h, content } => {
163                let st = Style {
164                    size: Size { width: length(*w), height: length(*h) },
165                    flex_shrink: 0.0,
166                    ..Default::default()
167                };
168                if memoize {
169                    let c = (**content).clone();
170                    let deps = spec_hash(content);
171                    View::<()>::new(st).memo(*key, deps, move || {
172                        MEMO_BUILDS.fetch_add(1, core::sync::atomic::Ordering::Relaxed);
173                        build(&c, true)
174                    })
175                } else {
176                    View::<()>::new(st).children(vec![build(content, false)])
177                }
178            }
179        }
180    }
181
182    fn fake_measure(
183        tm: &TextMeasure,
184        known: Size<Option<f32>>,
185        avail: Size<AvailableSpace>,
186    ) -> Size<f32> {
187        let chars = tm.content.chars().count().max(1) as f32;
188        let gw = tm.size_px * 0.5;
189        let ideal = chars * gw;
190        let maxw = match known.width {
191            Some(w) => w,
192            None => match avail.width {
193                AvailableSpace::Definite(w) => w,
194                AvailableSpace::MaxContent => f32::INFINITY,
195                AvailableSpace::MinContent => gw,
196            },
197        };
198        let w = ideal.min(maxw).max(gw);
199        let lines = if w > 0.0 { (ideal / w).ceil().max(1.0) } else { 1.0 };
200        Size {
201            width: w,
202            height: lines * tm.size_px * tm.line_height.max(1.0),
203        }
204    }
205
206    static MEMO_BUILDS: core::sync::atomic::AtomicU64 = core::sync::atomic::AtomicU64::new(0);
207
208    #[test]
209    fn memo_igual_a_fresco_fuzz() {
210        use core::sync::atomic::Ordering;
211        MEMO_BUILDS.store(0, Ordering::Relaxed);
212        let mut opportunities = 0u64;
213        for seed in 0..40u64 {
214            opportunities += run_seed(0xD1B5_4A32_D192_ED03 ^ seed.wrapping_mul(0x100_0193));
215        }
216        let builds = MEMO_BUILDS.load(Ordering::Relaxed);
217        // Correctitud ya asertada dentro de run_seed. Aquí probamos el REUSO: el
218        // builder se llamó MENOS veces que oportunidades (markers·frames) → hubo
219        // memos reusados sin reconstruir; y >0 → los cambios sí rebuildean.
220        assert!(builds > 0, "algún memo debe rebuildear (builds={builds})");
221        assert!(
222            builds < opportunities,
223            "el reuso debe ocurrir (builds={builds} de {opportunities} oportunidades)"
224        );
225        println!("[bloque25] seeds=40 builds={builds}/{opportunities} oportunidades (resto reusado); rects == fresco (±1px)");
226    }
227
228    /// Devuelve la cantidad de oportunidades de memo (Σ markers por frame).
229    fn run_seed(seed: u64) -> u64 {
230        let mut rng = Rng(seed | 1);
231        let mut next_key = 1u64;
232        let mut spec = gen(&mut rng, 5, true, &mut next_key);
233        let mut reg: MemoRegistry<()> = MemoRegistry::new();
234        let vp = (900.0, 700.0);
235        let mut opportunities = 0u64;
236
237        for frame in 0..60 {
238            if frame > 0 {
239                mutate(&mut spec, &mut rng);
240            }
241            // Fresco: memos expandidos inline.
242            let mut ft = LayoutTree::new();
243            let mf = mount(&mut ft, build(&spec, false));
244            let cf = {
245                let tm = &mf.text_measures;
246                ft.compute_with_measure(mf.root, vp, |nid, k, a| match tm.get(&nid) {
247                    Some(t) => fake_measure(t, k, a),
248                    None => Size { width: 0.0, height: 0.0 },
249                })
250                .unwrap()
251            };
252            // Memoizado.
253            let mut vm = build(&spec, true);
254            let mut pending = Vec::new();
255            take_memo_builders(&mut vm, &mut pending);
256            opportunities += pending.len() as u64;
257            let mut mt = LayoutTree::new();
258            let mut mm = mount(&mut mt, vm);
259            let mut cm = {
260                let tm = &mm.text_measures;
261                mt.compute_with_measure(mm.root, vp, |nid, k, a| match tm.get(&nid) {
262                    Some(t) => fake_measure(t, k, a),
263                    None => Size { width: 0.0, height: 0.0 },
264                })
265                .unwrap()
266            };
267            splice_memos(&mut mm, &mut cm, &pending, &mut reg, fake_measure);
268
269            // Tolerancia 1px: taffy redondea el layout a píxel de forma
270            // DEPENDIENTE de la posición absoluta (alinea bordes a la grilla). El
271            // subárbol aislado se computa en base-0 y se offsetea, así que su
272            // redondeo puede diferir del fresco (computado en la posición real)
273            // por ≤1px en tamaños. Es acotado, invisible, y CONSISTENTE en una
274            // app real (siempre se usa el camino memo, nunca se alterna con el
275            // fresco → sin jitter). La ESTRUCTURA y las posiciones coinciden.
276            let tol = 1.01_f32;
277            let mut mismatch = mf.nodes.len() != mm.nodes.len();
278            if !mismatch {
279                for i in 0..mf.nodes.len() {
280                    let a = cf.get(mf.nodes[i].id).unwrap();
281                    let b = cm.get(mm.nodes[i].id).unwrap();
282                    if (a.x - b.x).abs() > tol
283                        || (a.y - b.y).abs() > tol
284                        || (a.w - b.w).abs() > tol
285                        || (a.h - b.h).abs() > tol
286                    {
287                        mismatch = true;
288                        break;
289                    }
290                }
291            }
292            if mismatch && std::env::var("MEMO_DEBUG").is_ok() {
293                eprintln!("### seed={seed:#x} frame={frame} ###");
294                eprintln!("--- FRESCO ({} nodos) ---", mf.nodes.len());
295                for (i, n) in mf.nodes.iter().enumerate() {
296                    eprintln!("  {i}: memo_ref={:?} end={} rect={:?}", n.memo_ref, n.subtree_end, cf.get(n.id));
297                }
298                eprintln!("--- MEMO ({} nodos) ---", mm.nodes.len());
299                for (i, n) in mm.nodes.iter().enumerate() {
300                    eprintln!("  {i}: memo_ref={:?} end={} rect={:?}", n.memo_ref, n.subtree_end, cm.get(n.id));
301                }
302                panic!("dump");
303            }
304            assert!(!mismatch, "seed {seed:#x} frame {frame}: memo != fresco");
305        }
306        opportunities
307    }
308}
309
310#[cfg(test)]
311mod bloque24_harness {
312    //! Arnés de equivalencia del Bloque 24: genera árboles al azar, los muta
313    //! (estilo, texto, estructura) y verifica que `mount_incremental` (retenido)
314    //! da **exactamente los mismos rects** que `mount` fresco, frame a frame.
315    //! Es la red de seguridad ANTES de cablear el layout retenido a la ruta
316    //! caliente. La medida es falsa pero DETERMINISTA y depende del texto, así
317    //! que si el reuso no marcara dirty un texto cambiado, los rects diferirían
318    //! y el test fallaría.
319    use super::*;
320    use crate::{mount, View};
321    use llimphi_layout::taffy::prelude::*;
322    use llimphi_layout::LayoutTree;
323
324    struct Rng(u64);
325    impl Rng {
326        fn next(&mut self) -> u64 {
327            let mut x = self.0;
328            x ^= x << 13;
329            x ^= x >> 7;
330            x ^= x << 17;
331            self.0 = x;
332            x
333        }
334        fn range(&mut self, n: usize) -> usize {
335            (self.next() % (n as u64).max(1)) as usize
336        }
337    }
338
339    #[derive(Clone)]
340    enum Spec {
341        Leaf { w: f32, h: f32, text: Option<String> },
342        Row(Vec<Spec>),
343        Col(Vec<Spec>),
344    }
345
346    fn gen_spec(rng: &mut Rng, depth: usize) -> Spec {
347        if depth == 0 || rng.range(100) < 40 {
348            let text = if rng.range(2) == 0 {
349                Some(format!("n{}", rng.range(1000)))
350            } else {
351                None
352            };
353            Spec::Leaf {
354                w: 20.0 + rng.range(80) as f32,
355                h: 12.0 + rng.range(40) as f32,
356                text,
357            }
358        } else {
359            let n = 1 + rng.range(4);
360            let kids = (0..n).map(|_| gen_spec(rng, depth - 1)).collect();
361            if rng.range(2) == 0 {
362                Spec::Row(kids)
363            } else {
364                Spec::Col(kids)
365            }
366        }
367    }
368
369    /// Muta manteniendo la FORMA (child-counts) → ejercita el camino de reuso.
370    fn mutate_style(s: &mut Spec, rng: &mut Rng) {
371        match s {
372            Spec::Leaf { w, h, text } => match rng.range(4) {
373                0 => *w = 20.0 + rng.range(80) as f32,
374                1 => *h = 12.0 + rng.range(40) as f32,
375                2 => *text = Some(format!("m{}", rng.range(1000))),
376                _ => {}
377            },
378            Spec::Row(k) | Spec::Col(k) => {
379                for c in k {
380                    mutate_style(c, rng);
381                }
382            }
383        }
384    }
385
386    /// Cambia la FORMA (agrega/quita hijos) → fuerza el camino fresco.
387    fn mutate_structure(s: &mut Spec, rng: &mut Rng) {
388        match s {
389            Spec::Leaf { .. } => *s = gen_spec(rng, 3),
390            Spec::Row(k) | Spec::Col(k) => {
391                if k.len() > 1 && rng.range(2) == 0 {
392                    let i = rng.range(k.len());
393                    k.remove(i);
394                } else {
395                    let i = rng.range(k.len() + 1);
396                    k.insert(i, gen_spec(rng, 2));
397                }
398            }
399        }
400    }
401
402    fn build(s: &Spec) -> View<()> {
403        match s {
404            Spec::Leaf { w, h, text } => {
405                let st = Style {
406                    size: Size {
407                        width: length(*w),
408                        height: if text.is_some() { auto() } else { length(*h) },
409                    },
410                    flex_shrink: 0.0,
411                    ..Default::default()
412                };
413                let v = View::<()>::new(st);
414                match text {
415                    Some(t) => v.text(t.clone(), 14.0, Color::WHITE),
416                    None => v,
417                }
418            }
419            Spec::Row(k) | Spec::Col(k) => {
420                let row = matches!(s, Spec::Row(_));
421                View::<()>::new(Style {
422                    flex_direction: if row {
423                        FlexDirection::Row
424                    } else {
425                        FlexDirection::Column
426                    },
427                    ..Default::default()
428                })
429                .children(k.iter().map(build).collect())
430            }
431        }
432    }
433
434    /// Medida FALSA pero determinista: ancho ∝ nº de chars, alto por wrapping.
435    /// Depende del contenido → un cambio de texto altera la medida.
436    fn fake_measure(
437        tmap: &hashbrown::HashMap<NodeId, TextMeasure>,
438        nid: NodeId,
439        known: Size<Option<f32>>,
440        avail: Size<AvailableSpace>,
441    ) -> Size<f32> {
442        let Some(tm) = tmap.get(&nid) else {
443            return Size { width: 0.0, height: 0.0 };
444        };
445        let chars = tm.content.chars().count().max(1) as f32;
446        let gw = tm.size_px * 0.5;
447        let ideal = chars * gw;
448        let maxw = match known.width {
449            Some(w) => w,
450            None => match avail.width {
451                AvailableSpace::Definite(w) => w,
452                AvailableSpace::MaxContent => f32::INFINITY,
453                AvailableSpace::MinContent => gw,
454            },
455        };
456        let w = ideal.min(maxw).max(gw);
457        let lines = if w > 0.0 { (ideal / w).ceil().max(1.0) } else { 1.0 };
458        Size {
459            width: w,
460            height: lines * tm.size_px * tm.line_height.max(1.0),
461        }
462    }
463
464    #[test]
465    fn retenido_igual_a_fresco_fuzz() {
466        // Muchas semillas → árboles y secuencias de mutación variados.
467        let mut tot_reused = 0u32;
468        let mut tot_fresh = 0u32;
469        for seed in 0..40u64 {
470            let (r, f) = run_seed(0x9E37_79B9_7F4A_7C15 ^ seed.wrapping_mul(0x1000_0001B3));
471            tot_reused += r;
472            tot_fresh += f;
473        }
474        assert!(tot_reused > 30 * 40, "el reuso debe dominar (reused={tot_reused})");
475        assert!(tot_fresh > 5 * 40, "y el rebuild también (fresh={tot_fresh})");
476        println!("[bloque24] seeds=40 reused={tot_reused} fresh={tot_fresh} (todos los rects == fresco)");
477    }
478
479    fn run_seed(seed: u64) -> (u32, u32) {
480        let mut rng = Rng(seed | 1);
481        let mut spec = gen_spec(&mut rng, 4);
482        let mut ret_tree = LayoutTree::new();
483        let mut prev: Option<RetainedLayout> = None;
484        let mut reused = 0;
485        let mut fresh = 0;
486
487        for frame in 0..80 {
488            if frame > 0 {
489                if frame % 6 == 0 {
490                    mutate_structure(&mut spec, &mut rng);
491                } else {
492                    mutate_style(&mut spec, &mut rng);
493                }
494            }
495            // Viewport variable: prueba que el reuso recoge cambios de
496            // available-space (resize) aunque no marque dirty (taffy re-resuelve
497            // por cambio de espacio disponible).
498            let vp = match frame % 4 {
499                0 => (800.0, 600.0),
500                1 => (640.0, 600.0),
501                2 => (900.0, 500.0),
502                _ => (720.0, 680.0),
503            };
504
505            // Camino fresco de referencia.
506            let mut fresh_tree = LayoutTree::new();
507            let mf = mount(&mut fresh_tree, build(&spec));
508            let cf = {
509                let tf = &mf.text_measures;
510                fresh_tree
511                    .compute_with_measure(mf.root, vp, |nid, k, a| fake_measure(tf, nid, k, a))
512                    .unwrap()
513            };
514
515            // Camino retenido.
516            let (mr, retained, did_reuse) =
517                mount_incremental(&mut ret_tree, build(&spec), prev.as_ref());
518            let cr = {
519                let tr = &mr.text_measures;
520                ret_tree
521                    .compute_with_measure(mr.root, vp, |nid, k, a| fake_measure(tr, nid, k, a))
522                    .unwrap()
523            };
524            if did_reuse {
525                reused += 1;
526            } else {
527                fresh += 1;
528            }
529
530            assert_eq!(mf.nodes.len(), mr.nodes.len(), "frame {frame}: nº de nodos");
531            for i in 0..mf.nodes.len() {
532                let a = cf.get(mf.nodes[i].id).expect("rect fresco");
533                let b = cr.get(mr.nodes[i].id).expect("rect retenido");
534                assert!(
535                    (a.x - b.x).abs() < 1e-3
536                        && (a.y - b.y).abs() < 1e-3
537                        && (a.w - b.w).abs() < 1e-3
538                        && (a.h - b.h).abs() < 1e-3,
539                    "frame {frame} nodo {i} reuse={did_reuse}: fresco {a:?} != retenido {b:?}"
540                );
541            }
542            prev = Some(retained);
543        }
544        (reused, fresh)
545    }
546}
547