Co-authored-by: dev_agent <17+dev_agent@code.cubecraftcreations.com> Co-committed-by: dev_agent <17+dev_agent@code.cubecraftcreations.com>
153 lines
5.4 KiB
Python
153 lines
5.4 KiB
Python
"""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 json
|
|
from pathlib import Path
|
|
|
|
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"),
|
|
],
|
|
)
|
|
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
|
|
"emoji:\ufeff", # U+FEFF BOM: JS \s matches it, so reject to agree
|
|
"emoji:🐕\ufeff", # trailing U+FEFF
|
|
# Edge whitespace/controls are part of the value, never trimmed away.
|
|
"emoji:🐕 ", # trailing space
|
|
"emoji:🐕\n", # trailing newline
|
|
"emoji:🐕\x1c", # trailing control character (str.strip() eats it)
|
|
" emoji:🐕", # leading space
|
|
"lucide:dog ", # trailing space
|
|
" fa:dog ", # padded namespaced key
|
|
" toy-box ", # padded legacy bare name
|
|
],
|
|
)
|
|
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
|
|
|
|
|
|
# The emoji-payload rule is duplicated in the generated JS resolver
|
|
# (``isEmojiPayload`` in tools/icons/generate.mjs). Both suites assert
|
|
# this one table — admin/src/icons/registry.test.ts runs it through
|
|
# resolveIcon — so a divergence between the runtimes fails a test.
|
|
_EMOJI_PARITY_CASES = json.loads(
|
|
(
|
|
Path(__file__).parent.parent / "tools" / "icons" / "emoji-payload-cases.json"
|
|
).read_text(encoding="utf-8")
|
|
)["cases"]
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"case", _EMOJI_PARITY_CASES, ids=[case["why"] for case in _EMOJI_PARITY_CASES]
|
|
)
|
|
def test_validate_icon_emoji_verdict_matches_shared_table(case: dict) -> None:
|
|
value = "emoji:" + case["payload"]
|
|
if case["valid"]:
|
|
assert validate_icon(value) == value
|
|
else:
|
|
with pytest.raises(ValueError, match=r"^icon\b"):
|
|
validate_icon(value)
|
|
|
|
|
|
def test_emoji_parity_table_covers_the_contract_edges() -> None:
|
|
payloads = {case["payload"]: case["valid"] for case in _EMOJI_PARITY_CASES}
|
|
assert payloads["\ufeff"] is False
|
|
assert payloads["🐕 "] is False
|
|
assert payloads["🐕\x00"] is False
|
|
assert payloads["<b>"] is False and payloads["a>b"] is False
|
|
assert payloads["a&b"] is False
|
|
assert payloads["⭐" * EMOJI_MAX_CODE_POINTS] is True
|
|
assert payloads["⭐" * (EMOJI_MAX_CODE_POINTS + 1)] is False
|
|
family = "👨\u200d👩\u200d👧\u200d👦"
|
|
assert payloads[family] is True
|