//! Los pickers de **fecha** y **hora** montados: lo que el núcleo puro no puede //! certificar. //! //! El formato y el parseo se prueban en el propio crate. Acá se prueba la otra //! mitad, que es donde un picker falla de verdad: que el disparador diga el //! valor, que el popup elija el día correcto, que el scrim cierre, y que //! cambiar la hora **no borre** el minuto que ya estaba elegido. //! //! Sin GPU (CLAUDE.md §8). use llimphi_test::query::{Sel, Ui}; use llimphi_ui::Role; use llimphi_ui::View; use llimphi_widget_calendar::{CalendarOpciones, Limites, WeekStart}; use llimphi_widget_datetime_picker::{ date_picker_popup, date_picker_trigger, time_picker_popup, time_picker_trigger, Acciones, DatePopup, DateTrigger, FormatoFecha, FormatoHora, Hora, PickerPalette, TimePopup, TimeTrigger, }; #[derive(Clone, Debug, PartialEq)] enum Msg { Abrir, Cerrar, Limpiar, Fecha(i32, u32, u32), VerMes(i32, u32), HoraElegida(u8, u8), } fn fecha(y: i32, m: u32, d: u32) -> chrono::NaiveDate { chrono::NaiveDate::from_ymd_opt(y, m, d).unwrap() } fn pal() -> PickerPalette { PickerPalette::from_theme(&llimphi_theme::Theme::default()) } // ── disparadores ───────────────────────────────────────────────────────── #[test] fn el_disparador_de_fecha_dice_el_valor_o_el_placeholder() { let con_valor: View = date_picker_trigger(DateTrigger { valor: Some(fecha(2026, 8, 4)), formato: FormatoFecha::DiaMesAnio, placeholder: "Elegí una fecha".into(), enabled: true, on_open: Msg::Abrir, palette: pal(), }); let ui = Ui::new(con_valor, 240.0, 40.0); let campo = ui.get(Sel::new().role(Role::Button).label("Elegir fecha")); assert_eq!(campo.click(), Some(Msg::Abrir)); assert!(ui.find("04/08/2026").is_some(), "{}", ui.dump()); let vacio: View = date_picker_trigger(DateTrigger { valor: None, formato: FormatoFecha::DiaMesAnio, placeholder: "Elegí una fecha".into(), enabled: true, on_open: Msg::Abrir, palette: pal(), }); let ui = Ui::new(vacio, 240.0, 40.0); assert!(ui.find("Elegí una fecha").is_some(), "{}", ui.dump()); } #[test] fn un_disparador_deshabilitado_no_abre_nada() { let v: View = time_picker_trigger(TimeTrigger { valor: Some(Hora { h: 14, m: 5 }), formato: FormatoHora::H24, placeholder: "—".into(), enabled: false, on_open: Msg::Abrir, palette: pal(), }); let ui = Ui::new(v, 240.0, 40.0); let campo = ui.get(Sel::new().role(Role::Button).label("Elegir hora")); assert!(!campo.is_enabled(), "{}", ui.dump()); assert_eq!(campo.click(), None, "un campo deshabilitado no puede abrir el popup"); // Y sigue mostrando el valor: deshabilitado no es vacío. assert!(ui.find("14:05").is_some()); } // ── popup de fecha ─────────────────────────────────────────────────────── fn popup_fecha(valor: Option, opciones: CalendarOpciones) -> Ui { let v: View = date_picker_popup(DatePopup { anchor: (40.0, 60.0), viewport: (800.0, 600.0), valor, view: (2026, 8), today: Some(fecha(2026, 8, 20)), week_start: WeekStart::Monday, opciones, on_pick: std::sync::Arc::new(|d: chrono::NaiveDate| { use chrono::Datelike; Msg::Fecha(d.year(), d.month(), d.day()) }), on_view_change: std::sync::Arc::new(Msg::VerMes), on_dismiss: Msg::Cerrar, on_clear: Some(Msg::Limpiar), acciones: Acciones::hoy_y_limpiar(), palette: pal(), theme: llimphi_theme::Theme::default(), }); Ui::new(v, 800.0, 600.0) } #[test] fn el_popup_elige_el_dia_y_hereda_los_limites_del_calendario() { let ui = popup_fecha( Some(fecha(2026, 8, 4)), CalendarOpciones { limites: Limites::entre(fecha(2026, 8, 1), fecha(2026, 8, 15)), ..Default::default() }, ); // Click en un día devuelve ESE día. let dia = ui.get(Sel::new().role(Role::Button).label("7 de agosto de 2026")); assert_eq!(dia.click(), Some(Msg::Fecha(2026, 8, 7))); // Y los límites del calendario siguen valiendo dentro del popup: no se // pierden por estar compuesto. let fuera = ui.get(Sel::new().role(Role::Button).label("20 de agosto de 2026")); assert!(!fuera.is_enabled(), "{}", ui.dump()); } #[test] fn el_pie_ofrece_hoy_y_limpiar_y_hoy_elige_el_dia_inyectado() { let ui = popup_fecha(None, CalendarOpciones::default()); // «Hoy» emite el día que el host inyectó — el widget no toca el reloj. assert_eq!(ui.get("Hoy").click(), Some(Msg::Fecha(2026, 8, 20))); assert_eq!(ui.get("Limpiar").click(), Some(Msg::Limpiar)); } #[test] fn el_scrim_cierra_el_popup() { let ui = popup_fecha(None, CalendarOpciones::default()); // El nodo raíz es el scrim: cubre todo y su click cierra. Sin esto, un // popup queda abierto para siempre si el host se olvidó del Esc. let raiz = ui.root(); assert_eq!(raiz.click(), Some(Msg::Cerrar)); assert!(raiz.rect().w >= 800.0 && raiz.rect().h >= 600.0); } #[test] fn el_popup_no_se_sale_de_la_pantalla() { // Ancla pegada al borde: el panel se corre para entrar entero. let v: View = date_picker_popup(DatePopup { anchor: (790.0, 590.0), viewport: (800.0, 600.0), valor: None, view: (2026, 8), today: None, week_start: WeekStart::Monday, opciones: CalendarOpciones::default(), on_pick: std::sync::Arc::new(|_d| Msg::Cerrar), on_view_change: std::sync::Arc::new(Msg::VerMes), on_dismiss: Msg::Cerrar, on_clear: None, acciones: Acciones::default(), palette: pal(), theme: llimphi_theme::Theme::default(), }); let ui = Ui::new(v, 800.0, 600.0); // El día 1 tiene que estar dentro del viewport: si el panel se hubiera // salido, su grilla quedaría fuera de pantalla. let r = ui.get(Sel::new().role(Role::Button).label("1 de agosto de 2026")).rect(); assert!(r.x >= 0.0 && r.x + r.w <= 800.0, "se salió en x: {r:?}"); assert!(r.y >= 0.0 && r.y + r.h <= 600.0, "se salió en y: {r:?}"); } // ── popup de hora ──────────────────────────────────────────────────────── fn popup_hora(valor: Option, formato: FormatoHora, paso: u8) -> Ui { let v: View = time_picker_popup(TimePopup { anchor: (40.0, 60.0), viewport: (800.0, 600.0), valor, formato, paso_minutos: paso, on_pick: std::sync::Arc::new(|h: Hora| Msg::HoraElegida(h.h, h.m)), on_dismiss: Msg::Cerrar, palette: pal(), }); Ui::new(v, 800.0, 600.0) } #[test] fn elegir_una_hora_conserva_el_minuto_y_al_reves() { let ui = popup_hora(Some(Hora { h: 9, m: 45 }), FormatoHora::H24, 15); // Click en la hora 14: se queda el minuto 45 que ya estaba elegido. Es el // detalle que hace usable el picker — si lo reseteara, elegir la hora te // obligaría a volver a elegir el minuto siempre. assert_eq!( ui.get(Sel::new().role(Role::Button).label("Hora 14:45")).click(), Some(Msg::HoraElegida(14, 45)) ); // Y al revés: click en :30 conserva la hora 9. assert_eq!( ui.get(Sel::new().role(Role::Button).label("Minuto 30")).click(), Some(Msg::HoraElegida(9, 30)) ); } #[test] fn el_paso_decide_cuantos_minutos_se_ofrecen() { let de15 = popup_hora(None, FormatoHora::H24, 15); assert!(de15.find(Sel::new().role(Role::Button).label("Minuto 15")).is_some()); assert!( de15.find(Sel::new().role(Role::Button).label("Minuto 10")).is_none(), "con paso 15 no puede haber :10" ); let de5 = popup_hora(None, FormatoHora::H24, 5); assert!(de5.find(Sel::new().role(Role::Button).label("Minuto 10")).is_some()); // Las 24 horas siempre están, sea cual sea el paso. for h in [0u8, 13, 23] { assert!( de5.find(Sel::new().role(Role::Button).label(format!("Hora {h:02}:00"))) .is_some(), "falta la hora {h}" ); } } #[test] fn en_12_horas_las_celdas_se_nombran_en_12_horas() { let ui = popup_hora(Some(Hora { h: 15, m: 0 }), FormatoHora::H12, 30); // El nombre accesible dice la hora como se lee en ese formato, no el número // interno: «Hora 3:00 PM», no «Hora 15:00». let celda = ui.get(Sel::new().role(Role::Button).label("Hora 3:00 PM")); assert_eq!(celda.is_checked(), Some(true), "la hora elegida se marca: {}", ui.dump()); assert_eq!(celda.click(), Some(Msg::HoraElegida(15, 0))); // Y medianoche es 12 AM, no 0 AM. assert!(ui.find(Sel::new().role(Role::Button).label("Hora 12:00 AM")).is_some()); }