tawasuyu/llimphi
Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.
chore: refresco desde el monorepo — vello 0.9 / wgpu 29, 120 miembros
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}