Feature: expanded and custom task icons (Lucide + Font Awesome + emoji) (#248)

Co-authored-by: dev_agent <17+dev_agent@code.cubecraftcreations.com>
Co-committed-by: dev_agent <17+dev_agent@code.cubecraftcreations.com>
This commit was merged in pull request #248.
This commit is contained in:
2026-09-26 09:02:07 -04:00
committed by approver_agent
parent fb9907ae7f
commit bdf6d0aca1
33 changed files with 5108 additions and 1023 deletions
+87 -3
View File
@@ -35,6 +35,7 @@ for malformed bodies.
"""
from __future__ import annotations
import datetime
import importlib
from types import SimpleNamespace
@@ -223,7 +224,7 @@ async def test_create_persists_definition_with_assignees_and_windows(
}
assert payload["title"] == "Tidy the den"
assert payload["description"] == "Put everything back where it belongs."
assert payload["icon"] == "toy-box"
assert payload["icon"] == "lucide:toy-box"
assert payload["is_active"] is True
assert payload["skip_on_away"] is True
assert payload["rule"] == _weekly_rule_dict()
@@ -245,7 +246,7 @@ async def test_create_persists_definition_with_assignees_and_windows(
assert stored.definition.description == (
"Put everything back where it belongs."
)
assert stored.definition.icon == "toy-box"
assert stored.definition.icon == "lucide:toy-box"
assert stored.definition.is_active is True
assert stored.rule.to_dict() == _weekly_rule_dict()
assert sorted(child.id for child in stored.assignees) == [ada, bo]
@@ -396,7 +397,7 @@ async def test_edit_persists_supplied_fields_and_leaves_omitted_untouched(
assert stored.definition.description == (
"Put everything back where it belongs."
)
assert stored.definition.icon == "toy-box"
assert stored.definition.icon == "lucide:toy-box"
assert stored.rule.to_dict() == _weekly_rule_dict()
# assignee_child_ids was omitted: the assignees are untouched.
assert sorted(child.id for child in stored.assignees) == [ada, bo]
@@ -468,6 +469,89 @@ async def test_edit_can_clear_description_and_icon_explicitly(
assert stored.rule.to_dict() == _weekly_rule_dict()
async def test_legacy_bare_icon_reads_back_namespaced(
admin_questdefs: SimpleNamespace,
) -> None:
"""A row stored with a pre-namespacing bare icon reads as ``lucide:``.
The row is rewritten through the DAO (bypassing the write-side
validation, as a row from before namespacing would be) and then read
back through BOTH consumers: the admin definition response and the
kids' panel snapshot's per-instance ``icon``.
"""
client = admin_questdefs.client
ada = (await _create_children(client, ("Ada",)))[0]
today = datetime.date.today().isoformat()
created = await client.post(
"/api/v1/admin/quest-definitions",
json={
"title": "Walk the dog",
"rule": {"rule_type": "daily", "start_date": today},
"assignee_child_ids": [ada],
"windows": ["evening"],
"icon": "fa:dog",
},
headers=_admin_headers(),
)
assert created.status_code == 201
definition_id = created.json()["id"]
await _dao_rules.QuestDefinitionsDao(
admin_questdefs.database
).edit_definition(definition_id, icon="dog")
stored = (await _definitions_by_id(admin_questdefs.database))[
definition_id
]
assert stored.definition.icon == "dog"
listed = await client.get(
"/api/v1/admin/quest-definitions", headers=_admin_headers()
)
assert listed.status_code == 200
[definition] = [
d for d in listed.json()["definitions"] if d["id"] == definition_id
]
assert definition["icon"] == "lucide:dog"
panel = await client.get(
"/api/v1/panel/snapshot",
headers={"Authorization": "Bearer admin-test-panel-token"},
)
assert panel.status_code == 200
[child] = panel.json()["children"]
icons = {
instance["icon"]
for instance in child["instances"]
if instance["definition_id"] == definition_id
}
assert icons == {"lucide:dog"}
@pytest.mark.parametrize(
"icon", ["foo:dog", "lucide:", "emoji:<b>", "javascript:alert(1)"]
)
async def test_create_invalid_icon_is_422_naming_icon(
admin_questdefs: SimpleNamespace, icon: str
) -> None:
"""An invalid icon key is a 422 whose detail names the icon field."""
client = admin_questdefs.client
ada = (await _create_children(client, ("Ada",)))[0]
response = await client.post(
"/api/v1/admin/quest-definitions",
json={
"title": "Walk the dog",
"rule": _weekly_rule_body(),
"assignee_child_ids": [ada],
"windows": ["morning"],
"icon": icon,
},
headers=_admin_headers(),
)
assert response.status_code == 422
assert "icon" in str(response.json()["detail"])
assert await _definitions_by_id(admin_questdefs.database) == {}
async def test_edit_unknown_definition_is_404(
admin_questdefs: SimpleNamespace,
) -> None:
+1 -1
View File
@@ -135,7 +135,7 @@ async def test_admin_lists_active_and_inactive_definitions_with_full_shape(
"id": active_id,
"title": "Tidy the den",
"description": "Put everything back where it belongs.",
"icon": "toy-box",
"icon": "lucide:toy-box",
"is_active": True,
"skip_on_away": True,
"rule": {
+152
View File
@@ -0,0 +1,152 @@
"""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
+94
View File
@@ -0,0 +1,94 @@
"""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
+112
View File
@@ -886,3 +886,115 @@ def test_quest_complete_screen_returns_to_the_board_after_the_countdown() -> Non
assert result["probe"]["countdown"] == "Returning to The Party in 1 seconds"
# …and at zero the card has returned to the party board.
assert result["location"] == "/nestquest"
# --- Quest card icons (definition icon on the open tile) ---------------------
def _quest_row(instance_id: int, icon: str | None, state: str = "open") -> dict:
"""One ``instances`` attribute row for Ada's quests-due sensor."""
row = {
"id": instance_id,
"child_id": 1,
"title": f"Quest {instance_id}",
"icon": icon,
"window": "morning",
"due_time": "08:00",
"state": state,
"overdue": False,
"completed_at": None,
"on_time": None,
}
if state == "completed":
row["completed_at"] = "2026-09-26T07:30:00+00:00"
row["on_time"] = True
return row
def _quest_log_tiles(rows: list[dict]) -> dict[int, dict]:
"""Render Ada's quest log with ``rows`` and key its tiles by instance id."""
states = _three_child_states()
states["sensor.nestquest_ada_quests_due_today"] = _state(
str(len(rows)), child_name="Ada", present=True, instances=rows
)
result = _render_quest_log(
{"type": "custom:nestquest-quest-log-card"},
states,
url="http://homeassistant.local/nestquest/ada",
)
return {tile["id"]: tile for tile in result["tiles"]}
def test_open_quest_tile_renders_the_definition_icon() -> None:
"""The open card's tile shows the definition's namespaced icon, drawn
from the bundled registry: Lucide as a stroked svg, Font Awesome as a
filled svg, an emoji as its text, and a legacy bare name as Lucide."""
tiles = _quest_log_tiles(
[
_quest_row(1, "lucide:bed"),
_quest_row(2, "fa:broom"),
_quest_row(3, "emoji:🦊"),
_quest_row(4, "bath"),
]
)
assert tiles[1]["icon"] == "lucide:bed"
assert tiles[1]["fill"] == "none"
assert tiles[1]["paths"] > 0
assert tiles[2]["icon"] == "fa:broom"
assert tiles[2]["fill"] == "currentColor"
assert tiles[2]["paths"] > 0
assert tiles[3]["icon"] == "emoji:🦊"
assert tiles[3]["text"] == "🦊"
assert tiles[4]["icon"] == "lucide:bath"
assert tiles[4]["fill"] == "none"
for tile in tiles.values():
assert not tile["sealed"]
assert not tile["star"]
def test_open_quest_tile_falls_back_to_the_star() -> None:
"""An unknown name, an unknown namespace, or no icon at all keeps the
open card's current star glyph."""
tiles = _quest_log_tiles(
[
_quest_row(1, "lucide:not-a-real-icon"),
_quest_row(2, "fa:not-a-real-icon"),
_quest_row(3, "mdi:bed"),
_quest_row(4, ""),
_quest_row(5, None),
]
)
assert set(tiles) == {1, 2, 3, 4, 5}
for tile in tiles.values():
assert tile["star"]
assert tile["icon"] is None
def test_sealed_quest_tile_keeps_the_star() -> None:
"""A sealed card keeps its star tile (and its wax seal) whatever the
definition's icon; only the open card shows the quest's icon."""
tiles = _quest_log_tiles(
[
_quest_row(1, "lucide:bed", state="completed"),
_quest_row(2, "fa:broom", state="completed"),
_quest_row(3, "emoji:🦊", state="completed"),
]
)
for tile in tiles.values():
assert tile["sealed"]
assert tile["star"]
assert tile["icon"] is None
assert tile["text"] == ""
def test_only_the_open_tile_calls_the_icon_resolver() -> None:
source = _read(QUEST_LOG)
sealed_start = source.index('<div class="quest sealed"')
sealed_block = source[sealed_start : source.index("</div>", sealed_start)]
assert '<span class="tile" aria-hidden="true">${ICON_STAR}</span>' in sealed_block
assert "renderQuestIcon" not in sealed_block
assert (
source.count("renderQuestIcon(instance.icon, ICON_STAR)") == 1
), "only the open card's tile resolves the definition icon"
open_start = source.index('class="quest tappable"')
assert "renderQuestIcon(instance.icon, ICON_STAR)" in source[open_start:]
+90 -7
View File
@@ -106,13 +106,13 @@ def test_create_one_child_one_window(tmp_path) -> None:
[child.id],
["morning"],
description=" Twice daily ",
icon=" mdi:tooth ",
icon="lucide:tooth",
)
assert isinstance(result, CreatedQuestDefinition)
definition = result.definition
assert definition.title == "Brush teeth"
assert definition.description == "Twice daily"
assert definition.icon == "mdi:tooth"
assert definition.icon == "lucide:tooth"
assert definition.is_active is True
# D-008: the definition-level due_time column is never set here.
assert definition.due_time is None
@@ -232,6 +232,89 @@ def test_create_rejects_non_string_title(tmp_path) -> None:
_with_db(tmp_path, "create-bad-title-type.db")(_body)
@pytest.mark.parametrize(
("icon", "stored"),
[
("lucide:dog", "lucide:dog"),
("fa:dog", "fa:dog"),
("emoji:🐕", "emoji:🐕"),
("dog", "lucide:dog"), # legacy bare name: canonical lucide key
],
)
def test_create_stores_canonical_icon(tmp_path, icon, stored) -> None:
async def _body(database, children, rules, definitions, child):
result = await create_quest_definition(
database, "Walk dog", _daily_rule(), [child.id], ["morning"],
icon=icon,
)
assert result.definition.icon == stored
row = await definitions.get(result.definition.id)
assert row is not None
assert row.icon == stored
return None
_with_db(tmp_path, "create-icon.db")(_body)
_BAD_ICONS = [
"foo:dog",
"lucide:",
"lucide:Dog",
"lucide:has space",
"emoji:" + "🐕" * 9,
"emoji:a b",
"emoji:<b>",
"javascript:alert(1)",
]
@pytest.mark.parametrize("bad", _BAD_ICONS)
def test_create_rejects_invalid_icon_and_persists_nothing(tmp_path, bad) -> None:
async def _body(database, children, rules, definitions, child):
with pytest.raises(ValueError, match=r"^icon\b"):
await create_quest_definition(
database, "Walk dog", _daily_rule(), [child.id], ["morning"],
icon=bad,
)
assert await definitions.list_active() == []
return None
_with_db(tmp_path, "create-bad-icon.db")(_body)
@pytest.mark.parametrize("bad", _BAD_ICONS)
def test_edit_rejects_invalid_icon_and_changes_nothing(tmp_path, bad) -> None:
async def _body(database, children, rules, definitions, child):
created = await create_quest_definition(
database, "Walk dog", _daily_rule(), [child.id], ["morning"],
icon="lucide:dog",
)
with pytest.raises(ValueError, match=r"^icon\b"):
await edit_quest_definition(
database, created.definition.id, icon=bad
)
row = await definitions.get(created.definition.id)
assert row is not None
assert row.icon == "lucide:dog"
return None
_with_db(tmp_path, "edit-bad-icon.db")(_body)
def test_edit_normalizes_legacy_bare_icon(tmp_path) -> None:
async def _body(database, children, rules, definitions, child):
created = await create_quest_definition(
database, "Walk dog", _daily_rule(), [child.id], ["morning"],
)
result = await edit_quest_definition(
database, created.definition.id, icon="dog"
)
assert result.definition.icon == "lucide:dog"
return None
_with_db(tmp_path, "edit-legacy-icon.db")(_body)
@pytest.mark.parametrize("bad", [None, "daily", 42, {"rule_type": "daily"}])
def test_create_rejects_non_schedule_rule(tmp_path, bad) -> None:
async def _body(database, children, rules, definitions, child):
@@ -535,18 +618,18 @@ def test_edit_title_description_icon(tmp_path) -> None:
[child.id],
["morning"],
description="Old description",
icon="mdi:old",
icon="lucide:old",
)
result = await edit_quest_definition(
database,
created.definition.id,
title=" Floss ",
description=" New description ",
icon=" mdi:new ",
icon="fa:new",
)
assert result.definition.title == "Floss"
assert result.definition.description == "New description"
assert result.definition.icon == "mdi:new"
assert result.definition.icon == "fa:new"
# Untouched fields survive the edit unchanged.
assert result.definition.id == created.definition.id
assert result.definition.schedule_rule_id == created.definition.schedule_rule_id
@@ -561,7 +644,7 @@ def test_edit_title_description_icon(tmp_path) -> None:
assert stored is not None
assert stored.title == "Floss"
assert stored.description == "New description"
assert stored.icon == "mdi:new"
assert stored.icon == "fa:new"
return None
_with_db(tmp_path, "edit-metadata.db")(_body)
@@ -597,7 +680,7 @@ def test_edit_clears_optional_metadata(tmp_path) -> None:
[child.id],
["morning"],
description="Old",
icon="mdi:old",
icon="lucide:old",
)
result = await edit_quest_definition(
database,