Skip to main content

azul_layout/widgets/
time_picker.rs

1//! Time picker widget — two numeric up/down spinners (hour + minute) side by
2//! side with value-clamping, plus an optional AM/PM toggle for 12-hour mode.
3//!
4//! This is the spinner cousin of [`crate::widgets::number_input`]: each spinner
5//! is a small column of an up arrow (`▲`), a value display, and a down arrow
6//! (`▼`). Clicking an arrow increments/decrements the value, **clamps** it to
7//! its range (hour `0..=23` in 24-hour mode or `1..=12` in 12-hour mode, minute
8//! `0..=59`), updates the state, retexts the display node via
9//! `info.change_node_text`, and invokes the optional `on_change(state)`.
10//!
11//! The clamping/retext path mirrors `number_input.rs` (a proven pattern) and the
12//! clickable-cell + sibling navigation mirrors `segmented.rs`, so this widget is
13//! well-supported. The only deliberate behaviour note:
14//!
15//! PARTIAL — minute wrap-around does NOT roll into the hour. Per the build spec,
16//! incrementing minute past 59 (or below 0) simply clamps; it does not carry
17//! into the hour spinner. A carry would require coordinating two sibling
18//! displays from one handler, which is doable but out of scope here; clamping is
19//! the conservative, non-surprising behaviour.
20//!
21//! Key types: [`TimePicker`], [`TimePickerState`], [`TimePickerOnChange`].
22
23use azul_core::{
24    callbacks::{CoreCallback, CoreCallbackData, Update},
25    dom::{Dom, IdOrClass, IdOrClass::Class, IdOrClassVec, TabIndex},
26    refany::{OptionRefAny, RefAny},
27};
28use azul_css::dynamic_selector::CssPropertyWithConditions;
29use azul_css::dynamic_selector::CssPropertyWithConditionsVec;
30use azul_css::{
31    props::{
32        basic::{color::ColorU, StyleFontSize},
33        layout::{LayoutDisplay, LayoutFlexDirection, LayoutAlignItems, LayoutAlignSelf, LayoutFlexGrow, LayoutPaddingTop, LayoutPaddingBottom, LayoutPaddingLeft, LayoutPaddingRight, LayoutWidth, LayoutMarginLeft},
34        property::{CssProperty, *},
35        style::{StyleBackgroundContent, StyleBackgroundContentVec, LayoutBorderTopWidth, LayoutBorderBottomWidth, LayoutBorderLeftWidth, LayoutBorderRightWidth, StyleBorderTopStyle, BorderStyle, StyleBorderBottomStyle, StyleBorderLeftStyle, StyleBorderRightStyle, StyleBorderTopColor, StyleBorderBottomColor, StyleBorderLeftColor, StyleBorderRightColor, StyleBorderTopLeftRadius, StyleBorderTopRightRadius, StyleBorderBottomLeftRadius, StyleBorderBottomRightRadius, StyleTextAlign, StyleCursor, StyleUserSelect, StyleTextColor},
36    },
37    impl_option_inner, AzString,
38};
39
40use crate::callbacks::{Callback, CallbackInfo};
41
42// ---- classes ----
43static TIME_PICKER_CLASS: &[IdOrClass] =
44    &[Class(AzString::from_const_str("__azul-native-time-picker"))];
45static SPINNER_CLASS: &[IdOrClass] =
46    &[Class(AzString::from_const_str("__azul-native-time-picker-spinner"))];
47static DISPLAY_CLASS: &[IdOrClass] =
48    &[Class(AzString::from_const_str("__azul-native-time-picker-display"))];
49static ARROW_CLASS: &[IdOrClass] =
50    &[Class(AzString::from_const_str("__azul-native-time-picker-arrow"))];
51static SEPARATOR_CLASS: &[IdOrClass] =
52    &[Class(AzString::from_const_str("__azul-native-time-picker-separator"))];
53static AMPM_CLASS: &[IdOrClass] =
54    &[Class(AzString::from_const_str("__azul-native-time-picker-ampm"))];
55
56const UP_ARROW: AzString = AzString::from_const_str("\u{25B2}"); // ▲
57const DOWN_ARROW: AzString = AzString::from_const_str("\u{25BC}"); // ▼
58const SEPARATOR_TEXT: AzString = AzString::from_const_str(":");
59
60/// Callback type invoked when the hour, minute, or AM/PM value changes.
61pub type TimePickerOnChangeCallbackType =
62    extern "C" fn(RefAny, CallbackInfo, TimePickerState) -> Update;
63impl_widget_callback!(
64    TimePickerOnChange,
65    OptionTimePickerOnChange,
66    TimePickerOnChangeCallback,
67    TimePickerOnChangeCallbackType
68);
69
70azul_core::impl_managed_callback! {
71    wrapper:        TimePickerOnChangeCallback,
72    info_ty:        CallbackInfo,
73    return_ty:      Update,
74    default_ret:    Update::DoNothing,
75    invoker_static: TIME_PICKER_ON_CHANGE_INVOKER,
76    invoker_ty:     AzTimePickerOnChangeCallbackInvoker,
77    thunk_fn:       az_time_picker_on_change_callback_thunk,
78    setter_fn:      AzApp_setTimePickerOnChangeCallbackInvoker,
79    from_handle_fn: AzTimePickerOnChangeCallback_createFromHostHandle,
80    extra_args:     [ state: TimePickerState ],
81}
82
83/// A time picker: two clamped spinners (hour + minute) and an optional AM/PM
84/// toggle.
85#[derive(Debug, Clone, PartialEq, Eq)]
86#[repr(C)]
87pub struct TimePicker {
88    pub state: TimePickerStateWrapper,
89    /// Style for the row container.
90    pub container_style: CssPropertyWithConditionsVec,
91}
92
93/// Wraps [`TimePickerState`] together with its change callback.
94#[derive(Debug, Default, Clone, PartialEq, Eq)]
95#[repr(C)]
96pub struct TimePickerStateWrapper {
97    pub inner: TimePickerState,
98    pub on_change: OptionTimePickerOnChange,
99}
100
101/// State of a [`TimePicker`].
102#[derive(Debug, Copy, Clone, PartialEq, Eq)]
103#[repr(C)]
104pub struct TimePickerState {
105    /// The displayed hour: `0..=23` when [`Self::is_24h`], else `1..=12`.
106    pub hour: u32,
107    /// The minute, `0..=59`.
108    pub minute: u32,
109    /// PM flag — only meaningful in 12-hour mode (ignored when `is_24h`).
110    pub is_pm: bool,
111    /// `true` = 24-hour display (no AM/PM), `false` = 12-hour display + AM/PM.
112    pub is_24h: bool,
113}
114
115impl Default for TimePickerState {
116    fn default() -> Self {
117        Self {
118            hour: 0,
119            minute: 0,
120            is_pm: false,
121            is_24h: true,
122        }
123    }
124}
125
126impl TimePickerState {
127    /// Returns the hour in canonical 24-hour form (`0..=23`), accounting for the
128    /// AM/PM flag in 12-hour mode (12 AM -> 0, 12 PM -> 12).
129    #[must_use] pub const fn canonical_hour(&self) -> u32 {
130        if self.is_24h {
131            self.hour
132        } else {
133            let h12 = self.hour % 12; // 12 -> 0
134            h12 + if self.is_pm { 12 } else { 0 }
135        }
136    }
137
138    #[inline]
139    const fn hour_bounds(&self) -> (i64, i64) {
140        if self.is_24h {
141            (0, 23)
142        } else {
143            (1, 12)
144        }
145    }
146}
147
148// ---- colours ----
149const BORDER_COLOR: ColorU = ColorU { r: 206, g: 212, b: 218, a: 255 };
150const ARROW_COLOR: ColorU = ColorU { r: 73, g: 80, b: 87, a: 255 };
151const TEXT_COLOR: ColorU = ColorU { r: 33, g: 37, b: 41, a: 255 };
152const ACCENT_BG: ColorU = ColorU { r: 13, g: 110, b: 253, a: 255 };
153const WHITE: ColorU = ColorU { r: 255, g: 255, b: 255, a: 255 };
154
155const ACCENT_BG_ITEMS: &[StyleBackgroundContent] = &[StyleBackgroundContent::Color(ACCENT_BG)];
156const ACCENT_BG_VEC: StyleBackgroundContentVec =
157    StyleBackgroundContentVec::from_const_slice(ACCENT_BG_ITEMS);
158
159/// Container: a horizontal row that hugs its content.
160static CONTAINER_STYLE: &[CssPropertyWithConditions] = &[
161    CssPropertyWithConditions::simple(CssProperty::const_display(LayoutDisplay::Flex)),
162    CssPropertyWithConditions::simple(CssProperty::const_flex_direction(LayoutFlexDirection::Row)),
163    CssPropertyWithConditions::simple(CssProperty::const_align_items(LayoutAlignItems::Center)),
164    CssPropertyWithConditions::simple(CssProperty::align_self(LayoutAlignSelf::Start)),
165    CssPropertyWithConditions::simple(CssProperty::const_flex_grow(LayoutFlexGrow::const_new(0))),
166    CssPropertyWithConditions::simple(CssProperty::const_padding_top(LayoutPaddingTop::const_px(4))),
167    CssPropertyWithConditions::simple(CssProperty::const_padding_bottom(
168        LayoutPaddingBottom::const_px(4),
169    )),
170    CssPropertyWithConditions::simple(CssProperty::const_padding_left(LayoutPaddingLeft::const_px(
171        6,
172    ))),
173    CssPropertyWithConditions::simple(CssProperty::const_padding_right(
174        LayoutPaddingRight::const_px(6),
175    )),
176    CssPropertyWithConditions::simple(CssProperty::const_border_top_width(
177        LayoutBorderTopWidth::const_px(1),
178    )),
179    CssPropertyWithConditions::simple(CssProperty::const_border_bottom_width(
180        LayoutBorderBottomWidth::const_px(1),
181    )),
182    CssPropertyWithConditions::simple(CssProperty::const_border_left_width(
183        LayoutBorderLeftWidth::const_px(1),
184    )),
185    CssPropertyWithConditions::simple(CssProperty::const_border_right_width(
186        LayoutBorderRightWidth::const_px(1),
187    )),
188    CssPropertyWithConditions::simple(CssProperty::const_border_top_style(StyleBorderTopStyle {
189        inner: BorderStyle::Solid,
190    })),
191    CssPropertyWithConditions::simple(CssProperty::const_border_bottom_style(
192        StyleBorderBottomStyle {
193            inner: BorderStyle::Solid,
194        },
195    )),
196    CssPropertyWithConditions::simple(CssProperty::const_border_left_style(StyleBorderLeftStyle {
197        inner: BorderStyle::Solid,
198    })),
199    CssPropertyWithConditions::simple(CssProperty::const_border_right_style(
200        StyleBorderRightStyle {
201            inner: BorderStyle::Solid,
202        },
203    )),
204    CssPropertyWithConditions::simple(CssProperty::const_border_top_color(StyleBorderTopColor {
205        inner: BORDER_COLOR,
206    })),
207    CssPropertyWithConditions::simple(CssProperty::const_border_bottom_color(
208        StyleBorderBottomColor { inner: BORDER_COLOR },
209    )),
210    CssPropertyWithConditions::simple(CssProperty::const_border_left_color(StyleBorderLeftColor {
211        inner: BORDER_COLOR,
212    })),
213    CssPropertyWithConditions::simple(CssProperty::const_border_right_color(
214        StyleBorderRightColor { inner: BORDER_COLOR },
215    )),
216    CssPropertyWithConditions::simple(CssProperty::const_border_top_left_radius(
217        StyleBorderTopLeftRadius::const_px(6),
218    )),
219    CssPropertyWithConditions::simple(CssProperty::const_border_top_right_radius(
220        StyleBorderTopRightRadius::const_px(6),
221    )),
222    CssPropertyWithConditions::simple(CssProperty::const_border_bottom_left_radius(
223        StyleBorderBottomLeftRadius::const_px(6),
224    )),
225    CssPropertyWithConditions::simple(CssProperty::const_border_bottom_right_radius(
226        StyleBorderBottomRightRadius::const_px(6),
227    )),
228];
229
230/// One spinner column: up arrow, value, down arrow.
231static SPINNER_STYLE: &[CssPropertyWithConditions] = &[
232    CssPropertyWithConditions::simple(CssProperty::const_display(LayoutDisplay::Flex)),
233    CssPropertyWithConditions::simple(CssProperty::const_flex_direction(LayoutFlexDirection::Column)),
234    CssPropertyWithConditions::simple(CssProperty::const_align_items(LayoutAlignItems::Center)),
235    CssPropertyWithConditions::simple(CssProperty::const_flex_grow(LayoutFlexGrow::const_new(0))),
236    CssPropertyWithConditions::simple(CssProperty::const_width(LayoutWidth::const_px(40))),
237];
238
239/// Up/down arrow cell.
240static ARROW_STYLE: &[CssPropertyWithConditions] = &[
241    CssPropertyWithConditions::simple(CssProperty::const_font_size(StyleFontSize::const_px(11))),
242    CssPropertyWithConditions::simple(CssProperty::const_text_align(StyleTextAlign::Center)),
243    CssPropertyWithConditions::simple(CssProperty::const_cursor(StyleCursor::Pointer)),
244    CssPropertyWithConditions::simple(CssProperty::user_select(StyleUserSelect::None)),
245    CssPropertyWithConditions::simple(CssProperty::const_text_color(StyleTextColor {
246        inner: ARROW_COLOR,
247    })),
248    CssPropertyWithConditions::simple(CssProperty::const_padding_top(LayoutPaddingTop::const_px(2))),
249    CssPropertyWithConditions::simple(CssProperty::const_padding_bottom(
250        LayoutPaddingBottom::const_px(2),
251    )),
252];
253
254/// The value display in the middle of a spinner.
255static DISPLAY_STYLE: &[CssPropertyWithConditions] = &[
256    CssPropertyWithConditions::simple(CssProperty::const_font_size(StyleFontSize::const_px(18))),
257    CssPropertyWithConditions::simple(CssProperty::const_text_align(StyleTextAlign::Center)),
258    CssPropertyWithConditions::simple(CssProperty::user_select(StyleUserSelect::None)),
259    CssPropertyWithConditions::simple(CssProperty::const_text_color(StyleTextColor {
260        inner: TEXT_COLOR,
261    })),
262    CssPropertyWithConditions::simple(CssProperty::const_padding_top(LayoutPaddingTop::const_px(2))),
263    CssPropertyWithConditions::simple(CssProperty::const_padding_bottom(
264        LayoutPaddingBottom::const_px(2),
265    )),
266];
267
268/// The `:` separator between the hour and minute spinners.
269static SEPARATOR_STYLE: &[CssPropertyWithConditions] = &[
270    CssPropertyWithConditions::simple(CssProperty::const_font_size(StyleFontSize::const_px(18))),
271    CssPropertyWithConditions::simple(CssProperty::user_select(StyleUserSelect::None)),
272    CssPropertyWithConditions::simple(CssProperty::const_text_color(StyleTextColor {
273        inner: TEXT_COLOR,
274    })),
275    CssPropertyWithConditions::simple(CssProperty::const_padding_left(LayoutPaddingLeft::const_px(
276        2,
277    ))),
278    CssPropertyWithConditions::simple(CssProperty::const_padding_right(
279        LayoutPaddingRight::const_px(2),
280    )),
281];
282
283/// The clickable AM/PM toggle (12-hour mode only).
284static AMPM_STYLE: &[CssPropertyWithConditions] = &[
285    CssPropertyWithConditions::simple(CssProperty::const_font_size(StyleFontSize::const_px(13))),
286    CssPropertyWithConditions::simple(CssProperty::const_text_align(StyleTextAlign::Center)),
287    CssPropertyWithConditions::simple(CssProperty::const_cursor(StyleCursor::Pointer)),
288    CssPropertyWithConditions::simple(CssProperty::user_select(StyleUserSelect::None)),
289    CssPropertyWithConditions::simple(CssProperty::const_text_color(StyleTextColor { inner: WHITE })),
290    CssPropertyWithConditions::simple(CssProperty::const_background_content(ACCENT_BG_VEC)),
291    CssPropertyWithConditions::simple(CssProperty::const_margin_left(LayoutMarginLeft::const_px(8))),
292    CssPropertyWithConditions::simple(CssProperty::const_padding_top(LayoutPaddingTop::const_px(4))),
293    CssPropertyWithConditions::simple(CssProperty::const_padding_bottom(
294        LayoutPaddingBottom::const_px(4),
295    )),
296    CssPropertyWithConditions::simple(CssProperty::const_padding_left(LayoutPaddingLeft::const_px(
297        8,
298    ))),
299    CssPropertyWithConditions::simple(CssProperty::const_padding_right(
300        LayoutPaddingRight::const_px(8),
301    )),
302    CssPropertyWithConditions::simple(CssProperty::const_border_top_left_radius(
303        StyleBorderTopLeftRadius::const_px(4),
304    )),
305    CssPropertyWithConditions::simple(CssProperty::const_border_top_right_radius(
306        StyleBorderTopRightRadius::const_px(4),
307    )),
308    CssPropertyWithConditions::simple(CssProperty::const_border_bottom_left_radius(
309        StyleBorderBottomLeftRadius::const_px(4),
310    )),
311    CssPropertyWithConditions::simple(CssProperty::const_border_bottom_right_radius(
312        StyleBorderBottomRightRadius::const_px(4),
313    )),
314];
315
316impl TimePicker {
317    /// Creates a new 24-hour `TimePicker` with the given initial hour (`0..=23`)
318    /// and minute (`0..=59`), both clamped into range.
319    #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] // bounded layout/render numeric cast
320    #[must_use] pub fn create(hour: u32, minute: u32) -> Self {
321        let mut inner = TimePickerState::default();
322        let (lo, hi) = inner.hour_bounds();
323        inner.hour = i64::from(hour).clamp(lo, hi) as u32;
324        inner.minute = i64::from(minute).clamp(0, 59) as u32;
325        Self {
326            state: TimePickerStateWrapper {
327                inner,
328                on_change: None.into(),
329            },
330            container_style: CssPropertyWithConditionsVec::from_const_slice(CONTAINER_STYLE),
331        }
332    }
333
334    /// Switches between 24-hour (no AM/PM) and 12-hour (with AM/PM) display,
335    /// re-clamping the hour into the new range.
336    #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] // bounded layout/render numeric cast
337    pub fn set_24h(&mut self, is_24h: bool) {
338        self.state.inner.is_24h = is_24h;
339        let (lo, hi) = self.state.inner.hour_bounds();
340        self.state.inner.hour = i64::from(self.state.inner.hour).clamp(lo, hi) as u32;
341    }
342
343    /// Builder variant of [`Self::set_24h`].
344    #[must_use] pub fn with_24h(mut self, is_24h: bool) -> Self {
345        self.set_24h(is_24h);
346        self
347    }
348
349    /// Sets the AM/PM flag (only meaningful in 12-hour mode).
350    pub const fn set_pm(&mut self, is_pm: bool) {
351        self.state.inner.is_pm = is_pm;
352    }
353
354    /// Builder variant of [`Self::set_pm`].
355    #[must_use] pub const fn with_pm(mut self, is_pm: bool) -> Self {
356        self.set_pm(is_pm);
357        self
358    }
359
360    /// Sets the callback invoked when any value changes.
361    pub fn set_on_change<C: Into<TimePickerOnChangeCallback>>(&mut self, data: RefAny, callback: C) {
362        self.state.on_change = Some(TimePickerOnChange {
363            callback: callback.into(),
364            refany: data,
365        })
366        .into();
367    }
368
369    /// Builder variant of [`Self::set_on_change`].
370    #[must_use] pub fn with_on_change<C: Into<TimePickerOnChangeCallback>>(
371        mut self,
372        data: RefAny,
373        callback: C,
374    ) -> Self {
375        self.set_on_change(data, callback);
376        self
377    }
378
379    /// Replaces `self` with the default value and returns the original.
380    #[must_use] pub fn swap_with_default(&mut self) -> Self {
381        let mut s = Self::create(0, 0);
382        core::mem::swap(&mut s, self);
383        s
384    }
385
386    #[must_use] pub fn dom(self) -> Dom {
387        let inner = self.state.inner;
388        let is_24h = inner.is_24h;
389        let hour_text = AzString::from(format!("{}", inner.hour));
390        let minute_text = AzString::from(format!("{:02}", inner.minute));
391        let container_style = self.container_style.clone();
392
393        let state = RefAny::new(self.state);
394
395        let mut children = alloc::vec![
396            build_spinner(
397                hour_text,
398                state.clone(),
399                on_hour_up as usize,
400                on_hour_down as usize,
401            ),
402            Dom::create_text(SEPARATOR_TEXT)
403                .with_ids_and_classes(IdOrClassVec::from_const_slice(SEPARATOR_CLASS))
404                .with_css_props(CssPropertyWithConditionsVec::from_const_slice(SEPARATOR_STYLE)),
405            build_spinner(
406                minute_text,
407                state.clone(),
408                on_minute_up as usize,
409                on_minute_down as usize,
410            ),
411        ];
412
413        if !is_24h {
414            let ampm_text = if inner.is_pm {
415                AzString::from_const_str("PM")
416            } else {
417                AzString::from_const_str("AM")
418            };
419            children.push(
420                Dom::create_text(ampm_text)
421                    .with_ids_and_classes(IdOrClassVec::from_const_slice(AMPM_CLASS))
422                    .with_css_props(CssPropertyWithConditionsVec::from_const_slice(AMPM_STYLE))
423                    .with_callbacks(
424                        alloc::vec![CoreCallbackData {
425                            event: azul_core::dom::EventFilter::Hover(
426                                azul_core::dom::HoverEventFilter::MouseUp,
427                            ),
428                            callback: CoreCallback {
429                                cb: on_ampm_toggle as usize,
430                                ctx: OptionRefAny::None,
431                            },
432                            refany: state,
433                        }]
434                        .into(),
435                    )
436                    .with_tab_index(TabIndex::Auto),
437            );
438        }
439
440        Dom::create_div()
441            .with_ids_and_classes(IdOrClassVec::from_const_slice(TIME_PICKER_CLASS))
442            .with_css_props(container_style)
443            .with_children(children.into())
444    }
445}
446
447impl Default for TimePicker {
448    fn default() -> Self {
449        Self::create(0, 0)
450    }
451}
452
453/// Builds one spinner column (up arrow / value display / down arrow). The up and
454/// down arrows carry the shared `state` `RefAny` and the given click handlers; the
455/// middle display is class-tagged so handlers can re-text it.
456fn build_spinner(value: AzString, state: RefAny, up_cb: usize, down_cb: usize) -> Dom {
457    use azul_core::dom::{EventFilter, HoverEventFilter};
458
459    let arrow_cell = |arrow: AzString, cb: usize, refany: RefAny| -> Dom {
460        Dom::create_text(arrow)
461            .with_ids_and_classes(IdOrClassVec::from_const_slice(ARROW_CLASS))
462            .with_css_props(CssPropertyWithConditionsVec::from_const_slice(ARROW_STYLE))
463            .with_callbacks(
464                alloc::vec![CoreCallbackData {
465                    event: EventFilter::Hover(HoverEventFilter::MouseUp),
466                    callback: CoreCallback {
467                        cb,
468                        ctx: OptionRefAny::None,
469                    },
470                    refany,
471                }]
472                .into(),
473            )
474            .with_tab_index(TabIndex::Auto)
475    };
476
477    Dom::create_div()
478        .with_ids_and_classes(IdOrClassVec::from_const_slice(SPINNER_CLASS))
479        .with_css_props(CssPropertyWithConditionsVec::from_const_slice(SPINNER_STYLE))
480        .with_children(
481            alloc::vec![
482                arrow_cell(UP_ARROW, up_cb, state.clone()),
483                Dom::create_text(value)
484                    .with_ids_and_classes(IdOrClassVec::from_const_slice(DISPLAY_CLASS))
485                    .with_css_props(CssPropertyWithConditionsVec::from_const_slice(DISPLAY_STYLE)),
486                arrow_cell(DOWN_ARROW, down_cb, state),
487            ]
488            .into(),
489        )
490}
491
492/// Shared spinner logic: clamps the targeted field, re-texts the display node
493/// (the middle child of the clicked arrow's parent spinner), and fires the
494/// optional `on_change`.
495#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] // bounded layout/render numeric cast
496fn adjust_spinner(mut data: RefAny, mut info: CallbackInfo, is_hour: bool, delta: i64) -> Update {
497    // The clicked node is an arrow; its parent is the spinner; the spinner's
498    // first child is the up arrow and the next sibling is the value display.
499    let hit = info.get_hit_node();
500    let Some(parent) = info.get_parent(hit) else {
501        return Update::DoNothing;
502    };
503    let Some(up) = info.get_first_child(parent) else {
504        return Update::DoNothing;
505    };
506    let Some(display) = info.get_next_sibling(up) else {
507        return Update::DoNothing;
508    };
509
510    let (update, display_text) = {
511        let Some(mut w) = data.downcast_mut::<TimePickerStateWrapper>() else {
512            return Update::DoNothing;
513        };
514
515        let display_text = if is_hour {
516            let (lo, hi) = w.inner.hour_bounds();
517            w.inner.hour = (i64::from(w.inner.hour) + delta).clamp(lo, hi) as u32;
518            AzString::from(format!("{}", w.inner.hour))
519        } else {
520            // PARTIAL: minute clamps; it does not wrap/carry into the hour.
521            w.inner.minute = (i64::from(w.inner.minute) + delta).clamp(0, 59) as u32;
522            AzString::from(format!("{:02}", w.inner.minute))
523        };
524
525        let inner = w.inner;
526        let w = &mut *w;
527        let update = match w.on_change.as_mut() {
528            Some(TimePickerOnChange { callback, refany }) => {
529                (callback.cb)(refany.clone(), info, inner)
530            }
531            None => Update::DoNothing,
532        };
533        (update, display_text)
534    };
535
536    info.change_node_text(display, display_text);
537    update
538}
539
540extern "C" fn on_hour_up(data: RefAny, info: CallbackInfo) -> Update {
541    adjust_spinner(data, info, true, 1)
542}
543
544extern "C" fn on_hour_down(data: RefAny, info: CallbackInfo) -> Update {
545    adjust_spinner(data, info, true, -1)
546}
547
548extern "C" fn on_minute_up(data: RefAny, info: CallbackInfo) -> Update {
549    adjust_spinner(data, info, false, 1)
550}
551
552extern "C" fn on_minute_down(data: RefAny, info: CallbackInfo) -> Update {
553    adjust_spinner(data, info, false, -1)
554}
555
556/// Toggles the AM/PM flag and re-texts the clicked toggle node.
557extern "C" fn on_ampm_toggle(mut data: RefAny, mut info: CallbackInfo) -> Update {
558    let hit = info.get_hit_node();
559
560    let (update, text) = {
561        let Some(mut w) = data.downcast_mut::<TimePickerStateWrapper>() else {
562            return Update::DoNothing;
563        };
564        w.inner.is_pm = !w.inner.is_pm;
565        let inner = w.inner;
566        let text = if inner.is_pm {
567            AzString::from_const_str("PM")
568        } else {
569            AzString::from_const_str("AM")
570        };
571        let w = &mut *w;
572        let update = match w.on_change.as_mut() {
573            Some(TimePickerOnChange { callback, refany }) => {
574                (callback.cb)(refany.clone(), info, inner)
575            }
576            None => Update::DoNothing,
577        };
578        (update, text)
579    };
580
581    info.change_node_text(hit, text);
582    update
583}
584
585impl From<TimePicker> for Dom {
586    fn from(t: TimePicker) -> Self {
587        t.dom()
588    }
589}
590
591#[cfg(test)]
592mod autotest_generated {
593    use std::{
594        collections::{BTreeMap, HashMap},
595        sync::{Arc, Mutex},
596    };
597
598    use azul_core::{
599        dom::{DomId, DomNodeId, EventFilter, HoverEventFilter, NodeId, NodeType},
600        geom::{LogicalRect, OptionLogicalPosition},
601        gl::OptionGlContextPtr,
602        hit_test::ScrollPosition,
603        resources::RendererResources,
604        styled_dom::{NodeHierarchyItemId, StyledDom},
605        window::{MonitorVec, RawWindowHandle},
606    };
607    use rust_fontconfig::FcFontCache;
608
609    use super::*;
610    #[cfg(feature = "icu")]
611    use crate::icu::IcuLocalizerHandle;
612    use crate::{
613        callbacks::{CallbackChange, CallbackInfoRefData, ExternalSystemCallbacks},
614        solver3::{display_list::DisplayList, layout_tree::LayoutTree},
615        window::{DomLayoutResult, LayoutWindow},
616        window_state::FullWindowState,
617    };
618
619    // ==================================================================
620    // Const-evaluated extremes
621    //
622    // `canonical_hour` and `hour_bounds` are `const fn`, so evaluating them on
623    // states holding the integer extremes in a `const` item makes the compiler
624    // itself prove there is no overflow / const-eval panic on those inputs —
625    // a stronger statement than any runtime assertion.
626    // ==================================================================
627
628    const EXTREME_24H: TimePickerState = TimePickerState {
629        hour: u32::MAX,
630        minute: u32::MAX,
631        is_pm: true,
632        is_24h: true,
633    };
634    const EXTREME_12H: TimePickerState = TimePickerState {
635        hour: u32::MAX,
636        minute: u32::MAX,
637        is_pm: true,
638        is_24h: false,
639    };
640
641    const _CANONICAL_AT_MAX_24H: u32 = EXTREME_24H.canonical_hour();
642    const _CANONICAL_AT_MAX_12H: u32 = EXTREME_12H.canonical_hour();
643    const _BOUNDS_AT_MAX_24H: (i64, i64) = EXTREME_24H.hour_bounds();
644    const _BOUNDS_AT_MAX_12H: (i64, i64) = EXTREME_12H.hour_bounds();
645
646    // ==================================================================
647    // Flattened node layout
648    //
649    // `convert_dom_into_compact_dom` walks the tree in pre-order, and a time
650    // picker is a fixed-shape widget:
651    //
652    //     container
653    //       hour spinner   → [▲, display, ▼]
654    //       ":"
655    //       minute spinner → [▲, display, ▼]
656    //       AM/PM          (12-hour mode only)
657    //
658    // so the flattened indices are constant. `flattened_layout_is_the_fixed
659    // _ten_or_eleven_nodes` pins them against the real hierarchy, so the click
660    // tests below cannot silently drift onto the wrong node.
661    // ==================================================================
662
663    const N_CONTAINER: usize = 0;
664    const N_HOUR_SPINNER: usize = 1;
665    const N_HOUR_UP: usize = 2;
666    const N_HOUR_DISPLAY: usize = 3;
667    const N_HOUR_DOWN: usize = 4;
668    const N_SEPARATOR: usize = 5;
669    const N_MINUTE_SPINNER: usize = 6;
670    const N_MINUTE_UP: usize = 7;
671    const N_MINUTE_DISPLAY: usize = 8;
672    const N_MINUTE_DOWN: usize = 9;
673    const N_AMPM: usize = 10;
674
675    // The class names are part of the widget's public surface: user stylesheets
676    // select on them, so a rename is a breaking change and is spelled out here
677    // rather than read back out of the statics under test.
678    const CLASS_CONTAINER: &str = "__azul-native-time-picker";
679    const CLASS_SPINNER: &str = "__azul-native-time-picker-spinner";
680    const CLASS_DISPLAY: &str = "__azul-native-time-picker-display";
681    const CLASS_ARROW: &str = "__azul-native-time-picker-arrow";
682    const CLASS_SEPARATOR: &str = "__azul-native-time-picker-separator";
683    const CLASS_AMPM: &str = "__azul-native-time-picker-ampm";
684
685    const UP_GLYPH: &str = "\u{25B2}";
686    const DOWN_GLYPH: &str = "\u{25BC}";
687
688    // ==================================================================
689    // Harness
690    // ==================================================================
691
692    /// A `DomNodeId` in the root DOM pointing at flattened node `idx`.
693    fn node(idx: usize) -> DomNodeId {
694        DomNodeId {
695            dom: DomId::ROOT_ID,
696            node: NodeHierarchyItemId::from_crate_internal(Some(NodeId::new(idx))),
697        }
698    }
699
700    /// A `DomNodeId` whose node component is `None` — the "no concrete node was
701    /// hit" case that every hierarchy query must decline rather than index.
702    fn node_none() -> DomNodeId {
703        DomNodeId {
704            dom: DomId::ROOT_ID,
705            node: NodeHierarchyItemId::NONE,
706        }
707    }
708
709    /// A `DomLayoutResult` carrying only a `styled_dom`: the time-picker handlers
710    /// reach exactly four `CallbackInfo` queries (`get_hit_node`, `get_parent`,
711    /// `get_first_child`, `get_next_sibling`), all of which read the node
712    /// hierarchy only — no real layout (and no font) is needed.
713    fn layout_result(styled_dom: StyledDom) -> DomLayoutResult {
714        DomLayoutResult {
715            styled_dom,
716            layout_tree: LayoutTree {
717                nodes: Vec::new(),
718                warm: Vec::new(),
719                cold: Vec::new(),
720                root: 0,
721                dom_to_layout: BTreeMap::new(),
722                children_arena: Vec::new(),
723                children_offsets: Vec::new(),
724                subtree_needs_intrinsic: Vec::new(),
725            },
726            calculated_positions: Vec::new(),
727            viewport: LogicalRect::zero(),
728            display_list: DisplayList::default(),
729            scroll_ids: HashMap::new(),
730            scroll_id_to_node_id: HashMap::new(),
731        }
732    }
733
734    /// Runs `f` with a `CallbackInfo` whose window holds `styled_dom` as the root
735    /// DOM and whose hit node is `hit`. Returns `f`'s value plus every change the
736    /// callback pushed onto the transaction log.
737    fn with_info<R>(
738        styled_dom: StyledDom,
739        hit: DomNodeId,
740        f: impl FnOnce(&mut CallbackInfo) -> R,
741    ) -> (R, Vec<CallbackChange>) {
742        let mut layout_window =
743            LayoutWindow::new(FcFontCache::default()).expect("LayoutWindow::new failed");
744        layout_window
745            .layout_results
746            .insert(DomId::ROOT_ID, layout_result(styled_dom));
747
748        let renderer_resources = RendererResources::default();
749        let previous_window_state: Option<FullWindowState> = None;
750        let current_window_state = FullWindowState::default();
751        let gl_context = OptionGlContextPtr::None;
752        let scroll_states: BTreeMap<DomId, BTreeMap<NodeHierarchyItemId, ScrollPosition>> =
753            BTreeMap::new();
754        let window_handle = RawWindowHandle::Unsupported;
755        let system_callbacks = ExternalSystemCallbacks::rust_internal();
756
757        let ref_data = CallbackInfoRefData {
758            layout_window: &layout_window,
759            renderer_resources: &renderer_resources,
760            previous_window_state: &previous_window_state,
761            current_window_state: &current_window_state,
762            gl_context: &gl_context,
763            current_scroll_manager: &scroll_states,
764            current_window_handle: &window_handle,
765            system_callbacks: &system_callbacks,
766            system_style: Arc::new(azul_css::system::SystemStyle::default()),
767            monitors: Arc::new(Mutex::new(MonitorVec::from_const_slice(&[]))),
768            #[cfg(feature = "icu")]
769            icu_localizer: IcuLocalizerHandle::default(),
770            ctx: OptionRefAny::None,
771        };
772
773        let changes: Arc<Mutex<Vec<CallbackChange>>> = Arc::new(Mutex::new(Vec::new()));
774
775        let mut info = CallbackInfo::new(
776            &ref_data,
777            &changes,
778            hit,
779            OptionLogicalPosition::None,
780            OptionLogicalPosition::None,
781        );
782
783        let r = f(&mut info);
784        let pushed = info.take_changes();
785        (r, pushed)
786    }
787
788    // ------------------------------------------------------------------
789    // Tree probes
790    // ------------------------------------------------------------------
791
792    /// The text a node renders, or `None` for a non-text node.
793    fn text_of(dom: &Dom) -> Option<String> {
794        dom.root.get_node_type().format()
795    }
796
797    fn classes(dom: &Dom) -> Vec<String> {
798        dom.root
799            .get_ids_and_classes()
800            .as_ref()
801            .iter()
802            .filter_map(|c| match c {
803                IdOrClass::Class(s) => Some(s.as_str().to_string()),
804                IdOrClass::Id(_) => None,
805            })
806            .collect()
807    }
808
809    /// The classes of a *flattened* node.
810    fn flat_classes(sd: &StyledDom, idx: usize) -> Vec<String> {
811        sd.node_data.as_ref()[idx]
812            .get_ids_and_classes()
813            .as_ref()
814            .iter()
815            .filter_map(|c| match c {
816                IdOrClass::Class(s) => Some(s.as_str().to_string()),
817                IdOrClass::Id(_) => None,
818            })
819            .collect()
820    }
821
822    /// The text of a *flattened* node.
823    fn flat_text(sd: &StyledDom, idx: usize) -> Option<String> {
824        sd.node_data.as_ref()[idx].get_node_type().format()
825    }
826
827    /// The recursive `1-per-descendant` total of a `Dom`'s children — what
828    /// `estimated_total_children` caches. An under-report makes the flatten
829    /// under-allocate its arenas and panic on an out-of-bounds write.
830    fn descendants(dom: &Dom) -> usize {
831        dom.children
832            .as_ref()
833            .iter()
834            .map(|c| 1 + descendants(c))
835            .sum()
836    }
837
838    /// The hour column, the separator and the minute column of a rendered picker.
839    fn columns(dom: &Dom) -> (&Dom, &Dom, &Dom) {
840        let c = dom.children.as_ref();
841        assert!(
842            c.len() == 3 || c.len() == 4,
843            "a time picker renders hour + ':' + minute (+ AM/PM), got {} children",
844            c.len(),
845        );
846        (&c[0], &c[1], &c[2])
847    }
848
849    /// The `(hour, minute)` strings a rendered picker displays.
850    fn displayed(dom: &Dom) -> (String, String) {
851        let (hour, _, minute) = columns(dom);
852        (
853            text_of(&hour.children.as_ref()[1]).expect("the hour display is not a text node"),
854            text_of(&minute.children.as_ref()[1]).expect("the minute display is not a text node"),
855        )
856    }
857
858    /// The AM/PM label, or `None` when the widget is in 24-hour mode.
859    fn ampm_label(dom: &Dom) -> Option<String> {
860        dom.children.as_ref().get(3).and_then(text_of)
861    }
862
863    /// Every `RefAny` in the flattened DOM that carries the widget's own state.
864    fn state_payloads(sd: &StyledDom) -> Vec<RefAny> {
865        let mut out = Vec::new();
866        for nd in sd.node_data.as_ref() {
867            for cb in nd.callbacks.as_ref() {
868                let carries = {
869                    let mut r = cb.refany.clone();
870                    let carries = r.downcast_ref::<TimePickerStateWrapper>().is_some();
871                    carries
872                };
873                if carries {
874                    out.push(cb.refany.clone());
875                }
876            }
877        }
878        out
879    }
880
881    /// The single shared state `RefAny` the widget baked into its own handlers.
882    fn shared_state(sd: &StyledDom) -> RefAny {
883        state_payloads(sd)
884            .into_iter()
885            .next()
886            .expect("the rendered time picker carries no TimePickerStateWrapper")
887    }
888
889    /// `(flattened node id, payload)` of the node wired to `handler`.
890    fn wired_to(sd: &StyledDom, handler: usize) -> (DomNodeId, RefAny) {
891        for (i, nd) in sd.node_data.as_ref().iter().enumerate() {
892            for cb in nd.callbacks.as_ref() {
893                if cb.callback.cb == handler {
894                    return (node(i), cb.refany.clone());
895                }
896            }
897        }
898        panic!("the rendered time picker has nothing wired to that handler");
899    }
900
901    fn read_state(shared: &RefAny) -> TimePickerState {
902        let mut s = shared.clone();
903        let w = s
904            .downcast_ref::<TimePickerStateWrapper>()
905            .expect("the widget state changed type");
906        w.inner
907    }
908
909    /// Renders a picker and hands back its flattened DOM plus the very shared
910    /// state its own handlers were wired against — nothing is re-created by
911    /// hand, so a mismatch between `dom()` and the handlers cannot hide here.
912    fn laid_out(picker: TimePicker) -> (StyledDom, RefAny) {
913        let styled = StyledDom::create_from_dom(picker.dom());
914        let shared = shared_state(&styled);
915        (styled, shared)
916    }
917
918    /// A hand-built shared state, for driving the handlers against values the
919    /// public constructors clamp away.
920    fn wrapper(inner: TimePickerState) -> RefAny {
921        RefAny::new(TimePickerStateWrapper {
922            inner,
923            on_change: None.into(),
924        })
925    }
926
927    /// One "mouse-up on `hit`" delivered to `handler`.
928    fn press(
929        styled_dom: StyledDom,
930        payload: &RefAny,
931        hit: DomNodeId,
932        handler: extern "C" fn(RefAny, CallbackInfo) -> Update,
933    ) -> (Update, Vec<CallbackChange>) {
934        with_info(styled_dom, hit, |info| handler(payload.clone(), *info))
935    }
936
937    /// `times` presses of `handler` against `payload`, all delivered on `hit`.
938    fn press_n(
939        styled_dom: StyledDom,
940        payload: &RefAny,
941        hit: DomNodeId,
942        handler: extern "C" fn(RefAny, CallbackInfo) -> Update,
943        times: usize,
944    ) -> (Update, Vec<CallbackChange>) {
945        with_info(styled_dom, hit, |info| {
946            let mut last = Update::DoNothing;
947            for _ in 0..times {
948                last = handler(payload.clone(), *info);
949            }
950            last
951        })
952    }
953
954    /// The `(target, text)` of every text retext pushed onto the transaction log.
955    fn pushed_texts(changes: &[CallbackChange]) -> Vec<(DomNodeId, String)> {
956        changes
957            .iter()
958            .filter_map(|c| match c {
959                CallbackChange::ChangeNodeText { node_id, text } => {
960                    Some((*node_id, text.as_str().to_string()))
961                }
962                _ => None,
963            })
964            .collect()
965    }
966
967    /// The single retext a spinner/toggle press must push, asserting there is
968    /// exactly one and that it is the *only* change of any kind.
969    fn only_retext(changes: &[CallbackChange]) -> (DomNodeId, String) {
970        let texts = pushed_texts(changes);
971        assert_eq!(
972            texts.len(),
973            1,
974            "expected exactly one retext, got {} change(s) total",
975            changes.len(),
976        );
977        assert_eq!(
978            changes.len(),
979            1,
980            "the press pushed {} change(s) beyond its retext",
981            changes.len() - 1,
982        );
983        texts.into_iter().next().unwrap()
984    }
985
986    // ------------------------------------------------------------------
987    // Style probes
988    // ------------------------------------------------------------------
989
990    fn properties(v: &CssPropertyWithConditionsVec) -> Vec<CssProperty> {
991        v.as_ref().iter().map(|p| p.property.clone()).collect()
992    }
993
994    // ------------------------------------------------------------------
995    // User callbacks
996    // ------------------------------------------------------------------
997
998    /// A payload the change callback writes into. It arrives as the `data: RefAny`
999    /// argument — a *shared* clone of what the test still holds — so the test can
1000    /// read back exactly what the widget reported, without any global state.
1001    #[derive(Debug, Clone, PartialEq, Eq)]
1002    struct ChangeLog {
1003        seen: Vec<TimePickerState>,
1004        payload: u32,
1005    }
1006
1007    extern "C" fn record_change(
1008        mut data: RefAny,
1009        _info: CallbackInfo,
1010        state: TimePickerState,
1011    ) -> Update {
1012        if let Some(mut log) = data.downcast_mut::<ChangeLog>() {
1013            log.seen.push(state);
1014        }
1015        Update::RefreshDom
1016    }
1017
1018    extern "C" fn change_do_nothing(
1019        _data: RefAny,
1020        _info: CallbackInfo,
1021        _state: TimePickerState,
1022    ) -> Update {
1023        Update::DoNothing
1024    }
1025
1026    extern "C" fn change_refresh_all(
1027        _data: RefAny,
1028        _info: CallbackInfo,
1029        _state: TimePickerState,
1030    ) -> Update {
1031        Update::RefreshDomAllWindows
1032    }
1033
1034    /// A `Callback`-shaped (2-arg) function — the shape FFI bindings hand in,
1035    /// which the `From<Callback>` arm *transmutes* into the 3-arg time-picker
1036    /// slot. Never called.
1037    extern "C" fn generic_shaped(_data: RefAny, _info: CallbackInfo) -> Update {
1038        Update::DoNothing
1039    }
1040
1041    fn log_refany() -> RefAny {
1042        RefAny::new(ChangeLog {
1043            seen: Vec::new(),
1044            payload: 0xDEAD_BEEF,
1045        })
1046    }
1047
1048    fn read_log(probe: &RefAny) -> ChangeLog {
1049        let mut probe = probe.clone();
1050        let log = probe
1051            .downcast_ref::<ChangeLog>()
1052            .expect("the user payload changed type");
1053        log.clone()
1054    }
1055
1056    // ==================================================================
1057    // TimePickerState::canonical_hour
1058    // ==================================================================
1059
1060    #[test]
1061    fn canonical_hour_maps_every_twelve_hour_clock_face_to_its_canonical_hour() {
1062        // The whole point of the function: `12 AM -> 0` and `12 PM -> 12` are the
1063        // two cases a naive `hour + 12*pm` gets wrong (it would answer 12 and 24).
1064        for (hour, is_pm, want) in [
1065            (12u32, false, 0u32),
1066            (1, false, 1),
1067            (11, false, 11),
1068            (12, true, 12),
1069            (1, true, 13),
1070            (11, true, 23),
1071        ] {
1072            let s = TimePickerState {
1073                hour,
1074                minute: 0,
1075                is_pm,
1076                is_24h: false,
1077            };
1078            assert_eq!(
1079                s.canonical_hour(),
1080                want,
1081                "{hour} {} is not canonical hour {want}",
1082                if is_pm { "PM" } else { "AM" },
1083            );
1084        }
1085    }
1086
1087    #[test]
1088    fn canonical_hour_round_trips_every_hour_of_the_day_through_the_clock_face() {
1089        // encode (canonical -> 12-hour face + AM/PM) then decode (canonical_hour)
1090        // must be the identity across the whole day, or a picker built from a
1091        // 24-hour timestamp would hand a different hour back to the host.
1092        for canonical in 0..24u32 {
1093            let face = if canonical % 12 == 0 { 12 } else { canonical % 12 };
1094            let s = TimePickerState {
1095                hour: face,
1096                minute: 0,
1097                is_pm: canonical >= 12,
1098                is_24h: false,
1099            };
1100            assert_eq!(
1101                s.canonical_hour(),
1102                canonical,
1103                "the 12-hour encoding of {canonical}:00 did not decode back",
1104            );
1105        }
1106    }
1107
1108    #[test]
1109    fn canonical_hour_never_leaves_the_day_in_twelve_hour_mode_for_any_hour() {
1110        // `hour` is a public `u32` field: nothing stops a host from writing 0, 13
1111        // or u32::MAX into it. In 12-hour mode the `% 12` must keep the answer a
1112        // real hour whatever it is handed.
1113        for hour in [
1114            0u32,
1115            1,
1116            12,
1117            13,
1118            23,
1119            24,
1120            25,
1121            59,
1122            100,
1123            1_000_000,
1124            u32::MAX / 2,
1125            u32::MAX - 1,
1126            u32::MAX,
1127        ] {
1128            for is_pm in [false, true] {
1129                let s = TimePickerState {
1130                    hour,
1131                    minute: 0,
1132                    is_pm,
1133                    is_24h: false,
1134                };
1135                let c = s.canonical_hour();
1136                assert!(
1137                    c < 24,
1138                    "canonical_hour({hour}, pm={is_pm}) = {c} is not an hour of the day",
1139                );
1140            }
1141        }
1142    }
1143
1144    #[test]
1145    fn canonical_hour_in_24h_mode_returns_the_hour_verbatim() {
1146        // FINDING (pinned, not weakened): the doc comment promises `0..=23`, but
1147        // the 24-hour arm is a bare field read with no clamp. Every constructor
1148        // and setter in this file clamps, so the promise holds for widget-managed
1149        // state — but a host that writes `state.inner.hour` directly gets its own
1150        // value straight back out, un-normalised.
1151        for hour in [0u32, 23, 24, 99, u32::MAX] {
1152            let s = TimePickerState {
1153                hour,
1154                minute: 0,
1155                is_pm: false,
1156                is_24h: true,
1157            };
1158            assert_eq!(
1159                s.canonical_hour(),
1160                hour,
1161                "the 24-hour arm normalised {hour}, which it has never done",
1162            );
1163        }
1164    }
1165
1166    #[test]
1167    fn canonical_hour_ignores_the_pm_flag_in_24h_mode() {
1168        // A stale `is_pm` left over from a 12-hour session must not shift a
1169        // 24-hour reading by twelve hours.
1170        for hour in 0..24u32 {
1171            let am = TimePickerState {
1172                hour,
1173                minute: 0,
1174                is_pm: false,
1175                is_24h: true,
1176            };
1177            let pm = TimePickerState { is_pm: true, ..am };
1178            assert_eq!(
1179                am.canonical_hour(),
1180                pm.canonical_hour(),
1181                "the PM flag leaked into the 24-hour reading of hour {hour}",
1182            );
1183        }
1184    }
1185
1186    #[test]
1187    fn canonical_hour_does_not_depend_on_the_minute() {
1188        for minute in [0u32, 30, 59, 60, u32::MAX] {
1189            for is_24h in [false, true] {
1190                let s = TimePickerState {
1191                    hour: 7,
1192                    minute,
1193                    is_pm: true,
1194                    is_24h,
1195                };
1196                let baseline = TimePickerState { minute: 0, ..s };
1197                assert_eq!(
1198                    s.canonical_hour(),
1199                    baseline.canonical_hour(),
1200                    "minute {minute} changed the hour reading (is_24h={is_24h})",
1201                );
1202            }
1203        }
1204    }
1205
1206    #[test]
1207    fn canonical_hour_of_a_default_state_is_midnight() {
1208        assert_eq!(TimePickerState::default().canonical_hour(), 0);
1209        assert_eq!(TimePicker::default().state.inner.canonical_hour(), 0);
1210        assert_eq!(TimePickerStateWrapper::default().inner.canonical_hour(), 0);
1211    }
1212
1213    #[test]
1214    fn canonical_hour_is_total_at_the_u32_extremes() {
1215        // Const-evaluated above too; this pins the *values* the extremes produce.
1216        assert_eq!(EXTREME_24H.canonical_hour(), u32::MAX);
1217        // 4294967295 % 12 == 3, so the 12-hour arm answers 3 PM.
1218        assert_eq!(EXTREME_12H.canonical_hour(), 15);
1219    }
1220
1221    // ==================================================================
1222    // TimePickerState::hour_bounds
1223    // ==================================================================
1224
1225    #[test]
1226    fn hour_bounds_are_the_two_documented_bands() {
1227        let mut s = TimePickerState {
1228            is_24h: true,
1229            ..Default::default()
1230        };
1231        assert_eq!(s.hour_bounds(), (0, 23), "the 24-hour band is wrong");
1232        s.is_24h = false;
1233        assert_eq!(s.hour_bounds(), (1, 12), "the 12-hour band is wrong");
1234    }
1235
1236    #[test]
1237    fn hour_bounds_depend_on_nothing_but_the_mode() {
1238        // If the band ever started depending on the current hour, clamping would
1239        // become order-dependent and `set_24h` would stop being idempotent.
1240        for hour in [0u32, 1, 12, 13, 23, u32::MAX] {
1241            for minute in [0u32, 59, u32::MAX] {
1242                for is_pm in [false, true] {
1243                    for is_24h in [false, true] {
1244                        let s = TimePickerState {
1245                            hour,
1246                            minute,
1247                            is_pm,
1248                            is_24h,
1249                        };
1250                        let want = if is_24h { (0, 23) } else { (1, 12) };
1251                        assert_eq!(
1252                            s.hour_bounds(),
1253                            want,
1254                            "hour_bounds drifted for {hour}:{minute} pm={is_pm} 24h={is_24h}",
1255                        );
1256                    }
1257                }
1258            }
1259        }
1260    }
1261
1262    #[test]
1263    fn hour_bounds_are_ordered_and_fit_in_a_u32() {
1264        // The bounds are `i64` only so the clamp arithmetic cannot overflow; the
1265        // cast back to `u32` afterwards is unchecked, so both ends must be
1266        // non-negative and small.
1267        for is_24h in [false, true] {
1268            let s = TimePickerState {
1269                is_24h,
1270                ..TimePickerState::default()
1271            };
1272            let (lo, hi) = s.hour_bounds();
1273            assert!(lo <= hi, "hour_bounds({is_24h}) = ({lo}, {hi}) is inverted");
1274            assert!(lo >= 0, "a negative low bound would cast to a huge u32");
1275            assert!(hi <= i64::from(u32::MAX), "the high bound does not fit a u32");
1276            assert!(hi < 24, "the high bound is not an hour of the day");
1277        }
1278    }
1279
1280    #[test]
1281    fn hour_bounds_agree_with_what_the_constructors_actually_clamp_to() {
1282        // The band and the clamp are written twice (create / set_24h); a
1283        // divergence would leave a widget displaying an out-of-band hour.
1284        let (lo24, hi24) = TimePickerState {
1285            is_24h: true,
1286            ..TimePickerState::default()
1287        }
1288        .hour_bounds();
1289        assert_eq!(u32::try_from(lo24).unwrap(), TimePicker::create(0, 0).state.inner.hour);
1290        assert_eq!(
1291            u32::try_from(hi24).unwrap(),
1292            TimePicker::create(u32::MAX, 0).state.inner.hour,
1293        );
1294
1295        let (lo12, hi12) = TimePickerState {
1296            is_24h: false,
1297            ..TimePickerState::default()
1298        }
1299        .hour_bounds();
1300        assert_eq!(
1301            u32::try_from(lo12).unwrap(),
1302            TimePicker::create(0, 0).with_24h(false).state.inner.hour,
1303        );
1304        assert_eq!(
1305            u32::try_from(hi12).unwrap(),
1306            TimePicker::create(u32::MAX, 0).with_24h(false).state.inner.hour,
1307        );
1308    }
1309
1310    // ==================================================================
1311    // TimePicker::create
1312    // ==================================================================
1313
1314    #[test]
1315    fn create_clamps_rather_than_wraps_at_every_extreme() {
1316        // Saturation, not `% 24` / `% 60`: u32::MAX must land on the top of the
1317        // band, not on `u32::MAX % 24 == 3`.
1318        for (hour, minute, want_h, want_m) in [
1319            (0u32, 0u32, 0u32, 0u32),
1320            (23, 59, 23, 59),
1321            (24, 60, 23, 59),
1322            (25, 61, 23, 59),
1323            (99, 99, 23, 59),
1324            (u32::MAX - 1, u32::MAX - 1, 23, 59),
1325            (u32::MAX, u32::MAX, 23, 59),
1326        ] {
1327            let p = TimePicker::create(hour, minute);
1328            assert_eq!(
1329                (p.state.inner.hour, p.state.inner.minute),
1330                (want_h, want_m),
1331                "create({hour}, {minute}) was not clamped into range",
1332            );
1333        }
1334        assert_ne!(
1335            TimePicker::create(u32::MAX, u32::MAX).state.inner.hour,
1336            u32::MAX % 24,
1337            "create wrapped the hour instead of clamping it",
1338        );
1339        assert_ne!(
1340            TimePicker::create(u32::MAX, u32::MAX).state.inner.minute,
1341            u32::MAX % 60,
1342            "create wrapped the minute instead of clamping it",
1343        );
1344    }
1345
1346    #[test]
1347    fn create_lands_inside_its_own_bounds_for_every_input_it_is_given() {
1348        for hour in [0u32, 1, 12, 23, 24, 1000, u32::MAX / 3, u32::MAX] {
1349            for minute in [0u32, 1, 30, 59, 60, 12345, u32::MAX] {
1350                let p = TimePicker::create(hour, minute);
1351                let (lo, hi) = p.state.inner.hour_bounds();
1352                let h = i64::from(p.state.inner.hour);
1353                assert!(
1354                    (lo..=hi).contains(&h),
1355                    "create({hour}, {minute}) left hour {h} outside {lo}..={hi}",
1356                );
1357                assert!(
1358                    p.state.inner.minute <= 59,
1359                    "create({hour}, {minute}) left minute {} outside 0..=59",
1360                    p.state.inner.minute,
1361                );
1362            }
1363        }
1364    }
1365
1366    #[test]
1367    fn create_passes_every_in_range_value_through_untouched() {
1368        for hour in 0..24u32 {
1369            for minute in [0u32, 1, 7, 30, 58, 59] {
1370                let p = TimePicker::create(hour, minute);
1371                assert_eq!(
1372                    (p.state.inner.hour, p.state.inner.minute),
1373                    (hour, minute),
1374                    "create mangled the in-range value {hour}:{minute}",
1375                );
1376            }
1377        }
1378    }
1379
1380    #[test]
1381    fn create_always_starts_in_24h_am_without_a_callback() {
1382        for (hour, minute) in [(0u32, 0u32), (13, 45), (u32::MAX, u32::MAX)] {
1383            let p = TimePicker::create(hour, minute);
1384            assert!(p.state.inner.is_24h, "create({hour}, {minute}) did not start in 24-hour mode");
1385            assert!(!p.state.inner.is_pm, "create({hour}, {minute}) started in PM");
1386            assert!(
1387                p.state.on_change.as_ref().is_none(),
1388                "create({hour}, {minute}) installed a callback nobody asked for",
1389            );
1390        }
1391    }
1392
1393    #[test]
1394    fn create_zero_is_exactly_the_default_picker() {
1395        assert_eq!(TimePicker::create(0, 0), TimePicker::default());
1396        assert_eq!(TimePicker::create(0, 0).state.inner, TimePickerState::default());
1397        assert_eq!(TimePicker::default().state, TimePickerStateWrapper::default());
1398    }
1399
1400    #[test]
1401    fn create_uses_the_shared_const_container_style() {
1402        // A per-instance style vec would allocate on every rebuild; the widget is
1403        // deliberately built from a `'static` slice.
1404        let p = TimePicker::create(9, 15);
1405        assert_eq!(
1406            properties(&p.container_style),
1407            CONTAINER_STYLE
1408                .iter()
1409                .map(|c| c.property.clone())
1410                .collect::<Vec<_>>(),
1411            "the container style is not the shared const declaration",
1412        );
1413    }
1414
1415    #[test]
1416    fn create_is_deterministic() {
1417        for (h, m) in [(0u32, 0u32), (7, 8), (u32::MAX, u32::MAX)] {
1418            assert_eq!(TimePicker::create(h, m), TimePicker::create(h, m));
1419        }
1420    }
1421
1422    // ==================================================================
1423    // TimePicker::set_24h / with_24h
1424    // ==================================================================
1425
1426    #[test]
1427    fn set_24h_re_clamps_the_hour_into_the_new_band() {
1428        for (hour, want) in [(0u32, 1u32), (1, 1), (11, 11), (12, 12), (13, 12), (23, 12)] {
1429            let mut p = TimePicker::create(hour, 0);
1430            p.set_24h(false);
1431            assert_eq!(
1432                p.state.inner.hour, want,
1433                "switching {hour}:00 to 12-hour mode did not clamp to {want}",
1434            );
1435        }
1436    }
1437
1438    #[test]
1439    fn set_24h_clamps_afternoon_hours_instead_of_converting_them() {
1440        // FINDING (pinned as documented behaviour): the doc says "re-clamping",
1441        // and that is exactly what happens — 13:00..23:00 all collapse onto 12,
1442        // and since `is_pm` is left alone the widget then reads back as 12 AM
1443        // (canonical hour 0), i.e. thirteen hours earlier. A host that toggles a
1444        // 24-hour picker into 12-hour mode must convert the hour itself.
1445        let mut p = TimePicker::create(13, 30);
1446        assert_eq!(p.state.inner.canonical_hour(), 13);
1447        p.set_24h(false);
1448        assert_eq!(p.state.inner.hour, 12, "13:00 was not clamped to 12");
1449        assert!(!p.state.inner.is_pm, "set_24h invented a PM flag");
1450        assert_eq!(
1451            p.state.inner.canonical_hour(),
1452            0,
1453            "the clamp is expected to read back as midnight, not as 13:00",
1454        );
1455    }
1456
1457    #[test]
1458    fn set_24h_is_idempotent() {
1459        for start in [0u32, 5, 13, 23] {
1460            for target in [false, true] {
1461                let mut once = TimePicker::create(start, 30);
1462                once.set_24h(target);
1463                let mut twice = TimePicker::create(start, 30);
1464                twice.set_24h(target);
1465                twice.set_24h(target);
1466                assert_eq!(
1467                    once.state.inner, twice.state.inner,
1468                    "set_24h({target}) is not idempotent from {start}:30",
1469                );
1470            }
1471        }
1472    }
1473
1474    #[test]
1475    fn set_24h_round_trip_is_the_identity_only_inside_the_narrow_band() {
1476        // 1..=12 survive a 24h -> 12h -> 24h round trip; 0 and 13..=23 do not,
1477        // because the intermediate 12-hour band cannot represent them.
1478        for hour in 0..24u32 {
1479            let mut p = TimePicker::create(hour, 0);
1480            p.set_24h(false);
1481            p.set_24h(true);
1482            if (1..=12).contains(&hour) {
1483                assert_eq!(p.state.inner.hour, hour, "the round trip lost hour {hour}");
1484            } else {
1485                let want = if hour == 0 { 1 } else { 12 };
1486                assert_eq!(
1487                    p.state.inner.hour, want,
1488                    "hour {hour} did not collapse onto {want} as the clamp dictates",
1489                );
1490            }
1491        }
1492    }
1493
1494    #[test]
1495    fn set_24h_never_touches_the_minute_the_pm_flag_or_the_style() {
1496        for target in [false, true] {
1497            let before = TimePicker::create(9, 41).with_pm(true);
1498            let mut after = before.clone();
1499            after.set_24h(target);
1500            assert_eq!(after.state.inner.minute, before.state.inner.minute, "the minute moved");
1501            assert_eq!(after.state.inner.is_pm, before.state.inner.is_pm, "the PM flag moved");
1502            assert_eq!(
1503                properties(&after.container_style),
1504                properties(&before.container_style),
1505                "switching modes restyled the container",
1506            );
1507        }
1508    }
1509
1510    #[test]
1511    fn set_24h_leaves_a_hand_written_out_of_range_hour_inside_the_band() {
1512        // `hour` is public, so the widget must survive a host writing garbage into
1513        // it — the `i64` clamp is what keeps the `as u32` cast from wrapping.
1514        for hour in [0u32, 24, 100, u32::MAX / 2, u32::MAX - 1, u32::MAX] {
1515            for target in [false, true] {
1516                let mut p = TimePicker::create(0, 0);
1517                p.state.inner.hour = hour;
1518                p.set_24h(target);
1519                let (lo, hi) = p.state.inner.hour_bounds();
1520                let h = i64::from(p.state.inner.hour);
1521                assert!(
1522                    (lo..=hi).contains(&h),
1523                    "set_24h({target}) left hand-written hour {hour} at {h}, outside {lo}..={hi}",
1524                );
1525            }
1526        }
1527    }
1528
1529    #[test]
1530    fn with_24h_is_exactly_set_24h_in_builder_form() {
1531        for target in [false, true] {
1532            for hour in [0u32, 6, 13, 23] {
1533                let built = TimePicker::create(hour, 12).with_24h(target);
1534                let mut set = TimePicker::create(hour, 12);
1535                set.set_24h(target);
1536                assert_eq!(built, set, "with_24h({target}) diverged from set_24h at {hour}:12");
1537            }
1538        }
1539    }
1540
1541    #[test]
1542    fn with_24h_keeps_the_post_construction_invariants() {
1543        for target in [false, true] {
1544            let p = TimePicker::create(u32::MAX, u32::MAX).with_24h(target);
1545            assert_eq!(p.state.inner.is_24h, target, "the mode flag was not stored");
1546            let (lo, hi) = p.state.inner.hour_bounds();
1547            assert!((lo..=hi).contains(&i64::from(p.state.inner.hour)));
1548            assert!(p.state.inner.minute <= 59);
1549        }
1550    }
1551
1552    // ==================================================================
1553    // TimePicker::set_pm / with_pm
1554    // ==================================================================
1555
1556    #[test]
1557    fn set_pm_moves_nothing_but_the_flag() {
1558        for target in [false, true] {
1559            for is_24h in [false, true] {
1560                let before = TimePicker::create(11, 22).with_24h(is_24h);
1561                let mut after = before.clone();
1562                after.set_pm(target);
1563                assert_eq!(after.state.inner.is_pm, target, "the PM flag was not stored");
1564                assert_eq!(after.state.inner.hour, before.state.inner.hour, "the hour moved");
1565                assert_eq!(after.state.inner.minute, before.state.inner.minute, "the minute moved");
1566                assert_eq!(after.state.inner.is_24h, before.state.inner.is_24h, "the mode moved");
1567            }
1568        }
1569    }
1570
1571    #[test]
1572    fn set_pm_is_idempotent_and_two_flips_are_the_identity() {
1573        let mut p = TimePicker::create(4, 4).with_24h(false);
1574        p.set_pm(true);
1575        let once = p.state.inner;
1576        p.set_pm(true);
1577        assert_eq!(p.state.inner, once, "set_pm(true) is not idempotent");
1578        p.set_pm(false);
1579        p.set_pm(true);
1580        assert_eq!(p.state.inner, once, "two flips did not return to the same state");
1581    }
1582
1583    #[test]
1584    fn set_pm_does_not_re_clamp_an_out_of_band_hour() {
1585        // `set_pm` is a `const fn` field write by design; it must not silently
1586        // start doing the clamping that only `set_24h` / `create` do.
1587        let mut p = TimePicker::create(0, 0);
1588        p.state.inner.hour = u32::MAX;
1589        p.set_pm(true);
1590        assert_eq!(p.state.inner.hour, u32::MAX, "set_pm re-clamped the hour");
1591    }
1592
1593    #[test]
1594    fn with_pm_is_exactly_set_pm_in_builder_form() {
1595        for target in [false, true] {
1596            let built = TimePicker::create(3, 33).with_pm(target);
1597            let mut set = TimePicker::create(3, 33);
1598            set.set_pm(target);
1599            assert_eq!(built, set, "with_pm({target}) diverged from set_pm");
1600        }
1601    }
1602
1603    #[test]
1604    fn pm_in_24h_mode_is_inert_for_the_canonical_reading() {
1605        // The docs call the flag "only meaningful in 12-hour mode"; a 24-hour
1606        // widget must therefore read the same with it set or clear.
1607        let am = TimePicker::create(15, 0);
1608        let pm = TimePicker::create(15, 0).with_pm(true);
1609        assert_eq!(
1610            am.state.inner.canonical_hour(),
1611            pm.state.inner.canonical_hour(),
1612            "the PM flag changed a 24-hour reading",
1613        );
1614        assert_eq!(am.state.inner.hour, pm.state.inner.hour);
1615    }
1616
1617    // ==================================================================
1618    // TimePicker::set_on_change / with_on_change
1619    // ==================================================================
1620
1621    #[test]
1622    fn set_on_change_stores_the_function_pointer_and_the_payload_verbatim() {
1623        let mut p = TimePicker::create(1, 1);
1624        p.set_on_change(
1625            RefAny::new(0xDEAD_BEEF_u32),
1626            change_do_nothing as TimePickerOnChangeCallbackType,
1627        );
1628
1629        let c = p
1630            .state
1631            .on_change
1632            .as_ref()
1633            .expect("set_on_change did not store anything");
1634        assert_eq!(
1635            c.callback.cb as *const () as usize,
1636            change_do_nothing as *const () as usize,
1637            "the stored function pointer is not the one that was handed in",
1638        );
1639        assert!(
1640            matches!(c.callback.ctx, OptionRefAny::None),
1641            "a native Rust callback must not carry an FFI context",
1642        );
1643        let mut payload = c.refany.clone();
1644        assert_eq!(
1645            *payload.downcast_ref::<u32>().expect("the payload changed type"),
1646            0xDEAD_BEEF,
1647        );
1648    }
1649
1650    #[test]
1651    fn set_on_change_replaces_rather_than_accumulates() {
1652        let mut p = TimePicker::create(1, 1);
1653        p.set_on_change(RefAny::new(1u8), change_do_nothing as TimePickerOnChangeCallbackType);
1654        p.set_on_change(RefAny::new(2u8), change_refresh_all as TimePickerOnChangeCallbackType);
1655
1656        let c = p.state.on_change.as_ref().expect("the callback vanished");
1657        assert_eq!(
1658            c.callback.cb as *const () as usize,
1659            change_refresh_all as *const () as usize,
1660            "the second set_on_change did not win",
1661        );
1662        let mut payload = c.refany.clone();
1663        assert_eq!(*payload.downcast_ref::<u8>().expect("wrong payload type"), 2);
1664    }
1665
1666    #[test]
1667    fn set_on_change_does_not_disturb_the_time_or_the_container_style() {
1668        let before = TimePicker::create(23, 59);
1669        let mut after = TimePicker::create(23, 59);
1670        after.set_on_change(RefAny::new(0u8), change_do_nothing as TimePickerOnChangeCallbackType);
1671
1672        assert_eq!(after.state.inner, before.state.inner, "installing a callback moved the time");
1673        assert_eq!(
1674            properties(&after.container_style),
1675            properties(&before.container_style),
1676            "installing a callback restyled the container",
1677        );
1678    }
1679
1680    #[test]
1681    fn with_on_change_is_exactly_set_on_change_in_builder_form() {
1682        let built = TimePicker::create(5, 9)
1683            .with_on_change(RefAny::new(7u32), change_do_nothing as TimePickerOnChangeCallbackType);
1684        let mut set = TimePicker::create(5, 9);
1685        set.set_on_change(RefAny::new(7u32), change_do_nothing as TimePickerOnChangeCallbackType);
1686
1687        assert_eq!(built.state.inner, set.state.inner);
1688        let a = built.state.on_change.as_ref().expect("builder dropped the callback");
1689        let b = set.state.on_change.as_ref().expect("setter dropped the callback");
1690        assert_eq!(a.callback.cb as *const () as usize, b.callback.cb as *const () as usize);
1691
1692        let (mut pa, mut pb) = (a.refany.clone(), b.refany.clone());
1693        assert_eq!(
1694            *pa.downcast_ref::<u32>().expect("builder payload changed type"),
1695            *pb.downcast_ref::<u32>().expect("setter payload changed type"),
1696        );
1697    }
1698
1699    #[test]
1700    fn with_on_change_accepts_a_generic_callback_without_mangling_the_pointer() {
1701        // The `From<Callback>` arm *transmutes* a 2-arg fn pointer into the 3-arg
1702        // time-picker slot — this is the FFI (Python/C) path. The pointer must
1703        // come out bit-identical; a mangled one would be a wild jump on the first
1704        // arrow click.
1705        let generic = Callback {
1706            cb: generic_shaped,
1707            ctx: OptionRefAny::None,
1708        };
1709        let expected = generic_shaped as *const () as usize;
1710
1711        let p = TimePicker::create(1, 1).with_on_change(RefAny::new(0u8), generic);
1712        let c = p.state.on_change.as_ref().expect("the generic callback was dropped");
1713        assert_eq!(
1714            c.callback.cb as *const () as usize,
1715            expected,
1716            "the Callback -> TimePickerOnChangeCallback transmute mangled the pointer",
1717        );
1718    }
1719
1720    #[test]
1721    fn installing_a_callback_survives_every_other_builder_step() {
1722        // Order-independence: the builders are documented as composable, so a
1723        // callback must not be dropped by a later `with_24h` / `with_pm`.
1724        let p = TimePicker::create(9, 30)
1725            .with_on_change(RefAny::new(1u8), change_do_nothing as TimePickerOnChangeCallbackType)
1726            .with_24h(false)
1727            .with_pm(true);
1728        assert!(p.state.on_change.as_ref().is_some(), "a later builder dropped the callback");
1729        assert_eq!(p.state.inner.hour, 9);
1730        assert!(p.state.inner.is_pm);
1731        assert!(!p.state.inner.is_24h);
1732    }
1733
1734    // ==================================================================
1735    // TimePicker::swap_with_default
1736    // ==================================================================
1737
1738    #[test]
1739    fn swap_with_default_hands_out_the_original_and_leaves_a_default_behind() {
1740        let mut p = TimePicker::create(21, 45).with_24h(false).with_pm(true);
1741        let inner_before = p.state.inner;
1742        let taken = p.swap_with_default();
1743
1744        assert_eq!(taken.state.inner, inner_before, "the original state was not handed out");
1745        assert_eq!(p, TimePicker::default(), "the picker left behind is not a default one");
1746    }
1747
1748    #[test]
1749    fn swap_with_default_carries_the_callback_out_with_the_original() {
1750        // If the callback stayed behind on the *default* picker, the host would
1751        // keep getting change notifications from a widget it thought it had taken.
1752        let mut p = TimePicker::create(6, 6)
1753            .with_on_change(RefAny::new(3u8), change_do_nothing as TimePickerOnChangeCallbackType);
1754        let taken = p.swap_with_default();
1755
1756        assert!(
1757            taken.state.on_change.as_ref().is_some(),
1758            "the callback did not leave with the original",
1759        );
1760        assert!(
1761            p.state.on_change.as_ref().is_none(),
1762            "the callback stayed behind on the default",
1763        );
1764    }
1765
1766    #[test]
1767    fn swapping_a_default_repeatedly_is_idempotent() {
1768        let mut p = TimePicker::default();
1769        let first = p.swap_with_default();
1770        let second = p.swap_with_default();
1771        assert_eq!(first, TimePicker::default());
1772        assert_eq!(second, TimePicker::default());
1773        assert_eq!(p, TimePicker::default());
1774    }
1775
1776    #[test]
1777    fn swap_with_default_survives_a_hand_written_out_of_range_state() {
1778        let mut p = TimePicker::create(0, 0);
1779        p.state.inner.hour = u32::MAX;
1780        p.state.inner.minute = u32::MAX;
1781        let taken = p.swap_with_default();
1782
1783        assert_eq!(taken.state.inner.hour, u32::MAX, "swap normalised what it took out");
1784        assert_eq!(p.state.inner, TimePickerState::default(), "the replacement is not a default");
1785    }
1786
1787    // ==================================================================
1788    // TimePicker::dom
1789    // ==================================================================
1790
1791    #[test]
1792    fn dom_renders_three_columns_in_24h_mode_and_four_in_12h() {
1793        let h24 = TimePicker::create(9, 5).dom();
1794        assert!(matches!(h24.root.get_node_type(), NodeType::Div));
1795        assert_eq!(classes(&h24), vec![CLASS_CONTAINER.to_string()]);
1796        assert_eq!(h24.children.as_ref().len(), 3, "24-hour mode rendered an AM/PM toggle");
1797
1798        let h12 = TimePicker::create(9, 5).with_24h(false).dom();
1799        assert_eq!(h12.children.as_ref().len(), 4, "12-hour mode did not render an AM/PM toggle");
1800        assert_eq!(classes(&h12.children.as_ref()[3]), vec![CLASS_AMPM.to_string()]);
1801    }
1802
1803    #[test]
1804    fn dom_columns_carry_the_documented_classes_and_glyphs() {
1805        let dom = TimePicker::create(9, 5).dom();
1806        let (hour, sep, minute) = columns(&dom);
1807
1808        assert_eq!(classes(sep), vec![CLASS_SEPARATOR.to_string()]);
1809        assert_eq!(text_of(sep).as_deref(), Some(":"), "the separator is not a colon");
1810
1811        for (which, col) in [("hour", hour), ("minute", minute)] {
1812            assert_eq!(classes(col), vec![CLASS_SPINNER.to_string()], "{which}: wrong class");
1813            let cells = col.children.as_ref();
1814            assert_eq!(cells.len(), 3, "{which}: a spinner is up-arrow / value / down-arrow");
1815            assert_eq!(classes(&cells[0]), vec![CLASS_ARROW.to_string()]);
1816            assert_eq!(classes(&cells[1]), vec![CLASS_DISPLAY.to_string()]);
1817            assert_eq!(classes(&cells[2]), vec![CLASS_ARROW.to_string()]);
1818            assert_eq!(text_of(&cells[0]).as_deref(), Some(UP_GLYPH), "{which}: up arrow");
1819            assert_eq!(text_of(&cells[2]).as_deref(), Some(DOWN_GLYPH), "{which}: down arrow");
1820        }
1821    }
1822
1823    #[test]
1824    fn the_class_and_glyph_constants_are_the_ones_the_widget_declares() {
1825        // The strings above are hard-coded so a rename shows up as a test failure
1826        // rather than silently breaking every user stylesheet.
1827        let names = |c: &[IdOrClass]| -> Vec<String> {
1828            c.iter()
1829                .filter_map(|c| match c {
1830                    IdOrClass::Class(s) => Some(s.as_str().to_string()),
1831                    IdOrClass::Id(_) => None,
1832                })
1833                .collect()
1834        };
1835        assert_eq!(names(TIME_PICKER_CLASS), vec![CLASS_CONTAINER.to_string()]);
1836        assert_eq!(names(SPINNER_CLASS), vec![CLASS_SPINNER.to_string()]);
1837        assert_eq!(names(DISPLAY_CLASS), vec![CLASS_DISPLAY.to_string()]);
1838        assert_eq!(names(ARROW_CLASS), vec![CLASS_ARROW.to_string()]);
1839        assert_eq!(names(SEPARATOR_CLASS), vec![CLASS_SEPARATOR.to_string()]);
1840        assert_eq!(names(AMPM_CLASS), vec![CLASS_AMPM.to_string()]);
1841        assert_eq!(UP_ARROW.as_str(), UP_GLYPH);
1842        assert_eq!(DOWN_ARROW.as_str(), DOWN_GLYPH);
1843        assert_eq!(SEPARATOR_TEXT.as_str(), ":");
1844    }
1845
1846    #[test]
1847    fn dom_zero_pads_the_minute_but_not_the_hour() {
1848        // FINDING (pinned): the two displays disagree — `{}` for the hour and
1849        // `{:02}` for the minute — so 09:05 renders as "9:05". Deliberate or not,
1850        // the arrow handlers re-text with the same asymmetric formats, so at least
1851        // the widget is self-consistent.
1852        assert_eq!(
1853            displayed(&TimePicker::create(9, 5).dom()),
1854            ("9".to_string(), "05".to_string()),
1855        );
1856        assert_eq!(
1857            displayed(&TimePicker::create(0, 0).dom()),
1858            ("0".to_string(), "00".to_string()),
1859        );
1860        assert_eq!(
1861            displayed(&TimePicker::create(23, 59).dom()),
1862            ("23".to_string(), "59".to_string()),
1863        );
1864    }
1865
1866    #[test]
1867    fn dom_text_round_trips_the_whole_state_for_every_hour_and_minute() {
1868        // encode (state -> displayed text) == decode (parse the text back): the
1869        // rendered digits must be exactly the stored value, never a rounded or
1870        // re-derived one.
1871        for hour in 0..24u32 {
1872            for minute in [0u32, 1, 9, 10, 30, 58, 59] {
1873                let (h, m) = displayed(&TimePicker::create(hour, minute).dom());
1874                assert_eq!(
1875                    h.parse::<u32>().expect("the hour display is not a number"),
1876                    hour,
1877                    "the hour display drifted at {hour}:{minute}",
1878                );
1879                assert_eq!(
1880                    m.parse::<u32>().expect("the minute display is not a number"),
1881                    minute,
1882                    "the minute display drifted at {hour}:{minute}",
1883                );
1884                assert_eq!(m.chars().count(), 2, "the minute is not zero-padded at {hour}:{minute}");
1885            }
1886        }
1887    }
1888
1889    #[test]
1890    fn dom_renders_a_hand_written_out_of_range_state_without_panicking() {
1891        // `dom()` does not clamp — it formats whatever the state holds. The point
1892        // is that it stays total: no panic, no truncation, no wrap.
1893        let mut p = TimePicker::create(0, 0);
1894        p.state.inner.hour = u32::MAX;
1895        p.state.inner.minute = u32::MAX;
1896        let (h, m) = displayed(&p.dom());
1897        assert_eq!(h, u32::MAX.to_string(), "the hour display truncated a huge value");
1898        assert_eq!(m, u32::MAX.to_string(), "the minute display truncated a huge value");
1899    }
1900
1901    #[test]
1902    fn dom_ampm_label_tracks_the_pm_flag_and_only_exists_in_12h_mode() {
1903        assert_eq!(
1904            ampm_label(&TimePicker::create(6, 0).with_24h(false).dom()).as_deref(),
1905            Some("AM"),
1906        );
1907        assert_eq!(
1908            ampm_label(&TimePicker::create(6, 0).with_24h(false).with_pm(true).dom()).as_deref(),
1909            Some("PM"),
1910        );
1911        // The flag is still set here, but 24-hour mode must not render a toggle.
1912        assert_eq!(ampm_label(&TimePicker::create(6, 0).with_pm(true).dom()), None);
1913    }
1914
1915    #[test]
1916    fn dom_wires_each_of_the_five_handlers_exactly_once() {
1917        let styled = StyledDom::create_from_dom(TimePicker::create(8, 8).with_24h(false).dom());
1918        for (name, handler) in [
1919            ("on_hour_up", on_hour_up as usize),
1920            ("on_hour_down", on_hour_down as usize),
1921            ("on_minute_up", on_minute_up as usize),
1922            ("on_minute_down", on_minute_down as usize),
1923            ("on_ampm_toggle", on_ampm_toggle as usize),
1924        ] {
1925            let count = styled
1926                .node_data
1927                .as_ref()
1928                .iter()
1929                .flat_map(|nd| nd.callbacks.as_ref().iter())
1930                .filter(|cb| cb.callback.cb == handler)
1931                .count();
1932            assert_eq!(count, 1, "{name} is wired to {count} node(s), not exactly one");
1933        }
1934    }
1935
1936    #[test]
1937    fn dom_registers_every_handler_on_mouse_up_and_makes_the_cell_focusable() {
1938        let styled = StyledDom::create_from_dom(TimePicker::create(8, 8).with_24h(false).dom());
1939        let mut interactive = 0;
1940        for nd in styled.node_data.as_ref() {
1941            for cb in nd.callbacks.as_ref() {
1942                assert_eq!(
1943                    cb.event,
1944                    EventFilter::Hover(HoverEventFilter::MouseUp),
1945                    "a time-picker cell fires on something other than mouse-up",
1946                );
1947                assert!(
1948                    matches!(cb.callback.ctx, OptionRefAny::None),
1949                    "a native handler carries an FFI context",
1950                );
1951                interactive += 1;
1952            }
1953            if !nd.callbacks.as_ref().is_empty() {
1954                assert_eq!(
1955                    nd.flags.get_tab_index(),
1956                    Some(TabIndex::Auto),
1957                    "a clickable time-picker cell is not keyboard-focusable",
1958                );
1959            }
1960        }
1961        assert_eq!(interactive, 5, "12-hour mode must expose 4 arrows + 1 AM/PM toggle");
1962    }
1963
1964    #[test]
1965    fn dom_leaves_the_displays_and_the_separator_inert() {
1966        // Only the arrows and the toggle are clickable; a handler on a display
1967        // would fire on a click meant to select the text.
1968        let styled = StyledDom::create_from_dom(TimePicker::create(8, 8).with_24h(false).dom());
1969        for idx in [
1970            N_CONTAINER,
1971            N_HOUR_SPINNER,
1972            N_HOUR_DISPLAY,
1973            N_SEPARATOR,
1974            N_MINUTE_SPINNER,
1975            N_MINUTE_DISPLAY,
1976        ] {
1977            assert!(
1978                styled.node_data.as_ref()[idx].callbacks.as_ref().is_empty(),
1979                "flattened node {idx} registered a click handler it should not have",
1980            );
1981        }
1982    }
1983
1984    #[test]
1985    fn dom_shares_one_state_refany_across_every_handler() {
1986        // Four arrows and the toggle must all mutate the *same* state; a per-cell
1987        // copy would let the hour and the minute drift apart.
1988        let styled = StyledDom::create_from_dom(TimePicker::create(5, 5).with_24h(false).dom());
1989        let payloads = state_payloads(&styled);
1990        assert_eq!(payloads.len(), 5, "not every handler carries the widget state");
1991
1992        {
1993            let mut first = payloads[0].clone();
1994            let mut w = first
1995                .downcast_mut::<TimePickerStateWrapper>()
1996                .expect("the state changed type");
1997            w.inner.minute = 42;
1998        }
1999        for (i, p) in payloads.iter().enumerate() {
2000            assert_eq!(
2001                read_state(p).minute,
2002                42,
2003                "handler payload #{i} is a private copy of the state",
2004            );
2005        }
2006    }
2007
2008    #[test]
2009    fn dom_keeps_its_cached_child_count_in_sync_with_the_tree() {
2010        // `estimated_total_children` is a cache; if it under-reports, the flatten
2011        // under-allocates its arenas and panics on an out-of-bounds write.
2012        for is_24h in [false, true] {
2013            for (h, m) in [(0u32, 0u32), (23, 59), (u32::MAX, u32::MAX)] {
2014                let dom = TimePicker::create(h, m).with_24h(is_24h).dom();
2015                let expected_nodes = if is_24h { 10 } else { 11 };
2016                assert_eq!(
2017                    dom.estimated_total_children,
2018                    descendants(&dom),
2019                    "{h}:{m} 24h={is_24h}: the cached descendant count is wrong",
2020                );
2021                let styled = StyledDom::create_from_dom(dom);
2022                assert_eq!(
2023                    styled.node_data.as_ref().len(),
2024                    expected_nodes,
2025                    "{h}:{m} 24h={is_24h}: the widget did not flatten to {expected_nodes} nodes",
2026                );
2027            }
2028        }
2029    }
2030
2031    #[test]
2032    fn flattened_layout_is_the_fixed_ten_or_eleven_nodes() {
2033        // Pins the pre-order indices the click tests below index by name.
2034        let styled = StyledDom::create_from_dom(TimePicker::create(7, 8).with_24h(false).dom());
2035        for (idx, class, text) in [
2036            (N_CONTAINER, CLASS_CONTAINER, None),
2037            (N_HOUR_SPINNER, CLASS_SPINNER, None),
2038            (N_HOUR_UP, CLASS_ARROW, Some(UP_GLYPH)),
2039            (N_HOUR_DISPLAY, CLASS_DISPLAY, Some("7")),
2040            (N_HOUR_DOWN, CLASS_ARROW, Some(DOWN_GLYPH)),
2041            (N_SEPARATOR, CLASS_SEPARATOR, Some(":")),
2042            (N_MINUTE_SPINNER, CLASS_SPINNER, None),
2043            (N_MINUTE_UP, CLASS_ARROW, Some(UP_GLYPH)),
2044            (N_MINUTE_DISPLAY, CLASS_DISPLAY, Some("08")),
2045            (N_MINUTE_DOWN, CLASS_ARROW, Some(DOWN_GLYPH)),
2046            (N_AMPM, CLASS_AMPM, Some("AM")),
2047        ] {
2048            assert_eq!(
2049                flat_classes(&styled, idx),
2050                vec![class.to_string()],
2051                "flattened node {idx} is not the {class} node",
2052            );
2053            assert_eq!(
2054                flat_text(&styled, idx).as_deref(),
2055                text,
2056                "flattened node {idx} renders the wrong text",
2057            );
2058        }
2059        assert_eq!(
2060            wired_to(&styled, on_hour_up as usize).0,
2061            node(N_HOUR_UP),
2062            "the hour up arrow is not where the indices say it is",
2063        );
2064        assert_eq!(wired_to(&styled, on_hour_down as usize).0, node(N_HOUR_DOWN));
2065        assert_eq!(wired_to(&styled, on_minute_up as usize).0, node(N_MINUTE_UP));
2066        assert_eq!(wired_to(&styled, on_minute_down as usize).0, node(N_MINUTE_DOWN));
2067        assert_eq!(wired_to(&styled, on_ampm_toggle as usize).0, node(N_AMPM));
2068    }
2069
2070    #[test]
2071    fn from_timepicker_for_dom_is_the_dom_method() {
2072        let via_from: Dom = TimePicker::create(4, 20).into();
2073        let via_dom = TimePicker::create(4, 20).dom();
2074        assert_eq!(classes(&via_from), classes(&via_dom));
2075        assert_eq!(displayed(&via_from), displayed(&via_dom));
2076        assert_eq!(via_from.children.as_ref().len(), via_dom.children.as_ref().len());
2077        assert_eq!(via_from.estimated_total_children, via_dom.estimated_total_children);
2078    }
2079
2080    // ==================================================================
2081    // build_spinner
2082    // ==================================================================
2083
2084    #[test]
2085    fn build_spinner_stores_the_handler_pointers_verbatim_at_the_usize_extremes() {
2086        // The handlers are erased to `usize` before they reach the DOM, so nothing
2087        // type-checks them any more. Every value — including 0 and usize::MAX,
2088        // which are not valid code addresses — must come back out unchanged
2089        // rather than being validated, folded or truncated.
2090        for (up, down) in [
2091            (0usize, 0usize),
2092            (0, usize::MAX),
2093            (usize::MAX, 0),
2094            (usize::MAX, usize::MAX),
2095            (1, 2),
2096            (usize::MAX / 2, usize::MAX - 1),
2097        ] {
2098            let dom = build_spinner(AzString::from_const_str("0"), RefAny::new(0u8), up, down);
2099            let cells = dom.children.as_ref();
2100            assert_eq!(cells.len(), 3);
2101            assert_eq!(
2102                cells[0].root.callbacks.as_ref()[0].callback.cb, up,
2103                "the up handler {up} was mangled",
2104            );
2105            assert_eq!(
2106                cells[2].root.callbacks.as_ref()[0].callback.cb, down,
2107                "the down handler {down} was mangled",
2108            );
2109        }
2110    }
2111
2112    #[test]
2113    fn build_spinner_puts_the_value_between_the_two_arrows() {
2114        let dom = build_spinner(AzString::from_const_str("42"), RefAny::new(0u8), 1, 2);
2115        assert_eq!(classes(&dom), vec![CLASS_SPINNER.to_string()]);
2116        let cells = dom.children.as_ref();
2117        assert_eq!(text_of(&cells[0]).as_deref(), Some(UP_GLYPH));
2118        assert_eq!(text_of(&cells[1]).as_deref(), Some("42"));
2119        assert_eq!(text_of(&cells[2]).as_deref(), Some(DOWN_GLYPH));
2120        assert!(
2121            cells[1].root.callbacks.as_ref().is_empty(),
2122            "the value display must not be clickable",
2123        );
2124    }
2125
2126    #[test]
2127    fn build_spinner_preserves_every_kind_of_value_string_byte_for_byte() {
2128        // The value is host-supplied text in the general case, so it must survive
2129        // empty, NUL-bearing, astral-plane, combining, RTL and very long inputs
2130        // without being trimmed, escaped or re-encoded.
2131        let long = "9".repeat(4096);
2132        let values: Vec<String> = vec![
2133            String::new(),
2134            " ".to_string(),
2135            "\0".to_string(),
2136            "\n\t".to_string(),
2137            "٣٠".to_string(),                 // arabic-indic digits
2138            "\u{1F55B}".to_string(),          // 🕛
2139            "e\u{0301}".to_string(),          // combining acute
2140            "\u{202E}12".to_string(),         // RTL override
2141            "-1".to_string(),
2142            "٩٩:٩٩".to_string(),
2143            long,
2144        ];
2145        for v in values {
2146            let dom = build_spinner(AzString::from(v.clone()), RefAny::new(0u8), 1, 2);
2147            let shown = text_of(&dom.children.as_ref()[1]);
2148            assert_eq!(
2149                shown.as_deref(),
2150                Some(v.as_str()),
2151                "the spinner value was not preserved verbatim ({} bytes)",
2152                v.len(),
2153            );
2154        }
2155    }
2156
2157    #[test]
2158    fn build_spinner_shares_the_state_between_both_arrows() {
2159        let state = RefAny::new(TimePickerStateWrapper::default());
2160        let dom = build_spinner(AzString::from_const_str("0"), state.clone(), 1, 2);
2161        let cells = dom.children.as_ref();
2162
2163        {
2164            let mut up_payload = cells[0].root.callbacks.as_ref()[0].refany.clone();
2165            let mut w = up_payload
2166                .downcast_mut::<TimePickerStateWrapper>()
2167                .expect("the up arrow does not carry the state");
2168            w.inner.hour = 17;
2169        }
2170        let mut down_payload = cells[2].root.callbacks.as_ref()[0].refany.clone();
2171        let seen = down_payload
2172            .downcast_ref::<TimePickerStateWrapper>()
2173            .expect("the down arrow does not carry the state")
2174            .inner
2175            .hour;
2176        assert_eq!(seen, 17, "the two arrows of one spinner hold separate states");
2177        assert_eq!(read_state(&state).hour, 17, "the caller's own handle was not shared");
2178    }
2179
2180    #[test]
2181    fn build_spinner_makes_both_arrows_focusable_click_targets() {
2182        let dom = build_spinner(AzString::from_const_str("0"), RefAny::new(0u8), 1, 2);
2183        for (which, cell) in [("up", 0usize), ("down", 2usize)] {
2184            let cell = &dom.children.as_ref()[cell];
2185            let cbs = cell.root.callbacks.as_ref();
2186            assert_eq!(cbs.len(), 1, "{which}: an arrow registers exactly one handler");
2187            assert_eq!(cbs[0].event, EventFilter::Hover(HoverEventFilter::MouseUp));
2188            assert_eq!(
2189                cell.root.flags.get_tab_index(),
2190                Some(TabIndex::Auto),
2191                "{which}: the arrow is not keyboard-focusable",
2192            );
2193            assert_eq!(classes(cell), vec![CLASS_ARROW.to_string()]);
2194        }
2195    }
2196
2197    #[test]
2198    fn build_spinner_reports_its_three_children() {
2199        for value in ["", "0", "999999"] {
2200            let dom = build_spinner(AzString::from(value.to_string()), RefAny::new(0u8), 1, 2);
2201            assert_eq!(dom.estimated_total_children, descendants(&dom));
2202            assert_eq!(dom.estimated_total_children, 3);
2203        }
2204    }
2205
2206    // ==================================================================
2207    // adjust_spinner + the four arrow handlers
2208    // ==================================================================
2209
2210    #[test]
2211    fn hour_arrows_move_the_hour_by_one_and_retext_the_hour_display() {
2212        let (styled, shared) = laid_out(TimePicker::create(9, 30));
2213        let (hit, payload) = wired_to(&styled, on_hour_up as usize);
2214
2215        let (update, changes) = press(styled, &payload, hit, on_hour_up);
2216
2217        assert_eq!(read_state(&shared).hour, 10, "the up arrow did not increment the hour");
2218        assert_eq!(read_state(&shared).minute, 30, "the hour arrow moved the minute");
2219        assert_eq!(update, Update::DoNothing, "no callback is installed, so nothing to report");
2220        assert_eq!(
2221            only_retext(&changes),
2222            (node(N_HOUR_DISPLAY), "10".to_string()),
2223            "the up arrow retexted the wrong node (or with the wrong text)",
2224        );
2225    }
2226
2227    #[test]
2228    fn minute_arrows_move_the_minute_and_retext_it_zero_padded() {
2229        let (styled, shared) = laid_out(TimePicker::create(9, 30));
2230        let (hit, payload) = wired_to(&styled, on_minute_down as usize);
2231
2232        let (_, changes) = press(styled, &payload, hit, on_minute_down);
2233
2234        assert_eq!(read_state(&shared).minute, 29, "the down arrow did not decrement the minute");
2235        assert_eq!(read_state(&shared).hour, 9, "the minute arrow moved the hour");
2236        assert_eq!(only_retext(&changes), (node(N_MINUTE_DISPLAY), "29".to_string()));
2237
2238        // ... and single digits keep the two-digit form the initial render used.
2239        let (styled, _) = laid_out(TimePicker::create(9, 10));
2240        let (hit, payload) = wired_to(&styled, on_minute_down as usize);
2241        let (_, changes) = press(styled, &payload, hit, on_minute_down);
2242        assert_eq!(
2243            only_retext(&changes).1,
2244            "09",
2245            "the retext dropped the zero padding the first render used",
2246        );
2247    }
2248
2249    #[test]
2250    fn the_hour_clamps_at_both_ends_of_the_24_hour_band() {
2251        for (start, handler, want) in [
2252            (23u32, on_hour_up as extern "C" fn(RefAny, CallbackInfo) -> Update, 23u32),
2253            (0, on_hour_down as extern "C" fn(RefAny, CallbackInfo) -> Update, 0),
2254        ] {
2255            let (styled, shared) = laid_out(TimePicker::create(start, 0));
2256            let handler_addr = handler as usize;
2257            let (hit, payload) = wired_to(&styled, handler_addr);
2258
2259            let (_, changes) = press(styled, &payload, hit, handler);
2260
2261            assert_eq!(read_state(&shared).hour, want, "the hour escaped the band from {start}");
2262            assert_eq!(
2263                only_retext(&changes).1,
2264                want.to_string(),
2265                "the clamped hour was retexted with something else",
2266            );
2267        }
2268    }
2269
2270    #[test]
2271    fn the_hour_clamps_to_the_narrow_band_in_12_hour_mode() {
2272        for (start, handler, want) in [
2273            (12u32, on_hour_up as extern "C" fn(RefAny, CallbackInfo) -> Update, 12u32),
2274            (1, on_hour_down as extern "C" fn(RefAny, CallbackInfo) -> Update, 1),
2275            (11, on_hour_up as extern "C" fn(RefAny, CallbackInfo) -> Update, 12),
2276            (2, on_hour_down as extern "C" fn(RefAny, CallbackInfo) -> Update, 1),
2277        ] {
2278            let (styled, shared) = laid_out(TimePicker::create(start, 0).with_24h(false));
2279            let (hit, payload) = wired_to(&styled, handler as usize);
2280
2281            let (_, _) = press(styled, &payload, hit, handler);
2282
2283            assert_eq!(
2284                read_state(&shared).hour,
2285                want,
2286                "12-hour mode: pressing from {start} did not land on {want}",
2287            );
2288        }
2289    }
2290
2291    #[test]
2292    fn the_minute_clamps_and_never_carries_into_the_hour() {
2293        // The module's documented PARTIAL: 59 + 1 stays 59 and 0 - 1 stays 0 —
2294        // the hour must not move either way.
2295        for (start, handler, want) in [
2296            (59u32, on_minute_up as extern "C" fn(RefAny, CallbackInfo) -> Update, 59u32),
2297            (0, on_minute_down as extern "C" fn(RefAny, CallbackInfo) -> Update, 0),
2298        ] {
2299            let (styled, shared) = laid_out(TimePicker::create(12, start));
2300            let (hit, payload) = wired_to(&styled, handler as usize);
2301
2302            let (_, changes) = press(styled, &payload, hit, handler);
2303
2304            assert_eq!(read_state(&shared).minute, want, "the minute escaped 0..=59 from {start}");
2305            assert_eq!(read_state(&shared).hour, 12, "the minute carried into the hour");
2306            assert_eq!(
2307                only_retext(&changes),
2308                (node(N_MINUTE_DISPLAY), format!("{want:02}")),
2309                "the clamped minute was retexted wrongly",
2310            );
2311        }
2312    }
2313
2314    #[test]
2315    fn holding_an_arrow_walks_the_whole_band_and_then_stops() {
2316        // 200 presses is far more than the band is wide: the value must saturate
2317        // at the edge rather than wrapping around or running away.
2318        let (styled, shared) = laid_out(TimePicker::create(0, 0));
2319        let (hit, payload) = wired_to(&styled, on_hour_up as usize);
2320        press_n(styled, &payload, hit, on_hour_up, 200);
2321        assert_eq!(read_state(&shared).hour, 23, "200 up-presses did not saturate at 23");
2322
2323        let (styled, shared) = laid_out(TimePicker::create(23, 59));
2324        let (hit, payload) = wired_to(&styled, on_minute_down as usize);
2325        press_n(styled, &payload, hit, on_minute_down, 200);
2326        assert_eq!(read_state(&shared).minute, 0, "200 down-presses did not saturate at 0");
2327        assert_eq!(read_state(&shared).hour, 23, "the saturating minute borrowed from the hour");
2328    }
2329
2330    #[test]
2331    fn every_step_of_a_full_sweep_is_reported_exactly_once() {
2332        // Walking 0 -> 23 must push 23 distinct retexts in order and land on 23;
2333        // a duplicated or skipped step would desynchronise the display from the
2334        // state, which is the whole failure mode `change_node_text` exists to avoid.
2335        let (styled, shared) = laid_out(TimePicker::create(0, 0));
2336        let (hit, payload) = wired_to(&styled, on_hour_up as usize);
2337        let (_, changes) = press_n(styled, &payload, hit, on_hour_up, 23);
2338
2339        let texts: Vec<String> = pushed_texts(&changes).into_iter().map(|(_, t)| t).collect();
2340        assert_eq!(
2341            texts,
2342            (1..=23u32).map(|h| h.to_string()).collect::<Vec<_>>(),
2343            "the sweep did not report every hour exactly once, in order",
2344        );
2345        assert_eq!(read_state(&shared).hour, 23);
2346    }
2347
2348    #[test]
2349    fn a_press_on_a_node_without_a_parent_changes_nothing_at_all() {
2350        // The container has no parent, so the display lookup fails. The state must
2351        // be left alone — the handler bails out *before* it touches the value.
2352        let (styled, shared) = laid_out(TimePicker::create(9, 30));
2353        let (_, payload) = wired_to(&styled, on_hour_up as usize);
2354
2355        let (update, changes) = press(styled, &payload, node(N_CONTAINER), on_hour_up);
2356
2357        assert_eq!(update, Update::DoNothing);
2358        assert!(changes.is_empty(), "a parentless hit still retexted something");
2359        assert_eq!(
2360            read_state(&shared),
2361            TimePicker::create(9, 30).state.inner,
2362            "a hit that could not be resolved mutated the state anyway",
2363        );
2364    }
2365
2366    #[test]
2367    fn a_press_on_a_detached_or_out_of_range_node_changes_nothing_at_all() {
2368        for (name, hit) in [
2369            ("no node at all", node_none()),
2370            ("one past the end", node(11)),
2371            ("far out of range", node(usize::MAX / 2)),
2372        ] {
2373            let (styled, shared) = laid_out(TimePicker::create(9, 30));
2374            let (_, payload) = wired_to(&styled, on_minute_up as usize);
2375
2376            let (update, changes) = press(styled, &payload, hit, on_minute_up);
2377
2378            assert_eq!(update, Update::DoNothing, "{name}: unexpected verdict");
2379            assert!(changes.is_empty(), "{name}: a bad hit still retexted something");
2380            assert_eq!(
2381                read_state(&shared).minute,
2382                30,
2383                "{name}: a bad hit mutated the state",
2384            );
2385        }
2386    }
2387
2388    #[test]
2389    fn the_display_lookup_is_positional_and_trusts_whatever_node_it_lands_on() {
2390        // The walk is `hit -> parent -> first child -> next sibling` with no check
2391        // that the result is a display cell. Delivered on the separator (whose
2392        // parent is the *container*), it resolves to the separator itself and
2393        // overwrites the ":" with the hour. Unreachable through `dom()` — only the
2394        // arrows are wired — but pinned so the lack of validation is visible, and
2395        // so a future rewiring of the separator shows up here.
2396        let (styled, shared) = laid_out(TimePicker::create(9, 30));
2397        let (_, payload) = wired_to(&styled, on_hour_up as usize);
2398
2399        let (update, changes) = press(styled, &payload, node(N_SEPARATOR), on_hour_up);
2400
2401        assert_eq!(update, Update::DoNothing);
2402        assert_eq!(read_state(&shared).hour, 10, "the edit itself did not happen");
2403        assert_eq!(read_state(&shared).minute, 30, "an hour press moved the minute");
2404        assert_eq!(
2405            only_retext(&changes),
2406            (node(N_SEPARATOR), "10".to_string()),
2407            "the positional lookup landed somewhere other than the separator",
2408        );
2409    }
2410
2411    #[test]
2412    fn a_press_with_a_foreign_payload_is_declined_without_touching_the_dom() {
2413        // The `RefAny` is type-erased: a host that wires the wrong payload onto an
2414        // arrow must get a clean no-op, not a wild reinterpretation of the bytes.
2415        for payload in [
2416            RefAny::new(0u8),
2417            RefAny::new(TimePickerState::default()),
2418            RefAny::new(String::from("not a time picker")),
2419        ] {
2420            let (styled, shared) = laid_out(TimePicker::create(9, 30));
2421            let (hit, _) = wired_to(&styled, on_hour_up as usize);
2422
2423            let (update, changes) = press(styled, &payload, hit, on_hour_up);
2424
2425            assert_eq!(update, Update::DoNothing, "a foreign payload was accepted");
2426            assert!(changes.is_empty(), "a foreign payload still retexted the display");
2427            assert_eq!(read_state(&shared).hour, 9, "a foreign payload moved the real state");
2428        }
2429    }
2430
2431    #[test]
2432    fn adjust_spinner_saturates_at_the_extreme_deltas_it_can_be_handed() {
2433        // `delta` is an `i64` added to `i64::from(hour)`. From hour 0 the whole
2434        // `i64` range is representable, so both extremes must land on the ends of
2435        // the band rather than wrapping.
2436        //
2437        // NOTE: `i64::from(hour) + delta` is a plain `+`. It cannot overflow for
2438        // any delta the widget itself passes (±1), but a delta near `i64::MAX`
2439        // combined with a non-zero hour would — see the report.
2440        for (delta, want_hour, want_minute) in [
2441            (i64::MAX, 23u32, 59u32),
2442            (i64::MIN, 0, 0),
2443            (i64::MAX / 2, 23, 59),
2444            (i64::MIN / 2, 0, 0),
2445            (1_000_000_000, 23, 59),
2446            (-1_000_000_000, 0, 0),
2447        ] {
2448            let (styled, _) = laid_out(TimePicker::create(0, 0));
2449            let (hit, _) = wired_to(&styled, on_hour_up as usize);
2450            let state = wrapper(TimePickerState::default());
2451
2452            let (_, _) = with_info(styled, hit, |info| {
2453                let _ = adjust_spinner(state.clone(), *info, true, delta);
2454                let _ = adjust_spinner(state.clone(), *info, false, delta);
2455            });
2456
2457            assert_eq!(
2458                (read_state(&state).hour, read_state(&state).minute),
2459                (want_hour, want_minute),
2460                "delta {delta} did not saturate",
2461            );
2462        }
2463    }
2464
2465    #[test]
2466    fn adjust_spinner_saturates_a_hand_written_extreme_hour_back_into_the_band() {
2467        // `hour = u32::MAX` plus `delta = i64::MIN` is the widest gap the clamp
2468        // has to close; `i64::from(u32::MAX) + i64::MIN` stays inside `i64`.
2469        for (start_hour, delta, want) in [
2470            (u32::MAX, i64::MIN, 0u32),
2471            (u32::MAX, -1, 23),
2472            (u32::MAX, 0, 23),
2473            (u32::MAX, 1, 23),
2474            (0, -1, 0),
2475        ] {
2476            let (styled, _) = laid_out(TimePicker::create(0, 0));
2477            let (hit, _) = wired_to(&styled, on_hour_up as usize);
2478            let state = wrapper(TimePickerState {
2479                hour: start_hour,
2480                minute: 0,
2481                is_pm: false,
2482                is_24h: true,
2483            });
2484
2485            let (_, changes) = with_info(styled, hit, |info| {
2486                adjust_spinner(state.clone(), *info, true, delta)
2487            });
2488
2489            assert_eq!(
2490                read_state(&state).hour,
2491                want,
2492                "hour {start_hour} with delta {delta} did not clamp to {want}",
2493            );
2494            assert_eq!(
2495                only_retext(&changes).1,
2496                want.to_string(),
2497                "the clamped value and the retext disagree",
2498            );
2499        }
2500    }
2501
2502    #[test]
2503    fn adjust_spinner_with_a_zero_delta_still_retexts_and_notifies() {
2504        // A no-op press is still a press: the display is re-synced with the state
2505        // and the host is told. (This is the same path a clamped press takes.)
2506        let probe = log_refany();
2507        let (styled, shared) = laid_out(
2508            TimePicker::create(6, 7)
2509                .with_on_change(probe.clone(), record_change as TimePickerOnChangeCallbackType),
2510        );
2511        let (hit, _) = wired_to(&styled, on_hour_up as usize);
2512
2513        let (update, changes) = with_info(styled, hit, |info| {
2514            adjust_spinner(shared.clone(), *info, false, 0)
2515        });
2516
2517        assert_eq!(read_state(&shared).minute, 7, "a zero delta moved the value");
2518        assert_eq!(only_retext(&changes).1, "07", "a zero delta skipped the re-sync");
2519        assert_eq!(update, Update::RefreshDom, "the host's verdict was swallowed");
2520        assert_eq!(read_log(&probe).seen.len(), 1, "a zero delta did not notify the host");
2521    }
2522
2523    #[test]
2524    fn adjust_spinner_retexts_the_display_of_the_spinner_that_was_clicked() {
2525        // Both arrows of a column must resolve to the *same* middle display, and
2526        // never to the other column's.
2527        for (handler, want_node) in [
2528            (on_hour_up as usize, N_HOUR_DISPLAY),
2529            (on_hour_down as usize, N_HOUR_DISPLAY),
2530            (on_minute_up as usize, N_MINUTE_DISPLAY),
2531            (on_minute_down as usize, N_MINUTE_DISPLAY),
2532        ] {
2533            let (styled, _) = laid_out(TimePicker::create(6, 30));
2534            let (hit, payload) = wired_to(&styled, handler);
2535            let is_hour = want_node == N_HOUR_DISPLAY;
2536
2537            let (_, changes) = with_info(styled, hit, |info| {
2538                adjust_spinner(payload.clone(), *info, is_hour, 1)
2539            });
2540
2541            assert_eq!(
2542                only_retext(&changes).0,
2543                node(want_node),
2544                "a press on node {hit:?} retexted the wrong display",
2545            );
2546        }
2547    }
2548
2549    // ==================================================================
2550    // on_change notification
2551    // ==================================================================
2552
2553    #[test]
2554    fn the_change_callback_sees_the_state_after_the_edit_and_its_verdict_is_forwarded() {
2555        // Order matters: the state is written *before* the user callback runs, so
2556        // the callback observes the value the user just asked for, not the stale
2557        // one it is replacing.
2558        let probe = log_refany();
2559        let (styled, shared) = laid_out(
2560            TimePicker::create(9, 30)
2561                .with_on_change(probe.clone(), record_change as TimePickerOnChangeCallbackType),
2562        );
2563        let (hit, payload) = wired_to(&styled, on_hour_up as usize);
2564
2565        let (update, changes) = press(styled, &payload, hit, on_hour_up);
2566
2567        let log = read_log(&probe);
2568        assert_eq!(
2569            log.seen,
2570            vec![TimePickerState {
2571                hour: 10,
2572                minute: 30,
2573                is_pm: false,
2574                is_24h: true,
2575            }],
2576            "the change callback was not called exactly once with the NEW state",
2577        );
2578        assert_eq!(
2579            log.payload, 0xDEAD_BEEF,
2580            "the callback was handed something other than the user's own RefAny",
2581        );
2582        assert_eq!(update, Update::RefreshDom, "the user callback's Update was swallowed");
2583        assert_eq!(read_state(&shared).hour, 10);
2584        assert_eq!(changes.len(), 1, "the retext was skipped because a callback ran");
2585    }
2586
2587    #[test]
2588    fn the_change_callback_still_fires_when_the_press_was_clamped_away() {
2589        // Pinned as-is: a press at the edge of the band reports an unchanged
2590        // state rather than staying silent. Hosts that treat every notification
2591        // as an edit will see repeats while an arrow is held down.
2592        let probe = log_refany();
2593        let (styled, _) = laid_out(
2594            TimePicker::create(23, 0)
2595                .with_on_change(probe.clone(), record_change as TimePickerOnChangeCallbackType),
2596        );
2597        let (hit, payload) = wired_to(&styled, on_hour_up as usize);
2598
2599        press_n(styled, &payload, hit, on_hour_up, 3);
2600
2601        let log = read_log(&probe);
2602        assert_eq!(log.seen.len(), 3, "a clamped press stopped notifying the host");
2603        assert!(
2604            log.seen.iter().all(|s| s.hour == 23),
2605            "a clamped press reported a value the widget does not hold",
2606        );
2607    }
2608
2609    #[test]
2610    fn a_declining_change_callback_does_not_suppress_the_retext() {
2611        let (styled, shared) = laid_out(
2612            TimePicker::create(9, 30)
2613                .with_on_change(RefAny::new(0u8), change_do_nothing as TimePickerOnChangeCallbackType),
2614        );
2615        let (hit, payload) = wired_to(&styled, on_minute_up as usize);
2616
2617        let (update, changes) = press(styled, &payload, hit, on_minute_up);
2618
2619        assert_eq!(update, Update::DoNothing);
2620        assert_eq!(read_state(&shared).minute, 31);
2621        assert_eq!(
2622            only_retext(&changes),
2623            (node(N_MINUTE_DISPLAY), "31".to_string()),
2624            "a DoNothing user callback suppressed the widget's own repaint",
2625        );
2626    }
2627
2628    #[test]
2629    fn every_verdict_the_change_callback_returns_is_forwarded_unchanged() {
2630        for (cb, want) in [
2631            (change_do_nothing as TimePickerOnChangeCallbackType, Update::DoNothing),
2632            (change_refresh_all as TimePickerOnChangeCallbackType, Update::RefreshDomAllWindows),
2633            (record_change as TimePickerOnChangeCallbackType, Update::RefreshDom),
2634        ] {
2635            let (styled, _) = laid_out(
2636                TimePicker::create(9, 30).with_on_change(log_refany(), cb),
2637            );
2638            let (hit, payload) = wired_to(&styled, on_hour_down as usize);
2639            let (update, _) = press(styled, &payload, hit, on_hour_down);
2640            assert_eq!(update, want, "a user verdict was rewritten on its way out");
2641        }
2642    }
2643
2644    /// A payload that tries to reach back into the widget's own state from
2645    /// inside the change callback — the re-entrancy the borrow guard must refuse.
2646    #[derive(Debug)]
2647    struct ReentrantProbe {
2648        state: RefAny,
2649        got_mut: Option<bool>,
2650        got_ref: Option<bool>,
2651    }
2652
2653    extern "C" fn probe_reentrancy(
2654        mut data: RefAny,
2655        _info: CallbackInfo,
2656        _state: TimePickerState,
2657    ) -> Update {
2658        if let Some(mut p) = data.downcast_mut::<ReentrantProbe>() {
2659            let mut s = p.state.clone();
2660            let got_mut = s.downcast_mut::<TimePickerStateWrapper>().is_some();
2661            let got_ref = s.downcast_ref::<TimePickerStateWrapper>().is_some();
2662            p.got_mut = Some(got_mut);
2663            p.got_ref = Some(got_ref);
2664        }
2665        Update::DoNothing
2666    }
2667
2668    #[test]
2669    fn a_reentrant_callback_is_refused_the_state_instead_of_aliasing_it() {
2670        // The handler holds an exclusive borrow of the state while the user
2671        // callback runs. A callback that clones the state handle and tries to
2672        // borrow it again must be told "no" (None), not handed a second `&mut`
2673        // to memory the handler is still writing through.
2674        let (styled, shared) = laid_out(TimePicker::create(9, 30));
2675        let (hit, payload) = wired_to(&styled, on_hour_up as usize);
2676
2677        let probe = RefAny::new(ReentrantProbe {
2678            state: shared.clone(),
2679            got_mut: None,
2680            got_ref: None,
2681        });
2682        {
2683            let mut s = shared.clone();
2684            let mut w = s
2685                .downcast_mut::<TimePickerStateWrapper>()
2686                .expect("the state changed type");
2687            w.on_change = Some(TimePickerOnChange {
2688                callback: TimePickerOnChangeCallback::from(
2689                    probe_reentrancy as TimePickerOnChangeCallbackType,
2690                ),
2691                refany: probe.clone(),
2692            })
2693            .into();
2694        }
2695
2696        let (update, _) = press(styled, &payload, hit, on_hour_up);
2697        assert_eq!(update, Update::DoNothing);
2698
2699        {
2700            let mut p = probe.clone();
2701            let seen = p
2702                .downcast_ref::<ReentrantProbe>()
2703                .expect("the probe changed type");
2704            assert_eq!(
2705                seen.got_mut,
2706                Some(false),
2707                "a re-entrant downcast_mut handed out a second &mut to the live state",
2708            );
2709            assert_eq!(
2710                seen.got_ref,
2711                Some(false),
2712                "a re-entrant downcast_ref aliased a live &mut borrow",
2713            );
2714        }
2715        assert_eq!(read_state(&shared).hour, 10, "the edit itself was lost");
2716
2717        // Break the deliberate state -> probe -> state cycle so nothing leaks.
2718        {
2719            let mut s = shared.clone();
2720            if let Some(mut w) = s.downcast_mut::<TimePickerStateWrapper>() {
2721                w.on_change = None.into();
2722            };
2723        }
2724    }
2725
2726    // ==================================================================
2727    // on_ampm_toggle
2728    // ==================================================================
2729
2730    #[test]
2731    fn the_ampm_toggle_flips_the_flag_and_retexts_the_node_that_was_hit() {
2732        let (styled, shared) = laid_out(TimePicker::create(9, 30).with_24h(false));
2733        let (hit, payload) = wired_to(&styled, on_ampm_toggle as usize);
2734        assert_eq!(hit, node(N_AMPM));
2735
2736        let (update, changes) = press(styled, &payload, hit, on_ampm_toggle);
2737
2738        assert!(read_state(&shared).is_pm, "the toggle did not flip AM -> PM");
2739        assert_eq!(update, Update::DoNothing);
2740        assert_eq!(
2741            only_retext(&changes),
2742            (node(N_AMPM), "PM".to_string()),
2743            "the toggle did not relabel itself",
2744        );
2745    }
2746
2747    #[test]
2748    fn the_ampm_toggle_is_an_involution() {
2749        let (styled, shared) = laid_out(TimePicker::create(9, 30).with_24h(false).with_pm(true));
2750        let (hit, payload) = wired_to(&styled, on_ampm_toggle as usize);
2751
2752        let (_, changes) = press_n(styled, &payload, hit, on_ampm_toggle, 4);
2753
2754        assert!(read_state(&shared).is_pm, "four flips did not return to PM");
2755        let labels: Vec<String> = pushed_texts(&changes).into_iter().map(|(_, t)| t).collect();
2756        assert_eq!(
2757            labels,
2758            vec!["AM", "PM", "AM", "PM"],
2759            "the labels did not alternate with the flag",
2760        );
2761    }
2762
2763    #[test]
2764    fn the_ampm_toggle_moves_the_canonical_hour_by_exactly_twelve() {
2765        for hour in 1..=12u32 {
2766            let (styled, shared) = laid_out(TimePicker::create(hour, 0).with_24h(false));
2767            let before = read_state(&shared).canonical_hour();
2768            let (hit, payload) = wired_to(&styled, on_ampm_toggle as usize);
2769
2770            press(styled, &payload, hit, on_ampm_toggle);
2771
2772            let after = read_state(&shared).canonical_hour();
2773            assert_eq!(
2774                after,
2775                before + 12,
2776                "toggling {hour} AM did not move the canonical hour by twelve",
2777            );
2778            assert!(after < 24, "the toggle pushed the canonical hour out of the day");
2779        }
2780    }
2781
2782    #[test]
2783    fn the_ampm_toggle_leaves_the_hour_and_the_minute_alone() {
2784        let (styled, shared) = laid_out(TimePicker::create(11, 45).with_24h(false));
2785        let (hit, payload) = wired_to(&styled, on_ampm_toggle as usize);
2786
2787        press(styled, &payload, hit, on_ampm_toggle);
2788
2789        let s = read_state(&shared);
2790        assert_eq!((s.hour, s.minute), (11, 45), "the toggle moved the time itself");
2791        assert!(!s.is_24h, "the toggle changed the display mode");
2792    }
2793
2794    #[test]
2795    fn the_ampm_toggle_notifies_the_host_with_the_new_flag() {
2796        let probe = log_refany();
2797        let (styled, _) = laid_out(
2798            TimePicker::create(3, 15)
2799                .with_24h(false)
2800                .with_on_change(probe.clone(), record_change as TimePickerOnChangeCallbackType),
2801        );
2802        let (hit, payload) = wired_to(&styled, on_ampm_toggle as usize);
2803
2804        let (update, _) = press(styled, &payload, hit, on_ampm_toggle);
2805
2806        assert_eq!(
2807            read_log(&probe).seen,
2808            vec![TimePickerState {
2809                hour: 3,
2810                minute: 15,
2811                is_pm: true,
2812                is_24h: false,
2813            }],
2814            "the host was not told the new AM/PM state",
2815        );
2816        assert_eq!(update, Update::RefreshDom, "the host's verdict was swallowed");
2817    }
2818
2819    #[test]
2820    fn the_ampm_toggle_is_declined_for_a_foreign_payload() {
2821        for payload in [RefAny::new(0u8), RefAny::new(TimePickerState::default())] {
2822            let (styled, shared) = laid_out(TimePicker::create(9, 30).with_24h(false));
2823            let (hit, _) = wired_to(&styled, on_ampm_toggle as usize);
2824
2825            let (update, changes) = press(styled, &payload, hit, on_ampm_toggle);
2826
2827            assert_eq!(update, Update::DoNothing, "a foreign payload was accepted");
2828            assert!(changes.is_empty(), "a foreign payload still relabelled the toggle");
2829            assert!(!read_state(&shared).is_pm, "a foreign payload flipped the real flag");
2830        }
2831    }
2832
2833    #[test]
2834    fn the_ampm_toggle_flips_the_flag_even_in_24_hour_mode() {
2835        // The toggle node is never rendered in 24-hour mode, but the handler is
2836        // still reachable (an FFI host can wire it anywhere). It flips the flag
2837        // unconditionally — harmless, because `canonical_hour` ignores the flag
2838        // in 24-hour mode.
2839        let (styled, shared) = laid_out(TimePicker::create(15, 0));
2840        let (hit, payload) = wired_to(&styled, on_hour_up as usize);
2841        let before = read_state(&shared).canonical_hour();
2842
2843        press(styled, &payload, hit, on_ampm_toggle);
2844
2845        assert!(read_state(&shared).is_pm, "the flag did not flip");
2846        assert_eq!(
2847            read_state(&shared).canonical_hour(),
2848            before,
2849            "a 24-hour widget's reading moved when the meaningless flag flipped",
2850        );
2851    }
2852
2853    #[test]
2854    fn the_ampm_toggle_retexts_whatever_node_it_was_told_was_hit() {
2855        // Unlike the spinner arrows, the toggle does not validate the hit node —
2856        // it relabels it directly. Pinned so the loose behaviour is visible: a
2857        // detached hit still produces a (harmless, unappliable) retext.
2858        let (styled, shared) = laid_out(TimePicker::create(9, 30).with_24h(false));
2859        let (_, payload) = wired_to(&styled, on_ampm_toggle as usize);
2860
2861        let (update, changes) = press(styled, &payload, node_none(), on_ampm_toggle);
2862
2863        assert_eq!(update, Update::DoNothing);
2864        assert!(read_state(&shared).is_pm, "the flag did not flip for a detached hit");
2865        assert_eq!(
2866            only_retext(&changes),
2867            (node_none(), "PM".to_string()),
2868            "the toggle did not retext the (detached) node it was handed",
2869        );
2870    }
2871
2872    #[test]
2873    fn the_rendered_ampm_label_matches_what_the_toggle_would_push() {
2874        // End-to-end consistency: re-rendering after a toggle must produce the
2875        // same label the toggle pushed, or the widget would flicker back on the
2876        // next relayout.
2877        for start_pm in [false, true] {
2878            let picker = TimePicker::create(9, 30).with_24h(false).with_pm(start_pm);
2879            let (styled, shared) = laid_out(picker);
2880            let (hit, payload) = wired_to(&styled, on_ampm_toggle as usize);
2881
2882            let (_, changes) = press(styled, &payload, hit, on_ampm_toggle);
2883            let pushed = only_retext(&changes).1;
2884
2885            let after = read_state(&shared);
2886            let rerendered = ampm_label(
2887                &TimePicker::create(after.hour, after.minute)
2888                    .with_24h(false)
2889                    .with_pm(after.is_pm)
2890                    .dom(),
2891            );
2892            assert_eq!(
2893                rerendered.as_deref(),
2894                Some(pushed.as_str()),
2895                "the pushed label and the re-rendered label disagree (start_pm={start_pm})",
2896            );
2897        }
2898    }
2899}