1
//! Generic icon provider system for Azul
2
//!
3
//! This module defines a generic, callback-based icon resolution infrastructure.
4
//! The actual parsing/loading implementations live in `azul-layout`.
5
//!
6
//! # Architecture
7
//!
8
//! The icon system is fully generic using RefAny:
9
//!
10
//! 1. `IconProviderHandle` - stores icons in nested map: pack_name → (icon_name → RefAny)
11
//! 2. The resolver callback turns (icon_data, original_dom) into a StyledDom
12
//! 3. Differentiation between Image/Font/SVG/etc. is via RefAny::downcast
13
//! 4. Supports any icon source: images, fonts, SVGs, animated icons, etc.
14
//!
15
//! # Resolution Flow
16
//!
17
//! 1. User creates Icon nodes: `Dom::create_icon("home")`
18
//! 2. Before layout, `resolve_icons_in_styled_dom()` is called
19
//! 3. Each Icon node is looked up across all packs (first match wins)
20
//! 4. The resolver callback is invoked with the found RefAny data + original DOM
21
//! 5. The callback returns a StyledDom subtree that replaces the icon node
22
//!
23
//! # Caching
24
//!
25
//! Resolution results are CACHED on the [`SharedIconProvider`], keyed by
26
//! (icon spec, the original icon node's full `NodeData`, its `StyledNode`),
27
//! and flushed when the `SystemStyle` changes. The engine calls
28
//! `resolve_icons_in_styled_dom` on EVERY DOM regeneration — during a Wayland
29
//! drag-resize that is one call per pixel of mouse movement (373 in a measured
30
//! 5-second drag), and each un-cached resolution runs `StyledDom::create`'s
31
//! full single-node cascade whose output is then thrown away by the host's
32
//! own cascade recompute. ~66 ribbon icons × 373 regenerations ≈ 24 600
33
//! throwaway cascades per drag, all yielding bit-identical results
34
//! (scripts/RSS_MAP_2026_08_07.md §36c).
35
//!
36
//! The cache stores the resolver's output DECONSTRUCTED into exactly the
37
//! fields the replacement consumes (node type, inline style, accessibility,
38
//! styled node), so a hit is four field clones — no `Dom`, no `StyledDom`,
39
//! no cascade, no `CssPropertyCache`, not even the single-node extraction of
40
//! the original.
41
//!
42
//! Correctness notes:
43
//! - The KEY includes the whole original `NodeData` + `StyledNode`, because a
44
//!   custom resolver may read anything from `original_icon_dom` (the default
45
//!   one copies inline styles and accessibility info). Same name with
46
//!   different inline styles → separate entries; a hover-state flip on the
47
//!   node → different `StyledNode` → re-resolve.
48
//! - The icon SET and the resolver are frozen once the provider is shared
49
//!   (`App::run` consumes the handle; `SharedIconProvider` exposes no
50
//!   registration), so registration invalidation cannot be needed post-share.
51
//! - "Animated icons" remain compatible: animation is carried by the DATA the
52
//!   resolver returns (e.g. an image-callback node that animates per frame),
53
//!   not by re-resolving per frame — re-resolution only ever happened on DOM
54
//!   regeneration anyway.
55
//!
56
//! # Custom Resolvers
57
//!
58
//! Users can provide custom C callbacks for complete control:
59
//!
60
//! ```c
61
//! AzStyledDom my_resolver(
62
//!     AzRefAny* icon_data,           // NULL if icon not found
63
//!     AzStyledDom* original_icon_dom, // Contains icon_name, styles, a11y
64
//!     AzSystemStyle* system_style
65
//! ) {
66
//!     // Custom resolution logic - icon_data contains your registered data
67
//!     return create_my_icon_dom(...);
68
//! }
69
//! ```
70

            
71
use alloc::{
72
    boxed::Box,
73
    collections::BTreeMap,
74
    string::{String, ToString},
75
    sync::Arc,
76
    vec::Vec,
77
};
78
use core::fmt;
79
use core::mem::ManuallyDrop;
80

            
81
#[cfg(feature = "std")]
82
use std::sync::Mutex;
83

            
84
#[cfg(not(feature = "std"))]
85
use self::nostd_lock::Mutex;
86

            
87
/// Minimal `no_std` spinlock that mirrors the slice of the `std::sync::Mutex`
88
/// API actually used by this module (`new` + `lock` returning a `Result`).
89
#[cfg(not(feature = "std"))]
90
mod nostd_lock {
91
    use core::cell::UnsafeCell;
92
    use core::ops::{Deref, DerefMut};
93
    use core::sync::atomic::{AtomicBool, Ordering};
94

            
95
    pub struct Mutex<T> {
96
        locked: AtomicBool,
97
        data: UnsafeCell<T>,
98
    }
99

            
100
    unsafe impl<T: Send> Send for Mutex<T> {}
101
    unsafe impl<T: Send> Sync for Mutex<T> {}
102

            
103
    pub struct MutexGuard<'a, T> {
104
        lock: &'a Mutex<T>,
105
    }
106

            
107
    impl<T> Mutex<T> {
108
        pub fn new(data: T) -> Self {
109
            Mutex { locked: AtomicBool::new(false), data: UnsafeCell::new(data) }
110
        }
111

            
112
        /// Returns `Ok(guard)` to mirror `std::sync::Mutex::lock`. Never poisons.
113
        pub fn lock(&self) -> Result<MutexGuard<'_, T>, core::convert::Infallible> {
114
            while self
115
                .locked
116
                .compare_exchange(false, true, Ordering::Acquire, Ordering::Relaxed)
117
                .is_err()
118
            {
119
                core::hint::spin_loop();
120
            }
121
            Ok(MutexGuard { lock: self })
122
        }
123
    }
124

            
125
    impl<'a, T> Deref for MutexGuard<'a, T> {
126
        type Target = T;
127
        fn deref(&self) -> &T {
128
            unsafe { &*self.lock.data.get() }
129
        }
130
    }
131

            
132
    impl<'a, T> DerefMut for MutexGuard<'a, T> {
133
        fn deref_mut(&mut self) -> &mut T {
134
            unsafe { &mut *self.lock.data.get() }
135
        }
136
    }
137

            
138
    impl<'a, T> Drop for MutexGuard<'a, T> {
139
        fn drop(&mut self) {
140
            self.lock.locked.store(false, Ordering::Release);
141
        }
142
    }
143

            
144
    // Mirror `std::sync::Mutex: Debug` so containers can derive Debug. Does not
145
    // lock (the spinlock has no `try_lock`, and locking in `fmt` could deadlock).
146
    impl<T> core::fmt::Debug for Mutex<T> {
147
        fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
148
            f.debug_struct("Mutex").finish_non_exhaustive()
149
        }
150
    }
151
}
152

            
153
use azul_css::{AzString, system::SystemStyle};
154

            
155
use crate::{
156
    dom::{Dom, NodeData, NodeType},
157
    refany::{OptionRefAny, RefAny},
158
    styled_dom::StyledDom,
159
};
160

            
161
// Type name constants for RefAny-based icon type detection in debug output
162
const IMAGE_ICON_DATA_TYPE_NAME: &str = "ImageIconData";
163
const FONT_ICON_DATA_TYPE_NAME: &str = "FontIconData";
164

            
165
// Icon Resolver Callback
166

            
167
/// Callback type for resolving icon data to a `StyledDom`.
168
///
169
/// Parameters:
170
/// - `icon_data`: The `RefAny` data from the icon pack (cloned, or None if not found)
171
/// - `original_icon_dom`: The original icon node's `StyledDom` (contains inline styles, a11y info, `icon_name`)
172
/// - `system_style`: Current system style (theme, colors, etc.)
173
///
174
/// Returns: A `StyledDom` that will replace the icon node.
175
/// The resolver should copy relevant styles from `original_icon_dom` to the result.
176
/// Return an empty `StyledDom` to show a placeholder or nothing.
177
///
178
/// Note: `icon_name` is accessible via `original_icon_dom.node_data[0].get_node_type()` → `NodeType::Icon(name)`
179
pub type IconResolverCallbackType = extern "C" fn(
180
    icon_data: OptionRefAny,
181
    original_icon_dom: &StyledDom,
182
    system_style: &SystemStyle,
183
) -> StyledDom;
184

            
185
/// Default resolver that returns an empty `StyledDom` (shows placeholder)
186
4
#[must_use] pub extern "C" fn default_icon_resolver(
187
4
    _icon_data: OptionRefAny,
188
4
    _original_icon_dom: &StyledDom,
189
4
    _system_style: &SystemStyle,
190
4
) -> StyledDom {
191
    // Default: return empty DOM (icon won't be visible)
192
4
    StyledDom::default()
193
4
}
194

            
195
// Icon Provider Inner (single mutex)
196

            
197
/// Inner data for `IconProviderHandle` - all fields behind single mutex
198
#[derive(Debug, Clone)]
199
pub struct IconProviderInner {
200
    /// Nested map: `pack_name` → (`icon_name` → `RefAny`)
201
    /// Differentiation between Image/Font/SVG is via `RefAny::downcast`
202
    pub icons: BTreeMap<String, BTreeMap<String, RefAny>>,
203
    /// The resolver callback
204
    pub resolver: IconResolverCallbackType,
205
}
206

            
207
impl Default for IconProviderInner {
208
    fn default() -> Self {
209
        Self {
210
            icons: BTreeMap::new(),
211
            resolver: default_icon_resolver,
212
        }
213
    }
214
}
215

            
216
// Icon Provider Handle
217

            
218
/// Icon provider stored in `AppConfig`.
219
///
220
/// This is a Box<IconProviderInner> for C FFI compatibility.
221
/// When `App::run()` is called, it gets converted to Arc<Mutex<IconProviderInner>>
222
/// and cloned to each window.
223
///
224
/// Icons are stored in a nested map: `pack_name` → (`icon_name` → `RefAny`)
225
/// This allows:
226
/// - Multiple packs with different sources (app-images, material-icons, etc.)
227
/// - Easy unregistration of entire packs
228
/// - First-match-wins lookup across all packs
229
#[repr(C)]
230
pub struct IconProviderHandle {
231
    /// Boxed inner data - Box<T> is repr(C) compatible (single pointer).
232
    /// `ManuallyDrop` so the Box is freed ONLY by our `Drop` (gated on
233
    /// `run_destructor`), never by drop-glue. The codegen Az wrapper nests an
234
    /// `AzIconProviderHandle` field (in `AzAppConfig`) whose own `Drop` re-runs
235
    /// `_delete` -> `drop_in_place::<IconProviderHandle>` on the SAME bytes; with
236
    /// a bare `Box` the glue freed it a second time -> double free. Same
237
    /// convention as `GlContextPtr` / `CssPropertyCachePtr`.
238
    pub inner: ManuallyDrop<Box<IconProviderInner>>,
239
    pub run_destructor: bool,
240
}
241

            
242
impl Clone for IconProviderHandle {
243
101
    fn clone(&self) -> Self {
244
101
        Self {
245
101
            inner: ManuallyDrop::new(Box::new((**self.inner).clone())),
246
101
            run_destructor: true,
247
101
        }
248
101
    }
249
}
250

            
251
impl Drop for IconProviderHandle {
252
486
    fn drop(&mut self) {
253
        // First drop (run_destructor still true) frees the Box and clears the flag
254
        // in the shared bytes; the codegen's redundant second drop sees false -> no-op.
255
486
        if self.run_destructor {
256
442
            self.run_destructor = false;
257
442
            unsafe {
258
442
                ManuallyDrop::drop(&mut self.inner);
259
442
            }
260
44
        }
261
486
    }
262
}
263

            
264
impl fmt::Debug for IconProviderHandle {
265
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
266
        let pack_count = self.inner.icons.len();
267
        let icon_count: usize = self.inner.icons.values().map(BTreeMap::len).sum();
268
        
269
        f.debug_struct("IconProviderHandle")
270
            .field("pack_count", &pack_count)
271
            .field("icon_count", &icon_count)
272
            .finish_non_exhaustive()
273
    }
274
}
275

            
276
impl Default for IconProviderHandle {
277
1
    fn default() -> Self {
278
1
        Self::new()
279
1
    }
280
}
281

            
282
impl IconProviderInner {
283
    /// Resolves an icon SPEC to registered icon data.
284
    ///
285
    /// A spec is a comma-separated fallback list of entries, each either a
286
    /// bare icon name (`"content_copy"`, searched across all packs in
287
    /// registration order, first match wins) or a pack-qualified name
288
    /// (`"material-icons:save"`, searched only in that pack). The first
289
    /// entry that resolves wins, so markup can express per-platform
290
    /// fallbacks: `<icon>ios:open_menu,kde:three-lines,menu</icon>`.
291
    /// Icon names are case-insensitive; pack names are case-sensitive.
292
3506
    #[must_use] pub fn lookup_spec(&self, spec: &str) -> Option<RefAny> {
293
        // Verbatim first: a registered name is always found as-is (names may
294
        // legally contain ':', ',' or whitespace). The spec syntax below only
295
        // applies when nothing is registered under the literal name.
296
3506
        let verbatim = spec.to_lowercase();
297
3506
        if let Some(data) = self.icons.values().find_map(|pack| pack.get(&verbatim)) {
298
2106
            return Some(data.clone());
299
1400
        }
300

            
301
1411
        for entry in spec.split(',') {
302
1411
            let entry = entry.trim();
303
1411
            if entry.is_empty() {
304
28
                continue;
305
1383
            }
306
1383
            let (pack, name) = match entry.split_once(':') {
307
28
                Some((p, n)) => (Some(p.trim()), n.trim()),
308
1355
                None => (None, entry),
309
            };
310
1383
            let name_lower = name.to_lowercase();
311
1383
            let found = pack.map_or_else(
312
1355
                || self.icons.values().find_map(|pack| pack.get(&name_lower)),
313
28
                |p| self.icons.get(p).and_then(|pack| pack.get(&name_lower)),
314
            );
315
1383
            if let Some(data) = found {
316
17
                return Some(data.clone());
317
1366
            }
318
        }
319
1383
        None
320
3506
    }
