tawasuyu/llimphi
Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.
chore: refresco desde el monorepo (pin tawasuyu.git v0.3.0)
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}