tawasuyu/llimphi
Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.
chore: refresco desde el monorepo — vello 0.9 / wgpu 29, 120 miembros
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())
231 }
232
233 /// Valor dinámico declarado (`aria_value`): texto de un input, valor de un
234 /// slider.
235 pub fn value(&self) -> Option<&'a str> {
236 self.raw()
237 .semantics
238 .as_ref()
239 .and_then(|s| s.value.as_deref())
240 }
241
242 /// Descripción declarada (`aria_description`).
243 pub fn description(&self) -> Option<&'a str> {
244 self.raw()
245 .semantics
246 .as_ref()
247 .and_then(|s| s.description.as_deref())
248 }
249
250 /// Rect absoluto del nodo tras el layout.
251 pub fn rect(&self) -> Rect {
252 self.ui
253 .computed
254 .get(self.raw().id)
255 .unwrap_or(Rect { x: 0.0, y: 0.0, w: 0.0, h: 0.0 })
256 }
257
258 fn flag(&self, f: impl Fn(&llimphi_compositor::SemanticsFlags) -> Option<bool>) -> Option<bool> {
259 self.raw().semantics.as_ref().and_then(|s| f(&s.flags))
260 }
261
262 pub fn is_checked(&self) -> Option<bool> {
263 self.flag(|f| f.checked)
264 }
265 pub fn is_pressed(&self) -> Option<bool> {
266 self.flag(|f| f.pressed)
267 }
268 pub fn is_expanded(&self) -> Option<bool> {
269 self.flag(|f| f.expanded)
270 }
271 pub fn is_readonly(&self) -> Option<bool> {
272 self.flag(|f| f.readonly)
273 }
274 pub fn is_required(&self) -> Option<bool> {
275 self.flag(|f| f.required)
276 }
277
278 /// `false` sólo si el nodo declaró `aria_disabled(true)`. Un nodo que no
279 /// declara nada se considera habilitado.
280 pub fn is_enabled(&self) -> bool {
281 self.flag(|f| f.disabled) != Some(true)
282 }
283
284 /// El nodo participa de la navegación por teclado.
285 pub fn is_focusable(&self) -> bool {
286 self.raw().focusable.is_some()
287 }
288
289 /// El nodo tiene algún handler de click izquierdo.
290 pub fn is_clickable(&self) -> bool {
291 let n = self.raw();
292 n.on_click.is_some() || n.on_click_at.is_some()
293 }
294
295 /// Simula un click en el **centro** del nodo y devuelve el `Msg` resultante.
296 ///
297 /// Respeta la prioridad del runtime (`eventloop::input`): `on_click_at`
298 /// gana sobre `on_click` cuando ambos existen. El handler `_at` recibe
299 /// `(local_x, local_y, rect_w, rect_h)` con el centro del rect — la misma
300 /// convención que documenta `View::on_click_at`.
301 ///
302 /// Devuelve `None` si el nodo no es clickeable o si el handler decidió no
303 /// emitir nada.
304 pub fn click(&self) -> Option<Msg> {
305 let n = self.raw();
306 let r = self.rect();
307 if let Some(f) = &n.on_click_at {
308 return f(r.w / 2.0, r.h / 2.0, r.w, r.h);
309 }
310 n.on_click.clone()
311 }
312
313 /// El `Msg` que emite al **entrar** el puntero, si escucha el gesto. Es lo
314 /// que enciende un tooltip; sin accesor no había forma de aseverarlo.
315 pub fn pointer_enter(&self) -> Option<Msg> {
316 self.raw().on_pointer_enter.clone()
317 }
318
319 /// El `Msg` que emite al **salir** el puntero (el que lo apaga).
320 pub fn pointer_leave(&self) -> Option<Msg> {
321 self.raw().on_pointer_leave.clone()
322 }
323
324 /// Simula un **doble-click** en el centro del nodo y devuelve el `Msg`.
325 ///
326 /// Misma prioridad que el runtime: `on_double_tap_at` gana sobre
327 /// `on_double_tap`. Devuelve `None` si el nodo no escucha el gesto — que es
328 /// lo que hay que poder aseverar cuando un widget lo ofrece opcional.
329 pub fn double_tap(&self) -> Option<Msg> {
330 let n = self.raw();
331 let r = self.rect();
332 if let Some(f) = &n.on_double_tap_at {
333 return f(r.w / 2.0, r.h / 2.0, r.w, r.h);
334 }
335 n.on_double_tap.clone()
336 }
337
338 /// Como [`click`](Self::click) pero en un punto local concreto.
339 pub fn click_at(&self, local_x: f32, local_y: f32) -> Option<Msg> {
340 let n = self.raw();
341 let r = self.rect();
342 if let Some(f) = &n.on_click_at {
343 return f(local_x, local_y, r.w, r.h);
344 }
345 n.on_click.clone()
346 }
347
348 /// Hijos directos, en orden de lectura.
349 pub fn children(&self) -> Vec<NodeRef<'a, Msg>> {
350 self.ui
351 .direct_children(self.idx)
352 .into_iter()
353 .map(|i| self.ui.node(i))
354 .collect()
355 }
356
357 /// Busca dentro de este subárbol (incluyéndose a sí mismo).
358 pub fn find(&self, sel: impl Into<Sel>) -> Option<NodeRef<'a, Msg>> {
359 let sel = sel.into();
360 let end = self.raw().subtree_end;
361 (self.idx..end)
362 .map(|i| self.ui.node(i))
363 .find(|n| sel.matches(n))
364 }
365
366 /// Una línea legible del nodo, la unidad de [`Ui::dump`].
367 fn line(&self) -> String {
368 let mut s = String::new();
369 match self.role() {
370 Some(r) => {
371 let _ = write!(s, "{r:?}");
372 }
373 None => s.push('·'),
374 }
375 if let Some(l) = self.label() {
376 let _ = write!(s, " {l:?}");
377 } else if let Some(t) = self.text() {
378 let _ = write!(s, " txt:{t:?}");
379 }
380 if let Some(v) = self.value() {
381 let _ = write!(s, " = {v:?}");
382 }
383 for (nombre, v) in [
384 ("checked", self.is_checked()),
385 ("pressed", self.is_pressed()),
386 ("expanded", self.is_expanded()),
387 ("readonly", self.is_readonly()),
388 ("required", self.is_required()),
389 ] {
390 if let Some(v) = v {
391 let _ = write!(s, " {nombre}={v}");
392 }
393 }
394 if !self.is_enabled() {
395 s.push_str(" disabled");
396 }
397 if self.is_clickable() {
398 s.push_str(" [click]");
399 }
400 if self.is_focusable() {
401 s.push_str(" [focus]");
402 }
403 let r = self.rect();
404 let _ = write!(s, " @{:.0},{:.0} {:.0}×{:.0}", r.x, r.y, r.w, r.h);
405 s
406 }
407}
408
409/// Selector de nodos. Todos los criterios puestos deben cumplirse (AND).
410///
411/// Se construye con [`Sel::new`] y se encadena. Para los dos casos frecuentes
412/// hay conversiones directas: `ui.find(Role::Button)` y `ui.find("Guardar")`
413/// (esto último busca por nombre accesible).
414#[derive(Debug, Default, Clone)]
415pub struct Sel {
416 role: Option<Role>,
417 /// Nombre accesible exacto (label declarado, o texto si no hay label).
418 name: Option<String>,
419 /// Subcadena del nombre accesible.
420 name_contains: Option<String>,
421 value: Option<String>,
422 clickable: Option<bool>,
423 focusable: Option<bool>,
424 checked: Option<bool>,
425 enabled: Option<bool>,
426 /// Exige que el nodo **no** declare rol. Sirve para pescar cajas crudas
427 /// (un canvas con `on_click_at`) y, sobre todo, para auditar: un nodo
428 /// clickeable sin rol es exactamente lo que un lector de pantalla no sabe
429 /// anunciar.
430 role_none: bool,
431}
432
433impl Sel {
434 pub fn new() -> Self {
435 Self::default()
436 }
437
438 /// Exige este rol semántico.
439 pub fn role(mut self, role: Role) -> Self {
440 self.role = Some(role);
441 self
442 }
443
444 /// Exige que el nodo **no** declare rol semántico. Combinado con
445 /// `.clickable(true)` da la auditoría "qué es interactivo pero mudo".
446 pub fn role_none(mut self) -> Self {
447 self.role_none = true;
448 self
449 }
450
451 /// Exige este nombre accesible exacto (ver [`NodeRef::name`]).
452 pub fn label(mut self, name: impl Into<String>) -> Self {
453 self.name = Some(name.into());
454 self
455 }
456
457 /// Exige que el nombre accesible contenga esta subcadena.
458 pub fn label_contains(mut self, part: impl Into<String>) -> Self {
459 self.name_contains = Some(part.into());
460 self
461 }
462
463 /// Exige este `aria_value`.
464 pub fn value(mut self, v: impl Into<String>) -> Self {
465 self.value = Some(v.into());
466 self
467 }
468
469 /// Exige que el nodo tenga (o no) handler de click.
470 pub fn clickable(mut self, v: bool) -> Self {
471 self.clickable = Some(v);
472 self
473 }
474
475 pub fn focusable(mut self, v: bool) -> Self {
476 self.focusable = Some(v);
477 self
478 }
479
480 pub fn checked(mut self, v: bool) -> Self {
481 self.checked = Some(v);
482 self
483 }
484
485 pub fn enabled(mut self, v: bool) -> Self {
486 self.enabled = Some(v);
487 self
488 }
489
490 fn matches<Msg: Clone>(&self, n: &NodeRef<'_, Msg>) -> bool {
491 if let Some(r) = self.role {
492 if n.role() != Some(r) {
493 return false;
494 }
495 }
496 if self.role_none && n.role().is_some() {
497 return false;
498 }
499 if let Some(name) = &self.name {
500 if n.name() != Some(name.as_str()) {
501 return false;
502 }
503 }
504 if let Some(part) = &self.name_contains {
505 match n.name() {
506 Some(nm) if nm.contains(part.as_str()) => {}
507 _ => return false,
508 }
509 }
510 if let Some(v) = &self.value {
511 if n.value() != Some(v.as_str()) {
512 return false;
513 }
514 }
515 if let Some(c) = self.clickable {
516 if n.is_clickable() != c {
517 return false;
518 }
519 }
520 if let Some(f) = self.focusable {
521 if n.is_focusable() != f {
522 return false;
523 }
524 }
525 if let Some(c) = self.checked {
526 if n.is_checked() != Some(c) {
527 return false;
528 }
529 }
530 if let Some(e) = self.enabled {
531 if n.is_enabled() != e {
532 return false;
533 }
534 }
535 true
536 }
537}
538
539impl From<Role> for Sel {
540 fn from(r: Role) -> Self {
541 Sel::new().role(r)
542 }
543}
544
545impl From<&str> for Sel {
546 fn from(s: &str) -> Self {
547 Sel::new().label(s)
548 }
549}
550
551impl From<String> for Sel {
552 fn from(s: String) -> Self {
553 Sel::new().label(s)
554 }
555}
556
557#[cfg(test)]
558mod tests {
559 use super::*;
560 use llimphi_layout::taffy::prelude::length;
561 use llimphi_layout::taffy::Size;
562 use llimphi_layout::Style;
563 use llimphi_raster::peniko::Color;
564
565 #[derive(Clone, Debug, PartialEq)]
566 enum Msg {
567 Guardar,
568 Cancelar,
569 Punto(i32, i32),
570 }
571
572 fn caja(w: f32, h: f32) -> Style {
573 Style {
574 size: Size { width: length(w), height: length(h) },
575 ..Default::default()
576 }
577 }
578
579 /// Un formulario mínimo: dos botones (uno deshabilitado), un input con
580 /// valor, y un canvas con `on_click_at`.
581 fn formulario() -> View<Msg> {
582 let guardar = View::new(caja(80.0, 32.0))
583 .role(Role::Button)
584 .aria_label("Guardar")
585 .on_click(Msg::Guardar);
586 let cancelar = View::new(caja(80.0, 32.0))
587 .role(Role::Button)
588 .aria_label("Cancelar")
589 .aria_disabled(true)
590 .on_click(Msg::Cancelar);
591 let nombre = View::new(caja(200.0, 32.0))
592 .role(Role::TextInput)
593 .aria_label("Nombre")
594 .aria_value("sergio")
595 .aria_required(true);
596 let lienzo = View::new(caja(100.0, 100.0))
597 .on_click_at(|x, y, _w, _h| Some(Msg::Punto(x as i32, y as i32)));
598 let titulo = View::new(Style::default())
599 .role(Role::Heading)
600 .text("Datos", 18.0, Color::WHITE);
601 View::new(Style::default()).children(vec![titulo, nombre, guardar, cancelar, lienzo])
602 }
603
604 #[test]
605 fn encuentra_por_rol_y_por_nombre() {
606 let ui = Ui::new(formulario(), 400.0, 400.0);
607 assert_eq!(ui.find_all(Role::Button).len(), 2);
608 let g = ui.get(Sel::new().role(Role::Button).label("Guardar"));
609 assert_eq!(g.name(), Some("Guardar"));
610 // La conversión desde &str busca por nombre accesible.
611 assert!(ui.find("Cancelar").is_some());
612 assert!(ui.find("No existe").is_none());
613 }
614
615 #[test]
616 fn el_nombre_accesible_cae_al_texto_visible() {
617 let ui = Ui::new(formulario(), 400.0, 400.0);
618 // El heading no declara aria_label: su nombre sale del texto, igual
619 // que en a11y::build_tree.
620 let h = ui.get(Role::Heading);
621 assert_eq!(h.label(), None);
622 assert_eq!(h.text(), Some("Datos"));
623 assert_eq!(h.name(), Some("Datos"));
624 }
625
626 #[test]
627 fn flags_se_leen_como_los_declaro_el_widget() {
628 let ui = Ui::new(formulario(), 400.0, 400.0);
629 let n = ui.get(Role::TextInput);
630 assert_eq!(n.value(), Some("sergio"));
631 assert_eq!(n.is_required(), Some(true));
632 assert_eq!(n.is_readonly(), None, "no declarado ≠ declarado false");
633 assert!(ui.get("Cancelar").is_enabled() == false);
634 assert!(ui.get("Guardar").is_enabled());
635 }
636
637 #[test]
638 fn click_devuelve_el_msg_para_alimentar_update() {
639 let ui = Ui::new(formulario(), 400.0, 400.0);
640 assert_eq!(ui.get("Guardar").click(), Some(Msg::Guardar));
641 }
642
643 #[test]
644 fn click_en_nodo_at_usa_el_centro_del_rect() {
645 let ui = Ui::new(formulario(), 400.0, 400.0);
646 let lienzo = ui.get(Sel::new().clickable(true).role_none());
647 // 100×100 ⇒ centro (50, 50).
648 assert_eq!(lienzo.click(), Some(Msg::Punto(50, 50)));
649 assert_eq!(lienzo.click_at(10.0, 20.0), Some(Msg::Punto(10, 20)));
650 }
651
652 #[test]
653 fn el_layout_da_rects_reales() {
654 let ui = Ui::new(formulario(), 400.0, 400.0);
655 let r = ui.get("Guardar").rect();
656 assert_eq!((r.w, r.h), (80.0, 32.0));
657 }
658
659 #[test]
660 fn dump_es_evidencia_en_texto() {
661 let ui = Ui::new(formulario(), 400.0, 400.0);
662 let d = ui.dump();
663 assert!(d.contains("Button \"Guardar\" [click]"), "dump:\n{d}");
664 assert!(d.contains("disabled"), "dump:\n{d}");
665 assert!(d.contains("TextInput \"Nombre\" = \"sergio\""), "dump:\n{d}");
666 assert!(d.contains("Heading txt:\"Datos\""), "dump:\n{d}");
667 }
668
669 #[test]
670 #[should_panic(expected = "ningún nodo casa")]
671 fn get_paniquea_con_el_arbol_cuando_no_encuentra() {
672 let ui = Ui::new(formulario(), 400.0, 400.0);
673 ui.get("Fantasma");
674 }
675
676 #[test]
677 fn find_en_subarbol_no_sale_del_subarbol() {
678 let ui = Ui::new(formulario(), 400.0, 400.0);
679 let raiz = ui.root();
680 assert!(raiz.find("Guardar").is_some());
681 // Desde el botón Guardar no se ve Cancelar (es hermano, no descendiente).
682 let g = ui.get("Guardar");
683 assert!(g.find("Cancelar").is_none());
684 }
685
686 #[test]
687 fn children_devuelve_solo_hijos_directos() {
688 let ui = Ui::new(formulario(), 400.0, 400.0);
689 assert_eq!(ui.root().children().len(), 5);
690 }
691}