Namespace quest definition icon keys with legacy back-compat
Add core/icons.py as the one place icon keys are validated and normalized. A definition icon is now stored as lucide:<name>, fa:<name> or emoji:<payload> (at most 8 code points, no whitespace, control characters or markup). Create and edit validate strictly and store the canonical form; a legacy bare name is stored as lucide:<name>, and None still clears the icon. Reads normalize leniently so existing bare-name rows need no data migration: the admin quest-definition response and the panel snapshot's per-instance icon both return the namespaced form. Task 707994b7. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,105 @@
|
||||
"""Test the namespaced icon-key module (task 707994b7).
|
||||
|
||||
:func:`validate_icon` is the strict write-side check (create/edit);
|
||||
:func:`normalize_icon_for_read` the lenient read-side normalizer that
|
||||
lets legacy bare Lucide names render without a data migration.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from custom_components.nestquest.core.icons import (
|
||||
EMOJI_MAX_CODE_POINTS,
|
||||
ICON_NAMESPACES,
|
||||
normalize_icon_for_read,
|
||||
validate_icon,
|
||||
)
|
||||
|
||||
|
||||
def test_namespace_and_cap_constants() -> None:
|
||||
assert ICON_NAMESPACES == {"lucide", "fa", "emoji"}
|
||||
assert EMOJI_MAX_CODE_POINTS == 8
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("value", "expected"),
|
||||
[
|
||||
("lucide:dog", "lucide:dog"),
|
||||
("fa:dog", "fa:dog"),
|
||||
("fa:paw-print", "fa:paw-print"),
|
||||
("lucide:toy-box-2", "lucide:toy-box-2"),
|
||||
("emoji:🐕", "emoji:🐕"),
|
||||
# A ZWJ family sequence (7 code points) is within the cap.
|
||||
("emoji:👨👩👧👦", "emoji:👨👩👧👦"),
|
||||
("emoji:" + "⭐" * EMOJI_MAX_CODE_POINTS, "emoji:" + "⭐" * 8),
|
||||
# Legacy bare Lucide names normalize to the lucide namespace.
|
||||
("dog", "lucide:dog"),
|
||||
(" toy-box ", "lucide:toy-box"),
|
||||
(" fa:dog ", "fa:dog"),
|
||||
],
|
||||
)
|
||||
def test_validate_icon_accepts(value: str, expected: str) -> None:
|
||||
assert validate_icon(value) == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", [None, "", " ", "\t\n"])
|
||||
def test_validate_icon_clears(value: str | None) -> None:
|
||||
assert validate_icon(value) is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"value",
|
||||
[
|
||||
"foo:dog", # unknown namespace
|
||||
"javascript:alert(1)", # unknown namespace (and markup-ish)
|
||||
"mdi:dog", # not one of the supported namespaces
|
||||
"lucide:", # empty name
|
||||
"fa:", # empty name
|
||||
"emoji:", # empty payload
|
||||
"lucide:Dog", # uppercase
|
||||
"lucide:has space", # whitespace
|
||||
"lucide:-dog", # leading hyphen
|
||||
"lucide:dog-", # trailing hyphen
|
||||
"lucide:dog_bone", # not kebab
|
||||
"fa:dög", # non-ASCII
|
||||
"lucide:a:b", # colon inside the name
|
||||
"Dog", # bare legacy name must still be kebab
|
||||
"has space",
|
||||
"<script>",
|
||||
"emoji:" + "⭐" * (EMOJI_MAX_CODE_POINTS + 1), # over the cap
|
||||
"emoji:a b", # whitespace
|
||||
"emoji:<b>", # markup
|
||||
"emoji:a&b", # entity start
|
||||
"emoji:🐕\x00", # control character
|
||||
"emoji:🐕\x07🐕", # control character between emoji
|
||||
],
|
||||
)
|
||||
def test_validate_icon_rejects_naming_the_field(value: str) -> None:
|
||||
with pytest.raises(ValueError, match=r"^icon\b"):
|
||||
validate_icon(value)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", [42, True, ["dog"], {"icon": "dog"}])
|
||||
def test_validate_icon_rejects_non_strings(value: object) -> None:
|
||||
with pytest.raises(ValueError, match="icon must be a string"):
|
||||
validate_icon(value)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("value", "expected"),
|
||||
[
|
||||
(None, None),
|
||||
("", None),
|
||||
(" ", None),
|
||||
("lucide:dog", "lucide:dog"),
|
||||
("fa:dog", "fa:dog"),
|
||||
("emoji:🐕", "emoji:🐕"),
|
||||
# Legacy bare names (and anything else) fall through to lucide so
|
||||
# the frontend's fallback handles an unknown name, never an error.
|
||||
("dog", "lucide:dog"),
|
||||
("mdi:tooth", "lucide:mdi:tooth"),
|
||||
("Not Kebab", "lucide:Not Kebab"),
|
||||
],
|
||||
)
|
||||
def test_normalize_icon_for_read(value: str | None, expected: str | None) -> None:
|
||||
assert normalize_icon_for_read(value) == expected
|
||||
Reference in New Issue
Block a user