Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6b1b293dd1 | ||
|
|
de76b6e11a | ||
|
|
7eed8fdbf1 |
@@ -0,0 +1,31 @@
|
||||
# Third-party notices
|
||||
|
||||
NestQuest bundles glyph data from the icon sets below. The curated names are listed in
|
||||
`tools/icons/curated-icons.json`; `tools/icons/generate.mjs` reads each glyph's SVG path
|
||||
data from the locally installed packages and generates it into both bundles
|
||||
(`admin/src/icons/registry.generated.ts` and `frontend/src/icons/registry.generated.ts`).
|
||||
Nothing is loaded from a CDN or fetched at runtime. The package versions used are recorded
|
||||
in the header of each generated file.
|
||||
|
||||
## Font Awesome Free
|
||||
|
||||
- Project: <https://fontawesome.com> · repository: <https://github.com/FortAwesome/Font-Awesome>
|
||||
- Package: `@fortawesome/free-solid-svg-icons`
|
||||
- Licence: <https://fontawesome.com/license/free>
|
||||
- Icons: [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/)
|
||||
- Fonts: [SIL OFL 1.1](https://openfontlicense.org)
|
||||
- Code: [MIT](https://opensource.org/licenses/MIT)
|
||||
- Use in NestQuest: a curated subset of the Free **solid** icon set, embedded as SVG path
|
||||
data. No fonts are bundled.
|
||||
|
||||
Font Awesome Free by @fontawesome — https://fontawesome.com — icons licensed under CC BY 4.0.
|
||||
|
||||
## Lucide
|
||||
|
||||
- Project: <https://lucide.dev> · repository: <https://github.com/lucide-icons/lucide>
|
||||
- Package: `lucide-react`
|
||||
- Licence: [ISC](https://github.com/lucide-icons/lucide/blob/main/LICENSE)
|
||||
- Use in NestQuest: a curated subset of Lucide icons, embedded as SVG path data.
|
||||
|
||||
Lucide icons are Copyright (c) Lucide Contributors, licensed under the ISC License.
|
||||
Portions derived from Feather are Copyright (c) Cole Bemis, licensed under the MIT License.
|
||||
@@ -161,6 +161,10 @@ function fakeApi(initial: QuestDefinition[]) {
|
||||
}
|
||||
const match = /^\/api\/v1\/admin\/quest-definitions\/(\d+)$/.exec(path);
|
||||
if (method === "PATCH" && match) {
|
||||
// Like the API's strict validate_icon: a Lucide name never has a colon.
|
||||
if (typeof body.icon === "string" && /^lucide:.*:/.test(body.icon)) {
|
||||
return jsonResponse({ detail: "icon is not a valid icon key" }, 422);
|
||||
}
|
||||
const id = Number(match[1]);
|
||||
definitions = definitions.map((d) =>
|
||||
d.id === id
|
||||
@@ -463,7 +467,6 @@ describe("DefinitionsTab", () => {
|
||||
path: "/api/v1/admin/quest-definitions/11",
|
||||
body: {
|
||||
title: "Take out the bins",
|
||||
icon: null,
|
||||
rule: {
|
||||
rule_type: "monthly_day",
|
||||
interval: 1,
|
||||
@@ -1087,7 +1090,7 @@ describe("DefinitionsTab icon picker", () => {
|
||||
await waitFor(() => expect(rowIcon(screen.getByTestId("definition-11"))).toBe("trash-2"));
|
||||
});
|
||||
|
||||
it("an unchanged icon is still sent on edit and shows as selected", async () => {
|
||||
it("an unchanged icon is omitted on edit and shows as selected", async () => {
|
||||
await renderReady();
|
||||
expect(rowIcon(screen.getByTestId("definition-10"))).toBe("smile");
|
||||
fireEvent.click(screen.getByTestId("definition-10"));
|
||||
@@ -1101,7 +1104,7 @@ describe("DefinitionsTab icon picker", () => {
|
||||
fireEvent.click(within(s).getByRole("button", { name: "Save changes" }));
|
||||
});
|
||||
|
||||
expect((api.writes()[0].body as { icon: string }).icon).toBe("smile");
|
||||
expect(api.writes()[0].body).not.toHaveProperty("icon");
|
||||
});
|
||||
|
||||
it("an unknown legacy icon falls back to the generic glyph and is kept on save", async () => {
|
||||
@@ -1121,7 +1124,7 @@ describe("DefinitionsTab icon picker", () => {
|
||||
fireEvent.click(within(s).getByRole("button", { name: "Save changes" }));
|
||||
});
|
||||
|
||||
expect((api.writes()[0].body as { icon: string }).icon).toBe("rocket-ship-9000");
|
||||
expect(api.writes()[0].body).not.toHaveProperty("icon");
|
||||
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
|
||||
expect(rowIcon(screen.getByTestId("definition-20"))).toBeNull();
|
||||
});
|
||||
@@ -1408,7 +1411,7 @@ describe("DefinitionsTab Font Awesome and emoji icons", () => {
|
||||
await waitFor(() => expect(rowGlyph(screen.getByTestId("definition-52"))).toBe("fa:paw"));
|
||||
});
|
||||
|
||||
it("an unchanged emoji is sent as-is on edit", async () => {
|
||||
it("an unchanged emoji is omitted on edit", async () => {
|
||||
await renderReady([{ ...BRUSH, id: 53, icon: "emoji:🐶" }]);
|
||||
fireEvent.click(screen.getByTestId("definition-53"));
|
||||
const s = sheet();
|
||||
@@ -1416,7 +1419,7 @@ describe("DefinitionsTab Font Awesome and emoji icons", () => {
|
||||
target: { value: "Feed the dog" },
|
||||
});
|
||||
await save(s);
|
||||
expect((api.writes()[0].body as { icon: string }).icon).toBe("emoji:🐶");
|
||||
expect(api.writes()[0].body).not.toHaveProperty("icon");
|
||||
});
|
||||
|
||||
it("No icon and an emptied emoji field both clear the icon to null", async () => {
|
||||
@@ -1442,6 +1445,110 @@ describe("DefinitionsTab Font Awesome and emoji icons", () => {
|
||||
});
|
||||
});
|
||||
|
||||
/* ── Legacy icons ───────────────────────────────────────────────────── */
|
||||
|
||||
describe("DefinitionsTab legacy icon edits", () => {
|
||||
// The API reads a stored legacy `mdi:tooth` back as `lucide:mdi:tooth`,
|
||||
// which its strict write validation rejects if sent back.
|
||||
const LEGACY: QuestDefinition = { ...BRUSH, id: 60, icon: "lucide:mdi:tooth" };
|
||||
|
||||
beforeEach(() => {
|
||||
sessionStorage.clear();
|
||||
storeTokens({ access_token: "at-123", expires_in: 600 });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.unstubAllGlobals();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
function radio(s: HTMLElement, group: "Icon" | "Font Awesome", label: string): HTMLElement {
|
||||
return within(within(s).getByRole("radiogroup", { name: group })).getByRole("radio", {
|
||||
name: label,
|
||||
});
|
||||
}
|
||||
|
||||
async function save(s: HTMLElement) {
|
||||
await act(async () => {
|
||||
fireEvent.click(within(s).getByRole("button", { name: "Save changes" }));
|
||||
});
|
||||
}
|
||||
|
||||
it("editing only the title omits the legacy icon and saves", async () => {
|
||||
await renderReady([LEGACY]);
|
||||
fireEvent.click(screen.getByTestId("definition-60"));
|
||||
const s = sheet();
|
||||
fireEvent.change(within(s).getByRole("textbox", { name: "Title" }), {
|
||||
target: { value: "Brush teeth well" },
|
||||
});
|
||||
await save(s);
|
||||
|
||||
expect(api.writes()).toHaveLength(1);
|
||||
expect(api.writes()[0].method).toBe("PATCH");
|
||||
expect(api.writes()[0].body).not.toHaveProperty("icon");
|
||||
expect((api.writes()[0].body as { title: string }).title).toBe("Brush teeth well");
|
||||
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
|
||||
expect(await screen.findByText("Brush teeth well")).toBeTruthy();
|
||||
});
|
||||
|
||||
for (const [label, choose, sent] of [
|
||||
["a Lucide", (s: HTMLElement) => fireEvent.click(radio(s, "Icon", "Dog")), "lucide:dog"],
|
||||
["a Font Awesome", (s: HTMLElement) => fireEvent.click(radio(s, "Font Awesome", "Dog")), "fa:dog"],
|
||||
[
|
||||
"an emoji",
|
||||
(s: HTMLElement) =>
|
||||
fireEvent.change(within(s).getByRole("textbox", { name: "Emoji" }), {
|
||||
target: { value: "🦷" },
|
||||
}),
|
||||
"emoji:🦷",
|
||||
],
|
||||
["No icon", (s: HTMLElement) => fireEvent.click(radio(s, "Icon", "No icon")), null],
|
||||
] as const) {
|
||||
it(`choosing ${label} replacement for the legacy icon sends ${JSON.stringify(sent)}`, async () => {
|
||||
await renderReady([LEGACY]);
|
||||
fireEvent.click(screen.getByTestId("definition-60"));
|
||||
const s = sheet();
|
||||
choose(s);
|
||||
await save(s);
|
||||
|
||||
expect(api.writes()).toHaveLength(1);
|
||||
expect(api.writes()[0].body).toHaveProperty("icon", sent);
|
||||
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
|
||||
});
|
||||
}
|
||||
|
||||
it("an invalid emoji replacing the legacy icon is still rejected before the request", async () => {
|
||||
await renderReady([LEGACY]);
|
||||
fireEvent.click(screen.getByTestId("definition-60"));
|
||||
const s = sheet();
|
||||
fireEvent.change(within(s).getByRole("textbox", { name: "Emoji" }), {
|
||||
target: { value: "a b" },
|
||||
});
|
||||
await save(s);
|
||||
|
||||
expect(api.writes()).toHaveLength(0);
|
||||
expect(within(s).getByText(/Enter a single emoji/)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("creating a task still sends icon, even when none is chosen", async () => {
|
||||
await renderReady([LEGACY]);
|
||||
fireEvent.click(screen.getByRole("button", { name: "New" }));
|
||||
const s = sheet();
|
||||
fireEvent.change(within(s).getByRole("textbox", { name: "Title" }), {
|
||||
target: { value: "Floss" },
|
||||
});
|
||||
fireEvent.click(within(s).getByRole("button", { name: /Assigned children/ }));
|
||||
fireEvent.click(within(s).getByRole("checkbox", { name: "Declan" }));
|
||||
fireEvent.click(within(s).getByRole("button", { name: "Morning" }));
|
||||
await save(s);
|
||||
|
||||
expect(api.writes()).toHaveLength(1);
|
||||
expect(api.writes()[0].method).toBe("POST");
|
||||
expect(api.writes()[0].body).toHaveProperty("icon", null);
|
||||
});
|
||||
});
|
||||
|
||||
/* ── Start date ─────────────────────────────────────────────────────── */
|
||||
|
||||
function startDateInput(s: HTMLElement): HTMLInputElement {
|
||||
|
||||
@@ -462,6 +462,10 @@ function EditSheet({
|
||||
// assignee_child_ids, because the API replaces the whole set and inactive
|
||||
// children cannot be sent back.
|
||||
const [originalActiveIds] = useState(() => draft.assigneeIds);
|
||||
// The icon as loaded: while it is unchanged, the PATCH omits `icon` (absent
|
||||
// means unchanged), because a legacy value read back as `lucide:<old>` would
|
||||
// fail the API's strict icon validation if sent back.
|
||||
const [originalIcon] = useState(() => draft.icon);
|
||||
const inactiveAssignees =
|
||||
target === "new"
|
||||
? []
|
||||
@@ -544,6 +548,7 @@ function EditSheet({
|
||||
} else {
|
||||
const edit: DefinitionEditBody = { ...body };
|
||||
if (keepsAssignees) delete edit.assignee_child_ids;
|
||||
if (draft.icon === originalIcon) delete edit.icon;
|
||||
await updateDefinition(target.id, edit);
|
||||
}
|
||||
onSaved();
|
||||
|
||||
@@ -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[] {
|
||||
|
||||
@@ -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:
|
||||
|
||||
+27
-2
@@ -84,8 +84,33 @@ Footer: `Cancel` (flex 1, bordered) + `Save changes` (flex 2, gradient), both 13
|
||||
padding. Sheet enters over the dimmed list in `--nq-dur-modal`.
|
||||
|
||||
### 3.3 Icon picker
|
||||
Each definition carries a Lucide glyph, shown on both surfaces. **The allowed subset is not
|
||||
yet defined** — pick 24–40 household-relevant glyphs and ship them as a grid picker.
|
||||
Each definition carries one optional icon, shown on both surfaces. The picker offers three
|
||||
sources:
|
||||
|
||||
- **Lucide** — a grid of the curated Lucide glyphs, drawn stroked.
|
||||
- **Font Awesome** — a grid of a curated subset of the Font Awesome Free *solid* set,
|
||||
drawn filled.
|
||||
- **Emoji** — a text input; the typed emoji is stored as entered (not trimmed) and must be
|
||||
1–8 code points with no whitespace, control characters, `<`, `>` or `&`.
|
||||
|
||||
Both curated lists come from `tools/icons/curated-icons.json`; their glyph path data is
|
||||
generated into the admin and panel bundles by `tools/icons/generate.mjs` (no CDN, no
|
||||
runtime fetch). See `THIRD-PARTY-NOTICES.md` for licences.
|
||||
|
||||
The stored value is a **namespaced key**:
|
||||
|
||||
| Source | Stored key | Example |
|
||||
|---|---|---|
|
||||
| Lucide | `lucide:<name>` | `lucide:dog` |
|
||||
| Font Awesome | `fa:<name>` | `fa:paw` |
|
||||
| Emoji | `emoji:<grapheme>` | `emoji:🐶` |
|
||||
|
||||
`<name>` is a lowercase kebab name (`[a-z0-9-]`, no leading or trailing `-`). A **legacy
|
||||
bare name** written before namespacing (`dog`) reads as `lucide:<name>` — the API returns it
|
||||
namespaced, and any write of a bare name is stored as `lucide:<name>`; no data migration. An
|
||||
empty value clears the icon. An empty, unknown, or unresolvable key renders the **fallback
|
||||
glyph** — in the admin, the generic task glyph (clipboard with a check); on the panel, the
|
||||
star (`PANEL-SPEC.md` §3.2).
|
||||
|
||||
## 4. Schedule (1f)
|
||||
|
||||
|
||||
@@ -78,5 +78,6 @@ The panel speaks tabletop; the admin speaks plainly. Both sit on the same `quest
|
||||
decide before shipping the card.
|
||||
- No gamification is in scope. XP, levels, streaks and rarity were explicitly deferred,
|
||||
but the append-only event log already supports computing them later.
|
||||
- Icon per quest definition is chosen by the parent from a Lucide subset; the subset is
|
||||
not yet defined (`ADMIN-SPEC.md` §3.3).
|
||||
- Icon per quest definition is chosen by the parent from curated Lucide and Font Awesome
|
||||
Free sets or as an emoji, stored as a namespaced key (`lucide:`/`fa:`/`emoji:`) —
|
||||
resolved 2026-09-26, see `ADMIN-SPEC.md` §3.3.
|
||||
|
||||
@@ -35,7 +35,7 @@ implementer's call), shaped:
|
||||
{
|
||||
"instances": [
|
||||
{ "id": 4821, "definition_id": 12, "child_id": 3, "title": "Fill bird feeder",
|
||||
"icon": "bird", "window": "afternoon", "due_time": "17:00",
|
||||
"icon": "lucide:bird", "window": "afternoon", "due_time": "17:00",
|
||||
"state": "open", "overdue": true, "completed_at": null, "on_time": null }
|
||||
]
|
||||
}
|
||||
|
||||
+11
-2
@@ -91,7 +91,14 @@ collapse the column, the three-column rhythm is the layout.
|
||||
`--nq-p-quest-min-height` 116px, radius 12px, `--nq-p-card-open`, 1px
|
||||
`--nq-p-card-border`, `--nq-p-card-shadow`, padding 22px, 22px gap, row layout:
|
||||
|
||||
1. **Icon tile** 64×64, radius 10px, `--nq-p-icon-tile`, Lucide glyph 34px white.
|
||||
1. **Icon tile** 64×64, radius 10px, `--nq-p-icon-tile`, glyph 34px white — the
|
||||
definition's icon, resolved from its namespaced key (`ADMIN-SPEC.md` §3.3):
|
||||
`lucide:<name>` draws the curated Lucide glyph **stroked** (fill none, stroke-width 2,
|
||||
round caps and joins); `fa:<name>` draws the curated Font Awesome glyph **filled**;
|
||||
`emoji:<grapheme>` renders the emoji **as text**. A legacy bare name resolves as
|
||||
`lucide:<name>`. No icon, or any key that does not resolve (unknown namespace or name,
|
||||
malformed emoji), falls back to the **star** glyph. Glyph data is bundled from the
|
||||
shared registry — nothing is fetched at runtime.
|
||||
2. **Text** title 30px/700 Cinzel `--nq-p-ink`; meta 22px/600 Nunito
|
||||
`--nq-p-ink-secondary` reading `Due by <h:mm A>`. If past due:
|
||||
`Overdue · due <h:mm A>` in 22px/700 Cinzel uppercase, tracking .10em,
|
||||
@@ -106,7 +113,9 @@ Both the card body and the button open the confirm dialog (§4). Press feedback:
|
||||
### 3.3 Quest card — sealed
|
||||
|
||||
`--nq-p-card-done`, `opacity: .82`, no shadow, **not tappable**. Icon tile
|
||||
`--nq-p-icon-tile-done` with the glyph in `--nq-p-ink-secondary`. Title 30px/700 Cinzel
|
||||
`--nq-p-icon-tile-done` with the glyph in `--nq-p-ink-secondary`. The sealed tile keeps
|
||||
the static star glyph — it does **not** render the definition icon — alongside the wax
|
||||
seal below. Title 30px/700 Cinzel
|
||||
`--nq-p-ink-muted` with `text-decoration: line-through`. Meta reads
|
||||
`Claimed <h:mm A>` 22px/600 Nunito `--nq-p-ink-secondary`.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 &" }
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user