Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
430f8abc12 | ||
|
|
a64b619bee | ||
|
|
7af0b5a6cc |
@@ -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:<name>, 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 {
|
||||
|
||||
@@ -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({
|
||||
>
|
||||
<TaskGlyph size={18} />
|
||||
</button>
|
||||
{DEFINITION_ICONS.map(({ name, label, Icon }) => (
|
||||
<button
|
||||
key={name}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={draft.icon === name}
|
||||
aria-label={label}
|
||||
data-icon-option={name}
|
||||
className={draft.icon === name ? "defs-icon defs-icon--on" : "defs-icon"}
|
||||
onClick={() => update({ icon: name })}
|
||||
>
|
||||
<Icon size={18} aria-hidden="true" focusable="false" />
|
||||
</button>
|
||||
))}
|
||||
{DEFINITION_ICONS.map(({ name, label, Icon }) => {
|
||||
const on = normalizeDefinitionIconName(draft.icon) === name;
|
||||
return (
|
||||
<button
|
||||
key={name}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={on}
|
||||
aria-label={label}
|
||||
data-icon-option={name}
|
||||
className={on ? "defs-icon defs-icon--on" : "defs-icon"}
|
||||
onClick={() => update({ icon: lucideIconKey(name) })}
|
||||
>
|
||||
<Icon size={18} aria-hidden="true" focusable="false" />
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Field>
|
||||
|
||||
|
||||
@@ -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:<kebab-name>`; a legacy bare `<kebab-name>`
|
||||
* 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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
+2
-1
@@ -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),
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
"""Namespaced quest-definition icon keys.
|
||||
|
||||
A definition's ``icon`` is stored as ``<namespace>:<name>`` where the
|
||||
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 ``<``, ``>``, ``&``.
|
||||
|
||||
Rows written before namespacing hold a bare Lucide name (``dog``).
|
||||
:func:`validate_icon` (writes) normalizes such a legacy value to
|
||||
``lucide:<name>``; :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:<value>``. 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:<value>`` 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}"
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -22,14 +22,10 @@ Screens `1a`, `1b`, `1j`, `1c` of the mockups. Two cards:
|
||||
|
||||
Default view on the wall. Layout top to bottom:
|
||||
|
||||
1. **Wordmark row** at y=64. Rule · monogram · `NestQuest` (66px/900 Cinzel Decorative
|
||||
display, brand gradient via `background-clip: text`) · rule — the horizontal lockup of
|
||||
`design/logos/NestQuest_Logo.png`. The monogram is an 80px inline SVG (no raster, no
|
||||
remote asset): a compass rose — four long cardinal points, four short diagonal points,
|
||||
a ring broken by the points — around a d20 hexagon with facet lines and a knocked-out
|
||||
Cinzel Decorative "N", filled with the brand gradient (`--nq-brand-blue` →
|
||||
`--nq-brand-purple`, 135°). Gaps are SVG masks, so the mark stays transparent over the
|
||||
parchment.
|
||||
1. **Wordmark row** at y=64. Rule · d20 hexagon · `NestQuest` (66px/900 display, brand
|
||||
gradient via `background-clip: text`) · d20 · rule. d20 marks are 34px hexagons
|
||||
(`clip-path: polygon(50% 0, 93% 25%, 93% 75%, 50% 100%, 7% 75%, 7% 25%)`) filled with
|
||||
the brand gradient, numeral 15px/700 Cinzel.
|
||||
2. **Kicker** `The Party · <weekday>, <Month D>` — 21px/600 Cinzel, tracking .30em, uppercase.
|
||||
3. **Adventurer plates**, 3-up grid, 56px gap, page inset 110px, top y=262. One plate per
|
||||
child returned by the party sensor, in configured order.
|
||||
|
||||
@@ -231,76 +231,8 @@ function conditionPhrase(condition: string, label: string): string {
|
||||
}
|
||||
|
||||
const SHIELD_CLIP = "polygon(0 0, 100% 0, 100% 62%, 50% 100%, 0 62%)";
|
||||
// The brand monogram (design/logos/NestQuest_Logo.png): a compass rose —
|
||||
// four long cardinal points, four short diagonal points, a ring broken by
|
||||
// the points — around a d20 hexagon carrying a knocked-out "N". Drawn on
|
||||
// a 100-unit grid and filled with the brand gradient; the masks cut the
|
||||
// parchment gaps so the mark stays transparent on any backdrop.
|
||||
const MONOGRAM_POINTS =
|
||||
"M50 3 44.5 24.1 50 26 55.5 24.1Z M100 50 75.9 44.5 74 50 75.9 55.5Z " +
|
||||
"M50 97 55.5 75.9 50 74 44.5 75.9Z M0 50 24.1 55.5 26 50 24.1 44.5Z " +
|
||||
"M29.1 29.1 29.7 36.3 36.3 29.7Z M70.9 29.1 63.7 29.7 70.3 36.3Z " +
|
||||
"M70.9 70.9 70.3 63.7 63.7 70.3Z M29.1 70.9 36.3 70.3 29.7 63.7Z";
|
||||
const MONOGRAM_HEX = "M50 26.5 70.4 38.2 70.4 61.8 50 73.5 29.6 61.8 29.6 38.2Z";
|
||||
const MONOGRAM_FACETS =
|
||||
"M29.6 38.2H70.4M29.6 61.8H70.4M50 26.5 36.5 38.2M50 26.5 63.5 38.2" +
|
||||
"M50 73.5 36.5 61.8M50 73.5 63.5 61.8M36.5 38.2V61.8M63.5 38.2V61.8" +
|
||||
"M29.6 38.2 36.5 61.8M36.5 38.2 29.6 61.8" +
|
||||
"M70.4 38.2 63.5 61.8M63.5 38.2 70.4 61.8";
|
||||
const MONOGRAM = html`<svg
|
||||
class="monogram"
|
||||
viewBox="0 0 100 100"
|
||||
aria-hidden="true"
|
||||
focusable="false"
|
||||
>
|
||||
<defs>
|
||||
<linearGradient
|
||||
id="nq-monogram-fill"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
x1="0"
|
||||
y1="0"
|
||||
x2="100"
|
||||
y2="100"
|
||||
>
|
||||
<stop offset="0" style="stop-color: var(--nq-brand-blue)" />
|
||||
<stop offset="1" style="stop-color: var(--nq-brand-purple)" />
|
||||
</linearGradient>
|
||||
<mask id="nq-monogram-points-gap" maskUnits="userSpaceOnUse">
|
||||
<rect width="100" height="100" fill="#fff" />
|
||||
<path d=${MONOGRAM_POINTS} stroke="#000" stroke-width="3.5" />
|
||||
</mask>
|
||||
<mask id="nq-monogram-facets" maskUnits="userSpaceOnUse">
|
||||
<rect width="100" height="100" fill="#fff" />
|
||||
<path d=${MONOGRAM_FACETS} stroke="#000" stroke-width="1.6" />
|
||||
<text
|
||||
x="50"
|
||||
y="59.4"
|
||||
text-anchor="middle"
|
||||
font-family="'Cinzel Decorative', Cinzel, serif"
|
||||
font-weight="900"
|
||||
font-size="25"
|
||||
fill="#fff"
|
||||
stroke="#000"
|
||||
stroke-width="2"
|
||||
paint-order="stroke"
|
||||
>N</text>
|
||||
</mask>
|
||||
</defs>
|
||||
<g fill="url(#nq-monogram-fill)">
|
||||
<path d=${MONOGRAM_POINTS} />
|
||||
<g mask="url(#nq-monogram-points-gap)">
|
||||
<circle
|
||||
cx="50"
|
||||
cy="50"
|
||||
r="34.25"
|
||||
fill="none"
|
||||
stroke="url(#nq-monogram-fill)"
|
||||
stroke-width="6.5"
|
||||
/>
|
||||
<path mask="url(#nq-monogram-facets)" d=${MONOGRAM_HEX} />
|
||||
</g>
|
||||
</g>
|
||||
</svg>`;
|
||||
const HEXAGON_CLIP =
|
||||
"polygon(50% 0, 93% 25%, 93% 75%, 50% 100%, 7% 75%, 7% 25%)";
|
||||
|
||||
const boardStyles = css`
|
||||
* {
|
||||
@@ -367,11 +299,23 @@ const boardStyles = css`
|
||||
background: rgba(92, 62, 26, 0.35);
|
||||
}
|
||||
|
||||
.monogram {
|
||||
.d20 {
|
||||
flex: none;
|
||||
display: block;
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
clip-path: ${unsafeCSS(HEXAGON_CLIP)};
|
||||
background: var(--nq-brand-gradient);
|
||||
}
|
||||
|
||||
.d20-numeral {
|
||||
font-family: var(--nq-p-font-heading);
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.wordmark {
|
||||
@@ -553,12 +497,11 @@ const boardStyles = css`
|
||||
|
||||
.hint {
|
||||
position: absolute;
|
||||
top: 896px;
|
||||
top: 930px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
margin: 0;
|
||||
font-family: var(--nq-p-font-body);
|
||||
font-size: 27px;
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
line-height: 1.4;
|
||||
text-align: center;
|
||||
@@ -918,8 +861,13 @@ export class NestQuestPartyBoardCard extends LitElement {
|
||||
<div class="frame frame-inner"></div>
|
||||
<div class="wordmark-row">
|
||||
<span class="rule"></span>
|
||||
${MONOGRAM}
|
||||
<span class="d20" aria-hidden="true">
|
||||
<span class="d20-numeral">20</span>
|
||||
</span>
|
||||
<span class="wordmark">NestQuest</span>
|
||||
<span class="d20" aria-hidden="true">
|
||||
<span class="d20-numeral">20</span>
|
||||
</span>
|
||||
<span class="rule"></span>
|
||||
</div>
|
||||
<p class="kicker">The Party · ${formatDay(this._now, this._timeZone())}</p>
|
||||
|
||||
@@ -378,7 +378,7 @@ const logStyles = css`
|
||||
flex: none;
|
||||
width: 96px;
|
||||
height: 110px;
|
||||
padding: 4px;
|
||||
padding: 3px;
|
||||
clip-path: ${unsafeCSS(SHIELD_CLIP)};
|
||||
background: rgba(255, 255, 255, 0.22);
|
||||
}
|
||||
@@ -1938,7 +1938,7 @@ export class NestQuestQuestLogCard extends LitElement {
|
||||
const initial = (name?.charAt(0) || "?").toUpperCase();
|
||||
return html`
|
||||
<header class="header">
|
||||
<span class="crest${away ? " away" : ""}" aria-hidden="true">
|
||||
<span class="crest${away ? " away" : nothing}" aria-hidden="true">
|
||||
<span class="crest-face">
|
||||
<span class="initial">${initial}</span>
|
||||
</span>
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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",
|
||||
"<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
|
||||
# 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
|
||||
@@ -411,36 +411,6 @@ def test_panel_hard_rules_hold_in_the_card_sources() -> None:
|
||||
assert "@click" not in sealed_template
|
||||
|
||||
|
||||
def test_party_board_hint_clears_the_dock() -> None:
|
||||
"""PANEL-SPEC §2/§2.3: the hint line reads fully above the dock at
|
||||
1920x1080. The hint's line box (top + font-size x line-height) must
|
||||
end above the dock's top edge (1080 - bottom inset - dock height)
|
||||
with a visible gap, at the 27px/600 design size. The hint is a <p>,
|
||||
so its user-agent 1em top margin must be reset or it shifts the box
|
||||
down by a whole font-size."""
|
||||
style = _style_literal(_read(PARTY_BOARD), STYLE_CONSTANTS[PARTY_BOARD])
|
||||
rules = {selector.strip(): body for selector, body in _css_rules(style)}
|
||||
sizes = _size_tokens(_read(REWARD_TOKENS))
|
||||
|
||||
def px(body: str, prop: str) -> float:
|
||||
declared = re.search(rf"(?<![-\w]){prop}:\s*([^;]+);", body)
|
||||
assert declared is not None, prop
|
||||
value = _font_size_px(declared.group(1), sizes)
|
||||
assert value is not None, f"{prop}: {declared.group(1)}"
|
||||
return value
|
||||
|
||||
board, hint, dock = rules[".board"], rules[".hint"], rules[".dock"]
|
||||
assert "margin: 0;" in hint
|
||||
line_height = float(re.search(r"line-height:\s*([\d.]+);", hint).group(1))
|
||||
hint_bottom = px(hint, "top") + px(hint, "font-size") * line_height
|
||||
dock_top = px(board, "height") - px(dock, "bottom") - px(dock, "height")
|
||||
assert px(dock, "height") == 84
|
||||
assert px(dock, "bottom") == 46
|
||||
assert hint_bottom + 12 <= dock_top, (hint_bottom, dock_top)
|
||||
assert px(hint, "font-size") == 27
|
||||
assert "font-weight: 600;" in hint
|
||||
|
||||
|
||||
def test_panel_text_sizes_respect_the_22px_floor() -> None:
|
||||
"""PANEL-SPEC §1: no body text below 22px. Every font-size in the
|
||||
panel styles resolves against the panel size tokens or an explicit
|
||||
@@ -600,40 +570,6 @@ def _render_party_board(
|
||||
return json.loads(completed.stdout)
|
||||
|
||||
|
||||
def test_party_board_wordmark_row_is_the_monogram_lockup() -> None:
|
||||
"""PANEL-SPEC §2 item 1: the wordmark row is rule · compass-rose
|
||||
monogram · NestQuest · rule. The monogram is an inline SVG filled
|
||||
with the brand gradient — the old d20 hexagon pair and its "20"
|
||||
numerals are gone, and nothing is fetched from a remote URL or
|
||||
embedded as a raster logo."""
|
||||
source = _read(PARTY_BOARD)
|
||||
assert "d20" not in _style_literal(source, STYLE_CONSTANTS[PARTY_BOARD])
|
||||
assert 'class="d20' not in source
|
||||
assert "HEXAGON_CLIP" not in source
|
||||
|
||||
html = _render_party_board(
|
||||
{"type": "custom:nestquest-party-board-card"},
|
||||
_three_child_states(),
|
||||
)["html"]
|
||||
row = re.search(r'<div class="wordmark-row">(.*?)</div>', html, re.DOTALL)
|
||||
assert row is not None
|
||||
row_html = row.group(1)
|
||||
classes = re.findall(r'<(?:span|svg) class="([^"]+)"', row_html)
|
||||
assert classes == ["rule", "monogram", "wordmark", "rule"]
|
||||
assert '<svg class="monogram" viewBox="0 0 100 100" aria-hidden="true"' in row_html
|
||||
assert "var(--nq-brand-blue)" in row_html
|
||||
assert "var(--nq-brand-purple)" in row_html
|
||||
assert ">N</text>" in row_html
|
||||
assert ">NestQuest</span>" in row_html
|
||||
assert "d20" not in row_html
|
||||
assert "<img" not in row_html
|
||||
|
||||
bundle = _read(BUNDLE_PATH)
|
||||
assert "d20-numeral\">20<" not in bundle
|
||||
assert "data:image/png" not in bundle
|
||||
assert not re.search(r"https?://", bundle)
|
||||
|
||||
|
||||
def test_party_board_renders_discovered_children_in_sort_order() -> None:
|
||||
"""No child_order key: three plates render in the roster's sort
|
||||
order (the coordinator snapshot's order), one per child."""
|
||||
@@ -950,71 +886,3 @@ 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"
|
||||
|
||||
|
||||
def test_quest_log_crest_keeps_its_shield_classes() -> None:
|
||||
"""PANEL-SPEC §3 header crest: a present child's crest renders as the
|
||||
styled shield, not a bare span. Lit removes an attribute outright when
|
||||
any interpolated part of it is ``nothing``, so a class written as
|
||||
``crest${away ? " away" : nothing}`` lost ``crest`` for every present
|
||||
child and the header showed an unstyled sliver. The rendered markup
|
||||
must carry ``class="crest"`` (present) and ``class="crest away"``
|
||||
(away), each wrapping the face and initial."""
|
||||
generated = _generate_dashboard(
|
||||
{}, _three_child_states(), url="http://homeassistant.local/nestquest"
|
||||
)
|
||||
log_config = generated["views"][1]["cards"][0]
|
||||
for slug, crest_class in (("ada", "crest"), ("bo", "crest away")):
|
||||
result = _render_quest_log(
|
||||
log_config,
|
||||
_three_child_states(),
|
||||
url=f"http://homeassistant.local/nestquest/{slug}",
|
||||
)
|
||||
header = result["html"][result["html"].index('<header class="header">') :]
|
||||
crest = re.match(
|
||||
r'<header class="header">\s*(?:<!--[^>]*-->\s*)*<span ([^>]*)>\s*'
|
||||
r'<span class="crest-face">\s*<span class="initial">',
|
||||
header,
|
||||
)
|
||||
assert crest is not None, header[:600]
|
||||
assert f'class="{crest_class}"' in crest.group(1), crest.group(1)
|
||||
|
||||
|
||||
def test_quest_log_crest_matches_the_spec_geometry() -> None:
|
||||
"""PANEL-SPEC §3 header crest: 96x110 shield, 4px ring of
|
||||
rgba(255,255,255,.22) around the --nq-p-crest face, 44px/700 display
|
||||
initial in full-opacity white; away swaps only the face gradient.
|
||||
jsdom cannot lay out, so this pins the stylesheet the rendered test
|
||||
above shows is applied."""
|
||||
source = QUEST_LOG.read_text(encoding="utf-8")
|
||||
|
||||
def rule(selector: str) -> str:
|
||||
found = re.search(
|
||||
r"\n " + re.escape(selector) + r" \{(.*?)\n \}", source, re.DOTALL
|
||||
)
|
||||
assert found is not None, selector
|
||||
return found.group(1)
|
||||
|
||||
crest = rule(".crest")
|
||||
for decl in (
|
||||
"width: 96px;",
|
||||
"height: 110px;",
|
||||
"padding: 4px;",
|
||||
"clip-path: ${unsafeCSS(SHIELD_CLIP)};",
|
||||
"background: rgba(255, 255, 255, 0.22);",
|
||||
):
|
||||
assert decl in crest, decl
|
||||
face = rule(".crest-face")
|
||||
assert "background: var(--nq-p-crest);" in face
|
||||
assert "clip-path: ${unsafeCSS(SHIELD_CLIP)};" in face
|
||||
initial = rule(".crest .initial")
|
||||
for decl in (
|
||||
"font-family: var(--nq-p-font-display);",
|
||||
"font-size: 44px;",
|
||||
"font-weight: 700;",
|
||||
"color: #ffffff;",
|
||||
):
|
||||
assert decl in initial, decl
|
||||
assert "opacity" not in initial
|
||||
away = rule(".crest.away .crest-face")
|
||||
assert away.strip() == "background: var(--nq-p-crest-away);"
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user