From 166d7588204db670ce6bbe76ed99454e15339ff3 Mon Sep 17 00:00:00 2001 From: Karim Jerbi Date: Sat, 29 Aug 2026 19:33:05 +0100 Subject: [PATCH] fix(ui): stabilize themed cache keys with static byte slices and harden tests The themed handle cache (introduced in cc430cdb) keys entries by (address, length) of &'static [u8] art. However, the art was declared as const; Rust may inline distinct copies of a const at each use site, giving the same logical icon different addresses. This broke cache lookups for wrapper functions (e.g., themed_arrow_down) that refer to the same icon from a different module than the cache test. Convert the ~30 themed-relevant byte slices (TRI_*, HOME, UNDO, REDO, CHECK, etc.) to static so each artwork has one stable address process-wide. Also refactor themed_handle to drop the immutable borrow before building a miss handle, preventing a reentrant call from panicking on a second borrow_mut. Adjust themed_cache_tests to use delta assertions and per-test cache removal for isolation, and add coverage for second-call cache hits, wrapper delegation, and the length component of the key. Test: ui::icons::themed_cache_tests::themed_cache_returns_same_handle_for_same_bytes Test: ui::icons::themed_cache_tests::themed_cache_caches_distinct_bytes_separately Test: ui::icons::themed_cache_tests::themed_cache_second_call_is_a_cache_hit Test: ui::icons::themed_cache_tests::themed_wrapper_populates_cache_for_underlying_bytes Test: ui::icons::themed_cache_tests::themed_cache_key_includes_length Test: ui::icons::themed_cache_tests::themed_cache_speedup_over_uncached_handle_creation --- src/ui/icons.rs | 300 ++++++++++++++++++++++++++++++++++++------------ 1 file changed, 224 insertions(+), 76 deletions(-) diff --git a/src/ui/icons.rs b/src/ui/icons.rs index 3ef4cfc90..5bb37dd15 100644 --- a/src/ui/icons.rs +++ b/src/ui/icons.rs @@ -19,81 +19,80 @@ use iced::{ }; use rustc_hash::FxHashMap; -const TRI_DOWN: &[u8] = include_bytes!("../../assets/icons/ui/tri_down.svg"); -const TRI_UP: &[u8] = include_bytes!("../../assets/icons/ui/tri_up.svg"); -const TRI_RIGHT: &[u8] = include_bytes!("../../assets/icons/ui/tri_right.svg"); -const TRI_LEFT: &[u8] = include_bytes!("../../assets/icons/ui/tri_left.svg"); -const HOME: &[u8] = include_bytes!("../../assets/icons/ui/home.svg"); -const UNDO: &[u8] = include_bytes!("../../assets/icons/ui/undo.svg"); -const REDO: &[u8] = include_bytes!("../../assets/icons/ui/redo.svg"); +static TRI_DOWN: &[u8] = include_bytes!("../../assets/icons/ui/tri_down.svg"); +static TRI_UP: &[u8] = include_bytes!("../../assets/icons/ui/tri_up.svg"); +static TRI_RIGHT: &[u8] = include_bytes!("../../assets/icons/ui/tri_right.svg"); +static TRI_LEFT: &[u8] = include_bytes!("../../assets/icons/ui/tri_left.svg"); +static HOME: &[u8] = include_bytes!("../../assets/icons/ui/home.svg"); +static UNDO: &[u8] = include_bytes!("../../assets/icons/ui/undo.svg"); +static REDO: &[u8] = include_bytes!("../../assets/icons/ui/redo.svg"); // OSNAP marker symbols. Rendered as SVG (not Unicode glyphs) so the snap menu // shows the right shapes on the web build, whose bundled Fira Sans lacks the // geometric glyphs and rendered them as tofu boxes. (#138) -const OSNAP_ENDPOINT: &[u8] = include_bytes!("../../assets/icons/osnap/endpoint.svg"); -const OSNAP_MIDPOINT: &[u8] = include_bytes!("../../assets/icons/osnap/midpoint.svg"); -const OSNAP_CENTER: &[u8] = include_bytes!("../../assets/icons/osnap/center.svg"); -const OSNAP_NODE: &[u8] = include_bytes!("../../assets/icons/osnap/node.svg"); -const OSNAP_QUADRANT: &[u8] = include_bytes!("../../assets/icons/osnap/quadrant.svg"); -const OSNAP_INTERSECTION: &[u8] = include_bytes!("../../assets/icons/osnap/intersection.svg"); -const OSNAP_EXTENSION: &[u8] = include_bytes!("../../assets/icons/osnap/extension.svg"); -const OSNAP_INSERTION: &[u8] = include_bytes!("../../assets/icons/osnap/insertion.svg"); -const OSNAP_PERPENDICULAR: &[u8] = - include_bytes!("../../assets/icons/osnap/perpendicular.svg"); -const OSNAP_TANGENT: &[u8] = include_bytes!("../../assets/icons/osnap/tangent.svg"); -const OSNAP_NEAREST: &[u8] = include_bytes!("../../assets/icons/osnap/nearest.svg"); -const OSNAP_APPARENT: &[u8] = include_bytes!("../../assets/icons/osnap/apparent.svg"); -const OSNAP_PARALLEL: &[u8] = include_bytes!("../../assets/icons/osnap/parallel.svg"); -const OSNAP_GRID: &[u8] = include_bytes!("../../assets/icons/osnap/grid.svg"); +static OSNAP_ENDPOINT: &[u8] = include_bytes!("../../assets/icons/osnap/endpoint.svg"); +static OSNAP_MIDPOINT: &[u8] = include_bytes!("../../assets/icons/osnap/midpoint.svg"); +static OSNAP_CENTER: &[u8] = include_bytes!("../../assets/icons/osnap/center.svg"); +static OSNAP_NODE: &[u8] = include_bytes!("../../assets/icons/osnap/node.svg"); +static OSNAP_QUADRANT: &[u8] = include_bytes!("../../assets/icons/osnap/quadrant.svg"); +static OSNAP_INTERSECTION: &[u8] = include_bytes!("../../assets/icons/osnap/intersection.svg"); +static OSNAP_EXTENSION: &[u8] = include_bytes!("../../assets/icons/osnap/extension.svg"); +static OSNAP_INSERTION: &[u8] = include_bytes!("../../assets/icons/osnap/insertion.svg"); +static OSNAP_PERPENDICULAR: &[u8] = include_bytes!("../../assets/icons/osnap/perpendicular.svg"); +static OSNAP_TANGENT: &[u8] = include_bytes!("../../assets/icons/osnap/tangent.svg"); +static OSNAP_NEAREST: &[u8] = include_bytes!("../../assets/icons/osnap/nearest.svg"); +static OSNAP_APPARENT: &[u8] = include_bytes!("../../assets/icons/osnap/apparent.svg"); +static OSNAP_PARALLEL: &[u8] = include_bytes!("../../assets/icons/osnap/parallel.svg"); +static OSNAP_GRID: &[u8] = include_bytes!("../../assets/icons/osnap/grid.svg"); -const LAY_ON: &[u8] = include_bytes!("../../assets/icons/layers/layon.svg"); -const LAY_OFF: &[u8] = include_bytes!("../../assets/icons/layers/layoff.svg"); -const LAY_FRZ: &[u8] = include_bytes!("../../assets/icons/layers/layfrz.svg"); -const LAY_THW: &[u8] = include_bytes!("../../assets/icons/layers/laythw.svg"); -const LAY_LCK: &[u8] = include_bytes!("../../assets/icons/layers/laylck.svg"); -const LAY_ULK: &[u8] = include_bytes!("../../assets/icons/layers/layulk.svg"); +static LAY_ON: &[u8] = include_bytes!("../../assets/icons/layers/layon.svg"); +static LAY_OFF: &[u8] = include_bytes!("../../assets/icons/layers/layoff.svg"); +static LAY_FRZ: &[u8] = include_bytes!("../../assets/icons/layers/layfrz.svg"); +static LAY_THW: &[u8] = include_bytes!("../../assets/icons/layers/laythw.svg"); +static LAY_LCK: &[u8] = include_bytes!("../../assets/icons/layers/laylck.svg"); +static LAY_ULK: &[u8] = include_bytes!("../../assets/icons/layers/layulk.svg"); // Monochrome chrome glyphs (replace Unicode glyphs in buttons / menus / toolbars). // All are black-on-transparent; recolour them at the call site with [`tinted`]. -pub const CHECK: &[u8] = include_bytes!("../../assets/icons/ui/check.svg"); -pub const CLOSE: &[u8] = include_bytes!("../../assets/icons/ui/close.svg"); -pub const PLUS: &[u8] = include_bytes!("../../assets/icons/ui/plus.svg"); -pub const MINUS: &[u8] = include_bytes!("../../assets/icons/ui/minus.svg"); -pub const TRASH: &[u8] = include_bytes!("../../assets/icons/ui/trash.svg"); -pub const COPY: &[u8] = include_bytes!("../../assets/icons/ui/copy.svg"); -pub const MENU: &[u8] = include_bytes!("../../assets/icons/ui/menu.svg"); -pub const MOVE: &[u8] = include_bytes!("../../assets/icons/ui/move.svg"); -pub const RESIZE: &[u8] = include_bytes!("../../assets/icons/ui/resize.svg"); -pub const PIN: &[u8] = include_bytes!("../../assets/icons/ui/pin.svg"); -pub const SPLIT_V: &[u8] = include_bytes!("../../assets/icons/ui/split_v.svg"); -pub const SPLIT_H: &[u8] = include_bytes!("../../assets/icons/ui/split_h.svg"); -pub const GRID: &[u8] = include_bytes!("../../assets/icons/ui/grid.svg"); -pub const SNAP: &[u8] = include_bytes!("../../assets/icons/ui/snap.svg"); -pub const DOC_NEW: &[u8] = include_bytes!("../../assets/icons/ui/doc_new.svg"); -pub const FOLDER_OPEN: &[u8] = include_bytes!("../../assets/icons/ui/folder_open.svg"); -pub const SAVE: &[u8] = include_bytes!("../../assets/icons/ui/save.svg"); -pub const FILE_EXPORT: &[u8] = include_bytes!("../../assets/icons/ui/file_export.svg"); -pub const PRINT: &[u8] = include_bytes!("../../assets/icons/ui/print.svg"); -pub const HEART: &[u8] = include_bytes!("../../assets/icons/ui/heart.svg"); +pub static CHECK: &[u8] = include_bytes!("../../assets/icons/ui/check.svg"); +pub static CLOSE: &[u8] = include_bytes!("../../assets/icons/ui/close.svg"); +pub static PLUS: &[u8] = include_bytes!("../../assets/icons/ui/plus.svg"); +pub static MINUS: &[u8] = include_bytes!("../../assets/icons/ui/minus.svg"); +pub static TRASH: &[u8] = include_bytes!("../../assets/icons/ui/trash.svg"); +pub static COPY: &[u8] = include_bytes!("../../assets/icons/ui/copy.svg"); +pub static MENU: &[u8] = include_bytes!("../../assets/icons/ui/menu.svg"); +pub static MOVE: &[u8] = include_bytes!("../../assets/icons/ui/move.svg"); +pub static RESIZE: &[u8] = include_bytes!("../../assets/icons/ui/resize.svg"); +pub static PIN: &[u8] = include_bytes!("../../assets/icons/ui/pin.svg"); +pub static SPLIT_V: &[u8] = include_bytes!("../../assets/icons/ui/split_v.svg"); +pub static SPLIT_H: &[u8] = include_bytes!("../../assets/icons/ui/split_h.svg"); +pub static GRID: &[u8] = include_bytes!("../../assets/icons/ui/grid.svg"); +pub static SNAP: &[u8] = include_bytes!("../../assets/icons/ui/snap.svg"); +pub static DOC_NEW: &[u8] = include_bytes!("../../assets/icons/ui/doc_new.svg"); +pub static FOLDER_OPEN: &[u8] = include_bytes!("../../assets/icons/ui/folder_open.svg"); +pub static SAVE: &[u8] = include_bytes!("../../assets/icons/ui/save.svg"); +pub static FILE_EXPORT: &[u8] = include_bytes!("../../assets/icons/ui/file_export.svg"); +pub static PRINT: &[u8] = include_bytes!("../../assets/icons/ui/print.svg"); +pub static HEART: &[u8] = include_bytes!("../../assets/icons/ui/heart.svg"); #[cfg(target_arch = "wasm32")] -pub const GEAR: &[u8] = include_bytes!("../../assets/icons/ui/gear.svg"); -pub const DOT: &[u8] = include_bytes!("../../assets/icons/ui/dot.svg"); -pub const DIRTY_DOT: &[u8] = include_bytes!("../../assets/icons/ui/dirty_dot.svg"); -pub const ARROW_LONG_RIGHT: &[u8] = include_bytes!("../../assets/icons/ui/arrow_long_right.svg"); +pub static GEAR: &[u8] = include_bytes!("../../assets/icons/ui/gear.svg"); +pub static DOT: &[u8] = include_bytes!("../../assets/icons/ui/dot.svg"); +pub static DIRTY_DOT: &[u8] = include_bytes!("../../assets/icons/ui/dirty_dot.svg"); +pub static ARROW_LONG_RIGHT: &[u8] = include_bytes!("../../assets/icons/ui/arrow_long_right.svg"); // ── Status-bar toggle icons (issue #216) ────────────────────────────────── -pub const ST_ORTHO: &[u8] = include_bytes!("../../assets/icons/status/ortho.svg"); -pub const ST_POLAR: &[u8] = include_bytes!("../../assets/icons/status/polar.svg"); -pub const ST_OSNAP: &[u8] = include_bytes!("../../assets/icons/status/osnap.svg"); -pub const ST_OTRACK: &[u8] = include_bytes!("../../assets/icons/status/otrack.svg"); -pub const ST_DYN: &[u8] = include_bytes!("../../assets/icons/status/dyn.svg"); -pub const ST_LWT: &[u8] = include_bytes!("../../assets/icons/status/lwt.svg"); -pub const ST_TRANSPARENCY: &[u8] = include_bytes!("../../assets/icons/status/transparency.svg"); -pub const ST_ISOLATE: &[u8] = include_bytes!("../../assets/icons/status/isolate.svg"); -pub const ST_QUICKPROPS: &[u8] = include_bytes!("../../assets/icons/status/quickprops.svg"); -pub const ST_FILTER: &[u8] = include_bytes!("../../assets/icons/status/filter.svg"); -pub const ST_SELCYCLE: &[u8] = include_bytes!("../../assets/icons/status/selcycle.svg"); -pub const ST_CLEANSCREEN: &[u8] = include_bytes!("../../assets/icons/status/cleanscreen.svg"); +pub static ST_ORTHO: &[u8] = include_bytes!("../../assets/icons/status/ortho.svg"); +pub static ST_POLAR: &[u8] = include_bytes!("../../assets/icons/status/polar.svg"); +pub static ST_OSNAP: &[u8] = include_bytes!("../../assets/icons/status/osnap.svg"); +pub static ST_OTRACK: &[u8] = include_bytes!("../../assets/icons/status/otrack.svg"); +pub static ST_DYN: &[u8] = include_bytes!("../../assets/icons/status/dyn.svg"); +pub static ST_LWT: &[u8] = include_bytes!("../../assets/icons/status/lwt.svg"); +pub static ST_TRANSPARENCY: &[u8] = include_bytes!("../../assets/icons/status/transparency.svg"); +pub static ST_ISOLATE: &[u8] = include_bytes!("../../assets/icons/status/isolate.svg"); +pub static ST_QUICKPROPS: &[u8] = include_bytes!("../../assets/icons/status/quickprops.svg"); +pub static ST_FILTER: &[u8] = include_bytes!("../../assets/icons/status/filter.svg"); +pub static ST_SELCYCLE: &[u8] = include_bytes!("../../assets/icons/status/selcycle.svg"); +pub static ST_CLEANSCREEN: &[u8] = include_bytes!("../../assets/icons/status/cleanscreen.svg"); // Tool SVGs share a small source palette. These colours are semantic rather // than literal: cyan is the accent, pale grey is foreground, yellow is warning, @@ -151,17 +150,26 @@ thread_local! { /// is bounded by construction and needs no eviction. Per-frame /// cost: 9 ns/iter warm, 26 ns/iter cold (release profile, see /// `themed_cache_speedup_over_uncached_handle_creation`). +/// +/// **No borrow held during handle construction.** The cache is +/// read (immutable `borrow`), the `Ref` is dropped, and only then +/// is `svg::Handle::from_memory` invoked. The result is then +/// inserted under a fresh `borrow_mut`. This is defensive: a +/// re-entrant call into `themed*` during SVG parsing cannot +/// trigger a `RefCell` panic on the second `borrow_mut`, because +/// no `borrow_mut` is held while parsing runs. (The same +/// `thread_local!` + `RefCell` shape is used by `SEMANTIC_CACHE`.) fn themed_handle(bytes: &'static [u8]) -> svg::Handle { let key = (bytes.as_ptr() as usize, bytes.len()); + if let Some(handle) = THEMED_CACHE.with(|cache| cache.borrow().get(&key).cloned()) { + return handle; + } + // Miss: build the handle with no cache borrow held, then insert. + let handle = svg::Handle::from_memory(bytes); THEMED_CACHE.with(|cache| { - let mut cache = cache.borrow_mut(); - if let Some(handle) = cache.get(&key) { - return handle.clone(); - } - let handle = svg::Handle::from_memory(bytes); - cache.insert(key, handle.clone()); - handle - }) + cache.borrow_mut().insert(key, handle.clone()); + }); + handle } #[derive(Clone, Copy)] @@ -733,17 +741,42 @@ mod themed_cache_tests { THEMED_CACHE.with(|c| c.borrow().contains_key(&(ptr as usize, len))) } + /// Returns a clone of the cached `svg::Handle` for `(ptr, len)`, + /// or `None` if no entry is cached. Test-only inspection helper. + fn themed_cache_get_handle_for_test( + ptr: *const u8, + len: usize, + ) -> Option { + THEMED_CACHE.with(|c| c.borrow().get(&(ptr as usize, len)).cloned()) + } + + /// Removes the cache entry for `(ptr, len)`. Test-only helper so + /// individual tests can start from a known-empty cache regardless + /// of test execution order (tests share the `thread_local!` when + /// run on the same thread). + fn themed_cache_remove_for_test(ptr: *const u8, len: usize) { + THEMED_CACHE.with(|c| { + c.borrow_mut().remove(&(ptr as usize, len)); + }) + } + /// Two calls to the public `themed` function with the same /// `&'static [u8]` must share a single cache entry: the second /// call is a cache hit, not a fresh `Handle::from_memory`. #[test] fn themed_cache_returns_same_handle_for_same_bytes() { + themed_cache_remove_for_test(TRI_DOWN.as_ptr(), TRI_DOWN.len()); + assert!( + !themed_cache_contains_for_test(TRI_DOWN.as_ptr(), TRI_DOWN.len()), + "precondition: TRI_DOWN must not be cached before the test" + ); + let size_before = themed_cache_size_for_test(); let _: Element<'static, ()> = themed(TRI_DOWN, 16.0); let _: Element<'static, ()> = themed(TRI_DOWN, 16.0); assert_eq!( themed_cache_size_for_test(), - 1, - "two calls with the same bytes must share a single cache entry" + size_before + 1, + "two calls with the same bytes must share a single cache entry (delta +1)" ); assert!( themed_cache_contains_for_test(TRI_DOWN.as_ptr(), TRI_DOWN.len()), @@ -755,17 +788,132 @@ mod themed_cache_tests { /// entries (no false aliasing across different artwork). #[test] fn themed_cache_caches_distinct_bytes_separately() { + themed_cache_remove_for_test(TRI_DOWN.as_ptr(), TRI_DOWN.len()); + themed_cache_remove_for_test(TRI_UP.as_ptr(), TRI_UP.len()); + assert!(!themed_cache_contains_for_test(TRI_DOWN.as_ptr(), TRI_DOWN.len())); + assert!(!themed_cache_contains_for_test(TRI_UP.as_ptr(), TRI_UP.len())); + let size_before = themed_cache_size_for_test(); let _: Element<'static, ()> = themed(TRI_DOWN, 16.0); let _: Element<'static, ()> = themed(TRI_UP, 16.0); assert_eq!( themed_cache_size_for_test(), - 2, - "distinct bytes must produce distinct cache entries" + size_before + 2, + "distinct bytes must produce distinct cache entries (delta +2)" ); assert!(themed_cache_contains_for_test(TRI_DOWN.as_ptr(), TRI_DOWN.len())); assert!(themed_cache_contains_for_test(TRI_UP.as_ptr(), TRI_UP.len())); } + /// The cached `svg::Handle` is a single source of truth: two + /// calls with the same bytes must return a handle derived from + /// the same cache entry, not a freshly-built handle. + /// + /// **`svg::Handle` is opaque and has no `PartialEq` impl**, so + /// we cannot compare handles directly for pointer-identity. The + /// best verifiable proxy is: (a) the second call does not grow + /// the cache (a buggy `entry().or_insert_with` that always + /// inserted would still show size 1 for a single key, so this + /// rules out a double-insert on a single key), and (b) the + /// getter returns the cached handle across the two calls (the + /// cache survived and still resolves the key). Together these + /// confirm the cache is a single source — the test's identity + /// guarantee is the weaker "the cache is populated and reused" + /// rather than the stronger "the handle is pointer-identical", + /// which `svg::Handle` does not expose. + #[test] + fn themed_cache_second_call_is_a_cache_hit() { + themed_cache_remove_for_test(TRI_DOWN.as_ptr(), TRI_DOWN.len()); + assert!( + !themed_cache_contains_for_test(TRI_DOWN.as_ptr(), TRI_DOWN.len()), + "precondition: TRI_DOWN must not be cached" + ); + let size_before = themed_cache_size_for_test(); + // Warm the cache with the first call (miss → build → insert). + let _: Element<'static, ()> = themed(TRI_DOWN, 16.0); + let cached_after_first = + themed_cache_get_handle_for_test(TRI_DOWN.as_ptr(), TRI_DOWN.len()) + .expect("cache must hold the handle after the first call"); + let size_after_first = themed_cache_size_for_test(); + assert_eq!( + size_after_first, + size_before + 1, + "first call must insert exactly one entry" + ); + // Second call: must be a cache hit, not a fresh build. + let _: Element<'static, ()> = themed(TRI_DOWN, 16.0); + let cached_after_second = + themed_cache_get_handle_for_test(TRI_DOWN.as_ptr(), TRI_DOWN.len()) + .expect("cache must still hold the handle after the second call"); + assert_eq!( + themed_cache_size_for_test(), + size_after_first, + "second call must not grow the cache (hit, not rebuild)" + ); + let _ = (cached_after_first, cached_after_second); + } + + /// Wrapper functions (e.g. `themed_arrow_down`) delegate to + /// the 9 `themed*` byte-taking functions and therefore inherit + /// the cache transparently. This end-to-end test confirms the + /// delegation: a wrapper call populates the cache for its + /// underlying static bytes. The cache entry is cleared first so + /// the assertion is independent of test execution order (tests + /// share the `thread_local!` when run on the same thread). + #[test] + fn themed_wrapper_populates_cache_for_underlying_bytes() { + themed_cache_remove_for_test(TRI_DOWN.as_ptr(), TRI_DOWN.len()); + assert!( + !themed_cache_contains_for_test(TRI_DOWN.as_ptr(), TRI_DOWN.len()), + "precondition: TRI_DOWN must not be cached before the call" + ); + let size_before = themed_cache_size_for_test(); + // `themed_arrow_down` delegates to `themed(TRI_DOWN, size)`. + let _: Element<'static, ()> = themed_arrow_down(16.0); + assert_eq!( + themed_cache_size_for_test(), + size_before + 1, + "one wrapper call must populate exactly one cache entry (delta +1)" + ); + assert!( + themed_cache_contains_for_test(TRI_DOWN.as_ptr(), TRI_DOWN.len()), + "the cache must hold an entry for the wrapper's underlying bytes (TRI_DOWN)" + ); + } + + /// Cache key includes length: a sub-slice of a static must not + /// alias the full static's entry. This guards the + /// `(address, length)` belt-and-braces key `src/ui/icons.rs:164` + /// — a pointer-only key would incorrectly hit on `&BYTES[1..]`. + #[test] + fn themed_cache_key_includes_length() { + // Ensure clean state for both keys. + themed_cache_remove_for_test(TRI_DOWN.as_ptr(), TRI_DOWN.len()); + // Sub-slice of TRI_DOWN: same address region but different length. + // SAFETY: TRI_DOWN is at least 2 bytes (real SVG), so [1..] is valid. + let sub = &TRI_DOWN[1..]; + themed_cache_remove_for_test(sub.as_ptr(), sub.len()); + assert!(!themed_cache_contains_for_test(TRI_DOWN.as_ptr(), TRI_DOWN.len())); + assert!(!themed_cache_contains_for_test(sub.as_ptr(), sub.len())); + let size_before = themed_cache_size_for_test(); + // Populate full static. + let _: Element<'static, ()> = themed(TRI_DOWN, 16.0); + assert!(themed_cache_contains_for_test(TRI_DOWN.as_ptr(), TRI_DOWN.len())); + assert!( + !themed_cache_contains_for_test(sub.as_ptr(), sub.len()), + "sub-slice must not hit the full static's entry" + ); + assert_eq!(themed_cache_size_for_test(), size_before + 1); + // Populate sub-slice directly via the internal helper (bypasses + // the `themed` wrappers which only use the full statics). + let _ = super::themed_handle(sub); + assert!(themed_cache_contains_for_test(sub.as_ptr(), sub.len())); + assert_eq!( + themed_cache_size_for_test(), + size_before + 2, + "sub-slice must produce a distinct entry (length part of key)" + ); + } + /// Before/after benchmark: the cached `themed_handle` path /// (hashmap get + a `svg::Handle` clone) vs. the original /// un-cached path (`svg::Handle::from_memory` per call, which