//! `accordion` y `stepper` montados. //! //! Los núcleos (qué queda abierto, a qué paso se puede ir) se prueban en sus //! crates. Acá se prueba lo que sólo se ve montando: que el cuerpo de una //! sección cerrada **no se construya** —que es de lo que se trata el widget—, //! que el estado de cada paso llegue al nombre accesible, y que el pie diga //! «Finalizar» cuando no hay siguiente. //! //! Sin GPU (CLAUDE.md §8). use llimphi_test::query::{Sel, Ui}; use llimphi_ui::Role; use llimphi_ui::{llimphi_layout::Style, View}; use llimphi_widget_accordion::{accordion_view, AccordionPalette, AccordionSpec, Modo, Seccion}; use llimphi_widget_stepper::{stepper_view, Paso, StepperPalette, StepperSpec}; use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::Arc; #[derive(Clone, Debug, PartialEq)] enum Msg { Alternar(usize), IrA(usize), Finalizar, } /// Cuenta cuántos cuerpos se construyeron, **por llamada**. /// /// Un contador global sería una carrera: los tests corren en paralelo dentro /// del mismo proceso y se pisarían el conteo (visto: pasaba solo y fallaba con /// la suite entera). fn cuerpo(texto: &'static str, n: Arc) -> impl FnOnce() -> View { move || { n.fetch_add(1, Ordering::Relaxed); View::::new(Style::default()).text(texto, 12.0, llimphi_ui::peniko::Color::WHITE) } } fn acordeon_contando(abiertas: &[usize]) -> (Ui, usize) { let n = Arc::new(AtomicUsize::new(0)); let (a, b, c) = (n.clone(), n.clone(), n.clone()); let v: View = accordion_view(AccordionSpec { secciones: vec![ Seccion::new("Datos personales", cuerpo("cuerpo-datos", a)), Seccion::new("Facturación", cuerpo("cuerpo-factura", b)).con_resumen("Visa ••4242"), Seccion::new("Envío", cuerpo("cuerpo-envio", c)).habilitada(false), ], abiertas, modo: Modo::Una, on_toggle: Msg::Alternar, palette: AccordionPalette::default(), }); let ui = Ui::new(v, 400.0, 400.0); let construidos = n.load(Ordering::Relaxed); (ui, construidos) } fn acordeon(abiertas: &[usize]) -> Ui { acordeon_contando(abiertas).0 } #[test] fn el_cuerpo_de_una_seccion_cerrada_no_se_construye() { // Es el punto del widget: en un acordeón de veinte secciones con // formularios adentro, armar los diecinueve que no se ven es el costo real. let (ui, construidos) = acordeon_contando(&[1]); assert_eq!(construidos, 1, "sólo el cuerpo abierto"); assert!(ui.find("cuerpo-factura").is_some(), "{}", ui.dump()); assert!(ui.find("cuerpo-datos").is_none()); // Ninguna abierta: ningún cuerpo. assert_eq!(acordeon_contando(&[]).1, 0); } #[test] fn la_cabecera_declara_si_esta_desplegada_y_emite_su_indice() { let ui = acordeon(&[1]); let cerrada = ui.get(Sel::new().role(Role::Button).label("Datos personales")); assert_eq!(cerrada.is_expanded(), Some(false)); assert_eq!(cerrada.click(), Some(Msg::Alternar(0))); let abierta = ui.get(Sel::new().role(Role::Button).label("Facturación")); assert_eq!(abierta.is_expanded(), Some(true)); } #[test] fn una_seccion_deshabilitada_se_ve_pero_no_abre() { let ui = acordeon(&[]); let envio = ui.get(Sel::new().role(Role::Button).label("Envío")); assert!(!envio.is_enabled(), "{}", ui.dump()); assert_eq!(envio.click(), None); } #[test] fn el_resumen_solo_se_ve_con_la_seccion_cerrada() { // Cerrada: el resumen dice cómo quedó sin tener que abrirla. assert!(acordeon(&[]).find("Visa ••4242").is_some()); // Abierta: el dato ya está a la vista y el rótulo repetido es ruido. assert!(acordeon(&[1]).find("Visa ••4242").is_none()); } // ── stepper ────────────────────────────────────────────────────────────── fn asistente(pasos: Vec, actual: usize) -> Ui { let v: View = stepper_view(StepperSpec { pasos: &pasos, actual, cuerpo: View::::new(Style::default()), on_ir: Msg::IrA, on_finalizar: Some(Msg::Finalizar), palette: StepperPalette::default(), }); Ui::new(v, 600.0, 300.0) } fn pasos() -> Vec { vec![ Paso::hecho("Datos"), Paso::actual("Pago"), Paso::pendiente("Envío"), Paso::pendiente("Confirmar"), ] } #[test] fn el_estado_de_cada_paso_llega_al_nombre_accesible() { // Si el estado viviera sólo en el color del círculo, un lector de pantalla // no podría decir en qué anda el asistente. let ui = asistente(pasos(), 1); assert!(ui.find("Paso 1 de 4: Datos (hecho)").is_some(), "{}", ui.dump()); assert!(ui.find("Paso 2 de 4: Pago (en curso)").is_some()); assert!(ui.find("Paso 3 de 4: Envío (pendiente)").is_some()); let mut con_error = pasos(); con_error[0] = Paso::con_error("Datos"); assert!(asistente(con_error, 1).find("Paso 1 de 4: Datos (con error)").is_some()); } #[test] fn solo_se_puede_saltar_a_donde_la_regla_deja() { let ui = asistente(pasos(), 1); // Atrás, siempre. assert_eq!( ui.get(Sel::new().role(Role::Button).label("Paso 1 de 4: Datos (hecho)")).click(), Some(Msg::IrA(0)) ); // Adelante no, porque el paso actual no está resuelto — y se declara // deshabilitado, no mudo. let siguiente = ui.get(Sel::new().role(Role::Button).label("Paso 3 de 4: Envío (pendiente)")); assert!(!siguiente.is_enabled()); assert_eq!(siguiente.click(), None); } #[test] fn el_pie_dice_finalizar_cuando_no_hay_siguiente() { // Llamar «Siguiente» al botón cuando no hay siguiente es la mentira más // común de un wizard. let ui = asistente(pasos(), 3); assert!(ui.find("Siguiente").is_none(), "{}", ui.dump()); assert_eq!(ui.get("Finalizar").click(), Some(Msg::Finalizar)); assert_eq!(ui.get("Atrás").click(), Some(Msg::IrA(2))); } #[test] fn los_botones_del_pie_se_apagan_pero_no_desaparecen() { // Primer paso: «Atrás» apagado. Si desapareciera, «Siguiente» se correría // de lugar entre pasos. let ui = asistente(pasos(), 0); let atras = ui.get("Atrás"); assert!(!atras.is_enabled()); assert_eq!(atras.click(), None); // El primero está Hecho, así que desde ahí sí se avanza. assert_eq!(ui.get("Siguiente").click(), Some(Msg::IrA(1))); // Y desde el segundo (sin resolver) «Siguiente» queda apagado, presente. let ui = asistente(pasos(), 1); assert!(!ui.get("Siguiente").is_enabled()); assert_eq!(ui.get("Siguiente").click(), None); }