Co-authored-by: dev_agent <dev_agent@cubecraftlabs.com> Co-committed-by: dev_agent <dev_agent@cubecraftlabs.com>
164 lines
5.9 KiB
Python
164 lines
5.9 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_artefacts_carry_the_full_font_awesome_free_solid_set() -> None:
|
|
text = ARTEFACTS[0].read_text(encoding="utf-8")
|
|
solid = _generated_set(text, "FA_SOLID_ICONS")
|
|
names = [entry["name"] for entry in solid]
|
|
assert names == sorted(names)
|
|
assert len(set(names)) == len(names)
|
|
# The complete Free Solid set, not a curated pick (7.x ships ~1,400).
|
|
assert len(solid) >= 1400
|
|
for entry in solid:
|
|
assert set(entry) == {"kind", "key", "name", "label", "viewBox", "paths"}
|
|
assert entry["kind"] == "fa"
|
|
assert entry["key"] == f"fa:{entry['name']}"
|
|
assert entry["label"] == " ".join(
|
|
part[0].upper() + part[1:] for part in entry["name"].split("-")
|
|
)
|
|
assert re.fullmatch(r"0 0 \d+ \d+", entry["viewBox"])
|
|
assert entry["paths"] and all(entry["paths"])
|
|
# A well-known glyph, a barely-known one, and every curated quick-pick.
|
|
assert {"rocket", "mug-hot", "person-dress-burst"} <= set(names)
|
|
assert {e["name"] for e in _curated()["fa"]} <= set(names)
|
|
assert "rocket-ship-9000" not in names
|
|
|
|
|
|
def test_resolve_icon_resolves_any_free_solid_name() -> None:
|
|
node = shutil.which("node")
|
|
if node is None:
|
|
pytest.skip("needs node")
|
|
script = (
|
|
"const m = await import(process.argv[1]);"
|
|
"const out = {};"
|
|
"for (const v of JSON.parse(process.argv[2])) {"
|
|
" const r = m.resolveIcon(v); out[v] = [r.kind, r.label ?? null]; }"
|
|
"console.log(JSON.stringify(out));"
|
|
)
|
|
values = [
|
|
"fa:rocket",
|
|
"fa:mug-hot",
|
|
"fa:person-dress-burst",
|
|
"fa:paw",
|
|
"fa:rocket-ship-9000",
|
|
"fa:Rocket",
|
|
"fa:",
|
|
"lucide:dog",
|
|
"dog",
|
|
"lucide:rocket",
|
|
]
|
|
result = subprocess.run(
|
|
[node, "--input-type=module", "-e", script,
|
|
ARTEFACTS[0].as_uri(), json.dumps(values)],
|
|
cwd=REPO_ROOT,
|
|
capture_output=True,
|
|
text=True,
|
|
check=False,
|
|
)
|
|
if result.returncode != 0 and "Unknown file extension" in result.stderr:
|
|
pytest.skip("node cannot strip TypeScript types (needs Node 22.18+)")
|
|
assert result.returncode == 0, result.stderr
|
|
resolved = json.loads(result.stdout)
|
|
assert resolved["fa:rocket"] == ["fa", "Rocket"]
|
|
assert resolved["fa:mug-hot"] == ["fa", "Mug Hot"]
|
|
assert resolved["fa:person-dress-burst"] == ["fa", "Person Dress Burst"]
|
|
# Curated entries keep their quick-pick label.
|
|
assert resolved["fa:paw"] == ["fa", "Pets"]
|
|
assert resolved["lucide:dog"][0] == "lucide"
|
|
assert resolved["dog"][0] == "lucide"
|
|
for unknown in ("fa:rocket-ship-9000", "fa:Rocket", "fa:", "lucide:rocket"):
|
|
assert resolved[unknown] == ["fallback", None], unknown
|
|
|
|
|
|
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
|