//! # Consultas semánticas sobre un `View` montado //! //! El complemento barato de [`assert_golden!`](crate::assert_golden): en vez de //! diffear píxeles, monta el árbol y **pregunta por su significado** — //! "¿existe un botón etiquetado *Guardar*?", "¿el switch está en on?", "¿qué //! `Msg` emite ese ítem si lo clickeo?". //! //! Es la misma fuente de verdad que alimenta a los lectores de pantalla: el //! `SemanticsSpec` que los widgets declaran con `View::role` / `View::aria_*`, //! traducido cada frame a AccessKit por `llimphi-ui::a11y`. Un test que //! encuentra el botón por rol+label está certificando, de paso, que un usuario //! de Orca también lo encuentra. //! //! ## Por qué esto y no un golden //! //! Un golden PNG certifica *cómo se ve*; se rompe con cualquier cambio de tema, //! de fuente o de padding, y para leerlo hay que mirarlo (caro en tokens, ver //! CLAUDE.md §8). Una consulta certifica *qué hay y qué hace*: sobrevive al //! rediseño, falla con un mensaje en texto y corre sin GPU. //! //! **No necesita adapter de wgpu.** [`Ui::new`] sólo hace `mount` + layout — //! CPU pura. A diferencia de [`Harness`](crate::Harness), anda en cualquier CI //! sin lavapipe. //! //! ## Uso //! //! ```ignore //! use llimphi_test::query::{Sel, Ui}; //! use llimphi_compositor::Role; //! //! let ui = Ui::new(mi_app.view(), 800.0, 600.0); //! //! // Buscar por rol, por nombre accesible, o por ambos. //! assert!(ui.find(Role::Button).is_some()); //! let guardar = ui.get(Sel::new().role(Role::Button).label("Guardar")); //! assert!(guardar.is_enabled()); //! //! // Clickear = obtener el Msg y pasárselo al update de la app. //! let msg = guardar.click().expect("el botón emite Msg"); //! mi_app.update(msg); //! ``` //! //! Cuando algo no aparece, [`Ui::get`] paniquea con el árbol semántico entero //! ([`Ui::dump`]) en el mensaje — el "qué esperaba vs qué hay" viene servido. use std::cell::RefCell; use std::fmt::Write as _; use llimphi_compositor::{measure_text_node, mount, Mounted, Role, View}; use llimphi_layout::taffy; use llimphi_layout::{ComputedLayout, LayoutTree, Rect}; use llimphi_text::Typesetter; thread_local! { /// Un `Typesetter` por hilo: cargar fuentes es caro y `Ui::new` se llama /// una vez por assert. Mismo criterio que [`crate::with_harness`]. static TS: RefCell = RefCell::new(Typesetter::new()); } /// Un árbol montado + su layout, listo para interrogar. /// /// Se construye con [`Ui::new`] y no toca la GPU. Mantiene el `Mounted` vivo, /// así que los `Msg` de los handlers se pueden clonar fuera. pub struct Ui { mounted: Mounted, computed: ComputedLayout, } impl Ui { /// Monta `view` y le corre el layout contra un viewport `w×h`. /// /// Usa `compute_with_measure` (el mismo camino que las apps reales), así /// que los rects de texto son los de verdad — un párrafo que envuelve a /// tres líneas mide tres líneas, no una. pub fn new(view: View, w: f32, h: f32) -> Self { let mut layout = LayoutTree::new(); let mounted = mount(&mut layout, view); let computed = TS.with(|ts| { let ts = &mut *ts.borrow_mut(); let tmap = &mounted.text_measures; layout .compute_with_measure(mounted.root, (w, h), |nid, known, avail| { match tmap.get(&nid) { Some(tm) => measure_text_node(ts, tm, known, avail), None => taffy::Size::ZERO, } }) .expect("compute_with_measure") }); Self { mounted, computed } } /// Cantidad de nodos montados. pub fn len(&self) -> usize { self.mounted.nodes.len() } pub fn is_empty(&self) -> bool { self.mounted.nodes.is_empty() } /// Nodo por índice de montaje (0 = raíz del `View`). pub fn node(&self, idx: usize) -> NodeRef<'_, Msg> { NodeRef { ui: self, idx } } /// Raíz del árbol. pub fn root(&self) -> NodeRef<'_, Msg> { self.node(0) } /// Todos los nodos en orden de montaje (pre-orden = orden de lectura). pub fn all(&self) -> impl Iterator> { (0..self.len()).map(move |idx| self.node(idx)) } /// Todos los nodos que casan con el selector, en orden de lectura. pub fn find_all(&self, sel: impl Into) -> Vec> { let sel = sel.into(); self.all().filter(|n| sel.matches(n)).collect() } /// El primer nodo que casa, o `None`. pub fn find(&self, sel: impl Into) -> Option> { let sel = sel.into(); self.all().find(|n| sel.matches(n)) } /// Como [`find`](Self::find) pero paniquea si no hay exactamente uno, /// mostrando el árbol semántico completo. Es la forma pensada para tests: /// el fallo se lee sin abrir un PNG. pub fn get(&self, sel: impl Into) -> NodeRef<'_, Msg> { let sel = sel.into(); let hits = self.find_all(sel.clone()); match hits.len() { 1 => hits.into_iter().next().unwrap(), 0 => panic!( "ningún nodo casa con {sel:?}\n\n--- árbol semántico ---\n{}", self.dump() ), n => panic!( "{n} nodos casan con {sel:?} (esperaba 1; usá find_all si es a propósito)\n\n--- árbol semántico ---\n{}", self.dump() ), } } /// Índices de los hijos **directos** del nodo `idx`. /// /// Réplica exacta de `llimphi_ui::a11y::direct_children`: el montaje es /// pre-orden, el primer hijo está en `idx + 1` y los siguientes se /// alcanzan saltando por `subtree_end`. fn direct_children(&self, idx: usize) -> Vec { let parent = &self.mounted.nodes[idx]; let mut out = Vec::new(); let mut cursor = idx + 1; while cursor < parent.subtree_end { out.push(cursor); cursor = self.mounted.nodes[cursor].subtree_end; } out } /// El árbol semántico como texto indentado. **Ésta es la evidencia**: se /// pega en un test, en un mensaje de fallo o en un reporte sin renderizar /// un solo píxel. /// /// Los nodos sin rol ni nombre ni handlers se colapsan a `·` para que el /// volcado sea legible (una app real tiene cientos de cajas de layout). pub fn dump(&self) -> String { let mut s = String::new(); self.dump_into(&mut s, 0, 0); s } fn dump_into(&self, out: &mut String, idx: usize, depth: usize) { let n = self.node(idx); let _ = writeln!(out, "{:indent$}{}", "", n.line(), indent = depth * 2); for c in self.direct_children(idx) { self.dump_into(out, c, depth + 1); } } } /// Referencia a un nodo montado. Barata de copiar, atada al [`Ui`]. pub struct NodeRef<'a, Msg> { ui: &'a Ui, idx: usize, } impl Clone for NodeRef<'_, Msg> { fn clone(&self) -> Self { *self } } impl Copy for NodeRef<'_, Msg> {} impl<'a, Msg: Clone> NodeRef<'a, Msg> { /// Índice de montaje. Coincide con el que usa `llimphi_ui::a11y::node_id_for`. pub fn idx(&self) -> usize { self.idx } fn raw(&self) -> &'a llimphi_compositor::MountedNode { &self.ui.mounted.nodes[self.idx] } /// Rol declarado, o `None` si el nodo es una caja de layout sin semántica. pub fn role(&self) -> Option { self.raw().semantics.as_ref().and_then(|s| s.role) } /// Etiqueta **declarada** (`aria_label`). Ver [`name`](Self::name) para el /// nombre accesible efectivo. pub fn label(&self) -> Option<&'a str> { self.raw() .semantics .as_ref() .and_then(|s| s.label.as_deref()) } /// Texto visible del nodo. pub fn text(&self) -> Option<&'a str> { self.raw().text.as_ref().map(|t| t.content.as_str()) } /// **Nombre accesible**: el `aria_label` si está, si no el texto visible. /// Réplica de la regla de `a11y::build_tree` — lo que el lector enuncia. pub fn name(&self) -> Option<&'a str> { self.label().or_else(|| self.text()) } /// Valor dinámico declarado (`aria_value`): texto de un input, valor de un /// slider. pub fn value(&self) -> Option<&'a str> { self.raw() .semantics .as_ref() .and_then(|s| s.value.as_deref()) } /// Descripción declarada (`aria_description`). pub fn description(&self) -> Option<&'a str> { self.raw() .semantics .as_ref() .and_then(|s| s.description.as_deref()) } /// Rect absoluto del nodo tras el layout. pub fn rect(&self) -> Rect { self.ui .computed .get(self.raw().id) .unwrap_or(Rect { x: 0.0, y: 0.0, w: 0.0, h: 0.0 }) } fn flag(&self, f: impl Fn(&llimphi_compositor::SemanticsFlags) -> Option) -> Option { self.raw().semantics.as_ref().and_then(|s| f(&s.flags)) } pub fn is_checked(&self) -> Option { self.flag(|f| f.checked) } pub fn is_pressed(&self) -> Option { self.flag(|f| f.pressed) } pub fn is_expanded(&self) -> Option { self.flag(|f| f.expanded) } pub fn is_readonly(&self) -> Option { self.flag(|f| f.readonly) } pub fn is_required(&self) -> Option { self.flag(|f| f.required) } /// `false` sólo si el nodo declaró `aria_disabled(true)`. Un nodo que no /// declara nada se considera habilitado. pub fn is_enabled(&self) -> bool { self.flag(|f| f.disabled) != Some(true) } /// El nodo participa de la navegación por teclado. pub fn is_focusable(&self) -> bool { self.raw().focusable.is_some() } /// El nodo tiene algún handler de click izquierdo. pub fn is_clickable(&self) -> bool { let n = self.raw(); n.on_click.is_some() || n.on_click_at.is_some() } /// Simula un click en el **centro** del nodo y devuelve el `Msg` resultante. /// /// Respeta la prioridad del runtime (`eventloop::input`): `on_click_at` /// gana sobre `on_click` cuando ambos existen. El handler `_at` recibe /// `(local_x, local_y, rect_w, rect_h)` con el centro del rect — la misma /// convención que documenta `View::on_click_at`. /// /// Devuelve `None` si el nodo no es clickeable o si el handler decidió no /// emitir nada. pub fn click(&self) -> Option { let n = self.raw(); let r = self.rect(); if let Some(f) = &n.on_click_at { return f(r.w / 2.0, r.h / 2.0, r.w, r.h); } n.on_click.clone() } /// El `Msg` que emite al **entrar** el puntero, si escucha el gesto. Es lo /// que enciende un tooltip; sin accesor no había forma de aseverarlo. pub fn pointer_enter(&self) -> Option { self.raw().on_pointer_enter.clone() } /// El `Msg` que emite al **salir** el puntero (el que lo apaga). pub fn pointer_leave(&self) -> Option { self.raw().on_pointer_leave.clone() } /// Simula un **doble-click** en el centro del nodo y devuelve el `Msg`. /// /// Misma prioridad que el runtime: `on_double_tap_at` gana sobre /// `on_double_tap`. Devuelve `None` si el nodo no escucha el gesto — que es /// lo que hay que poder aseverar cuando un widget lo ofrece opcional. pub fn double_tap(&self) -> Option { let n = self.raw(); let r = self.rect(); if let Some(f) = &n.on_double_tap_at { return f(r.w / 2.0, r.h / 2.0, r.w, r.h); } n.on_double_tap.clone() } /// Como [`click`](Self::click) pero en un punto local concreto. pub fn click_at(&self, local_x: f32, local_y: f32) -> Option { let n = self.raw(); let r = self.rect(); if let Some(f) = &n.on_click_at { return f(local_x, local_y, r.w, r.h); } n.on_click.clone() } /// Hijos directos, en orden de lectura. pub fn children(&self) -> Vec> { self.ui .direct_children(self.idx) .into_iter() .map(|i| self.ui.node(i)) .collect() } /// Busca dentro de este subárbol (incluyéndose a sí mismo). pub fn find(&self, sel: impl Into) -> Option> { let sel = sel.into(); let end = self.raw().subtree_end; (self.idx..end) .map(|i| self.ui.node(i)) .find(|n| sel.matches(n)) } /// Una línea legible del nodo, la unidad de [`Ui::dump`]. fn line(&self) -> String { let mut s = String::new(); match self.role() { Some(r) => { let _ = write!(s, "{r:?}"); } None => s.push('·'), } if let Some(l) = self.label() { let _ = write!(s, " {l:?}"); } else if let Some(t) = self.text() { let _ = write!(s, " txt:{t:?}"); } if let Some(v) = self.value() { let _ = write!(s, " = {v:?}"); } for (nombre, v) in [ ("checked", self.is_checked()), ("pressed", self.is_pressed()), ("expanded", self.is_expanded()), ("readonly", self.is_readonly()), ("required", self.is_required()), ] { if let Some(v) = v { let _ = write!(s, " {nombre}={v}"); } } if !self.is_enabled() { s.push_str(" disabled"); } if self.is_clickable() { s.push_str(" [click]"); } if self.is_focusable() { s.push_str(" [focus]"); } let r = self.rect(); let _ = write!(s, " @{:.0},{:.0} {:.0}×{:.0}", r.x, r.y, r.w, r.h); s } } /// Selector de nodos. Todos los criterios puestos deben cumplirse (AND). /// /// Se construye con [`Sel::new`] y se encadena. Para los dos casos frecuentes /// hay conversiones directas: `ui.find(Role::Button)` y `ui.find("Guardar")` /// (esto último busca por nombre accesible). #[derive(Debug, Default, Clone)] pub struct Sel { role: Option, /// Nombre accesible exacto (label declarado, o texto si no hay label). name: Option, /// Subcadena del nombre accesible. name_contains: Option, value: Option, clickable: Option, focusable: Option, checked: Option, enabled: Option, /// Exige que el nodo **no** declare rol. Sirve para pescar cajas crudas /// (un canvas con `on_click_at`) y, sobre todo, para auditar: un nodo /// clickeable sin rol es exactamente lo que un lector de pantalla no sabe /// anunciar. role_none: bool, } impl Sel { pub fn new() -> Self { Self::default() } /// Exige este rol semántico. pub fn role(mut self, role: Role) -> Self { self.role = Some(role); self } /// Exige que el nodo **no** declare rol semántico. Combinado con /// `.clickable(true)` da la auditoría "qué es interactivo pero mudo". pub fn role_none(mut self) -> Self { self.role_none = true; self } /// Exige este nombre accesible exacto (ver [`NodeRef::name`]). pub fn label(mut self, name: impl Into) -> Self { self.name = Some(name.into()); self } /// Exige que el nombre accesible contenga esta subcadena. pub fn label_contains(mut self, part: impl Into) -> Self { self.name_contains = Some(part.into()); self } /// Exige este `aria_value`. pub fn value(mut self, v: impl Into) -> Self { self.value = Some(v.into()); self } /// Exige que el nodo tenga (o no) handler de click. pub fn clickable(mut self, v: bool) -> Self { self.clickable = Some(v); self } pub fn focusable(mut self, v: bool) -> Self { self.focusable = Some(v); self } pub fn checked(mut self, v: bool) -> Self { self.checked = Some(v); self } pub fn enabled(mut self, v: bool) -> Self { self.enabled = Some(v); self } fn matches(&self, n: &NodeRef<'_, Msg>) -> bool { if let Some(r) = self.role { if n.role() != Some(r) { return false; } } if self.role_none && n.role().is_some() { return false; } if let Some(name) = &self.name { if n.name() != Some(name.as_str()) { return false; } } if let Some(part) = &self.name_contains { match n.name() { Some(nm) if nm.contains(part.as_str()) => {} _ => return false, } } if let Some(v) = &self.value { if n.value() != Some(v.as_str()) { return false; } } if let Some(c) = self.clickable { if n.is_clickable() != c { return false; } } if let Some(f) = self.focusable { if n.is_focusable() != f { return false; } } if let Some(c) = self.checked { if n.is_checked() != Some(c) { return false; } } if let Some(e) = self.enabled { if n.is_enabled() != e { return false; } } true } } impl From for Sel { fn from(r: Role) -> Self { Sel::new().role(r) } } impl From<&str> for Sel { fn from(s: &str) -> Self { Sel::new().label(s) } } impl From for Sel { fn from(s: String) -> Self { Sel::new().label(s) } } #[cfg(test)] mod tests { use super::*; use llimphi_layout::taffy::prelude::length; use llimphi_layout::taffy::Size; use llimphi_layout::Style; use llimphi_raster::peniko::Color; #[derive(Clone, Debug, PartialEq)] enum Msg { Guardar, Cancelar, Punto(i32, i32), } fn caja(w: f32, h: f32) -> Style { Style { size: Size { width: length(w), height: length(h) }, ..Default::default() } } /// Un formulario mínimo: dos botones (uno deshabilitado), un input con /// valor, y un canvas con `on_click_at`. fn formulario() -> View { let guardar = View::new(caja(80.0, 32.0)) .role(Role::Button) .aria_label("Guardar") .on_click(Msg::Guardar); let cancelar = View::new(caja(80.0, 32.0)) .role(Role::Button) .aria_label("Cancelar") .aria_disabled(true) .on_click(Msg::Cancelar); let nombre = View::new(caja(200.0, 32.0)) .role(Role::TextInput) .aria_label("Nombre") .aria_value("sergio") .aria_required(true); let lienzo = View::new(caja(100.0, 100.0)) .on_click_at(|x, y, _w, _h| Some(Msg::Punto(x as i32, y as i32))); let titulo = View::new(Style::default()) .role(Role::Heading) .text("Datos", 18.0, Color::WHITE); View::new(Style::default()).children(vec![titulo, nombre, guardar, cancelar, lienzo]) } #[test] fn encuentra_por_rol_y_por_nombre() { let ui = Ui::new(formulario(), 400.0, 400.0); assert_eq!(ui.find_all(Role::Button).len(), 2); let g = ui.get(Sel::new().role(Role::Button).label("Guardar")); assert_eq!(g.name(), Some("Guardar")); // La conversión desde &str busca por nombre accesible. assert!(ui.find("Cancelar").is_some()); assert!(ui.find("No existe").is_none()); } #[test] fn el_nombre_accesible_cae_al_texto_visible() { let ui = Ui::new(formulario(), 400.0, 400.0); // El heading no declara aria_label: su nombre sale del texto, igual // que en a11y::build_tree. let h = ui.get(Role::Heading); assert_eq!(h.label(), None); assert_eq!(h.text(), Some("Datos")); assert_eq!(h.name(), Some("Datos")); } #[test] fn flags_se_leen_como_los_declaro_el_widget() { let ui = Ui::new(formulario(), 400.0, 400.0); let n = ui.get(Role::TextInput); assert_eq!(n.value(), Some("sergio")); assert_eq!(n.is_required(), Some(true)); assert_eq!(n.is_readonly(), None, "no declarado ≠ declarado false"); assert!(ui.get("Cancelar").is_enabled() == false); assert!(ui.get("Guardar").is_enabled()); } #[test] fn click_devuelve_el_msg_para_alimentar_update() { let ui = Ui::new(formulario(), 400.0, 400.0); assert_eq!(ui.get("Guardar").click(), Some(Msg::Guardar)); } #[test] fn click_en_nodo_at_usa_el_centro_del_rect() { let ui = Ui::new(formulario(), 400.0, 400.0); let lienzo = ui.get(Sel::new().clickable(true).role_none()); // 100×100 ⇒ centro (50, 50). assert_eq!(lienzo.click(), Some(Msg::Punto(50, 50))); assert_eq!(lienzo.click_at(10.0, 20.0), Some(Msg::Punto(10, 20))); } #[test] fn el_layout_da_rects_reales() { let ui = Ui::new(formulario(), 400.0, 400.0); let r = ui.get("Guardar").rect(); assert_eq!((r.w, r.h), (80.0, 32.0)); } #[test] fn dump_es_evidencia_en_texto() { let ui = Ui::new(formulario(), 400.0, 400.0); let d = ui.dump(); assert!(d.contains("Button \"Guardar\" [click]"), "dump:\n{d}"); assert!(d.contains("disabled"), "dump:\n{d}"); assert!(d.contains("TextInput \"Nombre\" = \"sergio\""), "dump:\n{d}"); assert!(d.contains("Heading txt:\"Datos\""), "dump:\n{d}"); } #[test] #[should_panic(expected = "ningún nodo casa")] fn get_paniquea_con_el_arbol_cuando_no_encuentra() { let ui = Ui::new(formulario(), 400.0, 400.0); ui.get("Fantasma"); } #[test] fn find_en_subarbol_no_sale_del_subarbol() { let ui = Ui::new(formulario(), 400.0, 400.0); let raiz = ui.root(); assert!(raiz.find("Guardar").is_some()); // Desde el botón Guardar no se ve Cancelar (es hermano, no descendiente). let g = ui.get("Guardar"); assert!(g.find("Cancelar").is_none()); } #[test] fn children_devuelve_solo_hijos_directos() { let ui = Ui::new(formulario(), 400.0, 400.0); assert_eq!(ui.root().children().len(), 5); } }