1
//! Switch (toggle) widget — a boolean on/off control rendered as a rounded,
2
//! pill-shaped "track" with a sliding circular "knob". A near-clone of
3
//! [`crate::widgets::check_box::CheckBox`] (boolean state + an `on_toggle`
4
//! callback) restyled as a switch: toggling flips the knob's horizontal
5
//! position (via `margin-left`) and the track's background colour.
6
//!
7
//! Key types: [`Switch`], [`SwitchState`], [`SwitchOnToggle`].
8

            
9
use azul_core::{
10
    callbacks::{CoreCallbackData, Update},
11
    dom::{Dom, IdOrClass, IdOrClass::Class, IdOrClassVec, TabIndex},
12
    refany::RefAny,
13
};
14
use azul_css::dynamic_selector::{CssPropertyWithConditions, CssPropertyWithConditionsVec};
15
use azul_css::{
16
    props::{
17
        basic::{color::ColorU, *},
18
        layout::{LayoutDisplay, LayoutFlexDirection, LayoutAlignItems, LayoutAlignSelf, LayoutFlexGrow, LayoutWidth, LayoutHeight, LayoutPaddingLeft, LayoutPaddingRight, LayoutPaddingTop, LayoutPaddingBottom, LayoutMarginLeft},
19
        property::{CssProperty, *},
20
        style::{StyleBackgroundContent, StyleBackgroundContentVec, StyleBorderTopLeftRadius, StyleBorderTopRightRadius, StyleBorderBottomLeftRadius, StyleBorderBottomRightRadius, StyleCursor},
21
    },
22
    impl_option_inner, AzString,
23
};
24

            
25
use crate::callbacks::{Callback, CallbackInfo};
26

            
27
static SWITCH_TRACK_CLASS: &[IdOrClass] =
28
    &[Class(AzString::from_const_str("__azul-native-switch"))];
29
static SWITCH_KNOB_CLASS: &[IdOrClass] =
30
    &[Class(AzString::from_const_str("__azul-native-switch-knob"))];
31

            
32
/// Callback function type invoked when the switch is toggled.
33
pub type SwitchOnToggleCallbackType = extern "C" fn(RefAny, CallbackInfo, SwitchState) -> Update;
34
impl_widget_callback!(
35
    SwitchOnToggle,
36
    OptionSwitchOnToggle,
37
    SwitchOnToggleCallback,
38
    SwitchOnToggleCallbackType
39
);
40

            
41
azul_core::impl_managed_callback! {
42
    wrapper:        SwitchOnToggleCallback,
43
    info_ty:        CallbackInfo,
44
    return_ty:      Update,
45
    default_ret:    Update::DoNothing,
46
    invoker_static: SWITCH_ON_TOGGLE_INVOKER,
47
    invoker_ty:     AzSwitchOnToggleCallbackInvoker,
48
    thunk_fn:       az_switch_on_toggle_callback_thunk,
49
    setter_fn:      AzApp_setSwitchOnToggleCallbackInvoker,
50
    from_handle_fn: AzSwitchOnToggleCallback_createFromHostHandle,
51
    extra_args:     [ state: SwitchState ],
52
}
53

            
54
/// A toggleable on/off switch widget with a sliding knob and toggle callback.
55
#[derive(Debug, Clone, PartialEq, Eq)]
56
#[repr(C)]
57
pub struct Switch {
58
    pub switch_state: SwitchStateWrapper,
59
    /// Style for the switch track (the pill-shaped container)
60
    pub track_style: CssPropertyWithConditionsVec,
61
    /// Style for the sliding knob
62
    pub knob_style: CssPropertyWithConditionsVec,
63
}
64

            
65
#[derive(Debug, Default, Clone, PartialEq, Eq)]
66
#[repr(C)]
67
pub struct SwitchStateWrapper {
68
    /// On/off state of this Switch
69
    pub inner: SwitchState,
70
    /// Optional: function to call when the Switch is toggled
71
    pub on_toggle: OptionSwitchOnToggle,
72
}
73

            
74
/// The on/off state of a [`Switch`].
75
#[derive(Copy, Debug, Default, Clone, PartialEq, Eq)]
76
#[repr(C)]
77
pub struct SwitchState {
78
    /// `true` = on (knob slid right), `false` = off (knob at left)
79
    pub checked: bool,
80
}
81

            
82
// ---- dimensions ----
83
const TRACK_WIDTH: isize = 36;
84
const TRACK_HEIGHT: isize = 20;
85
const TRACK_PADDING: isize = 2;
86
const TRACK_RADIUS: isize = 10;
87
const KNOB_SIZE: isize = 16;
88
const KNOB_RADIUS: isize = 8;
89
/// Horizontal travel of the knob = `track_width` − 2·padding − `knob_size`.
90
const KNOB_TRAVEL: isize = TRACK_WIDTH - (2 * TRACK_PADDING) - KNOB_SIZE;
91

            
92
// ---- colours ----
93
const TRACK_OFF_COLOR: ColorU = ColorU {
94
    r: 204,
95
    g: 204,
96
    b: 204,
97
    a: 255,
98
}; // #cccccc
99
const TRACK_ON_COLOR: ColorU = ColorU {
100
    r: 76,
101
    g: 217,
102
    b: 100,
103
    a: 255,
104
}; // #4cd964
105
const KNOB_COLOR: ColorU = ColorU {
106
    r: 255,
107
    g: 255,
108
    b: 255,
109
    a: 255,
110
}; // white
111

            
112
const TRACK_OFF_BG_ITEMS: &[StyleBackgroundContent] =
113
    &[StyleBackgroundContent::Color(TRACK_OFF_COLOR)];
114
const TRACK_OFF_BG: StyleBackgroundContentVec =
115
    StyleBackgroundContentVec::from_const_slice(TRACK_OFF_BG_ITEMS);
116
const TRACK_ON_BG_ITEMS: &[StyleBackgroundContent] =
117
    &[StyleBackgroundContent::Color(TRACK_ON_COLOR)];
118
const TRACK_ON_BG: StyleBackgroundContentVec =
119
    StyleBackgroundContentVec::from_const_slice(TRACK_ON_BG_ITEMS);
120
const KNOB_BG_ITEMS: &[StyleBackgroundContent] = &[StyleBackgroundContent::Color(KNOB_COLOR)];
121
const KNOB_BG: StyleBackgroundContentVec =
122
    StyleBackgroundContentVec::from_const_slice(KNOB_BG_ITEMS);
123

            
124
/// Build the track (pill container) style. Background colour is the only
125
/// state-dependent property, so the style is built at runtime per the recipe's
126
/// "runtime vec if param-dependent" path.
127
149
fn build_track_style(checked: bool) -> CssPropertyWithConditionsVec {
128
149
    let bg = if checked { TRACK_ON_BG } else { TRACK_OFF_BG };
129
149
    CssPropertyWithConditionsVec::from_vec(alloc::vec![
130
149
        CssPropertyWithConditions::simple(CssProperty::const_display(LayoutDisplay::Flex)),
131
149
        CssPropertyWithConditions::simple(CssProperty::const_flex_direction(
132
149
            LayoutFlexDirection::Row,
133
        )),
134
149
        CssPropertyWithConditions::simple(CssProperty::const_align_items(LayoutAlignItems::Center)),
135
149
        CssPropertyWithConditions::simple(CssProperty::align_self(LayoutAlignSelf::Center)),
136
149
        CssPropertyWithConditions::simple(CssProperty::const_flex_grow(LayoutFlexGrow::const_new(
137
            0,
138
        ))),
139
149
        CssPropertyWithConditions::simple(CssProperty::const_width(LayoutWidth::const_px(
140
            TRACK_WIDTH,
141
        ))),
142
149
        CssPropertyWithConditions::simple(CssProperty::const_height(LayoutHeight::const_px(
143
            TRACK_HEIGHT,
144
        ))),
145
149
        CssPropertyWithConditions::simple(CssProperty::const_padding_left(
146
149
            LayoutPaddingLeft::const_px(TRACK_PADDING),
147
        )),
148
149
        CssPropertyWithConditions::simple(CssProperty::const_padding_right(
149
149
            LayoutPaddingRight::const_px(TRACK_PADDING),
150
        )),
151
149
        CssPropertyWithConditions::simple(CssProperty::const_padding_top(
152
149
            LayoutPaddingTop::const_px(TRACK_PADDING),
153
        )),
154
149
        CssPropertyWithConditions::simple(CssProperty::const_padding_bottom(
155
149
            LayoutPaddingBottom::const_px(TRACK_PADDING),
156
        )),
157
149
        CssPropertyWithConditions::simple(CssProperty::const_border_top_left_radius(
158
149
            StyleBorderTopLeftRadius::const_px(TRACK_RADIUS),
159
        )),
160
149
        CssPropertyWithConditions::simple(CssProperty::const_border_top_right_radius(
161
149
            StyleBorderTopRightRadius::const_px(TRACK_RADIUS),
162
        )),
163
149
        CssPropertyWithConditions::simple(CssProperty::const_border_bottom_left_radius(
164
149
            StyleBorderBottomLeftRadius::const_px(TRACK_RADIUS),
165
        )),
166
149
        CssPropertyWithConditions::simple(CssProperty::const_border_bottom_right_radius(
167
149
            StyleBorderBottomRightRadius::const_px(TRACK_RADIUS),
168
        )),
169
149
        CssPropertyWithConditions::simple(CssProperty::const_cursor(StyleCursor::Pointer)),
170
149
        CssPropertyWithConditions::simple(CssProperty::const_background_content(bg)),
171
    ])
172
149
}
173

            
174
/// Build the knob style. The knob's `margin-left` is the state-dependent
175
/// property that slides it between the off (left) and on (right) positions.
176
151
fn build_knob_style(checked: bool) -> CssPropertyWithConditionsVec {
177
151
    let margin = if checked { KNOB_TRAVEL } else { 0 };
178
151
    CssPropertyWithConditionsVec::from_vec(alloc::vec![
179
151
        CssPropertyWithConditions::simple(CssProperty::const_width(LayoutWidth::const_px(
180
            KNOB_SIZE,
181
        ))),
182
151
        CssPropertyWithConditions::simple(CssProperty::const_height(LayoutHeight::const_px(
183
            KNOB_SIZE,
184
        ))),
185
151
        CssPropertyWithConditions::simple(CssProperty::const_flex_grow(LayoutFlexGrow::const_new(
186
            0,
187
        ))),
188
151
        CssPropertyWithConditions::simple(CssProperty::const_border_top_left_radius(
189
151
            StyleBorderTopLeftRadius::const_px(KNOB_RADIUS),
190
        )),
191
151
        CssPropertyWithConditions::simple(CssProperty::const_border_top_right_radius(
192
151
            StyleBorderTopRightRadius::const_px(KNOB_RADIUS),
193
        )),
194
151
        CssPropertyWithConditions::simple(CssProperty::const_border_bottom_left_radius(
195
151
            StyleBorderBottomLeftRadius::const_px(KNOB_RADIUS),
196
        )),
197
151
        CssPropertyWithConditions::simple(CssProperty::const_border_bottom_right_radius(
198
151
            StyleBorderBottomRightRadius::const_px(KNOB_RADIUS),
199
        )),
200
151
        CssPropertyWithConditions::simple(CssProperty::const_background_content(KNOB_BG)),
201
151
        CssPropertyWithConditions::simple(CssProperty::const_margin_left(
202
151
            LayoutMarginLeft::const_px(margin),
203
        )),
204
    ])
205
151
}
206

            
207
impl Switch {
208
    /// Creates a new switch in the given on/off state with default styling.
209
127
    #[must_use] pub fn create(checked: bool) -> Self {
210
127
        Self {
211
127
            switch_state: SwitchStateWrapper {
212
127
                inner: SwitchState { checked },
213
127
                ..Default::default()
214
127
            },
215
127
            track_style: build_track_style(checked),
216
127
            knob_style: build_knob_style(checked),
217
127
        }
218
127
    }
