tawasuyu

tawasuyu/llimphi

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

Rust 238TOML 102Markdown 83Shell 24178 Definitions

⎇ main llimphi-test/tests/pickers_fecha_hora.rs Rust · 237 lines · 9.0 KB History Who touched what raw

chore: refresco desde el monorepo — vello 0.9 / wgpu 29, 120 miembros d251eb32b · Sergio ·

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}