1
//! Tooltip widget — wraps an arbitrary anchor [`Dom`] and shows a small text
2
//! popup near it while the pointer hovers, hiding it again on leave.
3
//!
4
//! ## Implementation note (CSS-based, see `TODO2` below)
5
//!
6
//! The drop-down popup path (`open_menu_for_hit_node` / `MenuPopupPosition`) is
7
//! built for *menus* — a list of clickable `MenuItem`s — not arbitrary text
8
//! shown next to an anchor, and it would also require a live window/hit-test to
9
//! verify. This widget therefore takes the simpler, fully-compilable and
10
//! self-contained CSS route the recipe allows: the tip is an absolutely-
11
//! positioned child of a `position: relative` wrapper, hidden by default
12
//! (`opacity: 0`) and revealed on `MouseEnter` / hidden on `MouseLeave` via
13
//! `set_css_property`. No user callbacks are needed — the show/hide handlers are
14
//! internal.
15
//!
16
//! TODO2: this is a CSS simplification of a "real" floating popover. The tip is
17
//! placed at a fixed offset below the anchor (it does not measure the anchor's
18
//! height, flip when near a screen edge, or escape an `overflow: hidden`
19
//! ancestor). A future revision could route through the window-popup / menu
20
//! popup path for true screen-anchored positioning once that is runtime-
21
//! verifiable.
22
//!
23
//! Key types: [`Tooltip`].
24

            
25
use azul_core::{
26
    callbacks::{CoreCallback, CoreCallbackData, Update},
27
    dom::{Dom, EventFilter, HoverEventFilter, IdOrClass, IdOrClass::Class, IdOrClassVec},
28
    refany::{OptionRefAny, RefAny},
29
};
30
use azul_css::dynamic_selector::{CssPropertyWithConditions, CssPropertyWithConditionsVec};
31
use azul_css::{
32
    props::{
33
        basic::{color::ColorU, StyleFontSize},
34
        layout::{LayoutDisplay, LayoutPosition, LayoutFlexGrow, LayoutTop, LayoutLeft, LayoutPaddingLeft, LayoutPaddingRight, LayoutPaddingTop, LayoutPaddingBottom},
35
        property::{CssProperty, StyleWhiteSpaceValue},
36
        style::{StyleBackgroundContent, StyleBackgroundContentVec, StyleBorderTopLeftRadius, StyleBorderTopRightRadius, StyleBorderBottomLeftRadius, StyleBorderBottomRightRadius, StyleTextColor, StyleWhiteSpace, StyleOpacity},
37
    },
38
    AzString,
39
};
40

            
41
use crate::callbacks::CallbackInfo;
42

            
43
static TOOLTIP_WRAPPER_CLASS: &[IdOrClass] =
44
    &[Class(AzString::from_const_str("__azul-native-tooltip"))];
45
static TOOLTIP_TIP_CLASS: &[IdOrClass] =
46
    &[Class(AzString::from_const_str("__azul-native-tooltip-tip"))];
47

            
48
// ---- layout (logical px) ----
49
/// Fixed vertical offset of the tip below the wrapper's top edge. A
50
/// simplification — see the module-level `TODO2`.
51
const TIP_OFFSET_Y: isize = 22;
52
const TIP_RADIUS: isize = 4;
53

            
54
// ---- colours ----
55
/// Tip background (#333333, dark).
56
const TIP_BG_COLOR: ColorU = ColorU {
57
    r: 51,
58
    g: 51,
59
    b: 51,
60
    a: 240,
61
};
62
/// Tip text colour (white).
63
const TIP_TEXT_COLOR: ColorU = ColorU {
64
    r: 255,
65
    g: 255,
66
    b: 255,
67
    a: 255,
68
};
69

            
70
const TIP_BG_ITEMS: &[StyleBackgroundContent] = &[StyleBackgroundContent::Color(TIP_BG_COLOR)];
71
const TIP_BG: StyleBackgroundContentVec = StyleBackgroundContentVec::from_const_slice(TIP_BG_ITEMS);
72

            
73
/// Wrapper around the anchor: an inline-block positioning context so the
74
/// absolutely-positioned tip is placed relative to it.
75
static TOOLTIP_WRAPPER_STYLE: &[CssPropertyWithConditions] = &[
76
    CssPropertyWithConditions::simple(CssProperty::const_display(LayoutDisplay::InlineBlock)),
77
    CssPropertyWithConditions::simple(CssProperty::const_position(LayoutPosition::Relative)),
78
    CssPropertyWithConditions::simple(CssProperty::const_flex_grow(LayoutFlexGrow::const_new(0))),
79
];
80

            
81
/// The tip itself: absolutely positioned, hidden by default (`opacity: 0`).
82
static TOOLTIP_TIP_STYLE: &[CssPropertyWithConditions] = &[
83
    CssPropertyWithConditions::simple(CssProperty::const_position(LayoutPosition::Absolute)),
84
    CssPropertyWithConditions::simple(CssProperty::const_top(LayoutTop::const_px(TIP_OFFSET_Y))),
85
    CssPropertyWithConditions::simple(CssProperty::const_left(LayoutLeft::const_px(0))),
86
    CssPropertyWithConditions::simple(CssProperty::const_padding_left(LayoutPaddingLeft::const_px(
87
        8,
88
    ))),
89
    CssPropertyWithConditions::simple(CssProperty::const_padding_right(
90
        LayoutPaddingRight::const_px(8),
91
    )),
92
    CssPropertyWithConditions::simple(CssProperty::const_padding_top(LayoutPaddingTop::const_px(4))),
93
    CssPropertyWithConditions::simple(CssProperty::const_padding_bottom(
94
        LayoutPaddingBottom::const_px(4),
95
    )),
96
    CssPropertyWithConditions::simple(CssProperty::const_border_top_left_radius(
97
        StyleBorderTopLeftRadius::const_px(TIP_RADIUS),
98
    )),
99
    CssPropertyWithConditions::simple(CssProperty::const_border_top_right_radius(
100
        StyleBorderTopRightRadius::const_px(TIP_RADIUS),
101
    )),
102
    CssPropertyWithConditions::simple(CssProperty::const_border_bottom_left_radius(
103
        StyleBorderBottomLeftRadius::const_px(TIP_RADIUS),
104
    )),
105
    CssPropertyWithConditions::simple(CssProperty::const_border_bottom_right_radius(
106
        StyleBorderBottomRightRadius::const_px(TIP_RADIUS),
107
    )),
108
    CssPropertyWithConditions::simple(CssProperty::const_background_content(TIP_BG)),
109
    CssPropertyWithConditions::simple(CssProperty::const_text_color(StyleTextColor {
110
        inner: TIP_TEXT_COLOR,
111
    })),
112
    CssPropertyWithConditions::simple(CssProperty::const_font_size(StyleFontSize::const_px(12))),
113
    // Preserve the tip on one line so it does not wrap into the anchor's width.
114
    CssPropertyWithConditions::simple(CssProperty::WhiteSpace(StyleWhiteSpaceValue::Exact(
115
        StyleWhiteSpace::Nowrap,
116
    ))),
117
    // Hidden until hovered.
118
    CssPropertyWithConditions::simple(CssProperty::const_opacity(StyleOpacity::const_new(0))),
119
];
120

            
121
/// A tooltip: an anchor [`Dom`] plus the text shown on hover.
122
#[derive(Debug, Clone, PartialEq, Eq)]
123
#[repr(C)]
124
pub struct Tooltip {
125
    /// The element the tooltip is attached to.
126
    pub anchor: Dom,
127
    /// The text shown in the tip popup.
128
    pub text: AzString,
129
    /// Style of the positioning wrapper around the anchor.
130
    pub wrapper_style: CssPropertyWithConditionsVec,
131
    /// Style of the tip popup.
132
    pub tip_style: CssPropertyWithConditionsVec,
133
}
134

            
135
impl Default for Tooltip {
136
46
    fn default() -> Self {
137
46
        Self::new(Dom::default(), AzString::from_const_str(""))
138
46
    }
139
}
140

            
141
impl Tooltip {
142
    /// Creates a tooltip wrapping `anchor` that shows `text` on hover.
143
118
    #[must_use] pub fn new(anchor: Dom, text: AzString) -> Self {
144
118
        Self {
145
118
            anchor,
146
118
            text,
147
118
            wrapper_style: CssPropertyWithConditionsVec::from_const_slice(TOOLTIP_WRAPPER_STYLE),
148
118
            tip_style: CssPropertyWithConditionsVec::from_const_slice(TOOLTIP_TIP_STYLE),
149
118
        }
150
118
    }
