tawasuyu

tawasuyu/llimphi

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

Rust 614Markdown 274TOML 149RON 310606 Sutichaykuna

⎇ main llimphi-compositor/src/render/tests.rs Rust · 1211 siq'ikuna · 52.7 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 repaint_boundary_tests {
5    use super::*;
6    use crate::{mount, View};
7    use llimphi_layout::taffy::prelude::*;
8    use llimphi_layout::LayoutTree;
9
10    fn mount_compute(v: View<()>, vp: (f32, f32)) -> (Mounted<()>, ComputedLayout) {
11        let mut lt = LayoutTree::new();
12        let m = mount(&mut lt, v);
13        let c = lt.compute(m.root, vp).expect("layout");
14        (m, c)
15    }
16
17    /// Un boundary top-level se recolecta; uno bajo un `clip` no (no está limpio).
18    #[test]
19    fn recolecta_solo_boundaries_limpios() {
20        let limpio = View::<()>::new(Style::default())
21            .repaint_boundary(1)
22            .children(vec![View::new(Style::default()).fill(Color::WHITE)]);
23        let (m, _) = mount_compute(
24            View::<()>::new(Style::default()).children(vec![limpio]),
25            (200.0, 200.0),
26        );
27        assert_eq!(collect_clean_boundaries(&m).len(), 1);
28
29        // Ahora el mismo boundary DENTRO de un nodo con clip → no limpio.
30        let dentro_de_clip = View::<()>::new(Style::default()).clip(true).children(vec![
31            View::<()>::new(Style::default())
32                .repaint_boundary(2)
33                .children(vec![View::new(Style::default()).fill(Color::WHITE)]),
34        ]);
35        let (m2, _) = mount_compute(dentro_de_clip, (200.0, 200.0));
36        assert_eq!(collect_clean_boundaries(&m2).len(), 0);
37    }
38
39    /// El gate rechaza subárboles con painter / gradiente / hover adentro.
40    #[test]
41    fn gate_rechaza_no_cacheables() {
42        // painter → no cacheable.
43        let con_painter = View::<()>::new(Style::default())
44            .paint_with(|_s, _t, _r| {});
45        let (m, _) = mount_compute(
46            View::<()>::new(Style::default())
47                .repaint_boundary(1)
48                .children(vec![con_painter]),
49            (100.0, 100.0),
50        );
51        assert!(!boundary_cacheable(&m, 0, m.nodes.len(), None, None));
52
53        // Fill/texto puro → cacheable.
54        let puro = View::<()>::new(Style::default())
55            .repaint_boundary(1)
56            .fill(Color::WHITE)
57            .children(vec![View::new(Style::default()).fill(Color::BLACK)]);
58        let (m2, _) = mount_compute(puro, (100.0, 100.0));
59        assert!(boundary_cacheable(&m2, 0, m2.nodes.len(), None, None));
60        // …salvo que el hover caiga adentro.
61        assert!(!boundary_cacheable(&m2, 0, m2.nodes.len(), Some(1), None));
62    }
63
64    /// El hash es estable ante un rebuild idéntico, cambia con el fill, y NO
65    /// cambia bajo una traslación pura del boundary (rects relativos).
66    #[test]
67    fn hash_estable_sensible_y_translation_invariante() {
68        let build = |fill: Color, x: f32| {
69            View::<()>::new(Style {
70                inset: Rect { left: length(x), top: length(0.0), right: auto(), bottom: auto() },
71                position: Position::Absolute,
72                size: Size { width: length(50.0), height: length(50.0) },
73                ..Default::default()
74            })
75            .repaint_boundary(1)
76            .fill(fill)
77            .children(vec![View::new(Style {
78                size: Size { width: length(20.0), height: length(20.0) },
79                ..Default::default()
80            })
81            .fill(Color::BLACK)])
82        };
83        let vp = (300.0, 300.0);
84        let (m_a, c_a) = mount_compute(build(Color::WHITE, 0.0), vp);
85        let o_a = c_a.get(m_a.nodes[0].id).map(|r| (r.x, r.y)).unwrap();
86        let h_a = boundary_hash(&m_a, &c_a, 0, m_a.nodes.len(), o_a);
87
88        // Rebuild idéntico → mismo hash.
89        let (m_b, c_b) = mount_compute(build(Color::WHITE, 0.0), vp);
90        let o_b = c_b.get(m_b.nodes[0].id).map(|r| (r.x, r.y)).unwrap();
91        assert_eq!(h_a, boundary_hash(&m_b, &c_b, 0, m_b.nodes.len(), o_b));
92
93        // Cambió el fill → hash distinto.
94        let (m_c, c_c) = mount_compute(build(Color::from_rgba8(1, 2, 3, 255), 0.0), vp);
95        let o_c = c_c.get(m_c.nodes[0].id).map(|r| (r.x, r.y)).unwrap();
96        assert_ne!(h_a, boundary_hash(&m_c, &c_c, 0, m_c.nodes.len(), o_c));
97
98        // Trasladado 100px en x → MISMO hash (rects relativos al origen).
99        let (m_d, c_d) = mount_compute(build(Color::WHITE, 100.0), vp);
100        let o_d = c_d.get(m_d.nodes[0].id).map(|r| (r.x, r.y)).unwrap();
101        assert_eq!(h_a, boundary_hash(&m_d, &c_d, 0, m_d.nodes.len(), o_d));
102    }
103
104    /// End-to-end (CPU, sin GPU): pintar dos veces el mismo árbol con boundary
105    /// da 1 miss + 1 hit; cambiar el fill fuerza otro miss.
106    #[test]
107    fn paint_cached_hit_y_miss() {
108        let mut ts = llimphi_text::Typesetter::new();
109        let mut cache = RepaintCache::new();
110        let build = |fill: Color| {
111            View::<()>::new(Style {
112                size: Size { width: percent(1.0), height: percent(1.0) },
113                ..Default::default()
114            })
115            .children(vec![View::<()>::new(Style {
116                size: Size { width: length(80.0), height: length(80.0) },
117                ..Default::default()
118            })
119            .repaint_boundary(7)
120            .fill(fill)
121            .children(vec![View::new(Style {
122                size: Size { width: length(30.0), height: length(30.0) },
123                ..Default::default()
124            })
125            .fill(Color::BLACK)])])
126        };
127        let vp = (200.0, 200.0);
128
129        let (m1, c1) = mount_compute(build(Color::WHITE), vp);
130        let mut s1 = crate::Lienzo::new();
131        paint_cached(&mut s1, &m1, &c1, &mut ts, None, None, &mut cache);
132        assert_eq!((cache.hits, cache.misses), (0, 1), "primer frame = miss");
133        assert_eq!(cache.len(), 1);
134
135        // Mismo contenido → hit.
136        let (m2, c2) = mount_compute(build(Color::WHITE), vp);
137        let mut s2 = crate::Lienzo::new();
138        paint_cached(&mut s2, &m2, &c2, &mut ts, None, None, &mut cache);
139        assert_eq!((cache.hits, cache.misses), (1, 1), "segundo frame igual = hit");
140
141        // Cambió el fill → miss de nuevo.
142        let (m3, c3) = mount_compute(build(Color::from_rgba8(9, 9, 9, 255)), vp);
143        let mut s3 = crate::Lienzo::new();
144        paint_cached(&mut s3, &m3, &c3, &mut ts, None, None, &mut cache);
145        assert_eq!((cache.hits, cache.misses), (1, 2), "cambio de fill = miss");
146
147        // Un frame SIN boundary limpia el caché por GC.
148        let (m4, c4) = mount_compute(
149            View::<()>::new(Style::default()).fill(Color::WHITE),
150            vp,
151        );
152        let mut s4 = crate::Lienzo::new();
153        paint_cached(&mut s4, &m4, &c4, &mut ts, None, None, &mut cache);
154        assert_eq!(cache.len(), 0, "boundary ausente → GC");
155    }
156}
157
158#[cfg(test)]
159mod tests {
160    use crate::{hit_test_click, mount, View};
161    use llimphi_layout::taffy::prelude::*;
162    use llimphi_layout::{LayoutTree, Style};
163    use llimphi_text::peniko::kurbo::Affine;
164
165    #[test]
166    fn sticky_top_se_pega_al_scrollport_al_pasar_el_umbral() {
167        use super::sticky_offset;
168        use llimphi_layout::Rect;
169        // Scrollport = el área de página (arranca en y=50, alto 400).
170        let sp = (0.0, 50.0, 300.0, 400.0);
171        let top = [Some(0.0), None, None, None]; // top:0 relativo al scrollport
172        // En flujo, por debajo del umbral (y=200 > 50): sin enganche.
173        let en_flujo = Rect { x: 0.0, y: 200.0, w: 300.0, h: 40.0 };
174        assert_eq!(sticky_offset(top, en_flujo, sp), (0.0, 0.0));
175        // Justo en el umbral (y == scrollport.y): sin enganche todavía.
176        let en_umbral = Rect { x: 0.0, y: 50.0, w: 300.0, h: 40.0 };
177        assert_eq!(sticky_offset(top, en_umbral, sp), (0.0, 0.0));
178        // Scrolleado por encima del umbral (y=10 < 50): se pega, dy = 50-10 = 40.
179        let scrolleado = Rect { x: 0.0, y: 10.0, w: 300.0, h: 40.0 };
180        let (dx, dy) = sticky_offset(top, scrolleado, sp);
181        assert_eq!(dx, 0.0);
182        assert!((dy - 40.0).abs() < 1e-4, "dy debe pegarlo a y=50 (dy=40), es {dy}");
183        // Aún más arriba (y=-90): dy = 50-(-90) = 140 → borde superior en 50.
184        let muy_arriba = Rect { x: 0.0, y: -90.0, w: 300.0, h: 40.0 };
185        assert!((sticky_offset(top, muy_arriba, sp).1 - 140.0).abs() < 1e-4);
186    }
187
188    #[test]
189    fn sticky_bottom_y_precedencia_top() {
190        use super::sticky_offset;
191        use llimphi_layout::Rect;
192        let sp = (0.0, 0.0, 300.0, 200.0); // scrollport 0..200 en y
193        // bottom:0 → pega el borde inferior a y=200 cuando lo pasa.
194        let bottom = [None, None, Some(0.0), None];
195        let paso = Rect { x: 0.0, y: 180.0, w: 300.0, h: 40.0 }; // bottom=220 > 200
196        let (_dx, dy) = sticky_offset(bottom, paso, sp);
197        assert!((dy - (-20.0)).abs() < 1e-4, "empuja hacia arriba 20 (bottom a 200), es {dy}");
198        // top gana sobre bottom cuando ambos disparan.
199        let ambos = [Some(0.0), None, Some(0.0), None];
200        let arriba = Rect { x: 0.0, y: -30.0, w: 300.0, h: 40.0 };
201        let (_x, dy2) = sticky_offset(ambos, arriba, sp);
202        assert!((dy2 - 30.0).abs() < 1e-4, "top gana: dy=30 (pega a y=0), es {dy2}");
203    }
204
205    #[test]
206    fn over_subtree_salta_la_base_y_pinta_en_la_pasada_over() {
207        // View::over(true): el subárbol NO pinta en la pasada base (paint) y
208        // SÍ pinta completo en la pasada over (paint_over_layers) — el
209        // contrato que permite UI encima de contenido gpu_paint_with.
210        use super::{has_over_painter, paint, paint_over_layers};
211        use core::sync::atomic::{AtomicUsize, Ordering};
212        use alloc::sync::Arc;
213
214        let base_hits = Arc::new(AtomicUsize::new(0));
215        let over_hits = Arc::new(AtomicUsize::new(0));
216        let (b, o) = (base_hits.clone(), over_hits.clone());
217        let sz = |w: f32, h: f32| Style {
218            size: Size { width: length(w), height: length(h) },
219            ..Default::default()
220        };
221        let tree: View<()> = View::new(sz(200.0, 200.0)).children(vec![
222            View::new(sz(100.0, 100.0))
223                .paint_with(move |_s, _t, _r| { b.fetch_add(1, Ordering::Relaxed); }),
224            View::new(sz(100.0, 100.0)).over(true).children(vec![View::new(sz(50.0, 50.0))
225                .paint_with(move |_s, _t, _r| { o.fetch_add(1, Ordering::Relaxed); })]),
226        ]);
227
228        let mut layout = LayoutTree::new();
229        let mounted = mount(&mut layout, tree);
230        assert!(has_over_painter(&mounted), "el flag over debe activar la pasada");
231        let computed = layout
232            .compute_with_measure(mounted.root, (400.0, 400.0), |_, _, _| {
233                llimphi_layout::taffy::prelude::Size::ZERO
234            })
235            .expect("layout");
236        let mut ts = llimphi_text::Typesetter::new();
237
238        // Pasada base: pinta el hermano normal, saltea el subárbol over.
239        let mut scene = crate::Lienzo::new();
240        paint(&mut scene, &mounted, &computed, &mut ts, None, None);
241        assert_eq!(base_hits.load(Ordering::Relaxed), 1, "el nodo normal pinta en base");
242        assert_eq!(over_hits.load(Ordering::Relaxed), 0, "el subárbol over NO pinta en base");
243
244        // Pasada over: pinta el subárbol completo (y sólo él).
245        let mut over_scene = crate::Lienzo::new();
246        let any = paint_over_layers(&mut over_scene, &mounted, &computed, &mut ts, None, None);
247        assert!(any, "hay un subárbol over");
248        assert_eq!(over_hits.load(Ordering::Relaxed), 1, "el subárbol over pinta en la pasada over");
249        assert_eq!(base_hits.load(Ordering::Relaxed), 1, "el normal no se repinta en over");
250    }
251
252    #[test]
253    fn dos_capas_over_hermano_y_anidada_ambas_pintan() {
254        // Estructura de media: sidebar `.over` como HERMANO, y la barra `.over`
255        // ANIDADA dentro de otro hermano (content → canvas → barra). El bug del
256        // "no se ve con el sidebar abierto" sería que `paint_over_layers`, al
257        // saltar a `subtree_end` de la primera capa, se comiera la segunda.
258        use super::{paint_over_layers};
259        use core::sync::atomic::{AtomicUsize, Ordering};
260        use alloc::sync::Arc;
261
262        let sidebar_hits = Arc::new(AtomicUsize::new(0));
263        let bar_hits = Arc::new(AtomicUsize::new(0));
264        let (s, b) = (sidebar_hits.clone(), bar_hits.clone());
265        let sz = |w: f32, h: f32| Style {
266            size: Size { width: length(w), height: length(h) },
267            ..Default::default()
268        };
269        // root[ sidebar(.over)[panel] , content[ canvas[ bar(.over) ] ] ]
270        let tree: View<()> = View::new(sz(400.0, 300.0)).children(vec![
271            View::new(sz(100.0, 300.0)).over(true).children(vec![
272                View::new(sz(80.0, 280.0)).paint_with(move |_s, _t, _r| {
273                    s.fetch_add(1, Ordering::Relaxed);
274                }),
275            ]),
276            View::new(sz(300.0, 300.0)).children(vec![
277                View::new(sz(300.0, 300.0)).children(vec![
278                    View::new(sz(280.0, 44.0)).over(true).paint_with(move |_s, _t, _r| {
279                        b.fetch_add(1, Ordering::Relaxed);
280                    }),
281                ]),
282            ]),
283        ]);
284
285        let mut layout = LayoutTree::new();
286        let mounted = mount(&mut layout, tree);
287        let computed = layout
288            .compute_with_measure(mounted.root, (400.0, 300.0), |_, _, _| {
289                llimphi_layout::taffy::prelude::Size::ZERO
290            })
291            .expect("layout");
292        let mut ts = llimphi_text::Typesetter::new();
293        let mut over_scene = crate::Lienzo::new();
294        paint_over_layers(&mut over_scene, &mounted, &computed, &mut ts, None, None);
295        assert_eq!(sidebar_hits.load(Ordering::Relaxed), 1, "la capa over del sidebar pinta");
296        assert_eq!(
297            bar_hits.load(Ordering::Relaxed),
298            1,
299            "la capa over ANIDADA (barra) también pinta — no se la come el subtree_end del sidebar"
300        );
301    }
302
303    #[test]
304    fn intrinsic_incluye_espacio_final_evita_wrap_fantasma() {
305        // Bug: un texto con ESPACIO FINAL medido a max-content (shrink-to-fit)
306        // recibía caja = ceil(width), pero parley EXCLUYE la ws de fin del
307        // `width()`. El paint re-shapea a `caja + slack` y ahí el avance del
308        // espacio SÍ cuenta → parte a una 2ª línea fantasma que la medida
309        // reservó como 1 → clip. El fix mide el intrínseco con `full_width`
310        // (incluye el espacio), así la caja es ≥ ancho+espacio y el paint no
311        // parte. Verificado end-to-end: medir a MaxContent y re-medir a
312        // Definite(caja + slack) debe dar el MISMO alto (una línea).
313        use super::{measure_text_node, TEXT_WRAP_ROUNDING_SLACK};
314        use crate::TextMeasure;
315        use llimphi_layout::taffy::{AvailableSpace, Size};
316
317        let tm = |content: &str| TextMeasure {
318            content: content.to_string(),
319            size_px: 16.0,
320            alignment: llimphi_text::Alignment::Start,
321            italic: false,
322            font_family: None,
323            line_height: 1.2,
324            weight: 400.0,
325            max_lines: None,
326            ellipsis: false,
327            underline: false,
328            strikethrough: false,
329            spans: None,
330            letter_spacing: 0.0,
331            word_spacing: 0.0,
332            no_wrap: false,
333            overflow_wrap: false,
334            font_features: String::new(),
335            font_variations: String::new(),
336            font_width: 100.0,
337        };
338        let mut ts = llimphi_text::Typesetter::new();
339        let max_content = Size {
340            width: AvailableSpace::MaxContent,
341            height: AvailableSpace::MaxContent,
342        };
343        let none = Size { width: None, height: None };
344
345        for content in ["archived mailing list ", "one two three four five ", "Houdini "] {
346            // 1) Medida intrínseca (max-content), como la usa taffy para la caja.
347            let intr = measure_text_node(&mut ts, &tm(content), none, max_content);
348            let box_w = intr.width; // ya viene ceil'd por el fix
349            // 2) Paint real: re-shape a Definite(caja + slack).
350            let painted = measure_text_node(
351                &mut ts,
352                &tm(content),
353                Size { width: Some(box_w + TEXT_WRAP_ROUNDING_SLACK), height: None },
354                Size {
355                    width: AvailableSpace::Definite(box_w + TEXT_WRAP_ROUNDING_SLACK),
356                    height: AvailableSpace::MaxContent,
357                },
358            );
359            assert!(
360                (painted.height - intr.height).abs() < 0.6,
361                "{content:?}: el paint no debe partir a 2 líneas — intr h={:.1} caja={:.1} paint h={:.1}",
362                intr.height, box_w, painted.height
363            );
364        }
365
366        // Control: SIN espacio final la caja no cambia (full_width == width).
367        let sin = measure_text_node(&mut ts, &tm("archived mailing list"), none, max_content);
368        let con = measure_text_node(&mut ts, &tm("archived mailing list "), none, max_content);
369        assert!(
370            con.width > sin.width + 1.0,
371            "el espacio final debe ensanchar la caja intrínseca: sin={:.1} con={:.1}",
372            sin.width, con.width
373        );
374    }
375
376    #[test]
377    fn transform_origin_fija_el_pivote() {
378        // El pivote de `transform-origin` queda FIJO bajo la transformación:
379        // top-left (frac 0,0) fija la esquina (0,0); el default (None) fija el
380        // centro (50,50). Rect 100×100 en el origen, rotación 90°.
381        use super::resolve_node_transform;
382        use crate::TransformPivot;
383        use llimphi_text::peniko::kurbo::Point;
384        let r = llimphi_layout::Rect { x: 0.0, y: 0.0, w: 100.0, h: 100.0 };
385        let rot = Affine::rotate(core::f64::consts::FRAC_PI_2);
386
387        let tl = TransformPivot { px: (0.0, 0.0), frac: (0.0, 0.0) };
388        let xf_tl = resolve_node_transform(Some(rot), None, Some(tl), r).unwrap();
389        let p = xf_tl * Point::new(0.0, 0.0);
390        assert!(p.x.abs() < 1e-6 && p.y.abs() < 1e-6, "pivote top-left fijo, fue {p:?}");
391
392        // Default (None) ⇒ centro: (50,50) queda fijo.
393        let xf_c = resolve_node_transform(Some(rot), None, None, r).unwrap();
394        let c = xf_c * Point::new(50.0, 50.0);
395        assert!(
396            (c.x - 50.0).abs() < 1e-6 && (c.y - 50.0).abs() < 1e-6,
397            "centro fijo con pivote default, fue {c:?}"
398        );
399        // Y el centro NO queda fijo con pivote top-left (distingue los dos casos).
400        let c2 = xf_tl * Point::new(50.0, 50.0);
401        assert!((c2.x - 50.0).abs() > 1.0 || (c2.y - 50.0).abs() > 1.0, "top-left mueve el centro");
402    }
403
404    #[test]
405    fn resolve_clip_radius_lados_y_porcentajes() {
406        use super::resolve_clip_radius;
407        // Caja 200×100, centro al (50%,50%) = (100,50) local.
408        let (w, h, cxl, cyl): (f64, f64, f64, f64) = (200.0, 100.0, 100.0, 50.0);
409        // side 0: px + pct_w·w + pct_h·h + pct_diag·diag.
410        let diag = crate::sqrt_f64(w * w + h * h) / core::f64::consts::SQRT_2;
411        let r = resolve_clip_radius(&[10.0, 0.0, 0.0, 50.0, 0.0], cxl, cyl, w, h, true);
412        assert!((r - (10.0 + 0.5 * diag)).abs() < 1e-6);
413        // closest-side circle (1): min(100,100,50,50) = 50.
414        assert_eq!(
415            resolve_clip_radius(&[0.0, 0.0, 0.0, 0.0, 1.0], cxl, cyl, w, h, true),
416            50.0
417        );
418        // farthest-side circle (2): max(...) = 100.
419        assert_eq!(
420            resolve_clip_radius(&[0.0, 0.0, 0.0, 0.0, 2.0], cxl, cyl, w, h, true),
421            100.0
422        );
423        // closest-side ellipse eje X (3, is_x): min(cxl, w-cxl) = 100.
424        assert_eq!(
425            resolve_clip_radius(&[0.0, 0.0, 0.0, 0.0, 3.0], cxl, cyl, w, h, true),
426            100.0
427        );
428        // closest-side ellipse eje Y (3, !is_x): min(cyl, h-cyl) = 50.
429        assert_eq!(
430            resolve_clip_radius(&[0.0, 0.0, 0.0, 0.0, 3.0], cxl, cyl, w, h, false),
431            50.0
432        );
433        // Centro descentrado (30, 20): closest circle = min(30,170,20,80)=20.
434        assert_eq!(
435            resolve_clip_radius(&[0.0, 0.0, 0.0, 0.0, 1.0], 30.0, 20.0, w, h, true),
436            20.0
437        );
438    }
439
440    /// El slack de redondeo del paint absorbe la pérdida de hasta ~1px que
441    /// taffy introduce al redondear los bordes de una caja encogida-a-contenido.
442    /// Regresión de "«Learn more» envuelve a 2 líneas": la MEDIDA dimensiona el
443    /// nodo a `nat` (una línea), taffy lo deja en `nat - d` (con `d ≤ 1.0` por
444    /// redondeo), y el paint —si envolviera exactamente a ese ancho— partiría la
445    /// última palabra a una 2ª línea que desborda. Test font-independiente:
446    /// creamos un déficit `d` dentro del rango de taffy y verificamos que sin
447    /// slack envuelve y CON slack no.
448    #[test]
449    fn slack_de_redondeo_evita_wrap_espurio_en_texto_encogido() {
450        use llimphi_text::{Alignment, Typesetter};
451        let mut ts = Typesetter::new();
452        let clamp = |ts: &mut Typesetter, mw: Option<f32>| {
453            ts.layout_clamped(
454                "Learn more",
455                16.0,
456                mw,
457                Alignment::Start,
458                1.2,
459                false,
460                None,
461                400.0,
462                None,
463                false,
464                false,
465                false,
466                0.0,
467                0.0,
468                false,
469                "",
470                "",
471                100.0,
472            )
473        };
474        // Ancho natural (sin límite): una sola línea.
475        let nat = llimphi_text::measurement(&clamp(&mut ts, None)).width;
476        assert_eq!(clamp(&mut ts, None).lines().count(), 1, "natural = 1 línea");
477        // Déficit dentro del rango de redondeo de taffy (0 < d ≤ 1.0). Casi 1.0
478        // = peor caso: los redondeos de ambos bordes restan hasta 1px juntos.
479        let deficit = 0.999_f32;
480        let rounded_down = nat - deficit;
481        assert!(
482            clamp(&mut ts, Some(rounded_down)).lines().count() >= 2,
483            "sin slack, el ancho redondeado hacia abajo ({rounded_down}) parte el texto"
484        );
485        assert_eq!(
486            clamp(&mut ts, Some(rounded_down + super::TEXT_WRAP_ROUNDING_SLACK))
487                .lines()
488                .count(),
489            1,
490            "con el slack {}, el texto vuelve a una línea",
491            super::TEXT_WRAP_ROUNDING_SLACK
492        );
493    }
494
495    /// Un hijo clickeable de 100×100 anclado arriba-izquierda. Devuelve
496    /// `(mounted, computed)` ya layouteados sobre un viewport 400×400.
497    fn fixture(
498        transform: Option<Affine>,
499    ) -> (crate::Mounted<()>, llimphi_layout::ComputedLayout) {
500        let mut child = View::<()>::new(Style {
501            size: Size {
502                width: length(100.0),
503                height: length(100.0),
504            },
505            ..Default::default()
506        })
507        .on_click(());
508        if let Some(xf) = transform {
509            child = child.transform(xf);
510        }
511        let root = View::<()>::new(Style {
512            align_items: Some(AlignItems::FLEX_START),
513            justify_content: Some(JustifyContent::FLEX_START),
514            ..Default::default()
515        })
516        .children(vec![child]);
517        let mut layout = LayoutTree::new();
518        let mounted = mount(&mut layout, root);
519        let computed = layout.compute(mounted.root, (400.0, 400.0)).expect("layout");
520        (mounted, computed)
521    }
522
523    #[test]
524    fn sin_transform_el_hit_cae_en_el_rect() {
525        let (m, c) = fixture(None);
526        assert_eq!(hit_test_click(&m, &c, 50.0, 50.0), Some(1)); // dentro
527        assert_eq!(hit_test_click(&m, &c, 250.0, 50.0), None); // fuera
528    }
529
530    #[test]
531    fn traslacion_mueve_el_area_clickeable() {
532        // El nodo se ve corrido +200px en x; el click debe seguirlo.
533        let (m, c) = fixture(Some(Affine::translate((200.0, 0.0))));
534        assert_eq!(hit_test_click(&m, &c, 250.0, 50.0), Some(1)); // donde se ve
535        assert_eq!(hit_test_click(&m, &c, 50.0, 50.0), None); // ya no donde estaba
536    }
537
538    #[test]
539    fn rotacion_180_grados_alrededor_del_centro() {
540        // Rotar 180° alrededor del centro (50,50) deja el rect en su sitio:
541        // una esquina mapea a la opuesta, pero el cuadrado cubre lo mismo.
542        let (m, c) = fixture(Some(Affine::rotate(core::f64::consts::PI)));
543        assert_eq!(hit_test_click(&m, &c, 10.0, 10.0), Some(1));
544        assert_eq!(hit_test_click(&m, &c, 90.0, 90.0), Some(1));
545        assert_eq!(hit_test_click(&m, &c, 150.0, 150.0), None);
546    }
547
548    #[test]
549    fn escala_cero_es_inalcanzable() {
550        let (m, c) = fixture(Some(Affine::scale(0.0)));
551        assert_eq!(hit_test_click(&m, &c, 50.0, 50.0), None);
552    }
553
554    /// Como `fixture` pero seteando `transform_rel` (traslación en fracción
555    /// del tamaño del nodo) en vez del afín fijo.
556    fn fixture_rel(
557        rel: (f64, f64),
558    ) -> (crate::Mounted<()>, llimphi_layout::ComputedLayout) {
559        let child = View::<()>::new(Style {
560            size: Size { width: length(100.0), height: length(100.0) },
561            ..Default::default()
562        })
563        .on_click(())
564        .transform_rel(rel);
565        let root = View::<()>::new(Style {
566            align_items: Some(AlignItems::FLEX_START),
567            justify_content: Some(JustifyContent::FLEX_START),
568            ..Default::default()
569        })
570        .children(vec![child]);
571        let mut layout = LayoutTree::new();
572        let mounted = mount(&mut layout, root);
573        let computed = layout.compute(mounted.root, (400.0, 400.0)).expect("layout");
574        (mounted, computed)
575    }
576
577    /// Replica la estructura de dominium: un wrapper que clipea y lleva
578    /// `on_click_at` + `draggable_at`, con un hijo "canvas" que pinta por
579    /// uno de dos caminos. Devuelve `(mounted, computed)`. `gpu` elige el
580    /// camino: `false` = `paint_with` (camino LEGACY), `true` =
581    /// `gpu_paint_with().paint_over()` (camino Tier 1 nuevo).
582    fn dominium_like(gpu: bool) -> (crate::Mounted<()>, llimphi_layout::ComputedLayout) {
583        let mut canvas = View::<()>::new(Style {
584            size: Size { width: percent(1.0), height: percent(1.0) },
585            ..Default::default()
586        });
587        canvas = if gpu {
588            canvas
589                .gpu_paint_with(|_d, _q, _e, _t, _r, _vp| {})
590                .paint_over(|_s, _ts, _r| {})
591        } else {
592            canvas.paint_with(|_s, _ts, _r| {})
593        };
594        let wrapper = View::<()>::new(Style {
595            size: Size { width: percent(1.0), height: percent(1.0) },
596            ..Default::default()
597        })
598        .clip(true)
599        .on_click_at(|_lx, _ly, _rw, _rh| Some(()))
600        .draggable_at(|_phase, _dx, _dy, _x0, _y0| Some(()))
601        .children(vec![canvas]);
602        let mut layout = LayoutTree::new();
603        let mounted = mount(&mut layout, wrapper);
604        let computed = layout.compute(mounted.root, (400.0, 400.0)).expect("layout");
605        (mounted, computed)
606    }
607
608    #[test]
609    fn canvas_gpu_only_es_clickeable_igual_que_legacy() {
610        // BUG 2: el wrapper con `on_click_at`/`draggable_at` debe ser
611        // hit-testeable en AMBOS caminos. El click cae sobre el wrapper
612        // (índice 0, raíz) — un punto interior lo encuentra sin importar el
613        // tipo de painter del hijo.
614        let (m_leg, c_leg) = dominium_like(false);
615        assert_eq!(hit_test_click(&m_leg, &c_leg, 200.0, 200.0), Some(0), "LEGACY (paint_with)");
616        let (m_gpu, c_gpu) = dominium_like(true);
617        assert_eq!(hit_test_click(&m_gpu, &c_gpu, 200.0, 200.0), Some(0), "GPU (gpu_paint_with+paint_over)");
618    }
619
620    /// Una capa `.over(true)` se pinta DESPUÉS de todo lo demás, así que el
621    /// usuario la ve arriba. El hit-test tiene que coincidir con eso aunque el
622    /// nodo base esté DESPUÉS en orden de árbol.
623    ///
624    /// Caso real que lo motivó (saga del ⚙ de media, 2026-07): la barra de
625    /// control de media es `.over(true)` y el lienzo de video que va detrás
626    /// aparece después en el árbol; con el criterio viejo («último nodo que
627    /// cumple, en orden de árbol») el lienzo le robaba los clicks a los
628    /// botones que el usuario veía encima.
629    #[test]
630    fn capa_over_le_gana_el_click_al_nodo_base_posterior() {
631        fn caja<Msg: Clone>(msg: Msg) -> View<Msg> {
632            View::<Msg>::new(Style {
633                position: Position::Absolute,
634                inset: Rect { left: length(0.0), top: length(0.0), right: auto(), bottom: auto() },
635                size: Size { width: length(200.0), height: length(200.0) },
636                ..Default::default()
637            })
638            .on_click(msg)
639        }
640        // Dos cajas superpuestas en el MISMO rect. La `over` va PRIMERO en el
641        // árbol (la posición desfavorable), la base va después.
642        let root = View::<u8>::new(Style::default()).children(vec![
643            caja(1u8).over(true), // se pinta al final ⇒ está arriba
644            caja(2u8),            // posterior en el árbol, pero debajo
645        ]);
646        let mut layout = LayoutTree::new();
647        let m = mount(&mut layout, root);
648        let c = layout.compute(m.root, (400.0, 400.0)).expect("layout");
649        // idx 1 = la caja over, idx 2 = la base. Gana la que se ve.
650        assert_eq!(
651            hit_test_click(&m, &c, 100.0, 100.0),
652            Some(1),
653            "el click debe ir a la capa over (la que el usuario ve encima), \
654             no al nodo base posterior en orden de árbol"
655        );
656        // Control: fuera de las cajas no pega nada.
657        assert_eq!(hit_test_click(&m, &c, 300.0, 300.0), None);
658    }
659
660    /// El contrapeso del test anterior: sin capas over, el criterio de siempre
661    /// (último en orden de árbol = el que se pintó encima) queda intacto.
662    #[test]
663    fn sin_capas_over_gana_el_ultimo_del_arbol_como_siempre() {
664        fn caja<Msg: Clone>(msg: Msg) -> View<Msg> {
665            View::<Msg>::new(Style {
666                position: Position::Absolute,
667                inset: Rect { left: length(0.0), top: length(0.0), right: auto(), bottom: auto() },
668                size: Size { width: length(200.0), height: length(200.0) },
669                ..Default::default()
670            })
671            .on_click(msg)
672        }
673        let root = View::<u8>::new(Style::default()).children(vec![caja(1u8), caja(2u8)]);
674        let mut layout = LayoutTree::new();
675        let m = mount(&mut layout, root);
676        let c = layout.compute(m.root, (400.0, 400.0)).expect("layout");
677        assert_eq!(hit_test_click(&m, &c, 100.0, 100.0), Some(2));
678    }
679
680    #[test]
681    fn nodo_gpu_paint_with_solo_es_hittable_por_si_mismo() {
682        // Crítico para el motor voxel futuro: una vista 3D GPU-only que
683        // lleve su PROPIO `on_click_at` debe ser clickeable, aunque NO
684        // tenga `paint_with` ni contenido vello — sólo `gpu_painter`.
685        let canvas = View::<()>::new(Style {
686            size: Size { width: length(100.0), height: length(100.0) },
687            ..Default::default()
688        })
689        .gpu_paint_with(|_d, _q, _e, _t, _r, _vp| {})
690        .on_click(());
691        let root = View::<()>::new(Style {
692            align_items: Some(AlignItems::FLEX_START),
693            justify_content: Some(JustifyContent::FLEX_START),
694            ..Default::default()
695        })
696        .children(vec![canvas]);
697        let mut layout = LayoutTree::new();
698        let m = mount(&mut layout, root);
699        let c = layout.compute(m.root, (400.0, 400.0)).expect("layout");
700        assert_eq!(hit_test_click(&m, &c, 50.0, 50.0), Some(1), "gpu-only con on_click debe ser hittable");
701    }
702
703    #[test]
704    fn transform_rel_resuelve_contra_el_tamano_del_nodo() {
705        // El nodo es 100×100 en (0,0). `transform_rel(-0.5,-0.5)` =
706        // `translate(-50%,-50%)` = correr -50px,-50px (la mitad de 100). El
707        // área pintada pasa a (-50,-50)..(50,50): el centro del rect original
708        // (50,50) queda ahora en (0,0).
709        let (m, c) = fixture_rel((-0.5, -0.5));
710        // Donde se ve ahora (el viejo centro corrido a 0,0; y la esquina
711        // inferior-derecha del original (100,100) ahora en (50,50)).
712        assert_eq!(hit_test_click(&m, &c, 25.0, 25.0), Some(1)); // dentro del corrido
713        assert_eq!(hit_test_click(&m, &c, 49.0, 49.0), Some(1)); // casi esquina nueva
714        // Donde estaba antes pero ya NO (el rect se corrió fuera de ahí).
715        assert_eq!(hit_test_click(&m, &c, 75.0, 75.0), None);
716        // Sin transform_rel ese mismo punto SÍ caería dentro (control).
717        let (m0, c0) = fixture_rel((0.0, 0.0)); // (0,0) = no-op
718        assert_eq!(hit_test_click(&m0, &c0, 75.0, 75.0), Some(1));
719    }
720
721    #[test]
722    fn hit_test_cursor_directo_y_por_herencia() {
723        use crate::{hit_test_cursor, Cursor};
724        // Padre 200×200 con cursor Text; dentro un hijo 100×100 (arriba-izq)
725        // SIN cursor propio; y un segundo hijo 50×50 con cursor Pointer.
726        let hijo_sin = View::<()>::new(Style {
727            size: Size { width: length(100.0), height: length(100.0) },
728            ..Default::default()
729        });
730        let hijo_con = View::<()>::new(Style {
731            size: Size { width: length(50.0), height: length(50.0) },
732            ..Default::default()
733        })
734        .cursor(Cursor::Pointer);
735        let root = View::<()>::new(Style {
736            size: Size { width: length(200.0), height: length(200.0) },
737            flex_direction: FlexDirection::Column,
738            align_items: Some(AlignItems::FLEX_START),
739            justify_content: Some(JustifyContent::FLEX_START),
740            ..Default::default()
741        })
742        .cursor(Cursor::Text)
743        .children(vec![hijo_sin, hijo_con]);
744        let mut layout = LayoutTree::new();
745        let m = mount(&mut layout, root);
746        let c = layout.compute(m.root, (400.0, 400.0)).expect("layout");
747        // Sobre el hijo sin cursor (0..100, 0..100) → hereda Text del padre.
748        assert_eq!(hit_test_cursor(&m, &c, 50.0, 50.0), Some(Cursor::Text));
749        // Sobre el hijo con cursor propio (apilado debajo: y 100..150) → Pointer.
750        assert_eq!(hit_test_cursor(&m, &c, 25.0, 120.0), Some(Cursor::Pointer));
751        // Sobre el padre pero fuera de ambos hijos (x>100) → Text del padre.
752        assert_eq!(hit_test_cursor(&m, &c, 150.0, 50.0), Some(Cursor::Text));
753        // Fuera del padre → None (la ventana usa su default).
754        assert_eq!(hit_test_cursor(&m, &c, 350.0, 350.0), None);
755    }
756
757    #[test]
758    fn tab_traversal_envuelve_en_los_extremos() {
759        use crate::next_focus;
760        let order = [10u64, 20, 30];
761        // Avanza.
762        assert_eq!(next_focus(&order, Some(10), false), Some(20));
763        assert_eq!(next_focus(&order, Some(30), false), Some(10)); // wrap
764        // Retrocede (Shift+Tab).
765        assert_eq!(next_focus(&order, Some(20), true), Some(10));
766        assert_eq!(next_focus(&order, Some(10), true), Some(30)); // wrap
767        // Sin foco previo: Tab → primero, Shift+Tab → último.
768        assert_eq!(next_focus(&order, None, false), Some(10));
769        assert_eq!(next_focus(&order, None, true), Some(30));
770        // Foco obsoleto (id que ya no está) → arranca por el extremo.
771        assert_eq!(next_focus(&order, Some(99), false), Some(10));
772        // Lista vacía.
773        assert_eq!(next_focus(&[], Some(10), false), None);
774    }
775
776    #[test]
777    fn hit_test_scale_directo_y_por_herencia() {
778        use crate::{hit_test_scale, GesturePhase};
779        // Canvas zoomeable 200×200 (declara on_scale); dentro un widget 50×50
780        // (arriba-izq) SIN on_scale (no zoomea). El gesto sobre el widget debe
781        // "caer" al canvas ancestro (herencia, como el cursor), y fuera de
782        // todo debe dar None (el evento cae al scroll/on_wheel).
783        let widget = View::<()>::new(Style {
784            size: Size { width: length(50.0), height: length(50.0) },
785            ..Default::default()
786        });
787        let canvas = View::<()>::new(Style {
788            size: Size { width: length(200.0), height: length(200.0) },
789            align_items: Some(AlignItems::FLEX_START),
790            justify_content: Some(JustifyContent::FLEX_START),
791            ..Default::default()
792        })
793        .on_scale(|_phase: GesturePhase, _f, _fx, _fy| None)
794        .children(vec![widget]);
795        let mut layout = LayoutTree::new();
796        let m = mount(&mut layout, canvas);
797        let c = layout.compute(m.root, (400.0, 400.0)).expect("layout");
798        // Sobre el widget sin on_scale (0..50,0..50) → cae al canvas (idx 0).
799        assert_eq!(hit_test_scale(&m, &c, 25.0, 25.0), Some(0));
800        // Sobre el canvas fuera del widget (x>50) → el canvas (idx 0).
801        assert_eq!(hit_test_scale(&m, &c, 150.0, 25.0), Some(0));
802        // Fuera del canvas → None.
803        assert_eq!(hit_test_scale(&m, &c, 350.0, 350.0), None);
804    }
805
806    #[test]
807    fn hit_test_rotate_directo_y_por_herencia() {
808        use crate::{hit_test_rotate, GesturePhase};
809        // Mismo patrón que escala: canvas rotable con un widget no-rotable
810        // encima; el gesto cae al ancestro que declara on_rotate.
811        let widget = View::<()>::new(Style {
812            size: Size { width: length(50.0), height: length(50.0) },
813            ..Default::default()
814        });
815        let canvas = View::<()>::new(Style {
816            size: Size { width: length(200.0), height: length(200.0) },
817            align_items: Some(AlignItems::FLEX_START),
818            justify_content: Some(JustifyContent::FLEX_START),
819            ..Default::default()
820        })
821        .on_rotate(|_phase: GesturePhase, _d, _fx, _fy| None)
822        .children(vec![widget]);
823        let mut layout = LayoutTree::new();
824        let m = mount(&mut layout, canvas);
825        let c = layout.compute(m.root, (400.0, 400.0)).expect("layout");
826        assert_eq!(hit_test_rotate(&m, &c, 25.0, 25.0), Some(0));
827        assert_eq!(hit_test_rotate(&m, &c, 150.0, 25.0), Some(0));
828        assert_eq!(hit_test_rotate(&m, &c, 350.0, 350.0), None);
829    }
830
831    #[test]
832    fn hit_test_selectable_solo_sobre_texto_seleccionable() {
833        use crate::hit_test_selectable;
834        // Un label seleccionable 100×30 arriba-izq dentro de un panel 200×200
835        // SIN selectable. Sólo el label matchea; el resto del panel da None.
836        let label = View::<()>::new(Style {
837            size: Size { width: length(100.0), height: length(30.0) },
838            ..Default::default()
839        })
840        .text("hola", 14.0, llimphi_text::peniko::Color::from_rgba8(255, 255, 255, 255))
841        .selectable(7);
842        let panel = View::<()>::new(Style {
843            size: Size { width: length(200.0), height: length(200.0) },
844            align_items: Some(AlignItems::FLEX_START),
845            justify_content: Some(JustifyContent::FLEX_START),
846            ..Default::default()
847        })
848        .children(vec![label]);
849        let mut layout = LayoutTree::new();
850        let m = mount(&mut layout, panel);
851        let c = layout.compute(m.root, (400.0, 400.0)).expect("layout");
852        // Sobre el label (0..100, 0..30) → el label (idx 1).
853        assert_eq!(hit_test_selectable(&m, &c, 50.0, 15.0), Some(1));
854        // Sobre el panel fuera del label → None (el panel no es selectable).
855        assert_eq!(hit_test_selectable(&m, &c, 150.0, 150.0), None);
856    }
857
858    #[test]
859    fn hit_test_scroll_chain_devuelve_front_to_back() {
860        use crate::hit_test_scroll_chain;
861        // Padre scrollable 200×200 con un hijo scrollable 100×100 (arriba-izq).
862        // Bajo el hijo: chain = [hijo, padre]. Bajo el padre pero fuera del
863        // hijo: chain = [padre]. Fuera de ambos: chain vacío.
864        let hijo = View::<()>::new(Style {
865            size: Size { width: length(100.0), height: length(100.0) },
866            ..Default::default()
867        })
868        .on_scroll(|_dx, _dy| None::<()>);
869        let padre = View::<()>::new(Style {
870            size: Size { width: length(200.0), height: length(200.0) },
871            align_items: Some(AlignItems::FLEX_START),
872            justify_content: Some(JustifyContent::FLEX_START),
873            ..Default::default()
874        })
875        .on_scroll(|_dx, _dy| None::<()>)
876        .children(vec![hijo]);
877        let mut layout = LayoutTree::new();
878        let m = mount(&mut layout, padre);
879        let c = layout.compute(m.root, (400.0, 400.0)).expect("layout");
880        // Sobre el hijo (0..100,0..100) → chain = [hijo=1, padre=0].
881        let ch = hit_test_scroll_chain(&m, &c, 50.0, 50.0);
882        assert_eq!(ch, vec![1, 0]);
883        // Sobre el padre fuera del hijo (x>100) → chain = [padre=0].
884        let ch = hit_test_scroll_chain(&m, &c, 150.0, 50.0);
885        assert_eq!(ch, vec![0]);
886        // Fuera del padre → chain vacío.
887        let ch = hit_test_scroll_chain(&m, &c, 350.0, 350.0);
888        assert!(ch.is_empty());
889    }
890
891    #[test]
892    fn consume_scroll_chain_reparte_el_sobrante() {
893        use crate::{consume_scroll_chain, hit_test_scroll_chain};
894        // Hijo `take` que sólo puede absorber 3 líneas; padre `take` sin tope.
895        // Un evento de 10 líneas debe producir DOS Msg: hijo 3 + padre 7.
896        #[derive(Clone, Debug, PartialEq)]
897        enum M {
898            Hijo(f32),
899            Padre(f32),
900        }
901        let hijo = View::<M>::new(Style {
902            size: Size { width: length(100.0), height: length(100.0) },
903            ..Default::default()
904        })
905        .on_scroll_take(|dx, dy| {
906            let can = dy.min(3.0);
907            (Some(M::Hijo(can)), dx, dy - can)
908        });
909        let padre = View::<M>::new(Style {
910            size: Size { width: length(200.0), height: length(200.0) },
911            align_items: Some(AlignItems::FLEX_START),
912            justify_content: Some(JustifyContent::FLEX_START),
913            ..Default::default()
914        })
915        .on_scroll_take(|_dx, dy| (Some(M::Padre(dy)), 0.0, 0.0))
916        .children(vec![hijo]);
917        let mut layout = LayoutTree::new();
918        let m = mount(&mut layout, padre);
919        let c = layout.compute(m.root, (400.0, 400.0)).expect("layout");
920        let chain = hit_test_scroll_chain(&m, &c, 50.0, 50.0);
921        let msgs = consume_scroll_chain(&m, &chain, 0.0, 10.0);
922        assert_eq!(msgs, vec![M::Hijo(3.0), M::Padre(7.0)]);
923        // Si el hijo consume todo, el padre no recibe nada.
924        let msgs = consume_scroll_chain(&m, &chain, 0.0, 2.0);
925        assert_eq!(msgs, vec![M::Hijo(2.0)]);
926    }
927
928    #[test]
929    fn consume_scroll_chain_legado_y_defensas() {
930        use crate::{consume_scroll_chain, hit_test_scroll_chain};
931        #[derive(Clone, Debug, PartialEq)]
932        enum M {
933            Padre(f32),
934        }
935        // Hijo LEGACY que devuelve None (topado) → el padre take recibe el
936        // delta completo (semántica histórica intacta).
937        let hijo = View::<M>::new(Style {
938            size: Size { width: length(100.0), height: length(100.0) },
939            ..Default::default()
940        })
941        .on_scroll(|_dx, _dy| None::<M>);
942        let padre = View::<M>::new(Style {
943            size: Size { width: length(200.0), height: length(200.0) },
944            align_items: Some(AlignItems::FLEX_START),
945            justify_content: Some(JustifyContent::FLEX_START),
946            ..Default::default()
947        })
948        .on_scroll_take(|_dx, dy| (Some(M::Padre(dy)), 0.0, 0.0))
949        .children(vec![hijo]);
950        let mut layout = LayoutTree::new();
951        let m = mount(&mut layout, padre);
952        let c = layout.compute(m.root, (400.0, 400.0)).expect("layout");
953        let chain = hit_test_scroll_chain(&m, &c, 50.0, 50.0);
954        assert_eq!(consume_scroll_chain(&m, &chain, 0.0, 5.0), vec![M::Padre(5.0)]);
955
956        // Defensa: un take mentiroso que "agranda" el sobrante queda acotado
957        // al delta recibido — el padre nunca ve más de lo que entró.
958        let tramposo = View::<M>::new(Style {
959            size: Size { width: length(100.0), height: length(100.0) },
960            ..Default::default()
961        })
962        .on_scroll_take(|_dx, _dy| (None, 0.0, 999.0));
963        let padre2 = View::<M>::new(Style {
964            size: Size { width: length(200.0), height: length(200.0) },
965            align_items: Some(AlignItems::FLEX_START),
966            justify_content: Some(JustifyContent::FLEX_START),
967            ..Default::default()
968        })
969        .on_scroll_take(|_dx, dy| (Some(M::Padre(dy)), 0.0, 0.0))
970        .children(vec![tramposo]);
971        let mut layout = LayoutTree::new();
972        let m = mount(&mut layout, padre2);
973        let c = layout.compute(m.root, (400.0, 400.0)).expect("layout");
974        let chain = hit_test_scroll_chain(&m, &c, 50.0, 50.0);
975        assert_eq!(consume_scroll_chain(&m, &chain, 0.0, 5.0), vec![M::Padre(5.0)]);
976    }
977
978    #[test]
979    fn hit_test_double_tap_y_long_press() {
980        use crate::{hit_test_double_tap, hit_test_long_press};
981        // Un nodo 100×100 con doble-tap; otro 100×100 apilado debajo con
982        // long-press. Cada hit-test sólo ve su propio gesto.
983        let arriba = View::<()>::new(Style {
984            size: Size { width: length(100.0), height: length(100.0) },
985            ..Default::default()
986        })
987        .on_double_tap(());
988        let abajo = View::<()>::new(Style {
989            size: Size { width: length(100.0), height: length(100.0) },
990            ..Default::default()
991        })
992        .on_long_press(());
993        let root = View::<()>::new(Style {
994            flex_direction: FlexDirection::Column,
995            align_items: Some(AlignItems::FLEX_START),
996            justify_content: Some(JustifyContent::FLEX_START),
997            ..Default::default()
998        })
999        .children(vec![arriba, abajo]);
1000        let mut layout = LayoutTree::new();
1001        let m = mount(&mut layout, root);
1002        let c = layout.compute(m.root, (400.0, 400.0)).expect("layout");
1003        // Nodo de arriba (y 0..100): doble-tap sí, long-press no.
1004        assert_eq!(hit_test_double_tap(&m, &c, 50.0, 50.0), Some(1));
1005        assert_eq!(hit_test_long_press(&m, &c, 50.0, 50.0), None);
1006        // Nodo de abajo (y 100..200): long-press sí, doble-tap no.
1007        assert_eq!(hit_test_long_press(&m, &c, 50.0, 150.0), Some(2));
1008        assert_eq!(hit_test_double_tap(&m, &c, 50.0, 150.0), None);
1009        // Fuera de ambos.
1010        assert_eq!(hit_test_double_tap(&m, &c, 300.0, 300.0), None);
1011        assert_eq!(hit_test_long_press(&m, &c, 300.0, 300.0), None);
1012    }
1013}
1014
1015#[cfg(test)]
1016mod reciclado {
1017    use super::*;
1018    use llimphi_layout::taffy::prelude::*;
1019
1020    /// Árbol de `n` hojas bajo una fila, con estilos fijos.
1021    fn arbol(n: usize) -> View<()> {
1022        View::<()>::new(Style { flex_direction: FlexDirection::Row, ..Default::default() })
1023            .children(
1024                (0..n)
1025                    .map(|i| {
1026                        View::<()>::new(Style {
1027                            size: Size { width: length(10.0 + i as f32 % 7.0), height: length(10.0) },
1028                            flex_shrink: 0.0,
1029                            ..Default::default()
1030                        })
1031                    })
1032                    .collect(),
1033            )
1034    }
1035
1036    /// La prueba de que el reciclado hace lo que dice: el segundo mount escribe
1037    /// **sobre el mismo bloque de heap** (mismo puntero, misma capacidad) en vez
1038    /// de pedir otro, y el árbol resultante es idéntico al que sale sin reciclar.
1039    /// Si alguien rompe el `clear()`-y-reusar, el puntero cambia y esto cae.
1040    #[test]
1041    fn reciclar_reusa_el_mismo_bloque_y_da_el_mismo_arbol() {
1042        let mut tree = LayoutTree::new();
1043        let (m1, ret1, _) = mount_incremental(&mut tree, arbol(64), None);
1044        let ptr1 = m1.nodes.as_ptr();
1045        let cap1 = m1.nodes.capacity();
1046        let len1 = m1.nodes.len();
1047
1048        let (m2, _, reuso) =
1049            mount_incremental_reciclando(&mut tree, arbol(64), Some(ret1), Some(m1));
1050        assert!(reuso, "misma forma ⇒ debe reusar el layout retenido");
1051        assert_eq!(m2.nodes.len(), len1, "mismo árbol ⇒ mismo nº de nodos");
1052        assert_eq!(m2.nodes.capacity(), cap1, "el buffer reciclado no se re-pidió");
1053        assert_eq!(m2.nodes.as_ptr(), ptr1, "el buffer reciclado cambió de bloque");
1054
1055        // Y contra el camino histórico (sin reciclar): mismo resultado.
1056        let mut tree_ref = LayoutTree::new();
1057        let (mref, _, _) = mount_incremental(&mut tree_ref, arbol(64), None);
1058        assert_eq!(m2.nodes.len(), mref.nodes.len());
1059        for (a, b) in m2.nodes.iter().zip(mref.nodes.iter()) {
1060            assert_eq!(a.subtree_end, b.subtree_end, "forma del subárbol");
1061            assert_eq!(a.fill, b.fill);
1062        }
1063    }
1064
1065    /// El contrapeso: un árbol que se desploma de 4.096 a 4 nodos no puede
1066    /// dejarnos el buffer grande tomado para siempre. Se encoge con histéresis
1067    /// (sobra >4× ⇒ deja 2×), así una UI que oscila no rebota.
1068    #[test]
1069    fn el_buffer_se_encoge_cuando_el_arbol_se_desploma() {
1070        let mut tree = LayoutTree::new();
1071        let (grande, ret, _) = mount_incremental(&mut tree, arbol(4096), None);
1072        let cap_grande = grande.nodes.capacity();
1073        assert!(cap_grande >= 4096);
1074
1075        let (chico, _, _) =
1076            mount_incremental_reciclando(&mut tree, arbol(3), Some(ret), Some(grande));
1077        assert_eq!(chico.nodes.len(), 4, "raíz + 3 hojas");
1078        assert!(
1079            chico.nodes.capacity() < cap_grande / 4,
1080            "el buffer no se encogió: {} (venía de {cap_grande})",
1081            chico.nodes.capacity()
1082        );
1083    }
1084}
1085
1086#[cfg(test)]
1087mod over_bbox_tests {
1088    use super::*;
1089    use crate::{mount, Shadow, View};
1090    use llimphi_layout::taffy::prelude::*;
1091    use llimphi_layout::taffy::Rect as TaffyRect;
1092    use llimphi_layout::LayoutTree;
1093
1094    fn mount_compute(v: View<()>, vp: (f32, f32)) -> (Mounted<()>, ComputedLayout) {
1095        let mut lt = LayoutTree::new();
1096        let m = mount(&mut lt, v);
1097        let c = lt.compute(m.root, vp).expect("layout");
1098        (m, c)
1099    }
1100
1101    /// Sin nada marcado `over`, la pasada no tiene bbox — el caller se ahorra
1102    /// la scratch entera.
1103    #[test]
1104    fn sin_capa_over_no_hay_bbox() {
1105        let (m, c) = mount_compute(
1106            View::<()>::new(Style::default())
1107                .children(vec![View::new(Style::default()).fill(Color::WHITE)]),
1108            (200.0, 200.0),
1109        );
1110        assert!(over_bbox(&m, &c).is_none());
1111    }
1112
1113    /// El bbox encierra el subárbol over y NO el resto del árbol: es el punto
1114    /// de todo el cambio. Una barra al pie de un frame grande da una franja,
1115    /// no el frame.
1116    #[test]
1117    fn el_bbox_encierra_solo_la_capa_over() {
1118        let barra = View::<()>::new(Style {
1119            position: Position::Absolute,
1120            inset: TaffyRect {
1121                left: length(20.0_f32),
1122                top: length(180.0_f32),
1123                right: auto(),
1124                bottom: auto(),
1125            },
1126            size: Size {
1127                width: length(160.0_f32),
1128                height: length(6.0_f32),
1129            },
1130            ..Default::default()
1131        })
1132        .fill(Color::WHITE)
1133        .over(true);
1134        let fondo = View::<()>::new(Style {
1135            size: Size {
1136                width: percent(1.0_f32),
1137                height: percent(1.0_f32),
1138            },
1139            ..Default::default()
1140        })
1141        .fill(Color::BLACK);
1142        let (m, c) = mount_compute(
1143            View::<()>::new(Style {
1144                size: Size {
1145                    width: length(200.0_f32),
1146                    height: length(200.0_f32),
1147                },
1148                ..Default::default()
1149            })
1150            .children(vec![fondo, barra]),
1151            (200.0, 200.0),
1152        );
1153        let (x0, y0, x1, y1) = over_bbox(&m, &c).expect("hay capa over");
1154        // El rect es (20, 180, 160×6), más el margen de seguridad.
1155        assert_eq!(
1156            (x0, y0, x1, y1),
1157            (
1158                20.0 - OVER_BBOX_MARGEN,
1159                180.0 - OVER_BBOX_MARGEN,
1160                180.0 + OVER_BBOX_MARGEN,
1161                186.0 + OVER_BBOX_MARGEN
1162            )
1163        );
1164        // Y lo que importa: es una franja, no el frame. Si esto deja de valer,
1165        // rasterizar la capa over volvió a costar un frame entero.
1166        assert!(
1167            (y1 - y0) < 40.0,
1168            "el bbox se comió el alto del frame: {y0}..{y1}"
1169        );
1170    }
1171
1172    /// La sombra pinta FUERA del rect de su nodo: si el bbox no la contempla,
1173    /// recortar ahí la corta a la mitad.
1174    #[test]
1175    fn el_bbox_crece_con_la_sombra() {
1176        let build = |sombra: Option<Shadow>| {
1177            let mut v = View::<()>::new(Style {
1178                size: Size {
1179                    width: length(40.0_f32),
1180                    height: length(40.0_f32),
1181                },
1182                ..Default::default()
1183            })
1184            .fill(Color::WHITE)
1185            .over(true);
1186            if let Some(s) = sombra {
1187                v = v.shadow(s);
1188            }
1189            View::<()>::new(Style::default()).children(vec![v])
1190        };
1191        let (m1, c1) = mount_compute(build(None), (200.0, 200.0));
1192        let sin = over_bbox(&m1, &c1).expect("hay capa");
1193
1194        let (m2, c2) = mount_compute(
1195            build(Some(Shadow {
1196                color: Color::BLACK,
1197                blur: 10.0,
1198                dx: 0.0,
1199                dy: 4.0,
1200                spread: 2.0,
1201            })),
1202            (200.0, 200.0),
1203        );
1204        let con = over_bbox(&m2, &c2).expect("hay capa");
1205
1206        assert!(con.0 < sin.0 && con.1 < sin.1, "la sombra no ensanchó arriba/izquierda");
1207        assert!(con.2 > sin.2 && con.3 > sin.3, "la sombra no ensanchó abajo/derecha");
1208        // 3σ de blur + spread hacia cada lado, y el offset hacia abajo.
1209        assert_eq!(con.3 - sin.3, 4.0 + 2.0 + 10.0 * OVER_BBOX_SIGMAS as f32);
1210    }
1211}