tawasuyu

tawasuyu/llimphi

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

Rust 660Markdown 302TOML 157RON 312382 Sutichaykuna

⎇ main llimphi-test/tests/accordion_stepper.rs Rust · 179 siq'ikuna · 6.6 KB Ñawpa willay Pi imata llamkarqan ch'uya

chore: refresco desde el monorepo — vello 0.9 / wgpu 29, 120 miembros d251eb32b · Sergio ·

1//! `accordion` y `stepper` montados.
2//!
3//! Los núcleos (qué queda abierto, a qué paso se puede ir) se prueban en sus
4//! crates. Acá se prueba lo que sólo se ve montando: que el cuerpo de una
5//! sección cerrada **no se construya** —que es de lo que se trata el widget—,
6//! que el estado de cada paso llegue al nombre accesible, y que el pie diga
7//! «Finalizar» cuando no hay siguiente.
8//!
9//! Sin GPU (CLAUDE.md §8).
10
11use llimphi_test::query::{Sel, Ui};
12use llimphi_ui::Role;
13use llimphi_ui::{llimphi_layout::Style, View};
14use llimphi_widget_accordion::{accordion_view, AccordionPalette, AccordionSpec, Modo, Seccion};
15use llimphi_widget_stepper::{stepper_view, Paso, StepperPalette, StepperSpec};
16
17use std::sync::atomic::{AtomicUsize, Ordering};
18use std::sync::Arc;
19
20#[derive(Clone, Debug, PartialEq)]
21enum Msg {
22    Alternar(usize),
23    IrA(usize),
24    Finalizar,
25}
26
27/// Cuenta cuántos cuerpos se construyeron, **por llamada**.
28///
29/// Un contador global sería una carrera: los tests corren en paralelo dentro
30/// del mismo proceso y se pisarían el conteo (visto: pasaba solo y fallaba con
31/// la suite entera).
32fn cuerpo(texto: &'static str, n: Arc<AtomicUsize>) -> impl FnOnce() -> View<Msg> {
33    move || {
34        n.fetch_add(1, Ordering::Relaxed);
35        View::<Msg>::new(Style::default()).text(texto, 12.0, llimphi_ui::peniko::Color::WHITE)
36    }
37}
38
39fn acordeon_contando(abiertas: &[usize]) -> (Ui<Msg>, usize) {
40    let n = Arc::new(AtomicUsize::new(0));
41    let (a, b, c) = (n.clone(), n.clone(), n.clone());
42    let v: View<Msg> = accordion_view(AccordionSpec {
43        secciones: vec![
44            Seccion::new("Datos personales", cuerpo("cuerpo-datos", a)),
45            Seccion::new("Facturación", cuerpo("cuerpo-factura", b)).con_resumen("Visa ••4242"),
46            Seccion::new("Envío", cuerpo("cuerpo-envio", c)).habilitada(false),
47        ],
48        abiertas,
49        modo: Modo::Una,
50        on_toggle: Msg::Alternar,
51        palette: AccordionPalette::default(),
52    });
53    let ui = Ui::new(v, 400.0, 400.0);
54    let construidos = n.load(Ordering::Relaxed);
55    (ui, construidos)
56}
57
58fn acordeon(abiertas: &[usize]) -> Ui<Msg> {
59    acordeon_contando(abiertas).0
60}
61
62#[test]
63fn el_cuerpo_de_una_seccion_cerrada_no_se_construye() {
64    // Es el punto del widget: en un acordeón de veinte secciones con
65    // formularios adentro, armar los diecinueve que no se ven es el costo real.
66    let (ui, construidos) = acordeon_contando(&[1]);
67    assert_eq!(construidos, 1, "sólo el cuerpo abierto");
68    assert!(ui.find("cuerpo-factura").is_some(), "{}", ui.dump());
69    assert!(ui.find("cuerpo-datos").is_none());
70
71    // Ninguna abierta: ningún cuerpo.
72    assert_eq!(acordeon_contando(&[]).1, 0);
73}
74
75#[test]
76fn la_cabecera_declara_si_esta_desplegada_y_emite_su_indice() {
77    let ui = acordeon(&[1]);
78    let cerrada = ui.get(Sel::new().role(Role::Button).label("Datos personales"));
79    assert_eq!(cerrada.is_expanded(), Some(false));
80    assert_eq!(cerrada.click(), Some(Msg::Alternar(0)));
81
82    let abierta = ui.get(Sel::new().role(Role::Button).label("Facturación"));
83    assert_eq!(abierta.is_expanded(), Some(true));
84}
85
86#[test]
87fn una_seccion_deshabilitada_se_ve_pero_no_abre() {
88    let ui = acordeon(&[]);
89    let envio = ui.get(Sel::new().role(Role::Button).label("Envío"));
90    assert!(!envio.is_enabled(), "{}", ui.dump());
91    assert_eq!(envio.click(), None);
92}
93
94#[test]
95fn el_resumen_solo_se_ve_con_la_seccion_cerrada() {
96    // Cerrada: el resumen dice cómo quedó sin tener que abrirla.
97    assert!(acordeon(&[]).find("Visa ••4242").is_some());
98    // Abierta: el dato ya está a la vista y el rótulo repetido es ruido.
99    assert!(acordeon(&[1]).find("Visa ••4242").is_none());
100}
101
102// ── stepper ──────────────────────────────────────────────────────────────
103
104fn asistente(pasos: Vec<Paso>, actual: usize) -> Ui<Msg> {
105    let v: View<Msg> = stepper_view(StepperSpec {
106        pasos: &pasos,
107        actual,
108        cuerpo: View::<Msg>::new(Style::default()),
109        on_ir: Msg::IrA,
110        on_finalizar: Some(Msg::Finalizar),
111        palette: StepperPalette::default(),
112    });
113    Ui::new(v, 600.0, 300.0)
114}
115
116fn pasos() -> Vec<Paso> {
117    vec![
118        Paso::hecho("Datos"),
119        Paso::actual("Pago"),
120        Paso::pendiente("Envío"),
121        Paso::pendiente("Confirmar"),
122    ]
123}
124
125#[test]
126fn el_estado_de_cada_paso_llega_al_nombre_accesible() {
127    // Si el estado viviera sólo en el color del círculo, un lector de pantalla
128    // no podría decir en qué anda el asistente.
129    let ui = asistente(pasos(), 1);
130    assert!(ui.find("Paso 1 de 4: Datos (hecho)").is_some(), "{}", ui.dump());
131    assert!(ui.find("Paso 2 de 4: Pago (en curso)").is_some());
132    assert!(ui.find("Paso 3 de 4: Envío (pendiente)").is_some());
133
134    let mut con_error = pasos();
135    con_error[0] = Paso::con_error("Datos");
136    assert!(asistente(con_error, 1).find("Paso 1 de 4: Datos (con error)").is_some());
137}
138
139#[test]
140fn solo_se_puede_saltar_a_donde_la_regla_deja() {
141    let ui = asistente(pasos(), 1);
142    // Atrás, siempre.
143    assert_eq!(
144        ui.get(Sel::new().role(Role::Button).label("Paso 1 de 4: Datos (hecho)")).click(),
145        Some(Msg::IrA(0))
146    );
147    // Adelante no, porque el paso actual no está resuelto — y se declara
148    // deshabilitado, no mudo.
149    let siguiente = ui.get(Sel::new().role(Role::Button).label("Paso 3 de 4: Envío (pendiente)"));
150    assert!(!siguiente.is_enabled());
151    assert_eq!(siguiente.click(), None);
152}
153
154#[test]
155fn el_pie_dice_finalizar_cuando_no_hay_siguiente() {
156    // Llamar «Siguiente» al botón cuando no hay siguiente es la mentira más
157    // común de un wizard.
158    let ui = asistente(pasos(), 3);
159    assert!(ui.find("Siguiente").is_none(), "{}", ui.dump());
160    assert_eq!(ui.get("Finalizar").click(), Some(Msg::Finalizar));
161    assert_eq!(ui.get("Atrás").click(), Some(Msg::IrA(2)));
162}
163
164#[test]
165fn los_botones_del_pie_se_apagan_pero_no_desaparecen() {
166    // Primer paso: «Atrás» apagado. Si desapareciera, «Siguiente» se correría
167    // de lugar entre pasos.
168    let ui = asistente(pasos(), 0);
169    let atras = ui.get("Atrás");
170    assert!(!atras.is_enabled());
171    assert_eq!(atras.click(), None);
172    // El primero está Hecho, así que desde ahí sí se avanza.
173    assert_eq!(ui.get("Siguiente").click(), Some(Msg::IrA(1)));
174
175    // Y desde el segundo (sin resolver) «Siguiente» queda apagado, presente.
176    let ui = asistente(pasos(), 1);
177    assert!(!ui.get("Siguiente").is_enabled());
178    assert_eq!(ui.get("Siguiente").click(), None);
179}