151

            
152
    /// Sets the tip text.
153
    #[inline]
154
36
    pub fn set_text(&mut self, text: AzString) {
155
36
        self.text = text;
156
36
    }
157

            
158
    /// Builder-style setter for the tip text.
159
    #[inline]
160
18
    #[must_use] pub fn with_text(mut self, text: AzString) -> Self {
161
18
        self.set_text(text);
162
18
        self
163
18
    }
164

            
165
    /// Overrides the tip popup style.
166
    #[inline]
167
9
    pub fn set_tip_style(&mut self, style: CssPropertyWithConditionsVec) {
168
9
        self.tip_style = style;
169
9
    }
170

            
171
    /// Builder-style setter for the tip popup style.
172
    #[inline]
173
7
    #[must_use] pub fn with_tip_style(mut self, style: CssPropertyWithConditionsVec) -> Self {
174
7
        self.set_tip_style(style);
175
7
        self
176
7
    }
177

            
178
    #[inline]
179
13
    #[must_use] pub fn swap_with_default(&mut self) -> Self {
180
13
        let mut s = Self::default();
181
13
        core::mem::swap(&mut s, self);
182
13
        s
183
13
    }
184

            
185
35
    #[must_use] pub fn dom(self) -> Dom {
186
        // The hover handlers only navigate the DOM (the tip is found relative to
187
        // the hovered wrapper), so no per-tooltip state is needed.
188
35
        let marker = RefAny::new(());
189

            
190
35
        let tip = Dom::create_p_with_text(self.text)
191
35
            .with_ids_and_classes(IdOrClassVec::from_const_slice(TOOLTIP_TIP_CLASS))
192
35
            .with_css_props(self.tip_style);
193

            
194
35
        Dom::create_div()
195
35
            .with_ids_and_classes(IdOrClassVec::from_const_slice(TOOLTIP_WRAPPER_CLASS))
196
35
            .with_css_props(self.wrapper_style)
197
35
            .with_callbacks(
198
35
                vec![
199
35
                    CoreCallbackData {
200
35
                        event: EventFilter::Hover(HoverEventFilter::MouseEnter),
201
35
                        callback: CoreCallback {
202
35
                            cb: on_tooltip_enter as usize,
203
35
                            ctx: OptionRefAny::None,
204
35
                        },
205
35
                        refany: marker.clone(),
206
35
                    },
207
35
                    CoreCallbackData {
208
35
                        event: EventFilter::Hover(HoverEventFilter::MouseLeave),
209
35
                        callback: CoreCallback {
210
35
                            cb: on_tooltip_leave as usize,
211
35
                            ctx: OptionRefAny::None,
212
35
                        },
213
35
                        refany: marker,
214
35
                    },
215
                ]
216
35
                .into(),
217
            )
218
            // children: [anchor, tip] — the tip is the anchor's next sibling.
219
35
            .with_children(vec![self.anchor, tip].into())
220
35
    }
