diff --git a/docs/guides/creating-dui-packages.md b/docs/guides/creating-dui-packages.md index 28c6224..f575f25 100644 --- a/docs/guides/creating-dui-packages.md +++ b/docs/guides/creating-dui-packages.md @@ -496,80 +496,22 @@ When an event handler takes time to complete (e.g. making an API call), users ge ### Defining a Spinner -Add a `spinner` section to your manifest: +The spinner is a single, library-owned 360° rotation animation (8 frames at 100 ms intervals, fixed by DeUX). Your package only declares **where** the spinner appears. -```yaml -spinner: - type: rotation # "rotation", "pulse", or "custom" - node: spinner_icon # SVG element ID to animate (required for rotation/pulse) - frames: 12 # frames per cycle (default 12, minimum 2) - interval_ms: 80 # ms between frames (default 80, minimum 10) -``` - -Your SVG must include an element with the spinner node ID. It's typically hidden by default and made visible during animation: - -```xml - -``` - -### Spinner Types - -#### `rotation` - -Rotates the SVG node by `360/frames` degrees each frame around its centre. Good for loading spinners. - -```yaml -spinner: - type: rotation - node: spinner_icon - frames: 12 - interval_ms: 80 -``` - -#### `pulse` - -Cycles the node's opacity between 0.2 and 1.0 in a triangle wave. Good for subtle "working" indicators. +Add a `spinner` section to your manifest with a single `node` key: ```yaml spinner: - type: pulse - node: spinner_glow - frames: 8 - interval_ms: 100 + node: spinner # ID of the placeholder element in your SVG ``` -#### `custom` +Your layout SVG must contain an empty group with that ID, positioned via its `transform` attribute. DeUX injects the canonical spinner geometry (background tile + 8 radial bars) into this placeholder at runtime: -Use your own pre-rendered frames. Place them in one of two formats: - -**Numbered PNGs** in `assets/spinner/`: - -``` -MyPackage.dui/ - assets/ - spinner/ - frame_00.png - frame_01.png - frame_02.png - ... -``` - -**Animated GIF** at `assets/spinner.gif`: - -``` -MyPackage.dui/ - assets/ - spinner.gif +```xml + ``` -For custom spinners, the `node` field is optional (ignored). - -```yaml -spinner: - type: custom - interval_ms: 60 -``` +The placeholder's `transform` (typically `translate(cx cy)`) controls where the spinner is centred. All other attributes — frame count, interval, bar geometry, background colour — are owned by the library and are not configurable. ### How It Works @@ -580,20 +522,16 @@ The busy state is **entirely controlled by your application** via two methods: When busy: -1. Spinner frames are rendered **on top of the current UI state** — all bindings (text, colors, images, etc.) are preserved. Only the spinner node is animated; the rest of the key/card looks exactly as it did before. -2. For touchscreen cards, only the affected panel region is updated (not the entire strip) -3. The normal refresh cycle skips animating slots to avoid overwriting frames -4. Frames are re-generated each time the spinner starts so they always reflect the latest binding values +1. Spinner frames are rendered **on top of the current UI state** — all bindings (text, colors, images, etc.) are preserved. Only the spinner placeholder is animated; the rest of the key/card looks exactly as it did before. +2. For touchscreen cards, only the affected panel region is updated (not the entire strip). +3. The normal refresh cycle skips animating slots to avoid overwriting frames. +4. Frames are cached process-wide by `(rendered SVG, placeholder id, size, image format, background tile)`, so repeated busy cycles on the same panel reuse the same encoded bytes. -Duplicate `start_busy()` calls while already busy are no-ops. -`finish_busy()` when not busy is also a no-op. +Duplicate `start_busy()` calls while already busy are no-ops. `finish_busy()` when not busy is also a no-op. ### Controlling the Busy Lifecycle -Your application decides when to start and stop the spinner. This -decouples the spinner from the event handler — the handler can return -immediately while the spinner keeps running until an external signal -arrives. +Your application decides when to start and stop the spinner. This decouples the spinner from the event handler — the handler can return immediately while the spinner keeps running until an external signal arrives. ```python @key.on("toggle") @@ -620,13 +558,12 @@ async def handle(): await key.finish_busy() ``` -Sometimes you don't need a spinner at all — the task resolves instantly. -Since the application controls the busy state, you simply don't call -`start_busy()` for fast operations. +Sometimes you don't need a spinner at all — the task resolves instantly. Since the application controls the busy state, you simply don't call `start_busy()` for fast operations. **Validation rules:** -- For `rotation` and `pulse` types, the `node` must exist in the SVG -- For `custom` type, either `assets/spinner.gif` or `assets/spinner/frame_*.png` files must exist + +- `spinner.node` is required and must match an element ID present in the layout SVG. +- The legacy keys `type`, `frames`, `interval_ms`, and `background_node` are no longer accepted; package loading fails if they are present. ### Complete Example @@ -637,10 +574,7 @@ version: 1 layout: layout.svg spinner: - type: rotation - node: loading_ring - frames: 8 - interval_ms: 100 + node: spinner bindings: label: @@ -669,7 +603,7 @@ key = DuiKey(spec) async def handle(): await key.start_busy() # Spinner starts — the key still shows the current - # label and status_color while the spinner animates in the corner. + # label and status_color while the spinner animates in the centre. await smart_home_api.toggle_light() # Don't call finish_busy() here — wait for the state update callback. diff --git a/src/deux/dui/__init__.py b/src/deux/dui/__init__.py index 99bbae2..eb950a4 100644 --- a/src/deux/dui/__init__.py +++ b/src/deux/dui/__init__.py @@ -59,14 +59,22 @@ async def handle(): RotateTransform, SliderBinding, SpinnerSpec, - SpinnerType, TextBinding, ToggleBinding, TransformBinding, TransformKind, VisibilityBinding, ) -from .spinner import SpinnerFrames +from .spinner import ( + SPINNER_FRAME_COUNT, + SPINNER_INTERVAL_MS, +) +from .spinner import ( + clear_cache as clear_spinner_cache, +) +from .spinner import ( + get_frames as get_spinner_frames, +) from .svg_renderer import SvgRenderer __all__ = [ @@ -92,10 +100,10 @@ async def handle(): "Region", "RotateTransform", "SliderBinding", + "SPINNER_FRAME_COUNT", + "SPINNER_INTERVAL_MS", "SpinnerAnimator", - "SpinnerFrames", "SpinnerSpec", - "SpinnerType", "SvgRenderer", "TextBinding", "ToggleBinding", @@ -106,7 +114,9 @@ async def handle(): "add_dui_path", "clear_dui_cache", "clear_iconify_cache", + "clear_spinner_cache", "fetch_icon", + "get_spinner_frames", "list_dui_packages", "load_all_packages", "load_package", diff --git a/src/deux/dui/card.py b/src/deux/dui/card.py index ad3c124..bb4e144 100644 --- a/src/deux/dui/card.py +++ b/src/deux/dui/card.py @@ -13,7 +13,7 @@ from .binding_mixin import BindingMixin from .event_map import EventMap from .schema import PackageSpec -from .spinner import SpinnerFrames +from .spinner import SPINNER_INTERVAL_MS, get_frames from .svg_renderer import SvgRenderer if TYPE_CHECKING: @@ -101,7 +101,6 @@ def __init__(self, spec: PackageSpec | str) -> None: self._subscriptions: list[tuple[AsyncEvent, AsyncHandler]] = [] self._busy = False self._animator: SpinnerAnimator | None = None - self._spinner_frames: SpinnerFrames | None = None self._push_fn: PushFn | None = None self._panel_size: tuple[int, int] | None = None self._bg_tile: bytes | None = None @@ -537,18 +536,19 @@ async def _start_spinner(self) -> None: width, height = self._panel_size rendered_svg = self._renderer.render_svg() - spinner_frames = SpinnerFrames( - self._spec, + spinner_node_id = self._spec.spinner.node + frames = await asyncio.to_thread( + get_frames, + rendered_svg=rendered_svg, + spinner_node_id=spinner_node_id, width=width, height=height, - rendered_svg=rendered_svg, bg_tile=self._bg_tile, ) - self._spinner_frames = spinner_frames self._animator = SpinnerAnimator( - frames=await asyncio.to_thread(lambda: spinner_frames.frames), - interval_ms=spinner_frames.interval_ms, + frames=frames, + interval_ms=SPINNER_INTERVAL_MS, push_fn=self._push_fn, ) await self._animator.start() diff --git a/src/deux/dui/key.py b/src/deux/dui/key.py index 9be333c..95c4d8a 100644 --- a/src/deux/dui/key.py +++ b/src/deux/dui/key.py @@ -12,7 +12,7 @@ from .binding_mixin import BindingMixin from .event_map import EventMap from .schema import PackageSpec -from .spinner import SpinnerFrames +from .spinner import SPINNER_INTERVAL_MS, get_frames from .svg_renderer import SvgRenderer if TYPE_CHECKING: @@ -79,7 +79,6 @@ def __init__(self, spec: PackageSpec | str) -> None: self._dirty = True self._busy = False self._animator: SpinnerAnimator | None = None - self._spinner_frames: SpinnerFrames | None = None self._push_fn: PushFn | None = None self._key_size: tuple[int, int] | None = None @@ -383,17 +382,18 @@ async def _start_spinner(self) -> None: width, height = self._key_size rendered_svg = self._renderer.render_svg() - spinner_frames = SpinnerFrames( - self._spec, + spinner_node_id = self._spec.spinner.node + frames = await asyncio.to_thread( + get_frames, + rendered_svg=rendered_svg, + spinner_node_id=spinner_node_id, width=width, height=height, - rendered_svg=rendered_svg, ) - self._spinner_frames = spinner_frames self._animator = SpinnerAnimator( - frames=await asyncio.to_thread(lambda: spinner_frames.frames), - interval_ms=spinner_frames.interval_ms, + frames=frames, + interval_ms=SPINNER_INTERVAL_MS, push_fn=self._push_fn, ) await self._animator.start() diff --git a/src/deux/dui/loader.py b/src/deux/dui/loader.py index ade0c6d..fa290d5 100644 --- a/src/deux/dui/loader.py +++ b/src/deux/dui/loader.py @@ -13,8 +13,6 @@ from .schema import ( DEFAULT_HOLD_MS, DEFAULT_MAX_DURATION_MS, - DEFAULT_SPINNER_FRAMES, - DEFAULT_SPINNER_INTERVAL_MS, HOLD_SOURCES, KNOWN_MANIFEST_KEYS, TURN_SOURCES, @@ -40,7 +38,6 @@ RotateTransform, SliderBinding, SpinnerSpec, - SpinnerType, TextBinding, ToggleBinding, TransformBinding, @@ -696,9 +693,16 @@ def _parse_event(raw: dict[str, Any], index: int) -> EventMapping: ) +_SPINNER_REMOVED_KEYS = ("type", "frames", "interval_ms", "background_node") + + def _parse_spinner(raw: dict[str, Any]) -> SpinnerSpec: """Parse a spinner configuration from the manifest. + The spinner schema is intentionally minimal: only ``node`` is + accepted. All animation parameters (frame count, interval, + background) are owned by the library and are not configurable. + Parameters ---------- raw : dict[str, Any] @@ -712,47 +716,29 @@ def _parse_spinner(raw: dict[str, Any]) -> SpinnerSpec: Raises ------ PackageError - If the spinner type is missing/invalid or constraints are violated. + If ``node`` is missing/invalid, or if any removed legacy key + (``type``, ``frames``, ``interval_ms``, ``background_node``) + is present. """ - raw_type = raw.get("type") - if raw_type is None: - raise PackageError("Spinner missing 'type'") - try: - spinner_type = SpinnerType(raw_type) - except ValueError: - valid = [t.value for t in SpinnerType] - raise PackageError( - f"Invalid spinner type '{raw_type}'. Valid types: {valid}" - ) from None - - node = raw.get("node") - if spinner_type in (SpinnerType.ROTATION, SpinnerType.PULSE): - if not node or not isinstance(node, str): + for legacy in _SPINNER_REMOVED_KEYS: + if legacy in raw: raise PackageError( - f"Spinner type '{raw_type}' requires a 'node' (SVG element ID)" + f"Spinner key '{legacy}' is no longer supported. " + f"The spinner is now a single library-owned rotation " + f"animation; only 'node' may be configured." ) - elif node is not None and not isinstance(node, str): - raise PackageError("Spinner 'node' must be a string if provided") - - frames = raw.get("frames", DEFAULT_SPINNER_FRAMES) - if not isinstance(frames, int) or isinstance(frames, bool) or frames < 2: - raise PackageError("Spinner 'frames' must be an integer >= 2") - interval_ms = raw.get("interval_ms", DEFAULT_SPINNER_INTERVAL_MS) - if not isinstance(interval_ms, int) or isinstance(interval_ms, bool) or interval_ms < 10: - raise PackageError("Spinner 'interval_ms' must be an integer >= 10") + node = raw.get("node") + if not node or not isinstance(node, str): + raise PackageError("Spinner requires a 'node' (SVG element ID)") - background_node = raw.get("background_node") - if background_node is not None and not isinstance(background_node, str): - raise PackageError("Spinner 'background_node' must be a string if provided") + unknown = set(raw) - {"node"} + if unknown: + raise PackageError( + f"Unknown spinner keys: {sorted(unknown)}. Only 'node' is allowed." + ) - return SpinnerSpec( - type=spinner_type, - node=node, - frames=frames, - interval_ms=interval_ms, - background_node=background_node, - ) + return SpinnerSpec(node=node) def _parse_region(name: str, raw: dict[str, Any]) -> Region: @@ -1149,9 +1135,9 @@ def _load_assets(pkg_dir: Path) -> dict[str, bytes]: def _parse_and_validate_spinner( - manifest: dict[str, Any], svg_ids: set[str], assets: dict[str, bytes] + manifest: dict[str, Any], svg_ids: set[str] ) -> SpinnerSpec | None: - """Parse the ``spinner`` section and validate referenced nodes/assets. + """Parse the ``spinner`` section and validate the referenced node. Parameters ---------- @@ -1159,8 +1145,6 @@ def _parse_and_validate_spinner( Parsed manifest mapping. svg_ids : set[str] Set of element IDs available in the layout SVG. - assets : dict[str, bytes] - Loaded asset catalog, used to verify custom spinner frames. Returns ------- @@ -1171,8 +1155,7 @@ def _parse_and_validate_spinner( Raises ------ PackageError - If the spinner is malformed, references missing SVG ids, or its - ``custom`` type lacks the required asset files. + If the spinner is malformed or references a missing SVG id. """ raw_spinner = manifest.get("spinner") if raw_spinner is None: @@ -1181,30 +1164,13 @@ def _parse_and_validate_spinner( raise PackageError("'spinner' must be a mapping") spinner = _parse_spinner(raw_spinner) - if spinner.node is not None and spinner.node not in svg_ids: + if spinner.node not in svg_ids: raise PackageError( f"Spinner references node '{spinner.node}' " f"which does not exist in the SVG. " f"Available ids: {sorted(svg_ids)}" ) - if spinner.background_node is not None and spinner.background_node not in svg_ids: - raise PackageError( - f"Spinner references background_node '{spinner.background_node}' " - f"which does not exist in the SVG. " - f"Available ids: {sorted(svg_ids)}" - ) - - if spinner.type == SpinnerType.CUSTOM: - has_gif = "spinner.gif" in assets - frame_keys = sorted(k for k in assets if k.startswith("spinner/frame_")) - if not has_gif and not frame_keys: - raise PackageError( - "Spinner type 'custom' requires either 'assets/spinner.gif' " - "or frame images in 'assets/spinner/' " - "(e.g. 'spinner/frame_00.png', 'spinner/frame_01.png', ...)" - ) - return spinner @@ -1280,7 +1246,7 @@ def load_package(path: str | Path, *, strict: bool = True) -> PackageSpec: events = _parse_events(manifest) regions = _parse_regions(manifest) assets = _load_assets(pkg_dir) - spinner = _parse_and_validate_spinner(manifest, svg_ids, assets) + spinner = _parse_and_validate_spinner(manifest, svg_ids) logger.info( "Loaded .dui package '%s' (%s, %d bindings, %d events)", diff --git a/src/deux/dui/packages/IconKey.dui/layout.svg b/src/deux/dui/packages/IconKey.dui/layout.svg index fd44f99..3dc628a 100644 --- a/src/deux/dui/packages/IconKey.dui/layout.svg +++ b/src/deux/dui/packages/IconKey.dui/layout.svg @@ -21,18 +21,8 @@ Icon Key - - - - - - - - - - - - + + diff --git a/src/deux/dui/packages/IconKey.dui/manifest.yaml b/src/deux/dui/packages/IconKey.dui/manifest.yaml index 672d2ce..1966893 100644 --- a/src/deux/dui/packages/IconKey.dui/manifest.yaml +++ b/src/deux/dui/packages/IconKey.dui/manifest.yaml @@ -41,11 +41,7 @@ bindings: default: "1" spinner: - type: rotation node: spinner - frames: 8 - interval_ms: 100 - background_node: spinner_background events: - name: click diff --git a/src/deux/dui/packages/PictureKey.dui/layout.svg b/src/deux/dui/packages/PictureKey.dui/layout.svg index 873e9c6..e7c7d41 100644 --- a/src/deux/dui/packages/PictureKey.dui/layout.svg +++ b/src/deux/dui/packages/PictureKey.dui/layout.svg @@ -12,17 +12,7 @@ Picture Key - - - - - - - - - - - - + + \ No newline at end of file diff --git a/src/deux/dui/packages/PictureKey.dui/manifest.yaml b/src/deux/dui/packages/PictureKey.dui/manifest.yaml index fca41c6..1f327f9 100644 --- a/src/deux/dui/packages/PictureKey.dui/manifest.yaml +++ b/src/deux/dui/packages/PictureKey.dui/manifest.yaml @@ -38,11 +38,7 @@ bindings: default: false spinner: - type: rotation node: spinner - frames: 8 - interval_ms: 100 - background_node: spinner_background events: - name: click diff --git a/src/deux/dui/schema.py b/src/deux/dui/schema.py index a72d1fc..7d90a02 100644 --- a/src/deux/dui/schema.py +++ b/src/deux/dui/schema.py @@ -14,14 +14,6 @@ class PackageType(Enum): KEY = "Key" -class SpinnerType(Enum): - """Animation strategy for the busy spinner.""" - - ROTATION = "rotation" - PULSE = "pulse" - CUSTOM = "custom" - - class BindingType(Enum): """Supported binding types for SVG node manipulation.""" @@ -459,49 +451,30 @@ class Region: events: tuple[str, ...] = () -DEFAULT_SPINNER_FRAMES: int = 12 -"""Default number of frames in a spinner animation cycle.""" - -DEFAULT_SPINNER_INTERVAL_MS: int = 80 -"""Default interval (ms) between spinner animation frames.""" - - @dataclass(frozen=True, slots=True) class SpinnerSpec: """Configuration for a spinner animation. - The spinner is started and stopped explicitly by the application - via :meth:`~deux.dui.card.DuiCard.start_busy` / - :meth:`~deux.dui.card.DuiCard.finish_busy` (and the equivalent - methods on :class:`~deux.dui.key.DuiKey`). It provides visual - feedback by cycling pre-rendered animation frames on the key or - card panel. + The spinner is a single, library-owned rotation animation that + indicates a busy state. Packages declare an empty + ```` placeholder in their layout SVG; + the library injects a canonical 8-frame rotation animation into + that placeholder when :meth:`~deux.dui.card.DuiCard.start_busy` + (or the equivalent on :class:`~deux.dui.key.DuiKey`) is called. + + The placeholder's ``transform`` attribute (typically + ``translate(cx cy)``) is honored, so authors control where the + spinner appears. Inner geometry (background tile, 8 radial bars, + rotation step, frame interval) is fixed by the library. Parameters ---------- - type - Animation strategy: ``rotation`` (rotate an SVG node), - ``pulse`` (fade opacity), or ``custom`` (user-provided frames). node - SVG element ID to animate (required for ``rotation`` and - ``pulse``; ignored for ``custom``). - frames - Number of frames per animation cycle. - interval_ms - Milliseconds between frames. - background_node - Optional SVG element ID shown behind the spinner during busy - state. The node is made visible when the spinner is active and - hidden at rest, but it is **not** animated (no rotation, pulse, - or opacity changes are applied to it). Ignored for ``custom`` - type spinners. + SVG element ID of the placeholder group that the library + replaces with the canonical spinner content. """ - type: SpinnerType - node: str | None = None - frames: int = DEFAULT_SPINNER_FRAMES - interval_ms: int = DEFAULT_SPINNER_INTERVAL_MS - background_node: str | None = None + node: str VALID_CATEGORIES = frozenset( diff --git a/src/deux/dui/spinner.py b/src/deux/dui/spinner.py index 3cf93c7..f90955d 100644 --- a/src/deux/dui/spinner.py +++ b/src/deux/dui/spinner.py @@ -1,390 +1,385 @@ -"""Spinner frame generation for busy-state animations.""" +"""Library-owned spinner frame generation for busy-state animations. + +The spinner is a fixed 8-frame, 360°-rotation animation rendered into +a caller-provided placeholder ```` element in a package's +layout SVG. All geometry (background tile + 8 radial bars) is owned +by the library; package authors only choose where the placeholder is +positioned via its own ``transform`` attribute. + +Frames are cached process-wide in an LRU keyed by +``(rendered_svg, spinner_node_id, width, height, image_format, +bg_signature)`` so that repeated busy cycles on the same panel reuse +the same frame bytes. +""" from __future__ import annotations import copy +import hashlib import io import logging import xml.etree.ElementTree as ET +from collections import OrderedDict from typing import TYPE_CHECKING from PIL import Image from .._xml import safe_fromstring -from .schema import SpinnerType from .svg_renderer import _find_element_by_id if TYPE_CHECKING: - from .schema import PackageSpec, SpinnerSpec + pass logger = logging.getLogger(__name__) _SVG_NS = "http://www.w3.org/2000/svg" +#: Number of frames in the canonical spinner animation (one per 45°). +SPINNER_FRAME_COUNT: int = 8 + +#: Milliseconds between frames in the canonical spinner animation. +SPINNER_INTERVAL_MS: int = 100 + +#: Maximum number of frame-lists held in the process-wide LRU cache. +_CACHE_MAX_ENTRIES: int = 64 + +#: Canonical spinner content as an SVG group fragment. Coordinates are +#: centred around (0, 0) so the placeholder's ``transform`` attribute +#: positions it. One bar is tinted ``#dedede`` (the "head" of the +#: rotation); the remaining seven are ``#5c5b5b``. An opaque rounded +#: rectangle behind the bars provides contrast against arbitrary panel +#: backgrounds. +_SPINNER_TEMPLATE_SVG: str = ( + f'' + '' + '' + f'' + '' + '' + '' + '' + '' + '' + '' + '' + "" +) + + +_CacheKey = tuple[str, str, int, int, str, str] +_cache: OrderedDict[_CacheKey, list[bytes]] = OrderedDict() + + +def _bg_signature(bg_tile: bytes | Image.Image | None) -> str: + """Compute a stable digest for a background tile. + + Parameters + ---------- + bg_tile : bytes, PIL.Image.Image, or None + The background image data (or ``None`` if no tile is set). + + Returns + ------- + str + Hex digest uniquely identifying the tile, or ``"none"`` when + no tile is provided. + """ + if bg_tile is None: + return "none" + if isinstance(bg_tile, bytes): + return hashlib.sha1(bg_tile, usedforsecurity=False).hexdigest() + buf = io.BytesIO() + bg_tile.save(buf, format="PNG") + return hashlib.sha1(buf.getvalue(), usedforsecurity=False).hexdigest() + -class SpinnerFrames: - """Pre-renders spinner animation frames from an SVG template. +def clear_cache() -> None: + """Drop all cached spinner frames. + + Intended for use by tests and by application code that wants to + release memory held by the spinner LRU. + """ + _cache.clear() - Frames are generated lazily on first access and cached for the - lifetime of the instance. - When a *bg_tile* is provided, each frame is composited onto the - background tile before encoding so that transparent regions of - the spinner reveal the touchstrip background underneath. +def get_frames( + *, + rendered_svg: str, + spinner_node_id: str, + width: int, + height: int, + image_format: str = "JPEG", + bg_tile: bytes | Image.Image | None = None, +) -> list[bytes]: + """Return the 8 encoded frames of the canonical spinner animation. + + The result is cached process-wide in an LRU keyed by the rendered + SVG, target dimensions, image format, and background-tile + signature. Cache hits return the same ``list`` object that was + cached, so callers must not mutate it. Parameters ---------- - spec - The package specification containing the SVG source and spinner config. - width + rendered_svg : str + Layout SVG with bindings already applied (the same string that + would be rasterised for a static render). Must contain an + element whose ``id`` matches ``spinner_node_id``. + spinner_node_id : str + ID of the placeholder group that the library injects spinner + content into. + width : int Target image width in pixels. - height + height : int Target image height in pixels. - image_format + image_format : str, default="JPEG" + Output encoding; passed through to the image encoder. + bg_tile : bytes, PIL.Image.Image, or None, optional + Optional background tile composited beneath each frame to make + the spinner blend with the surrounding panel. + + Returns + ------- + list[bytes] + ``SPINNER_FRAME_COUNT`` encoded frames, one per 45° rotation + step. When the placeholder node is missing from + ``rendered_svg``, a list of blank fallback frames is returned. + """ + key: _CacheKey = ( + hashlib.sha1(rendered_svg.encode("utf-8"), usedforsecurity=False).hexdigest(), + spinner_node_id, + width, + height, + image_format.upper(), + _bg_signature(bg_tile), + ) + cached = _cache.get(key) + if cached is not None: + _cache.move_to_end(key) + return cached + + frames = _render_frames( + rendered_svg=rendered_svg, + spinner_node_id=spinner_node_id, + width=width, + height=height, + image_format=image_format, + bg_tile=bg_tile, + ) + + _cache[key] = frames + _cache.move_to_end(key) + while len(_cache) > _CACHE_MAX_ENTRIES: + _cache.popitem(last=False) + return frames + + +def _render_frames( + *, + rendered_svg: str, + spinner_node_id: str, + width: int, + height: int, + image_format: str, + bg_tile: bytes | Image.Image | None, +) -> list[bytes]: + """Generate the 8 spinner frames without consulting the cache. + + Parameters mirror :func:`get_frames`; see that function for + semantics. + + Returns + ------- + list[bytes] + Encoded frames, or a fallback list of blank frames when the + placeholder node cannot be located. + """ + base_root = safe_fromstring(rendered_svg) # untrusted: .dui package source + placeholder = _find_element_by_id(base_root, spinner_node_id) + if placeholder is None: + logger.warning( + "Spinner placeholder '%s' not found in rendered SVG; " + "returning blank frames", + spinner_node_id, + ) + return _blank_frames(width, height, image_format, bg_tile) + + step = 360.0 / SPINNER_FRAME_COUNT + frames: list[bytes] = [] + for i in range(SPINNER_FRAME_COUNT): + angle = step * i + root = copy.deepcopy(base_root) + target = _find_element_by_id(root, spinner_node_id) + if target is None: # defensive; deep-copied tree + return _blank_frames(width, height, image_format, bg_tile) + target.attrib.pop("display", None) + _replace_children(target, _build_spinner_fragment(angle)) + frames.append(_rasterise(root, width, height, image_format, bg_tile)) + return frames + + +def _build_spinner_fragment(angle: float) -> ET.Element: + """Parse the canonical spinner template for a given rotation angle. + + Parameters + ---------- + angle : float + Rotation in degrees applied to the inner rotor group. + + Returns + ------- + xml.etree.ElementTree.Element + Root ```` element of the canonical spinner content. + """ + return safe_fromstring(_SPINNER_TEMPLATE_SVG.format(angle=f"{angle:.1f}")) + + +def _replace_children(parent: ET.Element, replacement: ET.Element) -> None: + """Replace ``parent``'s children with the children of ``replacement``. + + The canonical spinner template wraps its content in an outer ```` + purely to satisfy XML parsing; only the inner elements are grafted + into the placeholder so that the placeholder's own ``transform`` + (and any other attributes) remain authoritative. + + Parameters + ---------- + parent : xml.etree.ElementTree.Element + Placeholder element whose children are discarded. + replacement : xml.etree.ElementTree.Element + Container whose children become the new children of ``parent``. + """ + for child in list(parent): + parent.remove(child) + for child in list(replacement): + parent.append(child) + + +def _rasterise( + root: ET.Element, + width: int, + height: int, + image_format: str, + bg_tile: bytes | Image.Image | None, +) -> bytes: + """Rasterise an SVG tree and composite it onto an optional tile. + + Parameters + ---------- + root : xml.etree.ElementTree.Element + SVG document root to render. + width : int + Output width in pixels. + height : int + Output height in pixels. + image_format : str Encoding format (``"JPEG"`` or ``"BMP"``). - rendered_svg - Optional pre-rendered SVG source with bindings already applied. - bg_tile - Optional RGB background tile to composite frames onto. + bg_tile : bytes, PIL.Image.Image, or None + Optional background tile used to fill transparent regions. + + Returns + ------- + bytes + Encoded image bytes in ``image_format``. """ + # Inline import: tests patch ``deux.render.svg_rasterize._svg_to_image``. + from ..render.svg_rasterize import _svg_to_image # noqa: PLC0415 - def __init__( - self, - spec: PackageSpec, - width: int, - height: int, - image_format: str = "JPEG", - rendered_svg: str | None = None, - bg_tile: bytes | Image.Image | None = None, - ) -> None: - if spec.spinner is None: - raise ValueError("PackageSpec has no spinner configuration") - self._spec = spec - self._spinner: SpinnerSpec = spec.spinner - self._width = width - self._height = height - self._image_format = image_format - self._rendered_svg = rendered_svg - self._bg_tile = bg_tile - self._cached_frames: list[bytes] | None = None - - @property - def frame_count(self) -> int: - """Number of frames in the animation cycle.""" - return self._spinner.frames - - @property - def interval_ms(self) -> int: - """Milliseconds between frames.""" - return self._spinner.interval_ms - - @property - def frames(self) -> list[bytes]: - """Encoded animation frames, generated on first access.""" - if self._cached_frames is None: - self._cached_frames = self._generate() - return self._cached_frames - - def _generate(self) -> list[bytes]: - """Generate all animation frames.""" - if self._spinner.type == SpinnerType.ROTATION: - return self._generate_rotation() - if self._spinner.type == SpinnerType.PULSE: - return self._generate_pulse() - return self._generate_custom() - - def _generate_rotation(self) -> list[bytes]: - """Generate frames by rotating the spinner node. - - Returns - ------- - list[bytes] - Encoded image frames, one per rotation step. - """ - svg_source = self._rendered_svg or self._spec.svg_source - base_root = safe_fromstring(svg_source) # untrusted: .dui package - node = self._spinner.node - assert node is not None - - # Find the element to determine its centre of rotation - elem = _find_element_by_id(base_root, node) - if elem is None: - logger.warning("Spinner node '%s' not found; returning blank frames", node) - return self._blank_frames() - - cx, cy = self._element_centre(elem) - step = 360.0 / self._spinner.frames - - frames: list[bytes] = [] - for i in range(self._spinner.frames): - root = copy.deepcopy(base_root) - el = _find_element_by_id(root, node) - if el is not None: - # Make the spinner node visible - el.attrib.pop("display", None) - angle = step * i - existing = el.get("transform", "") - rotation = f"rotate({angle:.1f},{cx:.1f},{cy:.1f})" - el.set("transform", f"{existing} {rotation}".strip()) - - self._show_background_node(root) - frames.append(self._rasterise(root)) - return frames - - def _generate_pulse(self) -> list[bytes]: - """Generate frames by pulsing opacity on the spinner node. - - Returns - ------- - list[bytes] - Encoded image frames with a triangle-wave opacity cycle. - """ - svg_source = self._rendered_svg or self._spec.svg_source - base_root = safe_fromstring(svg_source) # untrusted: .dui package - node = self._spinner.node - assert node is not None - - n = self._spinner.frames - frames: list[bytes] = [] - for i in range(n): - root = copy.deepcopy(base_root) - el = _find_element_by_id(root, node) - if el is not None: - el.attrib.pop("display", None) - # Triangle wave: 0→1→0 over the cycle - t = i / n - opacity = 1.0 - 2.0 * abs(t - 0.5) - opacity = max(0.2, min(1.0, 0.2 + 0.8 * opacity)) - el.set("opacity", f"{opacity:.2f}") - - self._show_background_node(root) - frames.append(self._rasterise(root)) - return frames - - def _show_background_node(self, root: ET.Element) -> None: - """Make the background node visible in the given SVG tree. - - If ``background_node`` is configured on the spinner spec, this - removes ``display="none"`` from it so the background appears - behind the animated spinner. No transform or opacity changes - are applied — the node is shown as-is. - - Parameters - ---------- - root - The parsed SVG element tree (will be mutated in place). - """ - bg_id = self._spinner.background_node - if bg_id is not None: - bg_el = _find_element_by_id(root, bg_id) - if bg_el is not None: - bg_el.attrib.pop("display", None) - - def _generate_custom(self) -> list[bytes]: - """Load custom frames from package assets. - - Looks for ``assets/spinner.gif`` first, then numbered PNGs in - ``assets/spinner/``. Falls back to blank frames if neither is found. - - Returns - ------- - list[bytes] - Encoded image frames loaded from the package assets. - """ - assets = self._spec.assets - - # Try animated GIF first - if "spinner.gif" in assets: - return self._load_gif_frames(assets["spinner.gif"]) - - # Try numbered PNGs in spinner/ subdirectory - frame_keys = sorted(k for k in assets if k.startswith("spinner/frame_")) - if not frame_keys: - logger.warning("No custom spinner frames found; returning blank frames") - return self._blank_frames() - - frames: list[bytes] = [] - for key in frame_keys: - frame_img: Image.Image = Image.open(io.BytesIO(assets[key])) - if frame_img.size != (self._width, self._height): - frame_img = frame_img.resize( - (self._width, self._height), Image.Resampling.LANCZOS - ) - frames.append(self._composite_on_bg(frame_img.convert("RGBA"))) - return frames - - def _load_gif_frames(self, data: bytes) -> list[bytes]: - """Extract and encode frames from an animated GIF. - - Parameters - ---------- - data : bytes - Raw GIF file bytes. - - Returns - ------- - list[bytes] - Encoded image frames; falls back to blank frames if the GIF - contains no frames. - """ - gif = Image.open(io.BytesIO(data)) - n_frames = getattr(gif, "n_frames", 1) - - frames: list[bytes] = [] - for i in range(n_frames): - gif.seek(i) - frame = gif.convert("RGBA") - if frame.size != (self._width, self._height): - frame = frame.resize( - (self._width, self._height), Image.Resampling.LANCZOS - ) - frames.append(self._composite_on_bg(frame)) - - if not frames: - logger.warning("Animated GIF has no frames; returning blank frames") - return self._blank_frames() - return frames - - def _blank_frames(self) -> list[bytes]: - """Return a list of blank encoded frames as fallback. - - When a background tile is set, blank frames show the background - tile instead of solid black. - - Returns - ------- - list[bytes] - Blank or background-filled frames, one per configured spinner - frame count. - """ - if self._bg_tile is not None: - data = self._encode_tile(self._bg_tile) - else: - data = self._encode_blank() - return [data] * self._spinner.frames - - def _composite_on_bg(self, frame: Image.Image) -> bytes: - """Composite a frame onto the background tile if available. - - Parameters - ---------- - frame : Image.Image - RGBA frame image. - - Returns - ------- - bytes - Encoded image bytes in the instance's configured format. - """ - if self._bg_tile is not None: - if isinstance(self._bg_tile, bytes): - base = Image.open(io.BytesIO(self._bg_tile)).convert("RGBA") - else: - base = self._bg_tile.convert("RGBA") - result = Image.alpha_composite(base, frame) + svg_bytes = ET.tostring(root, encoding="unicode", xml_declaration=True) + frame = _svg_to_image(svg_bytes.encode("utf-8"), width, height, mode="RGBA") + return _composite_on_bg(frame, bg_tile, image_format) + + +def _composite_on_bg( + frame: Image.Image, + bg_tile: bytes | Image.Image | None, + image_format: str, +) -> bytes: + """Composite an RGBA frame onto an optional tile and encode it. + + Parameters + ---------- + frame : PIL.Image.Image + RGBA frame produced by the SVG rasteriser. + bg_tile : bytes, PIL.Image.Image, or None + Optional tile composited beneath ``frame``. + image_format : str + Output encoding. + + Returns + ------- + bytes + Encoded image bytes. + """ + if bg_tile is not None: + if isinstance(bg_tile, bytes): + base = Image.open(io.BytesIO(bg_tile)).convert("RGBA") else: - result = frame - - return self._encode_pil(result) - - def _encode_pil(self, img: Image.Image) -> bytes: - """Encode a PIL image in the configured format. - - Parameters - ---------- - img - A ``PIL.Image.Image`` instance. - - Returns - ------- - bytes - Encoded image bytes. - """ - # Inline import: tests patch ``deux.render.key_renderer._encode_image_bytes``; - # importing it lazily keeps that patching point effective. - from ..render.key_renderer import _encode_image_bytes # noqa: PLC0415 - - return _encode_image_bytes(img, self._image_format) - - def _encode_tile(self, tile: bytes | Image.Image) -> bytes: - """Re-encode a tile in the configured output format. - - Parameters - ---------- - tile : bytes or Image.Image - PNG-encoded tile bytes or a PIL Image. - - Returns - ------- - bytes - Image bytes in the configured format. - """ - if isinstance(tile, bytes): - img = Image.open(io.BytesIO(tile)).convert("RGB") + base = bg_tile.convert("RGBA") + result = Image.alpha_composite(base, frame) + else: + result = frame + return _encode(result, image_format) + + +def _encode(img: Image.Image, image_format: str) -> bytes: + """Encode a PIL image in the requested format. + + Parameters + ---------- + img : PIL.Image.Image + Image to encode. + image_format : str + Output format (``"JPEG"`` or ``"BMP"``). + + Returns + ------- + bytes + Encoded image bytes. + """ + # Inline import: tests patch ``deux.render.key_renderer._encode_image_bytes``. + from ..render.key_renderer import _encode_image_bytes # noqa: PLC0415 + + return _encode_image_bytes(img, image_format) + + +def _blank_frames( + width: int, + height: int, + image_format: str, + bg_tile: bytes | Image.Image | None, +) -> list[bytes]: + """Return ``SPINNER_FRAME_COUNT`` blank frames as a fallback. + + When a background tile is provided, the blank frames show that + tile; otherwise they are solid black. + + Parameters + ---------- + width, height : int + Output dimensions. + image_format : str + Output encoding. + bg_tile : bytes, PIL.Image.Image, or None + Optional background tile. + + Returns + ------- + list[bytes] + ``SPINNER_FRAME_COUNT`` copies of a single blank frame. + """ + if bg_tile is not None: + if isinstance(bg_tile, bytes): + img = Image.open(io.BytesIO(bg_tile)).convert("RGB") else: - img = tile.convert("RGB") - return self._encode_pil(img) - - def _encode_blank(self) -> bytes: - """Encode a solid black frame. - - Returns - ------- - bytes - Black frame in the configured format. - """ - img = Image.new("RGB", (self._width, self._height), (0, 0, 0)) - return self._encode_pil(img) - - def _rasterise(self, root: ET.Element) -> bytes: - """Rasterise an SVG element tree to encoded image bytes. - - Parameters - ---------- - root : ET.Element - The SVG document root to render. - - Returns - ------- - bytes - Image data encoded in the instance's configured format. - """ - # Inline import: tests patch ``deux.render.svg_rasterize._svg_to_image``; - # importing it lazily keeps that patching point effective. - from ..render.svg_rasterize import _svg_to_image # noqa: PLC0415 - - svg_bytes = ET.tostring(root, encoding="unicode", xml_declaration=True) - frame = _svg_to_image( - svg_bytes.encode("utf-8"), self._width, self._height, mode="RGBA" - ) - return self._composite_on_bg(frame) - - @staticmethod - def _element_centre(elem: ET.Element) -> tuple[float, float]: - """Compute the centre of an SVG element from its geometry attributes. - - Handles both rectangular elements (``x``, ``y``, ``width``, ``height``) - and circle/ellipse elements (``cx``, ``cy``). - - Parameters - ---------- - elem : ET.Element - The SVG element to measure. - - Returns - ------- - tuple[float, float] - ``(cx, cy)`` centre coordinates. - """ - x = float(elem.get("x", "0")) - y = float(elem.get("y", "0")) - w = float(elem.get("width", "0")) - h = float(elem.get("height", "0")) - - # For circle/ellipse elements - if w == 0 and h == 0: - cx = float(elem.get("cx", str(x))) - cy = float(elem.get("cy", str(y))) - return cx, cy - - return x + w / 2, y + h / 2 + img = bg_tile.convert("RGB") + else: + img = Image.new("RGB", (width, height), (0, 0, 0)) + data = _encode(img, image_format) + return [data] * SPINNER_FRAME_COUNT diff --git a/src/deux/dui/svg_renderer.py b/src/deux/dui/svg_renderer.py index 2984f5d..ba4a818 100644 --- a/src/deux/dui/svg_renderer.py +++ b/src/deux/dui/svg_renderer.py @@ -912,13 +912,13 @@ def render_svg(self) -> str: return ET.tostring(root, encoding="unicode", xml_declaration=True) def _hide_spinner_node(self, root: ET.Element) -> None: - """Hide the spinner and its background node so they are invisible at rest. + """Hide the spinner placeholder so it is invisible at rest. - DUI package authors may not set ``display="none"`` on the spinner - element or its background, which would make them visible in every - non-busy render. This method forces both nodes hidden; the spinner - frame generators remove ``display="none"`` when producing animation - frames. + DUI package authors may not set ``display="none"`` on the + spinner placeholder, which would make it visible in every + non-busy render. This method forces the node hidden; the + spinner frame generator removes ``display="none"`` when + producing animation frames. Parameters ---------- @@ -926,14 +926,10 @@ def _hide_spinner_node(self, root: ET.Element) -> None: The parsed SVG element tree (will be mutated in place). """ spinner = self._spec.spinner - if spinner is not None and spinner.node is not None: + if spinner is not None: elem = _find_element_by_id(root, spinner.node) if elem is not None: elem.set("display", "none") - if spinner is not None and spinner.background_node is not None: - bg_elem = _find_element_by_id(root, spinner.background_node) - if bg_elem is not None: - bg_elem.set("display", "none") #: Dispatch table mapping binding types to handler methods. #: Populated per-instance in :meth:`__init__` (bound to ``self``) so that diff --git a/tests/test_busy_guard.py b/tests/test_busy_guard.py index ec02a04..3626389 100644 --- a/tests/test_busy_guard.py +++ b/tests/test_busy_guard.py @@ -17,15 +17,15 @@ PackageSpec, PackageType, SpinnerSpec, - SpinnerType, TextBinding, ) +from deux.dui.spinner import clear_cache as clear_spinner_cache _CARD_SVG = ( '' '' 'Default' - '' + '' "" ) @@ -33,11 +33,18 @@ '' '' 'Key' - '' + '' "" ) +@pytest.fixture(autouse=True) +def _reset_spinner_cache(): + clear_spinner_cache() + yield + clear_spinner_cache() + + def _fake_png(width: int = 120, height: int = 120) -> bytes: img = Image.new("RGB", (width, height), "black") buf = io.BytesIO() @@ -148,7 +155,7 @@ async def test_card_finish_busy_noop_when_not_busy(self): side_effect=_fake_image, ) async def test_card_spinner_starts_and_stops(self, mock_raster): - spinner = SpinnerSpec(type=SpinnerType.ROTATION, node="spinner", frames=4) + spinner = SpinnerSpec(node="spinner") spec = _make_card_spec(spinner=spinner) card = DuiCard(spec) @@ -170,7 +177,7 @@ async def test_card_spinner_uses_rendered_svg(self, mock_raster): bindings: dict[str, Binding] = { "title": TextBinding(node="title", default="Default"), } - spinner = SpinnerSpec(type=SpinnerType.ROTATION, node="spinner", frames=2) + spinner = SpinnerSpec(node="spinner") spec = _make_card_spec(spinner=spinner, bindings=bindings) card = DuiCard(spec) card.set("title", "Updated") @@ -255,7 +262,7 @@ async def test_key_finish_busy_noop_when_not_busy(self): side_effect=_fake_image, ) async def test_key_spinner_starts_and_stops(self, mock_raster): - spinner = SpinnerSpec(type=SpinnerType.ROTATION, node="spinner", frames=4) + spinner = SpinnerSpec(node="spinner") spec = _make_key_spec(spinner=spinner) key = DuiKey(spec) @@ -277,7 +284,7 @@ async def test_key_spinner_uses_rendered_svg(self, mock_raster): bindings: dict[str, Binding] = { "label": TextBinding(node="label", default="Key"), } - spinner = SpinnerSpec(type=SpinnerType.ROTATION, node="spinner", frames=2) + spinner = SpinnerSpec(node="spinner") spec = _make_key_spec(spinner=spinner, bindings=bindings) key = DuiKey(spec) key.set("label", "Playing") @@ -309,39 +316,34 @@ async def test_key_events_dispatch_without_busy(self): class TestSpinnerManifestValidation: - def test_invalid_spinner_type(self): - with pytest.raises(PackageError, match="Invalid spinner type"): - _parse_spinner({"type": "wobble"}) - - def test_frames_less_than_2(self): - with pytest.raises(PackageError, match="frames.*must be an integer >= 2"): - _parse_spinner({"type": "rotation", "node": "spinner", "frames": 1}) - - def test_interval_ms_less_than_10(self): - with pytest.raises(PackageError, match="interval_ms.*must be an integer >= 10"): - _parse_spinner({"type": "rotation", "node": "spinner", "interval_ms": 5}) - - def test_rotation_without_node_raises(self): + def test_missing_node_raises(self): with pytest.raises(PackageError, match="requires a 'node'"): - _parse_spinner({"type": "rotation"}) + _parse_spinner({}) - def test_pulse_without_node_raises(self): + def test_node_not_string_raises(self): with pytest.raises(PackageError, match="requires a 'node'"): - _parse_spinner({"type": "pulse"}) - - def test_custom_without_frames_raises(self, tmp_path): - """Custom spinner with no asset frames should fail.""" - pkg_dir = tmp_path / "Test.dui" - pkg_dir.mkdir() - (pkg_dir / "layout.svg").write_text(_CARD_SVG, encoding="utf-8") - manifest = ( - "name: Test\ntype: TouchStripCard\nversion: 1\nlayout: layout.svg\n" - "spinner:\n type: custom\n" - ) - (pkg_dir / "manifest.yaml").write_text(manifest, encoding="utf-8") + _parse_spinner({"node": 123}) + + def test_valid_node(self): + spec = _parse_spinner({"node": "spinner"}) + assert spec.node == "spinner" + + @pytest.mark.parametrize( + "legacy_key,legacy_value", + [ + ("type", "rotation"), + ("frames", 8), + ("interval_ms", 100), + ("background_node", "spinner_bg"), + ], + ) + def test_legacy_keys_rejected(self, legacy_key, legacy_value): + with pytest.raises(PackageError, match="no longer supported"): + _parse_spinner({"node": "spinner", legacy_key: legacy_value}) - with pytest.raises(PackageError, match="custom.*requires"): - load_package(pkg_dir) + def test_unknown_key_rejected(self): + with pytest.raises(PackageError, match="Unknown spinner keys"): + _parse_spinner({"node": "spinner", "wobble_speed": 5}) def test_spinner_node_not_in_svg_raises(self, tmp_path): """Spinner referencing a node not present in SVG should fail.""" @@ -350,51 +352,13 @@ def test_spinner_node_not_in_svg_raises(self, tmp_path): (pkg_dir / "layout.svg").write_text(_CARD_SVG, encoding="utf-8") manifest = ( "name: Test\ntype: TouchStripCard\nversion: 1\nlayout: layout.svg\n" - "spinner:\n type: rotation\n node: nonexistent\n" + "spinner:\n node: nonexistent\n" ) (pkg_dir / "manifest.yaml").write_text(manifest, encoding="utf-8") with pytest.raises(PackageError, match="does not exist in the SVG"): load_package(pkg_dir) - def test_background_node_not_string_raises(self): - """background_node must be a string if provided.""" - with pytest.raises(PackageError, match="background_node.*must be a string"): - _parse_spinner({ - "type": "rotation", - "node": "spinner", - "background_node": 123, - }) - - def test_background_node_valid_string(self): - """background_node as a valid string should parse without error.""" - spec = _parse_spinner({ - "type": "rotation", - "node": "spinner", - "background_node": "spinner_bg", - }) - assert spec.background_node == "spinner_bg" - - def test_background_node_none_by_default(self): - """background_node defaults to None when not specified.""" - spec = _parse_spinner({"type": "rotation", "node": "spinner"}) - assert spec.background_node is None - - def test_background_node_not_in_svg_raises(self, tmp_path): - """Spinner referencing a background_node not present in SVG should fail.""" - pkg_dir = tmp_path / "Test.dui" - pkg_dir.mkdir() - (pkg_dir / "layout.svg").write_text(_CARD_SVG, encoding="utf-8") - manifest = ( - "name: Test\ntype: TouchStripCard\nversion: 1\nlayout: layout.svg\n" - "spinner:\n type: rotation\n node: spinner\n" - " background_node: nonexistent_bg\n" - ) - (pkg_dir / "manifest.yaml").write_text(manifest, encoding="utf-8") - - with pytest.raises(PackageError, match="background_node.*does not exist in the SVG"): - load_package(pkg_dir) - # ── Cancellation during in-flight rasterisation ───────────────────── @@ -410,7 +374,7 @@ async def test_card_cancel_during_spinner_start(self, mock_raster): """Cancelling the task that runs start_busy should not leave broken state.""" import asyncio - spinner = SpinnerSpec(type=SpinnerType.ROTATION, node="spinner", frames=4) + spinner = SpinnerSpec(node="spinner") spec = _make_card_spec(spinner=spinner) card = DuiCard(spec) push_fn = AsyncMock() @@ -435,7 +399,7 @@ async def test_key_cancel_during_spinner_start(self, mock_raster): """Cancelling the task that runs start_busy on a key should not leave broken state.""" import asyncio - spinner = SpinnerSpec(type=SpinnerType.ROTATION, node="spinner", frames=4) + spinner = SpinnerSpec(node="spinner") spec = _make_key_spec(spinner=spinner) key = DuiKey(spec) push_fn = AsyncMock() diff --git a/tests/test_dui_svg_renderer.py b/tests/test_dui_svg_renderer.py index 5106899..0a766b0 100644 --- a/tests/test_dui_svg_renderer.py +++ b/tests/test_dui_svg_renderer.py @@ -21,7 +21,6 @@ RangeDirection, SliderBinding, SpinnerSpec, - SpinnerType, TextBinding, ToggleBinding, VisibilityBinding, @@ -2070,7 +2069,7 @@ def test_render_hides_visible_spinner_node(self): type=PackageType.KEY, version=1, svg_source=_SPINNER_VISIBLE_SVG, - spinner=SpinnerSpec(type=SpinnerType.ROTATION, node="spinner", frames=8), + spinner=SpinnerSpec(node="spinner"), ) renderer = SvgRenderer(spec) svg = renderer.render_svg() @@ -2257,63 +2256,3 @@ def test_separator_gets_inactive_attrs(self): # Separator should have inactive fill assert svg.count('fill="#888888"') == 2 # B + separator - -class TestSpinnerBackgroundNodeHidden: - """Spinner background_node must be hidden in non-busy renders.""" - - def test_render_hides_visible_background_node(self): - """render_svg() sets display='none' on the background_node.""" - spec = PackageSpec( - name="Test", - type=PackageType.KEY, - version=1, - svg_source=_SPINNER_VISIBLE_SVG, - spinner=SpinnerSpec( - type=SpinnerType.ROTATION, - node="spinner", - frames=8, - background_node="spinner_bg", - ), - ) - renderer = SvgRenderer(spec) - svg = renderer.render_svg() - import xml.etree.ElementTree as ET - - root = ET.fromstring(svg) # noqa: S314 - ns = {"svg": "http://www.w3.org/2000/svg"} - bg = root.find('.//svg:rect[@id="spinner_bg"]', ns) - if bg is None: - bg = root.find('.//rect[@id="spinner_bg"]') - assert bg is not None - assert bg.get("display") == "none" - - def test_no_background_node_no_error(self): - """Without background_node, render proceeds normally.""" - spec = PackageSpec( - name="Test", - type=PackageType.KEY, - version=1, - svg_source=_SPINNER_VISIBLE_SVG, - spinner=SpinnerSpec(type=SpinnerType.ROTATION, node="spinner", frames=8), - ) - renderer = SvgRenderer(spec) - svg = renderer.render_svg() - assert svg - - def test_missing_background_node_no_error(self): - """If background_node references a missing element, no crash.""" - spec = PackageSpec( - name="Test", - type=PackageType.KEY, - version=1, - svg_source=_SPINNER_VISIBLE_SVG, - spinner=SpinnerSpec( - type=SpinnerType.ROTATION, - node="spinner", - frames=8, - background_node="nonexistent_bg", - ), - ) - renderer = SvgRenderer(spec) - svg = renderer.render_svg() - assert svg diff --git a/tests/test_spinner.py b/tests/test_spinner.py index 45d1bbf..c23192a 100644 --- a/tests/test_spinner.py +++ b/tests/test_spinner.py @@ -1,4 +1,4 @@ -"""Tests for deux.dui.spinner — SpinnerFrames class.""" +"""Tests for ``deux.dui.spinner`` — library-owned spinner frame generation.""" from __future__ import annotations @@ -9,36 +9,24 @@ import pytest from PIL import Image -from deux.dui.schema import ( - PackageSpec, - PackageType, - SpinnerSpec, - SpinnerType, +from deux.dui.spinner import ( + SPINNER_FRAME_COUNT, + SPINNER_INTERVAL_MS, + _bg_signature, + _build_spinner_fragment, + clear_cache, + get_frames, ) -from deux.dui.spinner import SpinnerFrames _SPINNER_SVG = ( '' '' - '' - '' - '' + '' "" ) -def _fake_png(width: int = 120, height: int = 120) -> bytes: - """Return a minimal valid PNG.""" - img = Image.new("RGB", (width, height), "black") - buf = io.BytesIO() - img.save(buf, format="PNG") - return buf.getvalue() - - def _fake_image( svg_data: bytes, width: int = 120, @@ -51,349 +39,145 @@ def _fake_image( return Image.new(mode, (width, height), "black") -def _make_spec( - spinner: SpinnerSpec | None = None, - assets: dict[str, bytes] | None = None, - svg: str = _SPINNER_SVG, -) -> PackageSpec: - return PackageSpec( - name="TestSpinner", - type=PackageType.KEY, - version=1, - svg_source=svg, - spinner=spinner, - assets=assets or {}, - ) - - -class TestRotation: - @patch("deux.render.svg_rasterize._svg_to_image", side_effect=_fake_image) - def test_rotation_generates_correct_frame_count(self, mock_raster): - spec = _make_spec( - spinner=SpinnerSpec(type=SpinnerType.ROTATION, node="spinner", frames=8) - ) - sf = SpinnerFrames(spec, width=120, height=120) - assert len(sf.frames) == 8 +@pytest.fixture(autouse=True) +def _reset_cache(): + clear_cache() + yield + clear_cache() - def test_element_centre_calculation_rect(self): - """Rect at x=80,y=30,w=30,h=30 → centre (95, 45).""" - elem = ET.fromstring( - '' - ) - cx, cy = SpinnerFrames._element_centre(elem) - assert cx == pytest.approx(95.0) - assert cy == pytest.approx(45.0) - def test_element_centre_calculation_circle(self): - """Circle with cx=60, cy=60 → centre (60, 60).""" - elem = ET.fromstring( - '' - ) - cx, cy = SpinnerFrames._element_centre(elem) - assert cx == pytest.approx(60.0) - assert cy == pytest.approx(60.0) +class TestGetFrames: + """Behaviour of the ``get_frames`` entry point.""" - -class TestPulse: @patch("deux.render.svg_rasterize._svg_to_image", side_effect=_fake_image) - def test_pulse_generates_correct_frame_count(self, mock_raster): - spec = _make_spec( - spinner=SpinnerSpec(type=SpinnerType.PULSE, node="spinner", frames=6) - ) - sf = SpinnerFrames(spec, width=120, height=120) - assert len(sf.frames) == 6 - - -class TestCustom: - def test_custom_from_png_files(self): - """Custom spinner loads numbered PNGs from assets.""" - assets = {} - for i in range(4): - assets[f"spinner/frame_{i:02d}.png"] = _fake_png(120, 120) - - spec = _make_spec( - spinner=SpinnerSpec(type=SpinnerType.CUSTOM, frames=4), - assets=assets, - ) - sf = SpinnerFrames(spec, width=120, height=120) - frames = sf.frames - assert len(frames) == 4 - for f in frames: - assert isinstance(f, bytes) - assert len(f) > 0 - - def test_custom_from_animated_gif(self): - """Custom spinner loads frames from an animated GIF.""" - # Create a 3-frame animated GIF - imgs = [Image.new("RGB", (120, 120), color) for color in ["red", "green", "blue"]] - buf = io.BytesIO() - imgs[0].save(buf, format="GIF", save_all=True, append_images=imgs[1:], loop=0) - gif_bytes = buf.getvalue() - - spec = _make_spec( - spinner=SpinnerSpec(type=SpinnerType.CUSTOM, frames=3), - assets={"spinner.gif": gif_bytes}, + def test_returns_eight_frames(self, mock_raster): + frames = get_frames( + rendered_svg=_SPINNER_SVG, + spinner_node_id="spinner", + width=120, + height=120, ) - sf = SpinnerFrames(spec, width=120, height=120) - frames = sf.frames - assert len(frames) == 3 + assert len(frames) == SPINNER_FRAME_COUNT == 8 + assert all(isinstance(f, bytes) and f for f in frames) - -class TestCaching: @patch("deux.render.svg_rasterize._svg_to_image", side_effect=_fake_image) - def test_frames_are_cached_after_first_access(self, mock_raster): - spec = _make_spec( - spinner=SpinnerSpec(type=SpinnerType.ROTATION, node="spinner", frames=4) - ) - sf = SpinnerFrames(spec, width=120, height=120) - first = sf.frames - second = sf.frames - assert first is second - # _generate called only once - assert mock_raster.call_count == 4 # once per frame, not 8 + def test_constants(self, mock_raster): + assert SPINNER_FRAME_COUNT == 8 + assert SPINNER_INTERVAL_MS == 100 - -class TestRenderedSvg: @patch("deux.render.svg_rasterize._svg_to_image", side_effect=_fake_image) - def test_rotation_uses_rendered_svg_when_provided(self, mock_raster): - """When rendered_svg is passed, spinner frames use it instead of raw svg_source.""" - rendered = ( - '' - '' - 'Updated Title' - '' - "" - ) - spec = _make_spec( - spinner=SpinnerSpec(type=SpinnerType.ROTATION, node="spinner", frames=2) - ) - sf = SpinnerFrames(spec, width=120, height=120, rendered_svg=rendered) - assert len(sf.frames) == 2 - - # Verify the rendered SVG was used — it should contain "Updated Title" - first_call_svg: bytes = mock_raster.call_args_list[0][0][0] - assert b"Updated Title" in first_call_svg + def test_each_frame_has_distinct_rotation(self, mock_raster): + get_frames( + rendered_svg=_SPINNER_SVG, + spinner_node_id="spinner", + width=120, + height=120, + ) + assert mock_raster.call_count == SPINNER_FRAME_COUNT + rotations: list[str] = [] + for call in mock_raster.call_args_list: + svg_bytes: bytes = call.args[0] + root = ET.fromstring(svg_bytes) # noqa: S314 + target = root.find('.//{http://www.w3.org/2000/svg}g[@id="spinner"]') + assert target is not None + rotor = target.find('.//{http://www.w3.org/2000/svg}g[@class="deux-spinner-rotor"]') + assert rotor is not None + transform = rotor.get("transform", "") + rotations.append(transform) + assert len(set(rotations)) == SPINNER_FRAME_COUNT @patch("deux.render.svg_rasterize._svg_to_image", side_effect=_fake_image) - def test_pulse_uses_rendered_svg_when_provided(self, mock_raster): - rendered = ( - '' - '' - 'Rendered Content' - '' - "" - ) - spec = _make_spec( - spinner=SpinnerSpec(type=SpinnerType.PULSE, node="spinner", frames=2) - ) - sf = SpinnerFrames(spec, width=120, height=120, rendered_svg=rendered) - assert len(sf.frames) == 2 - - first_call_svg: bytes = mock_raster.call_args_list[0][0][0] - assert b"Rendered Content" in first_call_svg + def test_spinner_is_made_visible_in_frames(self, mock_raster): + svg_with_hidden = _SPINNER_SVG.replace( + '', + '', + ) + get_frames( + rendered_svg=svg_with_hidden, + spinner_node_id="spinner", + width=120, + height=120, + ) + for call in mock_raster.call_args_list: + svg_bytes: bytes = call.args[0] + root = ET.fromstring(svg_bytes) # noqa: S314 + target = root.find('.//{http://www.w3.org/2000/svg}g[@id="spinner"]') + assert target is not None + assert target.get("display") is None @patch("deux.render.svg_rasterize._svg_to_image", side_effect=_fake_image) - def test_falls_back_to_svg_source_without_rendered(self, mock_raster): - """Without rendered_svg, spinner uses the raw svg_source.""" - spec = _make_spec( - spinner=SpinnerSpec(type=SpinnerType.ROTATION, node="spinner", frames=2) - ) - sf = SpinnerFrames(spec, width=120, height=120) - assert len(sf.frames) == 2 - # Raw SVG should NOT contain "Updated Title" - first_call_svg: bytes = mock_raster.call_args_list[0][0][0] - assert b"Updated Title" not in first_call_svg - - -class TestErrors: - def test_no_spinner_spec_raises(self): - spec = _make_spec(spinner=None) - with pytest.raises(ValueError, match="no spinner configuration"): - SpinnerFrames(spec, width=120, height=120) + def test_missing_node_returns_blank_frames(self, mock_raster): + frames = get_frames( + rendered_svg=_SPINNER_SVG, + spinner_node_id="nonexistent", + width=120, + height=120, + ) + assert len(frames) == SPINNER_FRAME_COUNT + # All blank frames are identical + assert len(set(frames)) == 1 + # Rasteriser is not called when placeholder is missing + mock_raster.assert_not_called() @patch("deux.render.svg_rasterize._svg_to_image", side_effect=_fake_image) - def test_blank_frames_fallback_for_missing_node(self, mock_raster): - """When the spinner node ID doesn't exist in the SVG, return blank frames.""" - spec = _make_spec( - spinner=SpinnerSpec( - type=SpinnerType.ROTATION, node="nonexistent", frames=4 - ) - ) - sf = SpinnerFrames(spec, width=120, height=120) - frames = sf.frames - assert len(frames) == 4 - # All frames should be identical (blank) - assert all(f == frames[0] for f in frames) - - -class TestBackgroundNode: - """Background node is shown (not animated) during spinner frames.""" - - @patch("deux.render.svg_rasterize._svg_to_image", side_effect=_fake_image) - def test_rotation_shows_background_node(self, mock_raster): - """Rotation frames should unhide the background_node.""" - spec = _make_spec( - spinner=SpinnerSpec( - type=SpinnerType.ROTATION, - node="spinner", - frames=2, - background_node="spinner_background", - ) - ) - sf = SpinnerFrames(spec, width=120, height=120) - assert len(sf.frames) == 2 - - # Inspect the SVG passed to rasteriser — background should be visible - first_svg: bytes = mock_raster.call_args_list[0][0][0] - assert b'id="spinner_background"' in first_svg - # display="none" should have been removed from the background node + def test_canonical_geometry_is_injected(self, mock_raster): + get_frames( + rendered_svg=_SPINNER_SVG, + spinner_node_id="spinner", + width=120, + height=120, + ) + first_svg: bytes = mock_raster.call_args_list[0].args[0] + # Background rect (from library canonical template) + assert b'fill-opacity="0.8"' in first_svg + # 8 rotation bars root = ET.fromstring(first_svg) # noqa: S314 - bg = root.find('.//{http://www.w3.org/2000/svg}rect[@id="spinner_background"]') - if bg is None: - bg = root.find('.//rect[@id="spinner_background"]') - assert bg is not None - assert bg.get("display") is None - - @patch("deux.render.svg_rasterize._svg_to_image", side_effect=_fake_image) - def test_rotation_background_node_not_rotated(self, mock_raster): - """Background node must not receive a rotation transform.""" - spec = _make_spec( - spinner=SpinnerSpec( - type=SpinnerType.ROTATION, - node="spinner", - frames=4, - background_node="spinner_background", - ) + bars = root.findall( + './/{http://www.w3.org/2000/svg}g[@id="spinner"]' + "//{http://www.w3.org/2000/svg}rect" ) - sf = SpinnerFrames(spec, width=120, height=120) - assert len(sf.frames) == 4 - - # Check last frame (non-zero rotation) — background should have no transform - last_svg: bytes = mock_raster.call_args_list[3][0][0] - root = ET.fromstring(last_svg) # noqa: S314 - bg = root.find('.//{http://www.w3.org/2000/svg}rect[@id="spinner_background"]') - if bg is None: - bg = root.find('.//rect[@id="spinner_background"]') - assert bg is not None - assert "rotate" not in (bg.get("transform") or "") - - @patch("deux.render.svg_rasterize._svg_to_image", side_effect=_fake_image) - def test_pulse_shows_background_node(self, mock_raster): - """Pulse frames should unhide the background_node.""" - spec = _make_spec( - spinner=SpinnerSpec( - type=SpinnerType.PULSE, - node="spinner", - frames=2, - background_node="spinner_background", - ) - ) - sf = SpinnerFrames(spec, width=120, height=120) - assert len(sf.frames) == 2 - - first_svg: bytes = mock_raster.call_args_list[0][0][0] - root = ET.fromstring(first_svg) # noqa: S314 - bg = root.find('.//{http://www.w3.org/2000/svg}rect[@id="spinner_background"]') - if bg is None: - bg = root.find('.//rect[@id="spinner_background"]') - assert bg is not None - assert bg.get("display") is None - # Background should not have opacity set - assert bg.get("opacity") is None + # 1 background + 8 bars in the canonical template + assert len(bars) >= 8 @patch("deux.render.svg_rasterize._svg_to_image", side_effect=_fake_image) - def test_missing_background_node_no_error(self, mock_raster): - """If background_node references a missing element, no crash occurs.""" - spec = _make_spec( - spinner=SpinnerSpec( - type=SpinnerType.ROTATION, - node="spinner", - frames=2, - background_node="nonexistent_bg", - ) - ) - sf = SpinnerFrames(spec, width=120, height=120) - assert len(sf.frames) == 2 + def test_blank_frames_use_bg_tile_when_provided(self, mock_raster): + tile = Image.new("RGB", (120, 120), (50, 50, 50)) + buf = io.BytesIO() + tile.save(buf, format="PNG") - @patch("deux.render.svg_rasterize._svg_to_image", side_effect=_fake_image) - def test_no_background_node_works(self, mock_raster): - """Spinner without background_node still works normally.""" - spec = _make_spec( - spinner=SpinnerSpec( - type=SpinnerType.ROTATION, node="spinner", frames=2 - ) + frames = get_frames( + rendered_svg=_SPINNER_SVG, + spinner_node_id="missing", + width=120, + height=120, + bg_tile=buf.getvalue(), ) - sf = SpinnerFrames(spec, width=120, height=120) - assert len(sf.frames) == 2 - + assert len(frames) == SPINNER_FRAME_COUNT + assert all(f == frames[0] for f in frames) -class TestSpinnerWithBgTile: - """Spinner frames composited onto a background tile.""" - @patch("deux.render.svg_rasterize._svg_to_image", side_effect=_fake_image) - def test_rotation_with_bg_tile(self, mock_raster): - tile = Image.new("RGB", (120, 120), (255, 0, 0)) - spec = _make_spec( - spinner=SpinnerSpec(type=SpinnerType.ROTATION, node="spinner", frames=4) - ) - sf = SpinnerFrames(spec, width=120, height=120, bg_tile=tile) - assert len(sf.frames) == 4 - for frame_bytes in sf.frames: - img = Image.open(io.BytesIO(frame_bytes)) - assert img.size == (120, 120) +class TestBuildSpinnerFragment: + def test_fragment_parses_with_angle(self): + elem = _build_spinner_fragment(45.0) + assert elem.tag == "{http://www.w3.org/2000/svg}g" + rotor = elem.find('.//{http://www.w3.org/2000/svg}g[@class="deux-spinner-rotor"]') + assert rotor is not None + assert "rotate(45.0)" in (rotor.get("transform") or "") - @patch("deux.render.svg_rasterize._svg_to_image", side_effect=_fake_image) - def test_pulse_with_bg_tile(self, mock_raster): - tile = Image.new("RGB", (120, 120), (0, 255, 0)) - spec = _make_spec( - spinner=SpinnerSpec(type=SpinnerType.PULSE, node="spinner", frames=4) - ) - sf = SpinnerFrames(spec, width=120, height=120, bg_tile=tile) - assert len(sf.frames) == 4 - def test_blank_frames_with_bg_tile(self): - """Blank fallback frames use the background tile instead of black.""" - tile = Image.new("RGB", (120, 120), (0, 0, 255)) - spec = _make_spec( - spinner=SpinnerSpec(type=SpinnerType.ROTATION, node="nonexistent", frames=2) - ) - sf = SpinnerFrames(spec, width=120, height=120, bg_tile=tile) - for frame_bytes in sf.frames: - img = Image.open(io.BytesIO(frame_bytes)) - r, g, b = img.getpixel((60, 60)) - assert b > 200 - assert r < 50 +class TestBgSignature: + def test_none_returns_constant(self): + assert _bg_signature(None) == "none" - def test_custom_frames_with_bg_tile(self): - """Custom PNG frames are composited onto the background tile.""" - tile = Image.new("RGB", (120, 120), (255, 0, 0)) - frame_img = Image.new("RGBA", (120, 120), (0, 255, 0, 128)) - buf = io.BytesIO() - frame_img.save(buf, format="PNG") - frame_png = buf.getvalue() - spec = _make_spec( - spinner=SpinnerSpec(type=SpinnerType.CUSTOM, frames=1), - assets={"spinner/frame_00.png": frame_png}, - ) - sf = SpinnerFrames(spec, width=120, height=120, bg_tile=tile) - assert len(sf.frames) == 1 + def test_bytes_returns_digest(self): + sig = _bg_signature(b"abc") + assert sig != "none" + assert _bg_signature(b"abc") == sig - def test_gif_frames_with_bg_tile(self): - """GIF frames are composited onto the background tile.""" - tile = Image.new("RGB", (120, 120), (255, 0, 0)) - f1 = Image.new("RGB", (120, 120), (0, 255, 0)) - f2 = Image.new("RGB", (120, 120), (0, 0, 255)) - buf = io.BytesIO() - f1.save(buf, format="GIF", save_all=True, append_images=[f2], loop=0) - gif_data = buf.getvalue() - spec = _make_spec( - spinner=SpinnerSpec(type=SpinnerType.CUSTOM, frames=2), - assets={"spinner.gif": gif_data}, - ) - sf = SpinnerFrames(spec, width=120, height=120, bg_tile=tile) - assert len(sf.frames) == 2 + def test_image_returns_digest(self): + img = Image.new("RGB", (5, 5), (1, 2, 3)) + sig = _bg_signature(img) + assert sig != "none" + # Same image bytes → same signature + img2 = Image.new("RGB", (5, 5), (1, 2, 3)) + assert _bg_signature(img2) == sig diff --git a/tests/test_spinner_cache.py b/tests/test_spinner_cache.py new file mode 100644 index 0000000..b45ab1b --- /dev/null +++ b/tests/test_spinner_cache.py @@ -0,0 +1,188 @@ +"""Tests for the process-wide spinner frame LRU cache.""" + +from __future__ import annotations + +import io +from unittest.mock import patch + +import pytest +from PIL import Image + +from deux.dui import spinner as spinner_mod +from deux.dui.spinner import ( + SPINNER_FRAME_COUNT, + _bg_signature, + clear_cache, + get_frames, +) + +_SPINNER_SVG = ( + '' + '' + "" +) + +_SPINNER_SVG_ALT = ( + '' + 'other' + '' + "" +) + + +def _fake_image(svg_data, width, height, *, mode="RGBA", ctx=None): + return Image.new(mode, (width, height), "black") + + +@pytest.fixture(autouse=True) +def _reset_cache(): + clear_cache() + yield + clear_cache() + + +@patch("deux.render.svg_rasterize._svg_to_image", side_effect=_fake_image) +class TestCacheBehaviour: + """LRU cache identity, eviction, and key composition.""" + + def test_repeat_call_reuses_cached_frames(self, mock_raster): + first = get_frames( + rendered_svg=_SPINNER_SVG, + spinner_node_id="spinner", + width=64, + height=64, + ) + calls = mock_raster.call_count + second = get_frames( + rendered_svg=_SPINNER_SVG, + spinner_node_id="spinner", + width=64, + height=64, + ) + assert second is first + assert mock_raster.call_count == calls # no extra rasterisations + + def test_different_svg_creates_distinct_entry(self, mock_raster): + a = get_frames( + rendered_svg=_SPINNER_SVG, + spinner_node_id="spinner", + width=64, + height=64, + ) + b = get_frames( + rendered_svg=_SPINNER_SVG_ALT, + spinner_node_id="spinner", + width=64, + height=64, + ) + assert b is not a + assert mock_raster.call_count == 2 * SPINNER_FRAME_COUNT + + def test_different_size_creates_distinct_entry(self, mock_raster): + a = get_frames( + rendered_svg=_SPINNER_SVG, + spinner_node_id="spinner", + width=64, + height=64, + ) + b = get_frames( + rendered_svg=_SPINNER_SVG, + spinner_node_id="spinner", + width=128, + height=128, + ) + assert b is not a + + def test_different_format_creates_distinct_entry(self, mock_raster): + a = get_frames( + rendered_svg=_SPINNER_SVG, + spinner_node_id="spinner", + width=64, + height=64, + image_format="JPEG", + ) + b = get_frames( + rendered_svg=_SPINNER_SVG, + spinner_node_id="spinner", + width=64, + height=64, + image_format="BMP", + ) + assert b is not a + + def test_different_bg_tile_creates_distinct_entry(self, mock_raster): + tile1 = Image.new("RGB", (64, 64), (0, 0, 0)) + buf1 = io.BytesIO() + tile1.save(buf1, format="PNG") + tile2 = Image.new("RGB", (64, 64), (200, 200, 200)) + buf2 = io.BytesIO() + tile2.save(buf2, format="PNG") + + a = get_frames( + rendered_svg=_SPINNER_SVG, + spinner_node_id="spinner", + width=64, + height=64, + bg_tile=buf1.getvalue(), + ) + b = get_frames( + rendered_svg=_SPINNER_SVG, + spinner_node_id="spinner", + width=64, + height=64, + bg_tile=buf2.getvalue(), + ) + assert a is not b + + def test_clear_cache_evicts_all(self, mock_raster): + get_frames( + rendered_svg=_SPINNER_SVG, + spinner_node_id="spinner", + width=64, + height=64, + ) + clear_cache() + before = mock_raster.call_count + get_frames( + rendered_svg=_SPINNER_SVG, + spinner_node_id="spinner", + width=64, + height=64, + ) + assert mock_raster.call_count == before + SPINNER_FRAME_COUNT + + def test_lru_eviction(self, mock_raster, monkeypatch): + monkeypatch.setattr(spinner_mod, "_CACHE_MAX_ENTRIES", 2) + + svgs = [ + _SPINNER_SVG, + _SPINNER_SVG_ALT, + _SPINNER_SVG.replace("100", "101"), + ] + for svg in svgs: + get_frames( + rendered_svg=svg, + spinner_node_id="spinner", + width=64, + height=64, + ) + assert len(spinner_mod._cache) == 2 + + # Oldest (svgs[0]) was evicted → re-fetching forces re-render. + before = mock_raster.call_count + get_frames( + rendered_svg=svgs[0], + spinner_node_id="spinner", + width=64, + height=64, + ) + assert mock_raster.call_count == before + SPINNER_FRAME_COUNT + + +class TestBgSignatureStability: + def test_same_bytes_same_signature(self): + a = b"\x00\x01\x02" + assert _bg_signature(a) == _bg_signature(a) + + def test_different_bytes_different_signature(self): + assert _bg_signature(b"a") != _bg_signature(b"b")