Compare commits

...
Author SHA1 Message Date
overseerandClaude Opus 5.5 430f8abc12 Reject edge whitespace and controls in icon keys instead of trimming
validate_icon stripped the whole value before validating, so
`emoji:🐕 `, `emoji:🐕\n` and `emoji:🐕\x1c` were silently accepted as
`emoji:🐕`. Validate the raw namespace, name and emoji payload; a
whitespace-only value still clears the icon. Padded keys such as
`lucide:dog ` are now rejected naming `icon` (NQ-241-3).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 07:38:18 -04:00
overseerandClaude Opus 5.5 a64b619bee Resolve namespaced Lucide icons in the admin lookup and picker
The API now returns definition icons as lucide:<name>, but the admin
looked up and compared bare names only, so every icon rendered as the
generic fallback and nothing was selected in the edit picker (NQ-241-1).

Add normalizeDefinitionIconName as the one rule for both surfaces:
lucide:<name> and a legacy bare <name> give <name>; fa:, emoji: and
unknown namespaces give null (the fallback tile). Tapping a Lucide
choice now stores the canonical lucide:<name>; No icon still clears.

Task 707994b7.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 07:33:18 -04:00
overseerandClaude Opus 5.5 7af0b5a6cc Namespace quest definition icon keys with legacy back-compat
Add core/icons.py as the one place icon keys are validated and
normalized. A definition icon is now stored as lucide:<name>,
fa:<name> or emoji:<payload> (at most 8 code points, no whitespace,
control characters or markup). Create and edit validate strictly and
store the canonical form; a legacy bare name is stored as
lucide:<name>, and None still clears the icon.

Reads normalize leniently so existing bare-name rows need no data
migration: the admin quest-definition response and the panel snapshot's
per-instance icon both return the namespaced form.

