tools/icons/curated-icons.json is the one hand-edited list (the 36 curated Lucide icons plus 22 Font Awesome Free solid chore icons, name + label). tools/icons/generate.mjs reads it, takes path data offline from the pinned lucide-react and @fortawesome/free-solid-svg-icons packages, converts Lucide circles/rects/lines to paths, and writes the same plain-data module (viewBox + path strings, emoji text, resolveIcon parser with a fallback entry) into admin/src/icons and frontend/src/icons. No CDN, no runtime fetch. The admin Definitions tab now draws its Lucide set from the registry (lucide-react becomes a generator-only dev dependency); the panel quest log renders lucide:, fa: and emoji: icons, falling back to the star. Drift tests in Vitest and pytest fail if the source or either artefact is edited alone. Task: 03ef0bb3-8228-4be6-bb73-ee8bb72c2f0f Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
95 lines
3.2 KiB
Python
95 lines
3.2 KiB
Python
"""The shared quest-icon registry: one curated source, two identical artefacts.
|
|
|
|
``tools/icons/curated-icons.json`` is the only hand-edited list; the offline
|
|
generator ``tools/icons/generate.mjs`` writes the same module into the admin
|
|
PWA and the panel bundle. The admin Vitest suite regenerates and compares
|
|
byte-for-byte; these checks need no Node for the core guarantees.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import re
|
|
import shutil
|
|
import subprocess
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
REPO_ROOT = Path(__file__).parent.parent
|
|
SOURCE = REPO_ROOT / "tools" / "icons" / "curated-icons.json"
|
|
GENERATOR = REPO_ROOT / "tools" / "icons" / "generate.mjs"
|
|
ARTEFACTS = (
|
|
REPO_ROOT / "admin" / "src" / "icons" / "registry.generated.ts",
|
|
REPO_ROOT / "frontend" / "src" / "icons" / "registry.generated.ts",
|
|
)
|
|
PANEL_BUNDLE = (
|
|
REPO_ROOT / "custom_components" / "nestquest" / "www" / "nestquest-cards.js"
|
|
)
|
|
|
|
|
|
def _curated() -> dict[str, list[dict[str, str]]]:
|
|
return json.loads(SOURCE.read_text(encoding="utf-8"))
|
|
|
|
|
|
def _generated_set(text: str, const: str) -> list[dict]:
|
|
match = re.search(
|
|
rf"export const {const}: readonly GlyphIcon\[\] = (\[.*?\n\]);",
|
|
text,
|
|
re.DOTALL,
|
|
)
|
|
assert match, f"{const} not found in the generated registry"
|
|
return json.loads(match.group(1))
|
|
|
|
|
|
def test_both_artefacts_are_byte_identical() -> None:
|
|
admin, frontend = (path.read_bytes() for path in ARTEFACTS)
|
|
assert admin == frontend
|
|
|
|
|
|
def test_artefacts_carry_exactly_the_curated_names_and_labels() -> None:
|
|
curated = _curated()
|
|
text = ARTEFACTS[0].read_text(encoding="utf-8")
|
|
for namespace, const in (("lucide", "LUCIDE_ICONS"), ("fa", "FA_ICONS")):
|
|
entries = _generated_set(text, const)
|
|
assert [
|
|
{"name": e["name"], "label": e["label"]} for e in entries
|
|
] == curated[namespace]
|
|
for entry in entries:
|
|
assert entry["kind"] == namespace
|
|
assert entry["key"] == f"{namespace}:{entry['name']}"
|
|
assert re.fullmatch(r"0 0 \d+ \d+", entry["viewBox"])
|
|
assert entry["paths"] and all(entry["paths"])
|
|
assert len(curated["lucide"]) == 36
|
|
assert 15 <= len(curated["fa"]) <= 25
|
|
|
|
|
|
def test_registry_has_no_remote_url_cdn_or_lucide_runtime() -> None:
|
|
for path in (SOURCE, GENERATOR, *ARTEFACTS):
|
|
text = path.read_text(encoding="utf-8")
|
|
assert not re.search(r"https?://", text, re.IGNORECASE), path
|
|
assert not re.search(
|
|
r"\bcdn\b|jsdelivr|unpkg|cdnjs|kit\.fontawesome", text, re.IGNORECASE
|
|
), path
|
|
assert "data-lucide" not in text, path
|
|
|
|
|
|
def test_panel_bundle_embeds_the_registry() -> None:
|
|
bundle = PANEL_BUNDLE.read_text(encoding="utf-8")
|
|
assert "fa:broom" in bundle
|
|
assert "lucide:dog" in bundle
|
|
assert "data-lucide" not in bundle
|
|
|
|
|
|
def test_generator_reproduces_the_committed_artefacts() -> None:
|
|
node = shutil.which("node")
|
|
if node is None or not (REPO_ROOT / "admin" / "node_modules").is_dir():
|
|
pytest.skip("needs node and admin/node_modules (npm ci in admin/)")
|
|
result = subprocess.run(
|
|
[node, str(GENERATOR), "--check"],
|
|
cwd=REPO_ROOT,
|
|
capture_output=True,
|
|
text=True,
|
|
check=False,
|
|
)
|
|
assert result.returncode == 0, result.stderr
|