tawasuyu

tawasuyu/llimphi

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

Rust 614Markdown 274TOML 149RON 310606 Definiciones

⎇ main llimphi-test/src/query.rs Rust · 698 líneas · 22.7 KB Historia Quién tocó qué crudo

chore: refresco desde el monorepo (pin tawasuyu.git v0.3.0) b575c63f1 · Sergio ·

1//! # Consultas semánticas sobre un `View` montado
2//!
3//! El complemento barato de [`assert_golden!`](crate::assert_golden): en vez de
4//! diffear píxeles, monta el árbol y **pregunta por su significado** —
5//! "¿existe un botón etiquetado *Guardar*?", "¿el switch está en on?", "¿qué
6//! `Msg` emite ese ítem si lo clickeo?".
7//!
8//! Es la misma fuente de verdad que alimenta a los lectores de pantalla: el
9//! `SemanticsSpec` que los widgets declaran con `View::role` / `View::aria_*`,
10//! traducido cada frame a AccessKit por `llimphi-ui::a11y`. Un test que
11//! encuentra el botón por rol+label está certificando, de paso, que un usuario
12//! de Orca también lo encuentra.
13//!
14//! ## Por qué esto y no un golden
15//!
16//! Un golden PNG certifica *cómo se ve*; se rompe con cualquier cambio de tema,
17//! de fuente o de padding, y para leerlo hay que mirarlo (caro en tokens, ver
18//! CLAUDE.md §8). Una consulta certifica *qué hay y qué hace*: sobrevive al
19//! rediseño, falla con un mensaje en texto y corre sin GPU.
20//!
21//! **No necesita adapter de wgpu.** [`Ui::new`] sólo hace `mount` + layout —
22//! CPU pura. A diferencia de [`Harness`](crate::Harness), anda en cualquier CI
23//! sin lavapipe.
24//!
25//! ## Uso
26//!
27//! ```ignore
28//! use llimphi_test::query::{Sel, Ui};
29//! use llimphi_compositor::Role;
30//!
31//! let ui = Ui::new(mi_app.view(), 800.0, 600.0);
32//!
33//! // Buscar por rol, por nombre accesible, o por ambos.
34//! assert!(ui.find(Role::Button).is_some());
35//! let guardar = ui.get(Sel::new().role(Role::Button).label("Guardar"));
36//! assert!(guardar.is_enabled());
37//!
38//! // Clickear = obtener el Msg y pasárselo al update de la app.
39//! let msg = guardar.click().expect("el botón emite Msg");
40//! mi_app.update(msg);
41//! ```
42//!
43//! Cuando algo no aparece, [`Ui::get`] paniquea con el árbol semántico entero
44//! ([`Ui::dump`]) en el mensaje — el "qué esperaba vs qué hay" viene servido.
45
46use std::cell::RefCell;
47use std::fmt::Write as _;
48
49use llimphi_compositor::{measure_text_node, mount, Mounted, Role, View};
50use llimphi_layout::taffy;
51use llimphi_layout::{ComputedLayout, LayoutTree, Rect};
52use llimphi_text::Typesetter;
53
54thread_local! {
55    /// Un `Typesetter` por hilo: cargar fuentes es caro y `Ui::new` se llama
56    /// una vez por assert. Mismo criterio que [`crate::with_harness`].
57    static TS: RefCell<Typesetter> = RefCell::new(Typesetter::new());
58}
59
60/// Un árbol montado + su layout, listo para interrogar.
61///
62/// Se construye con [`Ui::new`] y no toca la GPU. Mantiene el `Mounted` vivo,
63/// así que los `Msg` de los handlers se pueden clonar fuera.
64pub struct Ui<Msg> {
65    mounted: Mounted<Msg>,
66    computed: ComputedLayout,
67}
68
69impl<Msg: Clone> Ui<Msg> {
70    /// Monta `view` y le corre el layout contra un viewport `w×h`.
71    ///
72    /// Usa `compute_with_measure` (el mismo camino que las apps reales), así
73    /// que los rects de texto son los de verdad — un párrafo que envuelve a
74    /// tres líneas mide tres líneas, no una.
75    pub fn new(view: View<Msg>, w: f32, h: f32) -> Self {
76        let mut layout = LayoutTree::new();
77        let mounted = mount(&mut layout, view);
78        let computed = TS.with(|ts| {
79            let ts = &mut *ts.borrow_mut();
80            let tmap = &mounted.text_measures;
81            layout
82                .compute_with_measure(mounted.root, (w, h), |nid, known, avail| {
83                    match tmap.get(&nid) {
84                        Some(tm) => measure_text_node(ts, tm, known, avail),
85                        None => taffy::Size::ZERO,
86                    }
87                })
88                .expect("compute_with_measure")
89        });
90        Self { mounted, computed }
91    }
92
93    /// Cantidad de nodos montados.
94    pub fn len(&self) -> usize {
95        self.mounted.nodes.len()
96    }
97
98    pub fn is_empty(&self) -> bool {
99        self.mounted.nodes.is_empty()
100    }
101
102    /// Nodo por índice de montaje (0 = raíz del `View`).
103    pub fn node(&self, idx: usize) -> NodeRef<'_, Msg> {
104        NodeRef { ui: self, idx }
105    }
106
107    /// Raíz del árbol.
108    pub fn root(&self) -> NodeRef<'_, Msg> {
109        self.node(0)
110    }
111
112    /// Todos los nodos en orden de montaje (pre-orden = orden de lectura).
113    pub fn all(&self) -> impl Iterator<Item = NodeRef<'_, Msg>> {
114        (0..self.len()).map(move |idx| self.node(idx))
115    }
116
117    /// Todos los nodos que casan con el selector, en orden de lectura.
118    pub fn find_all(&self, sel: impl Into<Sel>) -> Vec<NodeRef<'_, Msg>> {
119        let sel = sel.into();
120        self.all().filter(|n| sel.matches(n)).collect()
121    }
122
123    /// El primer nodo que casa, o `None`.
124    pub fn find(&self, sel: impl Into<Sel>) -> Option<NodeRef<'_, Msg>> {
125        let sel = sel.into();
126        self.all().find(|n| sel.matches(n))
127    }
128
129    /// Como [`find`](Self::find) pero paniquea si no hay exactamente uno,
130    /// mostrando el árbol semántico completo. Es la forma pensada para tests:
131    /// el fallo se lee sin abrir un PNG.
132    pub fn get(&self, sel: impl Into<Sel>) -> NodeRef<'_, Msg> {
133        let sel = sel.into();
134        let hits = self.find_all(sel.clone());
135        match hits.len() {
136            1 => hits.into_iter().next().unwrap(),
137            0 => panic!(
138                "ningún nodo casa con {sel:?}\n\n--- árbol semántico ---\n{}",
139                self.dump()
140            ),
141            n => panic!(
142                "{n} nodos casan con {sel:?} (esperaba 1; usá find_all si es a propósito)\n\n--- árbol semántico ---\n{}",
143                self.dump()
144            ),
145        }
146    }
147
148    /// Índices de los hijos **directos** del nodo `idx`.
149    ///
150    /// Réplica exacta de `llimphi_ui::a11y::direct_children`: el montaje es
151    /// pre-orden, el primer hijo está en `idx + 1` y los siguientes se
152    /// alcanzan saltando por `subtree_end`.
153    fn direct_children(&self, idx: usize) -> Vec<usize> {
154        let parent = &self.mounted.nodes[idx];
155        let mut out = Vec::new();
156        let mut cursor = idx + 1;
157        while cursor < parent.subtree_end {
158            out.push(cursor);
159            cursor = self.mounted.nodes[cursor].subtree_end;
160        }
161        out
162    }
163
164    /// El árbol semántico como texto indentado. **Ésta es la evidencia**: se
165    /// pega en un test, en un mensaje de fallo o en un reporte sin renderizar
166    /// un solo píxel.
167    ///
168    /// Los nodos sin rol ni nombre ni handlers se colapsan a `·` para que el
169    /// volcado sea legible (una app real tiene cientos de cajas de layout).
170    pub fn dump(&self) -> String {
171        let mut s = String::new();
172        self.dump_into(&mut s, 0, 0);
173        s
174    }
175
176    fn dump_into(&self, out: &mut String, idx: usize, depth: usize) {
177        let n = self.node(idx);
178        let _ = writeln!(out, "{:indent$}{}", "", n.line(), indent = depth * 2);
179        for c in self.direct_children(idx) {
180            self.dump_into(out, c, depth + 1);
181        }
182    }
183}
184
185/// Referencia a un nodo montado. Barata de copiar, atada al [`Ui`].
186pub struct NodeRef<'a, Msg> {
187    ui: &'a Ui<Msg>,
188    idx: usize,
189}
190
191impl<Msg> Clone for NodeRef<'_, Msg> {
192    fn clone(&self) -> Self {
193        *self
194    }
195}
196impl<Msg> Copy for NodeRef<'_, Msg> {}
197
198impl<'a, Msg: Clone> NodeRef<'a, Msg> {
199    /// Índice de montaje. Coincide con el que usa `llimphi_ui::a11y::node_id_for`.
200    pub fn idx(&self) -> usize {
201        self.idx
202    }
203
204    fn raw(&self) -> &'a llimphi_compositor::MountedNode<Msg> {
205        &self.ui.mounted.nodes[self.idx]
206    }
207
208    /// Rol declarado, o `None` si el nodo es una caja de layout sin semántica.
209    pub fn role(&self) -> Option<Role> {
210        self.raw().semantics.as_ref().and_then(|s| s.role)
211    }
212
213    /// Etiqueta **declarada** (`aria_label`). Ver [`name`](Self::name) para el
214    /// nombre accesible efectivo.
215    pub fn label(&self) -> Option<&'a str> {
216        self.raw()
217            .semantics
218            .as_ref()
219            .and_then(|s| s.label.as_deref())
220    }
221
222    /// Texto visible del nodo.
223    pub fn text(&self) -> Option<&'a str> {
224        self.raw().text.as_ref().map(|t| t.content.as_str())
225    }
226
227    /// **Nombre accesible**: el `aria_label` si está, si no el texto visible.
228    /// Réplica de la regla de `a11y::build_tree` — lo que el lector enuncia.
229    pub fn name(&self) -> Option<&'a str> {
230        self.label().or_else(|| self.text()).or_else(|| self.tooltip())
231    }
232
233    /// El tooltip declarado (`View::tooltip`). Es el último recurso de
234    /// [`Self::name`] y espeja lo que hace `llimphi_ui::a11y`: el tooltip de un
235    /// botón de sólo ícono **es** su nombre accesible.
236    pub fn tooltip(&self) -> Option<&'a str> {
237        self.raw().tooltip.as_deref()
238    }
239
240    /// Valor dinámico declarado (`aria_value`): texto de un input, valor de un
241    /// slider.
242    pub fn value(&self) -> Option<&'a str> {
243        self.raw()
244            .semantics
245            .as_ref()
246            .and_then(|s| s.value.as_deref())
247    }
248
249    /// Descripción declarada (`aria_description`).
250    pub fn description(&self) -> Option<&'a str> {
251        self.raw()
252            .semantics
253            .as_ref()
254            .and_then(|s| s.description.as_deref())
255    }
256
257    /// Rect absoluto del nodo tras el layout.
258    pub fn rect(&self) -> Rect {
259        self.ui
260            .computed
261            .get(self.raw().id)
262            .unwrap_or(Rect { x: 0.0, y: 0.0, w: 0.0, h: 0.0 })
263    }
264
265    fn flag(&self, f: impl Fn(&llimphi_compositor::SemanticsFlags) -> Option<bool>) -> Option<bool> {
266        self.raw().semantics.as_ref().and_then(|s| f(&s.flags))
267    }
268
269    pub fn is_checked(&self) -> Option<bool> {
270        self.flag(|f| f.checked)
271    }
272    pub fn is_pressed(&self) -> Option<bool> {
273        self.flag(|f| f.pressed)
274    }
275    pub fn is_expanded(&self) -> Option<bool> {
276        self.flag(|f| f.expanded)
277    }
278    pub fn is_readonly(&self) -> Option<bool> {
279        self.flag(|f| f.readonly)
280    }
281    pub fn is_required(&self) -> Option<bool> {
282        self.flag(|f| f.required)
283    }
284
285    /// `false` sólo si el nodo declaró `aria_disabled(true)`. Un nodo que no
286    /// declara nada se considera habilitado.
287    pub fn is_enabled(&self) -> bool {
288        self.flag(|f| f.disabled) != Some(true)
289    }
290
291    /// El nodo participa de la navegación por teclado.
292    pub fn is_focusable(&self) -> bool {
293        self.raw().focusable.is_some()
294    }
295
296    /// El nodo tiene algún handler de click izquierdo.
297    pub fn is_clickable(&self) -> bool {
298        let n = self.raw();
299        n.on_click.is_some() || n.on_click_at.is_some()
300    }
301
302    /// Simula un click en el **centro** del nodo y devuelve el `Msg` resultante.
303    ///
304    /// Respeta la prioridad del runtime (`eventloop::input`): `on_click_at`
305    /// gana sobre `on_click` cuando ambos existen. El handler `_at` recibe
306    /// `(local_x, local_y, rect_w, rect_h)` con el centro del rect — la misma
307    /// convención que documenta `View::on_click_at`.
308    ///
309    /// Devuelve `None` si el nodo no es clickeable o si el handler decidió no
310    /// emitir nada.
311    pub fn click(&self) -> Option<Msg> {
312        let n = self.raw();
313        let r = self.rect();
314        if let Some(f) = &n.on_click_at {
315            return f(r.w / 2.0, r.h / 2.0, r.w, r.h);
316        }
317        n.on_click.clone()
318    }
319
320    /// El `Msg` que emite al **entrar** el puntero, si escucha el gesto. Es lo
321    /// que enciende un tooltip; sin accesor no había forma de aseverarlo.
322    pub fn pointer_enter(&self) -> Option<Msg> {
323        self.raw().on_pointer_enter.clone()
324    }
325
326    /// El `Msg` que emite al **salir** el puntero (el que lo apaga).
327    pub fn pointer_leave(&self) -> Option<Msg> {
328        self.raw().on_pointer_leave.clone()
329    }
330
331    /// Simula un **doble-click** en el centro del nodo y devuelve el `Msg`.
332    ///
333    /// Misma prioridad que el runtime: `on_double_tap_at` gana sobre
334    /// `on_double_tap`. Devuelve `None` si el nodo no escucha el gesto — que es
335    /// lo que hay que poder aseverar cuando un widget lo ofrece opcional.
336    pub fn double_tap(&self) -> Option<Msg> {
337        let n = self.raw();
338        let r = self.rect();
339        if let Some(f) = &n.on_double_tap_at {
340            return f(r.w / 2.0, r.h / 2.0, r.w, r.h);
341        }
342        n.on_double_tap.clone()
343    }
344
345    /// Como [`click`](Self::click) pero en un punto local concreto.
346    pub fn click_at(&self, local_x: f32, local_y: f32) -> Option<Msg> {
347        let n = self.raw();
348        let r = self.rect();
349        if let Some(f) = &n.on_click_at {
350            return f(local_x, local_y, r.w, r.h);
351        }
352        n.on_click.clone()
353    }
354
355    /// Hijos directos, en orden de lectura.
356    pub fn children(&self) -> Vec<NodeRef<'a, Msg>> {
357        self.ui
358            .direct_children(self.idx)
359            .into_iter()
360            .map(|i| self.ui.node(i))
361            .collect()
362    }
363
364    /// Busca dentro de este subárbol (incluyéndose a sí mismo).
365    pub fn find(&self, sel: impl Into<Sel>) -> Option<NodeRef<'a, Msg>> {
366        let sel = sel.into();
367        let end = self.raw().subtree_end;
368        (self.idx..end)
369            .map(|i| self.ui.node(i))
370            .find(|n| sel.matches(n))
371    }
372
373    /// Una línea legible del nodo, la unidad de [`Ui::dump`].
374    fn line(&self) -> String {
375        let mut s = String::new();
376        match self.role() {
377            Some(r) => {
378                let _ = write!(s, "{r:?}");
379            }
380            None => s.push('·'),
381        }
382        if let Some(l) = self.label() {
383            let _ = write!(s, " {l:?}");
384        } else if let Some(t) = self.text() {
385            let _ = write!(s, " txt:{t:?}");
386        }
387        if let Some(v) = self.value() {
388            let _ = write!(s, " = {v:?}");
389        }
390        for (nombre, v) in [
391            ("checked", self.is_checked()),
392            ("pressed", self.is_pressed()),
393            ("expanded", self.is_expanded()),
394            ("readonly", self.is_readonly()),
395            ("required", self.is_required()),
396        ] {
397            if let Some(v) = v {
398                let _ = write!(s, " {nombre}={v}");
399            }
400        }
401        if !self.is_enabled() {
402            s.push_str(" disabled");
403        }
404        if self.is_clickable() {
405            s.push_str(" [click]");
406        }
407        if self.is_focusable() {
408            s.push_str(" [focus]");
409        }
410        let r = self.rect();
411        let _ = write!(s, "  @{:.0},{:.0} {:.0}×{:.0}", r.x, r.y, r.w, r.h);
412        s
413    }
414}
415
416/// Selector de nodos. Todos los criterios puestos deben cumplirse (AND).
417///
418/// Se construye con [`Sel::new`] y se encadena. Para los dos casos frecuentes
419/// hay conversiones directas: `ui.find(Role::Button)` y `ui.find("Guardar")`
420/// (esto último busca por nombre accesible).
421#[derive(Debug, Default, Clone)]
422pub struct Sel {
423    role: Option<Role>,
424    /// Nombre accesible exacto (label declarado, o texto si no hay label).
425    name: Option<String>,
426    /// Subcadena del nombre accesible.
427    name_contains: Option<String>,
428    value: Option<String>,
429    clickable: Option<bool>,
430    focusable: Option<bool>,
431    checked: Option<bool>,
432    enabled: Option<bool>,
433    /// Exige que el nodo **no** declare rol. Sirve para pescar cajas crudas
434    /// (un canvas con `on_click_at`) y, sobre todo, para auditar: un nodo
435    /// clickeable sin rol es exactamente lo que un lector de pantalla no sabe
436    /// anunciar.
437    role_none: bool,
438}
439
440impl Sel {
441    pub fn new() -> Self {
442        Self::default()
443    }
444
445    /// Exige este rol semántico.
446    pub fn role(mut self, role: Role) -> Self {
447        self.role = Some(role);
448        self
449    }
450
451    /// Exige que el nodo **no** declare rol semántico. Combinado con
452    /// `.clickable(true)` da la auditoría "qué es interactivo pero mudo".
453    pub fn role_none(mut self) -> Self {
454        self.role_none = true;
455        self
456    }
457
458    /// Exige este nombre accesible exacto (ver [`NodeRef::name`]).
459    pub fn label(mut self, name: impl Into<String>) -> Self {
460        self.name = Some(name.into());
461        self
462    }
463
464    /// Exige que el nombre accesible contenga esta subcadena.
465    pub fn label_contains(mut self, part: impl Into<String>) -> Self {
466        self.name_contains = Some(part.into());
467        self
468    }
469
470    /// Exige este `aria_value`.
471    pub fn value(mut self, v: impl Into<String>) -> Self {
472        self.value = Some(v.into());
473        self
474    }
475
476    /// Exige que el nodo tenga (o no) handler de click.
477    pub fn clickable(mut self, v: bool) -> Self {
478        self.clickable = Some(v);
479        self
480    }
481
482    pub fn focusable(mut self, v: bool) -> Self {
483        self.focusable = Some(v);
484        self
485    }
486
487    pub fn checked(mut self, v: bool) -> Self {
488        self.checked = Some(v);
489        self
490    }
491
492    pub fn enabled(mut self, v: bool) -> Self {
493        self.enabled = Some(v);
494        self
495    }
496
497    fn matches<Msg: Clone>(&self, n: &NodeRef<'_, Msg>) -> bool {
498        if let Some(r) = self.role {
499            if n.role() != Some(r) {
500                return false;
501            }
502        }
503        if self.role_none && n.role().is_some() {
504            return false;
505        }
506        if let Some(name) = &self.name {
507            if n.name() != Some(name.as_str()) {
508                return false;
509            }
510        }
511        if let Some(part) = &self.name_contains {
512            match n.name() {
513                Some(nm) if nm.contains(part.as_str()) => {}
514                _ => return false,
515            }
516        }
517        if let Some(v) = &self.value {
518            if n.value() != Some(v.as_str()) {
519                return false;
520            }
521        }
522        if let Some(c) = self.clickable {
523            if n.is_clickable() != c {
524                return false;
525            }
526        }
527        if let Some(f) = self.focusable {
528            if n.is_focusable() != f {
529                return false;
530            }
531        }
532        if let Some(c) = self.checked {
533            if n.is_checked() != Some(c) {
534                return false;
535            }
536        }
537        if let Some(e) = self.enabled {
538            if n.is_enabled() != e {
539                return false;
540            }
541        }
542        true
543    }
544}
545
546impl From<Role> for Sel {
547    fn from(r: Role) -> Self {
548        Sel::new().role(r)
549    }
550}
551
552impl From<&str> for Sel {
553    fn from(s: &str) -> Self {
554        Sel::new().label(s)
555    }
556}
557
558impl From<String> for Sel {
559    fn from(s: String) -> Self {
560        Sel::new().label(s)
561    }
562}
563
564#[cfg(test)]
565mod tests {
566    use super::*;
567    use llimphi_layout::taffy::prelude::length;
568    use llimphi_layout::taffy::Size;
569    use llimphi_layout::Style;
570    use llimphi_raster::peniko::Color;
571
572    #[derive(Clone, Debug, PartialEq)]
573    enum Msg {
574        Guardar,
575        Cancelar,
576        Punto(i32, i32),
577    }
578
579    fn caja(w: f32, h: f32) -> Style {
580        Style {
581            size: Size { width: length(w), height: length(h) },
582            ..Default::default()
583        }
584    }
585
586    /// Un formulario mínimo: dos botones (uno deshabilitado), un input con
587    /// valor, y un canvas con `on_click_at`.
588    fn formulario() -> View<Msg> {
589        let guardar = View::new(caja(80.0, 32.0))
590            .role(Role::Button)
591            .aria_label("Guardar")
592            .on_click(Msg::Guardar);
593        let cancelar = View::new(caja(80.0, 32.0))
594            .role(Role::Button)
595            .aria_label("Cancelar")
596            .aria_disabled(true)
597            .on_click(Msg::Cancelar);
598        let nombre = View::new(caja(200.0, 32.0))
599            .role(Role::TextInput)
600            .aria_label("Nombre")
601            .aria_value("sergio")
602            .aria_required(true);
603        let lienzo = View::new(caja(100.0, 100.0))
604            .on_click_at(|x, y, _w, _h| Some(Msg::Punto(x as i32, y as i32)));
605        let titulo = View::new(Style::default())
606            .role(Role::Heading)
607            .text("Datos", 18.0, Color::WHITE);
608        View::new(Style::default()).children(vec![titulo, nombre, guardar, cancelar, lienzo])
609    }
610
611    #[test]
612    fn encuentra_por_rol_y_por_nombre() {
613        let ui = Ui::new(formulario(), 400.0, 400.0);
614        assert_eq!(ui.find_all(Role::Button).len(), 2);
615        let g = ui.get(Sel::new().role(Role::Button).label("Guardar"));
616        assert_eq!(g.name(), Some("Guardar"));
617        // La conversión desde &str busca por nombre accesible.
618        assert!(ui.find("Cancelar").is_some());
619        assert!(ui.find("No existe").is_none());
620    }
621
622    #[test]
623    fn el_nombre_accesible_cae_al_texto_visible() {
624        let ui = Ui::new(formulario(), 400.0, 400.0);
625        // El heading no declara aria_label: su nombre sale del texto, igual
626        // que en a11y::build_tree.
627        let h = ui.get(Role::Heading);
628        assert_eq!(h.label(), None);
629        assert_eq!(h.text(), Some("Datos"));
630        assert_eq!(h.name(), Some("Datos"));
631    }
632
633    #[test]
634    fn flags_se_leen_como_los_declaro_el_widget() {
635        let ui = Ui::new(formulario(), 400.0, 400.0);
636        let n = ui.get(Role::TextInput);
637        assert_eq!(n.value(), Some("sergio"));
638        assert_eq!(n.is_required(), Some(true));
639        assert_eq!(n.is_readonly(), None, "no declarado ≠ declarado false");
640        assert!(ui.get("Cancelar").is_enabled() == false);
641        assert!(ui.get("Guardar").is_enabled());
642    }
643
644    #[test]
645    fn click_devuelve_el_msg_para_alimentar_update() {
646        let ui = Ui::new(formulario(), 400.0, 400.0);
647        assert_eq!(ui.get("Guardar").click(), Some(Msg::Guardar));
648    }
649
650    #[test]
651    fn click_en_nodo_at_usa_el_centro_del_rect() {
652        let ui = Ui::new(formulario(), 400.0, 400.0);
653        let lienzo = ui.get(Sel::new().clickable(true).role_none());
654        // 100×100 ⇒ centro (50, 50).
655        assert_eq!(lienzo.click(), Some(Msg::Punto(50, 50)));
656        assert_eq!(lienzo.click_at(10.0, 20.0), Some(Msg::Punto(10, 20)));
657    }
658
659    #[test]
660    fn el_layout_da_rects_reales() {
661        let ui = Ui::new(formulario(), 400.0, 400.0);
662        let r = ui.get("Guardar").rect();
663        assert_eq!((r.w, r.h), (80.0, 32.0));
664    }
665
666    #[test]
667    fn dump_es_evidencia_en_texto() {
668        let ui = Ui::new(formulario(), 400.0, 400.0);
669        let d = ui.dump();
670        assert!(d.contains("Button \"Guardar\" [click]"), "dump:\n{d}");
671        assert!(d.contains("disabled"), "dump:\n{d}");
672        assert!(d.contains("TextInput \"Nombre\" = \"sergio\""), "dump:\n{d}");
673        assert!(d.contains("Heading txt:\"Datos\""), "dump:\n{d}");
674    }
675
676    #[test]
677    #[should_panic(expected = "ningún nodo casa")]
678    fn get_paniquea_con_el_arbol_cuando_no_encuentra() {
679        let ui = Ui::new(formulario(), 400.0, 400.0);
680        ui.get("Fantasma");
681    }
682
683    #[test]
684    fn find_en_subarbol_no_sale_del_subarbol() {
685        let ui = Ui::new(formulario(), 400.0, 400.0);
686        let raiz = ui.root();
687        assert!(raiz.find("Guardar").is_some());
688        // Desde el botón Guardar no se ve Cancelar (es hermano, no descendiente).
689        let g = ui.get("Guardar");
690        assert!(g.find("Cancelar").is_none());
691    }
692
693    #[test]
694    fn children_devuelve_solo_hijos_directos() {
695        let ui = Ui::new(formulario(), 400.0, 400.0);
696        assert_eq!(ui.root().children().len(), 5);
697    }
698}