//! Certifica **por texto** que los widgets del catálogo declaran semántica //! accesible — sin renderizar un píxel ni tocar la GPU (CLAUDE.md §8). //! //! Cada test monta un widget real y le pregunta al árbol qué significa. Es el //! mismo dato que `llimphi-ui::a11y` traduce a AccessKit cada frame, así que un //! test verde acá es también la garantía de que un lector de pantalla encuentra //! el control. use llimphi_test::query::{Sel, Ui}; use llimphi_ui::Role; use llimphi_ui::{llimphi_layout::Style, View}; #[derive(Clone, Debug, PartialEq)] enum Msg { Tab(usize), Cerrar(usize), Fila(usize), Toggle(usize), } fn tema() -> llimphi_theme::Theme { llimphi_theme::Theme::default() } // ───────────────────────────────────────────────────────────────────────── // tabs — TabList + Tab con estado, y el × con nombre propio // ───────────────────────────────────────────────────────────────────────── #[test] fn tabs_declara_tablist_y_tabs_con_estado() { use llimphi_widget_tabs::{tabs_view, TabsPalette, TabsSpec}; let v = tabs_view(TabsSpec { labels: vec!["Archivo".into(), "Editar".into(), "Ver".into()], active: 1, on_select: Msg::Tab, content: View::new(Style::default()), tab_height: 28.0, palette: TabsPalette::from_theme(&tema()), tab_width: None, on_close: Some(std::sync::Arc::new(Msg::Cerrar)), on_reorder: None, strip_offset: 0.0, on_strip_scroll: None, }); let ui = Ui::new(v, 600.0, 400.0); assert!(ui.find(Role::TabList).is_some(), "falta el contenedor:\n{}", ui.dump()); let tabs = ui.find_all(Role::Tab); assert_eq!(tabs.len(), 3); // El nombre accesible sale del texto visible del tab. assert_eq!(tabs[0].name(), Some("Archivo")); // El activo es el único con pressed=true. assert_eq!(tabs[0].is_pressed(), Some(false)); assert_eq!(tabs[1].is_pressed(), Some(true)); // Y activar el tab devuelve el Msg que la app espera. assert_eq!(tabs[2].click(), Some(Msg::Tab(2))); } #[test] fn el_boton_de_cierre_no_se_llama_solo_x() { use llimphi_widget_tabs::{tabs_view, TabsPalette, TabsSpec}; let v = tabs_view(TabsSpec { labels: vec!["informe.md".into()], active: 0, on_select: Msg::Tab, content: View::new(Style::default()), tab_height: 28.0, palette: TabsPalette::from_theme(&tema()), tab_width: None, on_close: Some(std::sync::Arc::new(Msg::Cerrar)), on_reorder: None, strip_offset: 0.0, on_strip_scroll: None, }); let ui = Ui::new(v, 600.0, 400.0); // Sin aria_label el lector diría "×" y nada más. let cerrar = ui.get(Sel::new().role(Role::Button).label("Cerrar informe.md")); assert_eq!(cerrar.click(), Some(Msg::Cerrar(0))); } // ───────────────────────────────────────────────────────────────────────── // tree — TreeItem con expanded sólo donde tiene sentido // ───────────────────────────────────────────────────────────────────────── #[test] fn tree_declara_expanded_solo_en_nodos_con_hijos() { use llimphi_widget_tree::{tree_view, TreePalette, TreeRow, TreeSpec}; let v = tree_view(TreeSpec { rows: vec![ TreeRow::new("src", 0, true, true, false, Msg::Toggle(0), Msg::Fila(0)), TreeRow::new("main.rs", 1, false, false, true, Msg::Toggle(1), Msg::Fila(1)), TreeRow::new("target", 0, true, false, false, Msg::Toggle(2), Msg::Fila(2)), ], row_height: 22.0, indent_px: 12.0, palette: TreePalette::from_theme(&tema()), guides: false, }); let ui = Ui::new(v, 400.0, 400.0); assert!(ui.find(Role::Tree).is_some()); let filas = ui.find_all(Role::TreeItem); assert_eq!(filas.len(), 3); // Carpeta desplegada. assert_eq!(filas[0].name(), Some("src")); assert_eq!(filas[0].is_expanded(), Some(true)); // Hoja: `expanded` NO se declara — "no aplica" ≠ "plegado". assert_eq!(filas[1].is_expanded(), None, "una hoja no se pliega"); assert_eq!(filas[1].is_checked(), Some(true), "es la seleccionada"); // Carpeta plegada. assert_eq!(filas[2].is_expanded(), Some(false)); } // ───────────────────────────────────────────────────────────────────────── // list — List + ListItem con selección // ───────────────────────────────────────────────────────────────────────── #[test] fn list_declara_items_y_cual_esta_seleccionado() { use llimphi_widget_list::{list_view, ListPalette, ListRow, ListSpec}; let v = list_view(ListSpec { rows: vec![ ListRow { label: "alfa".into(), selected: false, on_click: Msg::Fila(0) }, ListRow { label: "beta".into(), selected: true, on_click: Msg::Fila(1) }, ], total: 2, caption: None, truncated_hint: None, row_height: 22.0, palette: ListPalette::from_theme(&tema()), }); let ui = Ui::new(v, 400.0, 300.0); assert!(ui.find(Role::List).is_some()); let sel = ui.get(Sel::new().role(Role::ListItem).checked(true)); assert_eq!(sel.name(), Some("beta")); assert_eq!(sel.click(), Some(Msg::Fila(1))); } // ───────────────────────────────────────────────────────────────────────── // progress — el caso que sin semántica es literalmente una caja de color // ───────────────────────────────────────────────────────────────────────── #[test] fn progress_enuncia_el_avance() { use llimphi_ui::llimphi_raster::peniko::Color; let v: View = llimphi_widget_progress::linear_progress_view( 0.42, Color::from_rgba8(40, 40, 40, 255), Color::from_rgba8(90, 160, 250, 255), 6.0, ); let ui = Ui::new(v, 200.0, 20.0); let barra = ui.get(Role::ProgressBar); assert_eq!(barra.value(), Some("42%")); } // ───────────────────────────────────────────────────────────────────────── // La auditoría: qué quedó interactivo pero mudo // ───────────────────────────────────────────────────────────────────────── #[test] fn los_tabs_no_dejan_nodos_clickeables_sin_rol() { use llimphi_widget_tabs::{tabs_view, TabsPalette, TabsSpec}; let v = tabs_view(TabsSpec { labels: vec!["uno".into(), "dos".into()], active: 0, on_select: Msg::Tab, content: View::new(Style::default()), tab_height: 28.0, palette: TabsPalette::from_theme(&tema()), tab_width: None, on_close: Some(std::sync::Arc::new(Msg::Cerrar)), on_reorder: None, strip_offset: 0.0, on_strip_scroll: None, }); let ui = Ui::new(v, 600.0, 400.0); // Un nodo clickeable sin rol es exactamente lo que un lector no sabe // anunciar. En este widget no debería quedar ninguno. let mudos = ui.find_all(Sel::new().clickable(true).role_none()); assert!( mudos.is_empty(), "{} nodo(s) clickeable(s) sin semántica:\n{}", mudos.len(), ui.dump() ); } // ───────────────────────────────────────────────────────────────────────── // transport — el caso más crudo: TODOS los botones son sólo-ícono // ───────────────────────────────────────────────────────────────────────── #[test] fn transport_nombra_cada_boton_y_refleja_el_estado() { use llimphi_widget_transport::{transport_button_view, TransportButton, TransportPalette}; let pal = TransportPalette::from_theme(&tema()); // El botón de play dice qué VA A HACER, no cómo se llama. let sonando: View = transport_button_view( TransportButton::PlayPause { playing: true }, &pal, |_a| Msg::Fila(0), ); let ui = Ui::new(sonando, 60.0, 40.0); let b = ui.get(Role::Button); assert_eq!(b.name(), Some("Pausar")); assert_eq!(b.is_pressed(), Some(true)); let pausado: View = transport_button_view( TransportButton::PlayPause { playing: false }, &pal, |_a| Msg::Fila(0), ); let ui = Ui::new(pausado, 60.0, 40.0); assert_eq!(ui.get(Role::Button).name(), Some("Reproducir")); // Y el salto lleva los segundos adentro del nombre. let salto: View = transport_button_view( TransportButton::SeekForward { secs: 10 }, &pal, |_a| Msg::Fila(0), ); let ui = Ui::new(salto, 60.0, 40.0); assert_eq!(ui.get(Role::Button).name(), Some("Avanzar 10 segundos")); } // ───────────────────────────────────────────────────────────────────────── // calendar — un "14" suelto no ubica a nadie // ───────────────────────────────────────────────────────────────────────── #[test] fn calendar_nombra_los_dias_con_la_fecha_completa() { use llimphi_widget_calendar::{calendar_view, CalendarPalette, CalendarSpec, WeekStart}; let sel = chrono::NaiveDate::from_ymd_opt(2026, 7, 14).unwrap(); let v: View = calendar_view(CalendarSpec { marcados: Vec::new(), view_year: 2026, view_month: 7, selected: Some(sel), today: None, week_start: WeekStart::Monday, palette: CalendarPalette::from_theme(&tema()), on_select: std::sync::Arc::new(|_d| Msg::Fila(0)), on_view_change: std::sync::Arc::new(|_y, _m| Msg::Fila(1)), }); let ui = Ui::new(v, 400.0, 400.0); // El día seleccionado se nombra entero y se marca. let dia = ui.get(Sel::new().role(Role::Button).label("14 de julio de 2026")); assert_eq!(dia.is_checked(), Some(true)); // Los chevrones de mes tienen nombre, no "‹" / "›". assert!(ui.find("Mes anterior").is_some(), "{}", ui.dump()); assert!(ui.find("Mes siguiente").is_some()); // Sólo un día marcado. assert_eq!(ui.find_all(Sel::new().role(Role::Button).checked(true)).len(), 1); } /// Un calendario de julio de 2026 con las opciones que se le pidan. fn calendario_julio( opciones: llimphi_widget_calendar::CalendarOpciones, ) -> Ui { use llimphi_widget_calendar::{calendar_view_ext, CalendarPalette, CalendarSpec, WeekStart}; let v: View = calendar_view_ext( CalendarSpec { marcados: Vec::new(), view_year: 2026, view_month: 7, selected: None, today: None, week_start: WeekStart::Monday, palette: CalendarPalette::from_theme(&tema()), on_select: std::sync::Arc::new(|_d| Msg::Fila(0)), on_view_change: std::sync::Arc::new(|_y, _m| Msg::Fila(1)), }, opciones, ); Ui::new(v, 400.0, 460.0) } fn fecha(y: i32, m: u32, d: u32) -> chrono::NaiveDate { chrono::NaiveDate::from_ymd_opt(y, m, d).unwrap() } #[test] fn calendar_apaga_los_dias_fuera_de_limites_y_no_deja_clickearlos() { use llimphi_widget_calendar::{CalendarOpciones, Limites}; let ui = calendario_julio(CalendarOpciones { limites: Limites::entre(fecha(2026, 7, 10), fecha(2026, 7, 20)), ..Default::default() }); // Un día de adentro se puede elegir. let dentro = ui.get(Sel::new().role(Role::Button).label("15 de julio de 2026")); assert!(dentro.is_enabled(), "{}", ui.dump()); assert!(dentro.click().is_some(), "un día dentro de límites debe emitir su Msg"); // Los bordes son inclusive. assert!(ui.get(Sel::new().role(Role::Button).label("10 de julio de 2026")).is_enabled()); assert!(ui.get(Sel::new().role(Role::Button).label("20 de julio de 2026")).is_enabled()); // Uno de afuera se ve (ubica en la grilla) pero no se puede elegir: si // desapareciera, la grilla se correría y julio dejaría de tener forma de // julio. let fuera = ui.get(Sel::new().role(Role::Button).label("21 de julio de 2026")); assert!(!fuera.is_enabled(), "el día fuera de límites debe quedar deshabilitado"); assert!(fuera.click().is_none(), "un día fuera de límites no puede emitir Msg"); } #[test] fn calendar_apaga_la_flecha_del_mes_que_esta_entero_fuera() { use llimphi_widget_calendar::{CalendarOpciones, Limites}; // Mínimo el 1 de julio: junio queda entero afuera, agosto no. let ui = calendario_julio(CalendarOpciones { limites: Limites::desde(fecha(2026, 7, 1)), ..Default::default() }); let anterior = ui.get("Mes anterior"); assert!(!anterior.is_enabled(), "{}", ui.dump()); assert!(anterior.click().is_none(), "la flecha apagada no navega"); // Y sigue estando, no desaparece: el header no cambia de ancho. assert!(ui.get("Mes siguiente").is_enabled()); } #[test] fn calendar_marca_la_banda_del_rango_y_sus_dos_extremos() { use llimphi_widget_calendar::{CalendarOpciones, RangoSeleccion}; let rango = RangoSeleccion::default().click(fecha(2026, 7, 10)).click(fecha(2026, 7, 14)); let ui = calendario_julio(CalendarOpciones { rango: Some(rango), ..Default::default() }); // Del 10 al 14 son cinco días marcados, ni uno más. let marcados = ui.find_all(Sel::new().role(Role::Button).checked(true)); assert_eq!(marcados.len(), 5, "{}", ui.dump()); for d in 10..=14 { let dia = ui.get(Sel::new().role(Role::Button).label(format!("{d} de julio de 2026"))); assert_eq!(dia.is_checked(), Some(true), "el {d} debería estar en la banda"); } // Los vecinos de afuera, no. for d in [9, 15] { let dia = ui.get(Sel::new().role(Role::Button).label(format!("{d} de julio de 2026"))); assert_eq!(dia.is_checked(), Some(false), "el {d} está fuera del rango"); } } #[test] fn calendar_con_navegacion_anual_suma_las_flechas_de_ano() { use llimphi_widget_calendar::CalendarOpciones; let ui = calendario_julio(CalendarOpciones { navegacion_anual: true, ..Default::default() }); assert!(ui.find("Año anterior").is_some(), "{}", ui.dump()); assert!(ui.find("Año siguiente").is_some()); // Y las de mes siguen ahí: son dos ejes, no un reemplazo. assert!(ui.find("Mes anterior").is_some()); assert!(ui.find("Mes siguiente").is_some()); // Sin pedirlas, no están (el default es el calendario de siempre). let base = calendario_julio(CalendarOpciones::default()); assert!(base.find("Año anterior").is_none()); } // ───────────────────────────────────────────────────────────────────────── // toolbar — lo que no entra se esconde bien, y el hover dice qué es // ───────────────────────────────────────────────────────────────────────── #[test] fn toolbar_esconde_lo_que_no_entra_detras_de_un_boton_que_se_nombra() { use llimphi_widget_toolbar::{ items_ocultos, toolbar_view_ext, ToolbarGroup, ToolbarItem, ToolbarOpciones, ToolbarPalette, }; let icono = |_s: f32, _c: llimphi_ui::peniko::Color| View::::new(Style::default()); let grupos = || { vec![ ToolbarGroup::new(vec![ ToolbarItem::new(icono, Msg::Fila(0)).with_label("subir"), ToolbarItem::new(icono, Msg::Fila(1)).with_label("bajar"), ]), ToolbarGroup::new(vec![ ToolbarItem::new(icono, Msg::Fila(2)).with_label("lista"), ToolbarItem::new(icono, Msg::Fila(3)).with_label("iconos"), ]), ] }; // Corte en 2: quedan dos ítems y el «⋯», que dice cuántos esconde. let ocultos = items_ocultos(&grupos(), 2); assert_eq!(ocultos.len(), 2); let v = toolbar_view_ext( grupos(), 36.0, &ToolbarPalette::from_theme(&tema()), ToolbarOpciones { visibles: Some(2), on_overflow: Some(Msg::Toggle(9)), ..Default::default() }, ); let ui = Ui::new(v, 300.0, 40.0); assert!(ui.find("subir").is_some(), "{}", ui.dump()); assert!(ui.find("bajar").is_some()); assert!(ui.find("lista").is_none(), "el ítem cortado no debe seguir en la barra"); let mas = ui.get("Más herramientas (2)"); assert_eq!(mas.click(), Some(Msg::Toggle(9))); // Sin corte, el «⋯» no existe: un botón de overflow con cero escondidos es // ruido. let v = toolbar_view_ext( grupos(), 36.0, &ToolbarPalette::from_theme(&tema()), ToolbarOpciones { on_overflow: Some(Msg::Toggle(9)), ..Default::default() }, ); let ui = Ui::new(v, 600.0, 40.0); assert!(ui.find_all(Sel::new().role(Role::Button)).iter().all(|n| n .name() .is_none_or(|s| !s.starts_with("Más herramientas")))); } #[test] fn toolbar_avisa_el_hover_de_cada_item_para_el_tooltip() { use llimphi_widget_toolbar::{ toolbar_view_ext, ToolbarGroup, ToolbarItem, ToolbarOpciones, ToolbarPalette, }; let icono = |_s: f32, _c: llimphi_ui::peniko::Color| View::::new(Style::default()); let v = toolbar_view_ext( vec![ToolbarGroup::new(vec![ ToolbarItem::new(icono, Msg::Fila(0)).with_label("subir"), ToolbarItem::new(icono, Msg::Fila(1)).with_label("bajar"), ])], 36.0, &ToolbarPalette::from_theme(&tema()), ToolbarOpciones { on_hover: Some(std::sync::Arc::new(|i: Option| { Msg::Toggle(i.map_or(99, |i| i)) })), ..Default::default() }, ); let ui = Ui::new(v, 300.0, 40.0); // Cada ítem avisa con SU índice al entrar, y con `None` al salir. let segundo = ui.get(Sel::new().role(Role::Button).label("bajar")); assert_eq!(segundo.pointer_enter(), Some(Msg::Toggle(1))); assert_eq!(segundo.pointer_leave(), Some(Msg::Toggle(99))); assert_eq!( ui.get(Sel::new().role(Role::Button).label("subir")).pointer_enter(), Some(Msg::Toggle(0)) ); } // ───────────────────────────────────────────────────────────────────────── // splitter — el divisor se agarra con el mouse; con teclado hay que poder // ───────────────────────────────────────────────────────────────────────── #[test] fn splitter_puede_dar_foco_y_doble_click_al_divisor() { use llimphi_widget_splitter::{ splitter_two, splitter_two_ext, Direction, PaneSize, SplitterOpciones, SplitterPalette, }; let pane = || View::::new(Style::default()); let pal = SplitterPalette::from_theme(&tema()); // Por default el divisor sigue siendo lo de siempre: se arrastra y nada // más — ni foco (no entra en el orden de Tab) ni doble-click. let base = splitter_two( Direction::Row, pane(), PaneSize::Fixed(100.0), pane(), PaneSize::Flex, |_p, _d| None::, &pal, ); let ui = Ui::new(base, 400.0, 200.0); let div = ui.get(Role::Splitter); assert!(!div.is_focusable(), "{}", ui.dump()); assert_eq!(div.double_tap(), None); // Con opciones, el divisor entra en el orden de Tab y el doble-click // emite el Msg del host — que es el que colapsa el pane. let con_opts = splitter_two_ext( Direction::Row, pane(), PaneSize::Fixed(100.0), pane(), PaneSize::Flex, |_p, _d| None::, &pal, SplitterOpciones { on_toggle: Some(Msg::Toggle(7)), focus_id: Some(42) }, ); let ui = Ui::new(con_opts, 400.0, 200.0); let div = ui.get(Role::Splitter); assert!(div.is_focusable(), "sin foco no hay resize por teclado: {}", ui.dump()); assert_eq!(div.double_tap(), Some(Msg::Toggle(7))); // Y sigue nombrándose para el lector de pantalla. assert_eq!(div.name(), Some("Divisor de paneles")); } // ───────────────────────────────────────────────────────────────────────── // table — la estructura completa Table > Row > Cell/ColumnHeader // ───────────────────────────────────────────────────────────────────────── #[test] fn table_expone_la_estructura_tabular() { use llimphi_widget_table::{table_view, TablePalette}; let v: View = table_view( &["Nombre".to_string(), "Cantidad".to_string()], &[ vec!["clavos".to_string(), "12".to_string()], vec!["tornillos".to_string(), "4".to_string()], ], None, None, "Agregar", &TablePalette::from_theme(&tema()), |_r, _c| Msg::Fila(0), |_r| Msg::Fila(1), || Msg::Fila(2), ); let ui = Ui::new(v, 600.0, 300.0); assert!(ui.find(Role::Table).is_some()); assert_eq!(ui.find_all(Role::ColumnHeader).len(), 2); // 1 fila de encabezado + 2 de datos. assert_eq!(ui.find_all(Role::Row).len(), 3); assert!(ui.find(Sel::new().role(Role::ColumnHeader).label("Cantidad")).is_some()); // El "×" de quitar fila tiene nombre. assert_eq!(ui.find_all(Sel::new().label("Quitar fila")).len(), 2); } // ───────────────────────────────────────────────────────────────────────── // field — el grupo lleva nombre, estado y el error como descripción // ───────────────────────────────────────────────────────────────────────── #[test] fn field_agrupa_label_estado_y_error() { use llimphi_widget_field::{field_view, FieldPalette, FieldSpec}; let v: View = field_view(FieldSpec { label: "Correo".into(), control: View::new(Style::default()), required: true, helper: Some("Lo usamos para avisarte".into()), error: Some("Formato inválido".into()), palette: FieldPalette::from_theme(&tema()), }); let ui = Ui::new(v, 400.0, 120.0); let g = ui.get(Sel::new().role(Role::Group).label("Correo")); assert_eq!(g.is_required(), Some(true)); // El error gana sobre el helper — es lo que el lector debe enunciar. assert_eq!(g.description(), Some("Formato inválido")); // El asterisco es notación visual: no ensucia el nombre accesible. assert_eq!(g.name(), Some("Correo")); } // ───────────────────────────────────────────────────────────────────────── // Determinado vs indeterminado // ───────────────────────────────────────────────────────────────────────── #[test] fn spinner_es_progreso_sin_valor_y_progress_con_valor() { use llimphi_ui::llimphi_raster::peniko::Color; let spin: View = llimphi_widget_spinner::spinner_view(Color::from_rgba8(200, 200, 200, 255), 0.12, 1.0); let ui = Ui::new(spin, 40.0, 40.0); let s = ui.get(Role::ProgressBar); assert_eq!(s.name(), Some("Cargando")); assert_eq!(s.value(), None, "indeterminado: sin porcentaje"); let barra: View = llimphi_widget_progress::linear_progress_view( 1.0, Color::from_rgba8(40, 40, 40, 255), Color::from_rgba8(90, 160, 250, 255), 6.0, ); let ui = Ui::new(barra, 200.0, 20.0); assert_eq!(ui.get(Role::ProgressBar).value(), Some("100%")); }