Task 707994b7.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 07:18:49 -04:00
orchestrator-agent fb9907ae7f Merge pull request Reconcile dev with main after the 0.8.0 release 2026-09-25 14:53:18 -04:00
overseer dbf410ada3 Merge pull request 'Release: multi-pattern presence, Sunday-first week and admin fixes (0.8.0)' (#239) from release/2026-09-25-presence-patterns-and-start-date into main
Reviewed-on: #239
Reviewed-by: The Reviewer <18+review_agent@code.cubecraftcreations.com>
2026-09-25 12:52:31 -04:00
overseer 2906da7ed1 release: clarify the 0.8.0 scope wording 2026-09-25 07:42:04 -04:00
overseer 4f609d7bb9 release: advance version to 0.8.0 and add the 0.8.0 release notes 2026-09-25 07:39:18 -04:00
dev_agent 39e0d715fc Feature: definition sheet start-date field (#238)
Co-authored-by: dev_agent <17+dev_agent@code.cubecraftcreations.com>
Co-committed-by: dev_agent <17+dev_agent@code.cubecraftcreations.com>
2026-09-25 07:38:14 -04:00
dev_agent d248b9cb32 Fix: create quest definition materializes its instances (#236)
Co-authored-by: dev_agent <17+dev_agent@code.cubecraftcreations.com>
Co-committed-by: dev_agent <17+dev_agent@code.cubecraftcreations.com>
2026-09-25 07:26:33 -04:00
21 changed files with 779 additions and 56 deletions
+36
View File
@@ -1,5 +1,41 @@
# NestQuest Release Notes
## Version 0.8.0 — 2026-09-25
### Scope
Minor release carrying the multi-pattern presence model (schema 9), the
Sunday-first calendar week, and the create-materializes fix, plus the
definition start-date field, onto `main`.
- **Multiple presence patterns per child (schema 9):** a child now has zero or
more presence patterns instead of at most one. Each pattern has a name, a kind
(`home` or `away`), its own cycle length (1–4 weeks), its own anchor date and a
per-week weekday set. Evaluation is first-match-wins — overrides, then away,
then home, then absent when the child has at least one home pattern, else
present. Migration 9 copies each existing schedule into a `home` pattern named
"Home schedule" and drops the old `presence_schedules` table. (PRs #231, #232.)
- **Sunday-first calendar week:** the admin Schedule month grid and the
Definitions weekday picker now render weeks Sunday-first; the stored/API
weekday numbering is unchanged (Monday=0..Sunday=6). (PR #230.)
- **Fix — newly created tasks materialize immediately:** creating a quest
definition now regenerates its future instances over the rolling horizon, so a
new task reaches the panel without waiting for an unrelated write. (PRs #235,
#236.)
- **Definition start date:** the definition edit sheet gains a Start date field
so a task can be scheduled to begin on a future date. (PRs #237, #238.)
- **Docs:** the multi-pattern decision (D-016), the migration-9 rollback step in
the runbook, and the ADMIN-SPEC Schedule section. (PRs #233, #234.)
### Upgrade note
This release runs schema version **9**. Version 0.7.1 refuses a version-9
database, so take a copy of the SQLite file (or note the Litestream generation)
before the first start on 0.8.0 if you need a rollback path.
### Requirements
## Version 0.7.1 — 2026-09-24
### Scope
+183 -3
View File
@@ -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"));
@@ -1118,3 +1118,183 @@ describe("DefinitionsTab icon picker", () => {
expect(rowIcon(screen.getByTestId("definition-20"))).toBeNull();
});
});
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 {
return within(s).getByLabelText("Start date", { selector: "input" }) as HTMLInputElement;
}
describe("DefinitionsTab start date", () => {
beforeEach(() => {
sessionStorage.clear();
storeTokens({ access_token: "at-123", expires_in: 600 });
});
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
vi.useRealTimers();
});
it("a new task defaults to today and POSTs the chosen start date", async () => {
vi.useFakeTimers({ toFake: ["Date"] });
vi.setSystemTime(new Date(2026, 8, 23, 9, 0)); // Wed 23 Sep 2026, local
await renderReady();
fireEvent.click(screen.getByRole("button", { name: "New" }));
const s = sheet();
const input = startDateInput(s);
expect(input.type).toBe("date");
expect(input.value).toBe("2026-09-23");
fireEvent.change(within(s).getByRole("textbox", { name: "Title" }), {
target: { value: "Feed the cat" },
});
fireEvent.click(within(s).getByRole("button", { name: /Assigned children/ }));
fireEvent.click(within(s).getByRole("checkbox", { name: "Declan" }));
fireEvent.click(within(s).getByRole("button", { name: "Evening" }));
fireEvent.change(input, { target: { value: "2026-10-15" } });
await act(async () => {
fireEvent.click(within(s).getByRole("button", { name: "Save changes" }));
});
expect(api.writes()).toHaveLength(1);
expect(api.writes()[0].method).toBe("POST");
expect((api.writes()[0].body as { rule: DefinitionRule }).rule.start_date).toBe("2026-10-15");
});
it("an edit pre-fills the definition's start date and PATCHes a changed one", async () => {
await renderReady();
fireEvent.click(screen.getByTestId("definition-10"));
const s = sheet();
const input = startDateInput(s);
expect(input.value).toBe("2026-01-01");
fireEvent.change(input, { target: { value: "2026-11-02" } });
await act(async () => {
fireEvent.click(within(s).getByRole("button", { name: "Save changes" }));
});
expect(api.writes()).toEqual([
expect.objectContaining({
method: "PATCH",
path: "/api/v1/admin/quest-definitions/10",
body: expect.objectContaining({ rule: rule({ start_date: "2026-11-02" }) }),
}),
]);
});
it("the occurrence preview requests the chosen start date", async () => {
await renderReady();
fireEvent.click(screen.getByTestId("definition-10"));
const s = sheet();
await waitFor(() => expect(api.previews()).toHaveLength(1));
fireEvent.change(startDateInput(s), { target: { value: "2027-03-01" } });
await settleDebounce();
await waitFor(() => expect(api.previews()).toHaveLength(2));
expect((api.previews()[1].body as { rule: DefinitionRule }).rule.start_date).toBe(
"2027-03-01",
);
});
it("a cleared start date is not submitted", async () => {
await renderReady();
fireEvent.click(screen.getByTestId("definition-10"));
const s = sheet();
fireEvent.change(startDateInput(s), { target: { value: "" } });
await act(async () => {
fireEvent.click(within(s).getByRole("button", { name: "Save changes" }));
});
expect(api.writes()).toHaveLength(0);
expect(within(s).getByText("Pick a start date.")).toBeTruthy();
});
});
+34 -15
View File
@@ -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. */
@@ -177,6 +182,7 @@ function parseWhole(value: string, min: number, max: number): number | null {
function buildRule(draft: Draft): DefinitionRule | string {
const interval = parseWhole(draft.interval, 1, 999);
if (interval === null) return "Interval must be a whole number of at least 1.";
if (draft.startDate === "") return "Pick a start date.";
const rule: DefinitionRule = {
rule_type: "daily",
interval,
@@ -575,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>
@@ -740,6 +749,16 @@ function EditSheet({
<OccurrencePreview ruleKey={previewKey} />
</Field>
<Field label="Start date" id="defs-label-start">
<input
className="defs-input"
type="date"
aria-labelledby="defs-label-start"
value={draft.startDate}
onChange={(e) => update({ startDate: e.target.value })}
/>
</Field>
<Field label="Due time" id="defs-label-due">
{orderedWindows.length === 0 ? (
<p className="defs-hint">Pick a window to set its due time.</p>
+22 -2
View File
@@ -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);
}
+5
View File
@@ -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
View File
@@ -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),
+113
View File
@@ -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
@@ -235,6 +236,11 @@ async def create_quest_definition(
transaction, so the returned bundle is a consistent creation
snapshot. Raises ValueError on any rejected argument; a rejected or
failed create persists nothing.
After the write, :func:`~.materialize.regenerate_for_definition`
materializes the new definition's instances over the rolling horizon
(host clock, :data:`~.const.DEFAULT_HORIZON_DAYS`), so it reaches the
board without waiting for an unrelated write.
"""
name = _validate_text(title, "title", required=True)
assert name is not None
@@ -243,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)
@@ -269,6 +275,9 @@ async def create_quest_definition(
) from error
raise
decoded_rule = schedule_rule_from_storage(storage)
await regenerate_for_definition(database, snapshot.definition.id)
return CreatedQuestDefinition(
definition=snapshot.definition,
rule=decoded_rule,
@@ -343,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:
+6 -1
View File
@@ -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,
+1 -1
View File
@@ -15,5 +15,5 @@
"iot_class": "local_polling",
"issue_tracker": "https://github.com/Maniacs-Mansion/NestQuest/issues",
"requirements": [],
"version": "0.7.1"
"version": "0.8.0"
}
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "nestquest"
version = "0.7.1"
version = "0.8.0"
description = "NestQuest Home Assistant Integration"
readme = "README.md"
requires-python = ">=3.11"
+87 -3
View File
@@ -35,6 +35,7 @@ for malformed bodies.
"""
from __future__ import annotations
import datetime
import importlib
from types import SimpleNamespace
@@ -223,7 +224,7 @@ async def test_create_persists_definition_with_assignees_and_windows(
}
assert payload["title"] == "Tidy the den"
assert payload["description"] == "Put everything back where it belongs."
assert payload["icon"] == "toy-box"
assert payload["icon"] == "lucide:toy-box"
assert payload["is_active"] is True
assert payload["skip_on_away"] is True
assert payload["rule"] == _weekly_rule_dict()
@@ -245,7 +246,7 @@ async def test_create_persists_definition_with_assignees_and_windows(
assert stored.definition.description == (
"Put everything back where it belongs."
)
assert stored.definition.icon == "toy-box"
assert stored.definition.icon == "lucide:toy-box"
assert stored.definition.is_active is True
assert stored.rule.to_dict() == _weekly_rule_dict()
assert sorted(child.id for child in stored.assignees) == [ada, bo]
@@ -396,7 +397,7 @@ async def test_edit_persists_supplied_fields_and_leaves_omitted_untouched(
assert stored.definition.description == (
"Put everything back where it belongs."
)
assert stored.definition.icon == "toy-box"
assert stored.definition.icon == "lucide:toy-box"
assert stored.rule.to_dict() == _weekly_rule_dict()
# assignee_child_ids was omitted: the assignees are untouched.
assert sorted(child.id for child in stored.assignees) == [ada, bo]
@@ -468,6 +469,89 @@ async def test_edit_can_clear_description_and_icon_explicitly(
assert stored.rule.to_dict() == _weekly_rule_dict()
async def test_legacy_bare_icon_reads_back_namespaced(
admin_questdefs: SimpleNamespace,
) -> None:
"""A row stored with a pre-namespacing bare icon reads as ``lucide:``.
The row is rewritten through the DAO (bypassing the write-side
validation, as a row from before namespacing would be) and then read
back through BOTH consumers: the admin definition response and the
kids' panel snapshot's per-instance ``icon``.
"""
client = admin_questdefs.client
ada = (await _create_children(client, ("Ada",)))[0]
today = datetime.date.today().isoformat()
created = await client.post(
"/api/v1/admin/quest-definitions",
json={
"title": "Walk the dog",
"rule": {"rule_type": "daily", "start_date": today},
"assignee_child_ids": [ada],
"windows": ["evening"],
"icon": "fa:dog",
},
headers=_admin_headers(),
)
assert created.status_code == 201
definition_id = created.json()["id"]
await _dao_rules.QuestDefinitionsDao(
admin_questdefs.database
).edit_definition(definition_id, icon="dog")
stored = (await _definitions_by_id(admin_questdefs.database))[
definition_id
]
assert stored.definition.icon == "dog"
listed = await client.get(
"/api/v1/admin/quest-definitions", headers=_admin_headers()
)
assert listed.status_code == 200
[definition] = [
d for d in listed.json()["definitions"] if d["id"] == definition_id
]
assert definition["icon"] == "lucide:dog"
panel = await client.get(
"/api/v1/panel/snapshot",
headers={"Authorization": "Bearer admin-test-panel-token"},
)
assert panel.status_code == 200
[child] = panel.json()["children"]
icons = {
instance["icon"]
for instance in child["instances"]
if instance["definition_id"] == definition_id
}
assert icons == {"lucide:dog"}
@pytest.mark.parametrize(
"icon", ["foo:dog", "lucide:", "emoji:<b>", "javascript:alert(1)"]
)
async def test_create_invalid_icon_is_422_naming_icon(
admin_questdefs: SimpleNamespace, icon: str
) -> None:
"""An invalid icon key is a 422 whose detail names the icon field."""
client = admin_questdefs.client
ada = (await _create_children(client, ("Ada",)))[0]
response = await client.post(
"/api/v1/admin/quest-definitions",
json={
"title": "Walk the dog",
"rule": _weekly_rule_body(),
"assignee_child_ids": [ada],
"windows": ["morning"],
"icon": icon,
},
headers=_admin_headers(),
)
assert response.status_code == 422
assert "icon" in str(response.json()["detail"])
assert await _definitions_by_id(admin_questdefs.database) == {}
async def test_edit_unknown_definition_is_404(
admin_questdefs: SimpleNamespace,
) -> None:
+1 -1
View File
@@ -135,7 +135,7 @@ async def test_admin_lists_active_and_inactive_definitions_with_full_shape(
"id": active_id,
"title": "Tidy the den",
"description": "Put everything back where it belongs.",
"icon": "toy-box",
"icon": "lucide:toy-box",
"is_active": True,
"skip_on_away": True,
"rule": {
+12 -10
View File
@@ -514,8 +514,10 @@ async def test_regenerate_household_never_duplicates(
client = admin_household.client
database = admin_household.database
before = await _instance_count(database)
assert before > 0 # the seed already materialized a few days
# The seed's creates materialized the horizon BEFORE Ada's custody
# pattern existed, so the first household walk may legitimately
# drop her away-day rows; only the second run must be stable.
assert await _instance_count(database) > 0
first = await client.post(
"/api/v1/admin/regenerate", json={}, headers=_admin_headers()
@@ -525,9 +527,8 @@ async def test_regenerate_household_never_duplicates(
assert body["scope"] == "household"
assert body["definition_id"] is None
assert body["child_id"] is None
assert body["count"] >= before
assert body["count"] > 0
after_first = await _instance_count(database)
assert after_first >= before
second = await client.post(
"/api/v1/admin/regenerate", json={}, headers=_admin_headers()
@@ -544,17 +545,18 @@ async def test_regenerate_definition_scope_rematerializes(
) -> None:
"""The definition scope rebuilds that definition's horizon, twice, stably.
Creating a definition through the admin route materializes NOTHING
(the core's create path does not regenerate); the scoped regenerate
call is what materializes its instances, and re-running it leaves
the row count exactly where the first run left it.
Creating a definition through the admin route already materializes
its horizon (the core's create path regenerates); the scoped
regenerate call rebuilds the same rows, and re-running it leaves
the row count exactly where the create left it.
"""
client = admin_api.client
database = admin_api.database
ada = await _create_child(client, "Ada")
definition_id = await _create_daily_definition(client, [ada])
assert await _instance_count(database) == 0
created_count = await _instance_count(database)
assert created_count > 0
first = await client.post(
"/api/v1/admin/regenerate",
@@ -568,7 +570,7 @@ async def test_regenerate_definition_scope_rematerializes(
assert body["child_id"] is None
assert body["count"] > 0
after_first = await _instance_count(database)
assert after_first == body["count"]
assert after_first == body["count"] == created_count
second = await client.post(
"/api/v1/admin/regenerate",
+7 -2
View File
@@ -151,12 +151,17 @@ async def _seed_missed_household(
database, yesterday_iso, yesterday_iso, today=yesterday
)
# Today's quest: created after the yesterday walk so it has no
# past-due instance, then materialized for today only.
# past-due instance; ``end_date`` keeps it (and create's horizon
# regeneration) to today only.
await _quest_definitions.create_quest_definition(
database,
"Recoverable chore",
_recurrence.ScheduleRule.from_dict(
{"rule_type": "daily", "start_date": today_iso}
{
"rule_type": "daily",
"start_date": today_iso,
"end_date": today_iso,
}
),
[ada.id],
[("afternoon", "17:00")],
+8 -3
View File
@@ -149,13 +149,18 @@ async def _seed_household(
(today + datetime.timedelta(days=3)).isoformat(),
today=today,
)
# The stale quest is created AFTER the today walk, so its only
# instance is the yesterday one written below.
# The stale quest's rule ends YESTERDAY, so create's own
# regeneration writes nothing and its only instance is the
# yesterday one written below.
await _quest_definitions.create_quest_definition(
database,
"Stale chore",
_recurrence.ScheduleRule.from_dict(
{"rule_type": "daily", "start_date": yesterday_iso}
{
"rule_type": "daily",
"start_date": yesterday_iso,
"end_date": yesterday_iso,
}
),
[ada.id],
[("morning", "09:00")],
+111
View File
@@ -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
+8 -3
View File
@@ -102,15 +102,20 @@ async def _seed_missed_household(
database, "Old chore", rule, [ada.id], [("evening", "18:30")]
)
await materialize(database, yesterday_iso, yesterday_iso, today=yesterday)
# A quest due TODAY (created after the yesterday walk so it has
# no past-due instance): the post-downtime catch-up run's
# A quest due TODAY only (created after the yesterday walk so it
# has no past-due instance; ``end_date`` keeps create's horizon
# regeneration to today): the post-downtime catch-up run's
# target — inside [watermark=today, later_today) but not
# inside the day-one sweep's window.
await create_quest_definition(
database,
"Recoverable chore",
ScheduleRule.from_dict(
{"rule_type": "daily", "start_date": today_iso}
{
"rule_type": "daily",
"start_date": today_iso,
"end_date": today_iso,
}
),
[ada.id],
[("afternoon", "17:00")],
+4
View File
@@ -468,7 +468,11 @@ async def _materialize_with_change(
proceeds with the snapped presence). This deterministically
exercises the snapshot-to-insert gap that a config edit can
interleave into.
``create_quest_definition`` already materializes its horizon, so the
table is cleared first: the gated walk must be the only writer.
"""
await database.execute("DELETE FROM quest_instances")
original_upsert = QuestInstancesDao.upsert_if_valid
entered = asyncio.Event()
release = asyncio.Event()
+126 -7
View File
@@ -106,13 +106,13 @@ def test_create_one_child_one_window(tmp_path) -> None:
[child.id],
["morning"],
description=" Twice daily ",
icon=" mdi:tooth ",
icon="lucide:tooth",
)
assert isinstance(result, CreatedQuestDefinition)
definition = result.definition
assert definition.title == "Brush teeth"
assert definition.description == "Twice daily"
assert definition.icon == "mdi:tooth"
assert definition.icon == "lucide:tooth"
assert definition.is_active is True
# D-008: the definition-level due_time column is never set here.
assert definition.due_time is None
@@ -168,6 +168,42 @@ def test_create_several_children_several_windows_with_due_times(
_with_db(tmp_path, "create-many.db")(_body)
def test_create_materializes_future_instances(tmp_path) -> None:
"""A fresh create regenerates its rolling horizon immediately.
Without this the new definition has no ``quest_instances`` rows until
some unrelated write regenerates it, so it never reaches the board.
"""
assert DEFAULT_HORIZON_DAYS >= 7
async def _body(database, children, rules, definitions, child):
today = _today_iso()
created = await create_quest_definition(
database,
"Take out bins",
ScheduleRule(
rule_type=RuleType.WEEKLY,
weekday_set={0},
start_date=today,
),
[child.id],
["morning"],
)
rows = await database.fetch_all(
"SELECT child_id, due_date FROM quest_instances "
"WHERE definition_id = ?",
(created.definition.id,),
)
assert rows, "create materialized no instances"
assert {row[0] for row in rows} == {child.id}
for _child_id, due_date in rows:
assert due_date >= today
assert datetime.date.fromisoformat(due_date).weekday() == 0
return None
_with_db(tmp_path, "create-materializes.db")(_body)
# ---------------------------------------------------------------------------
# create_quest_definition: argument rejections
# ---------------------------------------------------------------------------
@@ -196,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):
@@ -499,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
@@ -525,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)
@@ -561,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,
Generated
+1 -1
View File
@@ -373,7 +373,7 @@ wheels = [
[[package]]
name = "nestquest"
version = "0.7.1"
version = "0.8.0"
source = { virtual = "." }
[package.dev-dependencies]