From 60d15941361aae4fe59d39ef9d158480dacc5975 Mon Sep 17 00:00:00 2001 From: dev_agent <17+dev_agent@code.cubecraftcreations.com> Date: Sat, 26 Sep 2026 07:42:18 -0400 Subject: [PATCH] Core/API: namespaced icon keys (lucide/fa/emoji) with legacy back-compat (#241) --- admin/src/definitions/DefinitionsTab.test.tsx | 94 ++++++++++++++- admin/src/definitions/DefinitionsTab.tsx | 38 +++--- admin/src/definitions/definitionIcons.tsx | 24 +++- api/nestquest_core.py | 5 + api/routes_admin.py | 3 +- custom_components/nestquest/core/icons.py | 113 ++++++++++++++++++ .../nestquest/core/quest_definitions.py | 5 +- custom_components/nestquest/core/snapshot.py | 7 +- tests/test_api_admin_questdef.py | 90 +++++++++++++- tests/test_api_admin_questdef_list.py | 2 +- tests/test_core_icons.py | 111 +++++++++++++++++ tests/test_quest_definitions.py | 97 +++++++++++++-- 12 files changed, 554 insertions(+), 35 deletions(-) create mode 100644 custom_components/nestquest/core/icons.py create mode 100644 tests/test_core_icons.py diff --git a/admin/src/definitions/DefinitionsTab.test.tsx b/admin/src/definitions/DefinitionsTab.test.tsx index 0db0d5d..7f5ef3e 100644 --- a/admin/src/definitions/DefinitionsTab.test.tsx +++ b/admin/src/definitions/DefinitionsTab.test.tsx @@ -1,7 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { act, cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; import DefinitionsTab from "./DefinitionsTab"; -import { DEFINITION_ICONS, DefinitionIcon } from "./definitionIcons"; +import { DEFINITION_ICONS, DefinitionIcon, normalizeDefinitionIconName } from "./definitionIcons"; import type { AdminChild, DefinitionRule, QuestDefinition } from "../api/definitions"; import { storeTokens } from "../auth/oidc"; @@ -1052,7 +1052,7 @@ describe("DefinitionsTab icon picker", () => { expect(api.writes()).toHaveLength(1); expect(api.writes()[0].method).toBe("POST"); - expect((api.writes()[0].body as { icon: string }).icon).toBe("bed"); + expect((api.writes()[0].body as { icon: string }).icon).toBe("lucide:bed"); expect(rowIcon(await screen.findByTestId("definition-99"))).toBe("bed"); }); @@ -1073,7 +1073,7 @@ describe("DefinitionsTab icon picker", () => { const { method, path, body } = api.writes()[0]; expect(method).toBe("PATCH"); expect(path).toBe("/api/v1/admin/quest-definitions/11"); - expect((body as { icon: string }).icon).toBe("trash-2"); + expect((body as { icon: string }).icon).toBe("lucide:trash-2"); // Inactive "Old" stays assigned: the unchanged selection is still omitted. expect(body).not.toHaveProperty("assignee_child_ids"); await waitFor(() => expect(rowIcon(screen.getByTestId("definition-11"))).toBe("trash-2")); @@ -1119,6 +1119,94 @@ describe("DefinitionsTab icon picker", () => { }); }); +describe("DefinitionsTab namespaced icons", () => { + beforeEach(() => { + sessionStorage.clear(); + storeTokens({ access_token: "at-123", expires_in: 600 }); + }); + + afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); + vi.useRealTimers(); + }); + + function rowIcon(row: HTMLElement): string | null { + const svg = row.querySelector(".defs-row-glyph svg"); + expect(svg).not.toBeNull(); + return svg!.getAttribute("data-icon"); + } + + function iconRadio(s: HTMLElement, label: string): HTMLElement { + return within(within(s).getByRole("radiogroup", { name: "Icon" })).getByRole("radio", { + name: label, + }); + } + + it("normalizes lucide-namespaced and legacy bare values to one Lucide name", () => { + expect(normalizeDefinitionIconName("lucide:dog")).toBe("dog"); + expect(normalizeDefinitionIconName("dog")).toBe("dog"); + expect(normalizeDefinitionIconName("fa:dog")).toBeNull(); + expect(normalizeDefinitionIconName("emoji:🐢")).toBeNull(); + expect(normalizeDefinitionIconName("mystery:dog")).toBeNull(); + expect(normalizeDefinitionIconName(null)).toBeNull(); + }); + + for (const icon of ["lucide:dog", "dog"]) { + it(`renders and selects the Dog glyph for ${icon}`, async () => { + await renderReady([{ ...BRUSH, id: 30, icon }]); + const row = screen.getByTestId("definition-30"); + expect(rowIcon(row)).toBe("dog"); + + fireEvent.click(row); + const s = sheet(); + expect(iconRadio(s, "Dog").getAttribute("aria-checked")).toBe("true"); + expect(iconRadio(s, "Dog").className).toContain("defs-icon--on"); + expect(iconRadio(s, "No icon").getAttribute("aria-checked")).toBe("false"); + }); + } + + for (const icon of ["fa:dog", "emoji:🐢", "mystery:dog"]) { + it(`renders the fallback tile and selects nothing for ${icon}`, async () => { + await renderReady([{ ...BRUSH, id: 31, icon }]); + const row = screen.getByTestId("definition-31"); + expect(rowIcon(row)).toBeNull(); + + fireEvent.click(row); + const s = sheet(); + const group = within(s).getByRole("radiogroup", { name: "Icon" }); + for (const radio of within(group).getAllByRole("radio")) { + expect(radio.getAttribute("aria-checked")).toBe("false"); + } + }); + } + + it("tapping a Lucide choice on a namespaced task PATCHes lucide:, and No icon clears it", async () => { + await renderReady([{ ...BRUSH, id: 32, icon: "lucide:dog" }]); + fireEvent.click(screen.getByTestId("definition-32")); + let s = sheet(); + fireEvent.click(iconRadio(s, "Cat")); + expect(iconRadio(s, "Cat").getAttribute("aria-checked")).toBe("true"); + expect(iconRadio(s, "Dog").getAttribute("aria-checked")).toBe("false"); + + await act(async () => { + fireEvent.click(within(s).getByRole("button", { name: "Save changes" })); + }); + expect((api.writes()[0].body as { icon: string }).icon).toBe("lucide:cat"); + await waitFor(() => expect(rowIcon(screen.getByTestId("definition-32"))).toBe("cat")); + + fireEvent.click(screen.getByTestId("definition-32")); + s = sheet(); + expect(iconRadio(s, "Cat").getAttribute("aria-checked")).toBe("true"); + fireEvent.click(iconRadio(s, "No icon")); + expect(iconRadio(s, "No icon").getAttribute("aria-checked")).toBe("true"); + await act(async () => { + fireEvent.click(within(s).getByRole("button", { name: "Save changes" })); + }); + expect((api.writes()[1].body as { icon: string | null }).icon).toBeNull(); + }); +}); + /* ── Start date ─────────────────────────────────────────────────────── */ function startDateInput(s: HTMLElement): HTMLInputElement { diff --git a/admin/src/definitions/DefinitionsTab.tsx b/admin/src/definitions/DefinitionsTab.tsx index dcbb698..bb5a7ea 100644 --- a/admin/src/definitions/DefinitionsTab.tsx +++ b/admin/src/definitions/DefinitionsTab.tsx @@ -43,7 +43,12 @@ import { PlusGlyph, TaskGlyph, } from "./glyphs"; -import { DEFINITION_ICONS, DefinitionIcon } from "./definitionIcons"; +import { + DEFINITION_ICONS, + DefinitionIcon, + lucideIconKey, + normalizeDefinitionIconName, +} from "./definitionIcons"; import "./DefinitionsTab.css"; /** ADMIN-SPEC Β§1: every scroll column ends with 92px so content clears the tab bar. */ @@ -576,20 +581,23 @@ function EditSheet({ > - {DEFINITION_ICONS.map(({ name, label, Icon }) => ( - - ))} + {DEFINITION_ICONS.map(({ name, label, Icon }) => { + const on = normalizeDefinitionIconName(draft.icon) === name; + return ( + + ); + })} diff --git a/admin/src/definitions/definitionIcons.tsx b/admin/src/definitions/definitionIcons.tsx index dbf0a3d..484a430 100644 --- a/admin/src/definitions/definitionIcons.tsx +++ b/admin/src/definitions/definitionIcons.tsx @@ -1,7 +1,8 @@ /** * The shipped Lucide subset a definition's `icon` may name (ADMIN-SPEC Β§3.3). * Only these named imports are bundled β€” no CDN, no runtime fetch. The stored - * value is the Lucide kebab-case name, so it stays meaningful to any surface. + * value is the namespaced `lucide:`; a legacy bare `` + * still names the same Lucide entry. */ import { Apple, @@ -91,7 +92,26 @@ export const DEFINITION_ICONS: readonly DefinitionIconEntry[] = [ const BY_NAME = new Map(DEFINITION_ICONS.map((entry) => [entry.name, entry])); -export function findDefinitionIcon(name: string | null): DefinitionIconEntry | null { +const LUCIDE_PREFIX = "lucide:"; + +/** The canonical stored key for a Lucide choice, e.g. `lucide:dog`. */ +export function lucideIconKey(name: string): string { + return `${LUCIDE_PREFIX}${name}`; +} + +/** + * The Lucide name an icon value refers to: `lucide:dog` and a legacy bare + * `dog` both give `dog`. Any other namespace (`fa:`, `emoji:`, unknown) and + * null give null β€” the admin has no Lucide entry for them. + */ +export function normalizeDefinitionIconName(value: string | null): string | null { + if (value === null) return null; + if (value.startsWith(LUCIDE_PREFIX)) return value.slice(LUCIDE_PREFIX.length); + return value.includes(":") ? null : value; +} + +export function findDefinitionIcon(value: string | null): DefinitionIconEntry | null { + const name = normalizeDefinitionIconName(value); return name === null ? null : (BY_NAME.get(name) ?? null); } diff --git a/api/nestquest_core.py b/api/nestquest_core.py index cb4ff1e..35100b6 100644 --- a/api/nestquest_core.py +++ b/api/nestquest_core.py @@ -152,6 +152,10 @@ core_quest_definitions = importlib.import_module( f"{_CORE_MODULE_NAME}.quest_definitions" ) +#: The icon-key module (``core.icons.normalize_icon_for_read``) the +#: admin plane's quest-definition responses normalize ``icon`` through. +core_icons = importlib.import_module(f"{_CORE_MODULE_NAME}.icons") + #: The recurrence model (``core.recurrence.ScheduleRule`` and #: ``RuleValidationError``) the quest-definition routes build request #: rules with β€” the model validates at construction, the routes never @@ -215,6 +219,7 @@ __all__ = [ "core_db", "core_events", "core_history", + "core_icons", "core_materialize", "core_migrations", "core_presence", diff --git a/api/routes_admin.py b/api/routes_admin.py index b70e8eb..23a8f1d 100644 --- a/api/routes_admin.py +++ b/api/routes_admin.py @@ -336,6 +336,7 @@ from api.nestquest_core import ( core_dao_children, core_dao_rules, core_history, + core_icons, core_materialize, core_presence_management, core_quest_definitions, @@ -1205,7 +1206,7 @@ def _quest_definition_response( id=bundle.definition.id, title=bundle.definition.title, description=bundle.definition.description, - icon=bundle.definition.icon, + icon=core_icons.normalize_icon_for_read(bundle.definition.icon), is_active=bundle.definition.is_active, skip_on_away=bundle.definition.skip_on_away, rule=_rule_response(bundle.rule), diff --git a/custom_components/nestquest/core/icons.py b/custom_components/nestquest/core/icons.py new file mode 100644 index 0000000..7a2a04b --- /dev/null +++ b/custom_components/nestquest/core/icons.py @@ -0,0 +1,113 @@ +"""Namespaced quest-definition icon keys. + +A definition's ``icon`` is stored as ``:`` where the +namespace is one of :data:`ICON_NAMESPACES`: + +- ``lucide:`` / ``fa:`` β€” ```` is a lowercase kebab + name (ASCII ``[a-z0-9-]``, not starting or ending with ``-``). +- ``emoji:`` β€” a short emoji string of at most + :data:`EMOJI_MAX_CODE_POINTS` code points, with no whitespace, 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 +``lucide:``; :func:`normalize_icon_for_read` (reads) does the same +leniently so legacy rows render without a data migration. + +Nothing here imports :mod:`homeassistant`. +""" +from __future__ import annotations + +import re +import unicodedata + +ICON_NAMESPACE_LUCIDE = "lucide" +ICON_NAMESPACE_FA = "fa" +ICON_NAMESPACE_EMOJI = "emoji" +ICON_NAMESPACES = frozenset( + {ICON_NAMESPACE_LUCIDE, ICON_NAMESPACE_FA, ICON_NAMESPACE_EMOJI} +) + +#: Upper bound on an emoji payload's length in code points β€” enough for +#: ZWJ sequences such as a family emoji, too short for arbitrary text. +EMOJI_MAX_CODE_POINTS = 8 + +_KEBAB_NAME = re.compile(r"[a-z0-9](?:[a-z0-9-]*[a-z0-9])?") +_EMOJI_FORBIDDEN = frozenset("<>&") + + +def _is_kebab_name(value: str) -> bool: + return _KEBAB_NAME.fullmatch(value) is not None + + +def _is_valid_emoji_payload(value: str) -> bool: + if not value or len(value) > EMOJI_MAX_CODE_POINTS: + return False + return not any( + char.isspace() + or char in _EMOJI_FORBIDDEN + or unicodedata.category(char) == "Cc" + for char in value + ) + + +def validate_icon(value: object) -> str | None: + """Return the canonical namespaced icon key, or raise naming ``icon``. + + ``None`` or an empty/whitespace-only string returns ``None`` (the + icon is cleared). Otherwise the raw value is validated untrimmed, so + edge whitespace or control characters are rejected rather than + silently stripped. A value with no ``:`` is a legacy bare Lucide + name and is stored as ``lucide:``. An unknown namespace, an + empty or malformed name, or an invalid emoji payload raises + ValueError. + """ + if value is None: + return None + if not isinstance(value, str): + raise ValueError(f"icon must be a string, got {value!r}") + if not value.strip(): + return None + + namespace, separator, name = value.partition(":") + if not separator: + if not _is_kebab_name(value): + raise ValueError( + f"icon must be a lowercase kebab name, got {value!r}" + ) + return f"{ICON_NAMESPACE_LUCIDE}:{value}" + + if namespace not in ICON_NAMESPACES: + raise ValueError( + f"icon has an unknown namespace {namespace!r}; expected one of " + f"{sorted(ICON_NAMESPACES)}" + ) + if namespace == ICON_NAMESPACE_EMOJI: + if not _is_valid_emoji_payload(name): + raise ValueError( + f"icon emoji must be 1-{EMOJI_MAX_CODE_POINTS} code points " + f"with no whitespace, control characters or markup, " + f"got {value!r}" + ) + elif not _is_kebab_name(name): + raise ValueError( + f"icon {namespace} name must be a lowercase kebab name, " + f"got {value!r}" + ) + return value + + +def normalize_icon_for_read(value: str | None) -> str | None: + """Return a stored icon in namespaced form for output. + + Lenient: ``None``/empty returns ``None``, a recognised namespace is + returned unchanged, and anything else becomes ``lucide:`` so + legacy rows render (an unknown name falls through to the frontend's + fallback icon rather than erroring). + """ + if not value or not value.strip(): + return None + namespace, separator, _ = value.partition(":") + if separator and namespace in ICON_NAMESPACES: + return value + return f"{ICON_NAMESPACE_LUCIDE}:{value}" diff --git a/custom_components/nestquest/core/quest_definitions.py b/custom_components/nestquest/core/quest_definitions.py index 8078ce2..58903eb 100644 --- a/custom_components/nestquest/core/quest_definitions.py +++ b/custom_components/nestquest/core/quest_definitions.py @@ -59,6 +59,7 @@ from .dao_rules import ( schedule_rule_to_storage, ) from .db import NestQuestDatabase +from .icons import validate_icon from .materialize import regenerate_for_definition from .recurrence import ScheduleRule, occurs_on @@ -248,7 +249,7 @@ async def create_quest_definition( assignee_ids = _validate_assignee_ids(assignee_child_ids) window_specs = _normalize_windows(windows) description_value = _validate_text(description, "description") - icon_value = _validate_text(icon, "icon") + icon_value = validate_icon(icon) skip_on_away_value = _validate_skip_on_away(skip_on_away) storage = schedule_rule_to_storage(rule) @@ -351,7 +352,7 @@ async def edit_quest_definition( icon_value: str | None | object = _UNSET if icon is not _UNSET: - icon_value = _validate_text(icon, "icon") + icon_value = validate_icon(icon) rule_storage: ScheduleRuleStorage | object = _UNSET if rule is not _UNSET: diff --git a/custom_components/nestquest/core/snapshot.py b/custom_components/nestquest/core/snapshot.py index 662a2f9..88679be 100644 --- a/custom_components/nestquest/core/snapshot.py +++ b/custom_components/nestquest/core/snapshot.py @@ -42,6 +42,7 @@ from .dao_instances import CompletionEventsDao, QuestInstancesDao from .dao_presence import PresenceOverridesDao, PresencePatternsDao from .dao_rules import QuestDefinitionsDao from .db import NestQuestDatabase +from .icons import normalize_icon_for_read from .presence import ( MAX_PREVIEW_SCAN_DAYS, PresenceEngine, @@ -262,7 +263,11 @@ async def build_snapshot( definition_id=instance.definition_id, child_id=instance.child_id, title=definition.title if definition else "Quest", - icon=definition.icon if definition else None, + icon=( + normalize_icon_for_read(definition.icon) + if definition + else None + ), window=instance.window, due_date=instance.due_date, due_time=instance.due_time, diff --git a/tests/test_api_admin_questdef.py b/tests/test_api_admin_questdef.py index 3a0f398..8850b43 100644 --- a/tests/test_api_admin_questdef.py +++ b/tests/test_api_admin_questdef.py @@ -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:", "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: diff --git a/tests/test_api_admin_questdef_list.py b/tests/test_api_admin_questdef_list.py index 0584695..d9afb63 100644 --- a/tests/test_api_admin_questdef_list.py +++ b/tests/test_api_admin_questdef_list.py @@ -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": { diff --git a/tests/test_core_icons.py b/tests/test_core_icons.py new file mode 100644 index 0000000..de454d5 --- /dev/null +++ b/tests/test_core_icons.py @@ -0,0 +1,111 @@ +"""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 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", + "