321
}
322

            
323
impl IconProviderHandle {
324
    /// Create a new empty icon provider with the default (no-op) resolver.
325
    /// 
326
    /// Note: The default resolver in core crate returns an empty `StyledDom`.
327
    /// Use `set_resolver()` to set a proper resolver from the layout crate,
328
    /// or use `with_resolver()` to create with a custom resolver.
329
172
    #[must_use] pub fn new() -> Self {
330
172
        Self {
331
172
            inner: ManuallyDrop::new(Box::new(IconProviderInner {
332
172
                icons: BTreeMap::new(),
333
172
                resolver: default_icon_resolver,
334
172
            })),
335
172
            run_destructor: true,
336
172
        }
337
172
    }
338

            
339
    /// Create with a custom resolver callback
340
213
    pub fn with_resolver(resolver: IconResolverCallbackType) -> Self {
341
213
        Self {
342
213
            inner: ManuallyDrop::new(Box::new(IconProviderInner {
343
213
                icons: BTreeMap::new(),
344
213
                resolver,
345
213
            })),
346
213
            run_destructor: true,
347
213
        }
348
213
    }
349
    
350
    /// Convert this handle into an Arc<Mutex<IconProviderInner>> for use in windows.
351
    ///
352
    /// This consumes the Box and creates an Arc. Called by `App::run()` to create
353
    /// the shared icon provider that gets cloned to each window.
354
44
    pub(crate) fn into_shared(mut self) -> Arc<Mutex<IconProviderInner>> {
355
        // Take the Box out and disarm our Drop so it doesn't free the moved-out
356
        // allocation (ManuallyDrop::take leaves `inner` logically uninitialized).
357
44
        let inner = unsafe { ManuallyDrop::take(&mut self.inner) };
358
44
        self.run_destructor = false;
359
44
        Arc::new(Mutex::new(*inner))
360
44
    }
361

            
362
    /// Set the resolver callback
363
2
    pub fn set_resolver(&mut self, resolver: IconResolverCallbackType) {
364
2
        self.inner.resolver = resolver;
365
2
    }
366

            
367
    /// Register a single icon in a pack (creates pack if needed).
368
    ///
369
    /// Note: `pack_name` is case-sensitive, while `icon_name` is normalized to lowercase.
370
24945
    pub fn register_icon(&mut self, pack_name: &str, icon_name: &str, data: RefAny) {
371
24945
        let pack = self.inner.icons
372
24945
            .entry(pack_name.to_string())
373
24945
            .or_default();
374
24945
        pack.insert(icon_name.to_lowercase(), data);
375
24945
    }
376

            
377
    /// Unregister a single icon from a pack
378
57
    pub fn unregister_icon(&mut self, pack_name: &str, icon_name: &str) {
379
57
        if let Some(pack) = self.inner.icons.get_mut(pack_name) {
380
55
            pack.remove(&icon_name.to_lowercase());
381
55
            if pack.is_empty() {
382
52
                self.inner.icons.remove(pack_name);
383
52
            }
384
2
        }
385
57
    }
386

            
387
    /// Unregister an entire icon pack
388
23
    pub fn unregister_pack(&mut self, pack_name: &str) {
389
23
        self.inner.icons.remove(pack_name);
390
23
    }
391

            
392
    /// Look up an icon across all packs, returning the pack name and data reference (first match wins)
393
35
    fn lookup_with_pack(&self, icon_name: &str) -> Option<(&str, &RefAny)> {
394
35
        let icon_name_lower = icon_name.to_lowercase();
395
35
        for (pack_name, pack) in &self.inner.icons {
396
30
            if let Some(data) = pack.get(&icon_name_lower) {
397
27
                return Some((pack_name.as_str(), data));
398
3
            }
399
        }
400
8
        None
401
35
    }
402

            
403
    /// Look up an icon by spec (bare name, `pack:name`, or a comma-separated
404
    /// fallback list of either form; first match wins).
405
170
    #[must_use] pub fn lookup(&self, icon_name: &str) -> Option<RefAny> {
406
170
        self.inner.lookup_spec(icon_name)
407
170
    }
408

            
409
    /// Check if an icon spec resolves in any pack
410
378
    #[must_use] pub fn has_icon(&self, icon_name: &str) -> bool {
411
378
        self.inner.lookup_spec(icon_name).is_some()
412
378
    }
413

            
414
    /// List all pack names
415
242
    #[must_use] pub fn list_packs(&self) -> Vec<String> {
416
242
        self.inner.icons.keys().cloned().collect()
417
242
    }
418

            
419
    /// List all icon names in a specific pack
420
54
    #[must_use] pub fn list_icons_in_pack(&self, pack_name: &str) -> Vec<String> {
421
54
        self.inner.icons.get(pack_name)
422
54
            .map(|pack| pack.keys().cloned().collect())
423
54
            .unwrap_or_default()
424
54
    }
425

            
426
    /// Debug lookup: returns detailed info about an icon's `RefAny` contents
427
    #[allow(clippy::used_underscore_binding)] // intentional `_`-prefix (FFI/api.json pub field, or cfg-gated binding); access is deliberate
428
30
    #[must_use] pub fn debug_lookup(&self, icon_name: &str) -> AzString {
429
        use core::fmt::Write;
430

            
431
30
        let icon_name_lower = icon_name.to_lowercase();
432

            
433
30
        let mut result = format!("Debug lookup for icon '{icon_name}' (normalized: '{icon_name_lower}'):\n");
434

            
435
        // Report registered packs
436
30
        let _ = writeln!(result, "  Total packs: {}", self.inner.icons.len());
437
30
        for (pack_name, pack) in &self.inner.icons {
438
26
            let _ = writeln!(result, "    Pack '{}': {} icons", pack_name, pack.len());
439
472
            for name in pack.keys() {
440
472
                let _ = writeln!(result, "      - {name}");
441
472
            }
442
        }
443

            
444
        // Find the icon using shared lookup helper
445
30
        match self.lookup_with_pack(icon_name) {
446
24
            Some((pack, data)) => {
447
24
                let _ = writeln!(result, "\n  FOUND in pack '{pack}'");
448
24
                let type_name = data.get_type_name();
449
24
                let _ = writeln!(result, "  RefAny type_name: '{}'", type_name.as_str());
450

            
451
24
                let debug_info = data.sharing_info.debug_get_refcount_copied();
452
24
                let _ = writeln!(result, "  RefAny size: {} bytes", debug_info._internal_layout_size);
453

            
454
24
                let type_str = type_name.as_str();
455
24
                if type_str.contains(IMAGE_ICON_DATA_TYPE_NAME) {
456
1
                    result.push_str("  RefAny type: ImageIconData (image-based icon)\n");
457
23
                } else if type_str.contains(FONT_ICON_DATA_TYPE_NAME) {
458
1
                    result.push_str("  RefAny type: FontIconData (font-based icon)\n");
459
22
                } else {
460
22
                    let _ = writeln!(result, "  RefAny type: UNKNOWN ('{type_str}')");
461
22
                }
462
            }
463
6
            None => {
464
6
                result.push_str("\n  NOT FOUND in any pack\n");
465
6
            }
466
        }
467

            
468
30
        AzString::from(result)
469
30
    }
470
}
471

            
472
/// Thread-safe icon provider for use in windows.
473
/// 
474
/// This is created from `IconProviderHandle::into_shared()` in `App::run()`
475
/// and cloned to each window.
476
#[derive(Debug, Clone)]
477
pub struct SharedIconProvider {
478
    inner: Arc<Mutex<IconProviderInner>>,
479
    /// Resolution cache — see the module-level `# Caching` section. Shared by
480
    /// every clone of this provider (all windows), like `inner`.
481
    cache: Arc<Mutex<IconResolutionCache>>,
482
}
483

            
484
/// Hard cap on cached resolutions. A frame's live icon set is typically a few
485
/// dozen; the cap only matters when specs vary without bound (adversarial or
486
/// generated names). Policy on overflow is FLUSH-ALL: the next frame re-fills
487
/// with the live set, so a pathological producer degrades to today's uncached
488
/// behaviour instead of growing without limit.
489
const ICON_CACHE_CAP: usize = 512;
490

            
491
/// A resolver result, stored DECONSTRUCTED into exactly what
492
/// [`apply_cached_resolution`] consumes. See the module `# Caching` docs for
493
/// why this is not a `StyledDom`.
494
#[derive(Debug, Clone)]
495
enum CachedIconResolution {
496
    /// Resolver returned a zero-node `StyledDom` → the icon becomes an empty
497
    /// `Div` placeholder (same as the uncached empty arm).
498
    Empty,
499
    /// The dominant case: a single-node replacement.
500
    SingleNode {
501
        node_type: NodeType,
502
        style: azul_css::css::Css,
503
        accessibility: Option<Box<crate::a11y::AccessibilityInfo>>,
504
        /// `None` = the replacement carried no styled node; keep the host's
505
        /// (exact parity with the uncached path, which only overwrites when
506
        /// the replacement's `styled_nodes` is non-empty).
507
        styled_node: Option<Box<crate::styled_dom::StyledNode>>,
508
    },
509
    /// Multi-node subtree, cloned wholesale on hit. Rare — and today's
510
    /// splicing uses only its root (see `apply_multi_node_replacement`) — but
511
    /// stored complete so implementing real splicing later cannot be silently
512
    /// truncated by this cache.
513
    Subtree(Box<StyledDom>),
514
}
515

            
516
/// One cached resolution. `original`/`original_styled` are the KEY (together
517
/// with the spec, the map key one level up); `resolution` is the value.
518
#[derive(Debug)]
519
struct IconCacheEntry {
520
    original: NodeData,
521
    original_styled: crate::styled_dom::StyledNode,
522
    resolution: CachedIconResolution,
523
}
524

            
525
/// See the module-level `# Caching` section.
526
#[derive(Debug, Default)]
527
struct IconResolutionCache {
528
    /// The `SystemStyle` every entry was resolved under. A mismatch flushes:
529
    /// resolvers read the style (theme, tint, grayscale), so entries from
530
    /// another style are wrong, not merely stale.
531
    system_style: Option<SystemStyle>,
532
    /// spec → entries with that spec (usually exactly one; more when the same
533
    /// icon name appears with different inline styles).
534
    entries: BTreeMap<String, Vec<IconCacheEntry>>,
535
    /// Total entry count across all specs (the map holds vecs, so `len()` of
536
    /// the map alone cannot enforce [`ICON_CACHE_CAP`]).
537
    total: usize,
538
}
539

            
540
impl SharedIconProvider {
541
    /// Create from an `IconProviderHandle` (consumes the handle)
542
44
    #[must_use] pub fn from_handle(handle: IconProviderHandle) -> Self {
543
44
        Self {
544
44
            inner: handle.into_shared(),
545
44
            cache: Arc::new(Mutex::new(IconResolutionCache::default())),
546
44
        }
547
44
    }
548

            
549
    /// Flush the cache if `system_style` differs from the one its entries
550
    /// were resolved under. Called ONCE per `resolve_icons_in_styled_dom`
551
    /// batch, not per icon, so the `SystemStyle` comparison is per-frame.
552
41
    fn validate_cache_for_style(&self, system_style: &SystemStyle) {
553
41
        let Ok(mut cache) = self.cache.lock() else { return };
554
41
        match &cache.system_style {
555
8
            Some(cached) if cached == system_style => {}
556
35
            _ => {
557
35
                cache.entries.clear();
558
35
                cache.total = 0;
559
35
                cache.system_style = Some(system_style.clone());
560
35
            }
561
        }
562
41
    }
563

            
564
    /// Cache hit test. `None` = miss (resolve for real, then
565
    /// [`Self::store_resolution`]).
566
1187
    fn cached_resolution(
567
1187
        &self,
568
1187
        spec: &str,
569
1187
        node: &NodeData,
570
1187
        styled: &crate::styled_dom::StyledNode,
571
1187
    ) -> Option<CachedIconResolution> {
572
1187
        let cache = self.cache.lock().ok()?;
573
1187
        cache.entries.get(spec)?.iter().find_map(|e| {
574
15
            (e.original == *node && e.original_styled == *styled)
575
15
                .then(|| e.resolution.clone())
576
15
        })
577
1187
    }
