tawasuyu/llimphi
Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.
chore: refresco desde el monorepo — 132 miembros, 79 widgets, la galería vuelve y licencia dual
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}