Feature review remediation: emoji whitespace-contract parity (U+FEFF) #249

4 changed files with 101 additions and 4 deletions
+25 -1
View File
@@ -3,12 +3,18 @@ import { join } from "node:path";
import { describe, expect, it } from "vitest";
// The offline generator is the only writer of both registry artefacts.
import { OUTPUT_PATHS, REPO_ROOT, SOURCE_PATH, renderRegistry } from "../../../tools/icons/generate.mjs";
import { FALLBACK_ICON, FA_ICONS, LUCIDE_ICONS, resolveIcon } from "./registry.generated";
import { EMOJI_MAX_CODE_POINTS, FALLBACK_ICON, FA_ICONS, LUCIDE_ICONS, resolveIcon } from "./registry.generated";
type Curated = { lucide: { name: string; label: string }[]; fa: { name: string; label: string }[] };
const curated = JSON.parse(readFileSync(SOURCE_PATH, "utf8")) as Curated;
type EmojiPayloadCase = { payload: string; valid: boolean; why: string };
const emojiPayloadCases = JSON.parse(
readFileSync(join(REPO_ROOT, "tools", "icons", "emoji-payload-cases.json"), "utf8"),
) as { cases: EmojiPayloadCase[] };
describe("shared icon registry — one source of truth", () => {
it("both committed artefacts are byte-identical to a fresh generation", async () => {
const fresh = await renderRegistry();
@@ -86,6 +92,24 @@ describe("resolveIcon", () => {
}
expect(FALLBACK_ICON).toEqual({ kind: "fallback", key: null });
});
// tests/test_core_icons.py asserts the same table against the backend's
// validate_icon, so the two emoji rules cannot drift apart silently.
it.each(emojiPayloadCases.cases)("emoji verdict matches the shared table: $why", ({ payload, valid }) => {
const value = `emoji:${payload}`;
expect(resolveIcon(value)).toEqual(valid ? { kind: "emoji", key: value, text: payload } : FALLBACK_ICON);
});
it("the shared emoji table covers the contract edges", () => {
const verdicts = new Map(emojiPayloadCases.cases.map(({ payload, valid }) => [payload, valid]));
expect(verdicts.get("\ufeff")).toBe(false);
expect(verdicts.get("👨\u200d👩\u200d👧\u200d👦")).toBe(true);
expect(verdicts.get("🐕 ")).toBe(false);
expect(verdicts.get("🐕\u0000")).toBe(false);
for (const markup of ["<b>", "a>b", "a&b"]) expect(verdicts.get(markup)).toBe(false);
expect(verdicts.get("⭐".repeat(EMOJI_MAX_CODE_POINTS))).toBe(true);
expect(verdicts.get("⭐".repeat(EMOJI_MAX_CODE_POINTS + 1))).toBe(false);
});
});
function filesUnder(dir: string): string[] {
+9 -3
View File
@@ -6,8 +6,9 @@ namespace is one of :data:`ICON_NAMESPACES`:
- ``lucide:<name>`` / ``fa:<name>`` — ``<name>`` is a lowercase kebab
name (ASCII ``[a-z0-9-]``, not starting or ending with ``-``).
- ``emoji:<payload>`` — a short emoji string of at most
:data:`EMOJI_MAX_CODE_POINTS` code points, with no whitespace, no
control characters and none of ``<``, ``>``, ``&``.
:data:`EMOJI_MAX_CODE_POINTS` code points, with no whitespace (JS
``\\s``, so including U+FEFF), no control characters and none of
``<``, ``>``, ``&``.
Rows written before namespacing hold a bare Lucide name (``dog``).
:func:`validate_icon` (writes) normalizes such a legacy value to
@@ -33,7 +34,12 @@ ICON_NAMESPACES = frozenset(
EMOJI_MAX_CODE_POINTS = 8
_KEBAB_NAME = re.compile(r"[a-z0-9](?:[a-z0-9-]*[a-z0-9])?")
_EMOJI_FORBIDDEN = frozenset("<>&")
# U+FEFF (BOM / zero-width no-break space) is neither str.isspace() nor
# ``Cc`` in Python, but JS ``\s`` matches it, so it is listed here to keep
# this rule identical to the frontends' isEmojiPayload (/[\s<>&]|\p{Cc}/u).
# Other format characters, notably the ZWJ U+200D that emoji sequences
# need, stay valid in both runtimes.
_EMOJI_FORBIDDEN = frozenset("<>&\ufeff")
def _is_kebab_name(value: str) -> bool:
+41
View File
@@ -6,6 +6,9 @@ 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 (
@@ -70,6 +73,8 @@ def test_validate_icon_clears(value: str | None) -> None:
"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
@@ -109,3 +114,39 @@ def test_validate_icon_rejects_non_strings(value: object) -> None:
)
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
+26
View File
@@ -0,0 +1,26 @@
{
"_comment": "Shared emoji-payload verdicts. tests/test_core_icons.py (validate_icon) and admin/src/icons/registry.test.ts (resolveIcon) both assert every row, so the Python and JS rules cannot diverge silently.",
"cases": [
{ "payload": "🐕", "valid": true, "why": "plain emoji" },
{ "payload": "👨‍👩‍👧‍👦", "valid": true, "why": "ZWJ family (U+200D allowed)" },
{ "payload": "⭐⭐⭐⭐⭐⭐⭐⭐", "valid": true, "why": "exactly 8 code points" },
{ "payload": "⭐⭐⭐⭐⭐⭐⭐⭐⭐", "valid": false, "why": "9 code points, over the cap" },
{ "payload": "", "valid": false, "why": "empty" },
{ "payload": "", "valid": false, "why": "U+FEFF BOM alone" },
{ "payload": "🐕", "valid": false, "why": "trailing U+FEFF" },
{ "payload": "🐕 ", "valid": false, "why": "trailing space" },
{ "payload": "a b", "valid": false, "why": "inner space" },
{ "payload": "🐕 ", "valid": false, "why": "no-break space" },
{ "payload": "🐕 ", "valid": false, "why": "ideographic space" },
{ "payload": "🐕
", "valid": false, "why": "line separator" },
{ "payload": "🐕\n", "valid": false, "why": "newline" },
{ "payload": "🐕\u0000", "valid": false, "why": "NUL control" },
{ "payload": "🐕\u0007🐕", "valid": false, "why": "BEL control" },
{ "payload": "🐕\u001c", "valid": false, "why": "file-separator control" },
{ "payload": "🐕\u0085", "valid": false, "why": "NEL C1 control" },
{ "payload": "🐕\u007f", "valid": false, "why": "DEL control" },
{ "payload": "<b>", "valid": false, "why": "markup <" },
{ "payload": "a>b", "valid": false, "why": "markup >" },
{ "payload": "a&b", "valid": false, "why": "entity start &" }
]
}