Skip to main content

azul_layout/widgets/
stepper.rs

1//! Stepper / wizard widget — a horizontal multi-step progress indicator: a row
2//! of numbered step circles with labels, joined by connector lines. Completed
3//! and current steps are highlighted in the accent colour; upcoming steps (and
4//! the connectors that lead to them) are muted.
5//!
6//! This is a blend of [`crate::widgets::segmented::Segmented`] (a horizontal row
7//! of clickable items whose clicked index is derived from sibling position and
8//! whose active item is live-restyled via `set_css_property`) and the filled-track
9//! look of [`crate::widgets::progressbar::ProgressBar`] (the accent connector).
10//!
11//! Steps are CLICKABLE (free navigation, like a segmented control): clicking
12//! step `i` sets `current_step = i`, invokes the optional `on_step_change(state)`,
13//! and live-restyles every circle / connector / label to reflect the new
14//! position — no DOM rebuild. (A non-clickable, display-only stepper is also a
15//! valid design; this widget chooses clickable to exercise the segmented restyle
16//! pattern, and `set_current_step` still drives it from app code on rebuild.)
17//!
18//! A circle is "reached" (accent) iff its index `<= current_step`; the connector
19//! gap between circle `i` and `i+1` is accent iff `i < current_step`. Clicking the
20//! already-current step is a no-op (no callback).
21//!
22//! Key types: [`Stepper`], [`StepperState`], [`StepperOnStepChange`].
23
24use std::vec::Vec;
25
26use azul_core::{
27    callbacks::{CoreCallbackData, Update},
28    dom::{Dom, IdOrClass, IdOrClass::Class, IdOrClassVec, TabIndex},
29    refany::RefAny,
30};
31use azul_css::dynamic_selector::{CssPropertyWithConditions, CssPropertyWithConditionsVec};
32use azul_css::{
33    props::{
34        basic::{color::ColorU, PixelValue, StyleFontSize},
35        layout::{LayoutDisplay, LayoutFlexDirection, LayoutAlignItems, LayoutFlexGrow, LayoutFlexBasis, LayoutWidth, LayoutJustifyContent, LayoutHeight, LayoutMinWidth, LayoutPaddingTop},
36        property::{CssProperty, LayoutFlexBasisValue, LayoutWidthValue},
37        style::{StyleBackgroundContent, StyleBackgroundContentVec, StyleCursor, StyleBorderTopLeftRadius, StyleBorderTopRightRadius, StyleBorderBottomLeftRadius, StyleBorderBottomRightRadius, StyleTextAlign, StyleUserSelect, StyleTextColor},
38    },
39    impl_option_inner, AzString, StringVec,
40};
41
42use crate::callbacks::CallbackInfo;
43
44static STEPPER_CLASS: &[IdOrClass] = &[Class(AzString::from_const_str("__azul-native-stepper"))];
45static STEPPER_STEP_CLASS: &[IdOrClass] =
46    &[Class(AzString::from_const_str("__azul-native-stepper-step"))];
47static STEPPER_ROW_CLASS: &[IdOrClass] =
48    &[Class(AzString::from_const_str("__azul-native-stepper-row"))];
49static STEPPER_CIRCLE_CLASS: &[IdOrClass] =
50    &[Class(AzString::from_const_str("__azul-native-stepper-circle"))];
51static STEPPER_CONNECTOR_CLASS: &[IdOrClass] =
52    &[Class(AzString::from_const_str("__azul-native-stepper-connector"))];
53static STEPPER_LABEL_CLASS: &[IdOrClass] =
54    &[Class(AzString::from_const_str("__azul-native-stepper-label"))];
55
56/// Callback function type invoked when the current step changes.
57pub type StepperOnStepChangeCallbackType =
58    extern "C" fn(RefAny, CallbackInfo, StepperState) -> Update;
59impl_widget_callback!(
60    StepperOnStepChange,
61    OptionStepperOnStepChange,
62    StepperOnStepChangeCallback,
63    StepperOnStepChangeCallbackType
64);
65
66azul_core::impl_managed_callback! {
67    wrapper:        StepperOnStepChangeCallback,
68    info_ty:        CallbackInfo,
69    return_ty:      Update,
70    default_ret:    Update::DoNothing,
71    invoker_static: STEPPER_ON_STEP_CHANGE_INVOKER,
72    invoker_ty:     AzStepperOnStepChangeCallbackInvoker,
73    thunk_fn:       az_stepper_on_step_change_callback_thunk,
74    setter_fn:      AzApp_setStepperOnStepChangeCallbackInvoker,
75    from_handle_fn: AzStepperOnStepChangeCallback_createFromHostHandle,
76    extra_args:     [ state: StepperState ],
77}
78
79/// A horizontal numbered-step progress indicator with a step-change callback.
80#[derive(Debug, Clone, PartialEq, Eq)]
81#[repr(C)]
82pub struct Stepper {
83    pub stepper_state: StepperStateWrapper,
84    /// The label of each step, in order. The step count is `labels.len()`.
85    pub labels: StringVec,
86    /// Style for the row container.
87    pub container_style: CssPropertyWithConditionsVec,
88}
89
90#[derive(Debug, Default, Clone, PartialEq, Eq)]
91#[repr(C)]
92pub struct StepperStateWrapper {
93    /// The current step + total step count.
94    pub inner: StepperState,
95    /// Optional: function to call when the current step changes.
96    pub on_step_change: OptionStepperOnStepChange,
97}
98
99/// State of a [`Stepper`]: the zero-based current step and the total step count.
100#[derive(Debug, Copy, Clone, PartialEq, Eq, Default)]
101#[repr(C)]
102pub struct StepperState {
103    /// Zero-based index of the current (active) step.
104    pub current_step: usize,
105    /// Total number of steps.
106    pub total_steps: usize,
107}
108
109// ---- colours ----
110/// Accent (reached/current) colour (#0d6efd).
111const ACCENT_COLOR: ColorU = ColorU { r: 13, g: 110, b: 253, a: 255 };
112/// Accent text colour (white) — the number inside a reached circle.
113const WHITE: ColorU = ColorU { r: 255, g: 255, b: 255, a: 255 };
114/// Upcoming-circle background (#e9ecef, light grey).
115const MUTED_CIRCLE_COLOR: ColorU = ColorU { r: 233, g: 236, b: 239, a: 255 };
116/// Muted text colour (#868e96) — upcoming numbers/labels.
117const MUTED_TEXT_COLOR: ColorU = ColorU { r: 134, g: 142, b: 150, a: 255 };
118/// Reached-label text colour (#212529, dark).
119const DARK_TEXT_COLOR: ColorU = ColorU { r: 33, g: 37, b: 41, a: 255 };
120/// Upcoming-connector colour (#ced4da).
121const CONNECTOR_MUTED_COLOR: ColorU = ColorU { r: 206, g: 212, b: 218, a: 255 };
122/// Transparent — used for the (absent) connector at the row's two ends.
123const TRANSPARENT_COLOR: ColorU = ColorU { r: 0, g: 0, b: 0, a: 0 };
124
125const ACCENT_BG_ITEMS: &[StyleBackgroundContent] = &[StyleBackgroundContent::Color(ACCENT_COLOR)];
126const ACCENT_BG: StyleBackgroundContentVec =
127    StyleBackgroundContentVec::from_const_slice(ACCENT_BG_ITEMS);
128const MUTED_CIRCLE_BG_ITEMS: &[StyleBackgroundContent] =
129    &[StyleBackgroundContent::Color(MUTED_CIRCLE_COLOR)];
130const MUTED_CIRCLE_BG: StyleBackgroundContentVec =
131    StyleBackgroundContentVec::from_const_slice(MUTED_CIRCLE_BG_ITEMS);
132const CONNECTOR_MUTED_BG_ITEMS: &[StyleBackgroundContent] =
133    &[StyleBackgroundContent::Color(CONNECTOR_MUTED_COLOR)];
134const CONNECTOR_MUTED_BG: StyleBackgroundContentVec =
135    StyleBackgroundContentVec::from_const_slice(CONNECTOR_MUTED_BG_ITEMS);
136const TRANSPARENT_BG_ITEMS: &[StyleBackgroundContent] =
137    &[StyleBackgroundContent::Color(TRANSPARENT_COLOR)];
138const TRANSPARENT_BG: StyleBackgroundContentVec =
139    StyleBackgroundContentVec::from_const_slice(TRANSPARENT_BG_ITEMS);
140
141const CIRCLE_SIZE: isize = 28;
142const CIRCLE_RADIUS: isize = 14;
143const CONNECTOR_HEIGHT: isize = 2;
144
145/// Connector fill state for one half-segment.
146#[derive(Copy, Clone)]
147enum ConnFill {
148    /// Reached (accent).
149    Accent,
150    /// Not reached (muted grey).
151    Muted,
152    /// At a row end — drawn transparent so the line doesn't stick out.
153    Hidden,
154}
155
156impl ConnFill {
157    const fn bg(self) -> StyleBackgroundContentVec {
158        match self {
159            Self::Accent => ACCENT_BG,
160            Self::Muted => CONNECTOR_MUTED_BG,
161            Self::Hidden => TRANSPARENT_BG,
162        }
163    }
164}
165
166/// Row container: a horizontal flex row whose steps spread evenly.
167static STEPPER_CONTAINER_STYLE: &[CssPropertyWithConditions] = &[
168    CssPropertyWithConditions::simple(CssProperty::const_display(LayoutDisplay::Flex)),
169    CssPropertyWithConditions::simple(CssProperty::const_flex_direction(LayoutFlexDirection::Row)),
170    CssPropertyWithConditions::simple(CssProperty::const_align_items(LayoutAlignItems::Start)),
171    CssPropertyWithConditions::simple(CssProperty::const_flex_grow(LayoutFlexGrow::const_new(0))),
172];
173
174/// One step cell: a vertical flex column (indicator row over label) that grows to
175/// an equal share of the row (`flex-grow: 1; flex-basis: 0`).
176static STEPPER_STEP_STYLE: &[CssPropertyWithConditions] = &[
177    CssPropertyWithConditions::simple(CssProperty::const_display(LayoutDisplay::Flex)),
178    CssPropertyWithConditions::simple(CssProperty::const_flex_direction(LayoutFlexDirection::Column)),
179    CssPropertyWithConditions::simple(CssProperty::const_align_items(LayoutAlignItems::Center)),
180    CssPropertyWithConditions::simple(CssProperty::const_flex_grow(LayoutFlexGrow::const_new(1))),
181    CssPropertyWithConditions::simple(CssProperty::FlexBasis(LayoutFlexBasisValue::Exact(
182        LayoutFlexBasis::Exact(PixelValue::const_px(0)),
183    ))),
184    CssPropertyWithConditions::simple(CssProperty::const_cursor(StyleCursor::Pointer)),
185];
186
187/// Builds the indicator-row style: a full-width flex row that vertically centres
188/// the connectors (height `CONNECTOR_HEIGHT`) on the circle.
189fn row_style() -> CssPropertyWithConditionsVec {
190    CssPropertyWithConditionsVec::from_vec(vec![
191        CssPropertyWithConditions::simple(CssProperty::const_display(LayoutDisplay::Flex)),
192        CssPropertyWithConditions::simple(CssProperty::const_flex_direction(
193            LayoutFlexDirection::Row,
194        )),
195        CssPropertyWithConditions::simple(CssProperty::const_align_items(LayoutAlignItems::Center)),
196        // Full cell width so the flex-grow connectors actually have space to fill.
197        CssPropertyWithConditions::simple(CssProperty::Width(LayoutWidthValue::Exact(
198            LayoutWidth::Px(PixelValue::percent(100.0)),
199        ))),
200    ])
201}
202
203/// Builds the style for one numbered circle. Background + number colour are the
204/// only reached-dependent properties.
205fn circle_style(reached: bool) -> CssPropertyWithConditionsVec {
206    let (bg, text) = if reached {
207        (ACCENT_BG, WHITE)
208    } else {
209        (MUTED_CIRCLE_BG, MUTED_TEXT_COLOR)
210    };
211    CssPropertyWithConditionsVec::from_vec(vec![
212        CssPropertyWithConditions::simple(CssProperty::const_display(LayoutDisplay::Flex)),
213        CssPropertyWithConditions::simple(CssProperty::const_flex_direction(
214            LayoutFlexDirection::Row,
215        )),
216        CssPropertyWithConditions::simple(CssProperty::const_justify_content(
217            LayoutJustifyContent::Center,
218        )),
219        CssPropertyWithConditions::simple(CssProperty::const_align_items(LayoutAlignItems::Center)),
220        CssPropertyWithConditions::simple(CssProperty::const_flex_grow(LayoutFlexGrow::const_new(0))),
221        CssPropertyWithConditions::simple(CssProperty::const_width(LayoutWidth::const_px(
222            CIRCLE_SIZE,
223        ))),
224        CssPropertyWithConditions::simple(CssProperty::const_height(LayoutHeight::const_px(
225            CIRCLE_SIZE,
226        ))),
227        CssPropertyWithConditions::simple(CssProperty::const_min_width(LayoutMinWidth::const_px(
228            CIRCLE_SIZE,
229        ))),
230        CssPropertyWithConditions::simple(CssProperty::const_border_top_left_radius(
231            StyleBorderTopLeftRadius::const_px(CIRCLE_RADIUS),
232        )),
233        CssPropertyWithConditions::simple(CssProperty::const_border_top_right_radius(
234            StyleBorderTopRightRadius::const_px(CIRCLE_RADIUS),
235        )),
236        CssPropertyWithConditions::simple(CssProperty::const_border_bottom_left_radius(
237            StyleBorderBottomLeftRadius::const_px(CIRCLE_RADIUS),
238        )),
239        CssPropertyWithConditions::simple(CssProperty::const_border_bottom_right_radius(
240            StyleBorderBottomRightRadius::const_px(CIRCLE_RADIUS),
241        )),
242        CssPropertyWithConditions::simple(CssProperty::const_font_size(StyleFontSize::const_px(13))),
243        CssPropertyWithConditions::simple(CssProperty::const_text_align(StyleTextAlign::Center)),
244        CssPropertyWithConditions::simple(CssProperty::user_select(StyleUserSelect::None)),
245        CssPropertyWithConditions::simple(CssProperty::const_cursor(StyleCursor::Pointer)),
246        CssPropertyWithConditions::simple(CssProperty::const_background_content(bg)),
247        CssPropertyWithConditions::simple(CssProperty::const_text_color(StyleTextColor {
248            inner: text,
249        })),
250    ])
251}
252
253/// Builds the style for one connector half-line (left or right of a circle).
254fn connector_style(fill: ConnFill) -> CssPropertyWithConditionsVec {
255    CssPropertyWithConditionsVec::from_vec(vec![
256        CssPropertyWithConditions::simple(CssProperty::const_flex_grow(LayoutFlexGrow::const_new(1))),
257        CssPropertyWithConditions::simple(CssProperty::const_height(LayoutHeight::const_px(
258            CONNECTOR_HEIGHT,
259        ))),
260        CssPropertyWithConditions::simple(CssProperty::const_background_content(fill.bg())),
261    ])
262}
263
264/// Builds the style for one step label.
265fn label_style(reached: bool) -> CssPropertyWithConditionsVec {
266    let text = if reached { DARK_TEXT_COLOR } else { MUTED_TEXT_COLOR };
267    CssPropertyWithConditionsVec::from_vec(vec![
268        CssPropertyWithConditions::simple(CssProperty::const_font_size(StyleFontSize::const_px(12))),
269        CssPropertyWithConditions::simple(CssProperty::const_text_align(StyleTextAlign::Center)),
270        CssPropertyWithConditions::simple(CssProperty::user_select(StyleUserSelect::None)),
271        CssPropertyWithConditions::simple(CssProperty::const_cursor(StyleCursor::Pointer)),
272        CssPropertyWithConditions::simple(CssProperty::const_padding_top(LayoutPaddingTop::const_px(
273            6,
274        ))),
275        CssPropertyWithConditions::simple(CssProperty::const_text_color(StyleTextColor {
276            inner: text,
277        })),
278    ])
279}
280
281/// Connector fill for the left half-line of step `i` (the gap entering circle `i`).
282const fn conn_left_fill(i: usize, current: usize) -> ConnFill {
283    if i == 0 {
284        ConnFill::Hidden
285    } else if i <= current {
286        ConnFill::Accent
287    } else {
288        ConnFill::Muted
289    }
290}
291
292/// Connector fill for the right half-line of step `i` (the gap leaving circle `i`).
293const fn conn_right_fill(i: usize, last: usize, current: usize) -> ConnFill {
294    if i == last {
295        ConnFill::Hidden
296    } else if i < current {
297        ConnFill::Accent
298    } else {
299        ConnFill::Muted
300    }
301}
302
303impl Stepper {
304    /// Creates a stepper from the given step labels, with the first step current.
305    #[must_use] pub fn create(labels: StringVec) -> Self {
306        let total_steps = labels.as_ref().len();
307        Self {
308            stepper_state: StepperStateWrapper {
309                inner: StepperState {
310                    current_step: 0,
311                    total_steps,
312                },
313                ..Default::default()
314            },
315            labels,
316            container_style: CssPropertyWithConditionsVec::from_const_slice(
317                STEPPER_CONTAINER_STYLE,
318            ),
319        }
320    }
321
322    /// Sets the current (zero-based) step, clamped into `[0, total_steps - 1]`.
323    #[inline]
324    pub fn set_current_step(&mut self, current_step: usize) {
325        let total = self.stepper_state.inner.total_steps;
326        self.stepper_state.inner.current_step = if total == 0 {
327            0
328        } else {
329            current_step.min(total - 1)
330        };
331    }
332
333    /// Builder-style setter for the current step.
334    #[inline]
335    #[must_use] pub fn with_current_step(mut self, current_step: usize) -> Self {
336        self.set_current_step(current_step);
337        self
338    }
339
340    #[inline]
341    #[must_use] pub fn swap_with_default(&mut self) -> Self {
342        let mut s = Self::create(StringVec::from_const_slice(&[]));
343        core::mem::swap(&mut s, self);
344        s
345    }
346
347    #[inline]
348    pub fn set_on_step_change<C: Into<StepperOnStepChangeCallback>>(
349        &mut self,
350        data: RefAny,
351        on_step_change: C,
352    ) {
353        self.stepper_state.on_step_change = Some(StepperOnStepChange {
354            callback: on_step_change.into(),
355            refany: data,
356        })
357        .into();
358    }
359
360    #[inline]
361    #[must_use] pub fn with_on_step_change<C: Into<StepperOnStepChangeCallback>>(
362        mut self,
363        data: RefAny,
364        on_step_change: C,
365    ) -> Self {
366        self.set_on_step_change(data, on_step_change);
367        self
368    }
369
370    #[must_use] pub fn dom(self) -> Dom {
371        use azul_core::{
372            callbacks::CoreCallback,
373            dom::{EventFilter, HoverEventFilter},
374            refany::OptionRefAny,
375        };
376
377        let current = self.stepper_state.inner.current_step;
378        let count = self.labels.as_ref().len();
379        let last = count.saturating_sub(1);
380
381        // One shared RefAny across every step's callback (RefAny::clone shares the
382        // underlying state — same pattern as segmented/pagination/map).
383        let state = RefAny::new(self.stepper_state);
384
385        let mut children: Vec<Dom> = Vec::with_capacity(count);
386        for (i, label) in self.labels.as_ref().iter().enumerate() {
387            let reached = i <= current;
388
389            // Indicator row: [connector-left, circle, connector-right].
390            let row = Dom::create_div()
391                .with_ids_and_classes(IdOrClassVec::from_const_slice(STEPPER_ROW_CLASS))
392                .with_css_props(row_style())
393                .with_children(
394                    vec![
395                        Dom::create_div()
396                            .with_ids_and_classes(IdOrClassVec::from_const_slice(
397                                STEPPER_CONNECTOR_CLASS,
398                            ))
399                            .with_css_props(connector_style(conn_left_fill(i, current))),
400                        Dom::create_text(AzString::from(format!("{}", i + 1).as_str()))
401                            .with_ids_and_classes(IdOrClassVec::from_const_slice(
402                                STEPPER_CIRCLE_CLASS,
403                            ))
404                            .with_css_props(circle_style(reached)),
405                        Dom::create_div()
406                            .with_ids_and_classes(IdOrClassVec::from_const_slice(
407                                STEPPER_CONNECTOR_CLASS,
408                            ))
409                            .with_css_props(connector_style(conn_right_fill(i, last, current))),
410                    ]
411                    .into(),
412                );
413
414            let cell = Dom::create_div()
415                .with_ids_and_classes(IdOrClassVec::from_const_slice(STEPPER_STEP_CLASS))
416                .with_css_props(CssPropertyWithConditionsVec::from_const_slice(
417                    STEPPER_STEP_STYLE,
418                ))
419                .with_callbacks(
420                    vec![CoreCallbackData {
421                        event: EventFilter::Hover(HoverEventFilter::MouseUp),
422                        callback: CoreCallback {
423                            cb: on_step_click as usize,
424                            ctx: OptionRefAny::None,
425                        },
426                        refany: state.clone(),
427                    }]
428                    .into(),
429                )
430                .with_tab_index(TabIndex::Auto)
431                .with_children(
432                    vec![
433                        row,
434                        Dom::create_text(label.clone())
435                            .with_ids_and_classes(IdOrClassVec::from_const_slice(
436                                STEPPER_LABEL_CLASS,
437                            ))
438                            .with_css_props(label_style(reached)),
439                    ]
440                    .into(),
441                );
442
443            children.push(cell);
444        }
445
446        Dom::create_div()
447            .with_ids_and_classes(IdOrClassVec::from_const_slice(STEPPER_CLASS))
448            .with_css_props(self.container_style)
449            .with_children(children.into())
450    }
451}
452
453impl Default for Stepper {
454    fn default() -> Self {
455        Self::create(StringVec::from_const_slice(&[]))
456    }
457}
458
459/// Click handler shared by all step cells. Resolves the clicked cell from its
460/// sibling position (= the zero-based step index), and — only if the step
461/// actually changed — updates the state, invokes the user callback, and
462/// live-restyles every circle / connector / label (the segmented pattern).
463extern "C" fn on_step_click(mut data: RefAny, mut info: CallbackInfo) -> Update {
464    use azul_core::dom::DomNodeId;
465
466    let clicked = info.get_hit_node();
467    let Some(parent) = info.get_parent(clicked) else {
468        return Update::DoNothing;
469    };
470
471    // Collect the step cells in document order.
472    let mut cells: Vec<DomNodeId> = Vec::new();
473    let mut cur = info.get_first_child(parent);
474    while let Some(node) = cur {
475        cells.push(node);
476        cur = info.get_next_sibling(node);
477    }
478    let count = cells.len();
479    if count == 0 {
480        return Update::DoNothing;
481    }
482    let last = count - 1;
483
484    let Some(clicked_idx) = cells.iter().position(|n| *n == clicked) else {
485        return Update::DoNothing;
486    };
487
488    let current = {
489        let Some(st) = data.downcast_ref::<StepperStateWrapper>() else {
490            return Update::DoNothing;
491        };
492        st.inner.current_step
493    };
494    if clicked_idx == current {
495        // Clicked the already-current step — no change, no callback.
496        return Update::DoNothing;
497    }
498
499    let result = {
500        let Some(mut st) = data.downcast_mut::<StepperStateWrapper>() else {
501            return Update::DoNothing;
502        };
503        st.inner.current_step = clicked_idx;
504        let inner = st.inner;
505        let st = &mut *st;
506        match st.on_step_change.as_mut() {
507            Some(StepperOnStepChange { callback, refany }) => {
508                (callback.cb)(refany.clone(), info, inner)
509            }
510            None => Update::DoNothing,
511        }
512    };
513
514    // Live-restyle every cell: circle (reached → accent fill + white number),
515    // its two connector half-lines, and its label colour.
516    for (i, cell) in cells.iter().enumerate() {
517        let reached = i <= clicked_idx;
518
519        let Some(row) = info.get_first_child(*cell) else {
520            continue;
521        };
522        let conn_left = info.get_first_child(row);
523        let circle = conn_left.and_then(|cl| info.get_next_sibling(cl));
524        let conn_right = circle.and_then(|c| info.get_next_sibling(c));
525        let label = info.get_next_sibling(row);
526
527        if let Some(circle) = circle {
528            let (bg, text) = if reached {
529                (ACCENT_BG, WHITE)
530            } else {
531                (MUTED_CIRCLE_BG, MUTED_TEXT_COLOR)
532            };
533            info.set_css_property(circle, CssProperty::const_background_content(bg));
534            info.set_css_property(
535                circle,
536                CssProperty::const_text_color(StyleTextColor { inner: text }),
537            );
538        }
539        if let Some(cl) = conn_left {
540            info.set_css_property(
541                cl,
542                CssProperty::const_background_content(conn_left_fill(i, clicked_idx).bg()),
543            );
544        }
545        if let Some(cr) = conn_right {
546            info.set_css_property(
547                cr,
548                CssProperty::const_background_content(conn_right_fill(i, last, clicked_idx).bg()),
549            );
550        }
551        if let Some(label) = label {
552            let text = if reached { DARK_TEXT_COLOR } else { MUTED_TEXT_COLOR };
553            info.set_css_property(
554                label,
555                CssProperty::const_text_color(StyleTextColor { inner: text }),
556            );
557        }
558    }
559
560    result
561}
562
563impl From<Stepper> for Dom {
564    fn from(s: Stepper) -> Self {
565        s.dom()
566    }
567}
568
569#[cfg(test)]
570mod autotest_generated {
571    use std::{
572        collections::{BTreeMap, HashMap, HashSet},
573        sync::{Arc, Mutex},
574    };
575
576    use azul_core::{
577        dom::{DomId, DomNodeId, EventFilter, HoverEventFilter, NodeId, NodeType},
578        geom::{LogicalRect, OptionLogicalPosition},
579        gl::OptionGlContextPtr,
580        hit_test::ScrollPosition,
581        refany::OptionRefAny,
582        resources::RendererResources,
583        styled_dom::{NodeHierarchyItemId, StyledDom},
584        window::{MonitorVec, RawWindowHandle},
585    };
586    use azul_css::{
587        props::basic::{length::SizeMetric, pixel::PixelValue},
588        system::SystemStyle,
589    };
590    use rust_fontconfig::FcFontCache;
591
592    use super::*;
593    #[cfg(feature = "icu")]
594    use crate::icu::IcuLocalizerHandle;
595    use crate::{
596        callbacks::{CallbackChange, CallbackInfoRefData, ExternalSystemCallbacks},
597        solver3::{display_list::DisplayList, layout_tree::LayoutTree},
598        window::{DomLayoutResult, LayoutWindow},
599        window_state::FullWindowState,
600    };
601
602    // ------------------------------------------------------------------
603    // Const-evaluated extremes
604    //
605    // `conn_left_fill` / `conn_right_fill` are `const fn`, so evaluating them at
606    // the integer extremes in a `const` item makes the compiler itself prove
607    // there is no overflow / no const-eval panic on the whole `usize` range.
608    // ------------------------------------------------------------------
609
610    const _LEFT_AT_MAX: ConnFill = conn_left_fill(usize::MAX, usize::MAX);
611    const _LEFT_MAX_AT_ZERO: ConnFill = conn_left_fill(usize::MAX, 0);
612    const _LEFT_ZERO_AT_MAX: ConnFill = conn_left_fill(0, usize::MAX);
613    const _RIGHT_AT_MAX: ConnFill = conn_right_fill(usize::MAX, usize::MAX, usize::MAX);
614    const _RIGHT_MAX_AT_ZERO: ConnFill = conn_right_fill(usize::MAX, 0, 0);
615    const _RIGHT_ZERO_AT_MAX: ConnFill = conn_right_fill(0, usize::MAX, usize::MAX);
616
617    // ------------------------------------------------------------------
618    // Flattened node layout
619    //
620    // `convert_dom_into_compact_dom` walks the tree in pre-order, and one step
621    // cell contributes exactly six nodes:
622    //
623    //     cell → row → [conn-left, circle, conn-right] , label
624    //
625    // so step `i` occupies `1 + 6*i ..= 6 + 6*i`. `flattened_layout_is_six_nodes_per_step`
626    // pins this against the real hierarchy so the click tests below cannot drift.
627    // ------------------------------------------------------------------
628
629    const NODES_PER_STEP: usize = 6;
630
631    fn cell_node(i: usize) -> usize {
632        1 + NODES_PER_STEP * i
633    }
634    fn row_node(i: usize) -> usize {
635        2 + NODES_PER_STEP * i
636    }
637    fn conn_left_node(i: usize) -> usize {
638        3 + NODES_PER_STEP * i
639    }
640    fn circle_node(i: usize) -> usize {
641        4 + NODES_PER_STEP * i
642    }
643    fn conn_right_node(i: usize) -> usize {
644        5 + NODES_PER_STEP * i
645    }
646    fn label_node(i: usize) -> usize {
647        6 + NODES_PER_STEP * i
648    }
649
650    // ------------------------------------------------------------------
651    // Helpers
652    // ------------------------------------------------------------------
653
654    fn labels(v: &[&str]) -> StringVec {
655        StringVec::from_vec(v.iter().map(|s| AzString::from(*s)).collect::<Vec<_>>())
656    }
657
658    /// `n` distinct labels: `s0, s1, … s{n-1}`.
659    fn n_labels(n: usize) -> StringVec {
660        StringVec::from_vec((0..n).map(|i| AzString::from(format!("s{i}"))).collect::<Vec<_>>())
661    }
662
663    fn stepper(v: &[&str]) -> Stepper {
664        Stepper::create(labels(v))
665    }
666
667    /// The declared properties of a style vec, in declaration order.
668    fn properties(v: &CssPropertyWithConditionsVec) -> Vec<CssProperty> {
669        v.as_ref().iter().map(|p| p.property.clone()).collect()
670    }
671
672    /// The *kind* of every declared property, in order (ignores the values).
673    fn property_kinds(
674        v: &CssPropertyWithConditionsVec,
675    ) -> Vec<core::mem::Discriminant<CssProperty>> {
676        v.as_ref().iter().map(|p| core::mem::discriminant(&p.property)).collect()
677    }
678
679    /// Asserts a style vec declares each property kind at most once and attaches
680    /// no selector conditions. A duplicate is silently last-wins (hiding a value
681    /// conflict); a stray condition leaves the node unstyled until some selector
682    /// state happens to match.
683    fn assert_unconditional_and_unique(v: &CssPropertyWithConditionsVec, ctx: &str) {
684        let mut seen = HashSet::new();
685        for p in v.as_ref() {
686            assert!(
687                p.apply_if.as_ref().is_empty(),
688                "{ctx}: {:?} is conditional, not an unconditional declaration",
689                p.property
690            );
691            assert!(
692                seen.insert(core::mem::discriminant(&p.property)),
693                "{ctx}: {:?} is declared twice",
694                p.property
695            );
696        }
697        assert_eq!(seen.len(), v.as_ref().len(), "{ctx}: declaration count / kind count disagree");
698    }
699
700    /// The single background layer of a background vec, asserting there is
701    /// exactly one and that it is a flat colour (a gradient would not be `Color`).
702    fn only_color(bg: &StyleBackgroundContentVec) -> ColorU {
703        assert_eq!(bg.as_ref().len(), 1, "a stepper fill must be a single background layer");
704        match &bg.as_ref()[0] {
705            StyleBackgroundContent::Color(c) => *c,
706            other => panic!("stepper background is not a flat colour: {other:?}"),
707        }
708    }
709
710    /// `ConnFill` derives neither `Debug` nor `PartialEq`, so the three variants
711    /// are compared through the one thing they actually control: the colour.
712    fn fill_color(f: ConnFill) -> ColorU {
713        only_color(&f.bg())
714    }
715
716    fn background_color(props: &[CssProperty]) -> Option<ColorU> {
717        let found: Vec<&StyleBackgroundContentVec> = props
718            .iter()
719            .filter_map(|p| match p {
720                CssProperty::BackgroundContent(v) => v.get_property(),
721                _ => None,
722            })
723            .collect();
724        assert!(found.len() <= 1, "a stepper node must declare at most one background");
725        found.first().map(|bg| only_color(bg))
726    }
727
728    fn text_color(props: &[CssProperty]) -> Option<ColorU> {
729        let found: Vec<ColorU> = props
730            .iter()
731            .filter_map(|p| match p {
732                CssProperty::TextColor(v) => v.get_property().map(|c| c.inner),
733                _ => None,
734            })
735            .collect();
736        assert!(found.len() <= 1, "a stepper node must declare at most one text colour");
737        found.first().copied()
738    }
739
740    /// The raw `PixelValue` of `width` / `height` (the sizing *enums*), so the
741    /// metric can be asserted separately — the circle is absolute `px`, the
742    /// indicator row is a `%`.
743    fn width_value(props: &[CssProperty]) -> Option<PixelValue> {
744        props.iter().find_map(|p| match p {
745            CssProperty::Width(v) => match v.get_property() {
746                Some(LayoutWidth::Px(pv)) => Some(*pv),
747                _ => None,
748            },
749            _ => None,
750        })
751    }
752
753    fn height_value(props: &[CssProperty]) -> Option<PixelValue> {
754        props.iter().find_map(|p| match p {
755            CssProperty::Height(v) => match v.get_property() {
756                Some(LayoutHeight::Px(pv)) => Some(*pv),
757                _ => None,
758            },
759            _ => None,
760        })
761    }
762
763    fn min_width_value(props: &[CssProperty]) -> Option<PixelValue> {
764        props.iter().find_map(|p| match p {
765            CssProperty::MinWidth(v) => v.get_property().map(|x| x.inner),
766            _ => None,
767        })
768    }
769
770    fn font_size_value(props: &[CssProperty]) -> Option<PixelValue> {
771        props.iter().find_map(|p| match p {
772            CssProperty::FontSize(v) => v.get_property().map(|x| x.inner),
773            _ => None,
774        })
775    }
776
777    fn padding_top_value(props: &[CssProperty]) -> Option<PixelValue> {
778        props.iter().find_map(|p| match p {
779            CssProperty::PaddingTop(v) => v.get_property().map(|x| x.inner),
780            _ => None,
781        })
782    }
783
784    fn flex_grow_value(props: &[CssProperty]) -> Option<f32> {
785        props.iter().find_map(|p| match p {
786            CssProperty::FlexGrow(v) => v.get_property().map(|f| f.inner.get()),
787            _ => None,
788        })
789    }
790
791    /// The four corner radii as `(top-left, top-right, bottom-left, bottom-right)`.
792    fn radii(props: &[CssProperty]) -> (Option<PixelValue>, Option<PixelValue>, Option<PixelValue>, Option<PixelValue>) {
793        let find = |f: &dyn Fn(&CssProperty) -> Option<PixelValue>| props.iter().find_map(f);
794        (
795            find(&|p| match p {
796                CssProperty::BorderTopLeftRadius(v) => v.get_property().map(|r| r.inner),
797                _ => None,
798            }),
799            find(&|p| match p {
800                CssProperty::BorderTopRightRadius(v) => v.get_property().map(|r| r.inner),
801                _ => None,
802            }),
803            find(&|p| match p {
804                CssProperty::BorderBottomLeftRadius(v) => v.get_property().map(|r| r.inner),
805                _ => None,
806            }),
807            find(&|p| match p {
808                CssProperty::BorderBottomRightRadius(v) => v.get_property().map(|r| r.inner),
809                _ => None,
810            }),
811        )
812    }
813
814    /// The `f32` of a `PixelValue`, asserting it is an absolute `px` length. An
815    /// `em`/`%` slipping into the circle geometry would resolve against the
816    /// parent font/box instead of the intended fixed size.
817    fn px(pv: PixelValue) -> f32 {
818        assert_eq!(pv.metric, SizeMetric::Px, "stepper geometry must be absolute px, got {:?}", pv.metric);
819        pv.number.get()
820    }
821
822    /// Perceived brightness (0..=255) of an sRGB colour, Rec.709 weights. Kept to
823    /// plain `+`/`*` (no gamma expansion) so the readability assertions stay exact
824    /// and toolchain-independent.
825    fn luma(c: ColorU) -> f32 {
826        0.2126 * f32::from(c.r) + 0.7152 * f32::from(c.g) + 0.0722 * f32::from(c.b)
827    }
828
829    /// The text of a `NodeType::Text` node (`None` for any other node type).
830    fn text_of(node: &Dom) -> Option<&str> {
831        match node.root.get_node_type() {
832            NodeType::Text(s) => Some(s.as_ref().as_str()),
833            _ => None,
834        }
835    }
836
837    /// The properties of a rendered node's *inline* style, in declaration order.
838    fn inline_properties(node: &Dom) -> Vec<CssProperty> {
839        node.root.style.iter_inline_properties().map(|(p, _)| p.clone()).collect()
840    }
841
842    /// The true recursive descendant count of a `Dom` — what
843    /// `estimated_total_children` is documented to cache. Under-counting makes
844    /// `convert_dom_into_compact_dom` under-allocate and panic.
845    fn recursive_descendants(node: &Dom) -> usize {
846        node.children.as_ref().iter().map(|c| 1 + recursive_descendants(c)).sum()
847    }
848
849    fn step_cell(dom: &Dom, i: usize) -> &Dom {
850        &dom.children.as_ref()[i]
851    }
852    fn row_of(cell: &Dom) -> &Dom {
853        &cell.children.as_ref()[0]
854    }
855    fn label_of(cell: &Dom) -> &Dom {
856        &cell.children.as_ref()[1]
857    }
858    fn conn_left_of(row: &Dom) -> &Dom {
859        &row.children.as_ref()[0]
860    }
861    fn circle_of(row: &Dom) -> &Dom {
862        &row.children.as_ref()[1]
863    }
864    fn conn_right_of(row: &Dom) -> &Dom {
865        &row.children.as_ref()[2]
866    }
867
868    /// Boundary + "negative" step indices. `usize` has no negative values, so a
869    /// `-1` handed in through FFI arrives here as `usize::MAX`; both wrapped
870    /// forms are included so the setter is exercised at the two's-complement ends.
871    fn boundary_indices() -> Vec<usize> {
872        vec![
873            0,
874            1,
875            2,
876            usize::MAX / 2,
877            usize::MAX / 2 + 1,
878            usize::MAX - 1,
879            usize::MAX,
880            (-1i64) as usize,
881            i64::MIN as usize,
882            u32::MAX as usize,
883        ]
884    }
885
886    /// Adversarial step labels: empty, whitespace, combining marks, ZWJ emoji,
887    /// RTL, embedded NULs (`AzString` is length-based, so a NUL must not
888    /// truncate), control characters, and a string far longer than any plausible
889    /// step caption.
890    fn adversarial_strings() -> Vec<String> {
891        let mut v: Vec<String> = [
892            "",
893            "Details",
894            " ",
895            "e\u{0301}",                                   // e + combining acute
896            "\u{1F469}\u{200D}\u{1F469}\u{200D}\u{1F467}", // ZWJ family emoji
897            "\u{5E9}\u{5DC}\u{5D5}\u{5DD}",                // RTL Hebrew
898            "\0",                                          // a single NUL
899            "a\0b",                                        // embedded NUL
900            "\u{FFFD}\u{202E}\u{200B}",                    // replacement, RTL override, ZWSP
901            "line\nbreak\ttab",                            // control characters
902            "-9223372036854775808",                        // i64::MIN as a caption
903        ]
904        .iter()
905        .map(|s| (*s).to_string())
906        .collect();
907        v.push("x".repeat(100_000));
908        v
909    }
910
911    /// Forces the `fn`-item -> `fn`-pointer coercion the `Into` bound needs.
912    fn cb(f: StepperOnStepChangeCallbackType) -> StepperOnStepChangeCallback {
913        f.into()
914    }
915
916    /// A `RefAny` payload recording every state a user `on_step_change` observes.
917    struct StepLog {
918        seen: Vec<StepperState>,
919    }
920
921    extern "C" fn record_step(mut data: RefAny, _: CallbackInfo, state: StepperState) -> Update {
922        if let Some(mut log) = data.downcast_mut::<StepLog>() {
923            log.seen.push(state);
924        }
925        Update::RefreshDom
926    }
927
928    extern "C" fn step_do_nothing(_: RefAny, _: CallbackInfo, _: StepperState) -> Update {
929        Update::DoNothing
930    }
931
932    extern "C" fn step_refresh_all(_: RefAny, _: CallbackInfo, state: StepperState) -> Update {
933        // `current_step` is read (and discarded) purely so this body cannot be
934        // identical-code-folded onto another handler; the tests below compare
935        // callback function pointers for equality/inequality.
936        let _ = state.current_step;
937        Update::RefreshDomAllWindows
938    }
939
940    /// A payload whose callback tries to read the *same* `StepperStateWrapper`
941    /// `RefAny` that the handler is currently holding a mutable borrow on.
942    struct ReentrantProbe {
943        /// A clone of the state `RefAny` the handler was invoked with.
944        state: RefAny,
945        /// `Some(step)` if the re-entrant read succeeded, `None` if it was
946        /// refused. Starts as `Some(usize::MAX)` so "never ran" is distinguishable.
947        saw_step: Option<usize>,
948        calls: usize,
949    }
950
951    extern "C" fn probe_state_reentrantly(
952        mut data: RefAny,
953        _: CallbackInfo,
954        _: StepperState,
955    ) -> Update {
956        if let Some(mut probe) = data.downcast_mut::<ReentrantProbe>() {
957            probe.calls += 1;
958            let mut state = probe.state.clone();
959            probe.saw_step = state.downcast_ref::<StepperStateWrapper>().map(|w| w.inner.current_step);
960        }
961        Update::DoNothing
962    }
963
964    fn logged(data: &mut RefAny) -> Vec<StepperState> {
965        data.downcast_ref::<StepLog>().expect("payload must still be a StepLog").seen.clone()
966    }
967
968    fn current_step_of(data: &mut RefAny) -> usize {
969        data.downcast_ref::<StepperStateWrapper>()
970            .expect("payload must still be a StepperStateWrapper")
971            .inner
972            .current_step
973    }
974
975    /// The `RefAny` carried by step `i`'s click callback.
976    fn step_state(dom: &Dom, i: usize) -> RefAny {
977        dom.children.as_ref()[i]
978            .root
979            .get_callbacks()
980            .as_ref()
981            .first()
982            .expect("every step cell must carry the click callback")
983            .refany
984            .clone()
985    }
986
987    fn node(idx: usize) -> DomNodeId {
988        DomNodeId {
989            dom: DomId::ROOT_ID,
990            node: NodeHierarchyItemId::from_crate_internal(Some(NodeId::new(idx))),
991        }
992    }
993
994    /// A `DomNodeId` whose node component is `None` — the "no concrete node was
995    /// hit" case. `CallbackInfo::set_css_property` *panics* on such an id, so the
996    /// handler must bail out long before reaching the restyle loop.
997    fn node_none() -> DomNodeId {
998        DomNodeId {
999            dom: DomId::ROOT_ID,
1000            node: NodeHierarchyItemId::NONE,
1001        }
1002    }
1003
1004    /// A `DomLayoutResult` with an *empty* layout tree: `on_step_click` only walks
1005    /// `styled_dom.node_hierarchy`, so no real layout (and no font) is needed.
1006    fn layout_result(styled_dom: StyledDom) -> DomLayoutResult {
1007        DomLayoutResult {
1008            styled_dom,
1009            layout_tree: LayoutTree {
1010                nodes: Vec::new(),
1011                warm: Vec::new(),
1012                cold: Vec::new(),
1013                root: 0,
1014                dom_to_layout: BTreeMap::new(),
1015                children_arena: Vec::new(),
1016                children_offsets: Vec::new(),
1017                subtree_needs_intrinsic: Vec::new(),
1018            },
1019            calculated_positions: Vec::new(),
1020            viewport: LogicalRect::zero(),
1021            display_list: DisplayList::default(),
1022            scroll_ids: HashMap::new(),
1023            scroll_id_to_node_id: HashMap::new(),
1024        }
1025    }
1026
1027    /// Renders `s`, then hands back both the flattened DOM *and* the very `RefAny`
1028    /// the widget registered on step 0's mouse-up callback. Driving the handler
1029    /// with these two is the real wiring — nothing is re-created by hand, so a
1030    /// mismatch between what `dom()` stores and what the handler expects cannot
1031    /// hide behind the fixture. Requires at least one label.
1032    fn flatten(s: Stepper) -> (StyledDom, RefAny) {
1033        let dom = s.dom();
1034        let state = step_state(&dom, 0);
1035        (StyledDom::create_from_dom(dom), state)
1036    }
1037
1038    /// Invokes `on_step_click` against a `LayoutWindow` holding `styled` (or
1039    /// nothing at all, when `styled` is `None`), with `hit` as the hit node.
1040    /// Returns the `Update` plus every recorded `CallbackChange`.
1041    fn run_click(
1042        styled: Option<StyledDom>,
1043        hit: DomNodeId,
1044        data: RefAny,
1045    ) -> (Update, Vec<CallbackChange>) {
1046        let mut layout_window =
1047            LayoutWindow::new(FcFontCache::default()).expect("LayoutWindow::new failed");
1048        if let Some(sd) = styled {
1049            layout_window.layout_results.insert(DomId::ROOT_ID, layout_result(sd));
1050        }
1051
1052        let renderer_resources = RendererResources::default();
1053        let previous_window_state: Option<FullWindowState> = None;
1054        let current_window_state = FullWindowState::default();
1055        let gl_context = OptionGlContextPtr::None;
1056        let scroll_states: BTreeMap<DomId, BTreeMap<NodeHierarchyItemId, ScrollPosition>> =
1057            BTreeMap::new();
1058        let window_handle = RawWindowHandle::Unsupported;
1059        let system_callbacks = ExternalSystemCallbacks::rust_internal();
1060
1061        let ref_data = CallbackInfoRefData {
1062            layout_window: &layout_window,
1063            renderer_resources: &renderer_resources,
1064            previous_window_state: &previous_window_state,
1065            current_window_state: &current_window_state,
1066            gl_context: &gl_context,
1067            current_scroll_manager: &scroll_states,
1068            current_window_handle: &window_handle,
1069            system_callbacks: &system_callbacks,
1070            system_style: Arc::new(SystemStyle::default()),
1071            monitors: Arc::new(Mutex::new(MonitorVec::from_const_slice(&[]))),
1072            #[cfg(feature = "icu")]
1073            icu_localizer: IcuLocalizerHandle::default(),
1074            ctx: OptionRefAny::None,
1075        };
1076
1077        let changes: Arc<Mutex<Vec<CallbackChange>>> = Arc::new(Mutex::new(Vec::new()));
1078
1079        let info = CallbackInfo::new(
1080            &ref_data,
1081            &changes,
1082            hit,
1083            OptionLogicalPosition::None,
1084            OptionLogicalPosition::None,
1085        );
1086
1087        let update = on_step_click(data, info);
1088        let recorded = core::mem::take(&mut *changes.lock().expect("change log poisoned"));
1089        (update, recorded)
1090    }
1091
1092    /// Every colour the live restyle wrote, as `(flattened node index, "bg" |
1093    /// "text", colour)` in emission order. Panics on any property other than the
1094    /// two the handler is documented to write.
1095    fn restyle_writes(changes: &[CallbackChange]) -> Vec<(usize, &'static str, ColorU)> {
1096        let mut out = Vec::new();
1097        for change in changes {
1098            let CallbackChange::ChangeNodeCssProperties { node_id, properties, .. } = change else {
1099                panic!("the restyle must only emit ChangeNodeCssProperties, got {change:?}");
1100            };
1101            for p in properties.as_ref() {
1102                match p {
1103                    CssProperty::BackgroundContent(v) => {
1104                        let layers =
1105                            v.get_property().expect("the restyle must write an exact background");
1106                        out.push((node_id.index(), "bg", only_color(layers)));
1107                    }
1108                    CssProperty::TextColor(v) => {
1109                        let c =
1110                            v.get_property().expect("the restyle must write an exact text colour");
1111                        out.push((node_id.index(), "text", c.inner));
1112                    }
1113                    other => panic!("unexpected restyle property: {other:?}"),
1114                }
1115            }
1116        }
1117        out
1118    }
1119
1120    /// What a correct restyle of an `n`-step stepper landing on `clicked` looks
1121    /// like: per step the circle fill + number, both connector halves and the
1122    /// label colour, in the handler's documented emission order.
1123    fn expected_restyle(n: usize, clicked: usize) -> Vec<(usize, &'static str, ColorU)> {
1124        let last = n.saturating_sub(1);
1125        let mut out = Vec::with_capacity(5 * n);
1126        for i in 0..n {
1127            let reached = i <= clicked;
1128            out.push((
1129                circle_node(i),
1130                "bg",
1131                if reached { ACCENT_COLOR } else { MUTED_CIRCLE_COLOR },
1132            ));
1133            out.push((circle_node(i), "text", if reached { WHITE } else { MUTED_TEXT_COLOR }));
1134            out.push((conn_left_node(i), "bg", fill_color(conn_left_fill(i, clicked))));
1135            out.push((conn_right_node(i), "bg", fill_color(conn_right_fill(i, last, clicked))));
1136            out.push((
1137                label_node(i),
1138                "text",
1139                if reached { DARK_TEXT_COLOR } else { MUTED_TEXT_COLOR },
1140            ));
1141        }
1142        out
1143    }
1144
1145    // ==================================================================
1146    // ConnFill::bg
1147    // ==================================================================
1148
1149    #[test]
1150    fn conn_fill_bg_maps_each_variant_to_its_own_flat_colour() {
1151        assert_eq!(fill_color(ConnFill::Accent), ACCENT_COLOR);
1152        assert_eq!(fill_color(ConnFill::Muted), CONNECTOR_MUTED_COLOR);
1153        assert_eq!(fill_color(ConnFill::Hidden), TRANSPARENT_COLOR);
1154    }
1155
1156    #[test]
1157    fn conn_fill_bg_colours_are_pairwise_distinct_and_correctly_opaque() {
1158        let accent = fill_color(ConnFill::Accent);
1159        let muted = fill_color(ConnFill::Muted);
1160        let hidden = fill_color(ConnFill::Hidden);
1161
1162        assert_ne!(accent, muted, "reached and unreached connectors must be distinguishable");
1163        assert_ne!(accent, hidden);
1164        assert_ne!(muted, hidden);
1165
1166        assert_eq!(accent.a, 255, "a translucent accent track lets the page bleed through");
1167        assert_eq!(muted.a, 255, "a translucent muted track lets the page bleed through");
1168        assert_eq!(hidden.a, 0, "the row-end connector must be fully transparent, not merely pale");
1169    }
1170
1171    #[test]
1172    fn conn_fill_bg_is_pure_and_single_layered() {
1173        // Called four times per step on every click; a hidden `static mut` cache
1174        // or an accumulating vec would show up as drift between two calls.
1175        for f in [ConnFill::Accent, ConnFill::Muted, ConnFill::Hidden] {
1176            let a = f.bg();
1177            let b = f.bg();
1178            assert_eq!(a, b, "ConnFill::bg is not pure");
1179            assert_eq!(a.as_ref().len(), 1, "a connector fill is exactly one layer");
1180        }
1181    }
1182
1183    // ==================================================================
1184    // row_style
1185    // ==================================================================
1186
1187    #[test]
1188    fn row_style_is_a_full_width_vertically_centred_flex_row() {
1189        let style = row_style();
1190        let props = properties(&style);
1191        assert_eq!(props.len(), 4, "the indicator row declares exactly four properties");
1192
1193        assert!(
1194            props.iter().any(|p| matches!(
1195                p, CssProperty::Display(d) if d.get_property() == Some(&LayoutDisplay::Flex))),
1196            "the indicator row must be a flex box"
1197        );
1198        assert!(
1199            props.iter().any(|p| matches!(
1200                p, CssProperty::FlexDirection(d)
1201                    if d.get_property() == Some(&LayoutFlexDirection::Row))),
1202            "connectors sit left/right of the circle, so the row is horizontal"
1203        );
1204        assert!(
1205            props.iter().any(|p| matches!(
1206                p, CssProperty::AlignItems(a)
1207                    if a.get_property() == Some(&LayoutAlignItems::Center))),
1208            "the 2px connectors must be centred on the 28px circle"
1209        );
1210    }
1211
1212    #[test]
1213    fn row_style_width_is_a_full_percentage_not_a_pixel_length() {
1214        // A `px` width here would stop the flex-grow connectors from having any
1215        // space to fill, collapsing the track to nothing at every cell width.
1216        let style = row_style();
1217        let w = width_value(&properties(&style)).expect("the indicator row must declare a width");
1218        assert_eq!(w.metric, SizeMetric::Percent, "the row width must be relative to the cell");
1219        assert!(
1220            (w.number.get() - 100.0).abs() < f32::EPSILON,
1221            "the row must span the whole cell, got {}",
1222            w.number.get()
1223        );
1224    }
1225
1226    #[test]
1227    fn row_style_is_unconditional_unique_and_pure() {
1228        assert_unconditional_and_unique(&row_style(), "row_style");
1229        assert_eq!(properties(&row_style()), properties(&row_style()), "row_style is not pure");
1230    }
1231
1232    // ==================================================================
1233    // circle_style
1234    // ==================================================================
1235
1236    #[test]
1237    fn circle_style_declares_the_same_property_set_for_both_states() {
1238        // A property present in one state but not the other would not be reset on
1239        // restyle — the circle would keep a stale declaration after a click.
1240        let reached = circle_style(true);
1241        let unreached = circle_style(false);
1242
1243        assert_eq!(reached.as_ref().len(), 18, "the circle declares eighteen properties");
1244        assert_eq!(unreached.as_ref().len(), 18);
1245        assert_eq!(
1246            property_kinds(&reached),
1247            property_kinds(&unreached),
1248            "the two circle states must declare the same properties in the same order"
1249        );
1250    }
1251
1252    #[test]
1253    fn circle_style_colours_are_the_only_reached_dependent_declarations() {
1254        let reached = properties(&circle_style(true));
1255        let unreached = properties(&circle_style(false));
1256
1257        assert_eq!(background_color(&reached), Some(ACCENT_COLOR));
1258        assert_eq!(text_color(&reached), Some(WHITE));
1259        assert_eq!(background_color(&unreached), Some(MUTED_CIRCLE_COLOR));
1260        assert_eq!(text_color(&unreached), Some(MUTED_TEXT_COLOR));
1261
1262        // Everything that is not a colour must be byte-identical between states.
1263        let strip = |v: &[CssProperty]| -> Vec<CssProperty> {
1264            v.iter()
1265                .filter(|p| {
1266                    !matches!(
1267                        p,
1268                        CssProperty::BackgroundContent(_) | CssProperty::TextColor(_)
1269                    )
1270                })
1271                .cloned()
1272                .collect()
1273        };
1274        assert_eq!(
1275            strip(&reached),
1276            strip(&unreached),
1277            "reached-ness leaked into a non-colour property"
1278        );
1279    }
1280
1281    #[test]
1282    fn circle_style_geometry_is_a_fixed_pixel_circle() {
1283        for reached in [false, true] {
1284            let props = properties(&circle_style(reached));
1285            let ctx = format!("reached={reached}");
1286
1287            let w = px(width_value(&props).expect("width"));
1288            let h = px(height_value(&props).expect("height"));
1289            let mw = px(min_width_value(&props).expect("min-width"));
1290
1291            assert!((w - CIRCLE_SIZE as f32).abs() < f32::EPSILON, "{ctx}: width");
1292            assert!((h - CIRCLE_SIZE as f32).abs() < f32::EPSILON, "{ctx}: height");
1293            assert!(
1294                (mw - CIRCLE_SIZE as f32).abs() < f32::EPSILON,
1295                "{ctx}: without a min-width the circle would squash in a tight flex row"
1296            );
1297            assert!((w - h).abs() < f32::EPSILON, "{ctx}: a circle must be square");
1298
1299            let (tl, tr, bl, br) = radii(&props);
1300            let r = CIRCLE_RADIUS as f32;
1301            for (corner, v) in [("top-left", tl), ("top-right", tr), ("bottom-left", bl), ("bottom-right", br)] {
1302                let v = px(v.unwrap_or_else(|| panic!("{ctx}: missing {corner} radius")));
1303                assert!((v - r).abs() < f32::EPSILON, "{ctx}: {corner} radius is {v}, want {r}");
1304            }
1305
1306            assert_eq!(
1307                CIRCLE_RADIUS * 2,
1308                CIRCLE_SIZE,
1309                "a radius that is not half the box renders a rounded square, not a circle"
1310            );
1311            assert_eq!(
1312                flex_grow_value(&props),
1313                Some(0.0),
1314                "{ctx}: the circle must keep its fixed size, not stretch"
1315            );
1316            assert!(
1317                (px(font_size_value(&props).expect("font size")) - 13.0).abs() < f32::EPSILON,
1318                "{ctx}: font size"
1319            );
1320        }
1321    }
1322
1323    #[test]
1324    fn circle_style_centres_its_number_and_declares_the_interaction_affordances() {
1325        for reached in [false, true] {
1326            let props = properties(&circle_style(reached));
1327            let ctx = format!("reached={reached}");
1328
1329            assert!(
1330                props.iter().any(|p| matches!(
1331                    p, CssProperty::JustifyContent(j)
1332                        if j.get_property() == Some(&LayoutJustifyContent::Center))),
1333                "{ctx}: the step number must be horizontally centred in the circle"
1334            );
1335            assert!(
1336                props.iter().any(|p| matches!(
1337                    p, CssProperty::AlignItems(a)
1338                        if a.get_property() == Some(&LayoutAlignItems::Center))),
1339                "{ctx}: the step number must be vertically centred in the circle"
1340            );
1341            assert!(
1342                props.iter().any(|p| matches!(
1343                    p, CssProperty::TextAlign(t)
1344                        if t.get_property() == Some(&StyleTextAlign::Center))),
1345                "{ctx}: text-align"
1346            );
1347            assert!(
1348                props.iter().any(|p| matches!(
1349                    p, CssProperty::Cursor(c) if c.get_property() == Some(&StyleCursor::Pointer))),
1350                "{ctx}: a clickable step must show the pointer cursor"
1351            );
1352            assert!(
1353                props.iter().any(|p| matches!(
1354                    p, CssProperty::UserSelect(u)
1355                        if u.get_property() == Some(&StyleUserSelect::None))),
1356                "{ctx}: click-dragging a step must not select its number"
1357            );
1358        }
1359    }
1360
1361    #[test]
1362    fn circle_style_keeps_the_number_readable_and_opaque() {
1363        for reached in [false, true] {
1364            let props = properties(&circle_style(reached));
1365            let bg = background_color(&props).expect("background");
1366            let fg = text_color(&props).expect("text colour");
1367
1368            assert_eq!(bg.a, 255, "reached={reached}: a translucent circle lets the page bleed through");
1369            assert_eq!(fg.a, 255, "reached={reached}: translucent number");
1370            assert_ne!(bg, fg, "reached={reached}: an invisible number is not a step indicator");
1371            assert!(
1372                (luma(bg) - luma(fg)).abs() >= 60.0,
1373                "reached={reached}: brightness gap {:.1} is too low to read",
1374                (luma(bg) - luma(fg)).abs()
1375            );
1376        }
1377
1378        // The two states must be visually distinguishable — that is the entire
1379        // point of a progress indicator.
1380        assert_ne!(
1381            background_color(&properties(&circle_style(true))),
1382            background_color(&properties(&circle_style(false)))
1383        );
1384    }
1385
1386    #[test]
1387    fn circle_style_is_unconditional_unique_and_pure() {
1388        for reached in [false, true] {
1389            let ctx = format!("circle_style({reached})");
1390            assert_unconditional_and_unique(&circle_style(reached), &ctx);
1391            assert_eq!(
1392                properties(&circle_style(reached)),
1393                properties(&circle_style(reached)),
1394                "{ctx} is not pure"
1395            );
1396        }
1397    }
1398
1399    // ==================================================================
1400    // connector_style
1401    // ==================================================================
1402
1403    #[test]
1404    fn connector_style_declares_only_grow_height_and_fill() {
1405        for f in [ConnFill::Accent, ConnFill::Muted, ConnFill::Hidden] {
1406            let style = connector_style(f);
1407            let props = properties(&style);
1408            assert_eq!(props.len(), 3, "a connector half-line declares exactly three properties");
1409
1410            assert_eq!(
1411                flex_grow_value(&props),
1412                Some(1.0),
1413                "a connector must absorb all leftover width, otherwise the track is invisible"
1414            );
1415            let h = px(height_value(&props).expect("a connector must declare a height"));
1416            assert!(
1417                (h - CONNECTOR_HEIGHT as f32).abs() < f32::EPSILON,
1418                "connector height {h}, want {CONNECTOR_HEIGHT}"
1419            );
1420            assert_eq!(background_color(&props), Some(fill_color(f)));
1421        }
1422    }
1423
1424    #[test]
1425    fn connector_style_geometry_is_independent_of_the_fill() {
1426        // A hidden connector must still reserve exactly the same box as a visible
1427        // one, or the circles would shift horizontally at the row's two ends.
1428        let strip = |f: ConnFill| -> Vec<CssProperty> {
1429            properties(&connector_style(f))
1430                .into_iter()
1431                .filter(|p| !matches!(p, CssProperty::BackgroundContent(_)))
1432                .collect()
1433        };
1434        let accent = strip(ConnFill::Accent);
1435        assert_eq!(accent, strip(ConnFill::Muted), "muted connectors changed shape");
1436        assert_eq!(accent, strip(ConnFill::Hidden), "hidden connectors changed shape");
1437        assert_eq!(
1438            property_kinds(&connector_style(ConnFill::Accent)),
1439            property_kinds(&connector_style(ConnFill::Hidden)),
1440            "a hidden connector must declare the same properties, only a different colour"
1441        );
1442    }
1443
1444    #[test]
1445    fn connector_style_is_unconditional_unique_and_pure() {
1446        for (name, f) in
1447            [("accent", ConnFill::Accent), ("muted", ConnFill::Muted), ("hidden", ConnFill::Hidden)]
1448        {
1449            let ctx = format!("connector_style({name})");
1450            assert_unconditional_and_unique(&connector_style(f), &ctx);
1451            assert_eq!(
1452                properties(&connector_style(f)),
1453                properties(&connector_style(f)),
1454                "{ctx} is not pure"
1455            );
1456        }
1457    }
1458
1459    // ==================================================================
1460    // label_style
1461    // ==================================================================
1462
1463    #[test]
1464    fn label_style_declares_the_same_six_properties_for_both_states() {
1465        let reached = label_style(true);
1466        let unreached = label_style(false);
1467        assert_eq!(reached.as_ref().len(), 6, "a step label declares exactly six properties");
1468        assert_eq!(unreached.as_ref().len(), 6);
1469        assert_eq!(property_kinds(&reached), property_kinds(&unreached));
1470    }
1471
1472    #[test]
1473    fn label_style_colour_is_the_only_reached_dependent_declaration() {
1474        let reached = properties(&label_style(true));
1475        let unreached = properties(&label_style(false));
1476
1477        assert_eq!(text_color(&reached), Some(DARK_TEXT_COLOR));
1478        assert_eq!(text_color(&unreached), Some(MUTED_TEXT_COLOR));
1479        assert_ne!(
1480            text_color(&reached),
1481            text_color(&unreached),
1482            "reached and upcoming labels must be distinguishable"
1483        );
1484        assert_eq!(background_color(&reached), None, "a step label paints no background");
1485
1486        let strip = |v: &[CssProperty]| -> Vec<CssProperty> {
1487            v.iter().filter(|p| !matches!(p, CssProperty::TextColor(_))).cloned().collect()
1488        };
1489        assert_eq!(strip(&reached), strip(&unreached), "reached-ness leaked past the colour");
1490    }
1491
1492    #[test]
1493    fn label_style_geometry_and_affordances() {
1494        for reached in [false, true] {
1495            let props = properties(&label_style(reached));
1496            let ctx = format!("reached={reached}");
1497
1498            assert!(
1499                (px(font_size_value(&props).expect("font size")) - 12.0).abs() < f32::EPSILON,
1500                "{ctx}: labels are one point smaller than the circle number"
1501            );
1502            assert!(
1503                (px(padding_top_value(&props).expect("padding-top")) - 6.0).abs() < f32::EPSILON,
1504                "{ctx}: the label must clear the indicator row"
1505            );
1506            assert!(
1507                props.iter().any(|p| matches!(
1508                    p, CssProperty::TextAlign(t)
1509                        if t.get_property() == Some(&StyleTextAlign::Center))),
1510                "{ctx}: labels are centred under their circle"
1511            );
1512            assert!(
1513                props.iter().any(|p| matches!(
1514                    p, CssProperty::Cursor(c) if c.get_property() == Some(&StyleCursor::Pointer))),
1515                "{ctx}: the label is part of the clickable cell"
1516            );
1517            assert!(
1518                props.iter().any(|p| matches!(
1519                    p, CssProperty::UserSelect(u)
1520                        if u.get_property() == Some(&StyleUserSelect::None))),
1521                "{ctx}: click-dragging must not select the caption"
1522            );
1523        }
1524    }
1525
1526    #[test]
1527    fn label_style_stays_readable_on_a_white_page() {
1528        for reached in [false, true] {
1529            let fg = text_color(&properties(&label_style(reached))).expect("text colour");
1530            assert_eq!(fg.a, 255, "reached={reached}: translucent label text");
1531            assert!(
1532                (luma(WHITE) - luma(fg)) >= 60.0,
1533                "reached={reached}: label brightness gap against white is only {:.1}",
1534                luma(WHITE) - luma(fg)
1535            );
1536        }
1537    }
1538
1539    #[test]
1540    fn label_style_is_unconditional_unique_and_pure() {
1541        for reached in [false, true] {
1542            let ctx = format!("label_style({reached})");
1543            assert_unconditional_and_unique(&label_style(reached), &ctx);
1544            assert_eq!(
1545                properties(&label_style(reached)),
1546                properties(&label_style(reached)),
1547                "{ctx} is not pure"
1548            );
1549        }
1550    }
1551
1552    // ==================================================================
1553    // conn_left_fill  (numeric)
1554    // ==================================================================
1555
1556    #[test]
1557    fn conn_left_fill_hides_the_leading_edge_whatever_the_current_step() {
1558        // Step 0 has nothing to its left; a visible stub there would stick out of
1559        // the row. Index 0 must win over *every* `current`, including `usize::MAX`.
1560        for current in [0usize, 1, 2, usize::MAX / 2, usize::MAX - 1, usize::MAX] {
1561            assert_eq!(
1562                fill_color(conn_left_fill(0, current)),
1563                TRANSPARENT_COLOR,
1564                "current={current}: the row's leading edge must stay hidden"
1565            );
1566        }
1567    }
1568
1569    #[test]
1570    fn conn_left_fill_is_accent_up_to_and_including_the_current_step() {
1571        for current in 0..6usize {
1572            for i in 1..8usize {
1573                let want = if i <= current { ACCENT_COLOR } else { CONNECTOR_MUTED_COLOR };
1574                assert_eq!(
1575                    fill_color(conn_left_fill(i, current)),
1576                    want,
1577                    "i={i}, current={current}: wrong left-connector fill"
1578                );
1579            }
1580        }
1581    }
1582
1583    #[test]
1584    fn conn_left_fill_at_the_integer_extremes_does_not_panic() {
1585        // `usize::MAX` is also what a `-1` handed in through FFI looks like.
1586        let cases: [(usize, usize, ColorU); 7] = [
1587            (usize::MAX, usize::MAX, ACCENT_COLOR),
1588            (usize::MAX, usize::MAX - 1, CONNECTOR_MUTED_COLOR),
1589            (usize::MAX - 1, usize::MAX, ACCENT_COLOR),
1590            (usize::MAX, 0, CONNECTOR_MUTED_COLOR),
1591            (1, usize::MAX, ACCENT_COLOR),
1592            ((-1i64) as usize, (-1i64) as usize, ACCENT_COLOR),
1593            (i64::MIN as usize, usize::MAX, ACCENT_COLOR),
1594        ];
1595        for (i, current, want) in cases {
1596            assert_eq!(fill_color(conn_left_fill(i, current)), want, "i={i}, current={current}");
1597        }
1598    }
1599
1600    #[test]
1601    fn conn_left_fill_is_monotone_in_the_current_step() {
1602        // Advancing the wizard may only ever fill more of the track, never unfill
1603        // an already-accented segment.
1604        for i in 1..6usize {
1605            let mut seen_accent = false;
1606            for current in 0..12usize {
1607                let accent = fill_color(conn_left_fill(i, current)) == ACCENT_COLOR;
1608                if seen_accent {
1609                    assert!(accent, "i={i}: the left connector un-filled at current={current}");
1610                }
1611                seen_accent |= accent;
1612            }
1613            assert!(seen_accent, "i={i}: the left connector never fills");
1614        }
1615    }
1616
1617    #[test]
1618    fn conn_left_fill_only_ever_returns_one_of_the_three_known_colours() {
1619        for i in 0..8usize {
1620            for current in 0..8usize {
1621                let c = fill_color(conn_left_fill(i, current));
1622                assert!(
1623                    c == ACCENT_COLOR || c == CONNECTOR_MUTED_COLOR || c == TRANSPARENT_COLOR,
1624                    "i={i}, current={current}: unexpected colour {c:?}"
1625                );
1626            }
1627        }
1628    }
1629
1630    // ==================================================================
1631    // conn_right_fill  (numeric)
1632    // ==================================================================
1633
1634    #[test]
1635    fn conn_right_fill_hides_the_trailing_edge_of_the_last_step() {
1636        for last in [0usize, 1, 4, usize::MAX] {
1637            for current in [0usize, 1, usize::MAX] {
1638                assert_eq!(
1639                    fill_color(conn_right_fill(last, last, current)),
1640                    TRANSPARENT_COLOR,
1641                    "last={last}, current={current}: the row's trailing edge must stay hidden"
1642                );
1643            }
1644        }
1645    }
1646
1647    #[test]
1648    fn conn_right_fill_is_accent_strictly_before_the_current_step() {
1649        let last = 7usize;
1650        for current in 0..8usize {
1651            for i in 0..last {
1652                let want = if i < current { ACCENT_COLOR } else { CONNECTOR_MUTED_COLOR };
1653                assert_eq!(
1654                    fill_color(conn_right_fill(i, last, current)),
1655                    want,
1656                    "i={i}, last={last}, current={current}: wrong right-connector fill"
1657                );
1658            }
1659        }
1660    }
1661
1662    #[test]
1663    fn conn_right_fill_lets_last_win_over_an_accent_current() {
1664        // `i == last` is checked first, so a `current` beyond the end must not
1665        // paint a stub past the final circle.
1666        for current in [3usize, 4, 100, usize::MAX] {
1667            assert_eq!(
1668                fill_color(conn_right_fill(3, 3, current)),
1669                TRANSPARENT_COLOR,
1670                "current={current}: the last step grew a trailing accent stub"
1671            );
1672        }
1673    }
1674
1675    #[test]
1676    fn conn_right_fill_at_the_integer_extremes_does_not_panic() {
1677        let cases: [(usize, usize, usize, ColorU); 7] = [
1678            (usize::MAX, usize::MAX, usize::MAX, TRANSPARENT_COLOR),
1679            (usize::MAX - 1, usize::MAX, usize::MAX, ACCENT_COLOR),
1680            (usize::MAX - 1, usize::MAX, 0, CONNECTOR_MUTED_COLOR),
1681            (0, usize::MAX, usize::MAX, ACCENT_COLOR),
1682            (0, 0, usize::MAX, TRANSPARENT_COLOR),
1683            ((-1i64) as usize, (-1i64) as usize, 0, TRANSPARENT_COLOR),
1684            (i64::MIN as usize, usize::MAX, usize::MAX, ACCENT_COLOR),
1685        ];
1686        for (i, last, current, want) in cases {
1687            assert_eq!(
1688                fill_color(conn_right_fill(i, last, current)),
1689                want,
1690                "i={i}, last={last}, current={current}"
1691            );
1692        }
1693    }
1694
1695    #[test]
1696    fn the_two_halves_of_every_gap_agree() {
1697        // The gap between circle `i` and circle `i+1` is drawn by two independent
1698        // half-lines: `conn_right_fill(i, ..)` and `conn_left_fill(i + 1, ..)`.
1699        // If they ever disagree the track renders half accent / half grey.
1700        for n in 1..8usize {
1701            let last = n - 1;
1702            for current in 0..(n + 2) {
1703                for i in 0..last {
1704                    assert_eq!(
1705                        fill_color(conn_right_fill(i, last, current)),
1706                        fill_color(conn_left_fill(i + 1, current)),
1707                        "n={n}, current={current}: the two halves of gap {i}→{} disagree",
1708                        i + 1
1709                    );
1710                }
1711            }
1712        }
1713    }
1714
1715    // ==================================================================
1716    // Stepper::create
1717    // ==================================================================
1718
1719    #[test]
1720    fn create_preserves_labels_verbatim() {
1721        for case in [
1722            vec![],
1723            vec!["only"],
1724            vec!["Account", "Address"],
1725            vec!["Account", "Address", "Payment", "Review"],
1726            vec!["dup", "dup", "dup"],
1727        ] {
1728            let s = Stepper::create(labels(&case));
1729            let got: Vec<&str> = s.labels.as_ref().iter().map(AzString::as_str).collect();
1730            assert_eq!(got, case, "create must not reorder/drop/dedupe/rewrite labels");
1731        }
1732    }
1733
1734    #[test]
1735    fn create_preserves_adversarial_labels_byte_for_byte() {
1736        for s in adversarial_strings() {
1737            let w = Stepper::create(labels(&[s.as_str()]));
1738            assert_eq!(
1739                w.labels.as_ref()[0].as_str(),
1740                s.as_str(),
1741                "the caption changed on its way into the widget"
1742            );
1743            assert_eq!(
1744                w.labels.as_ref()[0].as_ref().len(),
1745                s.len(),
1746                "byte length changed (NUL truncation?)"
1747            );
1748            assert_eq!(w.stepper_state.inner.total_steps, 1);
1749        }
1750    }
1751
1752    #[test]
1753    fn create_derives_total_steps_from_the_label_count_and_starts_at_zero() {
1754        for n in [0usize, 1, 2, 7, 4096] {
1755            let s = Stepper::create(n_labels(n));
1756            assert_eq!(s.stepper_state.inner.total_steps, n, "n={n}: total_steps");
1757            assert_eq!(s.stepper_state.inner.current_step, 0, "n={n}: a fresh stepper starts at 0");
1758            assert_eq!(s.labels.as_ref().len(), n, "n={n}: label count");
1759            assert!(s.stepper_state.on_step_change.as_ref().is_none(), "n={n}: create wires no callback");
1760        }
1761    }
1762
1763    #[test]
1764    fn create_keeps_total_steps_and_the_label_count_in_lockstep() {
1765        // `dom()` counts labels while `set_current_step` clamps against
1766        // `total_steps`; a mismatch between the two is what would let the clamp
1767        // admit a step that does not render.
1768        for n in [0usize, 1, 3, 64] {
1769            let s = Stepper::create(n_labels(n));
1770            assert_eq!(
1771                s.stepper_state.inner.total_steps,
1772                s.labels.as_ref().len(),
1773                "n={n}: total_steps drifted from the label count"
1774            );
1775        }
1776    }
1777
1778    #[test]
1779    fn create_installs_the_shared_container_style() {
1780        let s = stepper(&["a", "b"]);
1781        assert_eq!(
1782            s.container_style.as_ref(),
1783            STEPPER_CONTAINER_STYLE,
1784            "create must install the shared container style"
1785        );
1786
1787        let props = properties(&s.container_style);
1788        assert_eq!(props.len(), 4);
1789        assert!(props.iter().any(|p| matches!(
1790            p, CssProperty::Display(d) if d.get_property() == Some(&LayoutDisplay::Flex))));
1791        assert!(props.iter().any(|p| matches!(
1792            p, CssProperty::FlexDirection(d)
1793                if d.get_property() == Some(&LayoutFlexDirection::Row))));
1794        assert!(props.iter().any(|p| matches!(
1795            p, CssProperty::AlignItems(a) if a.get_property() == Some(&LayoutAlignItems::Start))),
1796            "step cells must be top-aligned so labels of different heights do not shift the circles");
1797        assert_eq!(
1798            flex_grow_value(&props),
1799            Some(0.0),
1800            "the stepper hugs its steps instead of filling the parent"
1801        );
1802        assert_unconditional_and_unique(&s.container_style, "STEPPER_CONTAINER_STYLE");
1803    }
1804
1805    #[test]
1806    fn create_with_no_labels_equals_default() {
1807        let empty = Stepper::create(StringVec::from_const_slice(&[]));
1808        assert_eq!(empty, Stepper::default(), "Default must be the empty stepper");
1809        assert_eq!(empty.labels.as_ref().len(), 0);
1810        assert_eq!(empty.stepper_state.inner, StepperState { current_step: 0, total_steps: 0 });
1811    }
1812
1813    #[test]
1814    fn create_scales_to_a_very_long_label_list() {
1815        let n = 4096;
1816        let s = Stepper::create(n_labels(n));
1817        assert_eq!(s.labels.as_ref().len(), n);
1818        assert_eq!(s.labels.as_ref()[n - 1].as_str(), format!("s{}", n - 1));
1819        assert_eq!(s.stepper_state.inner.total_steps, n);
1820    }
1821
1822    // ==================================================================
1823    // Stepper::set_current_step  (numeric)
1824    // ==================================================================
1825
1826    #[test]
1827    fn set_current_step_clamps_every_boundary_value_into_range() {
1828        for i in boundary_indices() {
1829            let mut s = stepper(&["a", "b", "c"]);
1830            s.set_current_step(i);
1831            assert_eq!(
1832                s.stepper_state.inner.current_step,
1833                i.min(2),
1834                "index {i} was not clamped to [0, total_steps - 1]"
1835            );
1836        }
1837    }
1838
1839    #[test]
1840    fn set_current_step_on_an_empty_stepper_never_underflows() {
1841        // `total - 1` on an empty stepper would underflow to `usize::MAX` in
1842        // release and panic in debug; the `total == 0` guard must win.
1843        let mut s = Stepper::default();
1844        for i in boundary_indices() {
1845            s.set_current_step(i);
1846            assert_eq!(s.stepper_state.inner.current_step, 0, "index {i} on an empty stepper");
1847            assert_eq!(s.stepper_state.inner.total_steps, 0);
1848        }
1849    }
1850
1851    #[test]
1852    fn set_current_step_at_zero_and_at_the_last_index_is_exact() {
1853        for n in [1usize, 2, 5, 64] {
1854            let mut s = Stepper::create(n_labels(n));
1855            s.set_current_step(0);
1856            assert_eq!(s.stepper_state.inner.current_step, 0, "n={n}: zero");
1857            s.set_current_step(n - 1);
1858            assert_eq!(s.stepper_state.inner.current_step, n - 1, "n={n}: last index is in range");
1859            s.set_current_step(n);
1860            assert_eq!(s.stepper_state.inner.current_step, n - 1, "n={n}: one past the end clamps");
1861        }
1862    }
1863
1864    #[test]
1865    fn set_current_step_on_a_single_step_stepper_is_always_zero() {
1866        let mut s = stepper(&["only"]);
1867        for i in boundary_indices() {
1868            s.set_current_step(i);
1869            assert_eq!(s.stepper_state.inner.current_step, 0, "index {i} on a one-step stepper");
1870        }
1871    }
1872
1873    #[test]
1874    fn set_current_step_is_idempotent_and_last_write_wins() {
1875        let mut s = stepper(&["a", "b", "c"]);
1876        for _ in 0..3 {
1877            s.set_current_step(1);
1878        }
1879        assert_eq!(s.stepper_state.inner.current_step, 1);
1880
1881        for i in [0usize, usize::MAX, 2, 0] {
1882            s.set_current_step(i);
1883        }
1884        assert_eq!(s.stepper_state.inner.current_step, 0, "the last write must win");
1885    }
1886
1887    #[test]
1888    fn set_current_step_leaves_every_other_field_alone() {
1889        let mut s =
1890            stepper(&["a", "b"]).with_on_step_change(RefAny::new(7u8), cb(step_do_nothing));
1891        let before = s.clone();
1892
1893        s.set_current_step(usize::MAX);
1894
1895        assert_eq!(s.labels, before.labels, "labels changed");
1896        assert_eq!(s.container_style, before.container_style, "container style changed");
1897        assert_eq!(
1898            s.stepper_state.inner.total_steps, before.stepper_state.inner.total_steps,
1899            "total_steps changed"
1900        );
1901        assert!(s.stepper_state.on_step_change.as_ref().is_some(), "the callback was dropped");
1902    }
1903
1904    #[test]
1905    fn set_current_step_clamps_against_total_steps_not_the_label_count() {
1906        // `total_steps` is a public field, so app code can desync it from
1907        // `labels`. This documents which of the two the clamp actually consults.
1908        let mut s = stepper(&["a", "b", "c"]);
1909        s.stepper_state.inner.total_steps = 100;
1910        s.set_current_step(50);
1911        assert_eq!(
1912            s.stepper_state.inner.current_step, 50,
1913            "the clamp follows total_steps, not labels.len()"
1914        );
1915        s.set_current_step(usize::MAX);
1916        assert_eq!(s.stepper_state.inner.current_step, 99);
1917        assert_eq!(s.labels.as_ref().len(), 3, "the setter must not touch the labels");
1918    }
1919
1920    // ==================================================================
1921    // Stepper::with_current_step  (constructor)
1922    // ==================================================================
1923
1924    #[test]
1925    fn with_current_step_round_trips_through_the_setter() {
1926        for i in boundary_indices() {
1927            let via_builder = stepper(&["a", "b", "c"]).with_current_step(i);
1928            let mut via_setter = stepper(&["a", "b", "c"]);
1929            via_setter.set_current_step(i);
1930
1931            assert_eq!(via_builder, via_setter, "index {i}: builder and setter diverge");
1932            assert_eq!(via_builder.stepper_state.inner.current_step, i.min(2));
1933        }
1934    }
1935
1936    #[test]
1937    fn with_current_step_stores_exactly_what_it_reads_back_in_range() {
1938        // encode == decode for every in-range step of a 6-step wizard.
1939        let n = 6;
1940        for i in 0..n {
1941            let s = Stepper::create(n_labels(n)).with_current_step(i);
1942            assert_eq!(s.stepper_state.inner.current_step, i, "step {i} did not round-trip");
1943            assert_eq!(s.stepper_state.inner.total_steps, n);
1944        }
1945    }
1946
1947    #[test]
1948    fn with_current_step_does_not_panic_on_extreme_arguments() {
1949        for n in [0usize, 1, 3] {
1950            for i in boundary_indices() {
1951                let s = Stepper::create(n_labels(n)).with_current_step(i);
1952                let cur = s.stepper_state.inner.current_step;
1953                assert!(
1954                    n == 0 && cur == 0 || n > 0 && cur < n,
1955                    "n={n}, i={i}: current_step {cur} escaped the valid range"
1956                );
1957            }
1958        }
1959    }
1960
1961    #[test]
1962    fn with_current_step_preserves_the_rest_of_the_widget() {
1963        let base = stepper(&["a", "b", "c"]);
1964        let built = base.clone().with_current_step(2);
1965
1966        assert_eq!(built.labels, base.labels);
1967        assert_eq!(built.container_style, base.container_style);
1968        assert_eq!(built.labels.as_ref().len(), 3, "len/contents must stay consistent");
1969        assert_eq!(built.stepper_state.inner.total_steps, 3);
1970        assert!(built.stepper_state.on_step_change.as_ref().is_none());
1971    }
1972
1973    #[test]
1974    fn with_current_step_chains_with_last_wins() {
1975        let s = stepper(&["a", "b", "c"])
1976            .with_current_step(usize::MAX)
1977            .with_current_step(0)
1978            .with_current_step(1);
1979        assert_eq!(s.stepper_state.inner.current_step, 1);
1980    }
1981
1982    // ==================================================================
1983    // Stepper::swap_with_default
1984    // ==================================================================
1985
1986    #[test]
1987    fn swap_with_default_returns_the_original_and_leaves_a_default_behind() {
1988        let mut s = stepper(&["Account", "Address", "Payment"]).with_current_step(2);
1989        let expected = s.clone();
1990
1991        let taken = s.swap_with_default();
1992
1993        assert_eq!(taken, expected, "the caller must get the original widget back");
1994        assert_eq!(s, Stepper::default(), "a default must be left in its place");
1995        assert_eq!(s.labels.as_ref().len(), 0);
1996        assert_eq!(s.stepper_state.inner, StepperState { current_step: 0, total_steps: 0 });
1997    }
1998
1999    #[test]
2000    fn swap_with_default_moves_the_callback_out_with_the_widget() {
2001        let mut s = stepper(&["a", "b"]).with_on_step_change(RefAny::new(1u8), cb(record_step));
2002
2003        let taken = s.swap_with_default();
2004
2005        assert!(
2006            taken.stepper_state.on_step_change.as_ref().is_some(),
2007            "the callback must travel with the taken widget"
2008        );
2009        assert!(
2010            s.stepper_state.on_step_change.as_ref().is_none(),
2011            "the leftover default must not keep a handle on the callback"
2012        );
2013    }
2014
2015    #[test]
2016    fn swap_with_default_on_a_default_is_a_no_op() {
2017        let mut s = Stepper::default();
2018        let taken = s.swap_with_default();
2019        assert_eq!(taken, Stepper::default());
2020        assert_eq!(s, Stepper::default());
2021    }
2022
2023    #[test]
2024    fn swap_with_default_twice_yields_a_default_the_second_time() {
2025        let mut s = stepper(&["a", "b"]).with_current_step(1);
2026        let first = s.swap_with_default();
2027        let second = s.swap_with_default();
2028
2029        assert_eq!(first.labels.as_ref().len(), 2);
2030        assert_eq!(first.stepper_state.inner.current_step, 1);
2031        assert_eq!(second, Stepper::default(), "the second take is the default we left behind");
2032        assert_eq!(s, Stepper::default());
2033    }
2034
2035    #[test]
2036    fn swap_with_default_does_not_truncate_a_large_label_list() {
2037        let n = 1024;
2038        let mut s = Stepper::create(n_labels(n)).with_current_step(n - 1);
2039        let taken = s.swap_with_default();
2040
2041        assert_eq!(taken.labels.as_ref().len(), n);
2042        assert_eq!(taken.labels.as_ref()[n - 1].as_str(), format!("s{}", n - 1));
2043        assert_eq!(taken.stepper_state.inner.current_step, n - 1);
2044        assert_eq!(taken.stepper_state.inner.total_steps, n);
2045    }
2046
2047    // ==================================================================
2048    // Stepper::set_on_step_change  /  with_on_step_change
2049    // ==================================================================
2050
2051    #[test]
2052    fn set_on_step_change_installs_the_callback_and_shares_its_payload() {
2053        let mut s = stepper(&["a", "b"]);
2054        let mut payload = RefAny::new(StepLog { seen: Vec::new() });
2055        s.set_on_step_change(payload.clone(), cb(record_step));
2056
2057        let installed = s
2058            .stepper_state
2059            .on_step_change
2060            .as_ref()
2061            .expect("set_on_step_change must install a callback");
2062        assert_eq!(installed.callback.cb as usize, record_step as usize, "wrong function installed");
2063        assert!(
2064            matches!(installed.callback.ctx, OptionRefAny::None),
2065            "a native Rust callback carries no FFI context"
2066        );
2067
2068        // The stored `RefAny` must be a *share* of the caller's, not a copy:
2069        // writing through the widget's handle must be visible to the caller.
2070        let mut stored = installed.refany.clone();
2071        {
2072            let mut log = stored.downcast_mut::<StepLog>().expect("payload type must survive");
2073            log.seen.push(StepperState { current_step: 42, total_steps: 43 });
2074        }
2075        assert_eq!(
2076            logged(&mut payload),
2077            vec![StepperState { current_step: 42, total_steps: 43 }],
2078            "the payload was copied, not shared"
2079        );
2080    }
2081
2082    #[test]
2083    fn set_on_step_change_overwrites_a_previously_installed_callback() {
2084        let mut s = stepper(&["a", "b"]);
2085        s.set_on_step_change(RefAny::new(1u8), cb(record_step));
2086        s.set_on_step_change(RefAny::new(2u8), cb(step_refresh_all));
2087
2088        let installed = s.stepper_state.on_step_change.as_ref().expect("callback");
2089        assert_eq!(
2090            installed.callback.cb as usize, step_refresh_all as usize,
2091            "the last setter must win"
2092        );
2093        assert_ne!(installed.callback.cb as usize, record_step as usize);
2094    }
2095
2096    #[test]
2097    fn set_on_step_change_does_not_disturb_the_labels_or_the_state() {
2098        let mut s = stepper(&["a", "b", "c"]).with_current_step(2);
2099        s.set_on_step_change(RefAny::new(0u8), cb(step_do_nothing));
2100
2101        assert_eq!(s.labels.as_ref().len(), 3);
2102        assert_eq!(
2103            s.stepper_state.inner,
2104            StepperState { current_step: 2, total_steps: 3 },
2105            "installing a callback moved the current step"
2106        );
2107    }
2108
2109    #[test]
2110    fn set_on_step_change_accepts_an_arbitrary_payload_without_reading_it() {
2111        for payload in [RefAny::new(0u8), RefAny::new(String::new()), RefAny::new([0u64; 64])] {
2112            let mut s = stepper(&["a"]);
2113            s.set_on_step_change(payload, cb(step_do_nothing));
2114            assert!(s.stepper_state.on_step_change.as_ref().is_some());
2115        }
2116    }
2117
2118    #[test]
2119    fn with_on_step_change_matches_the_setter_exactly() {
2120        let payload = RefAny::new(9u8);
2121        let via_builder =
2122            stepper(&["a", "b"]).with_on_step_change(payload.clone(), cb(step_do_nothing));
2123        let mut via_setter = stepper(&["a", "b"]);
2124        via_setter.set_on_step_change(payload, cb(step_do_nothing));
2125
2126        assert_eq!(via_builder, via_setter, "builder and setter must produce the same widget");
2127    }
2128
2129    #[test]
2130    fn with_on_step_change_holds_its_invariants_after_construction() {
2131        let s = Stepper::create(n_labels(5))
2132            .with_current_step(3)
2133            .with_on_step_change(RefAny::new(0u8), cb(step_refresh_all));
2134
2135        assert_eq!(s.labels.as_ref().len(), 5, "label count must survive the builder chain");
2136        assert_eq!(
2137            s.stepper_state.inner,
2138            StepperState { current_step: 3, total_steps: 5 },
2139            "the state must survive"
2140        );
2141        assert_eq!(
2142            s.container_style.as_ref(),
2143            STEPPER_CONTAINER_STYLE,
2144            "the container style must survive"
2145        );
2146        let installed = s.stepper_state.on_step_change.as_ref().expect("callback");
2147        assert_eq!(installed.callback.cb as usize, step_refresh_all as usize);
2148    }
2149
2150    #[test]
2151    fn with_on_step_change_chains_with_last_wins() {
2152        let s = stepper(&["a"])
2153            .with_on_step_change(RefAny::new(0u8), cb(record_step))
2154            .with_on_step_change(RefAny::new(0u8), cb(step_do_nothing));
2155        let installed = s.stepper_state.on_step_change.as_ref().expect("callback");
2156        assert_eq!(installed.callback.cb as usize, step_do_nothing as usize);
2157    }
2158
2159    // ==================================================================
2160    // Stepper::dom
2161    // ==================================================================
2162
2163    #[test]
2164    fn dom_emits_one_six_node_cell_per_label_in_order() {
2165        let case = ["Account", "Address", "Payment", "Review"];
2166        let dom = Stepper::create(labels(&case)).dom();
2167
2168        assert!(matches!(dom.root.get_node_type(), NodeType::Div), "the stepper is a div");
2169        assert!(dom.root.has_class("__azul-native-stepper"));
2170        assert!(
2171            dom.root.get_callbacks().as_ref().is_empty(),
2172            "the container itself is not clickable"
2173        );
2174
2175        let children = dom.children.as_ref();
2176        assert_eq!(children.len(), case.len());
2177        for (i, cell) in children.iter().enumerate() {
2178            assert!(cell.root.has_class("__azul-native-stepper-step"), "step {i}: cell class");
2179            assert_eq!(cell.children.as_ref().len(), 2, "step {i}: cell holds a row + a label");
2180
2181            let row = row_of(cell);
2182            assert!(row.root.has_class("__azul-native-stepper-row"), "step {i}: row class");
2183            assert_eq!(row.children.as_ref().len(), 3, "step {i}: connector, circle, connector");
2184
2185            assert!(
2186                conn_left_of(row).root.has_class("__azul-native-stepper-connector"),
2187                "step {i}: left connector class"
2188            );
2189            assert!(
2190                conn_right_of(row).root.has_class("__azul-native-stepper-connector"),
2191                "step {i}: right connector class"
2192            );
2193            assert!(
2194                circle_of(row).root.has_class("__azul-native-stepper-circle"),
2195                "step {i}: circle class"
2196            );
2197            assert!(
2198                label_of(cell).root.has_class("__azul-native-stepper-label"),
2199                "step {i}: label class"
2200            );
2201            assert_eq!(text_of(label_of(cell)), Some(case[i]), "step {i}: wrong caption");
2202        }
2203    }
2204
2205    #[test]
2206    fn dom_numbers_the_circles_from_one() {
2207        // The circle shows a *one-based* number while every index in the widget is
2208        // zero-based; an off-by-one here is invisible to every other assertion.
2209        let n = 12;
2210        let dom = Stepper::create(n_labels(n)).dom();
2211        for (i, cell) in dom.children.as_ref().iter().enumerate() {
2212            let want = format!("{}", i + 1);
2213            assert_eq!(
2214                text_of(circle_of(row_of(cell))),
2215                Some(want.as_str()),
2216                "step {i} shows the wrong number"
2217            );
2218        }
2219    }
2220
2221    #[test]
2222    fn dom_of_an_empty_stepper_is_a_childless_container() {
2223        // `count == 0` makes `last = count.saturating_sub(1)`; the saturation must
2224        // hold and no stray child may be emitted.
2225        let dom = Stepper::default().dom();
2226        assert_eq!(dom.children.as_ref().len(), 0);
2227        assert_eq!(dom.estimated_total_children, 0);
2228        assert!(dom.root.has_class("__azul-native-stepper"));
2229
2230        let styled = StyledDom::create_from_dom(dom);
2231        assert_eq!(styled.node_hierarchy.as_ref().len(), 1, "just the container");
2232    }
2233
2234    #[test]
2235    fn dom_styles_every_circle_and_label_by_reached_ness() {
2236        for n in [1usize, 2, 3, 5] {
2237            for current in 0..n {
2238                let dom = Stepper::create(n_labels(n)).with_current_step(current).dom();
2239                for (i, cell) in dom.children.as_ref().iter().enumerate() {
2240                    let reached = i <= current;
2241                    assert_eq!(
2242                        inline_properties(circle_of(row_of(cell))),
2243                        properties(&circle_style(reached)),
2244                        "n={n} current={current}: circle {i} carries the wrong style"
2245                    );
2246                    assert_eq!(
2247                        inline_properties(label_of(cell)),
2248                        properties(&label_style(reached)),
2249                        "n={n} current={current}: label {i} carries the wrong style"
2250                    );
2251                }
2252            }
2253        }
2254    }
2255
2256    #[test]
2257    fn dom_paints_the_connectors_exactly_as_the_fill_helpers_say() {
2258        for n in [1usize, 2, 3, 6] {
2259            let last = n - 1;
2260            for current in 0..n {
2261                let dom = Stepper::create(n_labels(n)).with_current_step(current).dom();
2262                for (i, cell) in dom.children.as_ref().iter().enumerate() {
2263                    let row = row_of(cell);
2264                    assert_eq!(
2265                        background_color(&inline_properties(conn_left_of(row))),
2266                        Some(fill_color(conn_left_fill(i, current))),
2267                        "n={n} current={current}: left connector of step {i}"
2268                    );
2269                    assert_eq!(
2270                        background_color(&inline_properties(conn_right_of(row))),
2271                        Some(fill_color(conn_right_fill(i, last, current))),
2272                        "n={n} current={current}: right connector of step {i}"
2273                    );
2274                }
2275            }
2276        }
2277    }
2278
2279    #[test]
2280    fn dom_hides_the_two_outer_connectors_of_every_stepper() {
2281        for n in [1usize, 2, 5] {
2282            for current in 0..n {
2283                let dom = Stepper::create(n_labels(n)).with_current_step(current).dom();
2284                let children = dom.children.as_ref();
2285
2286                let first_row = row_of(&children[0]);
2287                assert_eq!(
2288                    background_color(&inline_properties(conn_left_of(first_row))),
2289                    Some(TRANSPARENT_COLOR),
2290                    "n={n} current={current}: the track sticks out to the left of step 1"
2291                );
2292
2293                let last_row = row_of(&children[n - 1]);
2294                assert_eq!(
2295                    background_color(&inline_properties(conn_right_of(last_row))),
2296                    Some(TRANSPARENT_COLOR),
2297                    "n={n} current={current}: the track sticks out past the final step"
2298                );
2299            }
2300        }
2301    }
2302
2303    #[test]
2304    fn dom_of_a_single_step_hides_both_of_its_connectors() {
2305        let dom = stepper(&["only"]).dom();
2306        let row = row_of(step_cell(&dom, 0));
2307        assert_eq!(
2308            background_color(&inline_properties(conn_left_of(row))),
2309            Some(TRANSPARENT_COLOR)
2310        );
2311        assert_eq!(
2312            background_color(&inline_properties(conn_right_of(row))),
2313            Some(TRANSPARENT_COLOR)
2314        );
2315        assert_eq!(
2316            inline_properties(circle_of(row)),
2317            properties(&circle_style(true)),
2318            "a lone step is always the current one"
2319        );
2320    }
2321
2322    #[test]
2323    fn dom_marks_a_contiguous_reached_prefix() {
2324        // "Reached" must be a prefix, never a hole: exactly `current + 1` accent
2325        // circles, all at the front.
2326        for n in [1usize, 4, 9] {
2327            for current in 0..n {
2328                let dom = Stepper::create(n_labels(n)).with_current_step(current).dom();
2329                let accent: Vec<usize> = dom
2330                    .children
2331                    .as_ref()
2332                    .iter()
2333                    .enumerate()
2334                    .filter(|(_, cell)| {
2335                        background_color(&inline_properties(circle_of(row_of(cell))))
2336                            == Some(ACCENT_COLOR)
2337                    })
2338                    .map(|(i, _)| i)
2339                    .collect();
2340                assert_eq!(
2341                    accent,
2342                    (0..=current).collect::<Vec<_>>(),
2343                    "n={n} current={current}: the reached prefix is not contiguous"
2344                );
2345            }
2346        }
2347    }
2348
2349    #[test]
2350    fn dom_with_an_out_of_range_current_step_renders_fully_complete_without_panicking() {
2351        // `current_step` is a public field, so it can be written past the end
2352        // without going through the clamping setter. Rendering must degrade to
2353        // "everything reached" rather than panic or wrap onto a real step.
2354        for current in [3usize, 4, 1_000, usize::MAX - 1, usize::MAX] {
2355            let mut s = Stepper::create(n_labels(3));
2356            s.stepper_state.inner.current_step = current;
2357            let dom = s.dom();
2358
2359            assert_eq!(dom.children.as_ref().len(), 3, "current={current}: child count changed");
2360            for (i, cell) in dom.children.as_ref().iter().enumerate() {
2361                assert_eq!(
2362                    inline_properties(circle_of(row_of(cell))),
2363                    properties(&circle_style(true)),
2364                    "current={current}: circle {i} must render reached"
2365                );
2366            }
2367            // The two row-end connectors still win over the accent fill.
2368            let children = dom.children.as_ref();
2369            assert_eq!(
2370                background_color(&inline_properties(conn_left_of(row_of(&children[0])))),
2371                Some(TRANSPARENT_COLOR),
2372                "current={current}: leading edge"
2373            );
2374            assert_eq!(
2375                background_color(&inline_properties(conn_right_of(row_of(&children[2])))),
2376                Some(TRANSPARENT_COLOR),
2377                "current={current}: trailing edge"
2378            );
2379        }
2380    }
2381
2382    #[test]
2383    fn dom_ignores_a_total_steps_that_disagrees_with_the_label_count() {
2384        // Only `labels.len()` drives rendering; a stale `total_steps` must not
2385        // emit phantom cells or truncate real ones.
2386        let mut s = stepper(&["a", "b", "c"]);
2387        s.stepper_state.inner.total_steps = 99;
2388        let dom = s.dom();
2389        assert_eq!(dom.children.as_ref().len(), 3);
2390        assert_eq!(
2391            background_color(&inline_properties(conn_right_of(row_of(step_cell(&dom, 2))))),
2392            Some(TRANSPARENT_COLOR),
2393            "`last` must come from the rendered label count, not from total_steps"
2394        );
2395    }
2396
2397    #[test]
2398    fn dom_makes_every_step_clickable_and_keyboard_reachable() {
2399        let n = 3;
2400        let dom = Stepper::create(n_labels(n)).dom();
2401        for (i, cell) in dom.children.as_ref().iter().enumerate() {
2402            let cbs = cell.root.get_callbacks();
2403            assert_eq!(cbs.as_ref().len(), 1, "step {i}: exactly one handler");
2404            assert_eq!(cbs.as_ref()[0].event, EventFilter::Hover(HoverEventFilter::MouseUp));
2405            assert_eq!(cbs.as_ref()[0].callback.cb, on_step_click as usize);
2406            assert!(matches!(cbs.as_ref()[0].callback.ctx, OptionRefAny::None));
2407            assert_eq!(
2408                cell.root.get_tab_index(),
2409                Some(TabIndex::Auto),
2410                "step {i} must be tab-reachable"
2411            );
2412
2413            // Only the cell is clickable — a handler on an inner node would
2414            // resolve its index against the wrong sibling list.
2415            let row = row_of(cell);
2416            for (name, inner) in [
2417                ("row", row),
2418                ("conn-left", conn_left_of(row)),
2419                ("circle", circle_of(row)),
2420                ("conn-right", conn_right_of(row)),
2421                ("label", label_of(cell)),
2422            ] {
2423                assert!(
2424                    inner.root.get_callbacks().as_ref().is_empty(),
2425                    "step {i}: the {name} node must not carry its own handler"
2426                );
2427            }
2428        }
2429    }
2430
2431    #[test]
2432    fn dom_shares_one_state_refany_across_every_step() {
2433        // The handler resolves the clicked index from the DOM, so all cells *must*
2434        // observe the same state — a per-cell copy would let two steps believe
2435        // they are both current.
2436        let dom = Stepper::create(n_labels(4)).dom();
2437
2438        let mut first = step_state(&dom, 0);
2439        {
2440            let mut w = first
2441                .downcast_mut::<StepperStateWrapper>()
2442                .expect("step state must be a StepperStateWrapper");
2443            w.inner.current_step = 3;
2444        }
2445
2446        for i in 1..4 {
2447            let mut other = step_state(&dom, i);
2448            assert_eq!(current_step_of(&mut other), 3, "step {i} does not share step 0's state");
2449        }
2450    }
2451
2452    #[test]
2453    fn dom_carries_the_installed_callback_and_the_total_into_the_shared_state() {
2454        let dom = Stepper::create(n_labels(4))
2455            .with_current_step(2)
2456            .with_on_step_change(RefAny::new(0u8), cb(step_refresh_all))
2457            .dom();
2458        let mut state = step_state(&dom, 0);
2459        let wrapper = state.downcast_ref::<StepperStateWrapper>().expect("StepperStateWrapper");
2460
2461        assert_eq!(wrapper.inner, StepperState { current_step: 2, total_steps: 4 });
2462        let installed = wrapper.on_step_change.as_ref().expect("the callback must reach the DOM");
2463        assert_eq!(installed.callback.cb as usize, step_refresh_all as usize);
2464    }
2465
2466    #[test]
2467    fn dom_preserves_adversarial_labels_verbatim() {
2468        for s in adversarial_strings() {
2469            let dom = Stepper::create(labels(&[s.as_str(), "other"])).dom();
2470            let children = dom.children.as_ref();
2471            assert_eq!(children.len(), 2);
2472            match label_of(&children[0]).root.get_node_type() {
2473                NodeType::Text(t) => {
2474                    assert_eq!(t.as_ref().as_str(), s.as_str(), "the caption changed inside dom()");
2475                    assert_eq!(t.as_ref().len(), s.len(), "byte length changed (NUL truncation?)");
2476                }
2477                other => panic!("expected a text node, got {other:?}"),
2478            }
2479            // The circle number must not be affected by the caption next to it.
2480            assert_eq!(text_of(circle_of(row_of(&children[0]))), Some("1"));
2481        }
2482    }
2483
2484    #[test]
2485    fn dom_keeps_estimated_total_children_in_sync() {
2486        // `estimated_total_children` is a cached count; if it under-counts,
2487        // `convert_dom_into_compact_dom` under-allocates and panics.
2488        for n in [0usize, 1, 2, 3, 5, 64, 257] {
2489            let dom = Stepper::create(n_labels(n)).dom();
2490            assert_eq!(dom.children.as_ref().len(), n, "child count for n={n}");
2491            assert_eq!(
2492                dom.estimated_total_children,
2493                recursive_descendants(&dom),
2494                "cached descendant count desynced for n={n}"
2495            );
2496            assert_eq!(
2497                dom.estimated_total_children,
2498                NODES_PER_STEP * n,
2499                "a step must cost exactly {NODES_PER_STEP} nodes (n={n})"
2500            );
2501        }
2502    }
2503
2504    #[test]
2505    fn dom_of_many_steps_flattens_without_panicking() {
2506        let n = 512;
2507        let styled = StyledDom::create_from_dom(Stepper::create(n_labels(n)).dom());
2508        assert_eq!(
2509            styled.node_hierarchy.as_ref().len(),
2510            NODES_PER_STEP * n + 1,
2511            "root + six nodes per step"
2512        );
2513    }
2514
2515    #[test]
2516    fn flattened_layout_is_six_nodes_per_step() {
2517        // Pins the pre-order node numbering the click tests below depend on.
2518        let n = 4;
2519        let styled = StyledDom::create_from_dom(Stepper::create(n_labels(n)).dom());
2520        let data = styled.node_data.as_container();
2521        assert_eq!(styled.node_hierarchy.as_ref().len(), NODES_PER_STEP * n + 1);
2522
2523        assert!(data
2524            .get(NodeId::new(0))
2525            .expect("root")
2526            .has_class("__azul-native-stepper"));
2527
2528        for i in 0..n {
2529            for (idx, class) in [
2530                (cell_node(i), "__azul-native-stepper-step"),
2531                (row_node(i), "__azul-native-stepper-row"),
2532                (conn_left_node(i), "__azul-native-stepper-connector"),
2533                (circle_node(i), "__azul-native-stepper-circle"),
2534                (conn_right_node(i), "__azul-native-stepper-connector"),
2535                (label_node(i), "__azul-native-stepper-label"),
2536            ] {
2537                let nd = data
2538                    .get(NodeId::new(idx))
2539                    .unwrap_or_else(|| panic!("step {i}: node {idx} is missing"));
2540                assert!(nd.has_class(class), "step {i}: node {idx} is not a {class}");
2541            }
2542            let want = format!("{}", i + 1);
2543            match data.get(NodeId::new(circle_node(i))).expect("circle").get_node_type() {
2544                NodeType::Text(t) => {
2545                    assert_eq!(t.as_ref().as_str(), want.as_str(), "step {i}: circle number");
2546                }
2547                other => panic!("step {i}: the circle is not a text node: {other:?}"),
2548            }
2549        }
2550    }
2551
2552    #[test]
2553    fn dom_via_from_matches_dom_exactly() {
2554        let build = || Stepper::create(n_labels(4)).with_current_step(2);
2555        let via_into: Dom = build().into();
2556        let via_dom = build().dom();
2557
2558        assert_eq!(via_into.children.as_ref().len(), via_dom.children.as_ref().len());
2559        assert_eq!(via_into.estimated_total_children, via_dom.estimated_total_children);
2560        for i in 0..via_dom.children.as_ref().len() {
2561            let a = step_cell(&via_into, i);
2562            let b = step_cell(&via_dom, i);
2563            assert_eq!(inline_properties(a), inline_properties(b), "`From` diverges at cell {i}");
2564            assert_eq!(
2565                inline_properties(circle_of(row_of(a))),
2566                inline_properties(circle_of(row_of(b))),
2567                "`From` diverges at circle {i}"
2568            );
2569            assert_eq!(text_of(label_of(a)), text_of(label_of(b)));
2570        }
2571    }
2572
2573    #[test]
2574    fn dom_with_duplicate_labels_still_produces_distinct_positional_steps() {
2575        // Position, not caption, decides reached-ness.
2576        let dom = stepper(&["same", "same", "same"]).with_current_step(1).dom();
2577        for (i, cell) in dom.children.as_ref().iter().enumerate() {
2578            assert_eq!(text_of(label_of(cell)), Some("same"));
2579            assert_eq!(
2580                inline_properties(circle_of(row_of(cell))),
2581                properties(&circle_style(i <= 1)),
2582                "step {i}"
2583            );
2584            let want = format!("{}", i + 1);
2585            assert_eq!(
2586                text_of(circle_of(row_of(cell))),
2587                Some(want.as_str()),
2588                "step {i}: the number must still be positional"
2589            );
2590        }
2591    }
2592
2593    #[test]
2594    fn dom_gives_every_cell_the_shared_equal_share_style() {
2595        let dom = Stepper::create(n_labels(3)).dom();
2596        for (i, cell) in dom.children.as_ref().iter().enumerate() {
2597            let props = inline_properties(cell);
2598            assert_eq!(
2599                props,
2600                properties(&CssPropertyWithConditionsVec::from_const_slice(STEPPER_STEP_STYLE)),
2601                "cell {i} does not carry the shared step style"
2602            );
2603            assert_eq!(
2604                flex_grow_value(&props),
2605                Some(1.0),
2606                "cell {i}: steps must spread evenly across the row"
2607            );
2608            assert!(
2609                props.iter().any(|p| matches!(
2610                    p, CssProperty::FlexBasis(b)
2611                        if matches!(b.get_property(), Some(LayoutFlexBasis::Exact(pv))
2612                            if pv.metric == SizeMetric::Px && pv.number.get() == 0.0))),
2613                "cell {i}: flex-basis must be 0 so flex-grow alone decides the share"
2614            );
2615        }
2616    }
2617
2618    // ==================================================================
2619    // on_step_click
2620    // ==================================================================
2621
2622    #[test]
2623    fn click_moves_to_the_clicked_step_and_restyles_the_whole_row() {
2624        let n = 4;
2625        for clicked in 1..n {
2626            let (styled, state) = flatten(Stepper::create(n_labels(n)));
2627            let mut probe = state.clone();
2628
2629            let (update, changes) = run_click(Some(styled), node(cell_node(clicked)), state);
2630
2631            assert_eq!(
2632                update,
2633                Update::DoNothing,
2634                "with no on_step_change installed the handler reports nothing to redraw"
2635            );
2636            assert_eq!(current_step_of(&mut probe), clicked, "the stored step did not move");
2637            assert_eq!(
2638                restyle_writes(&changes),
2639                expected_restyle(n, clicked),
2640                "clicked={clicked}: the live restyle is wrong"
2641            );
2642        }
2643    }
2644
2645    #[test]
2646    fn click_restyle_agrees_with_a_freshly_built_dom() {
2647        // The live restyle and a full rebuild must not drift apart, or a click
2648        // followed by a `RefreshDom` would visibly change the widget twice.
2649        let n = 5;
2650        for clicked in 1..n {
2651            let (styled, state) = flatten(Stepper::create(n_labels(n)));
2652            let (_, changes) = run_click(Some(styled), node(cell_node(clicked)), state);
2653            let writes = restyle_writes(&changes);
2654            assert_eq!(writes.len(), 5 * n, "clicked={clicked}: five writes per step");
2655
2656            let rebuilt = Stepper::create(n_labels(n)).with_current_step(clicked).dom();
2657            for i in 0..n {
2658                let cell = step_cell(&rebuilt, i);
2659                let row = row_of(cell);
2660                let circle = inline_properties(circle_of(row));
2661                let label = inline_properties(label_of(cell));
2662
2663                assert_eq!(
2664                    writes[5 * i],
2665                    (circle_node(i), "bg", background_color(&circle).expect("circle bg")),
2666                    "clicked={clicked}: circle {i} background"
2667                );
2668                assert_eq!(
2669                    writes[5 * i + 1],
2670                    (circle_node(i), "text", text_color(&circle).expect("circle text")),
2671                    "clicked={clicked}: circle {i} number colour"
2672                );
2673                assert_eq!(
2674                    writes[5 * i + 2],
2675                    (
2676                        conn_left_node(i),
2677                        "bg",
2678                        background_color(&inline_properties(conn_left_of(row)))
2679                            .expect("left connector bg")
2680                    ),
2681                    "clicked={clicked}: left connector {i}"
2682                );
2683                assert_eq!(
2684                    writes[5 * i + 3],
2685                    (
2686                        conn_right_node(i),
2687                        "bg",
2688                        background_color(&inline_properties(conn_right_of(row)))
2689                            .expect("right connector bg")
2690                    ),
2691                    "clicked={clicked}: right connector {i}"
2692                );
2693                assert_eq!(
2694                    writes[5 * i + 4],
2695                    (label_node(i), "text", text_color(&label).expect("label text")),
2696                    "clicked={clicked}: label {i} colour"
2697                );
2698            }
2699        }
2700    }
2701
2702    #[test]
2703    fn click_on_the_already_current_step_is_a_complete_no_op() {
2704        // Documented: no state write, no callback, and — critically — no restyle,
2705        // so a repeated click cannot flicker the row.
2706        for current in 0..4usize {
2707            let mut log = RefAny::new(StepLog { seen: Vec::new() });
2708            let s = Stepper::create(n_labels(4))
2709                .with_current_step(current)
2710                .with_on_step_change(log.clone(), cb(record_step));
2711            let (styled, state) = flatten(s);
2712            let mut probe = state.clone();
2713
2714            let (update, changes) = run_click(Some(styled), node(cell_node(current)), state);
2715
2716            assert_eq!(update, Update::DoNothing, "current={current}");
2717            assert!(changes.is_empty(), "current={current}: a no-op click restyled the row");
2718            assert_eq!(current_step_of(&mut probe), current, "current={current}: state moved");
2719            assert!(logged(&mut log).is_empty(), "current={current}: the callback was invoked");
2720        }
2721    }
2722
2723    #[test]
2724    fn click_invokes_the_user_callback_with_the_updated_state() {
2725        let mut log = RefAny::new(StepLog { seen: Vec::new() });
2726        let s = Stepper::create(n_labels(4)).with_on_step_change(log.clone(), cb(record_step));
2727        let (styled, state) = flatten(s);
2728
2729        let (update, changes) = run_click(Some(styled), node(cell_node(2)), state.clone());
2730        assert_eq!(update, Update::RefreshDom, "the user's Update must propagate");
2731        assert_eq!(
2732            logged(&mut log),
2733            vec![StepperState { current_step: 2, total_steps: 4 }],
2734            "the callback must see the *new* step, with the total intact"
2735        );
2736        assert_eq!(restyle_writes(&changes).len(), 20, "the restyle must still run");
2737
2738        // A second click moves the shared state again — the step is not sticky.
2739        let (styled2, _) = flatten(Stepper::create(n_labels(4)));
2740        let (_, _) = run_click(Some(styled2), node(cell_node(1)), state.clone());
2741        assert_eq!(
2742            logged(&mut log),
2743            vec![
2744                StepperState { current_step: 2, total_steps: 4 },
2745                StepperState { current_step: 1, total_steps: 4 },
2746            ]
2747        );
2748
2749        let mut state = state;
2750        assert_eq!(current_step_of(&mut state), 1, "the state holds the *last* clicked step");
2751    }
2752
2753    #[test]
2754    fn click_propagates_every_update_variant_unchanged() {
2755        for (handler, expected) in [
2756            (cb(step_do_nothing), Update::DoNothing),
2757            (cb(step_refresh_all), Update::RefreshDomAllWindows),
2758        ] {
2759            let s = Stepper::create(n_labels(2)).with_on_step_change(RefAny::new(0u8), handler);
2760            let (styled, state) = flatten(s);
2761            let (update, changes) = run_click(Some(styled), node(cell_node(1)), state);
2762            assert_eq!(update, expected);
2763            assert_eq!(
2764                restyle_writes(&changes).len(),
2765                10,
2766                "the restyle runs regardless of what the user returns"
2767            );
2768        }
2769    }
2770
2771    #[test]
2772    fn click_restyles_even_without_a_user_callback() {
2773        let (styled, state) = flatten(stepper(&["a", "b"]));
2774        let (update, changes) = run_click(Some(styled), node(cell_node(1)), state);
2775
2776        assert_eq!(update, Update::DoNothing);
2777        assert_eq!(
2778            restyle_writes(&changes),
2779            expected_restyle(2, 1),
2780            "progress feedback must not depend on the user wiring a callback"
2781        );
2782    }
2783
2784    #[test]
2785    fn click_on_a_single_step_stepper_stays_at_zero() {
2786        let (styled, state) = flatten(stepper(&["only"]));
2787        let mut probe = state.clone();
2788        let (update, changes) = run_click(Some(styled), node(cell_node(0)), state);
2789
2790        assert_eq!(update, Update::DoNothing);
2791        assert!(changes.is_empty(), "a one-step stepper has nothing to restyle");
2792        assert_eq!(current_step_of(&mut probe), 0);
2793    }
2794
2795    #[test]
2796    fn click_can_walk_backwards() {
2797        // Free navigation: a stepper is not a one-way ratchet, so clicking an
2798        // earlier step must un-fill the track behind it.
2799        let n = 5;
2800        let (styled, state) = flatten(Stepper::create(n_labels(n)).with_current_step(4));
2801        let mut probe = state.clone();
2802
2803        let (_, changes) = run_click(Some(styled), node(cell_node(1)), state);
2804
2805        assert_eq!(current_step_of(&mut probe), 1);
2806        assert_eq!(restyle_writes(&changes), expected_restyle(n, 1));
2807        // Steps 2..4 must have been actively reset, not merely left alone.
2808        assert!(
2809            restyle_writes(&changes)
2810                .contains(&(circle_node(4), "bg", MUTED_CIRCLE_COLOR)),
2811            "walking back left a stale accent circle behind"
2812        );
2813    }
2814
2815    #[test]
2816    fn click_on_the_root_node_does_nothing() {
2817        // The container has no parent -> the handler must bail, not index into nothing.
2818        let (styled, state) = flatten(stepper(&["a", "b"]));
2819        let mut probe = state.clone();
2820
2821        let (update, changes) = run_click(Some(styled), node(0), state);
2822
2823        assert_eq!(update, Update::DoNothing);
2824        assert!(changes.is_empty(), "a parentless hit pushed a DOM change");
2825        assert_eq!(current_step_of(&mut probe), 0, "the state must be untouched");
2826    }
2827
2828    #[test]
2829    fn click_on_a_stale_or_absent_node_does_nothing() {
2830        // Stale hit ids reach callbacks after a DOM mutation, and
2831        // `set_css_property` *panics* on a None node id — so the handler has to
2832        // bail out well before the restyle loop.
2833        for hit in [node(9999), node(usize::MAX - 1), node_none()] {
2834            let (styled, state) = flatten(Stepper::create(n_labels(3)).with_current_step(1));
2835            let mut probe = state.clone();
2836
2837            let (update, changes) = run_click(Some(styled), hit, state);
2838
2839            assert_eq!(update, Update::DoNothing, "{hit:?}: a stale hit was acted on");
2840            assert!(changes.is_empty(), "{hit:?}: a stale hit pushed a DOM change");
2841            assert_eq!(current_step_of(&mut probe), 1, "{hit:?}: a stale hit moved the step");
2842        }
2843    }
2844
2845    #[test]
2846    fn click_with_no_layout_result_does_nothing() {
2847        let dom = stepper(&["a", "b"]).dom();
2848        let state = step_state(&dom, 0);
2849
2850        let (update, changes) = run_click(None, node(cell_node(1)), state);
2851
2852        assert_eq!(
2853            update,
2854            Update::DoNothing,
2855            "an empty LayoutWindow must be handled, not unwrapped"
2856        );
2857        assert!(changes.is_empty());
2858    }
2859
2860    #[test]
2861    fn click_with_a_foreign_payload_does_nothing_and_leaves_it_intact() {
2862        // The handler downcasts blind; a foreign RefAny must bail out, not
2863        // reinterpret the bytes as a StepperStateWrapper.
2864        let (styled, _) = flatten(stepper(&["a", "b"]));
2865        let foreign = RefAny::new(0xDEAD_BEEF_u32);
2866
2867        let (update, changes) = run_click(Some(styled), node(cell_node(1)), foreign.clone());
2868
2869        assert_eq!(update, Update::DoNothing);
2870        assert!(
2871            changes.is_empty(),
2872            "a failed downcast must not leave a half-applied restyle"
2873        );
2874        let mut foreign = foreign;
2875        assert_eq!(
2876            *foreign.downcast_ref::<u32>().expect("the foreign payload was reinterpreted"),
2877            0xDEAD_BEEF,
2878            "the handler corrupted a RefAny it did not understand"
2879        );
2880    }
2881
2882    #[test]
2883    fn click_with_the_state_already_borrowed_does_nothing() {
2884        let (styled, state) = flatten(stepper(&["a", "b"]));
2885
2886        // A live mutable borrow on a sibling clone: the `downcast_ref` inside the
2887        // handler must fail (returning DoNothing) instead of aliasing `&mut`.
2888        let mut held = state.clone();
2889        let guard = held.downcast_mut::<StepperStateWrapper>().expect("first borrow succeeds");
2890
2891        let (update, changes) = run_click(Some(styled), node(cell_node(1)), state);
2892
2893        assert_eq!(update, Update::DoNothing);
2894        assert!(changes.is_empty(), "the handler restyled after failing to read the state");
2895        drop(guard);
2896    }
2897
2898    #[test]
2899    fn click_holds_the_state_borrow_across_the_user_callback() {
2900        // The handler invokes the user callback while its own `downcast_mut` on
2901        // the state is still live. A user callback that re-enters the *same*
2902        // state `RefAny` is therefore refused — it must get `None` back rather
2903        // than a second aliasing borrow (or a panic).
2904        //
2905        // NOTE: probe <-> state form a RefAny reference cycle, so this fixture
2906        // leaks. That is deliberate and harmless for a single test.
2907        let mut probe = RefAny::new(ReentrantProbe {
2908            state: RefAny::new(0u8),
2909            saw_step: Some(usize::MAX),
2910            calls: 0,
2911        });
2912        let s = Stepper::create(n_labels(3))
2913            .with_on_step_change(probe.clone(), cb(probe_state_reentrantly));
2914        let (styled, state) = flatten(s);
2915        {
2916            let mut p = probe.downcast_mut::<ReentrantProbe>().expect("probe");
2917            p.state = state.clone();
2918        }
2919
2920        let (update, changes) = run_click(Some(styled), node(cell_node(2)), state);
2921
2922        assert_eq!(update, Update::DoNothing);
2923        assert_eq!(restyle_writes(&changes).len(), 15, "the restyle must still run");
2924
2925        let p = probe.downcast_ref::<ReentrantProbe>().expect("probe");
2926        assert_eq!(p.calls, 1, "the user callback must run exactly once");
2927        assert_eq!(
2928            p.saw_step, None,
2929            "a re-entrant read of the state must be refused, not aliased"
2930        );
2931    }
2932
2933    #[test]
2934    fn a_hit_inside_a_cell_stays_memory_safe() {
2935        // The handler documents `currentTarget` semantics: only the step cells
2936        // carry the callback, so the hit node is always a cell. Should an inner
2937        // node ever reach it anyway, it must stay memory-safe and push no
2938        // half-finished restyle — the sibling walk simply finds no cells to
2939        // update. (Documenting the actual resolution, not endorsing it: an inner
2940        // hit resolves against *its own* siblings, so it can move the stored step
2941        // without any visual feedback.)
2942        for (hit, expected_step) in [
2943            (row_node(0), 0usize),      // row is child 0 of its cell -> no change
2944            (row_node(1), 0),           // ditto, whichever cell it belongs to
2945            (conn_left_node(0), 0),     // connector-left is child 0 of its row
2946            (circle_node(0), 1),        // circle is child 1 of its row
2947            (conn_right_node(0), 2),    // connector-right is child 2 of its row
2948        ] {
2949            let (styled, state) = flatten(Stepper::create(n_labels(3)));
2950            let mut probe = state.clone();
2951
2952            let (update, changes) = run_click(Some(styled), node(hit), state);
2953
2954            assert_eq!(update, Update::DoNothing, "node {hit}");
2955            assert!(changes.is_empty(), "node {hit}: an inner hit pushed a partial restyle");
2956            assert_eq!(current_step_of(&mut probe), expected_step, "node {hit}");
2957        }
2958    }
2959
2960    #[test]
2961    fn many_clicks_keep_the_state_and_the_restyle_in_agreement() {
2962        // A drift between the stored step and the pushed colours is exactly the
2963        // class of bug that makes a wizard render a step it does not hold.
2964        let n = 5;
2965        let (_, state) = flatten(Stepper::create(n_labels(n)));
2966
2967        for click in 0..60usize {
2968            // Never click the current step twice in a row — that is a documented
2969            // no-op and would push no changes at all.
2970            let expected = (click * 2 + 1) % n;
2971            let mut probe = state.clone();
2972            if current_step_of(&mut probe) == expected {
2973                continue;
2974            }
2975
2976            let (styled, _) = flatten(Stepper::create(n_labels(n)));
2977            let (_, changes) = run_click(Some(styled), node(cell_node(expected)), state.clone());
2978
2979            assert_eq!(
2980                current_step_of(&mut probe),
2981                expected,
2982                "click #{click}: the stored step drifted"
2983            );
2984            assert_eq!(
2985                restyle_writes(&changes),
2986                expected_restyle(n, expected),
2987                "click #{click}: the pushed colours disagree with the stored step"
2988            );
2989        }
2990    }
2991
2992    #[test]
2993    fn click_never_touches_total_steps() {
2994        let n = 4;
2995        let (styled, state) = flatten(Stepper::create(n_labels(n)));
2996        let (_, _) = run_click(Some(styled), node(cell_node(3)), state.clone());
2997
2998        let mut state = state;
2999        let wrapper = state.downcast_ref::<StepperStateWrapper>().expect("StepperStateWrapper");
3000        assert_eq!(
3001            wrapper.inner,
3002            StepperState { current_step: 3, total_steps: n },
3003            "a click must move the current step and nothing else"
3004        );
3005    }
3006}