219

            
220
    #[inline]
221
5
    #[must_use] pub fn swap_with_default(&mut self) -> Self {
222
5
        let mut s = Self::create(false);
223
5
        core::mem::swap(&mut s, self);
224
5
        s
225
5
    }
226

            
227
    #[inline]
228
15
    pub fn set_on_toggle<C: Into<SwitchOnToggleCallback>>(&mut self, data: RefAny, on_toggle: C) {
229
15
        self.switch_state.on_toggle = Some(SwitchOnToggle {
230
15
            callback: on_toggle.into(),
231
15
            refany: data,
232
15
        })
233
15
        .into();
234
15
    }
235

            
236
    #[inline]
237
9
    #[must_use] pub fn with_on_toggle<C: Into<SwitchOnToggleCallback>>(
238
9
        mut self,
239
9
        data: RefAny,
240
9
        on_toggle: C,
241
9
    ) -> Self {
242
9
        self.set_on_toggle(data, on_toggle);
243
9
        self
244
9
    }
245

            
246
    #[inline]
247
86
    #[must_use] pub fn dom(self) -> Dom {
248
        use azul_core::{
249
            callbacks::{CoreCallback, CoreCallbackData},
250
            dom::{Dom, EventFilter, HoverEventFilter},
251
        };
252

            
253
86
        Dom::create_div()
254
86
            .with_ids_and_classes(IdOrClassVec::from(SWITCH_TRACK_CLASS))
255
86
            .with_css_props(self.track_style)
256
86
            .with_callbacks(
257
86
                vec![CoreCallbackData {
258
86
                    event: EventFilter::Hover(HoverEventFilter::MouseUp),
259
86
                    callback: CoreCallback {
260
86
                        cb: input::default_on_switch_clicked as usize,
261
86
                        ctx: azul_core::refany::OptionRefAny::None,
262
86
                    },
263
86
                    refany: RefAny::new(self.switch_state),
264
86
                }]
265
86
                .into(),
266
            )
267
86
            .with_tab_index(TabIndex::Auto)
268
86
            .with_children(
269
86
                vec![Dom::create_div()
270
86
                    .with_ids_and_classes(IdOrClassVec::from(SWITCH_KNOB_CLASS))
271
86
                    .with_css_props(self.knob_style)]
272
86
                .into(),
273
            )
274
86
    }