578

            
579
    /// Insert a freshly-resolved entry, flushing everything first if the cap
580
    /// is reached (see [`ICON_CACHE_CAP`]).
581
1174
    fn store_resolution(
582
1174
        &self,
583
1174
        spec: &str,
584
1174
        node: &NodeData,
585
1174
        styled: &crate::styled_dom::StyledNode,
586
1174
        resolution: &CachedIconResolution,
587
1174
    ) {
588
1174
        let Ok(mut cache) = self.cache.lock() else { return };
589
1174
        if cache.total >= ICON_CACHE_CAP {
590
1
            cache.entries.clear();
591
1
            cache.total = 0;
592
1173
        }
593
1174
        cache
594
1174
            .entries
595
1174
            .entry(spec.to_string())
596
1174
            .or_default()
597
1174
            .push(IconCacheEntry {
598
1174
                original: node.clone(),
599
1174
                original_styled: styled.clone(),
600
1174
                resolution: resolution.clone(),
601
1174
            });
602
1174
        cache.total += 1;
603
1174
    }
604
    
605
    /// Resolve an icon to a `StyledDom` using the registered callback
606
1180
    #[must_use] pub fn resolve(
607
1180
        &self, 
608
1180
        original_icon_dom: &StyledDom,
609
1180
        icon_name: &str,
610
1180
        system_style: &SystemStyle,
611
1180
    ) -> StyledDom {
612
1180
        let (resolver, lookup_result) = {
613
1180
            let Ok(guard) = self.inner.lock() else {
614
                return StyledDom::default();
615
            };
616

            
617
1180
            let resolver = guard.resolver;
618
1180
            let lookup_result = guard.lookup_spec(icon_name);
619

            
620
1180
            (resolver, lookup_result)
621
        };
622

            
623
1180
        resolver(lookup_result.into(), original_icon_dom, system_style)
624
1180
    }
625

            
626
    /// Look up an icon by spec (bare name, `pack:name`, or a comma-separated
627
    /// fallback list of either form; first match wins)
628
888
    #[must_use] pub fn lookup(&self, icon_name: &str) -> Option<RefAny> {
629
888
        self.inner.lock().ok().and_then(|guard| guard.lookup_spec(icon_name))
630
888
    }
631

            
632
    /// Check if an icon spec resolves
633
890
    #[must_use] pub fn has_icon(&self, icon_name: &str) -> bool {
634
890
        self.inner.lock()
635
890
            .map(|guard| guard.lookup_spec(icon_name).is_some())
636
890
            .unwrap_or(false)
637
890
    }
