tawasuyu

tawasuyu/llimphi

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

Rust 614Markdown 274TOML 149RON 310606 Sutichaykuna

⎇ main llimphi-test/tests/semantica_widgets.rs Rust · 577 siq'ikuna · 25.5 KB Ñawpa willay Pi imata llamkarqan ch'uya

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

1//! Certifica **por texto** que los widgets del catálogo declaran semántica
2//! accesible — sin renderizar un píxel ni tocar la GPU (CLAUDE.md §8).
3//!
4//! Cada test monta un widget real y le pregunta al árbol qué significa. Es el
5//! mismo dato que `llimphi-ui::a11y` traduce a AccessKit cada frame, así que un
6//! test verde acá es también la garantía de que un lector de pantalla encuentra
7//! el control.
8
9use llimphi_test::query::{Sel, Ui};
10use llimphi_ui::Role;
11use llimphi_ui::{llimphi_layout::Style, View};
12
13#[derive(Clone, Debug, PartialEq)]
14enum Msg {
15    Tab(usize),
16    Cerrar(usize),
17    Fila(usize),
18    Toggle(usize),
19}
20
21fn tema() -> llimphi_theme::Theme {
22    llimphi_theme::Theme::default()
23}
24
25// ─────────────────────────────────────────────────────────────────────────
26//  tabs — TabList + Tab con estado, y el × con nombre propio
27// ─────────────────────────────────────────────────────────────────────────
28
29#[test]
30fn tabs_declara_tablist_y_tabs_con_estado() {
31    use llimphi_widget_tabs::{tabs_view, TabsPalette, TabsSpec};
32    let v = tabs_view(TabsSpec {
33        labels: vec!["Archivo".into(), "Editar".into(), "Ver".into()],
34        active: 1,
35        on_select: Msg::Tab,
36        content: View::new(Style::default()),
37        tab_height: 28.0,
38        palette: TabsPalette::from_theme(&tema()),
39        tab_width: None,
40        on_close: Some(std::sync::Arc::new(Msg::Cerrar)),
41        on_reorder: None,
42        strip_offset: 0.0,
43        on_strip_scroll: None,
44    });
45    let ui = Ui::new(v, 600.0, 400.0);
46
47    assert!(ui.find(Role::TabList).is_some(), "falta el contenedor:\n{}", ui.dump());
48
49    let tabs = ui.find_all(Role::Tab);
50    assert_eq!(tabs.len(), 3);
51    // El nombre accesible sale del texto visible del tab.
52    assert_eq!(tabs[0].name(), Some("Archivo"));
53    // El activo es el único con pressed=true.
54    assert_eq!(tabs[0].is_pressed(), Some(false));
55    assert_eq!(tabs[1].is_pressed(), Some(true));
56    // Y activar el tab devuelve el Msg que la app espera.
57    assert_eq!(tabs[2].click(), Some(Msg::Tab(2)));
58}
59
60#[test]
61fn el_boton_de_cierre_no_se_llama_solo_x() {
62    use llimphi_widget_tabs::{tabs_view, TabsPalette, TabsSpec};
63    let v = tabs_view(TabsSpec {
64        labels: vec!["informe.md".into()],
65        active: 0,
66        on_select: Msg::Tab,
67        content: View::new(Style::default()),
68        tab_height: 28.0,
69        palette: TabsPalette::from_theme(&tema()),
70        tab_width: None,
71        on_close: Some(std::sync::Arc::new(Msg::Cerrar)),
72        on_reorder: None,
73        strip_offset: 0.0,
74        on_strip_scroll: None,
75    });
76    let ui = Ui::new(v, 600.0, 400.0);
77    // Sin aria_label el lector diría "×" y nada más.
78    let cerrar = ui.get(Sel::new().role(Role::Button).label("Cerrar informe.md"));
79    assert_eq!(cerrar.click(), Some(Msg::Cerrar(0)));
80}
81
82// ─────────────────────────────────────────────────────────────────────────
83//  tree — TreeItem con expanded sólo donde tiene sentido
84// ─────────────────────────────────────────────────────────────────────────
85
86#[test]
87fn tree_declara_expanded_solo_en_nodos_con_hijos() {
88    use llimphi_widget_tree::{tree_view, TreePalette, TreeRow, TreeSpec};
89    let v = tree_view(TreeSpec {
90        rows: vec![
91            TreeRow::new("src", 0, true, true, false, Msg::Toggle(0), Msg::Fila(0)),
92            TreeRow::new("main.rs", 1, false, false, true, Msg::Toggle(1), Msg::Fila(1)),
93            TreeRow::new("target", 0, true, false, false, Msg::Toggle(2), Msg::Fila(2)),
94        ],
95        row_height: 22.0,
96        indent_px: 12.0,
97        palette: TreePalette::from_theme(&tema()),
98        guides: false,
99    });
100    let ui = Ui::new(v, 400.0, 400.0);
101
102    assert!(ui.find(Role::Tree).is_some());
103    let filas = ui.find_all(Role::TreeItem);
104    assert_eq!(filas.len(), 3);
105
106    // Carpeta desplegada.
107    assert_eq!(filas[0].name(), Some("src"));
108    assert_eq!(filas[0].is_expanded(), Some(true));
109    // Hoja: `expanded` NO se declara — "no aplica" ≠ "plegado".
110    assert_eq!(filas[1].is_expanded(), None, "una hoja no se pliega");
111    assert_eq!(filas[1].is_checked(), Some(true), "es la seleccionada");
112    // Carpeta plegada.
113    assert_eq!(filas[2].is_expanded(), Some(false));
114}
115
116// ─────────────────────────────────────────────────────────────────────────
117//  list — List + ListItem con selección
118// ─────────────────────────────────────────────────────────────────────────
119
120#[test]
121fn list_declara_items_y_cual_esta_seleccionado() {
122    use llimphi_widget_list::{list_view, ListPalette, ListRow, ListSpec};
123    let v = list_view(ListSpec {
124        rows: vec![
125            ListRow { label: "alfa".into(), selected: false, on_click: Msg::Fila(0) },
126            ListRow { label: "beta".into(), selected: true, on_click: Msg::Fila(1) },
127        ],
128        total: 2,
129        caption: None,
130        truncated_hint: None,
131        row_height: 22.0,
132        palette: ListPalette::from_theme(&tema()),
133    });
134    let ui = Ui::new(v, 400.0, 300.0);
135
136    assert!(ui.find(Role::List).is_some());
137    let sel = ui.get(Sel::new().role(Role::ListItem).checked(true));
138    assert_eq!(sel.name(), Some("beta"));
139    assert_eq!(sel.click(), Some(Msg::Fila(1)));
140}
141
142// ─────────────────────────────────────────────────────────────────────────
143//  progress — el caso que sin semántica es literalmente una caja de color
144// ─────────────────────────────────────────────────────────────────────────
145
146#[test]
147fn progress_enuncia_el_avance() {
148    use llimphi_ui::llimphi_raster::peniko::Color;
149    let v: View<Msg> = llimphi_widget_progress::linear_progress_view(
150        0.42,
151        Color::from_rgba8(40, 40, 40, 255),
152        Color::from_rgba8(90, 160, 250, 255),
153        6.0,
154    );
155    let ui = Ui::new(v, 200.0, 20.0);
156    let barra = ui.get(Role::ProgressBar);
157    assert_eq!(barra.value(), Some("42%"));
158}
159
160// ─────────────────────────────────────────────────────────────────────────
161//  La auditoría: qué quedó interactivo pero mudo
162// ─────────────────────────────────────────────────────────────────────────
163
164#[test]
165fn los_tabs_no_dejan_nodos_clickeables_sin_rol() {
166    use llimphi_widget_tabs::{tabs_view, TabsPalette, TabsSpec};
167    let v = tabs_view(TabsSpec {
168        labels: vec!["uno".into(), "dos".into()],
169        active: 0,
170        on_select: Msg::Tab,
171        content: View::new(Style::default()),
172        tab_height: 28.0,
173        palette: TabsPalette::from_theme(&tema()),
174        tab_width: None,
175        on_close: Some(std::sync::Arc::new(Msg::Cerrar)),
176        on_reorder: None,
177        strip_offset: 0.0,
178        on_strip_scroll: None,
179    });
180    let ui = Ui::new(v, 600.0, 400.0);
181    // Un nodo clickeable sin rol es exactamente lo que un lector no sabe
182    // anunciar. En este widget no debería quedar ninguno.
183    let mudos = ui.find_all(Sel::new().clickable(true).role_none());
184    assert!(
185        mudos.is_empty(),
186        "{} nodo(s) clickeable(s) sin semántica:\n{}",
187        mudos.len(),
188        ui.dump()
189    );
190}
191
192// ─────────────────────────────────────────────────────────────────────────
193//  transport — el caso más crudo: TODOS los botones son sólo-ícono
194// ─────────────────────────────────────────────────────────────────────────
195
196#[test]
197fn transport_nombra_cada_boton_y_refleja_el_estado() {
198    use llimphi_widget_transport::{transport_button_view, TransportButton, TransportPalette};
199    let pal = TransportPalette::from_theme(&tema());
200
201    // El botón de play dice qué VA A HACER, no cómo se llama.
202    let sonando: View<Msg> = transport_button_view(
203        TransportButton::PlayPause { playing: true },
204        &pal,
205        |_a| Msg::Fila(0),
206    );
207    let ui = Ui::new(sonando, 60.0, 40.0);
208    let b = ui.get(Role::Button);
209    assert_eq!(b.name(), Some("Pausar"));
210    assert_eq!(b.is_pressed(), Some(true));
211
212    let pausado: View<Msg> = transport_button_view(
213        TransportButton::PlayPause { playing: false },
214        &pal,
215        |_a| Msg::Fila(0),
216    );
217    let ui = Ui::new(pausado, 60.0, 40.0);
218    assert_eq!(ui.get(Role::Button).name(), Some("Reproducir"));
219
220    // Y el salto lleva los segundos adentro del nombre.
221    let salto: View<Msg> = transport_button_view(
222        TransportButton::SeekForward { secs: 10 },
223        &pal,
224        |_a| Msg::Fila(0),
225    );
226    let ui = Ui::new(salto, 60.0, 40.0);
227    assert_eq!(ui.get(Role::Button).name(), Some("Avanzar 10 segundos"));
228}
229
230// ─────────────────────────────────────────────────────────────────────────
231//  calendar — un "14" suelto no ubica a nadie
232// ─────────────────────────────────────────────────────────────────────────
233
234#[test]
235fn calendar_nombra_los_dias_con_la_fecha_completa() {
236    use llimphi_widget_calendar::{calendar_view, CalendarPalette, CalendarSpec, WeekStart};
237    let sel = chrono::NaiveDate::from_ymd_opt(2026, 7, 14).unwrap();
238    let v: View<Msg> = calendar_view(CalendarSpec {
239        marcados: Vec::new(),
240        view_year: 2026,
241        view_month: 7,
242        selected: Some(sel),
243        today: None,
244        week_start: WeekStart::Monday,
245        palette: CalendarPalette::from_theme(&tema()),
246        on_select: std::sync::Arc::new(|_d| Msg::Fila(0)),
247        on_view_change: std::sync::Arc::new(|_y, _m| Msg::Fila(1)),
248    });
249    let ui = Ui::new(v, 400.0, 400.0);
250
251    // El día seleccionado se nombra entero y se marca.
252    let dia = ui.get(Sel::new().role(Role::Button).label("14 de julio de 2026"));
253    assert_eq!(dia.is_checked(), Some(true));
254    // Los chevrones de mes tienen nombre, no "‹" / "›".
255    assert!(ui.find("Mes anterior").is_some(), "{}", ui.dump());
256    assert!(ui.find("Mes siguiente").is_some());
257    // Sólo un día marcado.
258    assert_eq!(ui.find_all(Sel::new().role(Role::Button).checked(true)).len(), 1);
259}
260
261/// Un calendario de julio de 2026 con las opciones que se le pidan.
262fn calendario_julio(
263    opciones: llimphi_widget_calendar::CalendarOpciones,
264) -> Ui<Msg> {
265    use llimphi_widget_calendar::{calendar_view_ext, CalendarPalette, CalendarSpec, WeekStart};
266    let v: View<Msg> = calendar_view_ext(
267        CalendarSpec {
268        marcados: Vec::new(),
269            view_year: 2026,
270            view_month: 7,
271            selected: None,
272            today: None,
273            week_start: WeekStart::Monday,
274            palette: CalendarPalette::from_theme(&tema()),
275            on_select: std::sync::Arc::new(|_d| Msg::Fila(0)),
276            on_view_change: std::sync::Arc::new(|_y, _m| Msg::Fila(1)),
277        },
278        opciones,
279    );
280    Ui::new(v, 400.0, 460.0)
281}
282
283fn fecha(y: i32, m: u32, d: u32) -> chrono::NaiveDate {
284    chrono::NaiveDate::from_ymd_opt(y, m, d).unwrap()
285}
286
287#[test]
288fn calendar_apaga_los_dias_fuera_de_limites_y_no_deja_clickearlos() {
289    use llimphi_widget_calendar::{CalendarOpciones, Limites};
290    let ui = calendario_julio(CalendarOpciones {
291        limites: Limites::entre(fecha(2026, 7, 10), fecha(2026, 7, 20)),
292        ..Default::default()
293    });
294
295    // Un día de adentro se puede elegir.
296    let dentro = ui.get(Sel::new().role(Role::Button).label("15 de julio de 2026"));
297    assert!(dentro.is_enabled(), "{}", ui.dump());
298    assert!(dentro.click().is_some(), "un día dentro de límites debe emitir su Msg");
299
300    // Los bordes son inclusive.
301    assert!(ui.get(Sel::new().role(Role::Button).label("10 de julio de 2026")).is_enabled());
302    assert!(ui.get(Sel::new().role(Role::Button).label("20 de julio de 2026")).is_enabled());
303
304    // Uno de afuera se ve (ubica en la grilla) pero no se puede elegir: si
305    // desapareciera, la grilla se correría y julio dejaría de tener forma de
306    // julio.
307    let fuera = ui.get(Sel::new().role(Role::Button).label("21 de julio de 2026"));
308    assert!(!fuera.is_enabled(), "el día fuera de límites debe quedar deshabilitado");
309    assert!(fuera.click().is_none(), "un día fuera de límites no puede emitir Msg");
310}
311
312#[test]
313fn calendar_apaga_la_flecha_del_mes_que_esta_entero_fuera() {
314    use llimphi_widget_calendar::{CalendarOpciones, Limites};
315    // Mínimo el 1 de julio: junio queda entero afuera, agosto no.
316    let ui = calendario_julio(CalendarOpciones {
317        limites: Limites::desde(fecha(2026, 7, 1)),
318        ..Default::default()
319    });
320    let anterior = ui.get("Mes anterior");
321    assert!(!anterior.is_enabled(), "{}", ui.dump());
322    assert!(anterior.click().is_none(), "la flecha apagada no navega");
323    // Y sigue estando, no desaparece: el header no cambia de ancho.
324    assert!(ui.get("Mes siguiente").is_enabled());
325}
326
327#[test]
328fn calendar_marca_la_banda_del_rango_y_sus_dos_extremos() {
329    use llimphi_widget_calendar::{CalendarOpciones, RangoSeleccion};
330    let rango = RangoSeleccion::default().click(fecha(2026, 7, 10)).click(fecha(2026, 7, 14));
331    let ui = calendario_julio(CalendarOpciones { rango: Some(rango), ..Default::default() });
332
333    // Del 10 al 14 son cinco días marcados, ni uno más.
334    let marcados = ui.find_all(Sel::new().role(Role::Button).checked(true));
335    assert_eq!(marcados.len(), 5, "{}", ui.dump());
336    for d in 10..=14 {
337        let dia = ui.get(Sel::new().role(Role::Button).label(format!("{d} de julio de 2026")));
338        assert_eq!(dia.is_checked(), Some(true), "el {d} debería estar en la banda");
339    }
340    // Los vecinos de afuera, no.
341    for d in [9, 15] {
342        let dia = ui.get(Sel::new().role(Role::Button).label(format!("{d} de julio de 2026")));
343        assert_eq!(dia.is_checked(), Some(false), "el {d} está fuera del rango");
344    }
345}
346
347#[test]
348fn calendar_con_navegacion_anual_suma_las_flechas_de_ano() {
349    use llimphi_widget_calendar::CalendarOpciones;
350    let ui = calendario_julio(CalendarOpciones { navegacion_anual: true, ..Default::default() });
351    assert!(ui.find("Año anterior").is_some(), "{}", ui.dump());
352    assert!(ui.find("Año siguiente").is_some());
353    // Y las de mes siguen ahí: son dos ejes, no un reemplazo.
354    assert!(ui.find("Mes anterior").is_some());
355    assert!(ui.find("Mes siguiente").is_some());
356    // Sin pedirlas, no están (el default es el calendario de siempre).
357    let base = calendario_julio(CalendarOpciones::default());
358    assert!(base.find("Año anterior").is_none());
359}
360
361// ─────────────────────────────────────────────────────────────────────────
362//  toolbar — lo que no entra se esconde bien, y el hover dice qué es
363// ─────────────────────────────────────────────────────────────────────────
364
365#[test]
366fn toolbar_esconde_lo_que_no_entra_detras_de_un_boton_que_se_nombra() {
367    use llimphi_widget_toolbar::{
368        items_ocultos, toolbar_view_ext, ToolbarGroup, ToolbarItem, ToolbarOpciones,
369        ToolbarPalette,
370    };
371    let icono = |_s: f32, _c: llimphi_ui::peniko::Color| View::<Msg>::new(Style::default());
372    let grupos = || {
373        vec![
374            ToolbarGroup::new(vec![
375                ToolbarItem::new(icono, Msg::Fila(0)).with_label("subir"),
376                ToolbarItem::new(icono, Msg::Fila(1)).with_label("bajar"),
377            ]),
378            ToolbarGroup::new(vec![
379                ToolbarItem::new(icono, Msg::Fila(2)).with_label("lista"),
380                ToolbarItem::new(icono, Msg::Fila(3)).with_label("iconos"),
381            ]),
382        ]
383    };
384
385    // Corte en 2: quedan dos ítems y el «⋯», que dice cuántos esconde.
386    let ocultos = items_ocultos(&grupos(), 2);
387    assert_eq!(ocultos.len(), 2);
388    let v = toolbar_view_ext(
389        grupos(),
390        36.0,
391        &ToolbarPalette::from_theme(&tema()),
392        ToolbarOpciones {
393            visibles: Some(2),
394            on_overflow: Some(Msg::Toggle(9)),
395            ..Default::default()
396        },
397    );
398    let ui = Ui::new(v, 300.0, 40.0);
399    assert!(ui.find("subir").is_some(), "{}", ui.dump());
400    assert!(ui.find("bajar").is_some());
401    assert!(ui.find("lista").is_none(), "el ítem cortado no debe seguir en la barra");
402    let mas = ui.get("Más herramientas (2)");
403    assert_eq!(mas.click(), Some(Msg::Toggle(9)));
404
405    // Sin corte, el «⋯» no existe: un botón de overflow con cero escondidos es
406    // ruido.
407    let v = toolbar_view_ext(
408        grupos(),
409        36.0,
410        &ToolbarPalette::from_theme(&tema()),
411        ToolbarOpciones { on_overflow: Some(Msg::Toggle(9)), ..Default::default() },
412    );
413    let ui = Ui::new(v, 600.0, 40.0);
414    assert!(ui.find_all(Sel::new().role(Role::Button)).iter().all(|n| n
415        .name()
416        .is_none_or(|s| !s.starts_with("Más herramientas"))));
417}
418
419#[test]
420fn toolbar_avisa_el_hover_de_cada_item_para_el_tooltip() {
421    use llimphi_widget_toolbar::{
422        toolbar_view_ext, ToolbarGroup, ToolbarItem, ToolbarOpciones, ToolbarPalette,
423    };
424    let icono = |_s: f32, _c: llimphi_ui::peniko::Color| View::<Msg>::new(Style::default());
425    let v = toolbar_view_ext(
426        vec![ToolbarGroup::new(vec![
427            ToolbarItem::new(icono, Msg::Fila(0)).with_label("subir"),
428            ToolbarItem::new(icono, Msg::Fila(1)).with_label("bajar"),
429        ])],
430        36.0,
431        &ToolbarPalette::from_theme(&tema()),
432        ToolbarOpciones {
433            on_hover: Some(std::sync::Arc::new(|i: Option<usize>| {
434                Msg::Toggle(i.map_or(99, |i| i))
435            })),
436            ..Default::default()
437        },
438    );
439    let ui = Ui::new(v, 300.0, 40.0);
440    // Cada ítem avisa con SU índice al entrar, y con `None` al salir.
441    let segundo = ui.get(Sel::new().role(Role::Button).label("bajar"));
442    assert_eq!(segundo.pointer_enter(), Some(Msg::Toggle(1)));
443    assert_eq!(segundo.pointer_leave(), Some(Msg::Toggle(99)));
444    assert_eq!(
445        ui.get(Sel::new().role(Role::Button).label("subir")).pointer_enter(),
446        Some(Msg::Toggle(0))
447    );
448}
449
450// ─────────────────────────────────────────────────────────────────────────
451//  splitter — el divisor se agarra con el mouse; con teclado hay que poder
452// ─────────────────────────────────────────────────────────────────────────
453
454#[test]
455fn splitter_puede_dar_foco_y_doble_click_al_divisor() {
456    use llimphi_widget_splitter::{
457        splitter_two, splitter_two_ext, Direction, PaneSize, SplitterOpciones, SplitterPalette,
458    };
459    let pane = || View::<Msg>::new(Style::default());
460    let pal = SplitterPalette::from_theme(&tema());
461
462    // Por default el divisor sigue siendo lo de siempre: se arrastra y nada
463    // más — ni foco (no entra en el orden de Tab) ni doble-click.
464    let base = splitter_two(
465        Direction::Row,
466        pane(),
467        PaneSize::Fixed(100.0),
468        pane(),
469        PaneSize::Flex,
470        |_p, _d| None::<Msg>,
471        &pal,
472    );
473    let ui = Ui::new(base, 400.0, 200.0);
474    let div = ui.get(Role::Splitter);
475    assert!(!div.is_focusable(), "{}", ui.dump());
476    assert_eq!(div.double_tap(), None);
477
478    // Con opciones, el divisor entra en el orden de Tab y el doble-click
479    // emite el Msg del host — que es el que colapsa el pane.
480    let con_opts = splitter_two_ext(
481        Direction::Row,
482        pane(),
483        PaneSize::Fixed(100.0),
484        pane(),
485        PaneSize::Flex,
486        |_p, _d| None::<Msg>,
487        &pal,
488        SplitterOpciones { on_toggle: Some(Msg::Toggle(7)), focus_id: Some(42) },
489    );
490    let ui = Ui::new(con_opts, 400.0, 200.0);
491    let div = ui.get(Role::Splitter);
492    assert!(div.is_focusable(), "sin foco no hay resize por teclado: {}", ui.dump());
493    assert_eq!(div.double_tap(), Some(Msg::Toggle(7)));
494    // Y sigue nombrándose para el lector de pantalla.
495    assert_eq!(div.name(), Some("Divisor de paneles"));
496}
497
498// ─────────────────────────────────────────────────────────────────────────
499//  table — la estructura completa Table > Row > Cell/ColumnHeader
500// ─────────────────────────────────────────────────────────────────────────
501
502#[test]
503fn table_expone_la_estructura_tabular() {
504    use llimphi_widget_table::{table_view, TablePalette};
505    let v: View<Msg> = table_view(
506        &["Nombre".to_string(), "Cantidad".to_string()],
507        &[
508            vec!["clavos".to_string(), "12".to_string()],
509            vec!["tornillos".to_string(), "4".to_string()],
510        ],
511        None,
512        None,
513        "Agregar",
514        &TablePalette::from_theme(&tema()),
515        |_r, _c| Msg::Fila(0),
516        |_r| Msg::Fila(1),
517        || Msg::Fila(2),
518    );
519    let ui = Ui::new(v, 600.0, 300.0);
520
521    assert!(ui.find(Role::Table).is_some());
522    assert_eq!(ui.find_all(Role::ColumnHeader).len(), 2);
523    // 1 fila de encabezado + 2 de datos.
524    assert_eq!(ui.find_all(Role::Row).len(), 3);
525    assert!(ui.find(Sel::new().role(Role::ColumnHeader).label("Cantidad")).is_some());
526    // El "×" de quitar fila tiene nombre.
527    assert_eq!(ui.find_all(Sel::new().label("Quitar fila")).len(), 2);
528}
529
530// ─────────────────────────────────────────────────────────────────────────
531//  field — el grupo lleva nombre, estado y el error como descripción
532// ─────────────────────────────────────────────────────────────────────────
533
534#[test]
535fn field_agrupa_label_estado_y_error() {
536    use llimphi_widget_field::{field_view, FieldPalette, FieldSpec};
537    let v: View<Msg> = field_view(FieldSpec {
538        label: "Correo".into(),
539        control: View::new(Style::default()),
540        required: true,
541        helper: Some("Lo usamos para avisarte".into()),
542        error: Some("Formato inválido".into()),
543        palette: FieldPalette::from_theme(&tema()),
544    });
545    let ui = Ui::new(v, 400.0, 120.0);
546
547    let g = ui.get(Sel::new().role(Role::Group).label("Correo"));
548    assert_eq!(g.is_required(), Some(true));
549    // El error gana sobre el helper — es lo que el lector debe enunciar.
550    assert_eq!(g.description(), Some("Formato inválido"));
551    // El asterisco es notación visual: no ensucia el nombre accesible.
552    assert_eq!(g.name(), Some("Correo"));
553}
554
555// ─────────────────────────────────────────────────────────────────────────
556//  Determinado vs indeterminado
557// ─────────────────────────────────────────────────────────────────────────
558
559#[test]
560fn spinner_es_progreso_sin_valor_y_progress_con_valor() {
561    use llimphi_ui::llimphi_raster::peniko::Color;
562    let spin: View<Msg> =
563        llimphi_widget_spinner::spinner_view(Color::from_rgba8(200, 200, 200, 255), 0.12, 1.0);
564    let ui = Ui::new(spin, 40.0, 40.0);
565    let s = ui.get(Role::ProgressBar);
566    assert_eq!(s.name(), Some("Cargando"));
567    assert_eq!(s.value(), None, "indeterminado: sin porcentaje");
568
569    let barra: View<Msg> = llimphi_widget_progress::linear_progress_view(
570        1.0,
571        Color::from_rgba8(40, 40, 40, 255),
572        Color::from_rgba8(90, 160, 250, 255),
573        6.0,
574    );
575    let ui = Ui::new(barra, 200.0, 20.0);
576    assert_eq!(ui.get(Role::ProgressBar).value(), Some("100%"));
577}