tawasuyu

tawasuyu/llimphi

Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.

Rust 229TOML 101Markdown 82Shell 24052 Definiciones

⎇ main llimphi-ui/src/eventloop/vigia.rs Rust · 574 líneas · 24.6 KB Historia Quién tocó qué crudo

chore: refresco desde el monorepo — 132 miembros, 79 widgets, la galería vuelve y licencia dual d8edd9a9c · Sergio ·

1// eventloop/vigia.rs — el vigía del hilo de UI.
2//
3// `COMPUTO-FUERA-DEL-HILO-UI.md` fija la regla: ningún `update`/`init` puede
4// hacer trabajo pesado sincrónico, porque bloquea el hilo de UI y la ventana
5// deja de repintar, de responder y de cerrarse. Y ese documento declara su
6// propio límite más grande:
7//
8//   «⚠️ **No ve cómputo de CPU.** [`auditar-hilo-ui.py`] detecta IO y
9//    `block_on`; un bucle que muele números en `update` le pasa por al lado. Y
10//    justamente el caso que originó esta regla —las efemérides de cosmos— era
11//    eso, no IO.»
12//
13// Por eso nueve apps quedaron en `[~]`: «sin IO bloqueante directo; **falta**
14// revisar cómputo de CPU», esperando una lectura humana del `update` que nadie
15// iba a hacer app por app.
16//
17// Un linter estático no puede cerrar eso: decidir si un bucle es caro es el
18// problema de la parada con sombrero. Pero **medirlo en vivo sí**, y sale casi
19// gratis: el runtime ya pasa por un solo lugar en cada `update`. Si tardó más
20// que el umbral, lo dice. Sin falsos positivos —no estima, cronometra— y sin
21// que las apps toquen una línea.
22//
23// Encendido **por defecto**. Ese es el punto: si hubiera que compilar con una
24// feature o exportar una env, volvería a depender de que alguien se acuerde, y
25// las nueve apps seguirían en `[~]`. El costo de estar prendido es un par
26// `Instant::now()` + `elapsed()` por `update`: **99 ns medidos** en esta
27// laptop. Aun en una tormenta de mouse-move de mil eventos por segundo son
28// 0,1 ms por segundo — una diezmilésima del presupuesto — contra un `update`
29// que en el caso que se busca dura décimas de segundo.
30//
31// `LLIMPHI_UI_SLOW_MS=<ms>` cambia el umbral; `=0` lo apaga.
32//
33// ── El otro agujero: `view` (2026-08-05) ────────────────────────────────────
34//
35// Cronometrar `update` e `init` dejaba afuera la tercera función que la app
36// corre en el hilo de UI, y es la peor de las tres: **`view` corre en CADA
37// cuadro**. `redraw.rs` la llama sin memoizar el resultado a nivel app (los
38// nodos `memo` cachean *subárboles*, no la llamada), así que un `view` que
39// muele no congela la ventana una vez —como un `update` lento— sino para
40// siempre, mientras la ventana esté abierta.
41//
42// El auditor estático tampoco lo ve, y por el mismo motivo de siempre: no ve
43// cómputo de CPU. (Extendido a `view`/`view_overlay` el mismo día, sale en
44// **cero** hallazgos de IO — o sea que lo único que podía haber ahí era
45// justamente lo que un linter no puede ver.)
46//
47// Umbral propio, **más bajo** (un cuadro a 60 Hz) y con su propia env
48// (`LLIMPHI_UI_SLOW_VIEW_MS`), porque el costo tiene otra forma: un `update`
49// lento se come N cuadros y pasa; una `view` lenta es un techo de fps. Por eso
50// el aviso traduce los ms a fps, que es el número accionable.
51//
52// El costo de tenerlo prendido es el mismo par `Instant::now()`/`elapsed()`
53// —99 ns medidos— dos veces por cuadro (view + overlay): 12 µs por segundo a
54// 60 fps.
55//
56// **Verificado punta a punta contra el runtime, no sólo por unit test.**
57// `LLIMPHI_UI_SLOW_VIEW_MS=0.05 stress_idle` (la `view` real mide 0,2-0,3 ms)
58// denuncia por stderr; con el umbral de fábrica, la misma corrida de 12 s no
59// dice **nada**. Y de paso muestra la regla de callarse trabajando bajo
60// presión de cuadro: ~720 cuadros por encima del umbral forzado, **4 líneas**.
61
62use std::cell::RefCell;
63use std::collections::HashMap;
64use std::sync::OnceLock;
65
66use crate::{App, Handle, Instant};
67
68/// Umbral por defecto, en milisegundos.
69///
70/// Un cuadro a 60 Hz son 16,7 ms. Con 100 no se denuncia un `update` que
71/// simplemente se pasó de un cuadro —eso es ruido— sino uno que se comió seis:
72/// ahí ya no es una animación que salta, es la ventana congelada.
73const UMBRAL_MS: f64 = 100.0;
74
75/// Umbral de `view`/`view_overlay`, en milisegundos. **Un cuadro entero a
76/// 60 Hz.**
77///
78/// Es más bajo que el de `update` porque `view` corre en cada cuadro: no hay
79/// un «se comió seis cuadros y siguió», hay un techo de fps. Con 16,7 ms el
80/// aviso dice exactamente «tu `view` sola no deja pasar de 60 fps».
81///
82/// **Y el número sale de una medición, no de la teoría.** `LLIMPHI_PERF=1
83/// stress_idle` (987 nodos, laptop Iris Xe, 2026-08-05) da `view` en
84/// **190-326 µs de media y 727 µs de p95**. O sea que el umbral queda 23×
85/// arriba del peor cuadro de un árbol normal: no puede haber falso positivo
86/// por un pico. Una app con una `view` genuinamente pesada —un chart grande,
87/// una tabla larga— sigue teniendo 3-5× de aire antes de que hable.
88const UMBRAL_VIEW_MS: f64 = 1000.0 / 60.0;
89
90/// Umbral forzado, sólo en tests. La lectura de env está cacheada en un
91/// `OnceLock` (a propósito: una env por proceso), y eso hace que un test no
92/// pueda cambiarla — el primer test que corra fija el valor para todos.
93#[cfg(test)]
94thread_local! {
95    static UMBRAL_TEST: std::cell::Cell<Option<f64>> = const { std::cell::Cell::new(None) };
96}
97
98fn umbral_ms() -> f64 {
99    #[cfg(test)]
100    if let Some(u) = UMBRAL_TEST.with(|c| c.get()) {
101        return u;
102    }
103    static CELL: OnceLock<f64> = OnceLock::new();
104    *CELL.get_or_init(|| match std::env::var("LLIMPHI_UI_SLOW_MS") {
105        Ok(v) if !v.is_empty() => v.parse::<f64>().unwrap_or(UMBRAL_MS),
106        _ => UMBRAL_MS,
107    })
108}
109
110/// Umbral de `view`. `LLIMPHI_UI_SLOW_VIEW_MS=<ms>` lo cambia; `=0` lo apaga.
111///
112/// Env propia y no la de `update`: son dos costos con dos formas distintas
113/// (uno por evento, otro por cuadro) y quien esté cazando uno no quiere que el
114/// otro le llene la terminal.
115fn umbral_view_ms() -> f64 {
116    #[cfg(test)]
117    if let Some(u) = UMBRAL_TEST.with(|c| c.get()) {
118        return u;
119    }
120    static CELL: OnceLock<f64> = OnceLock::new();
121    *CELL.get_or_init(|| match std::env::var("LLIMPHI_UI_SLOW_VIEW_MS") {
122        Ok(v) if !v.is_empty() => v.parse::<f64>().unwrap_or(UMBRAL_VIEW_MS),
123        _ => UMBRAL_VIEW_MS,
124    })
125}
126
127/// Lo peor visto en un sitio de llamada.
128#[derive(Clone, Copy, Debug, Default, PartialEq)]
129struct Peor {
130    veces: u32,
131    ms: f64,
132}
133
134/// Lleva la cuenta de los `update` lentos y decide qué vale la pena decir.
135///
136/// Está separado del resto —y es puro— para poder probarlo sin ventana ni GPU.
137#[derive(Default)]
138pub(crate) struct Vigia {
139    peor: HashMap<String, Peor>,
140}
141
142impl Vigia {
143    /// Registra que `sitio` tardó `ms` y devuelve la línea a imprimir, o `None`
144    /// si no hay nada nuevo que decir.
145    ///
146    /// **Sólo habla cuando aprende algo**: la primera vez que un sitio pasa el
147    /// umbral, y después sólo si empeora. Un `update` lento suele serlo en cada
148    /// evento; escupir una línea por vez llenaría la terminal y el aviso se
149    /// volvería invisible, que es la forma más común en que un aviso deja de
150    /// servir.
151    pub(crate) fn registrar(&mut self, sitio: &str, ms: f64, umbral: f64) -> Option<String> {
152        if umbral <= 0.0 || ms < umbral {
153            return None;
154        }
155        let e = self.peor.entry(sitio.to_string()).or_default();
156        e.veces += 1;
157        if e.veces > 1 && ms <= e.ms {
158            return None;
159        }
160        let primera = e.veces == 1;
161        e.ms = ms;
162        Some(if primera {
163            format!(
164                "llimphi: update bloqueó el hilo de UI {ms:.0} ms ({sitio}) — \
165                 el trabajo pesado va a un worker (ver COMPUTO-FUERA-DEL-HILO-UI.md)"
166            )
167        } else {
168            format!(
169                "llimphi: update bloqueó el hilo de UI {ms:.0} ms ({sitio}) — \
170                 peor de {} veces en este sitio",
171                e.veces
172            )
173        })
174    }
175
176    /// Lo mismo para `view`/`view_overlay`, con el texto que corresponde.
177    ///
178    /// Comparte el mapa de «lo peor visto» —la clave es `cual`, que nunca
179    /// choca con un `archivo.rs:línea`— y por lo tanto comparte la regla de
180    /// hablar sólo cuando aprende algo. Acá esa regla pesa **mucho** más que en
181    /// `update`: una `view` lenta lo es en cada cuadro, o sea sesenta líneas por
182    /// segundo si el vigía no se callara.
183    pub(crate) fn registrar_vista(&mut self, cual: &str, ms: f64, umbral: f64) -> Option<String> {
184        if umbral <= 0.0 || ms < umbral {
185            return None;
186        }
187        let e = self.peor.entry(cual.to_string()).or_default();
188        e.veces += 1;
189        if e.veces > 1 && ms <= e.ms {
190            return None;
191        }
192        let primera = e.veces == 1;
193        e.ms = ms;
194        // Los fps que ese solo costo impone. Es la traducción que vuelve el
195        // número accionable: «84 ms» no dice nada, «12 fps de techo» sí.
196        let fps = 1000.0 / ms.max(1e-9);
197        Some(if primera {
198            format!(
199                "llimphi: {cual} tardó {ms:.1} ms y corre en CADA cuadro — si la ventana \
200                 repinta en continuo, eso es un techo de {fps:.0} fps; el trabajo pesado se \
201                 precomputa en `update` o va a un worker (ver COMPUTO-FUERA-DEL-HILO-UI.md)"
202            )
203        } else {
204            format!(
205                "llimphi: {cual} tardó {ms:.1} ms (techo {fps:.0} fps) — peor de {} cuadros",
206                e.veces
207            )
208        })
209    }
210}
211
212thread_local! {
213    /// Vive en el hilo de UI, que es el único que corre `update`.
214    static VIGIA: RefCell<Vigia> = RefCell::new(Vigia::default());
215}
216
217#[cfg(test)]
218thread_local! {
219    /// Último aviso emitido en este hilo. Existe para que un test pueda
220    /// comprobar que el cronómetro y el vigía están de verdad conectados —
221    /// probar el `Vigia` por su cuenta no dice nada sobre si alguien lo llama.
222    static ULTIMO: RefCell<Option<String>> = const { RefCell::new(None) };
223}
224
225fn avisar(sitio: &str, ms: f64, umbral: f64) {
226    if let Some(linea) = VIGIA.with(|v| v.borrow_mut().registrar(sitio, ms, umbral)) {
227        llimphi_hal::avisar!("{linea}");
228        #[cfg(test)]
229        ULTIMO.with(|u| *u.borrow_mut() = Some(linea));
230    }
231}
232
233thread_local! {
234    /// ¿Todavía no se pintó ningún cuadro? Ver `aplicar_view`.
235    static PRIMER_VIEW: std::cell::Cell<bool> = const { std::cell::Cell::new(true) };
236}
237
238/// Aviso del **primer** cuadro: mismo número, otra conclusión.
239fn avisar_vista_primera(cual: &str, ms: f64) {
240    let linea = format!(
241        "llimphi: el primer {cual} tardó {ms:.1} ms — incluye cachés frías (fuentes), \
242         así que todavía no dice nada sobre el régimen. Si se repite, se avisa de nuevo"
243    );
244    llimphi_hal::avisar!("{linea}");
245    #[cfg(test)]
246    ULTIMO.with(|u| *u.borrow_mut() = Some(linea));
247}
248
249fn avisar_vista(cual: &str, ms: f64, umbral: f64) {
250    if let Some(linea) = VIGIA.with(|v| v.borrow_mut().registrar_vista(cual, ms, umbral)) {
251        llimphi_hal::avisar!("{linea}");
252        #[cfg(test)]
253        ULTIMO.with(|u| *u.borrow_mut() = Some(linea));
254    }
255}
256
257/// Aplica un `Msg` al modelo, cronometrando.
258///
259/// Es el **único** camino por el que el runtime llama a `App::update` — los 33
260/// sitios de llamada pasan por acá. `#[track_caller]` hace que el aviso nombre
261/// el sitio real (`input.rs:189` = teclado, `mod.rs:349` = canal) sin que haya
262/// que etiquetar los 33 a mano.
263#[track_caller]
264pub(crate) fn aplicar_update<A: App>(
265    model: A::Model,
266    msg: A::Msg,
267    handle: &Handle<A::Msg>,
268) -> A::Model {
269    let umbral = umbral_ms();
270    // Apagado: ni se toca el reloj.
271    if umbral <= 0.0 {
272        return A::update(model, msg, handle);
273    }
274    let t0 = Instant::now();
275    let salida = A::update(model, msg, handle);
276    let ms = t0.elapsed().as_secs_f64() * 1e3;
277    // El camino normal no formatea nada: sólo se arma el `String` del sitio si
278    // de verdad hay algo que decir.
279    if ms >= umbral {
280        let l = core::panic::Location::caller();
281        avisar(&format!("{}:{}", nombre_corto(l.file()), l.line()), ms, umbral);
282    }
283    salida
284}
285
286/// Construye el modelo inicial, cronometrando.
287///
288/// `init` merece su propio aviso y su propio texto: en winit corre dentro de
289/// `resumed`, o sea **después** de crear la ventana, así que un cómputo pesado
290/// ahí congela una ventana que el usuario ya está viendo.
291pub(crate) fn aplicar_init<A: App>(handle: &Handle<A::Msg>) -> A::Model {
292    let t0 = Instant::now();
293    let modelo = crate::dormir::init_o_despertar::<A>(handle);
294    crate::dormir::escuchar::<A>(handle);
295    let ms = t0.elapsed().as_secs_f64() * 1e3;
296    let umbral = umbral_ms();
297    if umbral > 0.0 && ms >= umbral {
298        llimphi_hal::avisar!(
299            "llimphi: init tardó {ms:.0} ms antes del primer cuadro — corre DENTRO de \
300             `resumed`, con la ventana ya creada, así que eso es la ventana congelada \
301             (ver COMPUTO-FUERA-DEL-HILO-UI.md)"
302        );
303    }
304    modelo
305}
306
307/// Construye el árbol de la app, cronometrando.
308///
309/// Es el **único** camino por el que el runtime llama a `App::view`: las dos
310/// llamadas viven en `helpers::resolve_layout_builders` (la segunda sólo
311/// cuando el árbol tiene `layout_builder`, y también se cronometra — es trabajo
312/// de la app en el mismo cuadro).
313///
314/// Acá no hace falta `#[track_caller]`: a diferencia de `update`, que llega por
315/// 33 caminos distintos y el sitio dice cuál, `view` es una sola función de una
316/// sola app por proceso. Nombrarla alcanza.
317pub(crate) fn aplicar_view<A: App>(model: &A::Model) -> crate::View<A::Msg> {
318    let umbral = umbral_view_ms();
319    if umbral <= 0.0 {
320        return A::view(model);
321    }
322    let t0 = Instant::now();
323    let vista = A::view(model);
324    let ms = t0.elapsed().as_secs_f64() * 1e3;
325    if ms >= umbral {
326        // **El primer cuadro no es el régimen**, y decirlo importa. Paga
327        // cachés frías —las fuentes sobre todo— y encima muchas apps repintan
328        // a demanda: una que quedó ociosa después del arranque no tiene ningún
329        // «techo de fps», tiene un arranque lento. Confundir las dos cosas
330        // manda a optimizar lo que no es.
331        //
332        // Lo encontró el propio vigía usándose (2026-08-06): en `chaka` avisó
333        // «116 ms, techo de 9 fps» y la app estaba ociosa — nunca llegó a 60
334        // cuadros en dieciocho segundos. El aviso era cierto en el número y
335        // falso en la conclusión.
336        if PRIMER_VIEW.with(|c| c.replace(false)) {
337            avisar_vista_primera("view()", ms);
338        } else {
339            avisar_vista("view()", ms, umbral);
340        }
341    }
342    vista
343}
344
345/// Ídem para el overlay, que corre en el mismo cuadro y en el mismo hilo.
346///
347/// Lleva cuenta aparte porque son dos funciones que la app escribe por
348/// separado: si la lenta es una, decirlo por el nombre de la otra manda a
349/// mirar el archivo equivocado.
350pub(crate) fn aplicar_view_overlay<A: App>(model: &A::Model) -> Option<crate::View<A::Msg>> {
351    let umbral = umbral_view_ms();
352    if umbral <= 0.0 {
353        return A::view_overlay(model);
354    }
355    let t0 = Instant::now();
356    let vista = A::view_overlay(model);
357    let ms = t0.elapsed().as_secs_f64() * 1e3;
358    if ms >= umbral {
359        avisar_vista("view_overlay()", ms, umbral);
360    }
361    vista
362}
363
364/// `src/eventloop/input.rs` en vez de la ruta absoluta del build.
365fn nombre_corto(ruta: &str) -> &str {
366    match ruta.rfind("llimphi-ui/") {
367        Some(i) => &ruta[i + "llimphi-ui/".len()..],
368        None => ruta,
369    }
370}
371
372#[cfg(test)]
373mod tests {
374    use super::*;
375
376    #[test]
377    fn lo_que_no_pasa_el_umbral_no_se_menciona() {
378        let mut v = Vigia::default();
379        assert_eq!(v.registrar("input.rs:1", 99.0, 100.0), None);
380        assert_eq!(v.registrar("input.rs:1", 0.02, 100.0), None);
381        // Umbral 0 = apagado, aunque el update haya tardado un minuto.
382        assert_eq!(v.registrar("input.rs:1", 60_000.0, 0.0), None);
383    }
384
385    #[test]
386    fn avisa_la_primera_vez_y_despues_solo_si_empeora() {
387        // Un update lento suele serlo en CADA evento. Si el vigía hablara cada
388        // vez, llenaría la terminal y el aviso se volvería invisible — que es
389        // la forma más común en que un aviso deja de servir.
390        let mut v = Vigia::default();
391        let primera = v.registrar("input.rs:189", 340.0, 100.0).expect("la primera avisa");
392        assert!(primera.contains("340 ms") && primera.contains("input.rs:189"));
393
394        assert_eq!(v.registrar("input.rs:189", 200.0, 100.0), None, "menos que lo peor: calla");
395        assert_eq!(v.registrar("input.rs:189", 340.0, 100.0), None, "empatar no es empeorar");
396
397        let peor = v.registrar("input.rs:189", 900.0, 100.0).expect("empeoró: avisa");
398        assert!(peor.contains("900 ms"), "{peor}");
399        assert!(peor.contains("peor de 4"), "tiene que contar todas las veces: {peor}");
400    }
401
402    #[test]
403    fn cada_sitio_lleva_su_propia_cuenta() {
404        // El teclado y el canal de ticks son caminos distintos; que uno ya haya
405        // avisado no puede tapar al otro.
406        let mut v = Vigia::default();
407        assert!(v.registrar("input.rs:189", 300.0, 100.0).is_some());
408        let otro = v.registrar("mod.rs:349", 150.0, 100.0);
409        assert!(otro.is_some(), "otro sitio, aviso propio");
410        assert!(otro.unwrap().contains("mod.rs:349"));
411    }
412
413    #[test]
414    fn el_sitio_sale_relativo_al_crate() {
415        // La ruta absoluta del build no le dice nada a nadie.
416        assert_eq!(
417            nombre_corto("/home/quien/tawasuyu/02_ruway/llimphi/llimphi-ui/src/eventloop/input.rs"),
418            "src/eventloop/input.rs"
419        );
420        assert_eq!(nombre_corto("otro/lado.rs"), "otro/lado.rs");
421    }
422
423    // ── El camino real, con una App de mentira ───────────────────────────
424
425    struct Lenta;
426
427    impl App for Lenta {
428        type Model = u32;
429        type Msg = u32;
430        fn init(_h: &Handle<Self::Msg>) -> Self::Model {
431            0
432        }
433        fn update(m: Self::Model, msg: Self::Msg, _h: &Handle<Self::Msg>) -> Self::Model {
434            // Muele: es justo el caso que el auditor estático no ve.
435            let mut acc = m;
436            for i in 0..msg {
437                acc = acc.wrapping_add(i).rotate_left(1);
438            }
439            acc
440        }
441        // Muele **en proporción al modelo**, igual que `update`. Así el mismo
442        // tipo sirve para las dos mitades del vigía y un test puede pedir una
443        // `view` cara (`&200_000`) o una barata (`&0`) sin otra App.
444        fn view(m: &Self::Model) -> crate::View<Self::Msg> {
445            let mut acc = 0u32;
446            for i in 0..*m {
447                acc = acc.wrapping_add(i).rotate_left(1);
448            }
449            // `black_box` para que el optimizador no borre el bucle entero
450            // —es un cómputo sin efecto— y deje al test midiendo la nada.
451            core::hint::black_box(acc);
452            crate::View::new(Default::default())
453        }
454
455        fn view_overlay(m: &Self::Model) -> Option<crate::View<Self::Msg>> {
456            let mut acc = 0u32;
457            for i in 0..*m {
458                acc = acc.wrapping_add(i).rotate_left(3);
459            }
460            core::hint::black_box(acc);
461            Some(crate::View::new(Default::default()))
462        }
463    }
464
465    #[test]
466    fn el_envoltorio_devuelve_el_modelo_que_devolvio_la_app() {
467        // Lo primero que no puede romper: cronometrar no puede cambiar lo que
468        // la app calcula.
469        let h = Handle::<u32>::for_test_with(|_| {});
470        assert_eq!(aplicar_update::<Lenta>(7, 0, &h), 7);
471        assert_eq!(aplicar_update::<Lenta>(0, 3, &h), Lenta::update(0, 3, &h));
472        assert_eq!(aplicar_init::<Lenta>(&h), 0);
473    }
474
475    #[test]
476    fn un_update_que_muele_dispara_el_aviso_y_nombra_el_sitio() {
477        // El test que cierra el agujero de verdad: acá NO hay IO ni `block_on`
478        // —lo único que el auditor estático sabe ver— sino un bucle que muele.
479        // Eso es exactamente lo que las efemérides de cosmos le hacían al hilo
480        // de UI, y lo que dejó nueve apps en `[~]`.
481        UMBRAL_TEST.with(|c| c.set(Some(0.000_1)));
482        let h = Handle::<u32>::for_test_with(|_| {});
483
484        aplicar_update::<Lenta>(0, 200_000, &h);
485        let aviso = ULTIMO.with(|u| u.borrow().clone()).expect("tenía que avisar");
486        assert!(aviso.contains("bloqueó el hilo de UI"), "{aviso}");
487        // `#[track_caller]` tiene que nombrar el sitio de llamada —esta línea—,
488        // no el interior del vigía: es lo que permite distinguir el camino del
489        // teclado del de los ticks sin etiquetar 33 sitios a mano.
490        assert!(aviso.contains("src/eventloop/vigia.rs"), "no nombró el sitio: {aviso}");
491
492        // Control negativo: con un umbral que no se alcanza, ni una palabra.
493        UMBRAL_TEST.with(|c| c.set(Some(60_000.0)));
494        ULTIMO.with(|u| *u.borrow_mut() = None);
495        aplicar_update::<Lenta>(0, 200_000, &h);
496        assert_eq!(ULTIMO.with(|u| u.borrow().clone()), None, "un update sano no habla");
497    }
498
499    // ── La mitad de `view` ───────────────────────────────────────────────
500
501    #[test]
502    fn una_view_que_muele_se_denuncia_como_techo_de_fps() {
503        // La diferencia con `update` no es de grado: un `update` lento congela
504        // la ventana **una vez**; una `view` lenta le pone un techo de fps
505        // mientras esté abierta. El aviso tiene que decir eso, porque es lo que
506        // manda a mirar el lugar correcto.
507        UMBRAL_TEST.with(|c| c.set(Some(0.000_1)));
508        ULTIMO.with(|u| *u.borrow_mut() = None);
509
510        // **El primer cuadro tiene su propio texto y su propia conclusión.**
511        // Paga cachés frías, y muchas apps repintan a demanda: una que quedó
512        // ociosa después del arranque no tiene techo de fps, tiene un arranque
513        // lento. Lo encontró el propio vigía en `chaka` (2026-08-06): avisó
514        // «116 ms, techo de 9 fps» y la app nunca llegó a 60 cuadros en
515        // dieciocho segundos.
516        aplicar_view::<Lenta>(&200_000);
517        let primero = ULTIMO.with(|u| u.borrow().clone()).expect("tenía que avisar");
518        assert!(primero.contains("primer"), "no distinguió el primer cuadro: {primero}");
519        assert!(
520            !primero.contains("techo"),
521            "el primer cuadro no autoriza a hablar de techo de fps: {primero}"
522        );
523
524        // Y del segundo en adelante sí: ahí ya es régimen.
525        ULTIMO.with(|u| *u.borrow_mut() = None);
526        aplicar_view::<Lenta>(&200_000);
527        let aviso = ULTIMO.with(|u| u.borrow().clone()).expect("tenía que avisar");
528        assert!(aviso.contains("view()"), "no nombró la función: {aviso}");
529        assert!(aviso.contains("CADA cuadro"), "{aviso}");
530        assert!(aviso.contains("fps"), "el número accionable es el techo de fps: {aviso}");
531
532        // Control negativo: con un umbral que no se alcanza, ni una palabra.
533        UMBRAL_TEST.with(|c| c.set(Some(60_000.0)));
534        ULTIMO.with(|u| *u.borrow_mut() = None);
535        aplicar_view::<Lenta>(&200_000);
536        assert_eq!(ULTIMO.with(|u| u.borrow().clone()), None, "una view sana no habla");
537    }
538
539    #[test]
540    fn la_view_y_el_overlay_llevan_cuenta_separada() {
541        // Son dos funciones que la app escribe por separado. Si compartieran
542        // cuenta, la primera en avisar taparía a la otra y el aviso mandaría a
543        // leer el archivo equivocado.
544        let mut v = Vigia::default();
545        assert!(v.registrar_vista("view()", 40.0, 16.7).is_some());
546        let otro = v.registrar_vista("view_overlay()", 20.0, 16.7);
547        assert!(otro.is_some(), "el overlay tiene su propio aviso aunque tarde menos");
548        assert!(otro.unwrap().contains("view_overlay()"));
549
550        // Y la misma regla de callarse: sesenta cuadros por segundo son sesenta
551        // líneas por segundo si el vigía no se contuviera.
552        assert_eq!(v.registrar_vista("view()", 30.0, 16.7), None, "menos que lo peor: calla");
553        let peor = v.registrar_vista("view()", 90.0, 16.7).expect("empeoró: avisa");
554        assert!(peor.contains("peor de 3 cuadros"), "{peor}");
555    }
556
557    #[test]
558    fn el_umbral_de_view_deja_pasar_un_arbol_normal() {
559        // El umbral no se eligió por teoría: `LLIMPHI_PERF=1 stress_idle` mide
560        // `view` en 190-326 µs de media y **727 µs de p95** sobre 987 nodos. Si
561        // alguien bajara `UMBRAL_VIEW_MS` cerca de eso, el vigía empezaría a
562        // denunciar cuadros sanos y —por la regla de arriba— el aviso de verdad
563        // quedaría enterrado. Esto fija el margen medido: 20× sobre el p95.
564        const P95_MEDIDO_MS: f64 = 0.727;
565        assert!(
566            UMBRAL_VIEW_MS >= P95_MEDIDO_MS * 20.0,
567            "el umbral de view ({UMBRAL_VIEW_MS} ms) quedó a menos de 20× del peor \
568             cuadro medido de un árbol normal ({P95_MEDIDO_MS} ms)"
569        );
570        // Y por el otro lado: si subiera de un cuadro a 60 Hz dejaría de
571        // significar «esto solo ya no deja llegar a 60 fps».
572        assert!(UMBRAL_VIEW_MS <= 1000.0 / 60.0);
573    }
574}