// El **auditor de nodos mudos**: cuántos nodos interactivos de una app no se // pueden nombrar ni apuntar. // // Un nodo que se clickea y no dice qué es tiene dos consecuencias, y la // segunda es la que motiva este archivo: // // 1. Un lector de pantalla no puede anunciarlo (`MANUAL.md` §16.ter). // 2. **Nada externo puede apuntarle.** Una señalética anclada al árbol —un // marco, un letrero, un tour— necesita un selector estable; un nodo sin // rol ni nombre no tiene ninguno, y sólo se lo puede señalar por // coordenadas, que es justo lo que se rompe al cambiar el tamaño, el tema // o el idioma. // // ⚠️ **Sin rol NO es lo mismo que inalcanzable, y la primera versión de este // archivo (2026-08-29) los sumaba juntos.** Los widgets ponen el rol en la // FILA (`TreeItem`, `Row`, `ListItem`) y el `on_click` en un hijo, y // `a11y::click_target` resuelve ese desajuste delegando el click del nodo con // rol a su subárbol **si hay exactamente un descendiente clickeable** // (MANUAL §16.ter, «Activación»). Ese hijo no es deuda: su ancla es el padre, // que tiene rol y nombre. Contarlo con los mudos de verdad hacía que el // número dijera «98 % mudo» de un árbol donde cada fila se anuncia bien. // // Por eso la tabla separa tres poblaciones: // · **delegados** — sin rol, pero su ancestro con rol delega en ellos. Se // los apunta por el ancestro. No son deuda. // · **mudos** — sin rol y sin quien delegue: nada los puede señalar. Es la // deuda real, y es el número que hay que bajar. // · **anónimos** — de los mudos, los que además no tienen nombre accesible // (ni `aria_label` ni texto visible). A éstos no se les puede armar un // selector de ninguna forma; el arreglo pide escribir el texto, no sólo // poner el rol. // // Se incluye con `include!` desde el example `auditar_mudos` de cada app —el // mismo patrón que `escena_patron.rs`— para que una sola edición cambie el // criterio de todas a la vez y los números se comparen entre sí. use llimphi_test::llimphi_compositor::View; use llimphi_test::query::{NodeRef, Sel, Ui}; /// Apaga lo que un `init` levanta y no le corresponde a un arnés. Se llama /// **antes** del primer `init`, desde `cabecera`. /// /// Auditar por escenarios re-inicializa el modelo una vez por estado, y el /// `init` de las apps que hospedan shuma abre el servidor del IDE y deja un /// lockfile en el `~/.claude/ide` **real** del operador. Uno de más apaga la /// autoconexión de su próxima sesión (`claude --ide` conecta sólo si hay /// exactamente uno), así que treinta escenarios serían treinta formas de /// romperle el día a alguien que no está mirando. `SHUMA_IDE=0` es la válvula /// que el propio módulo documenta para esto (`shuma-shell-llimphi/src/ide.rs`). #[allow(dead_code)] fn arnes() { // SAFETY: corre antes de que ningún escenario levante un hilo. unsafe { std::env::set_var("SHUMA_IDE", "0") }; // Y el libro de autoría de `nakui` fuera del árbol. `NakuiApp::init` abre un // backend de verdad: sin esto, cada corrida del auditor dejaba // `nakui-ui-state.jsonl`, `nakui-ui-state.snap.json` y el directorio // `nakui-historia/` en el **cwd**, que al correr `cargo run` es la raíz del // repo. Aparecían como `??` en `git status` sin ningún gesto que los // explicara — el mismo caso que `SHUMA_IDE`, con otro nombre. // SAFETY: ídem. unsafe { std::env::set_var( "NAKUI_EVENT_LOG", std::env::temp_dir().join("auditar-mudos-nakui/estado.jsonl"), ) }; } /// Cuenta los candidatos a heredar la activación de un ancestro: clickeables /// **sin rol propio** del subárbol de `n`, `n` incluido. Un descendiente con rol /// no compite — ya es un nodo activable por su cuenta (ver `a11y::click_target`). fn candidatos_en(n: &NodeRef<'_, Msg>) -> usize { if n.role().is_some() { return 0; } let propio = usize::from(n.is_clickable()); propio + n.children().iter().map(candidatos_en).sum::() } /// Recorre el árbol y devuelve los índices de los clickeables **sin rol** que /// un ancestro con rol alcanza por delegación — la regla de /// `a11y::click_target`: el ancestro no tiene handler propio, declara rol, y /// su subárbol tiene exactamente un clickeable. fn delegados(n: &NodeRef<'_, Msg>, out: &mut Vec) { let candidatos: usize = n.children().iter().map(candidatos_en).sum(); if !n.is_clickable() && n.role().is_some() && candidatos == 1 { // Es el ancestro que delega: el único candidato de su subárbol queda // alcanzable por él. Se sigue bajando igual, porque más abajo puede // haber otro nodo con rol que delegue en lo suyo. for h in n.children() { marcar_unico(&h, out); } } for h in n.children() { delegados(&h, out); } } /// Anota el único candidato del subárbol de `n` (mismo criterio que /// [`candidatos_en`]: se saltean las ramas que declaran rol propio). fn marcar_unico(n: &NodeRef<'_, Msg>, out: &mut Vec) { if n.role().is_some() { return; } if n.is_clickable() { out.push(n.idx()); return; } for h in n.children() { marcar_unico(&h, out); } } /// Monta `vista` a `w`×`h` y cuenta la deuda. Imprime una línea por app, /// tabulada, y devuelve `(interactivos, mudos, anonimos)`. fn auditar(nombre: &str, vista: View, w: f32, h: f32) -> (usize, usize, usize) { let ui = Ui::new(vista, w, h); let total = ui.len(); let interactivos = ui.find_all(Sel::new().clickable(true)).len(); let mut alcanzados = Vec::new(); delegados(&ui.root(), &mut alcanzados); let sin_rol = ui.find_all(Sel::new().clickable(true).role_none()); let delegados_n = sin_rol.iter().filter(|n| alcanzados.contains(&n.idx())).count(); let mudos: Vec<_> = sin_rol .into_iter() .filter(|n| !alcanzados.contains(&n.idx())) .collect(); let anonimos = mudos.iter().filter(|n| n.name().is_none()).count(); // Con `--volcado`, el árbol semántico entero (`Ui::dump`). Es lo que hay que // mirar cuando un mudo no se deja identificar por su caja: el volcado dice // quién es su padre, y ahí se ve de qué widget salió. if std::env::args().any(|a| a == "--volcado") { println!("{}", ui.dump()); } // Con `--detail` se listan los mudos uno por uno: es lo que hay que leer // para decidir si la deuda se salda con un rol o si además falta un nombre. if std::env::args().any(|a| a == "--detail") { for n in &mudos { let r = n.rect(); println!( " · {:<40} @{:.0},{:.0} {:.0}×{:.0}", n.name().unwrap_or("‹anónimo›"), r.x, r.y, r.w, r.h ); } } let pct = if interactivos == 0 { 0.0 } else { 100.0 * mudos.len() as f32 / interactivos as f32 }; println!( "{:<26} {:>7} {:>13} {:>10} {:>6} {:>7.0}% {:>9}", nombre, total, interactivos, delegados_n, mudos.len(), pct, anonimos ); (interactivos, mudos.len(), anonimos) } /// La cabecera de la tabla. La imprime el example antes de la primera fila. #[allow(dead_code)] fn cabecera() { arnes(); println!( "{:<26} {:>7} {:>13} {:>10} {:>6} {:>8} {:>9}", "app", "nodos", "interactivos", "delegados", "mudos", "", "anónimos" ); } /// Audita **un estado que no es el del arranque**: inicializa el modelo, le /// aplica `pasos` en orden y recién ahí monta `view` (y `view_overlay`, si la /// app la implementa). /// /// El cero del arranque no dice nada de un menú abierto: la mayor parte de lo /// que se clickea en `pata` o en `shuma` **no está en el árbol hasta que algo /// lo abre** (36 interactivos al arrancar contra 193 `on_click` en el código). /// Cada escenario es un `Vec` porque llegar a un estado suele pedir más de /// un paso —abrir el panel y después entrar a una pestaña—, y porque un `Msg` /// es el mismo verbo que dispara el usuario: si el escenario se escribe con el /// mensaje real, no puede auditar un árbol que la app no sabe producir. /// /// El modelo se re-inicializa por escenario a propósito: un panel abierto sobre /// los restos del anterior audita un árbol que el usuario no puede alcanzar. #[allow(dead_code)] fn escenario( nombre: &str, hnd: &llimphi_ui::Handle, pasos: Vec, ancho: f32, alto: f32, ) -> (usize, usize, usize) { let mut m = A::init(hnd); for paso in pasos { m = A::update(m, paso, hnd); } let mut acc = auditar(nombre, A::view(&m), ancho, alto); if let Some(ov) = A::view_overlay(&m) { let o = auditar(&format!("{nombre} ‹overlay›"), ov, ancho, alto); acc = (acc.0 + o.0, acc.1 + o.1, acc.2 + o.2); } acc } /// Cierra la tabla con el total de las filas que le pasaron. Un total en cero /// es lo único que certifica que la deuda se saldó en TODOS los escenarios — /// leer fila por fila una tabla de treinta líneas es cómo se pasan por alto. #[allow(dead_code)] fn total(filas: &[(usize, usize, usize)]) { let interactivos: usize = filas.iter().map(|f| f.0).sum(); let mudos: usize = filas.iter().map(|f| f.1).sum(); let anonimos: usize = filas.iter().map(|f| f.2).sum(); println!( "{:-<26} {:>7} {:>13} {:>10} {:>6} {:>8} {:>9}", "", "", interactivos, "", mudos, "", anonimos ); println!( "{} escenarios · {} interactivos · {} mudos · {} anónimos", filas.len(), interactivos, mudos, anonimos ); } /// Un `KeyEvent` de pulsación con los modificadores dados. Es el atajo escrito /// como lo escribe el usuario: `tecla(Key::Character("p".into()), true, true)` /// es Ctrl+Shift+P. #[allow(dead_code)] fn tecla( key: llimphi_test::llimphi_compositor::Key, ctrl: bool, shift: bool, ) -> llimphi_test::llimphi_compositor::KeyEvent { use llimphi_test::llimphi_compositor::{KeyEvent, KeyState, Modifiers}; KeyEvent { key, state: KeyState::Pressed, text: None, modifiers: Modifiers { ctrl, shift, ..Default::default() }, repeat: false, } } /// Como [`escenario`], pero llega al estado **por el atajo de teclado**, no por /// el `Msg`. Es la única puerta cuando lo que abre el panel es el `Msg` de un /// módulo cuyo tipo no es público (la paleta de comandos, el buscador en /// archivos, el terminal integrado de `nada`): el atajo es público porque lo /// tipea el usuario, y `App::on_key` traduce. /// /// Una tecla que la app ignora no cambia nada y la fila sale igual a la del /// arranque — eso ya es información: significa que el atajo no está cableado. #[allow(dead_code)] fn escenario_teclas( nombre: &str, hnd: &llimphi_ui::Handle, teclas: Vec, ancho: f32, alto: f32, ) -> (usize, usize, usize) { let mut m = A::init(hnd); for k in teclas { if let Some(msg) = A::on_key(&m, &k) { m = A::update(m, msg, hnd); } } let mut acc = auditar(nombre, A::view(&m), ancho, alto); if let Some(ov) = A::view_overlay(&m) { let o = auditar(&format!("{nombre} ‹overlay›"), ov, ancho, alto); acc = (acc.0 + o.0, acc.1 + o.1, acc.2 + o.2); } acc }