638
}
639

            
640
// Icon Resolution in StyledDom
641

            
642
/// Collected icon node info for replacement
643
struct CollectedIcon {
644
    /// Index in the `node_data` array
645
    node_idx: usize,
646
    /// The icon spec (explicit name, or derived from the node's text children)
647
    icon_name: AzString,
648
    /// Text children that supplied the spec (`<icon>name</icon>` markup form);
649
    /// their text is cleared once the icon node is replaced so the raw spec
650
    /// never renders next to the resolved icon.
651
    text_children: Vec<usize>,
652
}
653

            
654
/// Replacement result after resolving an icon
655
struct IconReplacement {
656
    /// Index of the icon node to replace
657
    node_idx: usize,
658
    /// The resolved replacement, already normalized for both the apply step
659
    /// and the cache (empty / single node / subtree)
660
    replacement: CachedIconResolution,
661
    /// Spec-supplying text children to clear after the swap
662
    text_children: Vec<usize>,
663
}
664

            
665
/// Collect all Icon nodes from the `StyledDom`.
666
///
667
/// An Icon node with an explicit non-empty name (`Dom::create_icon("x")`)
668
/// uses that name directly. An Icon node with an EMPTY name — the markup
669
/// form `<icon>content_copy</icon>`, where the tag itself carries no name —
670
/// derives its spec from its direct text children, exactly like a ligature
671
/// icon font turns glyph text into an icon. The arena is in DFS order, so a
672
/// node's children always appear after the node itself.
673
74
fn collect_icon_nodes(styled_dom: &StyledDom) -> Vec<CollectedIcon> {
674
    use alloc::collections::BTreeMap;
675

            
676
74
    let mut icons: Vec<CollectedIcon> = Vec::new();
677
74
    let mut specs: Vec<String> = Vec::new();
678
    // node_idx of un-named icon → position in `icons`
679
74
    let mut unnamed_icon_pos: BTreeMap<usize, usize> = BTreeMap::new();
680

            
681
74
    let node_data = styled_dom.node_data.as_ref();
682
74
    let hierarchy = styled_dom.node_hierarchy.as_ref();
683

            
684
2478
    for (idx, node) in node_data.iter().enumerate() {
685
2478
        match node.get_node_type() {
686
1221
            NodeType::Icon(icon_name) => {
687
1221
                if icon_name.as_ref().as_str().is_empty() {
688
34
                    unnamed_icon_pos.insert(idx, icons.len());
689
1209
                }
690
1221
                icons.push(CollectedIcon {
691
1221
                    node_idx: idx,
692
1221
                    icon_name: icon_name.clone_self(),
693
1221
                    text_children: Vec::new(),
694
1221
                });
695
1221
                specs.push(String::new());
696
            }
697
45
            NodeType::Text(text) => {
698
45
                let Some(parent) = hierarchy
699
45
                    .get(idx)
700
45
                    .and_then(crate::styled_dom::NodeHierarchyItem::parent_id)
701
                else {
702
                    continue;
703
                };
704
45
                let Some(&icon_pos) = unnamed_icon_pos.get(&parent.index()) else {
705
12
                    continue;
706
                };
707
33
                specs[icon_pos].push_str(text.as_ref().as_str());
708
33
                icons[icon_pos].text_children.push(idx);
709
            }
710
1212
            _ => {}
711
        }
712
    }
713

            
714
85
    for &icon_pos in unnamed_icon_pos.values() {
715
34
        let spec = specs[icon_pos].trim();
716
34
        if !spec.is_empty() {
717
33
            icons[icon_pos].icon_name = AzString::from(spec);
718
34
        }
719
    }
720

            
721
74
    icons
722
74
}
723

            
724
/// Extract a single-node `StyledDom` from a parent `StyledDom` at the given index.
725
/// This creates a minimal `StyledDom` containing just that node for the resolver.
726
1184
fn extract_single_node_styled_dom(styled_dom: &StyledDom, node_idx: usize) -> StyledDom {
727
    use crate::dom::{NodeDataVec, DomId};
728
    use crate::id::NodeId;
729
    use crate::styled_dom::{
730
        StyledNodeVec, NodeHierarchyItemIdVec, TagIdToNodeIdMappingVec,
731
        NodeHierarchyItemVec, NodeHierarchyItem, NodeHierarchyItemId,
732
        ParentWithNodeDepthVec, ParentWithNodeDepth,
733
    };
734
    use crate::style::{CascadeInfoVec, CascadeInfo};
735
    use crate::prop_cache::{CssPropertyCachePtr, CssPropertyCache};
736
    
737
1184
    let node_data = styled_dom.node_data.as_ref();
738
1184
    let styled_nodes = styled_dom.styled_nodes.as_ref();
739
    
740
1184
    if node_idx >= node_data.len() {
741
6
        return StyledDom::default();
742
1178
    }
743
    
744
    // Clone the single node
745
1178
    let single_node = node_data[node_idx].clone();
746
1178
    let single_styled = if node_idx < styled_nodes.len() {
747
1177
        styled_nodes[node_idx].clone()
748
    } else {
749
1
        crate::styled_dom::StyledNode::default()
750
    };
751
    
752
1178
    StyledDom {
753
1178
        root: NodeHierarchyItemId::from_crate_internal(Some(NodeId::ZERO)),
754
1178
        node_hierarchy: NodeHierarchyItemVec::from_vec(vec![NodeHierarchyItem {
755
1178
            parent: 0,
756
1178
            previous_sibling: 0,
757
1178
            next_sibling: 0,
758
1178
            last_child: 0,
759
1178
        }]),
760
1178
        node_data: NodeDataVec::from_vec(vec![single_node]),
761
1178
        styled_nodes: StyledNodeVec::from_vec(vec![single_styled]),
762
1178
        cascade_info: CascadeInfoVec::from_vec(vec![CascadeInfo { index_in_parent: 0, is_last_child: true }]),
763
1178
        nodes_with_window_callbacks: NodeHierarchyItemIdVec::from_vec(Vec::new()),
764
1178
        nodes_with_datasets: NodeHierarchyItemIdVec::from_vec(Vec::new()),
765
1178
        tag_ids_to_node_ids: TagIdToNodeIdMappingVec::from_vec(Vec::new()),
766
1178
        non_leaf_nodes: ParentWithNodeDepthVec::from_vec(Vec::new()),
767
1178
        css_property_cache: CssPropertyCachePtr::new(CssPropertyCache::empty(1)),
768
1178
        dom_id: DomId::ROOT_ID,
769
1178
    }
770
1184
}
771

            
772
/// Resolve all collected icons, consulting the provider's cache first.
773
///
774
/// On a HIT nothing is built at all: no single-node extraction (which clones
775
/// the node's inline `Css` and allocates a throwaway `CssPropertyCache`), no
776
/// pack lookup, no resolver call, no cascade. The 66-icon ribbon that
777
/// motivated this (`RSS_MAP` §36c) turns from 66 resolver round-trips per DOM
778
/// regeneration into 66 key comparisons.
779
43
fn resolve_collected_icons(
780
43
    icons: &[CollectedIcon],
781
43
    styled_dom: &StyledDom,
782
43
    provider: &SharedIconProvider,
783
43
    system_style: &SystemStyle,
784
43
) -> Vec<IconReplacement> {
785
43
    let node_data = styled_dom.node_data.as_ref();
786
43
    let styled_nodes = styled_dom.styled_nodes.as_ref();
787
43
    let default_styled = crate::styled_dom::StyledNode::default();
788

            
789
1187
    icons.iter().map(|icon| {
790
1187
        let spec = icon.icon_name.as_str();
791
        // The key mirrors what `extract_single_node_styled_dom` would hand the
792
        // resolver: this node's data + styled state (default when absent,
793
        // matching the extraction's own fallback).
794
1187
        let key_node = node_data.get(icon.node_idx);
795
1187
        let key_styled = styled_nodes.get(icon.node_idx).unwrap_or(&default_styled);
796

            
797
1187
        if let Some(node) = key_node {
798
1187
            if let Some(hit) = provider.cached_resolution(spec, node, key_styled) {
799
13
                return IconReplacement {
800
13
                    node_idx: icon.node_idx,
801
13
                    replacement: hit,
802
13
                    text_children: icon.text_children.clone(),
803
13
                };
804
1174
            }
805
        }
806

            
807
        // MISS: the uncached path, exactly as before — extract, resolve —
808
        // followed by normalize + store.
809
1174
        let original_icon_dom = extract_single_node_styled_dom(styled_dom, icon.node_idx);
810
1174
        let resolved = provider.resolve(&original_icon_dom, spec, system_style);
811
1174
        let resolution = normalize_replacement(resolved);
812
1174
        if let Some(node) = key_node {
813
1174
            provider.store_resolution(spec, node, key_styled, &resolution);
814
1174
        }
815
1174
        IconReplacement {
816
1174
            node_idx: icon.node_idx,
817
1174
            replacement: resolution,
818
1174
            text_children: icon.text_children.clone(),
819
1174
        }
820
1187
    }).collect()
821
43
}
822

            
823
/// Deconstruct a resolver-returned `StyledDom` into [`CachedIconResolution`].
824
///
825
/// The single-node arm takes the SAME fields, by move, that
826
/// `apply_single_node_replacement` takes — everything else in the returned
827
/// `StyledDom` (notably the `CssPropertyCache` its cascade just built) was
828
/// always discarded, which is precisely why the result is cacheable in this
829
/// reduced form.
830
1174
fn normalize_replacement(replacement: StyledDom) -> CachedIconResolution {
831
1174
    match replacement.node_data.as_ref().len() {
832
3
        0 => CachedIconResolution::Empty,
833
        1 => {
834
1170
            let StyledDom { node_data, styled_nodes, .. } = replacement;
835
1170
            let mut roots = node_data.into_library_owned_vec();
836
1170
            let root = roots.swap_remove(0);
837
1170
            let NodeData { node_type, style, accessibility, .. } = root;
838
1170
            let mut styled_vec = styled_nodes.into_library_owned_vec();
839
1170
            let styled_node = if styled_vec.is_empty() {
840
                None
841
            } else {
842
1170
                Some(Box::new(styled_vec.swap_remove(0)))
843
            };
844
1170
            CachedIconResolution::SingleNode { node_type, style, accessibility, styled_node }
845
        }
846
1
        _ => CachedIconResolution::Subtree(Box::new(replacement)),
847
    }
848
1174
}
849

            
850
/// Apply a normalized resolution to the icon node at `node_idx`. Semantics
851
/// are bit-for-bit those of the pre-cache code: `Empty` → placeholder `Div`
852
/// (old `apply_single_node_replacement` empty arm), `SingleNode` → move the
853
/// four fields in (old non-empty arm), `Subtree` → root-only splice via
854
/// `apply_multi_node_replacement`.
855
1184
fn apply_cached_resolution(
856
1184
    styled_dom: &mut StyledDom,
857
1184
    node_idx: usize,
858
1184
    resolution: CachedIconResolution,
859
1184
) {
860
1184
    match resolution {
861
        CachedIconResolution::Empty => {
862
4
            if let Some(node) = styled_dom.node_data.as_mut().get_mut(node_idx) {
863
4
                node.set_node_type(NodeType::Div);
864
4
            }
865
        }
866
1178
        CachedIconResolution::SingleNode { node_type, style, accessibility, styled_node } => {
867
1178
            if let Some(node) = styled_dom.node_data.as_mut().get_mut(node_idx) {
868
1178
                node.set_node_type(node_type);
869
1178
                node.set_style(style);
870
1178
                if let Some(a11y) = accessibility {
871
                    node.set_accessibility_info(*a11y);
872
1178
                }
873
            }
874
1178
            if let Some(replacement_styled) = styled_node {
875
1178
                if let Some(styled) = styled_dom.styled_nodes.as_mut().get_mut(node_idx) {
876
1178
                    *styled = *replacement_styled;
877
1178
                }
878
            }
879
        }
880
2
        CachedIconResolution::Subtree(replacement) => {
881
2
            apply_multi_node_replacement(styled_dom, node_idx, *replacement);
882
2
        }
883
    }
884
1184
}
885

            
886
/// Check if a replacement is a single-node replacement (fast path)
887
5
fn is_single_node_replacement(replacement: &StyledDom) -> bool {
888
5
    replacement.node_data.as_ref().len() == 1
889
5
}
890

            
891
/// Apply a single-node replacement (fast path: swap `NodeType` and MOVE properties).
892
///
893
/// Takes the replacement BY VALUE. It was previously borrowed and every field
894
/// deep-copied out of it — `NodeType`, the inline `Css`, the accessibility
895
/// box, and the `StyledNode` — even though the caller already owns each
896
/// replacement (`replacements.into_iter()`) and drops it immediately after.
897
///
898
/// Cloning a `Css` is not cheap: it is a `CssRuleBlockVec`, each block holding
899
/// a `CssDeclarationVec`, and `Css::from(CssPropertyWithConditionsVec)`
900
/// (`css/src/css.rs:171`) builds ONE rule block with a ONE-ELEMENT declaration
901
/// vec per property — so a widget with N inline properties is N separate heap
902
/// allocations, and cloning it re-allocates all N. Measured on an icon-dense
903
/// ribbon: 304 style clones cascading into **14 690** `CssDeclarationVec`
904
/// clones, ~2 MB of transient churn that glibc never returns to the OS.
905
///
906
/// Moving costs nothing and cannot fail. This is a memory AND a latency fix.
907
15
fn apply_single_node_replacement(
908
15
    styled_dom: &mut StyledDom,
909
15
    node_idx: usize,
910
15
    replacement: StyledDom,
911
15
) {
912
15
    if replacement.node_data.as_ref().is_empty() {
913
        // Empty replacement - convert to empty div
914
5
        let node_data = styled_dom.node_data.as_mut();
915
5
        if let Some(node) = node_data.get_mut(node_idx) {
916
1
            node.set_node_type(NodeType::Div);
917
4
        }
918
5
        return;
919
10
    }
920

            
921
    // Consume the replacement so its root's fields can be MOVED rather than
922
    // cloned. `swap_remove(0)` is fine: only index 0 is read, and the vec is
923
    // dropped immediately after.
924
10
    let StyledDom { node_data, styled_nodes, .. } = replacement;
925
10
    let mut roots = node_data.into_library_owned_vec();
926
10
    let root = roots.swap_remove(0);
927
10
    let NodeData { node_type, style, accessibility, .. } = root;
928

            
929
10
    if let Some(node) = styled_dom.node_data.as_mut().get_mut(node_idx) {
930
4
        node.set_node_type(node_type);
931
4
        node.set_style(style);
932
4
        if let Some(a11y) = accessibility {
933
            node.set_accessibility_info(*a11y);
934
4
        }
935
6
    }
936

            
937
    // Also update the styled_nodes to reflect the new styling.
938
10
    let mut styled_vec = styled_nodes.into_library_owned_vec();
939
10
    if !styled_vec.is_empty() {
940
10
        let replacement_styled = styled_vec.swap_remove(0);
941
10
        if let Some(styled) = styled_dom.styled_nodes.as_mut().get_mut(node_idx) {
942
4
            *styled = replacement_styled;
943
6
        }
944
    }
945
15
}
946

            
947
/// Apply multi-node replacement using subtree splicing
948
8
fn apply_multi_node_replacement(
949
8
    styled_dom: &mut StyledDom,
950
8
    node_idx: usize,
951
8
    replacement: StyledDom,
952
8
) {
953
    // Read the length BEFORE moving — it is used again after the call.
954
8
    let replacement_len = replacement.node_data.as_ref().len();
955
8
    if replacement_len == 0 {
956
3
        let node_data = styled_dom.node_data.as_mut();
957
3
        if let Some(node) = node_data.get_mut(node_idx) {
958
1
            node.set_node_type(NodeType::Div);
959
2
        }
960
3
        return;
961
5
    }
962

            
963
    // For now, just apply the root node (same as single-node). Ownership is
964
    // threaded through so the root's fields MOVE rather than being cloned;
965
    // see apply_single_node_replacement.
966
5
    apply_single_node_replacement(styled_dom, node_idx, replacement);
967
    
968
5
    if replacement_len > 1 {
969
5
        // TODO: Full subtree splicing requires inserting nodes into arrays
970
5
        #[cfg(all(debug_assertions, feature = "std"))]
971
5
        eprintln!(
972
5
            "Warning: Icon replacement has {replacement_len} nodes, only root node used."
973
5
        );
974
5
    }
975
8
}
976

            
977
/// Resolve all Icon nodes in a `StyledDom` to their actual content.
978
///
979
/// This function:
980
/// 1. Collects all Icon nodes from the `StyledDom`
981
/// 2. Resolves each icon via the provider's callback (passing original icon DOM)
982
/// 3. Applies replacements (single-node fast path or multi-node splicing)
983
///
984
/// This should be called after `StyledDom` creation but before layout.
985
42
pub fn resolve_icons_in_styled_dom(
986
42
    styled_dom: &mut StyledDom,
987
42
    provider: &SharedIconProvider,
988
42
    system_style: &SystemStyle,
989
42
) {
990
    // Step 1: Collect all icon nodes
991
42
    let icons = collect_icon_nodes(styled_dom);
992

            
993
42
    if icons.is_empty() {
994
1
        return;
995
41
    }
996

            
997
    // Step 1.5: A SystemStyle change (theme flip, tint, grayscale) invalidates
998
    // every cached resolution. Checked once per batch, not once per icon.
999
41
    provider.validate_cache_for_style(system_style);
    // Step 2: Resolve all icons (cache-first; see resolve_collected_icons)
41
    let replacements = resolve_collected_icons(&icons, styled_dom, provider, system_style);
    // Step 3: Apply replacements (reverse order to preserve indices)
1184
    for replacement in replacements.into_iter().rev() {
1184
        apply_cached_resolution(
1184
            styled_dom,
1184
            replacement.node_idx,
1184
            replacement.replacement,
        );
        // `<icon>name</icon>`: the spec text was consumed by the resolution —
        // clear the contributing text children so the raw name never renders
        // next to (or instead of) the resolved icon.
1217
        for &child_idx in &replacement.text_children {
33
            if let Some(node) = styled_dom.node_data.as_mut().get_mut(child_idx) {
33
                node.set_node_type(NodeType::Text(azul_css::css::BoxOrStatic::heap(
33
                    AzString::from_const_str(""),
33
                )));
33
            }
        }
    }
42
}
// FFI Option Types
impl_option!(
    IconProviderHandle,
    OptionIconProviderHandle,
    [Clone]
);
#[cfg(test)]
#[allow(clippy::float_cmp, clippy::too_many_lines)]
mod autotest_generated {
    use core::sync::atomic::{AtomicBool, AtomicUsize, Ordering as AtomicOrdering};
    use super::*;
    use crate::{dom::NodeDataVec, styled_dom::StyledNodeVec};
    // Test payloads. The names `ImageIconData` / `FontIconData` are load-bearing:
    // `debug_lookup` sniffs `RefAny::get_type_name()` (i.e. `core::any::type_name`)
    // for those substrings.
    #[derive(Debug, Clone, PartialEq)]
    struct TestIconData {
        id: u32,
    }
    #[derive(Debug)]
    struct ImageIconData {
        _w: u32,
    }
    #[derive(Debug)]
    struct FontIconData {
        _codepoint: u32,
    }
    /// Empty / control / unicode / huge names, all of which are legal icon names
    /// (the API places no constraints on them).
    fn adversarial_names() -> Vec<String> {
        vec![
            String::new(),
            String::from(" "),
            String::from("   "),
            String::from("\t\n\r"),
            String::from("\0"),
            String::from("a\0b"),
            String::from("\u{1b}[0m"),
            String::from("../../etc/passwd"),
            String::from("home;garbage"),
            String::from("{\"json\":true}"),
            String::from("-0"),
            String::from("NaN"),
            String::from("inf"),
            String::from("9223372036854775807"),
            String::from("\u{1F600}"),               // emoji
            String::from("e\u{0301}\u{0301}"),       // combining marks
            String::from("\u{202e}RTL\u{202d}"),     // bidi override
            String::from("\u{130}"),                 // LATIN CAPITAL I WITH DOT ABOVE
            String::from("\u{FFFD}\u{10FFFF}"),      // replacement + max scalar
            "[".repeat(10_000),                      // deeply "nested" junk
            "x".repeat(100_000),                     // huge
        ]
    }
    fn styled_dom_with_icons(names: &[&str]) -> StyledDom {
        let mut body = Dom::create_body();
        for n in names {
            body.add_child(Dom::create_icon(*n));
        }
        StyledDom::create_from_dom(body)
    }
    /// A `StyledDom` with *zero* nodes — `StyledDom::default()` has one (a Body),
    /// so the truly-empty case has to be built by hand.
    fn zero_node_styled_dom() -> StyledDom {
        StyledDom {
            node_data: NodeDataVec::from_vec(Vec::new()),
            styled_nodes: StyledNodeVec::from_vec(Vec::new()),
            ..StyledDom::default()
        }
    }
    fn node_type_at(sd: &StyledDom, idx: usize) -> NodeType {
        sd.node_data.as_ref()[idx].get_node_type().clone()
    }
    fn icon_indices(sd: &StyledDom) -> Vec<usize> {
        collect_icon_nodes(sd).iter().map(|i| i.node_idx).collect()
    }
    // Resolvers
    extern "C" fn div_resolver(
        _icon_data: OptionRefAny,
        _original_icon_dom: &StyledDom,
        _system_style: &SystemStyle,
    ) -> StyledDom {
        StyledDom::create_from_dom(Dom::create_div())
    }
    extern "C" fn zero_node_resolver(
        _icon_data: OptionRefAny,
        _original_icon_dom: &StyledDom,
        _system_style: &SystemStyle,
    ) -> StyledDom {
        zero_node_styled_dom()
    }
    // Statics for `shared_resolve_receives_icon_data_and_original_dom` ONLY.
    // (`extern "C" fn` cannot capture, and tests run in parallel — never share
    // one recording resolver between two tests.)
    static REC_CALLS: AtomicUsize = AtomicUsize::new(0);
    static REC_SAW_DATA: AtomicBool = AtomicBool::new(false);
    static REC_SAW_ICON_NODE: AtomicBool = AtomicBool::new(false);
    static REC_NAME_LEN: AtomicUsize = AtomicUsize::new(0);
    extern "C" fn recording_resolver(
        icon_data: OptionRefAny,
        original_icon_dom: &StyledDom,
        _system_style: &SystemStyle,
    ) -> StyledDom {
        REC_CALLS.fetch_add(1, AtomicOrdering::SeqCst);
        if matches!(icon_data, OptionRefAny::Some(_)) {
            REC_SAW_DATA.store(true, AtomicOrdering::SeqCst);
        }
        if let Some(node) = original_icon_dom.node_data.as_ref().first() {
            if let NodeType::Icon(name) = node.get_node_type() {
                REC_SAW_ICON_NODE.store(true, AtomicOrdering::SeqCst);
                REC_NAME_LEN.store(name.as_ref().as_str().len(), AtomicOrdering::SeqCst);
            }
        }
        StyledDom::create_from_dom(Dom::create_div())
    }
    // Mutex (the no_std spinlock under `no_std`, `std::sync::Mutex` otherwise)
    #[test]
    fn mutex_new_then_lock_roundtrips_the_value() {
        let m = Mutex::new(42u32);
        assert_eq!(*m.lock().unwrap(), 42);
        *m.lock().unwrap() = u32::MAX;
        assert_eq!(*m.lock().unwrap(), u32::MAX);
    }
    #[test]
    fn mutex_lock_on_empty_and_large_payloads() {
        let empty: Mutex<Vec<u8>> = Mutex::new(Vec::new());
        assert!(empty.lock().unwrap().is_empty());
        let big = Mutex::new(vec![0u8; 1_000_000]);
        assert_eq!(big.lock().unwrap().len(), 1_000_000);
        // Sequential re-lock must not deadlock (guard dropped at end of statement).
        for _ in 0..1_000 {
            assert!(big.lock().is_ok());
        }
    }
    // default_icon_resolver
    #[test]
    fn default_resolver_returns_one_body_node_for_none_and_some() {
        let orig = StyledDom::default();
        let style = SystemStyle::default();
        let none = default_icon_resolver(OptionRefAny::None, &orig, &style);
        // NOTE: the doc calls this an "empty StyledDom", but `StyledDom::default()`
        // carries exactly one node (a Body), so the result is single-node, NOT empty.
        assert_eq!(none.node_data.as_ref().len(), 1);
        assert!(is_single_node_replacement(&none));
        let some = default_icon_resolver(
            OptionRefAny::Some(RefAny::new(TestIconData { id: 1 })),
            &orig,
            &style,
        );
        assert_eq!(some.node_data.as_ref().len(), 1);
    }
    #[test]
    fn default_resolver_no_panic_on_zero_node_original_dom() {
        let orig = zero_node_styled_dom();
        let style = SystemStyle::default();
        let out = default_icon_resolver(OptionRefAny::None, &orig, &style);
        assert_eq!(out.node_data.as_ref().len(), 1);
    }
    // IconProviderHandle: construction / invariants
    #[test]
    fn new_handle_is_empty_and_all_queries_are_negative() {
        let h = IconProviderHandle::new();
        assert!(h.list_packs().is_empty());
        assert!(h.list_icons_in_pack("anything").is_empty());
        assert!(!h.has_icon("home"));
        assert!(h.lookup("home").is_none());
        assert!(h.lookup_with_pack("home").is_none());
        assert!(h.debug_lookup("home").as_str().contains("Total packs: 0"));
    }
    #[test]
    fn default_handle_matches_new_handle() {
        let a = IconProviderHandle::default();
        let b = IconProviderHandle::new();
        assert_eq!(a.list_packs(), b.list_packs());
        assert_eq!(a.has_icon(""), b.has_icon(""));
    }
    #[test]
    fn with_resolver_installs_the_callback() {
        let mut h = IconProviderHandle::with_resolver(div_resolver);
        h.register_icon("p", "home", RefAny::new(TestIconData { id: 1 }));
        let shared = SharedIconProvider::from_handle(h);
        let out = shared.resolve(&StyledDom::default(), "home", &SystemStyle::default());
        assert!(matches!(node_type_at(&out, 0), NodeType::Div));
    }
    #[test]
    fn set_resolver_overrides_the_default_resolver() {
        let mut h = IconProviderHandle::new();
        h.set_resolver(div_resolver);
        let shared = SharedIconProvider::from_handle(h);
        // Unregistered icon: resolver still runs, just with `None` data.
        let out = shared.resolve(&StyledDom::default(), "missing", &SystemStyle::default());
        assert!(matches!(node_type_at(&out, 0), NodeType::Div));
    }
    #[test]
    fn clone_of_handle_is_deep() {
        let mut a = IconProviderHandle::new();
        a.register_icon("p", "home", RefAny::new(TestIconData { id: 1 }));
        let mut b = a.clone();
        b.register_icon("p", "settings", RefAny::new(TestIconData { id: 2 }));
        b.unregister_icon("p", "home");
        assert!(a.has_icon("home"));
        assert!(!a.has_icon("settings"));
        assert!(b.has_icon("settings"));
        assert!(!b.has_icon("home"));
    }
    #[test]
    fn drop_of_clones_and_originals_is_safe() {
        // Guards the ManuallyDrop / run_destructor convention (see the type's docs).
        let mut a = IconProviderHandle::new();
        a.register_icon("p", "home", RefAny::new(TestIconData { id: 1 }));
        for _ in 0..100 {
            let c = a.clone();
            drop(c);
        }
        assert!(a.has_icon("home"));
        drop(a);
    }
    // register / unregister
    #[test]
    fn register_icon_lowercases_icon_name_but_not_pack_name() {
        let mut h = IconProviderHandle::new();
        h.register_icon("MyPack", "HoMe", RefAny::new(TestIconData { id: 1 }));
        assert_eq!(h.list_packs(), vec![String::from("MyPack")]);
        assert!(h.list_icons_in_pack("MyPack").contains(&String::from("home")));
        // Pack name is case-sensitive:
        assert!(h.list_icons_in_pack("mypack").is_empty());
        // Icon name is not:
        assert!(h.has_icon("HOME"));
        assert!(h.has_icon("home"));
        assert!(h.has_icon("hOmE"));
    }
    #[test]
    fn registering_the_same_icon_twice_overwrites_instead_of_duplicating() {
        let mut h = IconProviderHandle::new();
        h.register_icon("p", "home", RefAny::new(TestIconData { id: 1 }));
        h.register_icon("p", "HOME", RefAny::new(TestIconData { id: 2 }));
        assert_eq!(h.list_icons_in_pack("p").len(), 1);
        let mut data = h.lookup("home").expect("icon must exist");
        assert_eq!(data.downcast_ref::<TestIconData>().unwrap().id, 2);
    }
    #[test]
    fn unregister_icon_drops_the_pack_once_it_is_empty() {
        let mut h = IconProviderHandle::new();
        h.register_icon("p", "home", RefAny::new(TestIconData { id: 1 }));
        h.register_icon("p", "settings", RefAny::new(TestIconData { id: 2 }));
        h.unregister_icon("p", "HOME"); // case-insensitive on the icon name
        assert_eq!(h.list_packs(), vec![String::from("p")]);
        assert!(!h.has_icon("home"));
        h.unregister_icon("p", "settings");
        assert!(h.list_packs().is_empty(), "pack must be pruned when empty");
    }
    #[test]
    fn unregister_of_unknown_pack_or_icon_is_a_no_op() {
        let mut h = IconProviderHandle::new();
        h.register_icon("p", "home", RefAny::new(TestIconData { id: 1 }));
        h.unregister_icon("nonexistent-pack", "home");
        h.unregister_icon("p", "nonexistent-icon");
        h.unregister_pack("nonexistent-pack");
        h.unregister_pack("");
        h.unregister_icon("", "");
        assert!(h.has_icon("home"));
        assert_eq!(h.list_packs().len(), 1);
    }
    #[test]
    fn unregister_pack_removes_all_of_its_icons() {
        let mut h = IconProviderHandle::new();
        h.register_icon("p", "a", RefAny::new(TestIconData { id: 1 }));
        h.register_icon("p", "b", RefAny::new(TestIconData { id: 2 }));
        h.register_icon("q", "a", RefAny::new(TestIconData { id: 3 }));
        h.unregister_pack("p");
        assert_eq!(h.list_packs(), vec![String::from("q")]);
        // "a" still resolvable via the other pack.
        assert!(h.has_icon("a"));
        assert!(!h.has_icon("b"));
    }
    #[test]
    fn adversarial_names_roundtrip_through_register_lookup_unregister() {
        for (i, name) in adversarial_names().iter().enumerate() {
            let mut h = IconProviderHandle::new();
            h.register_icon("p", name, RefAny::new(TestIconData { id: i as u32 }));
            assert!(h.has_icon(name), "has_icon failed for name #{i}");
            let mut data = h.lookup(name).unwrap_or_else(|| panic!("lookup failed for name #{i}"));
            assert_eq!(data.downcast_ref::<TestIconData>().unwrap().id, i as u32);
            h.unregister_icon("p", name);
            assert!(!h.has_icon(name), "unregister failed for name #{i}");
            assert!(h.list_packs().is_empty());
        }
    }
    #[test]
    fn empty_pack_name_and_empty_icon_name_are_legal_keys() {
        let mut h = IconProviderHandle::new();
        h.register_icon("", "", RefAny::new(TestIconData { id: 9 }));
        assert_eq!(h.list_packs(), vec![String::new()]);
        assert_eq!(h.list_icons_in_pack(""), vec![String::new()]);
        assert!(h.has_icon(""));
        let (pack, _) = h.lookup_with_pack("").expect("empty key must be found");
        assert_eq!(pack, "");
    }
    // lookup / lookup_with_pack (parser-shaped adversarial cases)
    #[test]
    fn lookup_empty_input_returns_none() {
        let mut h = IconProviderHandle::new();
        h.register_icon("p", "home", RefAny::new(TestIconData { id: 1 }));
        assert!(h.lookup("").is_none());
        assert!(h.lookup_with_pack("").is_none());
        assert!(!h.has_icon(""));
    }
    #[test]
    fn lookup_whitespace_only_is_not_trimmed() {
        let mut h = IconProviderHandle::new();
        h.register_icon("p", "home", RefAny::new(TestIconData { id: 1 }));
        for ws in ["   ", "\t\n", "\r", "\u{a0}"] {
            assert!(h.lookup(ws).is_none(), "{ws:?} must not match");
        }
        // ...and a whitespace-only *registered* name matches only itself, verbatim.
        h.register_icon("p", "   ", RefAny::new(TestIconData { id: 2 }));
        assert!(h.lookup("   ").is_some());
        assert!(h.lookup(" ").is_none());
        assert!(h.lookup("").is_none());
    }
    #[test]
    fn lookup_garbage_returns_none_but_spec_whitespace_is_tolerated() {
        let mut h = IconProviderHandle::new();
        h.register_icon("p", "home", RefAny::new(TestIconData { id: 1 }));
        // Genuine garbage must never match 'home'.
        for junk in [
            "home;garbage",
            "home\0",
            "\0home",
            "ho\nme",
            "home/../../etc/passwd",
            "\u{1b}[31mhome\u{1b}[0m",
            "{\"icon\":\"home\"}",
        ] {
            assert!(h.lookup(junk).is_none(), "{junk:?} must not match 'home'");
            assert!(!h.has_icon(junk));
        }
        // Spec normalization: surrounding whitespace is trimmed per entry —
        // `<icon> home </icon>` markup must resolve (ligature-font model).
        for spec in [" home ", "home ", " home"] {
            assert!(h.lookup(spec).is_some(), "{spec:?} must resolve via spec trim");
            assert!(h.has_icon(spec));
        }
        assert!(h.lookup("home").is_some(), "positive control");
    }
    #[test]
    fn lookup_of_extremely_long_name_terminates_and_matches_exactly() {
        let mut h = IconProviderHandle::new();
        let long = "x".repeat(1_000_000);
        h.register_icon("p", &long, RefAny::new(TestIconData { id: 7 }));
        assert!(h.lookup(&long).is_some());
        assert!(h.has_icon(&long));
        // One char shorter -> no match, still no panic/hang.
        assert!(h.lookup(&"x".repeat(999_999)).is_none());
        // A 1M-char miss against a small map.
        let mut h2 = IconProviderHandle::new();
        h2.register_icon("p", "home", RefAny::new(TestIconData { id: 1 }));
        assert!(h2.lookup(&long).is_none());
    }
    #[test]
    fn lookup_of_boundary_numeric_strings_is_deterministic() {
        let mut h = IconProviderHandle::new();
        for (i, n) in ["0", "-0", "9223372036854775807", "-9223372036854775808", "nan", "inf"]
            .iter()
            .enumerate()
        {
            h.register_icon("p", n, RefAny::new(TestIconData { id: i as u32 }));
        }
        // Numeric-looking names are plain string keys: no numeric parsing, no coercion.
        assert!(h.lookup("0").is_some());
        assert!(h.lookup("-0").is_some());
        assert!(h.lookup("0.0").is_none());
        assert!(h.lookup("00").is_none());
        assert!(h.lookup("+0").is_none());
        assert!(h.lookup("9223372036854775808").is_none()); // i64::MAX + 1
        // ...but case folding still applies.
        assert!(h.lookup("NaN").is_some());
        assert!(h.lookup("INF").is_some());
    }
    #[test]
    fn lookup_of_unicode_names_folds_case_without_panicking() {
        let mut h = IconProviderHandle::new();
        h.register_icon("p", "\u{1F600}", RefAny::new(TestIconData { id: 1 }));
        h.register_icon("p", "\u{C4}", RefAny::new(TestIconData { id: 2 })); // Ä
        h.register_icon("p", "I", RefAny::new(TestIconData { id: 3 }));
        assert!(h.lookup("\u{1F600}").is_some(), "emoji key must round-trip");
        assert!(h.lookup("\u{E4}").is_some(), "ä must match registered Ä");
        assert!(h.lookup("i").is_some(), "I folds to i");
        // `str::to_lowercase` is full-Unicode: "İ" (U+0130) folds to TWO scalars
        // ("i" + U+0307), so the *stored key is not the registered string*.
        let mut h2 = IconProviderHandle::new();
        h2.register_icon("p", "\u{130}", RefAny::new(TestIconData { id: 4 }));
        assert!(h2.lookup("\u{130}").is_some(), "self-lookup must still work");
        let keys = h2.list_icons_in_pack("p");
        assert_eq!(keys, vec![String::from("\u{130}").to_lowercase()]);
        assert!(!keys.contains(&String::from("\u{130}")), "key is stored folded, not verbatim");
        assert!(h2.lookup("i").is_none(), "the bare ASCII 'i' must not match İ");
    }
    #[test]
    fn lookup_of_deeply_nested_input_does_not_stack_overflow() {
        let h = IconProviderHandle::new();
        // Lookup is a map probe, not a recursive-descent parse: depth is irrelevant,
        // but assert it explicitly so a future parsing implementation stays flat.
        for depth in [1_000usize, 10_000, 100_000] {
            let nested = "[".repeat(depth);
            assert!(h.lookup(&nested).is_none());
            assert!(!h.has_icon(&nested));
            assert!(h.debug_lookup(&nested).as_str().contains("NOT FOUND"));
        }
    }
    #[test]
    fn lookup_valid_minimal_positive_control_roundtrips_the_payload() {
        let mut h = IconProviderHandle::new();
        h.register_icon("p", "a", RefAny::new(TestIconData { id: 123 }));
        let mut data = h.lookup("a").expect("registered icon must be found");
        assert_eq!(*data.downcast_ref::<TestIconData>().unwrap(), TestIconData { id: 123 });
        // Wrong-type downcast must fail rather than reinterpret the bytes.
        assert!(data.downcast_ref::<u64>().is_none());
    }
    #[test]
    fn lookup_with_pack_first_match_is_the_lexicographically_first_pack() {
        let mut h = IconProviderHandle::new();
        // Register in reverse-alphabetical order: insertion order must NOT decide.
        h.register_icon("zzz", "home", RefAny::new(TestIconData { id: 26 }));
        h.register_icon("mmm", "home", RefAny::new(TestIconData { id: 13 }));
        h.register_icon("aaa", "home", RefAny::new(TestIconData { id: 1 }));
        let (pack, _) = h.lookup_with_pack("HOME").expect("must be found");
        assert_eq!(pack, "aaa", "BTreeMap order => first match is the first pack by name");
        let mut data = h.lookup("home").unwrap();
        assert_eq!(data.downcast_ref::<TestIconData>().unwrap().id, 1);
        // Removing the winner promotes the next pack in order.
        h.unregister_pack("aaa");
        let (pack, _) = h.lookup_with_pack("home").unwrap();
        assert_eq!(pack, "mmm");
    }
    // has_icon
    #[test]
    fn has_icon_true_false_and_edge_inputs() {
        let mut h = IconProviderHandle::new();
        h.register_icon("p", "home", RefAny::new(TestIconData { id: 1 }));
        assert!(h.has_icon("home"));
        assert!(!h.has_icon("definitely-not-registered"));
        for name in adversarial_names() {
            // Deterministic bool, no panic: none of these were registered.
            assert!(!h.has_icon(&name));
        }
        assert!(h.has_icon("home"), "state unchanged by the queries above");
    }
    // getters: list_packs / list_icons_in_pack
    #[test]
    fn list_packs_is_sorted_and_case_sensitive() {
        let mut h = IconProviderHandle::new();
        for p in ["zeta", "alpha", "Alpha", "mid", ""] {
            h.register_icon(p, "home", RefAny::new(TestIconData { id: 0 }));
        }
        // BTreeMap => byte-order sorted; "Alpha" != "alpha" (case-sensitive).
        assert_eq!(
            h.list_packs(),
            vec![
                String::new(),
                String::from("Alpha"),
                String::from("alpha"),
                String::from("mid"),
                String::from("zeta"),
            ]
        );
    }
    #[test]
    fn list_icons_in_pack_returns_folded_keys_and_empty_for_unknown_packs() {
        let mut h = IconProviderHandle::new();
        h.register_icon("p", "Zoom", RefAny::new(TestIconData { id: 1 }));
        h.register_icon("p", "HOME", RefAny::new(TestIconData { id: 2 }));
        assert_eq!(h.list_icons_in_pack("p"), vec![String::from("home"), String::from("zoom")]);
        assert!(h.list_icons_in_pack("P").is_empty());
        assert!(h.list_icons_in_pack("").is_empty());
        assert!(h.list_icons_in_pack(&"x".repeat(100_000)).is_empty());
    }
    // debug_lookup
    #[test]
    fn debug_lookup_reports_not_found_for_missing_icons() {
        let mut h = IconProviderHandle::new();
        h.register_icon("p", "home", RefAny::new(TestIconData { id: 1 }));
        let out = h.debug_lookup("settings");
        let s = out.as_str();
        assert!(s.contains("NOT FOUND in any pack"));
        assert!(s.contains("Total packs: 1"));
        assert!(s.contains("Pack 'p': 1 icons"));
    }
    #[test]
    fn debug_lookup_classifies_image_font_and_unknown_refany_types() {
        let mut h = IconProviderHandle::new();
        h.register_icon("p", "img", RefAny::new(ImageIconData { _w: 16 }));
        h.register_icon("p", "fnt", RefAny::new(FontIconData { _codepoint: 0xF015 }));
        h.register_icon("p", "other", RefAny::new(TestIconData { id: 1 }));
        let img = h.debug_lookup("img");
        assert!(img.as_str().contains("FOUND in pack 'p'"));
        assert!(img.as_str().contains("RefAny type: ImageIconData"));
        let fnt = h.debug_lookup("FNT"); // case-folded lookup path
        assert!(fnt.as_str().contains("RefAny type: FontIconData"));
        let other = h.debug_lookup("other");
        assert!(other.as_str().contains("RefAny type: UNKNOWN"));
    }
    #[test]
    fn debug_lookup_survives_adversarial_names() {
        let mut h = IconProviderHandle::new();
        for (i, name) in adversarial_names().iter().enumerate() {
            h.register_icon("p", name, RefAny::new(TestIconData { id: i as u32 }));
        }
        for name in adversarial_names() {
            let out = h.debug_lookup(&name);
            assert!(out.as_str().contains("FOUND in pack 'p'"), "must find {name:?}");
        }
        assert!(h.debug_lookup("never-registered").as_str().contains("NOT FOUND"));
    }
    // SharedIconProvider
    #[test]
    fn from_handle_preserves_every_registered_icon() {
        let mut h = IconProviderHandle::new();
        for i in 0..64u32 {
            h.register_icon("p", &format!("icon{i}"), RefAny::new(TestIconData { id: i }));
        }
        let shared = SharedIconProvider::from_handle(h);
        for i in 0..64u32 {
            let name = format!("ICON{i}");
            assert!(shared.has_icon(&name));
            let mut data = shared.lookup(&name).expect("must survive into_shared()");
            assert_eq!(data.downcast_ref::<TestIconData>().unwrap().id, i);
        }
        assert!(!shared.has_icon("icon64"));
        assert!(shared.lookup("").is_none());
    }
    #[test]
    fn shared_provider_lookup_and_has_icon_agree_on_adversarial_input() {
        let mut h = IconProviderHandle::new();
        h.register_icon("p", "home", RefAny::new(TestIconData { id: 1 }));
        let shared = SharedIconProvider::from_handle(h);
        for name in adversarial_names() {
            assert_eq!(
                shared.has_icon(&name),
                shared.lookup(&name).is_some(),
                "has_icon/lookup disagree for {name:?}"
            );
        }
        assert!(shared.has_icon("HoMe") && shared.lookup("HoMe").is_some());
    }
    #[test]
    fn shared_provider_clone_shares_the_same_icon_table() {
        let mut h = IconProviderHandle::new();
        h.register_icon("p", "home", RefAny::new(TestIconData { id: 1 }));
        let a = SharedIconProvider::from_handle(h);
        let b = a.clone();
        assert!(b.has_icon("home"));
        drop(a);
        assert!(b.has_icon("home"), "clone must keep the Arc alive");
        let mut data = b.lookup("home").unwrap();
        assert_eq!(data.downcast_ref::<TestIconData>().unwrap().id, 1);
    }
    #[test]
    fn shared_resolve_receives_icon_data_and_original_dom() {
        let mut h = IconProviderHandle::with_resolver(recording_resolver);
        h.register_icon("p", "home", RefAny::new(TestIconData { id: 1 }));
        let shared = SharedIconProvider::from_handle(h);
        let original = styled_dom_with_icons(&["home"]);
        let icon_idx = icon_indices(&original)[0];
        let single = extract_single_node_styled_dom(&original, icon_idx);
        let out = shared.resolve(&single, "HOME", &SystemStyle::default());
        assert!(REC_CALLS.load(AtomicOrdering::SeqCst) >= 1);
        assert!(REC_SAW_DATA.load(AtomicOrdering::SeqCst), "case-folded lookup must pass Some(data)");
        assert!(REC_SAW_ICON_NODE.load(AtomicOrdering::SeqCst), "node 0 of the original dom is the Icon");
        assert_eq!(REC_NAME_LEN.load(AtomicOrdering::SeqCst), "home".len());
        assert!(matches!(node_type_at(&out, 0), NodeType::Div));
    }
    #[test]
    fn shared_resolve_runs_the_resolver_even_when_the_icon_is_missing() {
        let h = IconProviderHandle::with_resolver(div_resolver);
        let shared = SharedIconProvider::from_handle(h);
        // Empty name, huge name, unicode name: resolver still returns its DOM.
        let huge = "x".repeat(100_000);
        for name in ["", "\u{1F600}", huge.as_str()] {
            let out = shared.resolve(&StyledDom::default(), name, &SystemStyle::default());
            assert!(matches!(node_type_at(&out, 0), NodeType::Div));
        }
    }
    #[cfg(feature = "std")]
    #[test]
    fn shared_provider_survives_concurrent_lookups() {
        let mut h = IconProviderHandle::new();
        for i in 0..16u32 {
            h.register_icon("p", &format!("icon{i}"), RefAny::new(TestIconData { id: i }));
        }
        let shared = SharedIconProvider::from_handle(h);
        let mut threads = Vec::new();
        for _ in 0..4 {
            let s = shared.clone();
            threads.push(std::thread::spawn(move || {
                let mut hits = 0usize;
                for i in 0..200u32 {
                    let name = format!("icon{}", i % 16);
                    if s.has_icon(&name) {
                        hits += 1;
                    }
                    let mut data = s.lookup(&name).expect("registered icon");
                    assert_eq!(data.downcast_ref::<TestIconData>().unwrap().id, i % 16);
                }
                hits
            }));
        }
        for t in threads {
            assert_eq!(t.join().unwrap(), 200);
        }
        assert!(shared.has_icon("icon0"), "table intact after contention");
    }
    // collect_icon_nodes
    #[test]
    fn collect_icon_nodes_is_empty_when_there_are_no_icons() {
        assert!(collect_icon_nodes(&StyledDom::default()).is_empty());
        assert!(collect_icon_nodes(&zero_node_styled_dom()).is_empty());
        assert!(collect_icon_nodes(&StyledDom::create_from_dom(Dom::create_div())).is_empty());
    }
    #[test]
    fn collect_icon_nodes_finds_every_icon_in_ascending_index_order_with_verbatim_names() {
        let names = ["HOME", "\u{1F600}", ""];
        let sd = styled_dom_with_icons(&names);
        let collected = collect_icon_nodes(&sd);
        assert_eq!(collected.len(), names.len());
        for (i, c) in collected.iter().enumerate() {
            // Node names are NOT folded at DOM-construction time (only at lookup).
            assert_eq!(c.icon_name.as_str(), names[i]);
            if i > 0 {
                assert!(c.node_idx > collected[i - 1].node_idx, "indices must ascend");
            }
        }
    }
    #[test]
    fn collect_icon_nodes_handles_a_very_long_icon_name() {
        let long = "x".repeat(100_000);
        let sd = styled_dom_with_icons(&[&long]);
        let collected = collect_icon_nodes(&sd);
        assert_eq!(collected.len(), 1);
        assert_eq!(collected[0].icon_name.as_str().len(), 100_000);
    }
    // extract_single_node_styled_dom (numeric / index boundaries)
    #[test]
    fn extract_single_node_at_index_zero() {
        let sd = styled_dom_with_icons(&["home"]);
        let out = extract_single_node_styled_dom(&sd, 0);
        assert_eq!(out.node_data.as_ref().len(), 1);
        assert_eq!(out.styled_nodes.as_ref().len(), 1);
        assert_eq!(node_type_at(&out, 0), node_type_at(&sd, 0));
    }
    #[test]
    fn extract_single_node_of_the_icon_keeps_the_icon_node_type() {
        let sd = styled_dom_with_icons(&["home"]);
        let idx = icon_indices(&sd)[0];
        let out = extract_single_node_styled_dom(&sd, idx);
        assert_eq!(out.node_data.as_ref().len(), 1);
        assert!(matches!(node_type_at(&out, 0), NodeType::Icon(_)));
    }
    #[test]
    fn extract_single_node_out_of_bounds_falls_back_to_default_without_panicking() {
        let sd = styled_dom_with_icons(&["home"]);
        let len = sd.node_data.as_ref().len();
        for idx in [len, len + 1, usize::MAX / 2, usize::MAX - 1, usize::MAX] {
            let out = extract_single_node_styled_dom(&sd, idx);
            // Falls back to StyledDom::default() -> exactly one (Body) node.
            assert_eq!(out.node_data.as_ref().len(), 1, "idx {idx} must not panic");
            assert!(!matches!(node_type_at(&out, 0), NodeType::Icon(_)));
        }
        // Zero-node input: even index 0 is out of bounds.
        let empty = zero_node_styled_dom();
        assert_eq!(extract_single_node_styled_dom(&empty, 0).node_data.as_ref().len(), 1);
    }
    #[test]
    fn extract_single_node_tolerates_styled_nodes_shorter_than_node_data() {
        let sd_full = styled_dom_with_icons(&["home"]);
        let idx = icon_indices(&sd_full)[0];
        let mut sd = sd_full;
        sd.styled_nodes = StyledNodeVec::from_vec(Vec::new()); // desynced arrays
        let out = extract_single_node_styled_dom(&sd, idx);
        assert_eq!(out.node_data.as_ref().len(), 1);
        assert_eq!(out.styled_nodes.as_ref().len(), 1, "must synthesize a default StyledNode");
        assert!(matches!(node_type_at(&out, 0), NodeType::Icon(_)));
    }
    // is_single_node_replacement
    #[test]
    fn is_single_node_replacement_true_false_and_edges() {
        assert!(is_single_node_replacement(&StyledDom::default()));
        assert!(is_single_node_replacement(&StyledDom::create_from_dom(Dom::create_div())));
        // Zero nodes is NOT "single node" (callers treat it as the empty case).
        assert!(!is_single_node_replacement(&zero_node_styled_dom()));
        let multi = StyledDom::create_from_dom(
            Dom::create_div().with_child(Dom::create_div()).with_child(Dom::create_div()),
        );
        assert!(multi.node_data.as_ref().len() > 1);
        assert!(!is_single_node_replacement(&multi));
    }
    // apply_single_node_replacement (index boundaries)
    #[test]
    fn apply_single_node_replacement_with_zero_node_dom_turns_the_icon_into_a_div() {
        let mut sd = styled_dom_with_icons(&["home"]);
        let idx = icon_indices(&sd)[0];
        let empty = zero_node_styled_dom();
        apply_single_node_replacement(&mut sd, idx, empty);
        assert!(matches!(node_type_at(&sd, idx), NodeType::Div));
        assert!(collect_icon_nodes(&sd).is_empty());
    }
    #[test]
    fn apply_single_node_replacement_copies_the_replacement_root_node_type() {
        let mut sd = styled_dom_with_icons(&["home"]);
        let idx = icon_indices(&sd)[0];
        let before_len = sd.node_data.as_ref().len();
        let repl = StyledDom::create_from_dom(Dom::create_div());
        apply_single_node_replacement(&mut sd, idx, repl);
        assert!(matches!(node_type_at(&sd, idx), NodeType::Div));
        assert_eq!(sd.node_data.as_ref().len(), before_len, "node count must not change");
    }
    #[test]
    fn apply_single_node_replacement_out_of_bounds_index_is_a_no_op() {
        let repl = StyledDom::create_from_dom(Dom::create_div());
        let empty = zero_node_styled_dom();
        let base = styled_dom_with_icons(&["home"]);
        let icon_idx = icon_indices(&base)[0];
        let len = base.node_data.as_ref().len();
        for idx in [len, len + 1, usize::MAX / 2, usize::MAX] {
            let mut sd = styled_dom_with_icons(&["home"]);
            // Cloned because the loop reuses them; production MOVES.
            apply_single_node_replacement(&mut sd, idx, repl.clone());
            apply_single_node_replacement(&mut sd, idx, empty.clone());
            assert_eq!(sd.node_data.as_ref().len(), len, "idx {idx} must not resize");
            assert!(
                matches!(node_type_at(&sd, icon_idx), NodeType::Icon(_)),
                "idx {idx} must leave the icon untouched"
            );
        }
    }
    // apply_multi_node_replacement (index boundaries)
    #[test]
    fn apply_multi_node_replacement_with_zero_node_dom_turns_the_icon_into_a_div() {
        let mut sd = styled_dom_with_icons(&["home"]);
        let idx = icon_indices(&sd)[0];
        apply_multi_node_replacement(&mut sd, idx, zero_node_styled_dom());
        assert!(matches!(node_type_at(&sd, idx), NodeType::Div));
    }
    #[test]
    fn apply_multi_node_replacement_applies_only_the_root_and_does_not_splice() {
        let mut sd = styled_dom_with_icons(&["home"]);
        let idx = icon_indices(&sd)[0];
        let before_len = sd.node_data.as_ref().len();
        let repl = StyledDom::create_from_dom(
            Dom::create_div().with_child(Dom::create_div()).with_child(Dom::create_div()),
        );
        assert!(repl.node_data.as_ref().len() > 1);
        apply_multi_node_replacement(&mut sd, idx, repl);
        // Documented TODO: subtree splicing is not implemented, only the root is used.
        assert!(matches!(node_type_at(&sd, idx), NodeType::Div));
        assert_eq!(sd.node_data.as_ref().len(), before_len, "children are dropped, not spliced");
    }
    #[test]
    fn apply_multi_node_replacement_out_of_bounds_index_is_a_no_op() {
        let repl = StyledDom::create_from_dom(Dom::create_div().with_child(Dom::create_div()));
        let base = styled_dom_with_icons(&["home"]);
        let icon_idx = icon_indices(&base)[0];
        let len = base.node_data.as_ref().len();
        for idx in [len, usize::MAX] {
            let mut sd = styled_dom_with_icons(&["home"]);
            // Cloned because the loop reuses it; production MOVES.
            apply_multi_node_replacement(&mut sd, idx, repl.clone());
            apply_multi_node_replacement(&mut sd, idx, zero_node_styled_dom());
            assert_eq!(sd.node_data.as_ref().len(), len);
            assert!(matches!(node_type_at(&sd, icon_idx), NodeType::Icon(_)));
        }
    }
    // resolve_collected_icons
    #[test]
    fn resolve_collected_icons_preserves_indices_and_resolves_each_icon() {
        let mut h = IconProviderHandle::with_resolver(div_resolver);
        h.register_icon("p", "home", RefAny::new(TestIconData { id: 1 }));
        let shared = SharedIconProvider::from_handle(h);
        let sd = styled_dom_with_icons(&["home", "missing", "\u{1F600}"]);
        let icons = collect_icon_nodes(&sd);
        let replacements =
            resolve_collected_icons(&icons, &sd, &shared, &SystemStyle::default());
        assert_eq!(replacements.len(), icons.len());
        for (r, i) in replacements.iter().zip(icons.iter()) {
            assert_eq!(r.node_idx, i.node_idx);
            // The custom resolver ignores the data, so even unregistered icons
            // resolve. Replacements are pre-normalized now: a one-node div
            // arrives as `SingleNode { node_type: Div, .. }`.
            assert!(matches!(
                &r.replacement,
                CachedIconResolution::SingleNode { node_type: NodeType::Div, .. }
            ));
        }
    }
    #[test]
    fn resolve_collected_icons_with_no_icons_returns_no_replacements() {
        let shared = SharedIconProvider::from_handle(IconProviderHandle::new());
        let sd = StyledDom::default();
        let out = resolve_collected_icons(&[], &sd, &shared, &SystemStyle::default());
        assert!(out.is_empty());
    }
    // resolve_icons_in_styled_dom (end to end)
    #[test]
    fn resolve_icons_in_styled_dom_is_a_no_op_without_icons() {
        let shared = SharedIconProvider::from_handle(IconProviderHandle::with_resolver(div_resolver));
        let mut sd = StyledDom::create_from_dom(Dom::create_body().with_child(Dom::create_text_do_not_use_without_block_level_wrapper("hi")));
        let before_len = sd.node_data.as_ref().len();
        let before_root = node_type_at(&sd, 0);
        resolve_icons_in_styled_dom(&mut sd, &shared, &SystemStyle::default());
        assert_eq!(sd.node_data.as_ref().len(), before_len);
        assert_eq!(node_type_at(&sd, 0), before_root);
    }
    #[test]
    fn resolve_icons_in_styled_dom_replaces_every_icon_case_insensitively() {
        let mut h = IconProviderHandle::with_resolver(div_resolver);
        h.register_icon("p", "home", RefAny::new(TestIconData { id: 1 }));
        let shared = SharedIconProvider::from_handle(h);
        // Mixed case in the DOM, lowercase in the pack, plus one unregistered icon.
        let mut sd = styled_dom_with_icons(&["HOME", "unregistered", "HoMe"]);
        let idxs = icon_indices(&sd);
        let before_len = sd.node_data.as_ref().len();
        resolve_icons_in_styled_dom(&mut sd, &shared, &SystemStyle::default());
        assert_eq!(sd.node_data.as_ref().len(), before_len);
        assert!(collect_icon_nodes(&sd).is_empty(), "no Icon node may survive resolution");
        for idx in idxs {
            assert!(matches!(node_type_at(&sd, idx), NodeType::Div));
        }
    }
    #[test]
    fn resolve_icons_in_styled_dom_with_the_default_resolver_removes_the_icon_nodes() {
        // The default resolver returns `StyledDom::default()` (one Body node), so
        // icons are replaced by that root's node type rather than being cleared.
        let shared = SharedIconProvider::from_handle(IconProviderHandle::new());
        let mut sd = styled_dom_with_icons(&["home"]);
        let idx = icon_indices(&sd)[0];
        resolve_icons_in_styled_dom(&mut sd, &shared, &SystemStyle::default());
        assert!(!matches!(node_type_at(&sd, idx), NodeType::Icon(_)));
        assert!(collect_icon_nodes(&sd).is_empty());
    }
    #[test]
    fn resolve_icons_in_styled_dom_handles_a_zero_node_replacement() {
        let shared =
            SharedIconProvider::from_handle(IconProviderHandle::with_resolver(zero_node_resolver));
        let mut sd = styled_dom_with_icons(&["home", "other"]);
        let idxs = icon_indices(&sd);
        let before_len = sd.node_data.as_ref().len();
        resolve_icons_in_styled_dom(&mut sd, &shared, &SystemStyle::default());
        assert_eq!(sd.node_data.as_ref().len(), before_len);
        for idx in idxs {
            assert!(matches!(node_type_at(&sd, idx), NodeType::Div), "empty => Div placeholder");
        }
    }
    #[test]
    fn resolve_icons_in_styled_dom_scales_to_many_icons() {
        let shared = SharedIconProvider::from_handle(IconProviderHandle::with_resolver(div_resolver));
        let names: Vec<String> = (0..500).map(|i| format!("icon{i}")).collect();
        let refs: Vec<&str> = names.iter().map(String::as_str).collect();
        let mut sd = styled_dom_with_icons(&refs);
        let before_len = sd.node_data.as_ref().len();
        resolve_icons_in_styled_dom(&mut sd, &shared, &SystemStyle::default());
        assert_eq!(sd.node_data.as_ref().len(), before_len);
        assert!(collect_icon_nodes(&sd).is_empty());
    }
}
/// Tests for the resolution CACHE — the reproduction of the per-regeneration
/// waste (RSS_MAP_2026_08_07.md §36c) and the properties of the fix.
///
/// The engine calls `resolve_icons_in_styled_dom` once per DOM regeneration on
/// a FRESH StyledDom each time (the layout callback rebuilds it), so "two
/// frames" here means two identically-built DOMs — exactly what a drag-resize
/// produces 373 times in five seconds.
#[cfg(test)]
#[allow(clippy::float_cmp)]
mod icon_cache_tests {
    use core::sync::atomic::{AtomicUsize, Ordering as AtomicOrdering};
    use super::*;
    use crate::refany::RefAny;
    #[derive(Debug, Clone, PartialEq)]
    struct CacheTestIconData {
        id: u32,
    }
13
    fn dom_with_icons(names: &[&str]) -> StyledDom {
13
        let mut body = Dom::create_body();
643
        for n in names {
630
            body.add_child(Dom::create_icon(*n));
630
        }
13
        StyledDom::create_from_dom(body)
13
    }
9
    fn icon_indices(sd: &StyledDom) -> Vec<usize> {
9
        collect_icon_nodes(sd).iter().map(|i| i.node_idx).collect()
9
    }
    // Per-test statics: `extern "C" fn` cannot capture, and tests run in
    // parallel — never share one counter between two tests (same convention
    // as `autotest_generated::REC_*`).
    static FRAME_CALLS: AtomicUsize = AtomicUsize::new(0);
1
    extern "C" fn frame_counting_resolver(
1
        _icon_data: OptionRefAny,
1
        _original: &StyledDom,
1
        _style: &SystemStyle,
1
    ) -> StyledDom {
1
        FRAME_CALLS.fetch_add(1, AtomicOrdering::SeqCst);
1
        StyledDom::create_from_dom(Dom::create_div())
1
    }
    /// THE REPRODUCTION. Before the cache, this counted one resolver call per
    /// icon per frame — 3 icons × 2 frames = 6 calls for six bit-identical
    /// results (scaled up in production: 66 icons × 373 regenerations in one
    /// measured drag ≈ 24 600 calls, each running a full throwaway single-node
    /// cascade). With the cache: ONE call, ever, for identical inputs.
    #[test]
1
    fn identical_icons_across_frames_resolve_exactly_once() {
1
        let mut h = IconProviderHandle::with_resolver(frame_counting_resolver);
1
        h.register_icon("p", "home", RefAny::new(CacheTestIconData { id: 1 }));
1
        let shared = SharedIconProvider::from_handle(h);
1
        let style = SystemStyle::default();
4
        for frame in 0..3 {
3
            let mut sd = dom_with_icons(&["home", "home", "home"]);
3
            let idxs = icon_indices(&sd);
3
            resolve_icons_in_styled_dom(&mut sd, &shared, &style);
12
            for idx in idxs {
9
                assert!(
9
                    matches!(sd.node_data.as_ref()[idx].get_node_type(), NodeType::Div),
                    "frame {frame}: icon must be resolved on the cached path too"
                );
            }
        }
1
        assert_eq!(
1
            FRAME_CALLS.load(AtomicOrdering::SeqCst),
            1,
            "identical (spec, node, styled-state) must hit the cache — both \
             across frames AND across duplicates within one frame"
        );
1
    }
    static STYLE_VARIANT_CALLS: AtomicUsize = AtomicUsize::new(0);
2
    extern "C" fn style_variant_resolver(
2
        _icon_data: OptionRefAny,
2
        _original: &StyledDom,
2
        _style: &SystemStyle,
2
    ) -> StyledDom {
2
        STYLE_VARIANT_CALLS.fetch_add(1, AtomicOrdering::SeqCst);
2
        StyledDom::create_from_dom(Dom::create_div())
2
    }
    /// The resolver copies inline styles off the original node, so the same
    /// icon NAME with different inline styles is a different resolution and
    /// must occupy a different cache entry.
    #[test]
1
    fn distinct_inline_styles_are_distinct_cache_entries() {
        use azul_css::dynamic_selector::CssPropertyWithConditions;
        use azul_css::props::layout::dimensions::LayoutWidth;
        use azul_css::props::property::CssProperty;
1
        let shared = SharedIconProvider::from_handle(IconProviderHandle::with_resolver(
1
            style_variant_resolver,
        ));
1
        let style = SystemStyle::default();
2
        let build = || {
2
            let mut body = Dom::create_body();
2
            body.add_child(Dom::create_icon("home"));
2
            body.add_child(Dom::create_icon("home").with_css_props(
2
                vec![CssPropertyWithConditions::simple(CssProperty::width(
2
                    LayoutWidth::px(24.0),
                ))]
2
                .into(),
            ));
2
            StyledDom::create_from_dom(body)
2
        };
1
        let mut frame1 = build();
1
        resolve_icons_in_styled_dom(&mut frame1, &shared, &style);
1
        assert_eq!(
1
            STYLE_VARIANT_CALLS.load(AtomicOrdering::SeqCst),
            2,
            "same name, different inline style => two resolutions"
        );
1
        let mut frame2 = build();
1
        resolve_icons_in_styled_dom(&mut frame2, &shared, &style);
1
        assert_eq!(
1
            STYLE_VARIANT_CALLS.load(AtomicOrdering::SeqCst),
            2,
            "both variants must be cache hits on the second frame"
        );
1
    }
    static SYS_STYLE_CALLS: AtomicUsize = AtomicUsize::new(0);
3
    extern "C" fn sys_style_resolver(
3
        _icon_data: OptionRefAny,
3
        _original: &StyledDom,
3
        _style: &SystemStyle,
3
    ) -> StyledDom {
3
        SYS_STYLE_CALLS.fetch_add(1, AtomicOrdering::SeqCst);
3
        StyledDom::create_from_dom(Dom::create_div())
3
    }
    /// Resolvers read the SystemStyle (theme, tint, grayscale), so a style
    /// change must flush. The policy is flush-on-change, not per-style keying:
    /// flipping BACK re-resolves too. That trade is deliberate — a style flip
    /// is a rare, user-visible event; keying every entry by style would bloat
    /// every comparison for it.
    #[test]
1
    fn system_style_change_flushes_the_cache() {
1
        let shared =
1
            SharedIconProvider::from_handle(IconProviderHandle::with_resolver(sys_style_resolver));
1
        let style_a = SystemStyle::default();
1
        let mut style_b = SystemStyle::default();
1
        style_b.language = azul_css::AzString::from("xx-ZZ");
1
        assert_ne!(style_a, style_b);
1
        let mut sd = dom_with_icons(&["home"]);
1
        resolve_icons_in_styled_dom(&mut sd, &shared, &style_a);
1
        assert_eq!(SYS_STYLE_CALLS.load(AtomicOrdering::SeqCst), 1);
1
        let mut sd = dom_with_icons(&["home"]);
1
        resolve_icons_in_styled_dom(&mut sd, &shared, &style_b);
1
        assert_eq!(SYS_STYLE_CALLS.load(AtomicOrdering::SeqCst), 2, "style change => re-resolve");
1
        let mut sd = dom_with_icons(&["home"]);
1
        resolve_icons_in_styled_dom(&mut sd, &shared, &style_a);
1
        assert_eq!(
1
            SYS_STYLE_CALLS.load(AtomicOrdering::SeqCst),
            3,
            "flush-on-change: flipping back re-resolves (documented policy)"
        );
1
    }
    static PARITY_CALLS: AtomicUsize = AtomicUsize::new(0);
1
    extern "C" fn parity_resolver(
1
        _icon_data: OptionRefAny,
1
        original: &StyledDom,
1
        _style: &SystemStyle,
1
    ) -> StyledDom {
        use azul_css::dynamic_selector::CssPropertyWithConditions;
        use azul_css::props::layout::dimensions::LayoutWidth;
        use azul_css::props::property::CssProperty;
1
        PARITY_CALLS.fetch_add(1, AtomicOrdering::SeqCst);
        // A realistic replacement: styled text (what a font-icon resolver
        // produces), reading nothing but producing node type + props + a11y.
1
        let mut dom = Dom::create_text_do_not_use_without_block_level_wrapper("\u{e88a}");
1
        dom.root.set_css_props(
1
            vec![CssPropertyWithConditions::simple(CssProperty::width(LayoutWidth::px(16.0)))]
1
                .into(),
        );
1
        if let Some(orig) = original.node_data.as_ref().first() {
1
            if let Some(a11y) = orig.get_accessibility_info() {
                dom = dom.with_accessibility_info(a11y.clone());
1
            }
        }
1
        StyledDom::create(&mut dom, azul_css::css::Css::empty())
1
    }
    /// A cache hit must produce a node BIT-IDENTICAL to what the fresh
    /// resolver produced on frame 1 — node type, inline style, the lot.
    #[test]
1
    fn cached_hit_produces_an_identical_node() {
1
        let shared =
1
            SharedIconProvider::from_handle(IconProviderHandle::with_resolver(parity_resolver));
1
        let style = SystemStyle::default();
1
        let mut frame1 = dom_with_icons(&["save"]);
1
        let idx1 = icon_indices(&frame1)[0];
1
        resolve_icons_in_styled_dom(&mut frame1, &shared, &style);
1
        assert_eq!(PARITY_CALLS.load(AtomicOrdering::SeqCst), 1);
1
        let mut frame2 = dom_with_icons(&["save"]);
1
        let idx2 = icon_indices(&frame2)[0];
1
        resolve_icons_in_styled_dom(&mut frame2, &shared, &style);
1
        assert_eq!(PARITY_CALLS.load(AtomicOrdering::SeqCst), 1, "frame 2 must be a hit");
1
        assert_eq!(
1
            frame1.node_data.as_ref()[idx1],
1
            frame2.node_data.as_ref()[idx2],
            "cached and freshly-resolved node must be indistinguishable"
        );
1
        assert_eq!(
1
            frame1.styled_nodes.as_ref()[idx1],
1
            frame2.styled_nodes.as_ref()[idx2],
        );
1
    }
    static EMPTY_CALLS: AtomicUsize = AtomicUsize::new(0);
1
    extern "C" fn empty_resolver(
1
        _icon_data: OptionRefAny,
1
        _original: &StyledDom,
1
        _style: &SystemStyle,
1
    ) -> StyledDom {
1
        EMPTY_CALLS.fetch_add(1, AtomicOrdering::SeqCst);
1
        StyledDom {
1
            node_data: crate::dom::NodeDataVec::from_vec(Vec::new()),
1
            styled_nodes: crate::styled_dom::StyledNodeVec::from_vec(Vec::new()),
1
            ..StyledDom::default()
1
        }
1
    }
    /// "Icon not found → empty div" is also a resolution and is also cached.
    #[test]
1
    fn empty_resolutions_are_cached_too() {
1
        let shared =
1
            SharedIconProvider::from_handle(IconProviderHandle::with_resolver(empty_resolver));
1
        let style = SystemStyle::default();
3
        for _ in 0..2 {
2
            let mut sd = dom_with_icons(&["missing"]);
2
            let idx = icon_indices(&sd)[0];
2
            resolve_icons_in_styled_dom(&mut sd, &shared, &style);
2
            assert!(matches!(sd.node_data.as_ref()[idx].get_node_type(), NodeType::Div));
        }
1
        assert_eq!(EMPTY_CALLS.load(AtomicOrdering::SeqCst), 1);
1
    }
    static SUBTREE_CALLS: AtomicUsize = AtomicUsize::new(0);
1
    extern "C" fn subtree_resolver(
1
        _icon_data: OptionRefAny,
1
        _original: &StyledDom,
1
        _style: &SystemStyle,
1
    ) -> StyledDom {
1
        SUBTREE_CALLS.fetch_add(1, AtomicOrdering::SeqCst);
1
        StyledDom::create_from_dom(
1
            Dom::create_body()
1
                .with_child(Dom::create_div())
1
                .with_child(Dom::create_text_do_not_use_without_block_level_wrapper("x")),
        )
1
    }
    /// Multi-node replacements go through the same cache (stored as a whole
    /// `StyledDom`, cloned per hit). Splicing itself is root-only today; the
    /// cache must not change that behaviour either way.
    #[test]
1
    fn subtree_resolutions_are_cached() {
1
        let shared =
1
            SharedIconProvider::from_handle(IconProviderHandle::with_resolver(subtree_resolver));
1
        let style = SystemStyle::default();
1
        let mut first_type = None;
3
        for _ in 0..2 {
2
            let mut sd = dom_with_icons(&["multi"]);
2
            let idx = icon_indices(&sd)[0];
2
            resolve_icons_in_styled_dom(&mut sd, &shared, &style);
2
            let t = sd.node_data.as_ref()[idx].get_node_type().clone();
2
            match &first_type {
1
                None => first_type = Some(t),
1
                Some(prev) => assert_eq!(prev, &t, "cached subtree must apply identically"),
            }
        }
1
        assert_eq!(SUBTREE_CALLS.load(AtomicOrdering::SeqCst), 1);
1
    }
    static CAP_CALLS: AtomicUsize = AtomicUsize::new(0);
612
    extern "C" fn cap_resolver(
612
        _icon_data: OptionRefAny,
612
        _original: &StyledDom,
612
        _style: &SystemStyle,
612
    ) -> StyledDom {
612
        CAP_CALLS.fetch_add(1, AtomicOrdering::SeqCst);
612
        StyledDom::create_from_dom(Dom::create_div())
612
    }
    /// Unbounded distinct specs must not grow the cache without limit; the
    /// flush-all overflow policy degrades to uncached behaviour, never to
    /// unbounded memory.
    #[test]
1
    fn cache_is_capped_and_correct_past_the_cap() {
1
        let shared =
1
            SharedIconProvider::from_handle(IconProviderHandle::with_resolver(cap_resolver));
1
        let style = SystemStyle::default();
612
        let names: Vec<String> = (0..(ICON_CACHE_CAP + 100)).map(|i| format!("icon{i}")).collect();
1
        let refs: Vec<&str> = names.iter().map(String::as_str).collect();
1
        let mut sd = dom_with_icons(&refs);
1
        resolve_icons_in_styled_dom(&mut sd, &shared, &style);
1
        assert_eq!(CAP_CALLS.load(AtomicOrdering::SeqCst), ICON_CACHE_CAP + 100);
1
        assert!(collect_icon_nodes(&sd).is_empty(), "every icon still resolved");
1
        let cache = shared.cache.lock().unwrap();
1
        assert!(
1
            cache.total <= ICON_CACHE_CAP,
            "cap must hold: total={} cap={}",
            cache.total,
            ICON_CACHE_CAP
        );
1
    }
}