221
}
222

            
223
/// Returns the tip node (the second child) of the hovered wrapper.
224
96
fn tip_of_wrapper(info: &CallbackInfo) -> Option<azul_core::dom::DomNodeId> {
225
96
    let wrapper = info.get_hit_node();
226
96
    let anchor = info.get_first_child(wrapper)?;
227
82
    info.get_next_sibling(anchor)
228
96
}
229

            
230
/// Pointer entered the wrapper → reveal the tip.
231
77
extern "C" fn on_tooltip_enter(_data: RefAny, mut info: CallbackInfo) -> Update {
232
77
    if let Some(tip) = tip_of_wrapper(&info) {
233
71
        info.set_css_property(tip, CssProperty::const_opacity(StyleOpacity::const_new(100)));
234
71
    }
235
77
    Update::DoNothing
236
77
}
237

            
238
/// Pointer left the wrapper → hide the tip.
239
10
extern "C" fn on_tooltip_leave(_data: RefAny, mut info: CallbackInfo) -> Update {
240
10
    if let Some(tip) = tip_of_wrapper(&info) {
241
5
        info.set_css_property(tip, CssProperty::const_opacity(StyleOpacity::const_new(0)));
242
5
    }
243
10
    Update::DoNothing
244
10
}
245

            
246
impl From<Tooltip> for Dom {
247
1
    fn from(t: Tooltip) -> Self {
248
1
        t.dom()
249
1
    }
250
}
251

            
252
#[cfg(test)]
253
// `assertions_on_constants`: these are deliberate invariant guards over sibling
254
// `const`s in this module. They are const-foldable *today*, which is exactly the
255
// point — they must go red the moment someone edits one of those constants into an
256
// inconsistent value. Deleting them (clippy's suggestion) would delete the check.
257
#[allow(clippy::assertions_on_constants)]
258
mod autotest_generated {
259
    use std::{
260
        collections::{BTreeMap, HashMap},
261
        sync::{Arc, Mutex},
262
    };
263

            
264
    use azul_core::{
265
        dom::{DomId, DomNodeId, NodeId, NodeType},
266
        geom::{LogicalRect, OptionLogicalPosition},
267
        gl::OptionGlContextPtr,
268
        hit_test::ScrollPosition,
269
        resources::RendererResources,
270
        styled_dom::{NodeHierarchyItemId, StyledDom},
271
        window::{MonitorVec, RawWindowHandle},
272
    };
273
    use azul_css::{props::property::CssPropertyType, system::SystemStyle};
274
    use rust_fontconfig::FcFontCache;
275

            
276
    use super::*;
277
    #[cfg(feature = "icu")]
278
    use crate::icu::IcuLocalizerHandle;
279
    use crate::{
280
        callbacks::{CallbackChange, CallbackInfoRefData, ExternalSystemCallbacks},
281
        solver3::{display_list::DisplayList, layout_tree::LayoutTree},
282
        window::{DomLayoutResult, LayoutWindow},
283
        window_state::FullWindowState,
284
    };
285

            
286
    // ------------------------------------------------------------------
287
    // Helpers — DOM inspection
288
    // ------------------------------------------------------------------
289

            
290
    const WRAPPER_CLASS_NAME: &str = "__azul-native-tooltip";
291
    const TIP_CLASS_NAME: &str = "__azul-native-tooltip-tip";
292

            
293
    /// True if `node` carries the CSS class `name`.
294
    fn has_class(node: &Dom, name: &str) -> bool {
295
        node.root
296
            .get_ids_and_classes()
297
            .as_ref()
298
            .iter()
299
            .any(|c| matches!(c, Class(s) if s.as_str() == name))
300
    }
301

            
302
    /// The text of a text node, looking through the `<p>` block wrapper the
303
    /// label convention mandates (`p > text`).
304
    fn text_of(node: &Dom) -> Option<&str> {
305
        match node.root.get_node_type() {
306
            NodeType::Text(s) => Some(s.as_ref().as_str()),
307
            NodeType::P => match node.children.as_ref() {
308
                [only] => match only.root.get_node_type() {
309
                    NodeType::Text(s) => Some(s.as_ref().as_str()),
310
                    _ => None,
311
                },
312
                _ => None,
313
            },
314
            _ => None,
315
        }
316
    }
317

            
318
    /// The *inline* properties a rendered node carries, in declaration order.
319
    fn inline_properties(node: &Dom) -> Vec<CssProperty> {
320
        node.root
321
            .style
322
            .iter_inline_properties()
323
            .map(|(p, _)| p.clone())
324
            .collect()
325
    }
326

            
327
    /// The property *types* of a style vec, in declaration order.
328
    fn prop_types(style: &CssPropertyWithConditionsVec) -> Vec<CssPropertyType> {
329
        style
330
            .as_ref()
331
            .iter()
332
            .map(|p| p.property.get_type())
333
            .collect()
334
    }
335

            
336
    /// *Every* opacity declared in a style vec, normalized to `0.0..=1.0`, in
337
    /// declaration order. More than one entry means the later one silently
338
    /// shadows the earlier — the tip would then not be hidden by default.
339
    fn declared_opacities(style: &CssPropertyWithConditionsVec) -> Vec<f32> {
340
        style
341
            .as_ref()
342
            .iter()
343
            .filter_map(|p| match &p.property {
344
                CssProperty::Opacity(v) => v.get_property().map(|o| o.inner.normalized()),
345
                _ => None,
346
            })
347
            .collect()
348
    }
349

            
350
    fn declared_positions(style: &CssPropertyWithConditionsVec) -> Vec<LayoutPosition> {
351
        style
352
            .as_ref()
353
            .iter()
354
            .filter_map(|p| match &p.property {
355
                CssProperty::Position(v) => v.get_property().copied(),
356
                _ => None,
357
            })
358
            .collect()
359
    }
360

            
361
    fn declared_displays(style: &CssPropertyWithConditionsVec) -> Vec<LayoutDisplay> {
362
        style
363
            .as_ref()
364
            .iter()
365
            .filter_map(|p| match &p.property {
366
                CssProperty::Display(v) => v.get_property().copied(),
367
                _ => None,
368
            })
369
            .collect()
370
    }
371

            
372
    /// The `CssPropertyType` of `opacity`, without hard-coding the enum variant.
373
    fn opacity_ty() -> CssPropertyType {
374
        CssProperty::const_opacity(StyleOpacity::const_new(0)).get_type()
375
    }
376

            
377
    /// A style vec built from an owned `Vec` (the shape a *user* override has).
378
    fn style_of(props: Vec<CssProperty>) -> CssPropertyWithConditionsVec {
379
        props
380
            .into_iter()
381
            .map(CssPropertyWithConditions::simple)
382
            .collect::<Vec<_>>()
383
            .into()
384
    }
385

            
386
    /// A `Dom` nested `depth` levels deep — a stress input for the recursive
387
    /// child bookkeeping `dom()` relies on.
388
    fn nested_anchor(depth: usize) -> Dom {
389
        let mut d = Dom::create_div().with_child(Dom::create_text_do_not_use_without_block_level_wrapper("leaf"));
390
        for _ in 0..depth {
391
            d = Dom::create_div().with_child(d);
392
        }
393
        d
394
    }
395

            
396
    /// Inputs chosen to break text handling: empty, whitespace-only, interior
397
    /// NULs, control characters, astral-plane emoji, ZWJ sequences, stacked
398
    /// combining marks, a bidi override, a BOM, and two very large strings.
399
    fn adversarial_texts() -> Vec<String> {
400
        vec![
401
            String::new(),
402
            " ".to_string(),
403
            "\0".to_string(),
404
            "a\0b\0".to_string(),
405
            "\n\r\t\u{0b}\u{0c}".to_string(),
406
            "🦀".to_string(),
407
            "👨‍👩‍👧‍👦".to_string(),
408
            "e\u{0301}\u{0301}\u{0301}\u{0301}".to_string(),
409
            "\u{202e}gnirts detrevni".to_string(),
410
            "\u{feff}bom-prefixed".to_string(),
411
            "fullwidth".to_string(),
412
            "\u{fdfa}".to_string(),
413
            "line\nbreak".to_string(),
414
            "a".repeat(100_000),
415
            "🦀".repeat(50_000),
416
        ]
417
    }
418

            
419
    // ------------------------------------------------------------------
420
    // Helpers — callback harness
421
    // ------------------------------------------------------------------
422

            
423
    /// A `DomLayoutResult` with an *empty* layout tree: the hover handlers only
424
    /// walk `styled_dom.node_hierarchy`, so no real layout (and no font) is
425
    /// needed.
426
    fn layout_result(styled_dom: StyledDom) -> DomLayoutResult {
427
        DomLayoutResult {
428
            styled_dom,
429
            layout_tree: LayoutTree {
430
                nodes: Vec::new(),
431
                warm: Vec::new(),
432
                cold: Vec::new(),
433
                root: 0,
434
                dom_to_layout: BTreeMap::new(),
435
                children_arena: Vec::new(),
436
                children_offsets: Vec::new(),
437
                subtree_needs_intrinsic: Vec::new(),
438
            },
439
            calculated_positions: Vec::new(),
440
            viewport: LogicalRect::zero(),
441
            display_list: Arc::new(DisplayList::default()),
442
            scroll_ids: HashMap::new(),
443
            scroll_id_to_node_id: HashMap::new(),
444
        }
445
    }
446

            
447
    fn node(index: usize) -> NodeHierarchyItemId {
448
        NodeHierarchyItemId::from_crate_internal(Some(NodeId::new(index)))
449
    }
450

            
451
    /// Runs `f` against a `CallbackInfo` whose hit node is `hit`, backed by a
452
    /// `LayoutWindow` holding `styled` (or holding nothing at all, when `styled`
453
    /// is `None`). Returns `f`'s result plus every recorded `CallbackChange`.
454
    fn with_info<R>(
455
        styled: Option<StyledDom>,
456
        hit: NodeHierarchyItemId,
457
        f: impl FnOnce(CallbackInfo) -> R,
458
    ) -> (R, Vec<CallbackChange>) {
459
        let mut layout_window =
460
            LayoutWindow::new(FcFontCache::default()).expect("LayoutWindow::new failed");
461
        if let Some(sd) = styled {
462
            layout_window
463
                .layout_results
464
                .insert(DomId::ROOT_ID, layout_result(sd));
465
        }
466

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

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

            
492
        let changes: Arc<Mutex<Vec<CallbackChange>>> = Arc::new(Mutex::new(Vec::new()));
493

            
494
        let info = CallbackInfo::new(
495
            &ref_data,
496
            &changes,
497
            DomNodeId {
498
                dom: DomId::ROOT_ID,
499
                node: hit,
500
            },
501
            OptionLogicalPosition::None,
502
            OptionLogicalPosition::None,
503
        );
504

            
505
        let out = f(info);
506
        let recorded = core::mem::take(&mut *changes.lock().expect("change log poisoned"));
507
        (out, recorded)
508
    }
509

            
510
    /// Every recorded CSS write, as `(node index, properties)`.
511
    fn css_writes(changes: &[CallbackChange]) -> Vec<(usize, Vec<CssProperty>)> {
512
        changes
513
            .iter()
514
            .filter_map(|c| match c {
515
                CallbackChange::ChangeNodeCssProperties {
516
                    node_id, properties, ..
517
                } => Some((node_id.index(), properties.as_ref().to_vec())),
518
                _ => None,
519
            })
520
            .collect()
521
    }
522

            
523
    /// Every recorded opacity write, as `(node index, normalized opacity)`.
524
    fn opacity_writes(changes: &[CallbackChange]) -> Vec<(usize, f32)> {
525
        let mut out = Vec::new();
526
        for (idx, props) in css_writes(changes) {
527
            for p in &props {
528
                if let CssProperty::Opacity(v) = p {
529
                    if let Some(o) = v.get_property() {
530
                        out.push((idx, o.inner.normalized()));
531
                    }
532
                }
533
            }
534
        }
535
        out
536
    }
537

            
538
    /// Index of the first node carrying `class` in a flattened `StyledDom`.
539
    fn index_of_class(styled: &StyledDom, class: &str) -> Option<usize> {
540
        styled.node_data.as_ref().iter().position(|nd| {
541
            nd.get_ids_and_classes()
542
                .as_ref()
543
                .iter()
544
                .any(|c| matches!(c, Class(s) if s.as_str() == class))
545
        })
546
    }
547

            
548
    /// A three-node styled DOM — `root(0)` with children `anchor(1)` and
549
    /// `tip(2)` — i.e. the exact hierarchy `tip_of_wrapper` walks.
550
    fn anchor_tip_dom() -> StyledDom {
551
        let styled = StyledDom::create_from_dom(
552
            Dom::create_div()
553
                .with_child(Dom::create_div())
554
                .with_child(Dom::create_div()),
555
        );
556
        assert_eq!(
557
            styled.node_hierarchy.as_ref().len(),
558
            3,
559
            "fixture must flatten to exactly wrapper/anchor/tip"
560
        );
561
        styled
562
    }
563

            
564
    // ------------------------------------------------------------------
565
    // Tooltip::new / Default
566
    // ------------------------------------------------------------------
567

            
568
    #[test]
569
    fn new_stores_anchor_and_text_verbatim() {
570
        let anchor = Dom::create_div().with_child(Dom::create_text_do_not_use_without_block_level_wrapper("anchor"));
571
        let text = AzString::from("tip".to_string());
572
        let t = Tooltip::new(anchor.clone(), text.clone());
573

            
574
        assert_eq!(t.anchor, anchor, "the anchor must be stored unmodified");
575
        assert_eq!(t.text, text, "the text must be stored unmodified");
576
    }
577

            
578
    #[test]
579
    fn new_uses_the_static_style_tables() {
580
        let t = Tooltip::new(Dom::create_div(), AzString::from_const_str("x"));
581

            
582
        assert_eq!(
583
            t.wrapper_style,
584
            CssPropertyWithConditionsVec::from_const_slice(TOOLTIP_WRAPPER_STYLE)
585
        );
586
        assert_eq!(
587
            t.tip_style,
588
            CssPropertyWithConditionsVec::from_const_slice(TOOLTIP_TIP_STYLE)
589
        );
590
        assert_eq!(t.wrapper_style.len(), TOOLTIP_WRAPPER_STYLE.len());
591
        assert_eq!(t.tip_style.len(), TOOLTIP_TIP_STYLE.len());
592
    }
593

            
594
    #[test]
595
    fn new_is_pure_and_independent_of_the_arguments() {
596
        // The style tables must not vary with the anchor/text — a widget whose
597
        // styling depended on its content would be unstyleable.
598
        let a = Tooltip::new(Dom::create_div(), AzString::from_const_str(""));
599
        let b = Tooltip::new(
600
            nested_anchor(8),
601
            AzString::from("🦀".repeat(1000)),
602
        );
603

            
604
        assert_eq!(a.wrapper_style, b.wrapper_style);
605
        assert_eq!(a.tip_style, b.tip_style);
606
    }
607

            
608
    #[test]
609
    fn new_survives_adversarial_text() {
610
        for s in adversarial_texts() {
611
            let t = Tooltip::new(Dom::create_div(), AzString::from(s.clone()));
612
            assert_eq!(
613
                t.text.as_str(),
614
                s.as_str(),
615
                "text must round-trip byte-for-byte through AzString"
616
            );
617
            assert_eq!(
618
                t.text.as_str().len(),
619
                s.len(),
620
                "byte length must be preserved (no re-encoding / truncation at NUL)"
621
            );
622
        }
623
    }
624

            
625
    #[test]
626
    fn new_with_a_deeply_nested_anchor_keeps_the_child_count_consistent() {
627
        let anchor = nested_anchor(64);
628
        let expected = anchor.estimated_total_children;
629
        let t = Tooltip::new(anchor.clone(), AzString::from_const_str("deep"));
630

            
631
        assert_eq!(t.anchor, anchor);
632
        assert_eq!(
633
            t.anchor.estimated_total_children, expected,
634
            "the constructor must not disturb the anchor's cached descendant count"
635
        );
636
    }
637

            
638
    #[test]
639
    fn new_with_a_very_wide_anchor_does_not_panic() {
640
        let anchor = Dom::create_div()
641
            .with_children((0..2000).map(|_| Dom::create_div()).collect::<Vec<_>>().into());
642
        let t = Tooltip::new(anchor, AzString::from_const_str("wide"));
643

            
644
        assert_eq!(t.anchor.children.as_ref().len(), 2000);
645
    }
646

            
647
    #[test]
648
    fn default_is_an_empty_body_anchor_with_empty_text() {
649
        let d = Tooltip::default();
650

            
651
        assert_eq!(d.text.as_str(), "");
652
        assert_eq!(d.anchor, Dom::default());
653
        assert_eq!(
654
            d,
655
            Tooltip::new(Dom::default(), AzString::from_const_str("")),
656
            "Default must agree with the documented constructor call"
657
        );
658
    }
659

            
660
    // ------------------------------------------------------------------
661
    // set_text / with_text
662
    // ------------------------------------------------------------------
663

            
664
    #[test]
665
    fn set_text_and_with_text_agree_and_touch_nothing_else() {
666
        for s in adversarial_texts() {
667
            let base = Tooltip::new(
668
                Dom::create_div().with_child(Dom::create_text_do_not_use_without_block_level_wrapper("a")),
669
                AzString::from_const_str("initial"),
670
            );
671

            
672
            let mut mutated = base.clone();
673
            mutated.set_text(AzString::from(s.clone()));
674
            let built = base.clone().with_text(AzString::from(s.clone()));
675

            
676
            assert_eq!(mutated, built, "with_text must be set_text + self");
677
            assert_eq!(mutated.text.as_str(), s.as_str());
678
            assert_eq!(mutated.anchor, base.anchor, "the anchor must be untouched");
679
            assert_eq!(mutated.wrapper_style, base.wrapper_style);
680
            assert_eq!(mutated.tip_style, base.tip_style);
681
        }
682
    }
683

            
684
    #[test]
685
    fn set_text_is_last_write_wins() {
686
        let mut t = Tooltip::default();
687
        let huge = "x".repeat(200_000);
688

            
689
        t.set_text(AzString::from(huge.clone()));
690
        assert_eq!(t.text.as_str().len(), huge.len());
691

            
692
        t.set_text(AzString::from_const_str(""));
693
        assert_eq!(t.text.as_str(), "", "a later empty write must win");
694

            
695
        t.set_text(AzString::from("🦀".to_string()));
696
        assert_eq!(t.text.as_str(), "🦀");
697
    }
698

            
699
    #[test]
700
    fn text_and_tip_style_setters_commute() {
701
        let style = style_of(vec![CssProperty::const_opacity(StyleOpacity::const_new(42))]);
702
        let text = AzString::from("both".to_string());
703

            
704
        let a = Tooltip::default()
705
            .with_text(text.clone())
706
            .with_tip_style(style.clone());
707
        let b = Tooltip::default()
708
            .with_tip_style(style)
709
            .with_text(text);
710

            
711
        assert_eq!(a, b, "the two builder setters must be independent");
712
    }
713

            
714
    // ------------------------------------------------------------------
715
    // set_tip_style / with_tip_style
716
    // ------------------------------------------------------------------
717

            
718
    #[test]
719
    fn set_tip_style_and_with_tip_style_agree() {
720
        let style = style_of(vec![
721
            CssProperty::const_position(LayoutPosition::Fixed),
722
            CssProperty::const_opacity(StyleOpacity::const_new(100)),
723
        ]);
724

            
725
        let mut mutated = Tooltip::default();
726
        mutated.set_tip_style(style.clone());
727
        let built = Tooltip::default().with_tip_style(style.clone());
728

            
729
        assert_eq!(mutated, built);
730
        assert_eq!(mutated.tip_style, style, "the style must be stored verbatim");
731
    }
732

            
733
    #[test]
734
    fn set_tip_style_does_not_touch_the_wrapper_style() {
735
        // The wrapper carries `position: relative`; losing it would make the
736
        // absolutely-positioned tip escape to the nearest positioned ancestor.
737
        let base = Tooltip::default();
738
        let mut t = base.clone();
739
        t.set_tip_style(CssPropertyWithConditionsVec::from_const_slice(&[]));
740

            
741
        assert_eq!(t.wrapper_style, base.wrapper_style);
742
        assert_eq!(t.text, base.text);
743
        assert_eq!(t.anchor, base.anchor);
744
    }
745

            
746
    #[test]
747
    fn tip_style_can_be_emptied_and_the_widget_still_builds() {
748
        let t = Tooltip::new(Dom::create_div(), AzString::from_const_str("naked"))
749
            .with_tip_style(CssPropertyWithConditionsVec::from_const_slice(&[]));
750
        assert_eq!(t.tip_style.len(), 0);
751

            
752
        let dom = t.dom();
753
        let tip = &dom.children.as_ref()[1];
754
        assert!(
755
            inline_properties(tip).is_empty(),
756
            "an empty override must produce an unstyled tip, not the default table"
757
        );
758
        assert_eq!(text_of(tip), Some("naked"));
759
    }
760

            
761
    #[test]
762
    fn a_huge_tip_style_is_stored_verbatim() {
763
        let props: Vec<CssProperty> = (0..10_000)
764
            .map(|i| CssProperty::const_opacity(StyleOpacity::const_new(i % 101)))
765
            .collect();
766
        let style = style_of(props);
767

            
768
        let t = Tooltip::default().with_tip_style(style.clone());
769
        assert_eq!(t.tip_style.len(), 10_000);
770
        assert_eq!(t.tip_style, style);
771

            
772
        let dom = t.dom();
773
        assert_eq!(
774
            inline_properties(&dom.children.as_ref()[1]).len(),
775
            10_000,
776
            "every declaration must survive the DOM build"
777
        );
778
    }
779

            
780
    // ------------------------------------------------------------------
781
    // swap_with_default
782
    // ------------------------------------------------------------------
783

            
784
    #[test]
785
    fn swap_with_default_returns_the_old_value_and_leaves_a_default() {
786
        let original = Tooltip::new(
787
            Dom::create_div().with_child(Dom::create_text_do_not_use_without_block_level_wrapper("anchor")),
788
            AzString::from("tip".to_string()),
789
        )
790
        .with_tip_style(style_of(vec![CssProperty::const_opacity(
791
            StyleOpacity::const_new(7),
792
        )]));
793

            
794
        let mut t = original.clone();
795
        let taken = t.swap_with_default();
796

            
797
        assert_eq!(taken, original, "the previous value must be handed back");
798
        assert_eq!(t, Tooltip::default(), "self must be left as a default");
799
    }
800

            
801
    #[test]
802
    fn swap_with_default_is_stable_under_repetition() {
803
        let mut t = Tooltip::default().with_text(AzString::from("a".repeat(50_000)));
804

            
805
        let first = t.swap_with_default();
806
        assert_eq!(first.text.as_str().len(), 50_000);
807

            
808
        for _ in 0..10 {
809
            let again = t.swap_with_default();
810
            assert_eq!(again, Tooltip::default());
811
            assert_eq!(t, Tooltip::default());
812
        }
813
    }
814

            
815
    #[test]
816
    fn swap_with_default_on_a_default_is_an_identity() {
817
        let mut t = Tooltip::default();
818
        let taken = t.swap_with_default();
819

            
820
        assert_eq!(taken, Tooltip::default());
821
        assert_eq!(t, Tooltip::default());
822
    }
823

            
824
    // ------------------------------------------------------------------
825
    // Static style tables
826
    // ------------------------------------------------------------------
827

            
828
    #[test]
829
    fn tip_style_starts_hidden_with_exactly_one_opacity_declaration() {
830
        // Two opacity declarations would make the last one win and could leave
831
        // the tip permanently visible.
832
        assert_eq!(
833
            declared_opacities(&CssPropertyWithConditionsVec::from_const_slice(
834
                TOOLTIP_TIP_STYLE
835
            )),
836
            vec![0.0],
837
            "the tip must be hidden by default via a single opacity declaration"
838
        );
839
    }
840

            
841
    #[test]
842
    fn tip_style_is_absolutely_positioned_and_does_not_wrap() {
843
        let style = CssPropertyWithConditionsVec::from_const_slice(TOOLTIP_TIP_STYLE);
844

            
845
        assert_eq!(declared_positions(&style), vec![LayoutPosition::Absolute]);
846
        assert!(
847
            style.as_ref().contains(&CssPropertyWithConditions::simple(
848
                CssProperty::const_top(LayoutTop::const_px(TIP_OFFSET_Y))
849
            )),
850
            "the documented vertical offset must be declared"
851
        );
852
        assert!(
853
            style.as_ref().contains(&CssPropertyWithConditions::simple(
854
                CssProperty::WhiteSpace(StyleWhiteSpaceValue::Exact(StyleWhiteSpace::Nowrap))
855
            )),
856
            "the tip must stay on one line"
857
        );
858
    }
859

            
860
    #[test]
861
    fn wrapper_style_is_an_inline_block_positioning_context() {
862
        let style = CssPropertyWithConditionsVec::from_const_slice(TOOLTIP_WRAPPER_STYLE);
863

            
864
        assert_eq!(declared_displays(&style), vec![LayoutDisplay::InlineBlock]);
865
        assert_eq!(
866
            declared_positions(&style),
867
            vec![LayoutPosition::Relative],
868
            "without `position: relative` the tip would anchor to some ancestor"
869
        );
870
        assert!(
871
            style.as_ref().contains(&CssPropertyWithConditions::simple(
872
                CssProperty::const_flex_grow(LayoutFlexGrow::const_new(0))
873
            )),
874
            "the wrapper must not grow past the anchor"
875
        );
876
    }
877

            
878
    #[test]
879
    fn neither_style_table_declares_a_property_type_twice() {
880
        for (name, table) in [
881
            ("wrapper", TOOLTIP_WRAPPER_STYLE),
882
            ("tip", TOOLTIP_TIP_STYLE),
883
        ] {
884
            let style = CssPropertyWithConditionsVec::from_const_slice(table);
885
            let mut types = prop_types(&style);
886
            let declared = types.len();
887
            assert!(declared > 0, "{name} style must not be empty");
888
            types.sort_unstable();
889
            types.dedup();
890
            assert_eq!(
891
                types.len(),
892
                declared,
893
                "{name}: a duplicated property type would make the later declaration \
894
                 silently win"
895
            );
896
        }
897
    }
898

            
899
    #[test]
900
    fn both_style_tables_apply_unconditionally() {
901
        for table in [TOOLTIP_WRAPPER_STYLE, TOOLTIP_TIP_STYLE] {
902
            assert!(
903
                table.iter().all(|p| p.apply_if.as_ref().is_empty()),
904
                "a stray condition would leave the tooltip unstyled"
905
            );
906
        }
907
    }
908

            
909
    #[test]
910
    fn tip_colours_are_opaque_enough_to_read() {
911
        assert!(
912
            TIP_BG_COLOR.a > 200,
913
            "a near-transparent tip background would be unreadable"
914
        );
915
        assert_eq!(TIP_TEXT_COLOR.a, 255);
916
        assert!(TIP_RADIUS >= 0 && TIP_OFFSET_Y > 0);
917
    }
918

            
919
    // ------------------------------------------------------------------
920
    // Tooltip::dom
921
    // ------------------------------------------------------------------
922

            
923
    #[test]
924
    fn dom_builds_a_wrapper_with_the_anchor_then_the_tip() {
925
        let anchor = Dom::create_div().with_child(Dom::create_text_do_not_use_without_block_level_wrapper("anchor"));
926
        let dom = Tooltip::new(anchor.clone(), AzString::from_const_str("tip")).dom();
927

            
928
        assert!(has_class(&dom, WRAPPER_CLASS_NAME));
929
        assert_eq!(dom.root.get_node_type(), &NodeType::Div);
930

            
931
        let children = dom.children.as_ref();
932
        assert_eq!(children.len(), 2, "children must be exactly [anchor, tip]");
933
        assert_eq!(children[0], anchor, "child 0 must be the anchor, verbatim");
934
        assert!(
935
            has_class(&children[1], TIP_CLASS_NAME),
936
            "child 1 must be the tip"
937
        );
938
        assert_eq!(text_of(&children[1]), Some("tip"));
939
        assert_eq!(
940
            inline_properties(&children[1]).len(),
941
            TOOLTIP_TIP_STYLE.len(),
942
            "the tip must carry the full tip style"
943
        );
944
        assert_eq!(
945
            inline_properties(&dom).len(),
946
            TOOLTIP_WRAPPER_STYLE.len(),
947
            "the wrapper must carry the full wrapper style"
948
        );
949
    }
950

            
951
    #[test]
952
    fn dom_preserves_adversarial_text_byte_for_byte() {
953
        for s in adversarial_texts() {
954
            let dom = Tooltip::new(Dom::create_div(), AzString::from(s.clone())).dom();
955
            let tip = &dom.children.as_ref()[1];
956
            assert_eq!(
957
                text_of(tip),
958
                Some(s.as_str()),
959
                "the tip text must survive the DOM build unchanged"
960
            );
961
        }
962
    }
963

            
964
    #[test]
965
    fn dom_applies_a_custom_tip_style_to_the_tip_only() {
966
        let custom = style_of(vec![CssProperty::const_opacity(StyleOpacity::const_new(
967
            100,
968
        ))]);
969
        let dom = Tooltip::new(Dom::create_div(), AzString::from_const_str("t"))
970
            .with_tip_style(custom.clone())
971
            .dom();
972

            
973
        assert_eq!(
974
            inline_properties(&dom.children.as_ref()[1]),
975
            vec![CssProperty::const_opacity(StyleOpacity::const_new(100))],
976
            "the override must replace the default tip table"
977
        );
978
        assert_eq!(
979
            inline_properties(&dom).len(),
980
            TOOLTIP_WRAPPER_STYLE.len(),
981
            "the wrapper must keep its own style"
982
        );
983
    }
984

            
985
    #[test]
986
    fn dom_binds_exactly_mouse_enter_and_mouse_leave_on_the_wrapper() {
987
        let dom = Tooltip::new(Dom::create_div(), AzString::from_const_str("t")).dom();
988
        let callbacks = dom.root.callbacks.as_ref();
989

            
990
        assert_eq!(callbacks.len(), 2, "exactly two hover handlers are expected");
991
        assert_eq!(
992
            callbacks[0].event,
993
            EventFilter::Hover(HoverEventFilter::MouseEnter)
994
        );
995
        assert_eq!(
996
            callbacks[1].event,
997
            EventFilter::Hover(HoverEventFilter::MouseLeave)
998
        );
999
        assert_eq!(callbacks[0].callback.cb, on_tooltip_enter as usize);
        assert_eq!(callbacks[1].callback.cb, on_tooltip_leave as usize);
        assert!(matches!(callbacks[0].callback.ctx, OptionRefAny::None));
        assert!(matches!(callbacks[1].callback.ctx, OptionRefAny::None));
        assert_eq!(
            callbacks[0].refany, callbacks[1].refany,
            "both handlers must share one marker RefAny (they are stateless)"
        );
    }
    #[test]
    fn dom_binds_no_callbacks_on_the_anchor_or_the_tip() {
        let dom = Tooltip::new(
            Dom::create_div().with_child(Dom::create_text_do_not_use_without_block_level_wrapper("a")),
            AzString::from_const_str("t"),
        )
        .dom();
        for (i, child) in dom.children.as_ref().iter().enumerate() {
            assert!(
                child.root.callbacks.as_ref().is_empty(),
                "child {i} must not carry hover handlers of its own"
            );
        }
    }
    #[test]
    fn from_impl_matches_dom_structurally() {
        // `dom()` mints a fresh marker `RefAny` per call, so the two DOMs are
        // deliberately compared field-by-field rather than with `==`.
        let make = || {
            Tooltip::new(
                Dom::create_div().with_child(Dom::create_text_do_not_use_without_block_level_wrapper("a")),
                AzString::from_const_str("tip"),
            )
        };
        let via_from = Dom::from(make());
        let via_dom = make().dom();
        assert_eq!(via_from.root.get_node_type(), via_dom.root.get_node_type());
        assert_eq!(
            via_from.root.get_ids_and_classes().as_ref(),
            via_dom.root.get_ids_and_classes().as_ref()
        );
        assert_eq!(via_from.root.style, via_dom.root.style);
        assert_eq!(via_from.children.as_ref(), via_dom.children.as_ref());
        assert_eq!(
            via_from.estimated_total_children,
            via_dom.estimated_total_children
        );
        let (a, b) = (
            via_from.root.callbacks.as_ref(),
            via_dom.root.callbacks.as_ref(),
        );
        assert_eq!(a.len(), b.len());
        for (x, y) in a.iter().zip(b.iter()) {
            assert_eq!(x.event, y.event);
            assert_eq!(x.callback.cb, y.callback.cb);
        }
    }
    #[test]
    fn dom_keeps_the_estimated_child_count_consistent_with_the_flattened_tree() {
        // A too-small `estimated_total_children` makes the arena conversion
        // under-allocate and panic on out-of-bounds writes.
        for depth in [0, 1, 8, 64] {
            let dom = Tooltip::new(nested_anchor(depth), AzString::from_const_str("t")).dom();
            let estimated = dom.estimated_total_children;
            let flattened = StyledDom::create_from_dom(dom).node_hierarchy.as_ref().len();
            assert_eq!(
                flattened,
                estimated + 1,
                "depth {depth}: the cached descendant count disagrees with the flattened tree"
            );
        }
    }
    #[test]
    fn dom_of_a_very_wide_anchor_flattens_without_panicking() {
        let anchor = Dom::create_div()
            .with_children((0..2000).map(|_| Dom::create_div()).collect::<Vec<_>>().into());
        let dom = Tooltip::new(anchor, AzString::from_const_str("wide")).dom();
        let styled = StyledDom::create_from_dom(dom);
        assert_eq!(
            styled.node_hierarchy.as_ref().len(),
            1 + 1 + 2000 + 2,
            "wrapper + anchor + 2000 grandchildren + tip <p> + tip text"
        );
    }
    #[test]
    fn dom_of_nested_tooltips_keeps_each_tip_as_the_second_child() {
        let inner = Tooltip::new(Dom::create_div(), AzString::from_const_str("inner")).dom();
        let outer = Tooltip::new(inner, AzString::from_const_str("outer")).dom();
        let outer_children = outer.children.as_ref();
        assert_eq!(outer_children.len(), 2);
        assert_eq!(text_of(&outer_children[1]), Some("outer"));
        let inner_children = outer_children[0].children.as_ref();
        assert_eq!(inner_children.len(), 2);
        assert_eq!(text_of(&inner_children[1]), Some("inner"));
    }
    // ------------------------------------------------------------------
    // tip_of_wrapper
    // ------------------------------------------------------------------
    #[test]
    fn tip_of_wrapper_without_a_layout_result_is_none() {
        let (tip, changes) = with_info(None, node(0), |info| tip_of_wrapper(&info));
        assert_eq!(tip, None);
        assert!(changes.is_empty());
    }
    #[test]
    fn tip_of_wrapper_with_a_stale_hit_node_is_none() {
        for stale in [3usize, 999, usize::MAX / 2] {
            let (tip, _) = with_info(Some(anchor_tip_dom()), node(stale), |info| {
                tip_of_wrapper(&info)
            });
            assert_eq!(tip, None, "node {stale} does not exist in the 3-node fixture");
        }
    }
    #[test]
    fn tip_of_wrapper_with_a_none_hit_node_is_none() {
        let (tip, _) = with_info(
            Some(anchor_tip_dom()),
            NodeHierarchyItemId::NONE,
            |info| tip_of_wrapper(&info),
        );
        assert_eq!(tip, None, "an unset hit node must not resolve to a tip");
    }
    #[test]
    fn tip_of_wrapper_on_a_childless_node_is_none() {
        // node 1 is a leaf -> no first child -> no tip.
        let (tip, _) = with_info(Some(anchor_tip_dom()), node(1), |info| tip_of_wrapper(&info));
        assert_eq!(tip, None);
    }
    #[test]
    fn tip_of_wrapper_without_a_second_child_is_none() {
        let styled = StyledDom::create_from_dom(Dom::create_div().with_child(Dom::create_div()));
        let (tip, _) = with_info(Some(styled), node(0), |info| tip_of_wrapper(&info));
        assert_eq!(
            tip, None,
            "a wrapper with a single child has no tip to reveal"
        );
    }
    #[test]
    fn tip_of_wrapper_returns_the_second_child() {
        let (tip, _) = with_info(Some(anchor_tip_dom()), node(0), |info| tip_of_wrapper(&info));
        assert_eq!(
            tip.and_then(|t| t.node.into_crate_internal()).map(|n| n.index()),
            Some(2)
        );
    }
    #[test]
    fn tip_of_wrapper_finds_the_tip_of_a_real_tooltip_dom() {
        // The anchor has a subtree of its own, so the tip is *not* simply
        // `hit + 1` — the handler must walk first-child -> next-sibling.
        let dom = Tooltip::new(nested_anchor(3), AzString::from_const_str("tip")).dom();
        let styled = StyledDom::create_from_dom(dom);
        let wrapper = index_of_class(&styled, WRAPPER_CLASS_NAME).expect("wrapper class missing");
        let expected = index_of_class(&styled, TIP_CLASS_NAME).expect("tip class missing");
        assert!(
            expected > wrapper + 1,
            "fixture must have a non-trivial anchor subtree between wrapper and tip"
        );
        let (tip, _) = with_info(Some(styled), node(wrapper), |info| tip_of_wrapper(&info));
        assert_eq!(
            tip.and_then(|t| t.node.into_crate_internal()).map(|n| n.index()),
            Some(expected)
        );
    }
    // ------------------------------------------------------------------
    // on_tooltip_enter / on_tooltip_leave
    // ------------------------------------------------------------------
    #[test]
    fn enter_reveals_and_leave_hides_exactly_the_tip() {
        for (name, handler, expected) in [
            (
                "enter",
                on_tooltip_enter as extern "C" fn(RefAny, CallbackInfo) -> Update,
                1.0_f32,
            ),
            (
                "leave",
                on_tooltip_leave as extern "C" fn(RefAny, CallbackInfo) -> Update,
                0.0_f32,
            ),
        ] {
            let (update, changes) = with_info(Some(anchor_tip_dom()), node(0), |info| {
                handler(RefAny::new(()), info)
            });
            assert_eq!(update, Update::DoNothing, "{name} must not relayout");
            assert_eq!(
                opacity_writes(&changes),
                vec![(2, expected)],
                "{name} must write exactly one opacity, on the tip node"
            );
            let writes = css_writes(&changes);
            assert_eq!(
                writes.len(),
                changes.len(),
                "{name} must only record CSS writes"
            );
            assert_eq!(writes[0].1.len(), 1, "{name} must write a single property");
        }
    }
    #[test]
    fn leave_restores_the_opacity_declared_in_the_static_tip_style() {
        // Round-trip: what the handler writes on leave must be exactly what the
        // stylesheet declares, otherwise the tip would not return to its
        // initial rendering.
        let declared = declared_opacities(&CssPropertyWithConditionsVec::from_const_slice(
            TOOLTIP_TIP_STYLE,
        ));
        let (_, changes) = with_info(Some(anchor_tip_dom()), node(0), |info| {
            on_tooltip_leave(RefAny::new(()), info)
        });
        assert_eq!(
            opacity_writes(&changes).iter().map(|(_, o)| *o).collect::<Vec<_>>(),
            declared
        );
    }
    #[test]
    fn enter_then_leave_is_a_round_trip() {
        let enter = with_info(Some(anchor_tip_dom()), node(0), |info| {
            on_tooltip_enter(RefAny::new(()), info)
        })
        .1;
        let leave = with_info(Some(anchor_tip_dom()), node(0), |info| {
            on_tooltip_leave(RefAny::new(()), info)
        })
        .1;
        let (e, l) = (opacity_writes(&enter), opacity_writes(&leave));
        assert_eq!(e.len(), 1);
        assert_eq!(l.len(), 1);
        assert_eq!(e[0].0, l[0].0, "both must target the same node");
        assert!(
            e[0].1 > l[0].1,
            "enter must make the tip more visible than leave ({} vs {})",
            e[0].1,
            l[0].1
        );
        assert_eq!((e[0].1, l[0].1), (1.0, 0.0));
    }
    #[test]
    fn handlers_are_noops_when_there_is_no_tip() {
        let fixtures: Vec<(&str, Option<StyledDom>, NodeHierarchyItemId)> = vec![
            ("no layout result", None, node(0)),
            ("stale hit node", Some(anchor_tip_dom()), node(999)),
            ("none hit node", Some(anchor_tip_dom()), NodeHierarchyItemId::NONE),
            ("leaf hit node", Some(anchor_tip_dom()), node(1)),
            (
                "single child",
                Some(StyledDom::create_from_dom(
                    Dom::create_div().with_child(Dom::create_div()),
                )),
                node(0),
            ),
        ];
        for (name, styled, hit) in fixtures {
            for handler in [
                on_tooltip_enter as extern "C" fn(RefAny, CallbackInfo) -> Update,
                on_tooltip_leave as extern "C" fn(RefAny, CallbackInfo) -> Update,
            ] {
                let (update, changes) =
                    with_info(styled.clone(), hit, |info| handler(RefAny::new(()), info));
                assert_eq!(update, Update::DoNothing, "{name}");
                assert!(
                    changes.is_empty(),
                    "{name}: nothing may be restyled without a tip"
                );
            }
        }
    }
    #[test]
    fn handlers_ignore_their_payload() {
        // The handlers are stateless — a foreign (or even empty) payload must
        // not change what they do.
        for data in [RefAny::new(()), RefAny::new(0xdead_beef_u64), RefAny::new(())] {
            let (update, changes) = with_info(Some(anchor_tip_dom()), node(0), |info| {
                on_tooltip_enter(data.clone(), info)
            });
            assert_eq!(update, Update::DoNothing);
            assert_eq!(opacity_writes(&changes), vec![(2, 1.0)]);
        }
    }
    #[test]
    fn repeated_enter_is_idempotent() {
        let mut all = Vec::new();
        for _ in 0..64 {
            let (update, changes) = with_info(Some(anchor_tip_dom()), node(0), |info| {
                on_tooltip_enter(RefAny::new(()), info)
            });
            assert_eq!(update, Update::DoNothing);
            all.push(opacity_writes(&changes));
        }
        assert!(
            all.iter().all(|w| *w == vec![(2, 1.0)]),
            "repeated hovers must keep producing the same single write"
        );
    }
    #[test]
    fn handlers_never_restyle_the_wrapper_or_the_anchor() {
        let dom = Tooltip::new(nested_anchor(2), AzString::from_const_str("tip")).dom();
        let styled = StyledDom::create_from_dom(dom);
        let wrapper = index_of_class(&styled, WRAPPER_CLASS_NAME).expect("wrapper class missing");
        let tip = index_of_class(&styled, TIP_CLASS_NAME).expect("tip class missing");
        for handler in [
            on_tooltip_enter as extern "C" fn(RefAny, CallbackInfo) -> Update,
            on_tooltip_leave as extern "C" fn(RefAny, CallbackInfo) -> Update,
        ] {
            let (_, changes) = with_info(Some(styled.clone()), node(wrapper), |info| {
                handler(RefAny::new(()), info)
            });
            let touched: Vec<usize> = css_writes(&changes).into_iter().map(|(i, _)| i).collect();
            assert_eq!(
                touched,
                vec![tip],
                "only the tip may be restyled, never the wrapper or the anchor subtree"
            );
        }
    }
    #[test]
    fn hovering_the_tip_itself_does_nothing() {
        let dom = Tooltip::new(Dom::create_div(), AzString::from_const_str("tip")).dom();
        let styled = StyledDom::create_from_dom(dom);
        let tip = index_of_class(&styled, TIP_CLASS_NAME).expect("tip class missing");
        let (update, changes) = with_info(Some(styled), node(tip), |info| {
            on_tooltip_enter(RefAny::new(()), info)
        });
        assert_eq!(update, Update::DoNothing);
        assert!(
            changes.is_empty(),
            "the tip is a leaf text node — it has no tip of its own"
        );
    }
    #[test]
    fn every_written_property_is_an_opacity() {
        for handler in [
            on_tooltip_enter as extern "C" fn(RefAny, CallbackInfo) -> Update,
            on_tooltip_leave as extern "C" fn(RefAny, CallbackInfo) -> Update,
        ] {
            let (_, changes) = with_info(Some(anchor_tip_dom()), node(0), |info| {
                handler(RefAny::new(()), info)
            });
            for (_, props) in css_writes(&changes) {
                for p in props {
                    assert_eq!(
                        p.get_type(),
                        opacity_ty(),
                        "the hover handlers must only toggle opacity"
                    );
                }
            }
        }
    }
}