Skip to main content

azul_core/
hit_test.rs

1//! Hit-test result types for determining which DOM nodes are under the cursor,
2//! scroll state tracking, and pipeline/document identification. These types
3//! feed into the event dispatch system.
4
5use alloc::collections::BTreeMap;
6use core::{
7    fmt,
8    sync::atomic::{AtomicU32, Ordering as AtomicOrdering},
9};
10
11use crate::{
12    dom::{DomId, DomNodeHash, DomNodeId, OptionDomNodeId, ScrollTagId, ScrollbarOrientation, TagId},
13    geom::{LogicalPosition, LogicalRect, LogicalSize},
14    id::NodeId,
15    resources::IdNamespace,
16    window::MouseCursorType,
17    OrderedMap,
18};
19
20/// Result of a hit test against a single DOM, containing all nodes hit
21/// by the cursor along with scroll, scrollbar, and cursor-type information.
22#[derive(Debug, Clone, PartialEq, Eq, PartialOrd)]
23pub struct HitTest {
24    pub regular_hit_test_nodes: BTreeMap<NodeId, HitTestItem>,
25    pub scroll_hit_test_nodes: BTreeMap<NodeId, ScrollHitTestItem>,
26    /// Hit test results for scrollbar components.
27    pub scrollbar_hit_test_nodes: BTreeMap<ScrollbarHitId, ScrollbarHitTestItem>,
28    /// Hit test results for cursor areas (text runs with cursor property).
29    /// Maps `NodeId` to (`CursorType`, `hit_depth`) - the cursor type and z-depth of the hit.
30    pub cursor_hit_test_nodes: BTreeMap<NodeId, CursorHitTestItem>,
31}
32
33/// Hit test item for cursor areas (determines which cursor icon to show).
34#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd)]
35#[repr(C)]
36pub struct CursorHitTestItem {
37    pub cursor_type: CursorType,
38    pub hit_depth: u32,
39    pub point_in_viewport: LogicalPosition,
40}
41
42impl HitTest {
43    #[must_use] pub const fn empty() -> Self {
44        Self {
45            regular_hit_test_nodes: BTreeMap::new(),
46            scroll_hit_test_nodes: BTreeMap::new(),
47            scrollbar_hit_test_nodes: BTreeMap::new(),
48            cursor_hit_test_nodes: BTreeMap::new(),
49        }
50    }
51    #[must_use] pub fn is_empty(&self) -> bool {
52        self.regular_hit_test_nodes.is_empty()
53            && self.scroll_hit_test_nodes.is_empty()
54            && self.scrollbar_hit_test_nodes.is_empty()
55            && self.cursor_hit_test_nodes.is_empty()
56    }
57}
58
59/// Unique identifier for a specific component of a scrollbar.
60#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
61#[repr(C, u8)]
62pub enum ScrollbarHitId {
63    VerticalTrack(DomId, NodeId),
64    VerticalThumb(DomId, NodeId),
65    HorizontalTrack(DomId, NodeId),
66    HorizontalThumb(DomId, NodeId),
67}
68
69/// Hit test item specifically for scrollbar components.
70#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd)]
71#[repr(C)]
72pub struct ScrollbarHitTestItem {
73    pub point_in_viewport: LogicalPosition,
74    pub point_relative_to_item: LogicalPosition,
75    pub orientation: ScrollbarOrientation,
76}
77
78/// Scroll frame identifier combining a unique `u64` tag with its owning `PipelineId`.
79#[derive(Copy, Clone, Eq, Hash, PartialEq, Ord, PartialOrd)]
80#[repr(C)]
81pub struct ExternalScrollId(pub u64, pub PipelineId);
82
83impl ::core::fmt::Display for ExternalScrollId {
84    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
85        write!(f, "ExternalScrollId({})", self.0)
86    }
87}
88
89impl ::core::fmt::Debug for ExternalScrollId {
90    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
91        write!(f, "{self}")
92    }
93}
94
95/// A node whose content overflows its parent, requiring scroll handling.
96#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd)]
97pub struct OverflowingScrollNode {
98    pub parent_rect: LogicalRect,
99    pub child_rect: LogicalRect,
100    pub virtual_child_rect: LogicalRect,
101    pub parent_external_scroll_id: ExternalScrollId,
102    pub parent_dom_hash: DomNodeHash,
103    pub scroll_tag_id: ScrollTagId,
104}
105
106impl Default for OverflowingScrollNode {
107    fn default() -> Self {
108        use crate::dom::TagId;
109        Self {
110            parent_rect: LogicalRect::zero(),
111            child_rect: LogicalRect::zero(),
112            virtual_child_rect: LogicalRect::zero(),
113            parent_external_scroll_id: ExternalScrollId(0, PipelineId::DUMMY),
114            parent_dom_hash: DomNodeHash { inner: 0 },
115            scroll_tag_id: ScrollTagId {
116                inner: TagId { inner: 0 },
117            },
118        }
119    }
120}
121
122/// Extra source identifier within a pipeline, allowing multiple independent
123/// subsystems to generate `PipelineId` values without collision.
124///
125/// All pipelines still share the same `IdNamespace` and `DocumentId`.
126pub type PipelineSourceId = u32;
127
128/// Information about a scroll frame, given to the user by the framework
129#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd)]
130pub struct ScrollPosition {
131    /// How big is the parent container
132    /// (so that things like "scroll to left edge" can be implemented)?
133    pub parent_rect: LogicalRect,
134    /// How big is the scroll rect (i.e. the union of all children)?
135    pub children_rect: LogicalRect,
136}
137
138/// Identifies a document within a namespace, used for multi-document rendering.
139#[derive(Copy, Clone, Eq, Hash, PartialEq, PartialOrd, Ord)]
140pub struct DocumentId {
141    pub namespace_id: IdNamespace,
142    pub id: u32,
143}
144
145impl ::core::fmt::Display for DocumentId {
146    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
147        write!(
148            f,
149            "DocumentId {{ ns: {}, id: {} }}",
150            self.namespace_id, self.id
151        )
152    }
153}
154
155impl ::core::fmt::Debug for DocumentId {
156    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
157        write!(f, "{self}")
158    }
159}
160
161/// Identifies a rendering pipeline by source and sequence number.
162#[derive(Copy, Clone, Eq, Hash, PartialEq, PartialOrd, Ord)]
163pub struct PipelineId(pub PipelineSourceId, pub u32);
164
165impl ::core::fmt::Display for PipelineId {
166    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
167        write!(f, "PipelineId({}, {})", self.0, self.1)
168    }
169}
170
171impl ::core::fmt::Debug for PipelineId {
172    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
173        write!(f, "{self}")
174    }
175}
176
177static LAST_PIPELINE_ID: AtomicU32 = AtomicU32::new(0);
178
179impl Default for PipelineId {
180    fn default() -> Self {
181        Self::new()
182    }
183}
184
185impl PipelineId {
186    pub const DUMMY: Self = Self(0, 0);
187
188    pub fn new() -> Self {
189        Self(
190            LAST_PIPELINE_ID.fetch_add(1, AtomicOrdering::SeqCst),
191            0,
192        )
193    }
194}
195
196/// A single hit-test result for a regular (non-scroll) DOM node.
197#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd)]
198pub struct HitTestItem {
199    /// The hit point in the coordinate space of the "viewport" of the display item.
200    /// The viewport is the scroll node formed by the root reference frame of the display item's
201    /// pipeline.
202    pub point_in_viewport: LogicalPosition,
203    /// The coordinates of the original hit test point relative to the origin of this item.
204    /// This is useful for calculating things like text offsets in the client.
205    pub point_relative_to_item: LogicalPosition,
206    /// Necessary to easily get the nearest `VirtualView` node
207    pub is_focusable: bool,
208    /// If this hit is a `VirtualView` node, stores the `VirtualViews` `DomId` + the origin of the `VirtualView`
209    pub is_virtual_view_hit: Option<(DomId, LogicalPosition)>,
210    /// Z-order depth from `WebRender` hit test (0 = frontmost/topmost in z-order).
211    /// Lower values are closer to the user. This preserves the ordering from
212    /// `WebRender`'s hit test results which returns items front-to-back.
213    pub hit_depth: u32,
214}
215
216/// A hit-test result for a scrollable DOM node.
217#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd)]
218pub struct ScrollHitTestItem {
219    /// The hit point in the coordinate space of the "viewport" of the display item.
220    /// The viewport is the scroll node formed by the root reference frame of the display item's
221    /// pipeline.
222    pub point_in_viewport: LogicalPosition,
223    /// The coordinates of the original hit test point relative to the origin of this item.
224    /// This is useful for calculating things like text offsets in the client.
225    pub point_relative_to_item: LogicalPosition,
226    /// If this hit is a `VirtualView` node, stores the `VirtualViews` `DomId` + the origin of the `VirtualView`
227    pub scroll_node: OverflowingScrollNode,
228}
229
230/// Map of active scroll states, keyed by their external scroll ID.
231#[derive(Debug, Default)]
232pub struct ScrollStates(pub OrderedMap<ExternalScrollId, ScrollState>);
233
234impl ScrollStates {
235    #[must_use] pub fn new() -> Self {
236        Self::default()
237    }
238
239    #[must_use] pub fn get_scroll_position(&self, scroll_id: &ExternalScrollId) -> Option<LogicalPosition> {
240        self.0.get(scroll_id).map(ScrollState::get)
241    }
242
243    /// Set the scroll amount - does not update the `entry.used_this_frame`,
244    /// since that is only relevant when we are actually querying the renderer.
245    pub fn set_scroll_position(
246        &mut self,
247        node: &OverflowingScrollNode,
248        scroll_position: LogicalPosition,
249    ) {
250        let max_scroll = max_scroll_rect(node);
251        self.0
252            .entry(node.parent_external_scroll_id)
253            .or_default()
254            .set(scroll_position.x, scroll_position.y, &max_scroll);
255    }
256
257    /// Updating (add to) the existing scroll amount does not update the
258    /// `entry.used_this_frame`, since that is only relevant when we are
259    /// actually querying the renderer.
260    pub fn scroll_node(
261        &mut self,
262        node: &OverflowingScrollNode,
263        scroll_by_x: f32,
264        scroll_by_y: f32,
265    ) {
266        let max_scroll = max_scroll_rect(node);
267        self.0
268            .entry(node.parent_external_scroll_id)
269            .or_default()
270            .add(scroll_by_x, scroll_by_y, &max_scroll);
271    }
272}
273
274/// Compute the maximum scrollable range for a scroll node.
275///
276/// The maximum scroll offset is `content − viewport` (`child_rect − parent_rect`),
277/// clamped to `>= 0`. Previously the scroll position was clamped to the full
278/// content size, which let the content scroll entirely out of view. The returned
279/// rect keeps `child_rect.origin` and stores the max offset in `size`.
280fn max_scroll_rect(node: &OverflowingScrollNode) -> LogicalRect {
281    LogicalRect::new(
282        node.child_rect.origin,
283        LogicalSize::new(
284            (node.child_rect.size.width - node.parent_rect.size.width).max(0.0),
285            (node.child_rect.size.height - node.parent_rect.size.height).max(0.0),
286        ),
287    )
288}
289
290/// Current scroll position for a single scroll frame.
291#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd)]
292#[repr(C)]
293pub struct ScrollState {
294    /// Amount in pixel that the current node is scrolled
295    pub scroll_position: LogicalPosition,
296}
297
298impl_option!(
299    ScrollState,
300    OptionScrollState,
301    [Debug, Copy, Clone, PartialEq, Eq, PartialOrd]
302);
303
304impl ScrollState {
305    /// Return the current position of the scroll state
306    #[must_use] pub const fn get(&self) -> LogicalPosition {
307        self.scroll_position
308    }
309
310    /// Add a scroll X / Y onto the existing scroll state.
311    ///
312    /// `max_scroll_rect` is the *scroll range* rect: its size is the maximum
313    /// scrollable offset (`content − viewport`, clamped to `>= 0`), NOT the full
314    /// content size. See [`ScrollStates::scroll_node`]. Clamping via `.max(0.0)`
315    /// first also collapses any NaN input to `0.0` (`f32::max` returns the
316    /// non-NaN operand), so a NaN delta can never poison the scroll position.
317    pub fn add(&mut self, x: f32, y: f32, max_scroll_rect: &LogicalRect) {
318        self.scroll_position.x = (self.scroll_position.x + x)
319            .max(0.0)
320            .min(max_scroll_rect.size.width.max(0.0));
321        self.scroll_position.y = (self.scroll_position.y + y)
322            .max(0.0)
323            .min(max_scroll_rect.size.height.max(0.0));
324    }
325
326    /// Set the scroll state to a new position.
327    ///
328    /// `max_scroll_rect` is the *scroll range* rect (see [`ScrollState::add`]).
329    pub const fn set(&mut self, x: f32, y: f32, max_scroll_rect: &LogicalRect) {
330        self.scroll_position.x = x.max(0.0).min(max_scroll_rect.size.width.max(0.0));
331        self.scroll_position.y = y.max(0.0).min(max_scroll_rect.size.height.max(0.0));
332    }
333}
334
335impl Default for ScrollState {
336    fn default() -> Self {
337        Self {
338            scroll_position: LogicalPosition::zero(),
339        }
340    }
341}
342
343/// Complete hit-test result across all DOMs, including the currently focused node.
344#[derive(Debug, Clone, PartialEq, Eq)]
345pub struct FullHitTest {
346    pub hovered_nodes: BTreeMap<DomId, HitTest>,
347    pub focused_node: OptionDomNodeId,
348}
349
350impl FullHitTest {
351    /// Create an empty hit-test result
352    #[must_use] pub fn empty(focused_node: Option<DomNodeId>) -> Self {
353        Self {
354            hovered_nodes: BTreeMap::new(),
355            focused_node: focused_node.into(),
356        }
357    }
358
359    /// Returns `true` if no nodes were hovered (ignores `focused_node`).
360    #[must_use] pub fn is_empty(&self) -> bool {
361        self.hovered_nodes.is_empty()
362    }
363}
364
365/// Result of determining which mouse cursor icon to display based on hit-test results.
366#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
367pub struct CursorTypeHitTest {
368    /// closest-node is used for determining the cursor: property
369    /// The node is guaranteed to have a non-default cursor: property,
370    /// so that the cursor icon can be set accordingly
371    pub cursor_node: Option<(DomId, NodeId)>,
372    /// Mouse cursor type to set (if `cursor_node` is None, this is set to
373    /// `MouseCursorType::Default`)
374    pub cursor_icon: MouseCursorType,
375}
376
377// ============================================================================
378// Type-safe hit-test tag system (merged from the former `hit_test_tag` module).
379//
380// Encodes WebRender's ItemTag = (u64, u16): the tag *type* lives in the upper
381// byte of tag.1 (DOM node / scrollbar / selection / cursor / scroll-container),
382// keeping tag types free of bit-level conflicts. See the TAG_TYPE_* constants.
383// ============================================================================
384// ============================================================================
385// Tag Type Markers (stored in upper byte of ItemTag.1)
386// ============================================================================
387
388/// Marker for DOM node tags (regular UI elements with callbacks, focus, etc.)
389pub const TAG_TYPE_DOM_NODE: u16 = 0x0100;
390
391/// Marker for scrollbar component tags
392pub const TAG_TYPE_SCROLLBAR: u16 = 0x0200;
393
394/// Marker for text selection hit-test areas (determines text selection regions)
395///
396/// These are pushed for text runs to enable text selection without affecting
397/// other hit-test logic. Selection may trigger re-rendering.
398///
399/// NOTE: Text selection hit-testing currently uses `TAG_TYPE_CURSOR` (0x0400).
400/// This constant is used by the `HitTestTag::Selection` variant for encoding
401/// selection-specific tags (e.g., text run selection areas).
402pub const TAG_TYPE_SELECTION: u16 = 0x0300;
403
404/// Marker for cursor hit-test areas (determines which cursor icon to show)
405///
406/// These are separate from DOM node tags to allow efficient cursor resolution
407/// without iterating over all DOM nodes. Cursor changes never require re-rendering.
408pub const TAG_TYPE_CURSOR: u16 = 0x0400;
409
410/// Marker for scroll container hit-test areas (for trackpad/wheel scrolling)
411///
412/// These identify scrollable containers even when no DOM node callbacks are registered.
413/// Scroll containers push this tag so the scroll manager can find them during wheel events.
414pub const TAG_TYPE_SCROLL_CONTAINER: u16 = 0x0500;
415
416
417// ============================================================================
418// Scrollbar Component Types (stored in lower byte of ItemTag.1 for scrollbar tags)
419// ============================================================================
420
421/// Scrollbar component type identifier.
422///
423/// Each scrollable container can have up to 2 scrollbars (vertical + horizontal),
424/// and each scrollbar has 2 main hit regions (track + thumb).
425///
426/// Future extensions could add:
427/// - `UpButton`, `DownButton`, `LeftButton`, `RightButton` for scroll arrows
428/// - `PageUp`, `PageDown` for page-scroll regions
429#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
430#[repr(u8)]
431pub enum ScrollbarComponent {
432    /// The vertical scrollbar track (background area)
433    VerticalTrack = 0,
434    /// The vertical scrollbar thumb (draggable handle)
435    VerticalThumb = 1,
436    /// The horizontal scrollbar track (background area)
437    HorizontalTrack = 2,
438    /// The horizontal scrollbar thumb (draggable handle)
439    HorizontalThumb = 3,
440    // Future: scroll arrow buttons
441    // VerticalUpButton = 4,
442    // VerticalDownButton = 5,
443    // HorizontalLeftButton = 6,
444    // HorizontalRightButton = 7,
445}
446
447impl ScrollbarComponent {
448    /// Convert from raw u8 value
449    #[must_use] pub const fn from_u8(value: u8) -> Option<Self> {
450        match value {
451            0 => Some(Self::VerticalTrack),
452            1 => Some(Self::VerticalThumb),
453            2 => Some(Self::HorizontalTrack),
454            3 => Some(Self::HorizontalThumb),
455            _ => None,
456        }
457    }
458
459}
460
461// ============================================================================
462// WebRender Hit-Test Tag (unified type-safe representation)
463// ============================================================================
464
465/// Unified, type-safe representation of a `WebRender` hit-test tag.
466///
467/// This enum represents all possible types of hit-test targets. Each variant
468/// can be encoded to and decoded from `WebRender`'s `(u64, u16)` `ItemTag` format.
469///
470/// ## Namespace Separation
471///
472/// Different tag types are kept in separate namespaces to:
473/// - Enable efficient hit-test queries (only iterate over relevant tags)
474/// - Get automatic depth sorting from `WebRender` per namespace
475/// - Prevent accidental collisions between different hit-test purposes
476///
477/// | Namespace | Purpose                              |
478/// |-----------|--------------------------------------|
479/// | 0x0100    | DOM nodes (callbacks, focus, hover)  |
480/// | 0x0200    | Scrollbar components                 |
481/// | 0x0300    | Selection areas (text selection)     |
482/// | 0x0400    | Cursor areas (cursor icon display)     |
483#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash)]
484pub enum HitTestTag {
485    /// A regular DOM node (button, div, text container, etc.)
486    ///
487    /// These are nodes that have callbacks, are focusable, or have hover styles.
488    /// The `TagId` is a sequential counter assigned during DOM styling.
489    DomNode {
490        /// The unique tag ID assigned to this DOM node
491        tag_id: TagId,
492    },
493
494    /// A scrollbar component (track or thumb)
495    ///
496    /// Each scrollable container can have up to 2 scrollbars.
497    /// The scrollbar is identified by the `DomId` and `NodeId` of the scrollable container.
498    Scrollbar {
499        /// The DOM that contains the scrollable container
500        dom_id: DomId,
501        /// The `NodeId` of the scrollable container (not the scrollbar itself)
502        node_id: NodeId,
503        /// Which component of the scrollbar was hit
504        component: ScrollbarComponent,
505    },
506
507    /// A cursor hit-test area (determines which cursor icon to display)
508    ///
509    /// These are pushed separately from DOM nodes to allow efficient cursor
510    /// resolution. The cursor type is encoded in the lower byte of tag.1.
511    Cursor {
512        /// The DOM node this cursor area belongs to
513        dom_id: DomId,
514        /// The `NodeId` of the element with the cursor property
515        node_id: NodeId,
516        /// The cursor type to display when hovering over this area
517        cursor_type: CursorType,
518    },
519
520    /// A text selection hit-test area
521    ///
522    /// These are pushed for text runs to enable text selection.
523    /// Separate from DOM nodes to prevent interference with other hit-testing.
524    Selection {
525        /// The DOM containing the text
526        dom_id: DomId,
527        /// The `NodeId` of the text container (not the Text node itself)
528        container_node_id: NodeId,
529        /// The index of the text run within the container (for multi-line text)
530        text_run_index: u16,
531    },
532}
533
534/// Cursor type encoded in cursor hit-test tags.
535/// Stored in the lower byte of the ItemTag.1 field.
536#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Default)]
537#[repr(u8)]
538pub enum CursorType {
539    #[default]
540    Default = 0,
541    Pointer = 1,
542    Text = 2,
543    Crosshair = 3,
544    Move = 4,
545    NotAllowed = 5,
546    Grab = 6,
547    Grabbing = 7,
548    EResize = 8,
549    WResize = 9,
550    NResize = 10,
551    SResize = 11,
552    EwResize = 12,
553    NsResize = 13,
554    NeswResize = 14,
555    NwseResize = 15,
556    ColResize = 16,
557    RowResize = 17,
558    Wait = 18,
559    Help = 19,
560    Progress = 20,
561    // Add more as needed, up to 255
562}
563
564impl CursorType {
565    /// Convert from raw u8 value
566    // Explicit u8 -> variant table documenting every discriminant; `0 => Default`
567    // intentionally mirrors the `_ => Default` fallback (the `#[default]` is 0).
568    #[allow(clippy::match_same_arms)]
569    #[must_use] pub const fn from_u8(value: u8) -> Self {
570        match value {
571            0 => Self::Default,
572            1 => Self::Pointer,
573            2 => Self::Text,
574            3 => Self::Crosshair,
575            4 => Self::Move,
576            5 => Self::NotAllowed,
577            6 => Self::Grab,
578            7 => Self::Grabbing,
579            8 => Self::EResize,
580            9 => Self::WResize,
581            10 => Self::NResize,
582            11 => Self::SResize,
583            12 => Self::EwResize,
584            13 => Self::NsResize,
585            14 => Self::NeswResize,
586            15 => Self::NwseResize,
587            16 => Self::ColResize,
588            17 => Self::RowResize,
589            18 => Self::Wait,
590            19 => Self::Help,
591            20 => Self::Progress,
592            _ => Self::Default,
593        }
594    }
595}
596
597impl HitTestTag {
598    /// Encode this tag to `WebRender`'s `ItemTag` format.
599    ///
600    /// Returns `(u64, u16)` suitable for passing to `WebRender`'s `push_hit_test`.
601    #[must_use] pub fn to_item_tag(&self) -> (u64, u16) {
602        match self {
603            Self::DomNode { tag_id } => {
604                // tag.0 = TagId.inner (the sequential counter)
605                // tag.1 = TAG_TYPE_DOM_NODE marker
606                (tag_id.inner, TAG_TYPE_DOM_NODE)
607            }
608            Self::Scrollbar {
609                dom_id,
610                node_id,
611                component,
612            } => {
613                // tag.0 = DomId (upper 32 bits) | NodeId (lower 32 bits)
614                let tag_value = ((dom_id.inner as u64) << 32) | (node_id.index() as u64);
615                // tag.1 = TAG_TYPE_SCROLLBAR | component type in lower byte
616                let tag_type = TAG_TYPE_SCROLLBAR | (*component as u16);
617                (tag_value, tag_type)
618            }
619            Self::Cursor {
620                dom_id,
621                node_id,
622                cursor_type,
623            } => {
624                // tag.0 = DomId (upper 32 bits) | NodeId (lower 32 bits)
625                let tag_value = ((dom_id.inner as u64) << 32) | (node_id.index() as u64);
626                // tag.1 = TAG_TYPE_CURSOR | cursor type in lower byte
627                let tag_type = TAG_TYPE_CURSOR | (*cursor_type as u16);
628                (tag_value, tag_type)
629            }
630            Self::Selection {
631                dom_id,
632                container_node_id,
633                text_run_index,
634            } => {
635                // tag.0 = DomId (upper 16 bits) | NodeId (middle 32 bits) | text_run_index (lower 16 bits)
636                // AUDIT: mask each field to its bit width so an out-of-range DomId /
637                // NodeId can never bleed into an adjacent field (silent cross-field
638                // corruption). Masking clamps consistently in debug and release —
639                // a >16-bit DomId is absurd but must degrade gracefully, not panic.
640                let dom_bits = (dom_id.inner as u64) & 0xFFFF;
641                let node_bits = (container_node_id.index() as u64) & 0xFFFF_FFFF;
642                let tag_value = (dom_bits << 48)
643                    | (node_bits << 16)
644                    | u64::from(*text_run_index);
645                (tag_value, TAG_TYPE_SELECTION)
646            }
647        }
648    }
649
650    /// Decode a `WebRender` `ItemTag` back to a typed `HitTestTag`.
651    ///
652    /// Returns `None` if the tag format is invalid or unrecognized.
653    #[must_use] pub fn from_item_tag(tag: (u64, u16)) -> Option<Self> {
654        let (tag_value, tag_type) = tag;
655
656        // Extract tag type from upper byte
657        let type_marker = tag_type & 0xFF00;
658
659        match type_marker {
660            TAG_TYPE_DOM_NODE => {
661                // DOM node tag: tag.0 is the TagId
662                Some(Self::DomNode {
663                    tag_id: TagId { inner: tag_value },
664                })
665            }
666            TAG_TYPE_SCROLLBAR => {
667                // Scrollbar tag: decode DomId, NodeId, and component
668                let dom_id = DomId {
669                    inner: ((tag_value >> 32) & 0xFFFF_FFFF) as usize,
670                };
671                let node_id = NodeId::new((tag_value & 0xFFFF_FFFF) as usize);
672                let component_value = (tag_type & 0x00FF) as u8;
673                let component = ScrollbarComponent::from_u8(component_value)?;
674
675                Some(Self::Scrollbar {
676                    dom_id,
677                    node_id,
678                    component,
679                })
680            }
681            TAG_TYPE_CURSOR => {
682                // Cursor tag: decode DomId, NodeId, and cursor type
683                let dom_id = DomId {
684                    inner: ((tag_value >> 32) & 0xFFFF_FFFF) as usize,
685                };
686                let node_id = NodeId::new((tag_value & 0xFFFF_FFFF) as usize);
687                let cursor_value = (tag_type & 0x00FF) as u8;
688                let cursor_type = CursorType::from_u8(cursor_value);
689
690                Some(Self::Cursor {
691                    dom_id,
692                    node_id,
693                    cursor_type,
694                })
695            }
696            TAG_TYPE_SELECTION => {
697                // Selection tag: decode DomId, NodeId, and text run index
698                let dom_id = DomId {
699                    inner: ((tag_value >> 48) & 0xFFFF) as usize,
700                };
701                let container_node_id = NodeId::new(((tag_value >> 16) & 0xFFFF_FFFF) as usize);
702                let text_run_index = (tag_value & 0xFFFF) as u16;
703
704                Some(Self::Selection {
705                    dom_id,
706                    container_node_id,
707                    text_run_index,
708                })
709            }
710            _ => {
711                // Unknown tag type - could be a legacy tag or corruption
712                // For backwards compatibility, treat tags with tag_type == 0
713                // as legacy DOM node tags (old format before type markers)
714                if tag_type == 0 {
715                    Some(Self::DomNode {
716                        tag_id: TagId { inner: tag_value },
717                    })
718                } else {
719                    None
720                }
721            }
722        }
723    }
724
725    /// Check if this is a DOM node tag
726    #[must_use] pub const fn is_dom_node(&self) -> bool {
727        matches!(self, Self::DomNode { .. })
728    }
729
730    /// Check if this is a scrollbar tag
731    #[must_use] pub const fn is_scrollbar(&self) -> bool {
732        matches!(self, Self::Scrollbar { .. })
733    }
734
735    /// Check if this is a cursor tag
736    #[must_use] pub const fn is_cursor(&self) -> bool {
737        matches!(self, Self::Cursor { .. })
738    }
739
740    /// Check if this is a selection tag
741    #[must_use] pub const fn is_selection(&self) -> bool {
742        matches!(self, Self::Selection { .. })
743    }
744
745    /// Get the `TagId` if this is a DOM node tag
746    #[must_use] pub const fn as_dom_node(&self) -> Option<TagId> {
747        match self {
748            Self::DomNode { tag_id } => Some(*tag_id),
749            _ => None,
750        }
751    }
752
753    /// Get cursor info if this is a cursor tag
754    #[must_use] pub const fn as_cursor(&self) -> Option<(DomId, NodeId, CursorType)> {
755        match self {
756            Self::Cursor {
757                dom_id,
758                node_id,
759                cursor_type,
760            } => Some((*dom_id, *node_id, *cursor_type)),
761            _ => None,
762        }
763    }
764
765    /// Get selection info if this is a selection tag
766    #[must_use] pub const fn as_selection(&self) -> Option<(DomId, NodeId, u16)> {
767        match self {
768            Self::Selection {
769                dom_id,
770                container_node_id,
771                text_run_index,
772            } => Some((*dom_id, *container_node_id, *text_run_index)),
773            _ => None,
774        }
775    }
776
777    /// Get scrollbar info if this is a scrollbar tag
778    #[must_use] pub const fn as_scrollbar(&self) -> Option<(DomId, NodeId, ScrollbarComponent)> {
779        match self {
780            Self::Scrollbar {
781                dom_id,
782                node_id,
783                component,
784            } => Some((*dom_id, *node_id, *component)),
785            _ => None,
786        }
787    }
788}
789
790impl fmt::Display for HitTestTag {
791    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
792        match self {
793            Self::DomNode { tag_id } => {
794                write!(f, "DomNode(tag:{})", tag_id.inner)
795            }
796            Self::Scrollbar {
797                dom_id,
798                node_id,
799                component,
800            } => {
801                write!(
802                    f,
803                    "Scrollbar(dom:{}, node:{}, {:?})",
804                    dom_id.inner,
805                    node_id.index(),
806                    component
807                )
808            }
809            Self::Cursor {
810                dom_id,
811                node_id,
812                cursor_type,
813            } => {
814                write!(
815                    f,
816                    "Cursor(dom:{}, node:{}, {:?})",
817                    dom_id.inner,
818                    node_id.index(),
819                    cursor_type
820                )
821            }
822            Self::Selection {
823                dom_id,
824                container_node_id,
825                text_run_index,
826            } => {
827                write!(
828                    f,
829                    "Selection(dom:{}, container:{}, run:{})",
830                    dom_id.inner,
831                    container_node_id.index(),
832                    text_run_index
833                )
834            }
835        }
836    }
837}
838
839#[cfg(test)]
840#[allow(clippy::float_cmp)] // exact-value assertions on computed layout floats
841mod tests {
842    use super::*;
843
844    #[test]
845    fn test_dom_node_tag_roundtrip() {
846        let tag = HitTestTag::DomNode {
847            tag_id: TagId { inner: 42 },
848        };
849        let item_tag = tag.to_item_tag();
850        let decoded = HitTestTag::from_item_tag(item_tag).unwrap();
851        assert_eq!(tag, decoded);
852    }
853
854    #[test]
855    fn test_scrollbar_tag_roundtrip() {
856        let tag = HitTestTag::Scrollbar {
857            dom_id: DomId { inner: 1 },
858            node_id: NodeId::new(123),
859            component: ScrollbarComponent::VerticalThumb,
860        };
861        let item_tag = tag.to_item_tag();
862        let decoded = HitTestTag::from_item_tag(item_tag).unwrap();
863        assert_eq!(tag, decoded);
864    }
865
866    #[test]
867    fn test_dom_node_tag_not_confused_with_scrollbar() {
868        // A DOM node tag with value 673 should NOT be decoded as a scrollbar
869        let dom_tag = HitTestTag::DomNode {
870            tag_id: TagId { inner: 673 },
871        };
872        let item_tag = dom_tag.to_item_tag();
873
874        // Verify it has the correct type marker
875        assert_eq!(item_tag.1, TAG_TYPE_DOM_NODE);
876
877        // Verify it decodes correctly
878        let decoded = HitTestTag::from_item_tag(item_tag).unwrap();
879        assert!(decoded.is_dom_node());
880        assert!(!decoded.is_scrollbar());
881    }
882
883    #[test]
884    fn test_legacy_tag_compatibility() {
885        // Old format tags had tag.1 == 0
886        // They should be treated as DOM node tags for backwards compatibility
887        let legacy_tag = (42u64, 0u16);
888        let decoded = HitTestTag::from_item_tag(legacy_tag).unwrap();
889        assert!(decoded.is_dom_node());
890        assert_eq!(decoded.as_dom_node().unwrap().inner, 42);
891    }
892
893    fn rect(x: f32, y: f32, w: f32, h: f32) -> LogicalRect {
894        LogicalRect::new(LogicalPosition::new(x, y), LogicalSize::new(w, h))
895    }
896
897    #[test]
898    fn scroll_state_clamps_to_content_minus_viewport() {
899        // content 300 tall, viewport 100 tall -> max scroll offset = 200
900        let max = max_scroll_rect(&OverflowingScrollNode {
901            parent_rect: rect(0.0, 0.0, 100.0, 100.0),
902            child_rect: rect(0.0, 0.0, 100.0, 300.0),
903            ..Default::default()
904        });
905        assert_eq!(max.size.height, 200.0);
906
907        let mut st = ScrollState::default();
908        st.set(0.0, 999.0, &max);
909        assert_eq!(st.scroll_position.y, 200.0); // not 300 (content would fully scroll away)
910    }
911
912    #[test]
913    fn scroll_state_no_scroll_when_content_fits() {
914        // content == viewport -> zero scroll range
915        let max = max_scroll_rect(&OverflowingScrollNode {
916            parent_rect: rect(0.0, 0.0, 100.0, 100.0),
917            child_rect: rect(0.0, 0.0, 100.0, 100.0),
918            ..Default::default()
919        });
920        let mut st = ScrollState::default();
921        st.add(50.0, 50.0, &max);
922        assert_eq!(st.scroll_position, LogicalPosition::zero());
923    }
924
925    #[test]
926    fn scroll_state_nan_delta_does_not_poison() {
927        let max = rect(0.0, 0.0, 100.0, 200.0);
928        let mut st = ScrollState::default();
929        st.add(f32::NAN, f32::NAN, &max);
930        assert_eq!(st.scroll_position, LogicalPosition::zero());
931    }
932
933    #[test]
934    fn selection_tag_out_of_range_domid_is_clamped_not_corrupting() {
935        // A DomId > 0xFFFF must not bleed into the NodeId field. The masked
936        // encode/decode round-trips within the 16-bit DomId window.
937        let tag = HitTestTag::Selection {
938            dom_id: DomId { inner: 0x1_0007 }, // exceeds 16 bits
939            container_node_id: NodeId::new(5),
940            text_run_index: 9,
941        };
942        let (value, ty) = tag.to_item_tag();
943        assert_eq!(ty, TAG_TYPE_SELECTION);
944        // NodeId field (bits 16..48) is exactly 5, uncorrupted by the overflow.
945        assert_eq!((value >> 16) & 0xFFFF_FFFF, 5);
946        // DomId field is the low 16 bits of the input (0x0007).
947        assert_eq!(value >> 48, 0x0007);
948    }
949}
950
951#[cfg(test)]
952#[allow(
953    clippy::float_cmp,
954    clippy::cast_lossless,
955    clippy::cast_possible_truncation,
956    clippy::unreadable_literal
957)]
958mod autotest_generated {
959    use super::*;
960
961    // ------------------------------------------------------------------
962    // helpers
963    // ------------------------------------------------------------------
964
965    fn r(x: f32, y: f32, w: f32, h: f32) -> LogicalRect {
966        LogicalRect::new(LogicalPosition::new(x, y), LogicalSize::new(w, h))
967    }
968
969    /// An `OverflowingScrollNode` with only the two rects that `max_scroll_rect`
970    /// actually reads set; everything else stays at its `Default`.
971    fn scroll_node_of(parent: LogicalRect, child: LogicalRect) -> OverflowingScrollNode {
972        OverflowingScrollNode {
973            parent_rect: parent,
974            child_rect: child,
975            ..Default::default()
976        }
977    }
978
979    fn ext_id(raw: u64) -> ExternalScrollId {
980        ExternalScrollId(raw, PipelineId::DUMMY)
981    }
982
983    const ALL_CURSORS: [CursorType; 21] = [
984        CursorType::Default,
985        CursorType::Pointer,
986        CursorType::Text,
987        CursorType::Crosshair,
988        CursorType::Move,
989        CursorType::NotAllowed,
990        CursorType::Grab,
991        CursorType::Grabbing,
992        CursorType::EResize,
993        CursorType::WResize,
994        CursorType::NResize,
995        CursorType::SResize,
996        CursorType::EwResize,
997        CursorType::NsResize,
998        CursorType::NeswResize,
999        CursorType::NwseResize,
1000        CursorType::ColResize,
1001        CursorType::RowResize,
1002        CursorType::Wait,
1003        CursorType::Help,
1004        CursorType::Progress,
1005    ];
1006
1007    const ALL_SCROLLBAR_COMPONENTS: [ScrollbarComponent; 4] = [
1008        ScrollbarComponent::VerticalTrack,
1009        ScrollbarComponent::VerticalThumb,
1010        ScrollbarComponent::HorizontalTrack,
1011        ScrollbarComponent::HorizontalThumb,
1012    ];
1013
1014    // ------------------------------------------------------------------
1015    // HitTest / FullHitTest — constructors + predicates
1016    // ------------------------------------------------------------------
1017
1018    #[test]
1019    fn hit_test_empty_is_neutral_element() {
1020        let h = HitTest::empty();
1021        assert!(h.is_empty());
1022        assert_eq!(h.regular_hit_test_nodes.len(), 0);
1023        assert_eq!(h.scroll_hit_test_nodes.len(), 0);
1024        assert_eq!(h.scrollbar_hit_test_nodes.len(), 0);
1025        assert_eq!(h.cursor_hit_test_nodes.len(), 0);
1026        // repeated construction is stable / equal
1027        assert_eq!(HitTest::empty(), HitTest::empty());
1028    }
1029
1030    #[test]
1031    fn hit_test_is_empty_false_if_any_single_map_is_populated() {
1032        // Each of the four maps must independently flip `is_empty()` to false,
1033        // otherwise a hit in (say) only the cursor map would be silently dropped.
1034        let mut a = HitTest::empty();
1035        a.regular_hit_test_nodes.insert(
1036            NodeId::ZERO,
1037            HitTestItem {
1038                point_in_viewport: LogicalPosition::zero(),
1039                point_relative_to_item: LogicalPosition::zero(),
1040                is_focusable: false,
1041                is_virtual_view_hit: None,
1042                hit_depth: 0,
1043            },
1044        );
1045        assert!(!a.is_empty());
1046
1047        let mut b = HitTest::empty();
1048        b.scroll_hit_test_nodes.insert(
1049            NodeId::new(usize::MAX),
1050            ScrollHitTestItem {
1051                point_in_viewport: LogicalPosition::zero(),
1052                point_relative_to_item: LogicalPosition::zero(),
1053                scroll_node: OverflowingScrollNode::default(),
1054            },
1055        );
1056        assert!(!b.is_empty());
1057
1058        let mut c = HitTest::empty();
1059        c.scrollbar_hit_test_nodes.insert(
1060            ScrollbarHitId::VerticalTrack(DomId::ROOT_ID, NodeId::ZERO),
1061            ScrollbarHitTestItem {
1062                point_in_viewport: LogicalPosition::zero(),
1063                point_relative_to_item: LogicalPosition::zero(),
1064                orientation: ScrollbarOrientation::Vertical,
1065            },
1066        );
1067        assert!(!c.is_empty());
1068
1069        let mut d = HitTest::empty();
1070        d.cursor_hit_test_nodes.insert(
1071            NodeId::ZERO,
1072            CursorHitTestItem {
1073                cursor_type: CursorType::Default,
1074                hit_depth: u32::MAX,
1075                point_in_viewport: LogicalPosition::new(f32::NAN, f32::INFINITY),
1076            },
1077        );
1078        assert!(!d.is_empty());
1079    }
1080
1081    #[test]
1082    fn full_hit_test_empty_is_empty_regardless_of_focused_node() {
1083        let none = FullHitTest::empty(None);
1084        assert!(none.is_empty());
1085        assert!(none.focused_node.is_none());
1086
1087        // `is_empty()` is documented to ignore `focused_node` — a focused node
1088        // must NOT make an unhovered hit test look non-empty.
1089        let focused = FullHitTest::empty(Some(DomNodeId::ROOT));
1090        assert!(focused.is_empty());
1091        assert!(focused.focused_node.is_some());
1092        assert_eq!(focused.hovered_nodes.len(), 0);
1093    }
1094
1095    #[test]
1096    fn full_hit_test_is_empty_false_once_a_dom_is_hovered() {
1097        let mut f = FullHitTest::empty(None);
1098        f.hovered_nodes.insert(DomId::ROOT_ID, HitTest::empty());
1099        // NOTE: an *empty* HitTest inserted under a DomId still counts as
1100        // "hovered" — `is_empty()` only looks at the outer map's length.
1101        assert!(!f.is_empty());
1102    }
1103
1104    // ------------------------------------------------------------------
1105    // PipelineId / DocumentId / ExternalScrollId — constructors + serializers
1106    // ------------------------------------------------------------------
1107
1108    #[test]
1109    fn pipeline_id_new_is_monotonic_and_second_field_is_zero() {
1110        let a = PipelineId::new();
1111        let b = PipelineId::new();
1112        let c = PipelineId::default();
1113        // The counter only ever moves forward (other tests in this binary may
1114        // bump it concurrently, so assert ordering, not adjacency).
1115        assert!(b.0 > a.0);
1116        assert!(c.0 > b.0);
1117        assert_eq!(a.1, 0);
1118        assert_eq!(b.1, 0);
1119        assert_eq!(PipelineId::DUMMY, PipelineId(0, 0));
1120    }
1121
1122    #[test]
1123    fn pipeline_id_display_and_debug_agree_on_edge_values() {
1124        assert_eq!(alloc::format!("{}", PipelineId::DUMMY), "PipelineId(0, 0)");
1125        let maxed = PipelineId(u32::MAX, u32::MAX);
1126        let shown = alloc::format!("{maxed}");
1127        assert_eq!(shown, "PipelineId(4294967295, 4294967295)");
1128        assert_eq!(alloc::format!("{maxed:?}"), shown);
1129    }
1130
1131    #[test]
1132    fn document_id_display_handles_min_and_max() {
1133        let zero = DocumentId {
1134            namespace_id: IdNamespace(0),
1135            id: 0,
1136        };
1137        let maxed = DocumentId {
1138            namespace_id: IdNamespace(u32::MAX),
1139            id: u32::MAX,
1140        };
1141        for d in [zero, maxed] {
1142            let shown = alloc::format!("{d}");
1143            assert!(!shown.is_empty());
1144            assert!(shown.starts_with("DocumentId {"));
1145            // Debug delegates to Display, so the two must be byte-identical.
1146            assert_eq!(alloc::format!("{d:?}"), shown);
1147        }
1148        assert!(alloc::format!("{maxed}").contains("4294967295"));
1149    }
1150
1151    #[test]
1152    fn external_scroll_id_display_omits_the_pipeline_but_keys_stay_distinct() {
1153        let a = ExternalScrollId(7, PipelineId(1, 0));
1154        let b = ExternalScrollId(7, PipelineId(2, 0));
1155
1156        // Display/Debug only render `.0` — they are deliberately lossy, so two
1157        // *different* IDs can format identically. Guard that this laxness never
1158        // leaks into equality/ordering (which is what BTreeMap keys rely on).
1159        assert_eq!(alloc::format!("{a}"), "ExternalScrollId(7)");
1160        assert_eq!(alloc::format!("{a:?}"), alloc::format!("{b:?}"));
1161        assert_ne!(a, b);
1162
1163        let mut states = ScrollStates::new();
1164        states.0.insert(a, ScrollState::default());
1165        states.0.insert(b, ScrollState::default());
1166        assert_eq!(states.0.len(), 2);
1167    }
1168
1169    #[test]
1170    fn external_scroll_id_display_no_panic_at_u64_max() {
1171        let shown = alloc::format!("{}", ExternalScrollId(u64::MAX, PipelineId::DUMMY));
1172        assert_eq!(shown, "ExternalScrollId(18446744073709551615)");
1173    }
1174
1175    // ------------------------------------------------------------------
1176    // max_scroll_rect — numeric edge cases
1177    // ------------------------------------------------------------------
1178
1179    #[test]
1180    fn max_scroll_rect_is_content_minus_viewport_and_keeps_origin() {
1181        let max = max_scroll_rect(&scroll_node_of(
1182            r(0.0, 0.0, 100.0, 100.0),
1183            r(-12.5, 7.25, 400.0, 300.0),
1184        ));
1185        assert_eq!(max.size.width, 300.0);
1186        assert_eq!(max.size.height, 200.0);
1187        // documented: the returned rect keeps `child_rect.origin`
1188        assert_eq!(max.origin.x, -12.5);
1189        assert_eq!(max.origin.y, 7.25);
1190    }
1191
1192    #[test]
1193    fn max_scroll_rect_clamps_negative_range_to_zero() {
1194        // viewport larger than content -> nothing to scroll (must not go negative)
1195        let max = max_scroll_rect(&scroll_node_of(
1196            r(0.0, 0.0, 500.0, 500.0),
1197            r(0.0, 0.0, 100.0, 100.0),
1198        ));
1199        assert_eq!(max.size.width, 0.0);
1200        assert_eq!(max.size.height, 0.0);
1201    }
1202
1203    #[test]
1204    fn max_scroll_rect_nan_and_infinite_sizes_do_not_produce_nan() {
1205        // NaN content size: `NaN - x` is NaN, and `.max(0.0)` collapses it to 0.0.
1206        let nan = max_scroll_rect(&scroll_node_of(
1207            r(0.0, 0.0, 10.0, 10.0),
1208            r(0.0, 0.0, f32::NAN, f32::NAN),
1209        ));
1210        assert!(!nan.size.width.is_nan());
1211        assert!(!nan.size.height.is_nan());
1212        assert_eq!(nan.size.width, 0.0);
1213        assert_eq!(nan.size.height, 0.0);
1214
1215        // inf - inf == NaN -> also collapses to 0.0 rather than poisoning scroll.
1216        let both_inf = max_scroll_rect(&scroll_node_of(
1217            r(0.0, 0.0, f32::INFINITY, f32::INFINITY),
1218            r(0.0, 0.0, f32::INFINITY, f32::INFINITY),
1219        ));
1220        assert_eq!(both_inf.size.width, 0.0);
1221        assert_eq!(both_inf.size.height, 0.0);
1222
1223        // Infinite content over a finite viewport stays infinite (unbounded scroll).
1224        let inf = max_scroll_rect(&scroll_node_of(
1225            r(0.0, 0.0, 10.0, 10.0),
1226            r(0.0, 0.0, f32::INFINITY, f32::INFINITY),
1227        ));
1228        assert!(inf.size.width.is_infinite() && inf.size.width.is_sign_positive());
1229        assert!(inf.size.height.is_infinite() && inf.size.height.is_sign_positive());
1230    }
1231
1232    #[test]
1233    fn max_scroll_rect_at_float_extremes_does_not_panic() {
1234        let max = max_scroll_rect(&scroll_node_of(
1235            r(f32::MIN, f32::MIN, f32::MIN_POSITIVE, f32::MAX),
1236            r(f32::MAX, f32::MAX, f32::MAX, f32::MIN_POSITIVE),
1237        ));
1238        assert!(max.size.width >= 0.0);
1239        assert!(max.size.height >= 0.0);
1240        assert!(!max.size.width.is_nan());
1241        assert!(!max.size.height.is_nan());
1242    }
1243
1244    // ------------------------------------------------------------------
1245    // ScrollState — numeric (zero / negative / min-max / overflow / NaN)
1246    // ------------------------------------------------------------------
1247
1248    #[test]
1249    fn scroll_state_default_and_get_round_trip() {
1250        let st = ScrollState::default();
1251        assert_eq!(st.get(), LogicalPosition::zero());
1252
1253        let st = ScrollState {
1254            scroll_position: LogicalPosition::new(3.5, -4.25),
1255        };
1256        // `get` is a pure accessor: it must not clamp or normalize.
1257        assert_eq!(st.get().x, 3.5);
1258        assert_eq!(st.get().y, -4.25);
1259    }
1260
1261    #[test]
1262    fn scroll_state_set_zero_is_identity_within_range() {
1263        let max = r(0.0, 0.0, 100.0, 200.0);
1264        let mut st = ScrollState::default();
1265        st.set(0.0, 0.0, &max);
1266        assert_eq!(st.get(), LogicalPosition::zero());
1267
1268        st.set(50.0, 150.0, &max);
1269        assert_eq!(st.get().x, 50.0);
1270        assert_eq!(st.get().y, 150.0);
1271    }
1272
1273    #[test]
1274    fn scroll_state_set_clamps_negative_to_zero_and_overshoot_to_max() {
1275        let max = r(0.0, 0.0, 100.0, 200.0);
1276        let mut st = ScrollState::default();
1277
1278        st.set(-1.0, -f32::MAX, &max);
1279        assert_eq!(st.get().x, 0.0);
1280        assert_eq!(st.get().y, 0.0);
1281
1282        st.set(f32::MAX, f32::MAX, &max);
1283        assert_eq!(st.get().x, 100.0);
1284        assert_eq!(st.get().y, 200.0);
1285
1286        st.set(f32::INFINITY, f32::INFINITY, &max);
1287        assert_eq!(st.get().x, 100.0);
1288        assert_eq!(st.get().y, 200.0);
1289
1290        st.set(f32::NEG_INFINITY, f32::NEG_INFINITY, &max);
1291        assert_eq!(st.get().x, 0.0);
1292        assert_eq!(st.get().y, 0.0);
1293    }
1294
1295    #[test]
1296    fn scroll_state_set_nan_position_collapses_to_zero() {
1297        let max = r(0.0, 0.0, 100.0, 200.0);
1298        let mut st = ScrollState {
1299            scroll_position: LogicalPosition::new(40.0, 40.0),
1300        };
1301        st.set(f32::NAN, f32::NAN, &max);
1302        assert!(!st.get().x.is_nan());
1303        assert!(!st.get().y.is_nan());
1304        assert_eq!(st.get(), LogicalPosition::zero());
1305    }
1306
1307    #[test]
1308    fn scroll_state_set_nan_max_range_collapses_to_zero() {
1309        // A NaN *range* must not leak into the position either: `NaN.max(0.0)`
1310        // is 0.0, so the position clamps to 0 rather than becoming NaN.
1311        let max = r(0.0, 0.0, f32::NAN, f32::NAN);
1312        let mut st = ScrollState::default();
1313        st.set(75.0, 75.0, &max);
1314        assert_eq!(st.get(), LogicalPosition::zero());
1315    }
1316
1317    #[test]
1318    fn scroll_state_set_negative_max_range_collapses_to_zero() {
1319        let max = r(0.0, 0.0, -50.0, -50.0);
1320        let mut st = ScrollState::default();
1321        st.set(10.0, 10.0, &max);
1322        assert_eq!(st.get(), LogicalPosition::zero());
1323    }
1324
1325    #[test]
1326    fn scroll_state_add_accumulates_then_saturates_at_the_range() {
1327        let max = r(0.0, 0.0, 100.0, 200.0);
1328        let mut st = ScrollState::default();
1329        st.add(30.0, 30.0, &max);
1330        st.add(30.0, 30.0, &max);
1331        assert_eq!(st.get().x, 60.0);
1332        assert_eq!(st.get().y, 60.0);
1333
1334        // Deltas far beyond the f32 range must clamp to the max offset, and
1335        // re-applying them must not push the position past it (or to +inf).
1336        st.add(f32::MAX, f32::MAX, &max);
1337        st.add(f32::MAX, f32::MAX, &max);
1338        assert_eq!(st.get().x, 100.0);
1339        assert_eq!(st.get().y, 200.0);
1340        assert!(st.get().x.is_finite() && st.get().y.is_finite());
1341    }
1342
1343    #[test]
1344    fn scroll_state_add_negative_underflow_clamps_to_zero() {
1345        let max = r(0.0, 0.0, 100.0, 200.0);
1346        let mut st = ScrollState {
1347            scroll_position: LogicalPosition::new(10.0, 10.0),
1348        };
1349        st.add(f32::MIN, f32::MIN, &max);
1350        assert_eq!(st.get(), LogicalPosition::zero());
1351
1352        st.add(f32::NEG_INFINITY, f32::NEG_INFINITY, &max);
1353        assert_eq!(st.get(), LogicalPosition::zero());
1354        assert!(st.get().x.is_finite() && st.get().y.is_finite());
1355    }
1356
1357    #[test]
1358    fn scroll_state_add_inf_minus_inf_does_not_poison_position() {
1359        // Worst case: an unbounded scroll range lets the position itself become
1360        // +inf, and the *next* delta is -inf -> `inf + -inf == NaN`. The `.max(0.0)`
1361        // clamp must still collapse that NaN back to a defined 0.0.
1362        let unbounded = r(0.0, 0.0, f32::INFINITY, f32::INFINITY);
1363        let mut st = ScrollState::default();
1364        st.add(f32::INFINITY, f32::INFINITY, &unbounded);
1365        assert!(st.get().x.is_infinite());
1366
1367        st.add(f32::NEG_INFINITY, f32::NEG_INFINITY, &unbounded);
1368        assert!(!st.get().x.is_nan());
1369        assert!(!st.get().y.is_nan());
1370        assert_eq!(st.get(), LogicalPosition::zero());
1371    }
1372
1373    #[test]
1374    fn scroll_state_add_nan_delta_from_nonzero_position_resets_to_zero() {
1375        let max = r(0.0, 0.0, 100.0, 200.0);
1376        let mut st = ScrollState {
1377            scroll_position: LogicalPosition::new(50.0, 50.0),
1378        };
1379        st.add(f32::NAN, 0.0, &max);
1380        // `50 + NaN == NaN`, `NaN.max(0.0) == 0.0`: the delta is discarded *and*
1381        // the previously-good X position is lost. Defined, but lossy — pinned here.
1382        assert_eq!(st.get().x, 0.0);
1383        assert_eq!(st.get().y, 50.0);
1384    }
1385
1386    // ------------------------------------------------------------------
1387    // ScrollStates — map behaviour
1388    // ------------------------------------------------------------------
1389
1390    #[test]
1391    fn scroll_states_new_is_empty_and_lookup_misses_return_none() {
1392        let states = ScrollStates::new();
1393        assert_eq!(states.0.len(), 0);
1394        assert!(states.get_scroll_position(&ext_id(0)).is_none());
1395        assert!(states.get_scroll_position(&ext_id(u64::MAX)).is_none());
1396    }
1397
1398    #[test]
1399    fn scroll_states_set_scroll_position_creates_entry_and_clamps() {
1400        let node = scroll_node_of(r(0.0, 0.0, 100.0, 100.0), r(0.0, 0.0, 100.0, 300.0));
1401        let mut states = ScrollStates::new();
1402
1403        states.set_scroll_position(&node, LogicalPosition::new(999.0, 999.0));
1404        let pos = states
1405            .get_scroll_position(&node.parent_external_scroll_id)
1406            .expect("entry must exist after set_scroll_position");
1407        assert_eq!(states.0.len(), 1);
1408        assert_eq!(pos.x, 0.0); // no horizontal overflow -> zero range
1409        assert_eq!(pos.y, 200.0); // 300 content - 100 viewport
1410
1411        // Re-setting the same node updates in place rather than adding a key.
1412        states.set_scroll_position(&node, LogicalPosition::new(-5.0, -5.0));
1413        assert_eq!(states.0.len(), 1);
1414        let pos = states
1415            .get_scroll_position(&node.parent_external_scroll_id)
1416            .unwrap();
1417        assert_eq!(pos, LogicalPosition::zero());
1418    }
1419
1420    #[test]
1421    fn scroll_states_set_scroll_position_with_nan_stays_defined() {
1422        let node = scroll_node_of(r(0.0, 0.0, 100.0, 100.0), r(0.0, 0.0, 100.0, 300.0));
1423        let mut states = ScrollStates::new();
1424        states.set_scroll_position(&node, LogicalPosition::new(f32::NAN, f32::NAN));
1425        let pos = states
1426            .get_scroll_position(&node.parent_external_scroll_id)
1427            .unwrap();
1428        assert!(!pos.x.is_nan() && !pos.y.is_nan());
1429        assert_eq!(pos, LogicalPosition::zero());
1430    }
1431
1432    #[test]
1433    fn scroll_states_scroll_node_accumulates_and_saturates() {
1434        let node = scroll_node_of(r(0.0, 0.0, 100.0, 100.0), r(0.0, 0.0, 400.0, 300.0));
1435        let mut states = ScrollStates::new();
1436
1437        states.scroll_node(&node, 0.0, 0.0);
1438        assert_eq!(
1439            states
1440                .get_scroll_position(&node.parent_external_scroll_id)
1441                .unwrap(),
1442            LogicalPosition::zero()
1443        );
1444
1445        states.scroll_node(&node, 10.0, 10.0);
1446        states.scroll_node(&node, 10.0, 10.0);
1447        let pos = states
1448            .get_scroll_position(&node.parent_external_scroll_id)
1449            .unwrap();
1450        assert_eq!(pos.x, 20.0);
1451        assert_eq!(pos.y, 20.0);
1452
1453        // Saturate: max range is (400-100, 300-100) = (300, 200).
1454        states.scroll_node(&node, f32::MAX, f32::INFINITY);
1455        let pos = states
1456            .get_scroll_position(&node.parent_external_scroll_id)
1457            .unwrap();
1458        assert_eq!(pos.x, 300.0);
1459        assert_eq!(pos.y, 200.0);
1460
1461        // ...and NaN deltas never corrupt the stored position.
1462        states.scroll_node(&node, f32::NAN, f32::NAN);
1463        let pos = states
1464            .get_scroll_position(&node.parent_external_scroll_id)
1465            .unwrap();
1466        assert!(!pos.x.is_nan() && !pos.y.is_nan());
1467        assert_eq!(states.0.len(), 1);
1468    }
1469
1470    #[test]
1471    fn scroll_states_keys_are_pipeline_qualified() {
1472        // Same raw scroll tag, different pipeline => two independent scroll states.
1473        let a = OverflowingScrollNode {
1474            parent_rect: r(0.0, 0.0, 10.0, 10.0),
1475            child_rect: r(0.0, 0.0, 10.0, 100.0),
1476            parent_external_scroll_id: ExternalScrollId(1, PipelineId(1, 0)),
1477            ..Default::default()
1478        };
1479        let b = OverflowingScrollNode {
1480            parent_external_scroll_id: ExternalScrollId(1, PipelineId(2, 0)),
1481            ..a
1482        };
1483
1484        let mut states = ScrollStates::new();
1485        states.scroll_node(&a, 0.0, 25.0);
1486        states.scroll_node(&b, 0.0, 50.0);
1487        assert_eq!(states.0.len(), 2);
1488        assert_eq!(
1489            states
1490                .get_scroll_position(&a.parent_external_scroll_id)
1491                .unwrap()
1492                .y,
1493            25.0
1494        );
1495        assert_eq!(
1496            states
1497                .get_scroll_position(&b.parent_external_scroll_id)
1498                .unwrap()
1499                .y,
1500            50.0
1501        );
1502    }
1503
1504    // ------------------------------------------------------------------
1505    // ScrollbarComponent / CursorType — from_u8 over the whole domain
1506    // ------------------------------------------------------------------
1507
1508    #[test]
1509    fn scrollbar_component_from_u8_covers_all_256_values() {
1510        for v in 0u8..=255 {
1511            match ScrollbarComponent::from_u8(v) {
1512                Some(c) => {
1513                    assert!(v < 4, "value {v} unexpectedly decoded to {c:?}");
1514                    // discriminant round-trips
1515                    assert_eq!(c as u8, v);
1516                }
1517                None => assert!(v >= 4, "value {v} should have decoded"),
1518            }
1519        }
1520        for c in ALL_SCROLLBAR_COMPONENTS {
1521            assert_eq!(ScrollbarComponent::from_u8(c as u8), Some(c));
1522        }
1523    }
1524
1525    #[test]
1526    fn cursor_type_from_u8_is_total_and_unknown_falls_back_to_default() {
1527        for v in 0u8..=255 {
1528            let c = CursorType::from_u8(v);
1529            if v <= 20 {
1530                assert_eq!(c as u8, v, "known discriminant {v} must round-trip");
1531            } else {
1532                // Out-of-range bytes must degrade to Default, never panic.
1533                assert_eq!(c, CursorType::Default, "unknown byte {v} must be Default");
1534            }
1535        }
1536        assert_eq!(CursorType::default(), CursorType::Default);
1537        for c in ALL_CURSORS {
1538            assert_eq!(CursorType::from_u8(c as u8), c);
1539        }
1540    }
1541
1542    // ------------------------------------------------------------------
1543    // HitTestTag — encode/decode round-trips
1544    // ------------------------------------------------------------------
1545
1546    #[test]
1547    fn dom_node_tag_round_trips_at_u64_boundaries() {
1548        for inner in [0u64, 1, 673, u64::from(u32::MAX), u64::MAX] {
1549            let tag = HitTestTag::DomNode {
1550                tag_id: TagId { inner },
1551            };
1552            let item = tag.to_item_tag();
1553            assert_eq!(item, (inner, TAG_TYPE_DOM_NODE));
1554            assert_eq!(HitTestTag::from_item_tag(item), Some(tag));
1555            assert_eq!(tag.as_dom_node().unwrap().inner, inner);
1556        }
1557    }
1558
1559    #[test]
1560    fn scrollbar_tag_round_trips_for_every_component_at_field_boundaries() {
1561        let ids = [
1562            (0usize, 0usize),
1563            (0, u32::MAX as usize),
1564            (u32::MAX as usize, 0),
1565            (u32::MAX as usize, u32::MAX as usize),
1566        ];
1567        for component in ALL_SCROLLBAR_COMPONENTS {
1568            for (dom, node) in ids {
1569                let tag = HitTestTag::Scrollbar {
1570                    dom_id: DomId { inner: dom },
1571                    node_id: NodeId::new(node),
1572                    component,
1573                };
1574                let item = tag.to_item_tag();
1575                assert_eq!(item.1 & 0xFF00, TAG_TYPE_SCROLLBAR);
1576                assert_eq!(
1577                    HitTestTag::from_item_tag(item),
1578                    Some(tag),
1579                    "scrollbar round-trip failed for dom={dom} node={node} {component:?}"
1580                );
1581            }
1582        }
1583    }
1584
1585    #[test]
1586    fn cursor_tag_round_trips_for_every_cursor_type() {
1587        for cursor_type in ALL_CURSORS {
1588            let tag = HitTestTag::Cursor {
1589                dom_id: DomId { inner: u32::MAX as usize },
1590                node_id: NodeId::new(u32::MAX as usize),
1591                cursor_type,
1592            };
1593            let item = tag.to_item_tag();
1594            assert_eq!(item.1 & 0xFF00, TAG_TYPE_CURSOR);
1595            assert_eq!(
1596                HitTestTag::from_item_tag(item),
1597                Some(tag),
1598                "cursor round-trip failed for {cursor_type:?}"
1599            );
1600        }
1601    }
1602
1603    #[test]
1604    fn selection_tag_round_trips_at_every_field_boundary() {
1605        let cases = [
1606            (0usize, 0usize, 0u16),
1607            (0xFFFF, u32::MAX as usize, u16::MAX),
1608            (1, 1, 1),
1609            (0xFFFF, 0, u16::MAX),
1610        ];
1611        for (dom, node, run) in cases {
1612            let tag = HitTestTag::Selection {
1613                dom_id: DomId { inner: dom },
1614                container_node_id: NodeId::new(node),
1615                text_run_index: run,
1616            };
1617            let item = tag.to_item_tag();
1618            assert_eq!(item.1, TAG_TYPE_SELECTION);
1619            assert_eq!(
1620                HitTestTag::from_item_tag(item),
1621                Some(tag),
1622                "selection round-trip failed for dom={dom} node={node} run={run}"
1623            );
1624        }
1625    }
1626
1627    #[test]
1628    fn selection_tag_oversized_node_id_is_masked_not_bled_into_dom_id() {
1629        // Mirror of the DomId-overflow guard, from the other side: a
1630        // container_node_id wider than 32 bits must be masked, leaving the
1631        // DomId field (bits 48..64) intact.
1632        let tag = HitTestTag::Selection {
1633            dom_id: DomId { inner: 0x00AB },
1634            container_node_id: NodeId::new(u32::MAX as usize),
1635            text_run_index: 0xBEEF,
1636        };
1637        let (value, _) = tag.to_item_tag();
1638        assert_eq!(value >> 48, 0x00AB);
1639        assert_eq!((value >> 16) & 0xFFFF_FFFF, u64::from(u32::MAX));
1640        assert_eq!(value & 0xFFFF, 0xBEEF);
1641    }
1642
1643    #[test]
1644    fn tag_namespaces_do_not_collide_on_identical_payloads() {
1645        // The same numeric payload under four different type markers must decode
1646        // to four different variants — that is the whole point of the namespaces.
1647        let payload = 0x0000_0001_0000_0002u64;
1648        let decoded: [HitTestTag; 4] = [
1649            HitTestTag::from_item_tag((payload, TAG_TYPE_DOM_NODE)).unwrap(),
1650            HitTestTag::from_item_tag((payload, TAG_TYPE_SCROLLBAR)).unwrap(),
1651            HitTestTag::from_item_tag((payload, TAG_TYPE_SELECTION)).unwrap(),
1652            HitTestTag::from_item_tag((payload, TAG_TYPE_CURSOR)).unwrap(),
1653        ];
1654        assert!(decoded[0].is_dom_node());
1655        assert!(decoded[1].is_scrollbar());
1656        assert!(decoded[2].is_selection());
1657        assert!(decoded[3].is_cursor());
1658        for (i, a) in decoded.iter().enumerate() {
1659            for b in decoded.iter().skip(i + 1) {
1660                assert_ne!(a, b);
1661            }
1662        }
1663    }
1664
1665    // ------------------------------------------------------------------
1666    // HitTestTag::from_item_tag — malformed / unknown input
1667    // ------------------------------------------------------------------
1668
1669    #[test]
1670    fn from_item_tag_sweeps_every_type_marker_without_panicking() {
1671        // Exhaustive sweep of the upper byte x a few lower bytes: every
1672        // combination must either decode or return None — never panic.
1673        for hi in 0u16..=0xFF {
1674            for lo in [0u16, 1, 3, 4, 20, 21, 0xFF] {
1675                let tag_type = (hi << 8) | lo;
1676                let decoded = HitTestTag::from_item_tag((0xDEAD_BEEF_CAFE_BABE, tag_type));
1677                let expected_some = match hi {
1678                    0x00 => tag_type == 0,      // legacy DOM tags only
1679                    0x01 | 0x03 | 0x04 => true, // DomNode / Selection / Cursor
1680                    0x02 => lo < 4,             // Scrollbar: component must be valid
1681                    _ => false,                 // 0x05.. (incl. SCROLL_CONTAINER) is unknown
1682                };
1683                assert_eq!(
1684                    decoded.is_some(),
1685                    expected_some,
1686                    "tag_type {tag_type:#06x} decoded to {decoded:?}"
1687                );
1688            }
1689        }
1690    }
1691
1692    #[test]
1693    fn from_item_tag_rejects_invalid_scrollbar_components() {
1694        for lo in 4u16..=0xFF {
1695            let item = (0u64, TAG_TYPE_SCROLLBAR | lo);
1696            assert!(
1697                HitTestTag::from_item_tag(item).is_none(),
1698                "scrollbar component byte {lo} should be rejected"
1699            );
1700        }
1701    }
1702
1703    #[test]
1704    fn from_item_tag_unknown_cursor_byte_degrades_to_default() {
1705        // Unlike scrollbars, an unknown cursor byte is *not* an error: it maps
1706        // to CursorType::Default so a corrupt tag still yields a usable cursor.
1707        for lo in [21u16, 100, 0xFF] {
1708            let decoded = HitTestTag::from_item_tag((0, TAG_TYPE_CURSOR | lo)).unwrap();
1709            assert_eq!(
1710                decoded.as_cursor().unwrap().2,
1711                CursorType::Default,
1712                "cursor byte {lo} should fall back to Default"
1713            );
1714        }
1715    }
1716
1717    #[test]
1718    fn from_item_tag_dom_node_ignores_the_lower_byte() {
1719        // Only the upper byte selects the namespace; junk in the lower byte of a
1720        // DOM-node tag must not flip it to another variant or drop it.
1721        for lo in [0u16, 1, 0x7F, 0xFF] {
1722            let decoded = HitTestTag::from_item_tag((99, TAG_TYPE_DOM_NODE | lo)).unwrap();
1723            assert!(decoded.is_dom_node());
1724            assert_eq!(decoded.as_dom_node().unwrap().inner, 99);
1725        }
1726    }
1727
1728    #[test]
1729    fn from_item_tag_scroll_container_marker_is_not_decodable() {
1730        // TAG_TYPE_SCROLL_CONTAINER has no HitTestTag variant — it must be
1731        // rejected rather than silently aliased onto another namespace.
1732        assert!(HitTestTag::from_item_tag((0, TAG_TYPE_SCROLL_CONTAINER)).is_none());
1733        assert!(HitTestTag::from_item_tag((u64::MAX, TAG_TYPE_SCROLL_CONTAINER)).is_none());
1734    }
1735
1736    #[test]
1737    fn from_item_tag_legacy_zero_type_only_matches_exact_zero() {
1738        // tag_type == 0 is the legacy DOM-node escape hatch...
1739        let legacy = HitTestTag::from_item_tag((u64::MAX, 0)).unwrap();
1740        assert_eq!(legacy.as_dom_node().unwrap().inner, u64::MAX);
1741        // ...but a *nonzero* lower byte with a zero upper byte is not a known
1742        // namespace and must be rejected, not treated as legacy.
1743        for lo in 1u16..=0xFF {
1744            assert!(
1745                HitTestTag::from_item_tag((0, lo)).is_none(),
1746                "tag_type {lo:#06x} must not be treated as a legacy DOM tag"
1747            );
1748        }
1749    }
1750
1751    #[cfg(target_pointer_width = "64")]
1752    #[test]
1753    fn scrollbar_encode_keeps_decoded_fields_inside_their_bit_windows() {
1754        // A NodeId wider than 32 bits is absurd, but must degrade without
1755        // panicking (no shift/`as` overflow) and must not produce out-of-window
1756        // decoded values. NOTE: unlike `Selection`, the `Scrollbar`/`Cursor`
1757        // encoders do NOT mask their fields, so such an id is lossy — see report.
1758        let tag = HitTestTag::Scrollbar {
1759            dom_id: DomId { inner: 0 },
1760            node_id: NodeId::new(1usize << 32),
1761            component: ScrollbarComponent::VerticalTrack,
1762        };
1763        let (value, ty) = tag.to_item_tag();
1764        assert_eq!(ty & 0xFF00, TAG_TYPE_SCROLLBAR);
1765
1766        let decoded = HitTestTag::from_item_tag((value, ty)).expect("must still decode");
1767        let (dom, node, component) = decoded.as_scrollbar().unwrap();
1768        assert!(dom.inner <= u32::MAX as usize);
1769        assert!(node.index() <= u32::MAX as usize);
1770        assert_eq!(component, ScrollbarComponent::VerticalTrack);
1771    }
1772
1773    // ------------------------------------------------------------------
1774    // HitTestTag — predicates + accessors
1775    // ------------------------------------------------------------------
1776
1777    fn sample_tags() -> [HitTestTag; 4] {
1778        [
1779            HitTestTag::DomNode {
1780                tag_id: TagId { inner: 7 },
1781            },
1782            HitTestTag::Scrollbar {
1783                dom_id: DomId { inner: 1 },
1784                node_id: NodeId::new(2),
1785                component: ScrollbarComponent::HorizontalThumb,
1786            },
1787            HitTestTag::Cursor {
1788                dom_id: DomId { inner: 3 },
1789                node_id: NodeId::new(4),
1790                cursor_type: CursorType::Grabbing,
1791            },
1792            HitTestTag::Selection {
1793                dom_id: DomId { inner: 5 },
1794                container_node_id: NodeId::new(6),
1795                text_run_index: 8,
1796            },
1797        ]
1798    }
1799
1800    #[test]
1801    fn exactly_one_predicate_is_true_per_variant() {
1802        for tag in sample_tags() {
1803            let flags = [
1804                tag.is_dom_node(),
1805                tag.is_scrollbar(),
1806                tag.is_cursor(),
1807                tag.is_selection(),
1808            ];
1809            assert_eq!(
1810                flags.iter().filter(|b| **b).count(),
1811                1,
1812                "predicates not mutually exclusive for {tag:?}"
1813            );
1814        }
1815    }
1816
1817    #[test]
1818    fn accessors_agree_with_predicates_and_return_none_otherwise() {
1819        for tag in sample_tags() {
1820            assert_eq!(tag.as_dom_node().is_some(), tag.is_dom_node());
1821            assert_eq!(tag.as_scrollbar().is_some(), tag.is_scrollbar());
1822            assert_eq!(tag.as_cursor().is_some(), tag.is_cursor());
1823            assert_eq!(tag.as_selection().is_some(), tag.is_selection());
1824
1825            // exactly one accessor yields a value
1826            let some_count = usize::from(tag.as_dom_node().is_some())
1827                + usize::from(tag.as_scrollbar().is_some())
1828                + usize::from(tag.as_cursor().is_some())
1829                + usize::from(tag.as_selection().is_some());
1830            assert_eq!(some_count, 1, "accessor overlap for {tag:?}");
1831        }
1832    }
1833
1834    #[test]
1835    fn accessors_return_the_constructed_payload() {
1836        let [dom, scrollbar, cursor, selection] = sample_tags();
1837
1838        assert_eq!(dom.as_dom_node().unwrap().inner, 7);
1839
1840        let (d, n, c) = scrollbar.as_scrollbar().unwrap();
1841        assert_eq!((d.inner, n.index()), (1, 2));
1842        assert_eq!(c, ScrollbarComponent::HorizontalThumb);
1843
1844        let (d, n, c) = cursor.as_cursor().unwrap();
1845        assert_eq!((d.inner, n.index()), (3, 4));
1846        assert_eq!(c, CursorType::Grabbing);
1847
1848        let (d, n, run) = selection.as_selection().unwrap();
1849        assert_eq!((d.inner, n.index()), (5, 6));
1850        assert_eq!(run, 8);
1851    }
1852
1853    // ------------------------------------------------------------------
1854    // HitTestTag — Display
1855    // ------------------------------------------------------------------
1856
1857    #[test]
1858    fn hit_test_tag_display_is_non_empty_and_variant_tagged() {
1859        let [dom, scrollbar, cursor, selection] = sample_tags();
1860        for (tag, prefix) in [
1861            (dom, "DomNode("),
1862            (scrollbar, "Scrollbar("),
1863            (cursor, "Cursor("),
1864            (selection, "Selection("),
1865        ] {
1866            let shown = alloc::format!("{tag}");
1867            assert!(!shown.is_empty());
1868            assert!(
1869                shown.starts_with(prefix),
1870                "expected {shown:?} to start with {prefix:?}"
1871            );
1872        }
1873    }
1874
1875    #[test]
1876    fn hit_test_tag_display_handles_extreme_ids() {
1877        let tags = [
1878            HitTestTag::DomNode {
1879                tag_id: TagId { inner: u64::MAX },
1880            },
1881            HitTestTag::Scrollbar {
1882                dom_id: DomId { inner: usize::MAX },
1883                node_id: NodeId::new(usize::MAX),
1884                component: ScrollbarComponent::VerticalThumb,
1885            },
1886            HitTestTag::Cursor {
1887                dom_id: DomId { inner: usize::MAX },
1888                node_id: NodeId::new(usize::MAX),
1889                cursor_type: CursorType::Progress,
1890            },
1891            HitTestTag::Selection {
1892                dom_id: DomId { inner: usize::MAX },
1893                container_node_id: NodeId::new(usize::MAX),
1894                text_run_index: u16::MAX,
1895            },
1896        ];
1897        for tag in tags {
1898            assert!(!alloc::format!("{tag}").is_empty());
1899            assert!(!alloc::format!("{tag:?}").is_empty());
1900            // encoding an extreme tag must not panic either
1901            let _ = tag.to_item_tag();
1902        }
1903    }
1904}