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//! Los pickers de **fecha** y **hora** montados: lo que el núcleo puro no puede
2//! certificar.
3//!
4//! El formato y el parseo se prueban en el propio crate. Acá se prueba la otra
5//! mitad, que es donde un picker falla de verdad: que el disparador diga el
6//! valor, que el popup elija el día correcto, que el scrim cierre, y que
7//! cambiar la hora **no borre** el minuto que ya estaba elegido.
8//!
9//! Sin GPU (CLAUDE.md §8).
10
11use llimphi_test::query::{Sel, Ui};
12use llimphi_ui::Role;
13use llimphi_ui::View;
14use llimphi_widget_calendar::{CalendarOpciones, Limites, WeekStart};
15use llimphi_widget_datetime_picker::{
16 date_picker_popup, date_picker_trigger, time_picker_popup, time_picker_trigger, Acciones,
17 DatePopup, DateTrigger, FormatoFecha, FormatoHora, Hora, PickerPalette, TimePopup,
18 TimeTrigger,
19};
20
21#[derive(Clone, Debug, PartialEq)]
22enum Msg {
23 Abrir,
24 Cerrar,
25 Limpiar,
26 Fecha(i32, u32, u32),
27 VerMes(i32, u32),
28 HoraElegida(u8, u8),
29}
30
31fn fecha(y: i32, m: u32, d: u32) -> chrono::NaiveDate {
32 chrono::NaiveDate::from_ymd_opt(y, m, d).unwrap()
33}
34
35fn pal() -> PickerPalette {
36 PickerPalette::from_theme(&llimphi_theme::Theme::default())
37}
38
39// ── disparadores ─────────────────────────────────────────────────────────
40
41#[test]
42fn el_disparador_de_fecha_dice_el_valor_o_el_placeholder() {
43 let con_valor: View<Msg> = date_picker_trigger(DateTrigger {
44 valor: Some(fecha(2026, 8, 4)),
45 formato: FormatoFecha::DiaMesAnio,
46 placeholder: "Elegí una fecha".into(),
47 enabled: true,
48 on_open: Msg::Abrir,
49 palette: pal(),
50 });
51 let ui = Ui::new(con_valor, 240.0, 40.0);
52 let campo = ui.get(Sel::new().role(Role::Button).label("Elegir fecha"));
53 assert_eq!(campo.click(), Some(Msg::Abrir));
54 assert!(ui.find("04/08/2026").is_some(), "{}", ui.dump());
55
56 let vacio: View<Msg> = date_picker_trigger(DateTrigger {
57 valor: None,
58 formato: FormatoFecha::DiaMesAnio,
59 placeholder: "Elegí una fecha".into(),
60 enabled: true,
61 on_open: Msg::Abrir,
62 palette: pal(),
63 });
64 let ui = Ui::new(vacio, 240.0, 40.0);
65 assert!(ui.find("Elegí una fecha").is_some(), "{}", ui.dump());
66}
67
68#[test]
69fn un_disparador_deshabilitado_no_abre_nada() {
70 let v: View<Msg> = time_picker_trigger(TimeTrigger {
71 valor: Some(Hora { h: 14, m: 5 }),
72 formato: FormatoHora::H24,
73 placeholder: "—".into(),
74 enabled: false,
75 on_open: Msg::Abrir,
76 palette: pal(),
77 });
78 let ui = Ui::new(v, 240.0, 40.0);
79 let campo = ui.get(Sel::new().role(Role::Button).label("Elegir hora"));
80 assert!(!campo.is_enabled(), "{}", ui.dump());
81 assert_eq!(campo.click(), None, "un campo deshabilitado no puede abrir el popup");
82 // Y sigue mostrando el valor: deshabilitado no es vacío.
83 assert!(ui.find("14:05").is_some());
84}
85
86// ── popup de fecha ───────────────────────────────────────────────────────
87
88fn popup_fecha(valor: Option<chrono::NaiveDate>, opciones: CalendarOpciones) -> Ui<Msg> {
89 let v: View<Msg> = date_picker_popup(DatePopup {
90 anchor: (40.0, 60.0),
91 viewport: (800.0, 600.0),
92 valor,
93 view: (2026, 8),
94 today: Some(fecha(2026, 8, 20)),
95 week_start: WeekStart::Monday,
96 opciones,
97 on_pick: std::sync::Arc::new(|d: chrono::NaiveDate| {
98 use chrono::Datelike;
99 Msg::Fecha(d.year(), d.month(), d.day())
100 }),
101 on_view_change: std::sync::Arc::new(Msg::VerMes),
102 on_dismiss: Msg::Cerrar,
103 on_clear: Some(Msg::Limpiar),
104 acciones: Acciones::hoy_y_limpiar(),
105 palette: pal(),
106 theme: llimphi_theme::Theme::default(),
107 });
108 Ui::new(v, 800.0, 600.0)
109}
110
111#[test]
112fn el_popup_elige_el_dia_y_hereda_los_limites_del_calendario() {
113 let ui = popup_fecha(
114 Some(fecha(2026, 8, 4)),
115 CalendarOpciones {
116 limites: Limites::entre(fecha(2026, 8, 1), fecha(2026, 8, 15)),
117 ..Default::default()
118 },
119 );
120 // Click en un día devuelve ESE día.
121 let dia = ui.get(Sel::new().role(Role::Button).label("7 de agosto de 2026"));
122 assert_eq!(dia.click(), Some(Msg::Fecha(2026, 8, 7)));
123 // Y los límites del calendario siguen valiendo dentro del popup: no se
124 // pierden por estar compuesto.
125 let fuera = ui.get(Sel::new().role(Role::Button).label("20 de agosto de 2026"));
126 assert!(!fuera.is_enabled(), "{}", ui.dump());
127}
128
129#[test]
130fn el_pie_ofrece_hoy_y_limpiar_y_hoy_elige_el_dia_inyectado() {
131 let ui = popup_fecha(None, CalendarOpciones::default());
132 // «Hoy» emite el día que el host inyectó — el widget no toca el reloj.
133 assert_eq!(ui.get("Hoy").click(), Some(Msg::Fecha(2026, 8, 20)));
134 assert_eq!(ui.get("Limpiar").click(), Some(Msg::Limpiar));
135}
136
137#[test]
138fn el_scrim_cierra_el_popup() {
139 let ui = popup_fecha(None, CalendarOpciones::default());
140 // El nodo raíz es el scrim: cubre todo y su click cierra. Sin esto, un
141 // popup queda abierto para siempre si el host se olvidó del Esc.
142 let raiz = ui.root();
143 assert_eq!(raiz.click(), Some(Msg::Cerrar));
144 assert!(raiz.rect().w >= 800.0 && raiz.rect().h >= 600.0);
145}
146
147#[test]
148fn el_popup_no_se_sale_de_la_pantalla() {
149 // Ancla pegada al borde: el panel se corre para entrar entero.
150 let v: View<Msg> = date_picker_popup(DatePopup {
151 anchor: (790.0, 590.0),
152 viewport: (800.0, 600.0),
153 valor: None,
154 view: (2026, 8),
155 today: None,
156 week_start: WeekStart::Monday,
157 opciones: CalendarOpciones::default(),
158 on_pick: std::sync::Arc::new(|_d| Msg::Cerrar),
159 on_view_change: std::sync::Arc::new(Msg::VerMes),
160 on_dismiss: Msg::Cerrar,
161 on_clear: None,
162 acciones: Acciones::default(),
163 palette: pal(),
164 theme: llimphi_theme::Theme::default(),
165 });
166 let ui = Ui::new(v, 800.0, 600.0);
167 // El día 1 tiene que estar dentro del viewport: si el panel se hubiera
168 // salido, su grilla quedaría fuera de pantalla.
169 let r = ui.get(Sel::new().role(Role::Button).label("1 de agosto de 2026")).rect();
170 assert!(r.x >= 0.0 && r.x + r.w <= 800.0, "se salió en x: {r:?}");
171 assert!(r.y >= 0.0 && r.y + r.h <= 600.0, "se salió en y: {r:?}");
172}
173
174// ── popup de hora ────────────────────────────────────────────────────────
175
176fn popup_hora(valor: Option<Hora>, formato: FormatoHora, paso: u8) -> Ui<Msg> {
177 let v: View<Msg> = time_picker_popup(TimePopup {
178 anchor: (40.0, 60.0),
179 viewport: (800.0, 600.0),
180 valor,
181 formato,
182 paso_minutos: paso,
183 on_pick: std::sync::Arc::new(|h: Hora| Msg::HoraElegida(h.h, h.m)),
184 on_dismiss: Msg::Cerrar,
185 palette: pal(),
186 });
187 Ui::new(v, 800.0, 600.0)
188}
189
190#[test]
191fn elegir_una_hora_conserva_el_minuto_y_al_reves() {
192 let ui = popup_hora(Some(Hora { h: 9, m: 45 }), FormatoHora::H24, 15);
193 // Click en la hora 14: se queda el minuto 45 que ya estaba elegido. Es el
194 // detalle que hace usable el picker — si lo reseteara, elegir la hora te
195 // obligaría a volver a elegir el minuto siempre.
196 assert_eq!(
197 ui.get(Sel::new().role(Role::Button).label("Hora 14:45")).click(),
198 Some(Msg::HoraElegida(14, 45))
199 );
200 // Y al revés: click en :30 conserva la hora 9.
201 assert_eq!(
202 ui.get(Sel::new().role(Role::Button).label("Minuto 30")).click(),
203 Some(Msg::HoraElegida(9, 30))
204 );
205}
206
207#[test]
208fn el_paso_decide_cuantos_minutos_se_ofrecen() {
209 let de15 = popup_hora(None, FormatoHora::H24, 15);
210 assert!(de15.find(Sel::new().role(Role::Button).label("Minuto 15")).is_some());
211 assert!(
212 de15.find(Sel::new().role(Role::Button).label("Minuto 10")).is_none(),
213 "con paso 15 no puede haber :10"
214 );
215 let de5 = popup_hora(None, FormatoHora::H24, 5);
216 assert!(de5.find(Sel::new().role(Role::Button).label("Minuto 10")).is_some());
217 // Las 24 horas siempre están, sea cual sea el paso.
218 for h in [0u8, 13, 23] {
219 assert!(
220 de5.find(Sel::new().role(Role::Button).label(format!("Hora {h:02}:00")))
221 .is_some(),
222 "falta la hora {h}"
223 );
224 }
225}
226
227#[test]
228fn en_12_horas_las_celdas_se_nombran_en_12_horas() {
229 let ui = popup_hora(Some(Hora { h: 15, m: 0 }), FormatoHora::H12, 30);
230 // El nombre accesible dice la hora como se lee en ese formato, no el número
231 // interno: «Hora 3:00 PM», no «Hora 15:00».
232 let celda = ui.get(Sel::new().role(Role::Button).label("Hora 3:00 PM"));
233 assert_eq!(celda.is_checked(), Some(true), "la hora elegida se marca: {}", ui.dump());
234 assert_eq!(celda.click(), Some(Msg::HoraElegida(15, 0)));
235 // Y medianoche es 12 AM, no 0 AM.
236 assert!(ui.find(Sel::new().role(Role::Button).label("Hora 12:00 AM")).is_some());
237}