gioser

tawasuyu/shuma

Interactive shell with zsh/fish parity, native multiplexing + remote sessions, on Llimphi.

⎇ main 02_ruway/llimphi/auditor_mudos.rs Rust · 272 líneas · 11.3 KB Historia Quién tocó qué crudo

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

1// El **auditor de nodos mudos**: cuántos nodos interactivos de una app no se
2// pueden nombrar ni apuntar.
3//
4// Un nodo que se clickea y no dice qué es tiene dos consecuencias, y la
5// segunda es la que motiva este archivo:
6//
7// 1. Un lector de pantalla no puede anunciarlo (`MANUAL.md` §16.ter).
8// 2. **Nada externo puede apuntarle.** Una señalética anclada al árbol —un
9//    marco, un letrero, un tour— necesita un selector estable; un nodo sin
10//    rol ni nombre no tiene ninguno, y sólo se lo puede señalar por
11//    coordenadas, que es justo lo que se rompe al cambiar el tamaño, el tema
12//    o el idioma.
13//
14// ⚠️ **Sin rol NO es lo mismo que inalcanzable, y la primera versión de este
15// archivo (2026-08-29) los sumaba juntos.** Los widgets ponen el rol en la
16// FILA (`TreeItem`, `Row`, `ListItem`) y el `on_click` en un hijo, y
17// `a11y::click_target` resuelve ese desajuste delegando el click del nodo con
18// rol a su subárbol **si hay exactamente un descendiente clickeable**
19// (MANUAL §16.ter, «Activación»). Ese hijo no es deuda: su ancla es el padre,
20// que tiene rol y nombre. Contarlo con los mudos de verdad hacía que el
21// número dijera «98 % mudo» de un árbol donde cada fila se anuncia bien.
22//
23// Por eso la tabla separa tres poblaciones:
24//   · **delegados** — sin rol, pero su ancestro con rol delega en ellos. Se
25//     los apunta por el ancestro. No son deuda.
26//   · **mudos** — sin rol y sin quien delegue: nada los puede señalar. Es la
27//     deuda real, y es el número que hay que bajar.
28//   · **anónimos** — de los mudos, los que además no tienen nombre accesible
29//     (ni `aria_label` ni texto visible). A éstos no se les puede armar un
30//     selector de ninguna forma; el arreglo pide escribir el texto, no sólo
31//     poner el rol.
32//
33// Se incluye con `include!` desde el example `auditar_mudos` de cada app —el
34// mismo patrón que `escena_patron.rs`— para que una sola edición cambie el
35// criterio de todas a la vez y los números se comparen entre sí.
36
37use llimphi_test::llimphi_compositor::View;
38use llimphi_test::query::{NodeRef, Sel, Ui};
39
40/// Apaga lo que un `init` levanta y no le corresponde a un arnés. Se llama
41/// **antes** del primer `init`, desde `cabecera`.
42///
43/// Auditar por escenarios re-inicializa el modelo una vez por estado, y el
44/// `init` de las apps que hospedan shuma abre el servidor del IDE y deja un
45/// lockfile en el `~/.claude/ide` **real** del operador. Uno de más apaga la
46/// autoconexión de su próxima sesión (`claude --ide` conecta sólo si hay
47/// exactamente uno), así que treinta escenarios serían treinta formas de
48/// romperle el día a alguien que no está mirando. `SHUMA_IDE=0` es la válvula
49/// que el propio módulo documenta para esto (`shuma-shell-llimphi/src/ide.rs`).
50#[allow(dead_code)]
51fn arnes() {
52    // SAFETY: corre antes de que ningún escenario levante un hilo.
53    unsafe { std::env::set_var("SHUMA_IDE", "0") };
54    // Y el libro de autoría de `nakui` fuera del árbol. `NakuiApp::init` abre un
55    // backend de verdad: sin esto, cada corrida del auditor dejaba
56    // `nakui-ui-state.jsonl`, `nakui-ui-state.snap.json` y el directorio
57    // `nakui-historia/` en el **cwd**, que al correr `cargo run` es la raíz del
58    // repo. Aparecían como `??` en `git status` sin ningún gesto que los
59    // explicara — el mismo caso que `SHUMA_IDE`, con otro nombre.
60    // SAFETY: ídem.
61    unsafe {
62        std::env::set_var(
63            "NAKUI_EVENT_LOG",
64            std::env::temp_dir().join("auditar-mudos-nakui/estado.jsonl"),
65        )
66    };
67}
68
69/// Cuenta los candidatos a heredar la activación de un ancestro: clickeables
70/// **sin rol propio** del subárbol de `n`, `n` incluido. Un descendiente con rol
71/// no compite — ya es un nodo activable por su cuenta (ver `a11y::click_target`).
72fn candidatos_en<Msg: Clone + 'static>(n: &NodeRef<'_, Msg>) -> usize {
73    if n.role().is_some() {
74        return 0;
75    }
76    let propio = usize::from(n.is_clickable());
77    propio + n.children().iter().map(candidatos_en).sum::<usize>()
78}
79
80/// Recorre el árbol y devuelve los índices de los clickeables **sin rol** que
81/// un ancestro con rol alcanza por delegación — la regla de
82/// `a11y::click_target`: el ancestro no tiene handler propio, declara rol, y
83/// su subárbol tiene exactamente un clickeable.
84fn delegados<Msg: Clone + 'static>(n: &NodeRef<'_, Msg>, out: &mut Vec<usize>) {
85    let candidatos: usize = n.children().iter().map(candidatos_en).sum();
86    if !n.is_clickable() && n.role().is_some() && candidatos == 1 {
87        // Es el ancestro que delega: el único candidato de su subárbol queda
88        // alcanzable por él. Se sigue bajando igual, porque más abajo puede
89        // haber otro nodo con rol que delegue en lo suyo.
90        for h in n.children() {
91            marcar_unico(&h, out);
92        }
93    }
94    for h in n.children() {
95        delegados(&h, out);
96    }
97}
98
99/// Anota el único candidato del subárbol de `n` (mismo criterio que
100/// [`candidatos_en`]: se saltean las ramas que declaran rol propio).
101fn marcar_unico<Msg: Clone + 'static>(n: &NodeRef<'_, Msg>, out: &mut Vec<usize>) {
102    if n.role().is_some() {
103        return;
104    }
105    if n.is_clickable() {
106        out.push(n.idx());
107        return;
108    }
109    for h in n.children() {
110        marcar_unico(&h, out);
111    }
112}
113
114/// Monta `vista` a `w`×`h` y cuenta la deuda. Imprime una línea por app,
115/// tabulada, y devuelve `(interactivos, mudos, anonimos)`.
116fn auditar<Msg: Clone + 'static>(nombre: &str, vista: View<Msg>, w: f32, h: f32) -> (usize, usize, usize) {
117    let ui = Ui::new(vista, w, h);
118
119    let total = ui.len();
120    let interactivos = ui.find_all(Sel::new().clickable(true)).len();
121
122    let mut alcanzados = Vec::new();
123    delegados(&ui.root(), &mut alcanzados);
124
125    let sin_rol = ui.find_all(Sel::new().clickable(true).role_none());
126    let delegados_n = sin_rol.iter().filter(|n| alcanzados.contains(&n.idx())).count();
127    let mudos: Vec<_> = sin_rol
128        .into_iter()
129        .filter(|n| !alcanzados.contains(&n.idx()))
130        .collect();
131    let anonimos = mudos.iter().filter(|n| n.name().is_none()).count();
132
133    // Con `--volcado`, el árbol semántico entero (`Ui::dump`). Es lo que hay que
134    // mirar cuando un mudo no se deja identificar por su caja: el volcado dice
135    // quién es su padre, y ahí se ve de qué widget salió.
136    if std::env::args().any(|a| a == "--volcado") {
137        println!("{}", ui.dump());
138    }
139    // Con `--detail` se listan los mudos uno por uno: es lo que hay que leer
140    // para decidir si la deuda se salda con un rol o si además falta un nombre.
141    if std::env::args().any(|a| a == "--detail") {
142        for n in &mudos {
143            let r = n.rect();
144            println!(
145                "    · {:<40} @{:.0},{:.0} {:.0}×{:.0}",
146                n.name().unwrap_or("‹anónimo›"),
147                r.x, r.y, r.w, r.h
148            );
149        }
150    }
151
152    let pct = if interactivos == 0 { 0.0 } else { 100.0 * mudos.len() as f32 / interactivos as f32 };
153    println!(
154        "{:<26} {:>7} {:>13} {:>10} {:>6} {:>7.0}% {:>9}",
155        nombre, total, interactivos, delegados_n, mudos.len(), pct, anonimos
156    );
157    (interactivos, mudos.len(), anonimos)
158}
159
160/// La cabecera de la tabla. La imprime el example antes de la primera fila.
161#[allow(dead_code)]
162fn cabecera() {
163    arnes();
164    println!(
165        "{:<26} {:>7} {:>13} {:>10} {:>6} {:>8} {:>9}",
166        "app", "nodos", "interactivos", "delegados", "mudos", "", "anónimos"
167    );
168}
169
170/// Audita **un estado que no es el del arranque**: inicializa el modelo, le
171/// aplica `pasos` en orden y recién ahí monta `view` (y `view_overlay`, si la
172/// app la implementa).
173///
174/// El cero del arranque no dice nada de un menú abierto: la mayor parte de lo
175/// que se clickea en `pata` o en `shuma` **no está en el árbol hasta que algo
176/// lo abre** (36 interactivos al arrancar contra 193 `on_click` en el código).
177/// Cada escenario es un `Vec<Msg>` porque llegar a un estado suele pedir más de
178/// un paso —abrir el panel y después entrar a una pestaña—, y porque un `Msg`
179/// es el mismo verbo que dispara el usuario: si el escenario se escribe con el
180/// mensaje real, no puede auditar un árbol que la app no sabe producir.
181///
182/// El modelo se re-inicializa por escenario a propósito: un panel abierto sobre
183/// los restos del anterior audita un árbol que el usuario no puede alcanzar.
184#[allow(dead_code)]
185fn escenario<A: llimphi_ui::App>(
186    nombre: &str,
187    hnd: &llimphi_ui::Handle<A::Msg>,
188    pasos: Vec<A::Msg>,
189    ancho: f32,
190    alto: f32,
191) -> (usize, usize, usize) {
192    let mut m = A::init(hnd);
193    for paso in pasos {
194        m = A::update(m, paso, hnd);
195    }
196    let mut acc = auditar(nombre, A::view(&m), ancho, alto);
197    if let Some(ov) = A::view_overlay(&m) {
198        let o = auditar(&format!("{nombre} ‹overlay›"), ov, ancho, alto);
199        acc = (acc.0 + o.0, acc.1 + o.1, acc.2 + o.2);
200    }
201    acc
202}
203
204/// Cierra la tabla con el total de las filas que le pasaron. Un total en cero
205/// es lo único que certifica que la deuda se saldó en TODOS los escenarios —
206/// leer fila por fila una tabla de treinta líneas es cómo se pasan por alto.
207#[allow(dead_code)]
208fn total(filas: &[(usize, usize, usize)]) {
209    let interactivos: usize = filas.iter().map(|f| f.0).sum();
210    let mudos: usize = filas.iter().map(|f| f.1).sum();
211    let anonimos: usize = filas.iter().map(|f| f.2).sum();
212    println!(
213        "{:-<26} {:>7} {:>13} {:>10} {:>6} {:>8} {:>9}",
214        "", "", interactivos, "", mudos, "", anonimos
215    );
216    println!(
217        "{} escenarios · {} interactivos · {} mudos · {} anónimos",
218        filas.len(),
219        interactivos,
220        mudos,
221        anonimos
222    );
223}
224
225/// Un `KeyEvent` de pulsación con los modificadores dados. Es el atajo escrito
226/// como lo escribe el usuario: `tecla(Key::Character("p".into()), true, true)`
227/// es Ctrl+Shift+P.
228#[allow(dead_code)]
229fn tecla(
230    key: llimphi_test::llimphi_compositor::Key,
231    ctrl: bool,
232    shift: bool,
233) -> llimphi_test::llimphi_compositor::KeyEvent {
234    use llimphi_test::llimphi_compositor::{KeyEvent, KeyState, Modifiers};
235    KeyEvent {
236        key,
237        state: KeyState::Pressed,
238        text: None,
239        modifiers: Modifiers { ctrl, shift, ..Default::default() },
240        repeat: false,
241    }
242}
243
244/// Como [`escenario`], pero llega al estado **por el atajo de teclado**, no por
245/// el `Msg`. Es la única puerta cuando lo que abre el panel es el `Msg` de un
246/// módulo cuyo tipo no es público (la paleta de comandos, el buscador en
247/// archivos, el terminal integrado de `nada`): el atajo es público porque lo
248/// tipea el usuario, y `App::on_key` traduce.
249///
250/// Una tecla que la app ignora no cambia nada y la fila sale igual a la del
251/// arranque — eso ya es información: significa que el atajo no está cableado.
252#[allow(dead_code)]
253fn escenario_teclas<A: llimphi_ui::App>(
254    nombre: &str,
255    hnd: &llimphi_ui::Handle<A::Msg>,
256    teclas: Vec<llimphi_test::llimphi_compositor::KeyEvent>,
257    ancho: f32,
258    alto: f32,
259) -> (usize, usize, usize) {
260    let mut m = A::init(hnd);
261    for k in teclas {
262        if let Some(msg) = A::on_key(&m, &k) {
263            m = A::update(m, msg, hnd);
264        }
265    }
266    let mut acc = auditar(nombre, A::view(&m), ancho, alto);
267    if let Some(ov) = A::view_overlay(&m) {
268        let o = auditar(&format!("{nombre} ‹overlay›"), ov, ancho, alto);
269        acc = (acc.0 + o.0, acc.1 + o.1, acc.2 + o.2);
270    }
271    acc
272}