275
}
276

            
277
impl Default for Switch {
278
2
    fn default() -> Self {
279
2
        Self::create(false)
280
2
    }
281
}
282

            
283
// handle input events for the switch
284
mod input {
285

            
286
    use azul_core::{callbacks::Update, refany::RefAny};
287
    use azul_css::props::{layout::LayoutMarginLeft, property::CssProperty};
288

            
289
    use super::{SwitchOnToggle, SwitchStateWrapper, KNOB_TRAVEL, TRACK_OFF_BG, TRACK_ON_BG};
290
    use crate::callbacks::CallbackInfo;
291

            
292
68
    pub(super) extern "C" fn default_on_switch_clicked(
293
68
        mut switch: RefAny,
294
68
        mut info: CallbackInfo,
295
68
    ) -> Update {
296
68
        let Some(mut switch) = switch.downcast_mut::<SwitchStateWrapper>() else {
297
1
            return Update::DoNothing;
298
        };
299

            
300
67
        let track_id = info.get_hit_node();
301
67
        let Some(knob_id) = info.get_first_child(track_id) else {
302
6
            return Update::DoNothing;
303
        };
304

            
305
61
        switch.inner.checked = !switch.inner.checked;
306

            
307
61
        let result = {
308
            // rustc doesn't understand the borrowing lifetime here
309
61
            let switch = &mut *switch;
310
61
            let on_toggle = &mut switch.on_toggle;
311
61
            let inner = switch.inner;
312

            
313
61
            match on_toggle.as_mut() {
314
                Some(SwitchOnToggle {
315
3
                    callback,
316
3
                    refany: data,
317
3
                }) => (callback.cb)(data.clone(), info, inner),
318
58
                None => Update::DoNothing,
319
            }
320
        };
321

            
322
        // CallbackInfo is Copy, so `info` is still usable after the call above.
323
61
        if switch.inner.checked {
324
32
            info.set_css_property(track_id, CssProperty::const_background_content(TRACK_ON_BG));
325
32
            info.set_css_property(
326
32
                knob_id,
327
32
                CssProperty::const_margin_left(LayoutMarginLeft::const_px(KNOB_TRAVEL)),
328
32
            );
329
32
        } else {
330
29
            info.set_css_property(track_id, CssProperty::const_background_content(TRACK_OFF_BG));
331
29
            info.set_css_property(
332
29
                knob_id,
333
29
                CssProperty::const_margin_left(LayoutMarginLeft::const_px(0)),
334
29
            );
335
29
        }
336

            
337
61
        result
338
68
    }
339
}
340

            
341
impl From<Switch> for Dom {
342
2
    fn from(s: Switch) -> Self {
343
2
        s.dom()
344
2
    }
345
}
346

            
347
#[cfg(test)]
348
#[allow(clippy::float_cmp)] // every float here is an exact, integral px constant
349
// `assertions_on_constants`: these are deliberate invariant guards over sibling
350
// `const`s in this module. They are const-foldable *today*, which is exactly the
351
// point — they must go red the moment someone edits one of those constants into an
352
// inconsistent value. Deleting them (clippy's suggestion) would delete the check.
353
#[allow(clippy::assertions_on_constants)]
354
mod autotest_generated {
355
    use std::{
356
        collections::{BTreeMap, HashMap},
357
        mem::discriminant,
358
        sync::{Arc, Mutex},
359
    };
360

            
361
    use azul_core::{
362
        dom::{DomId, DomNodeId, EventFilter, HoverEventFilter, NodeId, NodeType},
363
        geom::{LogicalRect, OptionLogicalPosition},
364
        gl::OptionGlContextPtr,
365
        hit_test::ScrollPosition,
366
        refany::OptionRefAny,
367
        resources::RendererResources,
368
        styled_dom::{NodeHierarchyItemId, StyledDom},
369
        window::{MonitorVec, RawWindowHandle},
370
    };
371
    use azul_css::props::basic::{length::SizeMetric, pixel::PixelValue};
372
    use rust_fontconfig::FcFontCache;
373

            
374
    use super::*;
375
    #[cfg(feature = "icu")]
376
    use crate::icu::IcuLocalizerHandle;
377
    use crate::{
378
        callbacks::{CallbackChange, CallbackInfoRefData, ExternalSystemCallbacks},
379
        solver3::{display_list::DisplayList, layout_tree::LayoutTree},
380
        window::{DomLayoutResult, LayoutWindow},
381
        window_state::FullWindowState,
382
    };
383

            
384
    // ------------------------------------------------------------------
385
    // Geometry, spelled out independently of the module's own constants
386
    // ------------------------------------------------------------------
387
    //
388
    // These literals are deliberately *not* derived from `TRACK_WIDTH` & friends:
389
    // they are the numbers a designer signed off on. If a constant upstream drifts,
390
    // the relation tests below fail instead of silently re-deriving themselves.
391

            
392
    const TRACK_W: f32 = 36.0;
393
    const TRACK_H: f32 = 20.0;
394
    const PAD: f32 = 2.0;
395
    const TRACK_R: f32 = 10.0;
396
    const KNOB: f32 = 16.0;
397
    const KNOB_R: f32 = 8.0;
398
    /// `36 - 2*2 - 16`
399
    const TRAVEL: f32 = 16.0;
400

            
401
    /// Flattened node ids of `Switch::dom()` (pre-order).
402
    const TRACK: usize = 0;
403
    const KNOB_NODE: usize = 1;
404

            
405
    // ------------------------------------------------------------------
406
    // Callback harness
407
    // ------------------------------------------------------------------
408

            
409
    /// A `DomNodeId` in the root DOM pointing at flattened node `idx`.
410
    fn node(idx: usize) -> DomNodeId {
411
        DomNodeId {
412
            dom: DomId::ROOT_ID,
413
            node: NodeHierarchyItemId::from_crate_internal(Some(NodeId::new(idx))),
414
        }
415
    }
416

            
417
    /// A `DomNodeId` whose node component is `None` — the "no concrete node was hit"
418
    /// case. `CallbackInfo::set_css_property` *panics* on such an id, so the handler
419
    /// must bail out before ever reaching it.
420
    fn node_none() -> DomNodeId {
421
        DomNodeId {
422
            dom: DomId::ROOT_ID,
423
            node: NodeHierarchyItemId::NONE,
424
        }
425
    }
426

            
427
    /// A `DomLayoutResult` carrying only a `styled_dom`: the switch handler reaches
428
    /// exactly two `CallbackInfo` queries (`get_hit_node`, `get_first_child`), and both
429
    /// read the node hierarchy only — no real layout (and no font) is needed.
430
    fn layout_result(styled_dom: StyledDom) -> DomLayoutResult {
431
        DomLayoutResult {
432
            styled_dom,
433
            layout_tree: LayoutTree {
434
                nodes: Vec::new(),
435
                warm: Vec::new(),
436
                cold: Vec::new(),
437
                root: 0,
438
                dom_to_layout: BTreeMap::new(),
439
                children_arena: Vec::new(),
440
                children_offsets: Vec::new(),
441
                subtree_needs_intrinsic: Vec::new(),
442
            },
443
            calculated_positions: Vec::new(),
444
            viewport: LogicalRect::zero(),
445
            display_list: Arc::new(DisplayList::default()),
446
            scroll_ids: HashMap::new(),
447
            scroll_id_to_node_id: HashMap::new(),
448
        }
449
    }
450

            
451
    /// Runs `f` with a `CallbackInfo` whose window holds `styled_dom` as the root DOM
452
    /// and whose hit node is `hit`. Returns `f`'s value plus every change the callback
453
    /// pushed onto the transaction log.
454
    fn with_info<R>(
455
        styled_dom: StyledDom,
456
        hit: DomNodeId,
457
        f: impl FnOnce(&mut CallbackInfo) -> R,
458
    ) -> (R, Vec<CallbackChange>) {
459
        let mut layout_window =
460
            LayoutWindow::new(FcFontCache::default()).expect("LayoutWindow::new failed");
461
        layout_window
462
            .layout_results
463
            .insert(DomId::ROOT_ID, layout_result(styled_dom));
464

            
465
        let renderer_resources = RendererResources::default();
466
        let previous_window_state: Option<FullWindowState> = None;
467
        let current_window_state = FullWindowState::default();
468
        let gl_context = OptionGlContextPtr::None;
469
        let scroll_states: BTreeMap<DomId, BTreeMap<NodeHierarchyItemId, ScrollPosition>> =
470
            BTreeMap::new();
471
        let window_handle = RawWindowHandle::Unsupported;
472
        let system_callbacks = ExternalSystemCallbacks::rust_internal();
473

            
474
        let ref_data = CallbackInfoRefData {
475
            layout_window: &layout_window,
476
            renderer_resources: &renderer_resources,
477
            previous_window_state: &previous_window_state,
478
            current_window_state: &current_window_state,
479
            gl_context: &gl_context,
480
            current_scroll_manager: &scroll_states,
481
            current_window_handle: &window_handle,
482
            system_callbacks: &system_callbacks,
483
            system_style: Arc::new(azul_css::system::SystemStyle::default()),
484
            monitors: Arc::new(Mutex::new(MonitorVec::from_const_slice(&[]))),
485
            #[cfg(feature = "icu")]
486
            icu_localizer: IcuLocalizerHandle::default(),
487
            ctx: OptionRefAny::None,
488
        };
489

            
490
        let changes: Arc<Mutex<Vec<CallbackChange>>> = Arc::new(Mutex::new(Vec::new()));
491

            
492
        let mut info = CallbackInfo::new(
493
            &ref_data,
494
            &changes,
495
            hit,
496
            OptionLogicalPosition::None,
497
            OptionLogicalPosition::None,
498
        );
499

            
500
        let r = f(&mut info);
501
        let pushed = info.take_changes();
502
        (r, pushed)
503
    }
504

            
505
    /// Renders `switch`, then hands back both the laid-out DOM *and* the very `RefAny`
506
    /// the widget registered on its own mouse-up callback. Driving the handler with
507
    /// these two is the real wiring — nothing is re-created by hand, so a mismatch
508
    /// between what `dom()` stores and what the handler expects cannot hide behind the
509
    /// fixture.
510
    fn laid_out(switch: Switch) -> (StyledDom, RefAny) {
511
        let dom = switch.dom();
512
        let state = dom.root.callbacks.as_ref()[0].refany.clone();
513
        (StyledDom::create_from_dom(dom), state)
514
    }
515

            
516
    /// One "mouse-up on `hit`" delivered to the widget's own registered handler.
517
    fn click(
518
        styled_dom: StyledDom,
519
        state: &RefAny,
520
        hit: DomNodeId,
521
    ) -> (Update, Vec<CallbackChange>) {
522
        with_info(styled_dom, hit, |info| {
523
            input::default_on_switch_clicked(state.clone(), *info)
524
        })
525
    }
526

            
527
    fn is_checked(state: &RefAny) -> bool {
528
        let mut state = state.clone();
529
        let wrapper = state
530
            .downcast_ref::<SwitchStateWrapper>()
531
            .expect("the widget state changed type");
532
        wrapper.inner.checked
533
    }
534

            
535
    /// Every `(node, property)` pair the handler wrote, flattened and in push order.
536
    fn pushed_pairs(changes: &[CallbackChange]) -> Vec<(NodeId, CssProperty)> {
537
        changes
538
            .iter()
539
            .filter_map(|c| match c {
540
                CallbackChange::ChangeNodeCssProperties {
541
                    node_id, properties, ..
542
                } => Some((*node_id, properties.as_ref().to_vec())),
543
                _ => None,
544
            })
545
            .flat_map(|(n, ps)| ps.into_iter().map(move |p| (n, p)))
546
            .collect()
547
    }
548

            
549
    fn pushed_backgrounds(changes: &[CallbackChange]) -> Vec<(NodeId, StyleBackgroundContentVec)> {
550
        pushed_pairs(changes)
551
            .into_iter()
552
            .filter_map(|(n, p)| match p {
553
                CssProperty::BackgroundContent(b) => b.get_property().cloned().map(|b| (n, b)),
554
                _ => None,
555
            })
556
            .collect()
557
    }
558

            
559
    fn pushed_margins(changes: &[CallbackChange]) -> Vec<(NodeId, f32)> {
560
        pushed_pairs(changes)
561
            .into_iter()
562
            .filter_map(|(n, p)| match p {
563
                CssProperty::MarginLeft(m) => m.get_property().map(|m| (n, px(&m.inner))),
564
                _ => None,
565
            })
566
            .collect()
567
    }
568

            
569
    // ------------------------------------------------------------------
570
    // Style-vec probes
571
    // ------------------------------------------------------------------
572

            
573
    fn properties(v: &CssPropertyWithConditionsVec) -> Vec<CssProperty> {
574
        v.as_ref().iter().map(|p| p.property.clone()).collect()
575
    }
576

            
577
    fn find<T>(v: &CssPropertyWithConditionsVec, f: impl Fn(&CssProperty) -> Option<T>) -> Option<T> {
578
        v.as_ref().iter().find_map(|p| f(&p.property))
579
    }
580

            
581
    /// The `f32` of a `PixelValue`, asserting it is an absolute `px` length. An `em`
582
    /// or `%` slipping into the switch geometry would resolve against the parent
583
    /// font/box, so a 36px track could render at any size at all — and the knob's
584
    /// travel would no longer line up with it.
585
    fn px(pv: &PixelValue) -> f32 {
586
        assert_eq!(
587
            pv.metric,
588
            SizeMetric::Px,
589
            "switch geometry must be absolute px, got {:?}",
590
            pv.metric,
591
        );
592
        pv.number.get()
593
    }
594

            
595
    fn width_px(v: &CssPropertyWithConditionsVec) -> Option<f32> {
596
        find(v, |p| match p {
597
            CssProperty::Width(w) => match w.get_property() {
598
                Some(LayoutWidth::Px(pv)) => Some(px(pv)),
599
                _ => None,
600
            },
601
            _ => None,
602
        })
603
    }
604

            
605
    fn height_px(v: &CssPropertyWithConditionsVec) -> Option<f32> {
606
        find(v, |p| match p {
607
            CssProperty::Height(h) => match h.get_property() {
608
                Some(LayoutHeight::Px(pv)) => Some(px(pv)),
609
                _ => None,
610
            },
611
            _ => None,
612
        })
613
    }
614

            
615
    /// The knob's `margin-left` — the single property that encodes "which side is the
616
    /// knob on". This is the only thing distinguishing the two knob styles.
617
    fn margin_left_px(v: &CssPropertyWithConditionsVec) -> Option<f32> {
618
        find(v, |p| match p {
619
            CssProperty::MarginLeft(m) => m.get_property().map(|m| px(&m.inner)),
620
            _ => None,
621
        })
622
    }
623

            
624
    /// `(top, right, bottom, left)` padding, each as an absolute px.
625
    fn paddings_px(v: &CssPropertyWithConditionsVec) -> (Option<f32>, Option<f32>, Option<f32>, Option<f32>) {
626
        let get = |f: fn(&CssProperty) -> Option<f32>| find(v, f);
627
        (
628
            get(|p| match p {
629
                CssProperty::PaddingTop(x) => x.get_property().map(|x| px(&x.inner)),
630
                _ => None,
631
            }),
632
            get(|p| match p {
633
                CssProperty::PaddingRight(x) => x.get_property().map(|x| px(&x.inner)),
634
                _ => None,
635
            }),
636
            get(|p| match p {
637
                CssProperty::PaddingBottom(x) => x.get_property().map(|x| px(&x.inner)),
638
                _ => None,
639
            }),
640
            get(|p| match p {
641
                CssProperty::PaddingLeft(x) => x.get_property().map(|x| px(&x.inner)),
642
                _ => None,
643
            }),
644
        )
645
    }
646

            
647
    /// The four corner radii, in declaration order. (Each corner is its own newtype, so
648
    /// the arms cannot be collapsed into an or-pattern.)
649
    fn radii_px(v: &CssPropertyWithConditionsVec) -> Vec<f32> {
650
        v.as_ref()
651
            .iter()
652
            .filter_map(|p| match &p.property {
653
                CssProperty::BorderTopLeftRadius(r) => r.get_property().map(|r| px(&r.inner)),
654
                CssProperty::BorderTopRightRadius(r) => r.get_property().map(|r| px(&r.inner)),
655
                CssProperty::BorderBottomLeftRadius(r) => r.get_property().map(|r| px(&r.inner)),
656
                CssProperty::BorderBottomRightRadius(r) => r.get_property().map(|r| px(&r.inner)),
657
                _ => None,
658
            })
659
            .collect()
660
    }
661

            
662
    fn flex_grow(v: &CssPropertyWithConditionsVec) -> Option<f32> {
663
        find(v, |p| match p {
664
            CssProperty::FlexGrow(g) => g.get_property().map(|g| g.inner.get()),
665
            _ => None,
666
        })
667
    }
668

            
669
    fn background(v: &CssPropertyWithConditionsVec) -> Option<StyleBackgroundContentVec> {
670
        find(v, |p| match p {
671
            CssProperty::BackgroundContent(b) => b.get_property().cloned(),
672
            _ => None,
673
        })
674
    }
675

            
676
    /// The solid-colour layers of a background, in order. A gradient/image layer is
677
    /// dropped, so `solid_colors(bg).len() != bg.len()` means something non-solid crept in.
678
    fn solid_colors(bg: &StyleBackgroundContentVec) -> Vec<ColorU> {
679
        bg.as_ref()
680
            .iter()
681
            .filter_map(|c| match c {
682
                StyleBackgroundContent::Color(c) => Some(*c),
683
                _ => None,
684
            })
685
            .collect()
686
    }
687

            
688
    /// Every absolute-px number declared by a style vec.
689
    fn px_values(v: &CssPropertyWithConditionsVec) -> Vec<f32> {
690
        v.as_ref()
691
            .iter()
692
            .filter_map(|p| match &p.property {
693
                CssProperty::Width(x) => match x.get_property() {
694
                    Some(LayoutWidth::Px(pv)) => Some(px(pv)),
695
                    _ => None,
696
                },
697
                CssProperty::Height(x) => match x.get_property() {
698
                    Some(LayoutHeight::Px(pv)) => Some(px(pv)),
699
                    _ => None,
700
                },
701
                CssProperty::PaddingTop(x) => x.get_property().map(|x| px(&x.inner)),
702
                CssProperty::PaddingRight(x) => x.get_property().map(|x| px(&x.inner)),
703
                CssProperty::PaddingBottom(x) => x.get_property().map(|x| px(&x.inner)),
704
                CssProperty::PaddingLeft(x) => x.get_property().map(|x| px(&x.inner)),
705
                CssProperty::MarginLeft(x) => x.get_property().map(|x| px(&x.inner)),
706
                CssProperty::BorderTopLeftRadius(x) => x.get_property().map(|x| px(&x.inner)),
707
                CssProperty::BorderTopRightRadius(x) => x.get_property().map(|x| px(&x.inner)),
708
                CssProperty::BorderBottomLeftRadius(x) => x.get_property().map(|x| px(&x.inner)),
709
                CssProperty::BorderBottomRightRadius(x) => x.get_property().map(|x| px(&x.inner)),
710
                _ => None,
711
            })
712
            .collect()
713
    }
714

            
715
    fn classes(dom: &Dom) -> Vec<String> {
716
        dom.root
717
            .get_ids_and_classes()
718
            .as_ref()
719
            .iter()
720
            .filter_map(|c| match c {
721
                Class(s) => Some(s.as_str().to_string()),
722
                IdOrClass::Id(_) => None,
723
            })
724
            .collect()
725
    }
726

            
727
    /// The properties of a rendered node's *inline* style, in declaration order.
728
    fn inline_properties(dom: &Dom) -> Vec<CssProperty> {
729
        dom.root
730
            .style
731
            .iter_inline_properties()
732
            .map(|(p, _)| p.clone())
733
            .collect()
734
    }
735

            
736
    // ------------------------------------------------------------------
737
    // Toggle callbacks
738
    // ------------------------------------------------------------------
739

            
740
    /// A payload the toggle callback writes into. It arrives as the `data: RefAny`
741
    /// argument — a *shared* clone of what the test still holds — so the test can read
742
    /// back exactly what the widget passed, without any global state.
743
    #[derive(Debug, Clone, PartialEq, Eq)]
744
    struct ToggleLog {
745
        seen: Vec<bool>,
746
        payload: u32,
747
    }
748

            
749
    extern "C" fn record_toggle(mut data: RefAny, _info: CallbackInfo, state: SwitchState) -> Update {
750
        if let Some(mut log) = data.downcast_mut::<ToggleLog>() {
751
            log.seen.push(state.checked);
752
        }
753
        Update::RefreshDom
754
    }
755

            
756
    extern "C" fn toggle_do_nothing(
757
        _data: RefAny,
758
        _info: CallbackInfo,
759
        _state: SwitchState,
760
    ) -> Update {
761
        Update::DoNothing
762
    }
763

            
764
    extern "C" fn toggle_refresh_all(
765
        _data: RefAny,
766
        _info: CallbackInfo,
767
        _state: SwitchState,
768
    ) -> Update {
769
        Update::RefreshDomAllWindows
770
    }
771

            
772
    /// A `Callback`-shaped (2-arg) function — the shape FFI bindings hand in, which the
773
    /// `From<Callback>` arm *transmutes* into the 3-arg switch slot. Never called.
774
    extern "C" fn generic_shaped(_data: RefAny, _info: CallbackInfo) -> Update {
775
        Update::DoNothing
776
    }
777

            
778
    fn log_refany() -> RefAny {
779
        RefAny::new(ToggleLog {
780
            seen: Vec::new(),
781
            payload: 0xDEAD_BEEF,
782
        })
783
    }
784

            
785
    fn read_log(probe: &RefAny) -> ToggleLog {
786
        let mut probe = probe.clone();
787
        let log = probe
788
            .downcast_ref::<ToggleLog>()
789
            .expect("the user payload changed type");
790
        log.clone()
791
    }
792

            
793
    // ==================================================================
794
    // Geometry constants — numeric limits / relations
795
    // ==================================================================
796

            
797
    #[test]
798
    fn knob_travel_matches_its_documented_formula_and_is_positive() {
799
        // The doc comment on KNOB_TRAVEL *is* the spec. A negative travel (knob wider
800
        // than the track's content box) would slide the knob left, off the widget.
801
        assert_eq!(
802
            KNOB_TRAVEL,
803
            TRACK_WIDTH - (2 * TRACK_PADDING) - KNOB_SIZE,
804
            "KNOB_TRAVEL no longer matches its own documented formula",
805
        );
806
        assert!(
807
            KNOB_TRAVEL > 0,
808
            "the knob has no room to travel: KNOB_TRAVEL = {KNOB_TRAVEL}",
809
        );
810
        assert_eq!(KNOB_TRAVEL as f32, TRAVEL);
811
    }
812

            
813
    #[test]
814
    fn the_knob_exactly_fills_the_track_from_edge_to_edge_when_on() {
815
        // padding + travel + knob + padding == track width. One px of drift either way
816
        // and the "on" knob either overhangs the pill or leaves a visible gap.
817
        assert_eq!(
818
            TRACK_PADDING + KNOB_TRAVEL + KNOB_SIZE + TRACK_PADDING,
819
            TRACK_WIDTH,
820
            "the switched-on knob does not sit flush against the track's right padding edge",
821
        );
822
    }
823

            
824
    #[test]
825
    fn the_knob_exactly_fills_the_tracks_vertical_padding_box() {
826
        // A knob taller than `track_height - 2*padding` overflows the pill vertically;
827
        // a shorter one floats. 16 == 20 - 2*2.
828
        assert_eq!(
829
            KNOB_SIZE,
830
            TRACK_HEIGHT - (2 * TRACK_PADDING),
831
            "the knob no longer fits the track's vertical padding box",
832
        );
833
    }
834

            
835
    #[test]
836
    fn both_shapes_are_fully_round_not_merely_rounded() {
837
        // radius == half the cross-axis extent is what makes a pill a pill and a knob a
838
        // circle. Anything less renders a rounded rectangle.
839
        assert_eq!(TRACK_RADIUS * 2, TRACK_HEIGHT, "the track is not a pill");
840
        assert_eq!(KNOB_RADIUS * 2, KNOB_SIZE, "the knob is not a circle");
841
    }
842

            
843
    #[test]
844
    fn every_geometry_constant_survives_floatvalues_fixed_point_encoding() {
845
        // `FloatValue::const_new` multiplies by 1000 in *isize* arithmetic with no
846
        // checked path — a constant near isize::MAX/1000 would wrap silently and produce
847
        // a nonsense length. Assert every constant is comfortably representable, and
848
        // that the encode/decode round-trips exactly.
849
        const MULT: isize = 1000;
850
        for (name, v) in [
851
            ("TRACK_WIDTH", TRACK_WIDTH),
852
            ("TRACK_HEIGHT", TRACK_HEIGHT),
853
            ("TRACK_PADDING", TRACK_PADDING),
854
            ("TRACK_RADIUS", TRACK_RADIUS),
855
            ("KNOB_SIZE", KNOB_SIZE),
856
            ("KNOB_RADIUS", KNOB_RADIUS),
857
            ("KNOB_TRAVEL", KNOB_TRAVEL),
858
        ] {
859
            assert!(
860
                v.checked_mul(MULT).is_some(),
861
                "{name} = {v} overflows FloatValue's fixed-point encoding",
862
            );
863
            assert!(v >= 0, "{name} = {v} is negative");
864

            
865
            // encode -> decode must be lossless for these integral px values.
866
            let encoded = LayoutMarginLeft::const_px(v);
867
            assert_eq!(
868
                px(&encoded.inner),
869
                v as f32,
870
                "{name} = {v} does not round-trip through PixelValue",
871
            );
872
        }
873
    }
874

            
875
    #[test]
876
    fn no_declared_px_value_is_nan_infinite_or_negative() {
877
        for checked in [false, true] {
878
            for (name, v) in [
879
                ("track", build_track_style(checked)),
880
                ("knob", build_knob_style(checked)),
881
            ] {
882
                let values = px_values(&v);
883
                assert!(
884
                    !values.is_empty(),
885
                    "checked={checked}: the {name} style declares no px lengths at all",
886
                );
887
                for x in values {
888
                    assert!(
889
                        x.is_finite(),
890
                        "checked={checked}: the {name} style declares a non-finite length {x}",
891
                    );
892
                    assert!(
893
                        x >= 0.0,
894
                        "checked={checked}: the {name} style declares a negative length {x}",
895
                    );
896
                }
897
            }
898
        }
899
    }
900

            
901
    // ==================================================================
902
    // build_track_style
903
    // ==================================================================
904

            
905
    #[test]
906
    fn build_track_style_is_pure() {
907
        for checked in [false, true] {
908
            assert_eq!(
909
                properties(&build_track_style(checked)),
910
                properties(&build_track_style(checked)),
911
                "build_track_style({checked}) is not deterministic",
912
            );
913
        }
914
    }
915

            
916
    #[test]
917
    fn build_track_style_differs_between_the_two_states_only_in_the_background() {
918
        // Everything but the colour must be byte-for-byte identical: a track that
919
        // changed size or radius when flipped would reflow its neighbours mid-animation.
920
        let on = properties(&build_track_style(true));
921
        let off = properties(&build_track_style(false));
922
        assert_eq!(
923
            on.len(),
924
            off.len(),
925
            "the two track styles declare a different number of properties",
926
        );
927

            
928
        let differing: Vec<_> = on
929
            .iter()
930
            .zip(off.iter())
931
            .filter(|(a, b)| a != b)
932
            .map(|(a, _)| discriminant(a))
933
            .collect();
934
        assert_eq!(
935
            differing,
936
            vec![discriminant(&CssProperty::const_background_content(TRACK_ON_BG))],
937
            "the on/off track styles differ in something other than the background",
938
        );
939
    }
940

            
941
    #[test]
942
    fn build_track_style_maps_on_to_green_and_off_to_grey() {
943
        // A swapped branch here yields a switch that reads as "on" when it is off —
944
        // which still type-checks and still animates.
945
        let on = background(&build_track_style(true)).expect("the on track has no background");
946
        let off = background(&build_track_style(false)).expect("the off track has no background");
947

            
948
        assert_eq!(solid_colors(&on), vec![TRACK_ON_COLOR]);
949
        assert_eq!(solid_colors(&off), vec![TRACK_OFF_COLOR]);
950
        assert_ne!(
951
            solid_colors(&on),
952
            solid_colors(&off),
953
            "the on and off tracks are the same colour — the switch has no visible state",
954
        );
955
    }
956

            
957
    #[test]
958
    fn the_track_background_is_exactly_one_fully_opaque_solid_layer() {
959
        // A translucent (or multi-layer, or gradient) track would let whatever is behind
960
        // it show through, so "off grey" would not actually be grey.
961
        for checked in [false, true] {
962
            let bg = background(&build_track_style(checked)).expect("no background");
963
            assert_eq!(
964
                bg.as_ref().len(),
965
                1,
966
                "checked={checked}: the track stacks {} background layers",
967
                bg.as_ref().len(),
968
            );
969
            let colors = solid_colors(&bg);
970
            assert_eq!(
971
                colors.len(),
972
                1,
973
                "checked={checked}: the track background is not a plain solid colour",
974
            );
975
            assert_eq!(
976
                colors[0].a, 255,
977
                "checked={checked}: the track background is translucent (a = {})",
978
                colors[0].a,
979
            );
980
        }
981
    }
982

            
983
    #[test]
984
    fn build_track_style_geometry_is_absolute_px_in_both_states() {
985
        for checked in [false, true] {
986
            let v = build_track_style(checked);
987
            // `px()` asserts SizeMetric::Px — an em/% here would scale with the parent
988
            // and desynchronise the knob's (absolute px) travel from the track.
989
            assert_eq!(width_px(&v), Some(TRACK_W), "checked={checked}: track width");
990
            assert_eq!(height_px(&v), Some(TRACK_H), "checked={checked}: track height");
991
            assert_eq!(
992
                paddings_px(&v),
993
                (Some(PAD), Some(PAD), Some(PAD), Some(PAD)),
994
                "checked={checked}: the track padding is not uniform",
995
            );
996
            assert_eq!(
997
                radii_px(&v),
998
                vec![TRACK_R; 4],
999
                "checked={checked}: the track's four corners are not all {TRACK_R}px",
            );
        }
    }
    #[test]
    fn build_track_style_declares_no_property_twice() {
        // A duplicate declaration means the later one silently wins — a latent
        // "why is my override ignored" bug that never surfaces as an error.
        for checked in [false, true] {
            let props = properties(&build_track_style(checked));
            let mut seen = Vec::new();
            for p in &props {
                let d = discriminant(p);
                assert!(
                    !seen.contains(&d),
                    "checked={checked}: the track style declares {p:?} twice",
                );
                seen.push(d);
            }
        }
    }
    #[test]
    fn build_track_style_marks_the_track_as_clickable_and_non_growing() {
        for checked in [false, true] {
            let v = build_track_style(checked);
            let cursor = find(&v, |p| match p {
                CssProperty::Cursor(c) => c.get_property().copied(),
                _ => None,
            });
            assert_eq!(
                cursor,
                Some(StyleCursor::Pointer),
                "checked={checked}: the switch does not present as clickable",
            );
            // flex-grow > 0 would let the track stretch past 36px inside a flex row,
            // and the knob's fixed 16px travel would no longer reach the right edge.
            assert_eq!(
                flex_grow(&v),
                Some(0.0),
                "checked={checked}: the track is allowed to grow",
            );
        }
    }
    #[test]
    fn build_track_style_lays_the_knob_out_as_a_centred_row() {
        // The knob is positioned by `margin-left` alone, which only behaves as a
        // left-anchored offset inside a row flex container.
        for checked in [false, true] {
            let v = build_track_style(checked);
            assert_eq!(
                find(&v, |p| match p {
                    CssProperty::Display(d) => d.get_property().copied(),
                    _ => None,
                }),
                Some(LayoutDisplay::Flex),
            );
            assert_eq!(
                find(&v, |p| match p {
                    CssProperty::FlexDirection(d) => d.get_property().copied(),
                    _ => None,
                }),
                Some(LayoutFlexDirection::Row),
                "checked={checked}: margin-left only slides the knob in a row container",
            );
            assert_eq!(
                find(&v, |p| match p {
                    CssProperty::AlignItems(a) => a.get_property().copied(),
                    _ => None,
                }),
                Some(LayoutAlignItems::Center),
            );
        }
    }
    // ==================================================================
    // build_knob_style
    // ==================================================================
    #[test]
    fn build_knob_style_is_pure() {
        for checked in [false, true] {
            assert_eq!(
                properties(&build_knob_style(checked)),
                properties(&build_knob_style(checked)),
                "build_knob_style({checked}) is not deterministic",
            );
        }
    }
    #[test]
    fn build_knob_style_differs_between_the_two_states_only_in_margin_left() {
        let on = properties(&build_knob_style(true));
        let off = properties(&build_knob_style(false));
        assert_eq!(
            on.len(),
            off.len(),
            "the two knob styles declare a different number of properties",
        );
        let differing: Vec<_> = on
            .iter()
            .zip(off.iter())
            .filter(|(a, b)| a != b)
            .map(|(a, _)| discriminant(a))
            .collect();
        assert_eq!(
            differing,
            vec![discriminant(&CssProperty::const_margin_left(
                LayoutMarginLeft::const_px(0)
            ))],
            "the on/off knob styles differ in something other than margin-left",
        );
    }
    #[test]
    fn build_knob_style_parks_the_knob_left_when_off_and_right_when_on() {
        assert_eq!(
            margin_left_px(&build_knob_style(false)),
            Some(0.0),
            "the off knob is not flush against the track's left padding edge",
        );
        assert_eq!(
            margin_left_px(&build_knob_style(true)),
            Some(TRAVEL),
            "the on knob does not travel the full width of the track",
        );
    }
    #[test]
    fn build_knob_style_geometry_is_a_circle_in_absolute_px() {
        for checked in [false, true] {
            let v = build_knob_style(checked);
            assert_eq!(width_px(&v), Some(KNOB), "checked={checked}: knob width");
            assert_eq!(height_px(&v), Some(KNOB), "checked={checked}: knob height");
            assert_eq!(
                width_px(&v),
                height_px(&v),
                "checked={checked}: the knob is not square, so it cannot be a circle",
            );
            assert_eq!(
                radii_px(&v),
                vec![KNOB_R; 4],
                "checked={checked}: the knob's four corners are not all {KNOB_R}px",
            );
            assert_eq!(
                flex_grow(&v),
                Some(0.0),
                "checked={checked}: the knob is allowed to grow and would fill the track",
            );
        }
    }
    #[test]
    fn build_knob_style_declares_no_property_twice() {
        // Two `margin-left` declarations would make the knob's position depend on
        // declaration order rather than on `checked`.
        for checked in [false, true] {
            let props = properties(&build_knob_style(checked));
            let mut seen = Vec::new();
            for p in &props {
                let d = discriminant(p);
                assert!(
                    !seen.contains(&d),
                    "checked={checked}: the knob style declares {p:?} twice",
                );
                seen.push(d);
            }
        }
    }
    #[test]
    fn the_knob_is_opaque_white_in_both_states() {
        // The knob must never inherit or blend with the track colour, or the "off" knob
        // would disappear into the grey.
        for checked in [false, true] {
            let bg = background(&build_knob_style(checked)).expect("the knob has no background");
            assert_eq!(bg.as_ref().len(), 1, "checked={checked}: the knob stacks layers");
            assert_eq!(solid_colors(&bg), vec![KNOB_COLOR]);
            assert_eq!(
                KNOB_COLOR.a, 255,
                "the knob is translucent and would tint with the track",
            );
            assert_ne!(
                solid_colors(&bg),
                vec![TRACK_OFF_COLOR],
                "checked={checked}: the knob is the same colour as the off track",
            );
            assert_ne!(
                solid_colors(&bg),
                vec![TRACK_ON_COLOR],
                "checked={checked}: the knob is the same colour as the on track",
            );
        }
    }
    #[test]
    fn the_knob_never_leaves_the_track_in_either_state() {
        // The real safety property, read back out of the *built styles* rather than the
        // constants: left edge >= 0 and right edge <= the track's content width.
        let content_w = TRACK_W - 2.0 * PAD;
        for checked in [false, true] {
            let margin = margin_left_px(&build_knob_style(checked)).expect("no margin-left");
            let size = width_px(&build_knob_style(checked)).expect("no width");
            assert!(margin >= 0.0, "checked={checked}: the knob is pushed off the left edge");
            assert!(
                margin + size <= content_w,
                "checked={checked}: the knob overhangs the track ({margin} + {size} > {content_w})",
            );
            assert!(
                height_px(&build_knob_style(checked)).expect("no height") <= TRACK_H - 2.0 * PAD,
                "checked={checked}: the knob overflows the track vertically",
            );
        }
    }
    // ==================================================================
    // Switch::create / Default
    // ==================================================================
    #[test]
    fn create_stores_the_flag_and_installs_no_callback() {
        for checked in [false, true] {
            let s = Switch::create(checked);
            assert_eq!(
                s.switch_state.inner.checked, checked,
                "create({checked}) did not store the flag it was given",
            );
            assert!(
                s.switch_state.on_toggle.as_ref().is_none(),
                "create({checked}) invented a toggle callback out of nowhere",
            );
        }
    }
    #[test]
    fn create_is_pure_and_its_two_states_are_distinguishable() {
        assert_eq!(Switch::create(true), Switch::create(true));
        assert_eq!(Switch::create(false), Switch::create(false));
        assert_ne!(
            Switch::create(true),
            Switch::create(false),
            "an on and an off switch are indistinguishable",
        );
    }
    #[test]
    fn create_wires_the_flag_through_to_both_style_builders() {
        // The one way `create` can be wrong without any test noticing: passing the flag
        // to one builder and a literal (or the negation) to the other, so the track says
        // "on" while the knob sits left.
        for checked in [false, true] {
            let s = Switch::create(checked);
            assert_eq!(
                properties(&s.track_style),
                properties(&build_track_style(checked)),
                "create({checked}) did not build the track for state {checked}",
            );
            assert_eq!(
                properties(&s.knob_style),
                properties(&build_knob_style(checked)),
                "create({checked}) did not build the knob for state {checked}",
            );
        }
    }
    #[test]
    fn the_rendered_colour_and_the_knob_position_always_agree_with_the_stored_flag() {
        for checked in [false, true] {
            let s = Switch::create(checked);
            let bg = background(&s.track_style).expect("no track background");
            let margin = margin_left_px(&s.knob_style).expect("no knob margin");
            let (expected_color, expected_margin) = if s.switch_state.inner.checked {
                (TRACK_ON_COLOR, TRAVEL)
            } else {
                (TRACK_OFF_COLOR, 0.0)
            };
            assert_eq!(solid_colors(&bg), vec![expected_color]);
            assert_eq!(
                margin, expected_margin,
                "checked={checked}: the knob position contradicts the track colour",
            );
        }
    }
    #[test]
    fn default_is_an_off_switch() {
        assert_eq!(Switch::default(), Switch::create(false));
        assert!(!Switch::default().switch_state.inner.checked);
        assert!(!SwitchState::default().checked, "the default SwitchState is not off");
        assert!(!SwitchStateWrapper::default().inner.checked);
        assert!(SwitchStateWrapper::default().on_toggle.as_ref().is_none());
    }
    // ==================================================================
    // Switch::swap_with_default
    // ==================================================================
    #[test]
    fn swap_with_default_returns_the_old_widget_and_leaves_an_off_switch_behind() {
        let mut s = Switch::create(true);
        let old = s.swap_with_default();
        assert_eq!(old, Switch::create(true), "the old widget was not returned intact");
        assert_eq!(s, Switch::create(false), "what was left behind is not a fresh off switch");
    }
    #[test]
    fn swap_with_default_on_an_already_default_widget_is_a_no_op() {
        let mut s = Switch::create(false);
        let old = s.swap_with_default();
        assert_eq!(old, s, "swapping a default with a default produced two different widgets");
        assert_eq!(old, Switch::create(false));
    }
    #[test]
    fn swapping_twice_round_trips_the_original_widget() {
        let mut a = Switch::create(true);
        let mut b = a.swap_with_default(); // a = default, b = on
        let c = b.swap_with_default(); // b = default, c = on
        assert_eq!(c, Switch::create(true));
        assert_eq!(a, Switch::create(false));
        assert_eq!(b, Switch::create(false));
    }
    #[test]
    fn swap_with_default_moves_the_toggle_callback_out_rather_than_copying_or_dropping_it() {
        let probe = log_refany();
        let mut s = Switch::create(true)
            .with_on_toggle(probe.clone(), record_toggle as SwitchOnToggleCallbackType);
        let old = s.swap_with_default();
        // The callback (and its payload) left with the returned value ...
        let moved = old
            .switch_state
            .on_toggle
            .as_ref()
            .expect("the toggle callback vanished during the swap");
        assert_eq!(
            moved.callback.cb as *const () as usize,
            record_toggle as SwitchOnToggleCallbackType as *const () as usize,
            "the fn pointer was mangled by the swap",
        );
        // ... and did NOT stay behind: a duplicated callback would fire twice, and a
        // duplicated RefAny would double-free its payload.
        assert!(
            s.switch_state.on_toggle.as_ref().is_none(),
            "the toggle callback was copied instead of moved",
        );
        // The payload is still alive and unchanged after the move.
        assert_eq!(read_log(&probe).payload, 0xDEAD_BEEF);
    }
    // ==================================================================
    // Switch::set_on_toggle / with_on_toggle
    // ==================================================================
    #[test]
    fn set_on_toggle_stores_the_function_pointer_and_the_payload_verbatim() {
        let mut s = Switch::create(false);
        s.set_on_toggle(
            RefAny::new(0xDEAD_BEEF_u32),
            toggle_do_nothing as SwitchOnToggleCallbackType,
        );
        let t = s
            .switch_state
            .on_toggle
            .as_ref()
            .expect("set_on_toggle did not store anything");
        assert_eq!(
            t.callback.cb as *const () as usize,
            toggle_do_nothing as SwitchOnToggleCallbackType as *const () as usize,
            "the fn pointer was corrupted on the way in",
        );
        let mut data = t.refany.clone();
        assert_eq!(
            *data.downcast_ref::<u32>().expect("the payload changed type"),
            0xDEAD_BEEF,
            "the payload was corrupted",
        );
        assert!(
            data.downcast_ref::<u64>().is_none(),
            "downcasting to the wrong type must fail, not reinterpret the bytes",
        );
    }
    #[test]
    fn set_on_toggle_replaces_rather_than_accumulates() {
        // `OptionSwitchOnToggle` is a single slot; setting twice must leave the *second*
        // callback installed (and must not leak or free the first one's RefAny).
        let first = log_refany();
        let mut s = Switch::create(false);
        s.set_on_toggle(first.clone(), toggle_do_nothing as SwitchOnToggleCallbackType);
        s.set_on_toggle(RefAny::new(1u8), toggle_refresh_all as SwitchOnToggleCallbackType);
        let t = s.switch_state.on_toggle.as_ref().expect("the callback vanished");
        assert_eq!(
            t.callback.cb as *const () as usize,
            toggle_refresh_all as SwitchOnToggleCallbackType as *const () as usize,
            "the second set_on_toggle did not win",
        );
        // The displaced payload is still a valid, readable RefAny (not freed twice).
        assert_eq!(read_log(&first).payload, 0xDEAD_BEEF);
    }
    #[test]
    fn set_on_toggle_does_not_disturb_the_state_or_the_styles() {
        for checked in [false, true] {
            let pristine = Switch::create(checked);
            let mut s = Switch::create(checked);
            s.set_on_toggle(RefAny::new(0u8), toggle_do_nothing as SwitchOnToggleCallbackType);
            assert_eq!(
                s.switch_state.inner.checked, checked,
                "installing a callback flipped the switch",
            );
            assert_eq!(
                properties(&s.track_style),
                properties(&pristine.track_style),
                "installing a callback rewrote the track style",
            );
            assert_eq!(
                properties(&s.knob_style),
                properties(&pristine.knob_style),
                "installing a callback rewrote the knob style",
            );
        }
    }
    #[test]
    fn with_on_toggle_is_exactly_set_on_toggle_in_builder_form() {
        let by_builder = Switch::create(true)
            .with_on_toggle(RefAny::new(7u32), toggle_do_nothing as SwitchOnToggleCallbackType);
        let mut by_setter = Switch::create(true);
        by_setter.set_on_toggle(RefAny::new(7u32), toggle_do_nothing as SwitchOnToggleCallbackType);
        assert_eq!(by_builder.switch_state.inner, by_setter.switch_state.inner);
        assert_eq!(
            properties(&by_builder.track_style),
            properties(&by_setter.track_style),
        );
        assert_eq!(
            properties(&by_builder.knob_style),
            properties(&by_setter.knob_style),
        );
        let a = by_builder.switch_state.on_toggle.as_ref().expect("builder lost the callback");
        let b = by_setter.switch_state.on_toggle.as_ref().expect("setter lost the callback");
        assert_eq!(a.callback.cb as *const () as usize, b.callback.cb as *const () as usize);
        let (mut a, mut b) = (a.refany.clone(), b.refany.clone());
        assert_eq!(
            *a.downcast_ref::<u32>().expect("builder payload changed type"),
            *b.downcast_ref::<u32>().expect("setter payload changed type"),
        );
    }
    #[test]
    fn with_on_toggle_accepts_a_generic_callback_without_mangling_the_pointer() {
        // The `From<Callback>` arm *transmutes* a 2-arg fn pointer into the 3-arg switch
        // slot — this is the FFI (Python/C) path. The pointer must come out bit-identical;
        // a mangled one would be called as a wild jump on the first click.
        let generic = Callback {
            cb: generic_shaped,
            ctx: OptionRefAny::None,
        };
        let expected = generic_shaped as *const () as usize;
        let s = Switch::create(false).with_on_toggle(RefAny::new(0u8), generic);
        let t = s.switch_state.on_toggle.as_ref().expect("the generic callback was dropped");
        assert_eq!(
            t.callback.cb as *const () as usize,
            expected,
            "the Callback -> SwitchOnToggleCallback transmute mangled the pointer",
        );
    }
    // ==================================================================
    // Switch::dom
    // ==================================================================
    #[test]
    fn dom_builds_a_focusable_track_with_exactly_one_knob_child() {
        for checked in [false, true] {
            let dom = Switch::create(checked).dom();
            assert!(matches!(dom.root.get_node_type(), NodeType::Div));
            assert_eq!(
                dom.root.flags.get_tab_index(),
                Some(TabIndex::Auto),
                "checked={checked}: the switch is not keyboard-focusable",
            );
            assert_eq!(classes(&dom), vec!["__azul-native-switch".to_string()]);
            let children = dom.children.as_ref();
            assert_eq!(children.len(), 1, "checked={checked}: the switch must have exactly one knob");
            assert_eq!(
                classes(&children[0]),
                vec!["__azul-native-switch-knob".to_string()],
                "checked={checked}: the knob carries the wrong class (external CSS would miss it)",
            );
            assert!(
                children[0].children.as_ref().is_empty(),
                "checked={checked}: the knob grew children",
            );
            assert_eq!(
                children[0].root.flags.get_tab_index(),
                None,
                "checked={checked}: the knob is separately focusable, so Tab stops twice",
            );
        }
    }
    #[test]
    fn dom_puts_the_track_style_on_the_track_and_the_knob_style_on_the_knob() {
        // Swapping the two would style the 16px knob like a 36px pill (and vice versa) —
        // the widget would still render, just wrong.
        for checked in [false, true] {
            let s = Switch::create(checked);
            let track = properties(&s.track_style);
            let knob = properties(&s.knob_style);
            let dom = s.dom();
            assert_eq!(
                inline_properties(&dom),
                track,
                "checked={checked}: the track style did not land on the track",
            );
            assert_eq!(
                inline_properties(&dom.children.as_ref()[0]),
                knob,
                "checked={checked}: the knob style did not land on the knob",
            );
        }
    }
    #[test]
    fn dom_registers_exactly_one_mouse_up_handler_and_it_is_the_widgets_own() {
        for checked in [false, true] {
            let dom = Switch::create(checked).dom();
            let callbacks = dom.root.callbacks.as_ref();
            assert_eq!(callbacks.len(), 1, "checked={checked}: expected exactly one callback");
            assert_eq!(
                callbacks[0].event,
                EventFilter::Hover(HoverEventFilter::MouseUp),
                "checked={checked}: the switch must toggle on mouse-up",
            );
            assert_eq!(
                callbacks[0].callback.cb,
                input::default_on_switch_clicked as usize,
                "checked={checked}: the registered handler is not default_on_switch_clicked",
            );
            // The knob itself must stay inert — a second handler there would toggle twice
            // per click (the event bubbles).
            assert!(
                dom.children.as_ref()[0].root.callbacks.as_ref().is_empty(),
                "checked={checked}: the knob registered a handler of its own",
            );
        }
    }
    #[test]
    fn dom_hands_the_widget_state_to_the_handler_not_the_user_payload() {
        // `dom()` moves `switch_state` (state + on_toggle + user RefAny) into the
        // callback's RefAny. If it stored the *user's* payload instead, the handler's
        // `downcast_mut::<SwitchStateWrapper>()` would fail and every click would be a
        // silent no-op.
        for checked in [false, true] {
            let dom = Switch::create(checked)
                .with_on_toggle(RefAny::new(9u32), toggle_do_nothing as SwitchOnToggleCallbackType)
                .dom();
            let mut state = dom.root.callbacks.as_ref()[0].refany.clone();
            let wrapper = state
                .downcast_ref::<SwitchStateWrapper>()
                .expect("the handler's RefAny is not a SwitchStateWrapper");
            assert_eq!(
                wrapper.inner.checked, checked,
                "the on/off flag was lost on the way into the DOM",
            );
            assert!(
                wrapper.on_toggle.as_ref().is_some(),
                "the user's toggle callback was lost on the way into the DOM",
            );
        }
    }
    #[test]
    fn dom_of_a_callback_less_switch_still_registers_the_toggle_handler() {
        // The switch must always install its own handler: the knob has to slide even
        // with no user callback.
        let dom = Switch::create(false).dom();
        assert_eq!(dom.root.callbacks.as_ref().len(), 1);
        let mut state = dom.root.callbacks.as_ref()[0].refany.clone();
        let wrapper = state.downcast_ref::<SwitchStateWrapper>().expect("wrong RefAny type");
        assert!(wrapper.on_toggle.as_ref().is_none());
    }
    #[test]
    fn from_switch_for_dom_is_the_same_as_calling_dom() {
        for checked in [false, true] {
            let via_from = Dom::from(Switch::create(checked));
            let via_dom = Switch::create(checked).dom();
            assert_eq!(classes(&via_from), classes(&via_dom));
            assert_eq!(inline_properties(&via_from), inline_properties(&via_dom));
            assert_eq!(via_from.children.as_ref().len(), via_dom.children.as_ref().len());
            assert_eq!(
                inline_properties(&via_from.children.as_ref()[0]),
                inline_properties(&via_dom.children.as_ref()[0]),
            );
            assert_eq!(
                via_from.root.callbacks.as_ref().len(),
                via_dom.root.callbacks.as_ref().len(),
            );
            assert_eq!(via_from.root.flags.get_tab_index(), via_dom.root.flags.get_tab_index());
        }
    }
    #[test]
    fn the_rendered_dom_flattens_to_exactly_two_nodes() {
        // `Dom::estimated_total_children` is a *cached* count; if it under-reports, the
        // flatten under-allocates its arenas. Two nodes: track (0), knob (1).
        for checked in [false, true] {
            let styled = StyledDom::create_from_dom(Switch::create(checked).dom());
            assert_eq!(
                styled.node_data.as_ref().len(),
                2,
                "checked={checked}: the switch no longer flattens to a track + a knob",
            );
        }
    }
    // ==================================================================
    // input::default_on_switch_clicked
    // ==================================================================
    #[test]
    fn clicking_an_off_switch_turns_it_on_and_pushes_the_on_visuals() {
        let (styled, state) = laid_out(Switch::create(false));
        assert!(!is_checked(&state));
        let (update, changes) = click(styled, &state, node(TRACK));
        assert!(is_checked(&state), "the click did not turn the switch on");
        assert!(
            matches!(update, Update::DoNothing),
            "with no user callback installed, the handler must report DoNothing",
        );
        assert_eq!(
            pushed_backgrounds(&changes)
                .iter()
                .map(|(n, b)| (*n, solid_colors(b)))
                .collect::<Vec<_>>(),
            vec![(NodeId::new(TRACK), vec![TRACK_ON_COLOR])],
            "turning the switch on did not repaint the *track* green",
        );
        assert_eq!(
            pushed_margins(&changes),
            vec![(NodeId::new(KNOB_NODE), TRAVEL)],
            "turning the switch on did not slide the *knob* right",
        );
    }
    #[test]
    fn clicking_an_on_switch_turns_it_off_and_pushes_the_off_visuals() {
        let (styled, state) = laid_out(Switch::create(true));
        let (_, changes) = click(styled, &state, node(TRACK));
        assert!(!is_checked(&state), "the click did not turn the switch off");
        assert_eq!(
            pushed_backgrounds(&changes)
                .iter()
                .map(|(n, b)| (*n, solid_colors(b)))
                .collect::<Vec<_>>(),
            vec![(NodeId::new(TRACK), vec![TRACK_OFF_COLOR])],
        );
        assert_eq!(pushed_margins(&changes), vec![(NodeId::new(KNOB_NODE), 0.0)]);
    }
    #[test]
    fn a_click_pushes_exactly_what_a_freshly_created_switch_would_render() {
        // The handler re-derives the visuals by hand instead of calling
        // `build_track_style`/`build_knob_style`. That duplication is the bug surface:
        // a colour or a travel distance can drift in one place and not the other, and
        // the switch then renders differently after a click than it did on mount.
        for start in [false, true] {
            let (styled, state) = laid_out(Switch::create(start));
            let (_, changes) = click(styled, &state, node(TRACK));
            let expected = Switch::create(!start);
            assert_eq!(
                pushed_backgrounds(&changes).into_iter().map(|(_, b)| b).collect::<Vec<_>>(),
                vec![background(&expected.track_style).expect("no background")],
                "start={start}: the clicked track colour differs from a freshly built one",
            );
            assert_eq!(
                pushed_margins(&changes).into_iter().map(|(_, m)| m).collect::<Vec<_>>(),
                vec![margin_left_px(&expected.knob_style).expect("no margin")],
                "start={start}: the clicked knob offset differs from a freshly built one",
            );
        }
    }
    #[test]
    fn clicking_twice_returns_to_the_original_state() {
        let (styled, state) = laid_out(Switch::create(false));
        // Two independent deliveries against the same widget state — the styled DOM is
        // rebuilt each time because the harness consumes it, but the RefAny is shared,
        // which is exactly how the real event loop drives it.
        let (styled2, _) = laid_out(Switch::create(false));
        let (_, first) = click(styled, &state, node(TRACK));
        let (_, second) = click(styled2, &state, node(TRACK));
        assert!(!is_checked(&state), "two clicks did not return the switch to its original state");
        assert_eq!(pushed_margins(&first), vec![(NodeId::new(KNOB_NODE), TRAVEL)]);
        assert_eq!(pushed_margins(&second), vec![(NodeId::new(KNOB_NODE), 0.0)]);
    }
    #[test]
    fn clicking_with_a_refany_of_the_wrong_type_is_a_silent_no_op() {
        // The handler downcasts blind; a foreign RefAny must bail out, not reinterpret
        // the bytes as a SwitchStateWrapper.
        let (styled, _) = laid_out(Switch::create(false));
        let foreign = RefAny::new(0xDEAD_BEEF_u32);
        let (update, changes) = click(styled, &foreign, node(TRACK));
        assert!(matches!(update, Update::DoNothing));
        assert!(changes.is_empty(), "the handler wrote to the DOM through a foreign RefAny");
        let mut foreign = foreign;
        assert_eq!(
            *foreign.downcast_ref::<u32>().expect("the foreign payload was reinterpreted"),
            0xDEAD_BEEF,
            "the handler corrupted a RefAny it did not understand",
        );
    }
    #[test]
    fn clicking_the_knob_itself_does_not_half_apply_the_toggle() {
        // The knob is the child that sits under the cursor for most of the track's area,
        // and it has no first child of its own. The handler needs that child to slide the
        // knob, so it must leave the *flag* alone too — a flipped flag with no visual
        // update is a switch that renders the opposite of what it reports.
        let (styled, state) = laid_out(Switch::create(false));
        let (update, changes) = click(styled, &state, node(KNOB_NODE));
        assert!(matches!(update, Update::DoNothing));
        assert!(changes.is_empty(), "a change was pushed for a node the handler could not resolve");
        assert!(
            !is_checked(&state),
            "the flag was flipped even though the knob could not be moved",
        );
    }
    #[test]
    fn stale_or_missing_hit_ids_do_not_panic_or_toggle() {
        // Stale hit ids reach callbacks after a DOM mutation. `set_css_property` *panics*
        // on a None node id, so the handler has to bail out before that point.
        // usize::MAX is unencodable by NodeId's 1-based scheme and would overflow while
        // building this fixture, before `click()` is even called; usize::MAX - 1 is the
        // repo's MAX_ENCODABLE_NODE and still absent from the layout.
        for hit in [node(2), node(99), node(usize::MAX - 1), node_none()] {
            let (styled, state) = laid_out(Switch::create(true));
            let (update, changes) = click(styled, &state, hit);
            assert!(matches!(update, Update::DoNothing), "{hit:?}: a stale hit was acted on");
            assert!(changes.is_empty(), "{hit:?}: a stale hit pushed a DOM change");
            assert!(is_checked(&state), "{hit:?}: a stale hit toggled the switch");
        }
    }
    #[test]
    fn the_toggle_callback_sees_the_new_state_and_its_verdict_is_forwarded() {
        // Order matters: the flag is flipped *before* the user callback runs, so the
        // callback observes the state the user just asked for — not the stale one.
        let probe = log_refany();
        let (styled, state) = laid_out(
            Switch::create(false)
                .with_on_toggle(probe.clone(), record_toggle as SwitchOnToggleCallbackType),
        );
        let (update, changes) = click(styled, &state, node(TRACK));
        assert_eq!(
            read_log(&probe).seen,
            vec![true],
            "the toggle callback was not called exactly once with the NEW state",
        );
        assert!(
            matches!(update, Update::RefreshDom),
            "the user callback's Update was swallowed instead of forwarded",
        );
        // ... and the visual sync still happens *after* the user callback returns.
        assert_eq!(pushed_margins(&changes), vec![(NodeId::new(KNOB_NODE), TRAVEL)]);
    }
    #[test]
    fn the_toggle_callback_receives_the_user_payload_not_the_widget_state() {
        let probe = log_refany();
        let (styled, state) = laid_out(
            Switch::create(true)
                .with_on_toggle(probe.clone(), record_toggle as SwitchOnToggleCallbackType),
        );
        click(styled, &state, node(TRACK));
        let log = read_log(&probe);
        assert_eq!(
            log.payload, 0xDEAD_BEEF,
            "the callback was handed something other than the user's own RefAny",
        );
        // create(true) -> clicked once -> the callback must have seen `false`.
        assert_eq!(log.seen, vec![false]);
    }
    #[test]
    fn a_toggle_callback_that_declines_the_update_still_gets_the_visuals_synced() {
        // A user callback returning DoNothing must not suppress the widget's own visual
        // bookkeeping — otherwise the flag says "on" and the knob stays parked left.
        let (styled, state) = laid_out(
            Switch::create(false)
                .with_on_toggle(RefAny::new(0u8), toggle_do_nothing as SwitchOnToggleCallbackType),
        );
        let (update, changes) = click(styled, &state, node(TRACK));
        assert!(matches!(update, Update::DoNothing));
        assert!(is_checked(&state));
        assert_eq!(
            pushed_margins(&changes),
            vec![(NodeId::new(KNOB_NODE), TRAVEL)],
            "a DoNothing user callback suppressed the knob slide",
        );
    }
    #[test]
    fn a_toggle_callback_on_an_unresolvable_node_is_never_called_at_all() {
        // The bail-out happens before the flip *and* before the user callback: a click
        // that cannot be rendered must not be reported to the app either.
        let probe = log_refany();
        let (styled, state) = laid_out(
            Switch::create(false)
                .with_on_toggle(probe.clone(), record_toggle as SwitchOnToggleCallbackType),
        );
        let (update, changes) = click(styled, &state, node(KNOB_NODE));
        assert!(matches!(update, Update::DoNothing));
        assert!(changes.is_empty());
        assert!(!is_checked(&state));
        assert!(
            read_log(&probe).seen.is_empty(),
            "the user was notified of a toggle that never happened",
        );
    }
    #[test]
    fn many_clicks_leave_the_flag_the_colour_and_the_knob_in_agreement() {
        // 51 clicks starting off -> on. Every push must agree with the flag it
        // accompanies; a drift between the three is exactly the class of bug that makes
        // a switch render inverted after a while.
        let mut expected_on = false;
        let (_, state) = laid_out(Switch::create(false));
        for i in 0..51u32 {
            let (styled, _) = laid_out(Switch::create(false));
            let (_, changes) = click(styled, &state, node(TRACK));
            expected_on = !expected_on;
            let (color, margin) = if expected_on {
                (TRACK_ON_COLOR, TRAVEL)
            } else {
                (TRACK_OFF_COLOR, 0.0)
            };
            assert_eq!(
                pushed_backgrounds(&changes)
                    .iter()
                    .map(|(n, b)| (*n, solid_colors(b)))
                    .collect::<Vec<_>>(),
                vec![(NodeId::new(TRACK), vec![color])],
                "click #{i}: the pushed track colour disagrees with the flag",
            );
            assert_eq!(
                pushed_margins(&changes),
                vec![(NodeId::new(KNOB_NODE), margin)],
                "click #{i}: the pushed knob offset disagrees with the flag",
            );
            assert_eq!(is_checked(&state), expected_on, "click #{i}: the flag drifted");
        }
        assert!(is_checked(&state), "an odd number of clicks left the switch off");
    }
    #[test]
    fn a_click_writes_to_two_distinct_nodes_and_never_to_the_wrong_one() {
        // The track gets the colour, the knob gets the offset — never the other way
        // round, and never both onto the same node.
        let (styled, state) = laid_out(Switch::create(false));
        let (_, changes) = click(styled, &state, node(TRACK));
        let bg_nodes: Vec<_> = pushed_backgrounds(&changes).into_iter().map(|(n, _)| n).collect();
        let margin_nodes: Vec<_> = pushed_margins(&changes).into_iter().map(|(n, _)| n).collect();
        assert_eq!(bg_nodes, vec![NodeId::new(TRACK)]);
        assert_eq!(margin_nodes, vec![NodeId::new(KNOB_NODE)]);
        assert_ne!(
            bg_nodes, margin_nodes,
            "the colour and the knob offset landed on the same node",
        );
    }
}