Compare commits

..
Author SHA1 Message Date
Joshua King c988f6508b Rename task_* tables to quest_* with legacy-dev migration path (D-007)
Per design/IMPLEMENTATION-ORDER.md 1.1 and decision D-007: the domain
model takes the quest vocabulary pre-release. SCHEMA_V1 now creates
quest_definitions/quest_instances directly; DAO classes, records and
error messages follow (QuestDefinitionsDao, QuestInstancesDao). The
migration runner gains migration 2, an async-callable step that detects
surviving task_* tables on dev machines and ALTER TABLE RENAMES them
inside the migration transaction, with FK clauses rewritten by SQLite.
Migration 1's content rewrite is legal because nothing was ever
shipped; dev databases stamped version 1 ride migration 2 up to 2.
2026-09-15 09:35:21 -04:00
overseer 3ecca2d212 Merge pull request 'Feat/ui design tokens' (#43) from feat/ui-design-tokens into dev
Reviewed-on: #43
2026-09-14 21:32:14 -04:00
overseer 30d103aefb Upload files to "design/tokens" 2026-09-14 21:31:55 -04:00
overseer 658afec491 adding in design files 2026-09-14 21:31:32 -04:00
orchestrator-agent b1157b3d6d Converge dev and main (single reconciliation PR) (#38) 2026-09-14 15:20:58 -04:00
18 changed files with 1301 additions and 222 deletions
+28 -28
View File
@@ -1,4 +1,4 @@
"""Typed DAO layer for task_instances and the append-only completion_events. """Typed DAO layer for quest_instances and the append-only completion_events.
All SQL for these tables lives in this module per the feature All SQL for these tables lives in this module per the feature
guardrails: callers get typed dataclasses back and never see raw rows guardrails: callers get typed dataclasses back and never see raw rows
@@ -14,7 +14,7 @@ separate reversal event, and instance state derives from the latest
event. A package-wide guard test (bottom of the test module) keeps event. A package-wide guard test (bottom of the test module) keeps
mutation SQL for this table out of every other module. mutation SQL for this table out of every other module.
Upsert semantics: ``TaskInstancesDao.upsert`` is idempotent on Upsert semantics: ``QuestInstancesDao.upsert`` is idempotent on
(definition_id, due_date) — re-running materialization for a date (definition_id, due_date) — re-running materialization for a date
whose instance already exists updates nothing that matters whose instance already exists updates nothing that matters
(child_id/due_time are refreshed only in the degenerate re-generation (child_id/due_time are refreshed only in the degenerate re-generation
@@ -82,8 +82,8 @@ def _next_day_start(date_str: str) -> str:
@dataclass(frozen=True) @dataclass(frozen=True)
class TaskInstanceRecord: class QuestInstanceRecord:
"""One row of ``task_instances``.""" """One row of ``quest_instances``."""
id: int id: int
definition_id: int definition_id: int
@@ -121,8 +121,8 @@ EVENT_COMPLETED = "completed"
EVENT_UNCOMPLETED = "uncompleted" EVENT_UNCOMPLETED = "uncompleted"
def _instance_from_row(row: tuple) -> TaskInstanceRecord: def _instance_from_row(row: tuple) -> QuestInstanceRecord:
return TaskInstanceRecord( return QuestInstanceRecord(
id=row[0], id=row[0],
definition_id=row[1], definition_id=row[1],
child_id=row[2], child_id=row[2],
@@ -145,8 +145,8 @@ def _event_from_row(row: tuple) -> CompletionEventRecord:
) )
class TaskInstancesDao: class QuestInstancesDao:
"""Typed async access to the ``task_instances`` table.""" """Typed async access to the ``quest_instances`` table."""
def __init__(self, database: NestQuestDatabase) -> None: def __init__(self, database: NestQuestDatabase) -> None:
self._database = database self._database = database
@@ -159,7 +159,7 @@ class TaskInstancesDao:
generated_at: str, generated_at: str,
*, *,
due_time: str | None = None, due_time: str | None = None,
) -> TaskInstanceRecord: ) -> QuestInstanceRecord:
"""Create the dated instance for (definition_id, due_date) idempotently. """Create the dated instance for (definition_id, due_date) idempotently.
Running materialization twice for the same definition/date Running materialization twice for the same definition/date
@@ -207,16 +207,16 @@ class TaskInstancesDao:
f"{today!r}" f"{today!r}"
) )
definition = await self._database.fetch_one( definition = await self._database.fetch_one(
"SELECT child_id FROM task_definitions WHERE id = ?", "SELECT child_id FROM quest_definitions WHERE id = ?",
(definition_id,), (definition_id,),
) )
if definition is None: if definition is None:
raise ValueError( raise ValueError(
f"task definition {definition_id} does not exist" f"quest definition {definition_id} does not exist"
) )
if definition[0] != child_id: if definition[0] != child_id:
raise ValueError( raise ValueError(
f"task definition {definition_id} is assigned to " f"quest definition {definition_id} is assigned to "
f"child {definition[0]}, not {child_id}; instance " f"child {definition[0]}, not {child_id}; instance "
"must carry the definition's assignee" "must carry the definition's assignee"
) )
@@ -227,7 +227,7 @@ class TaskInstancesDao:
raise ValueError(f"child {child_id} does not exist") raise ValueError(f"child {child_id} does not exist")
existing = await self._database.fetch_one( existing = await self._database.fetch_one(
"SELECT 1 FROM completion_events " "SELECT 1 FROM completion_events "
"WHERE instance_id = (SELECT id FROM task_instances " "WHERE instance_id = (SELECT id FROM quest_instances "
"WHERE definition_id = ? AND due_date = ?)", "WHERE definition_id = ? AND due_date = ?)",
(definition_id, due_date), (definition_id, due_date),
) )
@@ -238,7 +238,7 @@ class TaskInstancesDao:
"event and is immutable; it cannot be regenerated" "event and is immutable; it cannot be regenerated"
) )
await self._database.execute( await self._database.execute(
"INSERT INTO task_instances (definition_id, child_id, " "INSERT INTO quest_instances (definition_id, child_id, "
"due_date, due_time, generated_at) " "due_date, due_time, generated_at) "
"VALUES (?, ?, ?, ?, ?) " "VALUES (?, ?, ?, ?, ?) "
"ON CONFLICT (definition_id, due_date) DO UPDATE SET " "ON CONFLICT (definition_id, due_date) DO UPDATE SET "
@@ -259,30 +259,30 @@ class TaskInstancesDao:
async def get( async def get(
self, definition_id: int, due_date: str self, definition_id: int, due_date: str
) -> TaskInstanceRecord | None: ) -> QuestInstanceRecord | None:
"""Return the instance for (definition_id, due_date), or None.""" """Return the instance for (definition_id, due_date), or None."""
row = await self._database.fetch_one( row = await self._database.fetch_one(
f"SELECT {_INSTANCE_COLUMNS} FROM task_instances " f"SELECT {_INSTANCE_COLUMNS} FROM quest_instances "
"WHERE definition_id = ? AND due_date = ?", "WHERE definition_id = ? AND due_date = ?",
(definition_id, due_date), (definition_id, due_date),
) )
return _instance_from_row(row) if row is not None else None return _instance_from_row(row) if row is not None else None
async def get_by_id(self, instance_id: int) -> TaskInstanceRecord | None: async def get_by_id(self, instance_id: int) -> QuestInstanceRecord | None:
"""Return the instance with ``instance_id``, or None.""" """Return the instance with ``instance_id``, or None."""
row = await self._database.fetch_one( row = await self._database.fetch_one(
f"SELECT {_INSTANCE_COLUMNS} FROM task_instances WHERE id = ?", f"SELECT {_INSTANCE_COLUMNS} FROM quest_instances WHERE id = ?",
(instance_id,), (instance_id,),
) )
return _instance_from_row(row) if row is not None else None return _instance_from_row(row) if row is not None else None
async def list_by_child_and_date( async def list_by_child_and_date(
self, child_id: int, due_date: str self, child_id: int, due_date: str
) -> list[TaskInstanceRecord]: ) -> list[QuestInstanceRecord]:
"""Return the child's instances due exactly on ``due_date``.""" """Return the child's instances due exactly on ``due_date``."""
_validate_date(due_date, "due_date") _validate_date(due_date, "due_date")
rows = await self._database.fetch_all( rows = await self._database.fetch_all(
f"SELECT {_INSTANCE_COLUMNS} FROM task_instances " f"SELECT {_INSTANCE_COLUMNS} FROM quest_instances "
"WHERE child_id = ? AND due_date = ? ORDER BY id", "WHERE child_id = ? AND due_date = ? ORDER BY id",
(child_id, due_date), (child_id, due_date),
) )
@@ -293,7 +293,7 @@ class TaskInstancesDao:
child_id: int, child_id: int,
range_start: str, range_start: str,
range_end: str, range_end: str,
) -> list[TaskInstanceRecord]: ) -> list[QuestInstanceRecord]:
"""Return the child's instances with due_date in the closed range.""" """Return the child's instances with due_date in the closed range."""
_validate_date(range_start, "range_start") _validate_date(range_start, "range_start")
_validate_date(range_end, "range_end") _validate_date(range_end, "range_end")
@@ -303,7 +303,7 @@ class TaskInstancesDao:
f"{range_end!r} < {range_start!r}" f"{range_end!r} < {range_start!r}"
) )
rows = await self._database.fetch_all( rows = await self._database.fetch_all(
f"SELECT {_INSTANCE_COLUMNS} FROM task_instances " f"SELECT {_INSTANCE_COLUMNS} FROM quest_instances "
"WHERE child_id = ? AND due_date >= ? AND due_date <= ? " "WHERE child_id = ? AND due_date >= ? AND due_date <= ? "
"ORDER BY due_date, id", "ORDER BY due_date, id",
(child_id, range_start, range_end), (child_id, range_start, range_end),
@@ -338,10 +338,10 @@ class TaskInstancesDao:
today = datetime.date.today().isoformat() today = datetime.date.today().isoformat()
effective_cutoff = max(cutoff_date, today) effective_cutoff = max(cutoff_date, today)
result = await self._database.execute( result = await self._database.execute(
"DELETE FROM task_instances WHERE definition_id = ? " "DELETE FROM quest_instances WHERE definition_id = ? "
"AND due_date >= ? AND NOT EXISTS (" "AND due_date >= ? AND NOT EXISTS ("
" SELECT 1 FROM completion_events " " SELECT 1 FROM completion_events "
" WHERE instance_id = task_instances.id" " WHERE instance_id = quest_instances.id"
")", ")",
(definition_id, effective_cutoff), (definition_id, effective_cutoff),
) )
@@ -418,16 +418,16 @@ class CompletionEventsDao:
async with _connection_lock(self._database): async with _connection_lock(self._database):
async with self._database.transaction(): async with self._database.transaction():
instance = await self._database.fetch_one( instance = await self._database.fetch_one(
"SELECT child_id FROM task_instances WHERE id = ?", "SELECT child_id FROM quest_instances WHERE id = ?",
(instance_id,), (instance_id,),
) )
if instance is None: if instance is None:
raise ValueError( raise ValueError(
f"task instance {instance_id} does not exist" f"quest instance {instance_id} does not exist"
) )
if instance[0] != child_id: if instance[0] != child_id:
raise ValueError( raise ValueError(
f"task instance {instance_id} belongs to child " f"quest instance {instance_id} belongs to child "
f"{instance[0]}, not {child_id}" f"{instance[0]}, not {child_id}"
) )
child = await self._database.fetch_one( child = await self._database.fetch_one(
@@ -493,7 +493,7 @@ class CompletionEventsDao:
) )
if due_date_scope: if due_date_scope:
predicate = ( predicate = (
"SELECT id FROM task_instances WHERE child_id = ? " "SELECT id FROM quest_instances WHERE child_id = ? "
"AND due_date >= ? AND due_date <= ?" "AND due_date >= ? AND due_date <= ?"
) )
parameters: list[object] = [child_id, range_start, range_end] parameters: list[object] = [child_id, range_start, range_end]
+23 -23
View File
@@ -1,6 +1,6 @@
"""Typed DAO layer for the schedule_rules and task_definitions tables. """Typed DAO layer for the schedule_rules and quest_definitions tables.
All SQL for ``schedule_rules`` and ``task_definitions`` lives in this All SQL for ``schedule_rules`` and ``quest_definitions`` lives in this
module per the feature guardrails: callers get typed dataclasses back module per the feature guardrails: callers get typed dataclasses back
and never see raw rows or SQL. Every method is async and runs through and never see raw rows or SQL. Every method is async and runs through
:class:`~.db.NestQuestDatabase`, so each statement executes on the HA :class:`~.db.NestQuestDatabase`, so each statement executes on the HA
@@ -9,16 +9,16 @@ executor and the event loop never blocks.
Guardrail mapping: Guardrail mapping:
- ``schedule_rules`` deletion is delete-if-unreferenced: a rule still - ``schedule_rules`` deletion is delete-if-unreferenced: a rule still
referenced by any task definition is rejected (the done-condition), referenced by any quest definition is rejected (the done-condition),
not cascaded. not cascaded.
- ``task_definitions`` has no delete path at all: definitions are - ``quest_definitions`` has no delete path at all: definitions are
deactivated, never hard-deleted, and history must survive. deactivated, never hard-deleted, and history must survive.
- ``set_assignee`` changes future instances only — the DAO writes the - ``set_assignee`` changes future instances only — the DAO writes the
definition row and nothing else; reassignment never rewrites definition row and nothing else; reassignment never rewrites
existing instances or completion history (Feature 06 guardrail). existing instances or completion history (Feature 06 guardrail).
- Validation of rule well-formedness and child activeness lives partly - Validation of rule well-formedness and child activeness lives partly
in the schema CHECKs (rule-type coherence) and partly in in the schema CHECKs (rule-type coherence) and partly in
:meth:`TaskDefinitionsDao.create` (active child, rule existence); :meth:`QuestDefinitionsDao.create` (active child, rule existence);
no permission checks here, that is the Feature 09 gate's job. no permission checks here, that is the Feature 09 gate's job.
Concurrency: ``delete_rule_if_unreferenced`` performs its reference Concurrency: ``delete_rule_if_unreferenced`` performs its reference
@@ -81,8 +81,8 @@ class ScheduleRuleRecord:
@dataclass(frozen=True) @dataclass(frozen=True)
class TaskDefinitionRecord: class QuestDefinitionRecord:
"""One row of ``task_definitions``.""" """One row of ``quest_definitions``."""
id: int id: int
title: str title: str
@@ -119,8 +119,8 @@ def _rule_from_row(row: tuple) -> ScheduleRuleRecord:
) )
def _definition_from_row(row: tuple) -> TaskDefinitionRecord: def _definition_from_row(row: tuple) -> QuestDefinitionRecord:
return TaskDefinitionRecord( return QuestDefinitionRecord(
id=row[0], id=row[0],
title=row[1], title=row[1],
description=row[2], description=row[2],
@@ -258,7 +258,7 @@ class ScheduleRulesDao:
async with _connection_lock(self._database): async with _connection_lock(self._database):
async with self._database.transaction(): async with self._database.transaction():
row = await self._database.fetch_one( row = await self._database.fetch_one(
"SELECT 1 FROM task_definitions " "SELECT 1 FROM quest_definitions "
"WHERE schedule_rule_id = ? LIMIT 1", "WHERE schedule_rule_id = ? LIMIT 1",
(rule_id,), (rule_id,),
) )
@@ -271,8 +271,8 @@ class ScheduleRulesDao:
return result.rowcount > 0 return result.rowcount > 0
class TaskDefinitionsDao: class QuestDefinitionsDao:
"""Typed async access to the ``task_definitions`` table. """Typed async access to the ``quest_definitions`` table.
No delete method exists: definitions are deactivated via No delete method exists: definitions are deactivated via
:meth:`set_active`, never hard-deleted, so completion history keeps :meth:`set_active`, never hard-deleted, so completion history keeps
@@ -293,7 +293,7 @@ class TaskDefinitionsDao:
icon: str | None = None, icon: str | None = None,
due_time: str | None = None, due_time: str | None = None,
is_active: bool = True, is_active: bool = True,
) -> TaskDefinitionRecord: ) -> QuestDefinitionRecord:
"""Insert one definition and return the record as stored. """Insert one definition and return the record as stored.
Validates the assignment target as part of the same serialized Validates the assignment target as part of the same serialized
@@ -312,7 +312,7 @@ class TaskDefinitionsDao:
child_id, schedule_rule_id child_id, schedule_rule_id
) )
result = await self._database.execute( result = await self._database.execute(
"INSERT INTO task_definitions (title, description, " "INSERT INTO quest_definitions (title, description, "
"icon, child_id, schedule_rule_id, due_time, " "icon, child_id, schedule_rule_id, due_time, "
"is_active, created_at) " "is_active, created_at) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?)", "VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
@@ -349,28 +349,28 @@ class TaskDefinitionsDao:
f"schedule rule {schedule_rule_id} does not exist" f"schedule rule {schedule_rule_id} does not exist"
) )
async def get(self, definition_id: int) -> TaskDefinitionRecord | None: async def get(self, definition_id: int) -> QuestDefinitionRecord | None:
"""Return the definition with ``definition_id``, or None.""" """Return the definition with ``definition_id``, or None."""
row = await self._database.fetch_one( row = await self._database.fetch_one(
f"SELECT {_DEFINITION_COLUMNS} FROM task_definitions " f"SELECT {_DEFINITION_COLUMNS} FROM quest_definitions "
"WHERE id = ?", "WHERE id = ?",
(definition_id,), (definition_id,),
) )
return _definition_from_row(row) if row is not None else None return _definition_from_row(row) if row is not None else None
async def list_by_child(self, child_id: int) -> list[TaskDefinitionRecord]: async def list_by_child(self, child_id: int) -> list[QuestDefinitionRecord]:
"""Return all definitions assigned to ``child_id``, newest first.""" """Return all definitions assigned to ``child_id``, newest first."""
rows = await self._database.fetch_all( rows = await self._database.fetch_all(
f"SELECT {_DEFINITION_COLUMNS} FROM task_definitions " f"SELECT {_DEFINITION_COLUMNS} FROM quest_definitions "
"WHERE child_id = ? ORDER BY id DESC", "WHERE child_id = ? ORDER BY id DESC",
(child_id,), (child_id,),
) )
return [_definition_from_row(row) for row in rows] return [_definition_from_row(row) for row in rows]
async def list_active(self) -> list[TaskDefinitionRecord]: async def list_active(self) -> list[QuestDefinitionRecord]:
"""Return all active definitions, oldest first (stable order).""" """Return all active definitions, oldest first (stable order)."""
rows = await self._database.fetch_all( rows = await self._database.fetch_all(
f"SELECT {_DEFINITION_COLUMNS} FROM task_definitions " f"SELECT {_DEFINITION_COLUMNS} FROM quest_definitions "
"WHERE is_active = 1 ORDER BY id" "WHERE is_active = 1 ORDER BY id"
) )
return [_definition_from_row(row) for row in rows] return [_definition_from_row(row) for row in rows]
@@ -409,7 +409,7 @@ class TaskDefinitionsDao:
return 0 return 0
parameters.append(definition_id) parameters.append(definition_id)
result = await self._database.execute( result = await self._database.execute(
f"UPDATE task_definitions SET {', '.join(assignments)} " f"UPDATE quest_definitions SET {', '.join(assignments)} "
"WHERE id = ?", "WHERE id = ?",
tuple(parameters), tuple(parameters),
) )
@@ -422,7 +422,7 @@ class TaskDefinitionsDao:
existing instances and completion history untouched. existing instances and completion history untouched.
""" """
result = await self._database.execute( result = await self._database.execute(
"UPDATE task_definitions SET is_active = ? WHERE id = ?", "UPDATE quest_definitions SET is_active = ? WHERE id = ?",
(int(is_active), definition_id), (int(is_active), definition_id),
) )
return result.rowcount return result.rowcount
@@ -442,7 +442,7 @@ class TaskDefinitionsDao:
async with self._database.transaction(): async with self._database.transaction():
await self._validate_assignable_child(child_id) await self._validate_assignable_child(child_id)
result = await self._database.execute( result = await self._database.execute(
"UPDATE task_definitions SET child_id = ? " "UPDATE quest_definitions SET child_id = ? "
"WHERE id = ?", "WHERE id = ?",
(child_id, definition_id), (child_id, definition_id),
) )
+78 -7
View File
@@ -14,6 +14,22 @@ Version 0 means "the file exists but carries no NestQuest schema yet"
the runner against version 0 applies migration 1, which applies the the runner against version 0 applies migration 1, which applies the
full v1 DDL from :mod:`.schema` and stamps version 1. full v1 DDL from :mod:`.schema` and stamps version 1.
Migration 1 was rewritten pre-release (D-007): nothing was ever
shipped, so the v1 DDL now creates the ``quest_*`` tables directly
instead of the legacy ``task_*`` spellings, and no compatibility shim
exists. Dev machines that already hold a version-1 database with
``task_*`` tables are handled by migration 2, which detects the legacy
table names and ``ALTER TABLE ... RENAME TO``s them to their current
names before anything else assumes the new model. The rename step runs
inside the same transaction as its version stamp, so a crash mid-rename
rolls both back together.
Migration entries are either an ordered sequence of SQL statements or
an async callable taking the open database. Callables exist for steps
whose SQL depends on the database's current state (the legacy rename
only fires when a legacy table is actually present) and run under the
same per-migration transaction as statement lists.
Each migration's statements and its version stamp run inside ONE Each migration's statements and its version stamp run inside ONE
transaction, and the stamp is written as DELETE + INSERT rather than transaction, and the stamp is written as DELETE + INSERT rather than
UPDATE because a version-0 database has no version row to update yet. UPDATE because a version-0 database has no version row to update yet.
@@ -43,13 +59,18 @@ from __future__ import annotations
import asyncio import asyncio
import logging import logging
from collections.abc import Sequence from collections.abc import Awaitable, Callable, Sequence
from typing import Union
from .db import NestQuestDatabase from .db import NestQuestDatabase
from .schema import SCHEMA_V1_STATEMENTS from .schema import SCHEMA_V1_STATEMENTS
LOGGER = logging.getLogger(__name__) LOGGER = logging.getLogger(__name__)
#: One migration entry: either an ordered sequence of SQL statements or
#: an async callable run under the entry's migration transaction.
MigrationStep = Union[Sequence[str], Callable[[NestQuestDatabase], Awaitable[None]]]
#: Metadata table holding the applied schema version. Prefixed with the #: Metadata table holding the applied schema version. Prefixed with the
#: integration domain so a config directory shared with other tools can #: integration domain so a config directory shared with other tools can
#: never collide with a foreign ``schema_version`` table. #: never collide with a foreign ``schema_version`` table.
@@ -73,10 +94,48 @@ VERSION_TABLE_DDL = f"""
#: version ``n-1`` to version ``n``. #: version ``n-1`` to version ``n``.
MIGRATION_1_V1_DDL: list[str] = list(SCHEMA_V1_STATEMENTS) MIGRATION_1_V1_DDL: list[str] = list(SCHEMA_V1_STATEMENTS)
#: Legacy (pre-D-007) table name -> current table name. Order matters:
#: the parent table renames first so the child's foreign key reference
#: is rewritten while its parent's new name is already in place.
LEGACY_TASK_TABLE_RENAMES: tuple[tuple[str, str], ...] = (
("task_definitions", "quest_definitions"),
("task_instances", "quest_instances"),
)
async def _rename_legacy_task_tables(database: NestQuestDatabase) -> None:
"""Rename any surviving ``task_*`` tables to their ``quest_*`` names.
Dev-machines-only path (D-007): a database stamped version 1 by the
pre-rename runner holds ``task_definitions``/``task_instances``.
Each legacy name still present is renamed; fresh databases find
nothing and this step is a no-op. Modern SQLite rewrites foreign
key clauses in other tables to follow the rename, so the renamed
schema is indistinguishable from a freshly created one apart from
internal autoindex names. Runs inside the migration transaction,
so the renames and the version stamp commit or roll back together.
"""
for legacy_name, current_name in LEGACY_TASK_TABLE_RENAMES:
row = await database.fetch_one(
"SELECT 1 FROM sqlite_master WHERE type = 'table' "
"AND name = ?",
(legacy_name,),
)
if row is not None:
await database.execute(
f'ALTER TABLE "{legacy_name}" RENAME TO "{current_name}"'
)
LOGGER.info(
"Renamed legacy table %s to %s", legacy_name, current_name
)
#: The ordered migration list. Append-only: never edit an applied #: The ordered migration list. Append-only: never edit an applied
#: entry, add the next one instead. #: entry, add the next one instead. (Migration 1's content was
MIGRATIONS: Sequence[Sequence[str]] = [ #: rewritten pre-release per D-007 — no version 1 database shipped.)
MIGRATIONS: Sequence[MigrationStep] = [
MIGRATION_1_V1_DDL, MIGRATION_1_V1_DDL,
_rename_legacy_task_tables,
] ]
@@ -140,7 +199,7 @@ def _migration_lock(database: NestQuestDatabase) -> asyncio.Lock:
async def apply_migrations( async def apply_migrations(
database: NestQuestDatabase, database: NestQuestDatabase,
migrations: Sequence[Sequence[str]] = MIGRATIONS, migrations: Sequence[MigrationStep] = MIGRATIONS,
) -> int: ) -> int:
"""Bring the database schema up to the latest version. """Bring the database schema up to the latest version.
@@ -208,8 +267,14 @@ async def apply_migrations(
f"{latest}); downgrades are not supported" f"{latest}); downgrades are not supported"
) )
if in_tx_version < target: if in_tx_version < target:
for sql in migrations[target - 1]: step = migrations[target - 1]
if callable(step):
await step(database)
applied_statements = 0
else:
for sql in step:
await database.execute(sql) await database.execute(sql)
applied_statements = len(step)
# INSERT keyed on the singleton row (id = 1): a # INSERT keyed on the singleton row (id = 1): a
# fresh file has no row yet, and migration 1 is the # fresh file has no row yet, and migration 1 is the
# only migration a version-0 database can run, so # only migration a version-0 database can run, so
@@ -225,12 +290,18 @@ async def apply_migrations(
) )
applied = True applied = True
if applied: if applied:
if applied_statements == 0:
LOGGER.info(
"NestQuest schema migrated to version %d",
target,
)
else:
LOGGER.info( LOGGER.info(
"NestQuest schema migrated to version %d " "NestQuest schema migrated to version %d "
"(%d statement%s)", "(%d statement%s)",
target, target,
len(migrations[target - 1]), applied_statements,
"s" if len(migrations[target - 1]) != 1 else "", "s" if applied_statements != 1 else "",
) )
return latest return latest
+11 -11
View File
@@ -86,7 +86,7 @@ date range; a single-day override stores the same date in both columns.
``is_present`` 0/1 marks the child absent/present for the whole range; ``is_present`` 0/1 marks the child absent/present for the whole range;
``note`` is optional free text. ``note`` is optional free text.
Task instances: ``task_instances`` deliberately carries NO completion Quest instances: ``quest_instances`` deliberately carries NO completion
status column. An instance's current state derives from the latest row status column. An instance's current state derives from the latest row
in ``completion_events`` (Feature 08), so materialization (Feature 07) in ``completion_events`` (Feature 08), so materialization (Feature 07)
can insert instances without knowing anything about completion and the can insert instances without knowing anything about completion and the
@@ -103,7 +103,7 @@ stamps one coherent time).
Uniqueness: the (definition_id, due_date) pair is declared as a Uniqueness: the (definition_id, due_date) pair is declared as a
table-level ``UNIQUE`` constraint rather than a separate ``CREATE table-level ``UNIQUE`` constraint rather than a separate ``CREATE
UNIQUE INDEX`` statement. SQLite materializes it as an implicit unique UNIQUE INDEX`` statement. SQLite materializes it as an implicit unique
index (``sqlite_autoindex_task_instances_1``) — the required "unique index (``sqlite_autoindex_quest_instances_1``) — the required "unique
index prevents duplicate instances" — and the constraint form cannot be index prevents duplicate instances" — and the constraint form cannot be
silently dropped the way a standalone index can, so the duplicate door silently dropped the way a standalone index can, so the duplicate door
stays shut. Idempotent materialization keys on exactly this pair. No stays shut. Idempotent materialization keys on exactly this pair. No
@@ -128,7 +128,7 @@ representable — missed is a derived state announced on the HA event bus
(Feature 10), never an appended event. (Feature 10), never an appended event.
``child_id`` is deliberately denormalized (it is also derivable via ``child_id`` is deliberately denormalized (it is also derivable via
``task_instances``): history reporting (Feature 13) filters and rates ``quest_instances``): history reporting (Feature 13) filters and rates
per child directly against the log, and the log should stay per child directly against the log, and the log should stay
self-describing without joins. self-describing without joins.
@@ -219,9 +219,9 @@ SCHEMA_V1_SCHEDULE_RULES_DDL: list[str] = [
""", """,
] ]
SCHEMA_V1_TASK_DEFINITIONS_DDL: list[str] = [ SCHEMA_V1_QUEST_DEFINITIONS_DDL: list[str] = [
""" """
CREATE TABLE IF NOT EXISTS task_definitions ( CREATE TABLE IF NOT EXISTS quest_definitions (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL, title TEXT NOT NULL,
description TEXT, description TEXT,
@@ -271,11 +271,11 @@ SCHEMA_V1_PRESENCE_OVERRIDES_DDL: list[str] = [
""", """,
] ]
SCHEMA_V1_TASK_INSTANCES_DDL: list[str] = [ SCHEMA_V1_QUEST_INSTANCES_DDL: list[str] = [
""" """
CREATE TABLE IF NOT EXISTS task_instances ( CREATE TABLE IF NOT EXISTS quest_instances (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
definition_id INTEGER NOT NULL REFERENCES task_definitions(id), definition_id INTEGER NOT NULL REFERENCES quest_definitions(id),
child_id INTEGER NOT NULL REFERENCES children(id), child_id INTEGER NOT NULL REFERENCES children(id),
due_date TEXT NOT NULL, due_date TEXT NOT NULL,
due_time TEXT, due_time TEXT,
@@ -289,7 +289,7 @@ SCHEMA_V1_COMPLETION_EVENTS_DDL: list[str] = [
""" """
CREATE TABLE IF NOT EXISTS completion_events ( CREATE TABLE IF NOT EXISTS completion_events (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
instance_id INTEGER NOT NULL REFERENCES task_instances(id), instance_id INTEGER NOT NULL REFERENCES quest_instances(id),
child_id INTEGER NOT NULL REFERENCES children(id), child_id INTEGER NOT NULL REFERENCES children(id),
event_type TEXT NOT NULL CHECK (event_type IN ('completed', 'uncompleted')), event_type TEXT NOT NULL CHECK (event_type IN ('completed', 'uncompleted')),
actor_source TEXT NOT NULL CHECK (actor_source IN ('user', 'panel')), actor_source TEXT NOT NULL CHECK (actor_source IN ('user', 'panel')),
@@ -315,10 +315,10 @@ SCHEMA_V1_STATEMENTS: list[str] = [
*SCHEMA_V1_CHILDREN_DDL, *SCHEMA_V1_CHILDREN_DDL,
*SCHEMA_V1_ADMIN_USERS_DDL, *SCHEMA_V1_ADMIN_USERS_DDL,
*SCHEMA_V1_SCHEDULE_RULES_DDL, *SCHEMA_V1_SCHEDULE_RULES_DDL,
*SCHEMA_V1_TASK_DEFINITIONS_DDL, *SCHEMA_V1_QUEST_DEFINITIONS_DDL,
*SCHEMA_V1_PRESENCE_SCHEDULES_DDL, *SCHEMA_V1_PRESENCE_SCHEDULES_DDL,
*SCHEMA_V1_PRESENCE_OVERRIDES_DDL, *SCHEMA_V1_PRESENCE_OVERRIDES_DDL,
*SCHEMA_V1_TASK_INSTANCES_DDL, *SCHEMA_V1_QUEST_INSTANCES_DDL,
*SCHEMA_V1_COMPLETION_EVENTS_DDL, *SCHEMA_V1_COMPLETION_EVENTS_DDL,
] ]
+136
View File
@@ -0,0 +1,136 @@
# Admin spec — parent phone card
Screens `1d`–`1i`. One card, `nestquest-admin-card`, four tabs. Reference viewport
390×844. Plain CubeCraft styling and plain domain words — no parchment, no Cinzel, no
quest vocabulary in this surface.
## 1. Shell
- Page `--nq-a-page`; cards `--nq-a-surface` with 1px `--nq-a-border`.
- **Header** white, 1px bottom border: screen title 24px/800, tracking −.02em, plus a
12px/600 `--nq-a-ink-secondary` sub-line. Optional trailing action (icon square 36px,
or a gradient `New` button).
- **Tab bar** absolute bottom, `--nq-a-tabbar-height` 70px + 12px bottom padding, white,
1px top border, 4 items: `house` Today · `list-checks` Tasks · `calendar-days` Schedule ·
`history` History. Active icon and label `--nq-brand-blue` at 700; inactive icon
`--nq-a-ink-tertiary`, label `--nq-a-ink-secondary` 600.
- **Every scroll column ends with 92px bottom padding** so content clears the tab bar.
This was a real defect in review — do not omit it.
- Two densities exist. Ship **compact**; `--nq-a-density-*` carries the roomy values.
- Everything this card mutates goes through a NestQuest service, which enforces the admin
allowlist server-side. The card must assume it can be rendered by a non-admin and still
be safe — hiding controls is not the boundary (D-003).
## 2. Today (1d compact, 1h roomy)
### 2.1 Header
Title `Today`; sub `<weekday>, <Month D> · cycle day N of 14`; trailing `settings` square.
### 2.2 Stat row
Three equal cards, 8px gap, padding 12px: numeral 22px/800 (done in `--nq-brand-blue`,
remaining in `--nq-a-ink`, overdue in `--nq-a-danger`), label 10px/600 uppercase tracking
.08em `--nq-a-ink-secondary` — `Done`, `Remaining`, `Overdue`.
### 2.3 Child rows
One card, rows divided by `--nq-a-divider`, row padding 11px 13px:
30px gradient avatar square (radius 7px, initial 13px/800 white) · name 14px/700 ·
meta 11px/600 (`3 of 7 · 1 overdue`; all-done in `--nq-a-success`; away reads
`Away · returns <Mon D>`) · 82×6px progress track with gradient fill. An away child's
avatar is flat `#9898A8` and the row carries a lowercase `override` pill when the absence
comes from a presence override rather than the pattern.
### 2.4 Roomy variant (1h)
Header becomes a hero: kicker 11px/700 uppercase, `8 of 12 done` 32px/800, 10px gradient
progress bar, 13px/600 sub-line. Each child gets its own 12px-radius card with
`0 4px 12px rgba(0,0,0,.06)`, 48px avatar, 19px/800 name, right-aligned `3/7` +
status line, and its overdue item inlined as a `--nq-a-danger-bg` row with a
`Mark done` action.
### 2.5 Needs attention
Section label 11px/700 uppercase, then one card of event rows: Lucide glyph 18px ·
title `<Quest> · <Child>` 13px/700 · meta 11px/600 (`Overdue since 9:00 AM` in
`--nq-a-danger`; `Completed 7:04 AM from panel` in `--nq-a-ink-secondary`) · trailing
text action `Mark done` (`--nq-brand-blue`) or `Undo` (`--nq-brand-purple`).
### 2.6 Permission note
An info strip (`--nq-a-info-bg`, `info` glyph, 11.5px/600 `--nq-a-info-ink`):
"Undo is admin-only. A panel tap can never reverse a completion." Keep this copy — it is
the one place the security model is explained to the user.
## 3. Definitions (1e)
### 3.1 List
Header `Tasks` / `7 definitions · 3 children` + gradient `New` button. Filter chips row
(All / one per child): active `#18181D` fill white text, inactive white with border.
Rows: glyph 17px · title 13.5px/700 · meta 11px/600 `<assignees> · <recurrence> · <windows>`
· `chevron-right`. An inactive definition renders the whole row at
`--nq-a-ink-tertiary` with `· inactive` in the meta — never hard-deleted (glossary).
The row being edited takes `--nq-a-selected` with brand-blue glyph, `Editing` meta and
`chevron-down`.
### 3.2 Edit sheet
Bottom sheet, `--nq-a-radius-sheet`, `--nq-a-sheet-shadow`, 38×4px grabber, padding
14px 18px 24px. Title 19px/800. Fields, 11px gap, each with a 10px/700 uppercase label:
| Field | Control |
|---|---|
| Assigned children | multi-select row → sheet; shows `Declan` or `All three` |
| Repeats | select (Daily / Weekly / Monthly / Yearly / Custom + interval) |
| Due time | time field per window |
| Window | 3-up segmented, **multi-select** — a definition may own several |
| Skip on away days | toggle, gradient when on, sub "No instance when the child is absent" |
Footer: `Cancel` (flex 1, bordered) + `Save changes` (flex 2, gradient), both 13px vertical
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.
## 4. Schedule (1f)
- Header `Schedule` / `2-week cycle · anchor <weekday>, <Mon D>`.
- **Month grid** card: month label 11px/700 uppercase + chevron pair; weekday initials
10px/700 `--nq-a-ink-tertiary`; day cells `aspect-ratio: 1`, radius 6px —
rotation-home days `--nq-a-selected` with `#201F7A` numerals, away days
`--nq-a-page` with `--nq-a-ink-secondary`, override days `--nq-a-warning-bg` with
`--nq-a-warning-ink` and a 1px warning border, today filled with the brand gradient in
white 800. Legend row beneath, 10.5px/600, one swatch per state.
- **Presence pattern** card: one row per child, name 13px/700 + rule 12px/600
(`Weeks 1 & 3 of cycle`, `Every day`).
- **Overrides** card: `Add` text action; rows with `calendar-x` / `calendar-check`,
title `<Child> away · <Mon D – D>`, meta reason, trailing `trash-2`.
- Cycle position is always computed from the anchor date. **Never** from ISO week numbers
or week parity (D-004).
## 5. History (1g)
- Header `History` / `Append-only event log · last 14 days` + bordered `CSV` button
(`download` glyph) that exports the filtered range.
- Stat row: `91%` On time · `128` Completed · `7` Missed, numerals 20px/800.
- Filter chips: `All events` / `Reversals` / `Missed`.
- Day-grouped sections (label 11px/700 uppercase) of event rows: 7px status dot
(green completed, `--nq-a-reversal` un-completed, `--nq-a-danger` missed) ·
title `<Quest> · <Child>` 12.5px/700 · meta 10.5px/600 giving event type, actor and
on-time flag (`completed · panel (Declan) · on time`, `uncompleted · joshua (admin)`,
`missed · nightly sweep`, `completed · panel (Declan) · late 22 min`) · time 11px/600.
- Footer strip (`--nq-a-page`, `lock` glyph): "Events are never edited or deleted. A
reversal is its own row." Rows are never editable in the UI (D-005).
## 6. Presence override editor (1i)
Pushed screen with a back chevron; title `Presence override` / sub "Beats the custody
pattern for these dates".
- Form card (roomy density, 18px padding, 16px gaps):
**Child** 3-up segmented, selected = gradient;
**Status for these dates** 2-up segmented (`Away` / `Home`), selected = `#18181D`;
**From** / **Through** date fields side by side, 13px padding, 14px/700 values;
**Reason (optional)** text field.
- **Consequence warning** — `--nq-a-warning-bg` card with `alert-circle`:
headline `This removes N upcoming tasks` 13.5px/700 `--nq-a-warning-ink`, body 12px/600
`--nq-a-warning-ink-2` naming the child, the date range, and stating "Completed days are
untouched." The count must be computed live from the materialiser before saving.
- Footer: `Cancel` (flex 1, white, bordered) + `Save override` (flex 2, gradient), 15px
vertical padding.
- Saving must never delete an instance that already has a completion event.
+82
View File
@@ -0,0 +1,82 @@
# NestQuest — design handoff
Approved UI design for the NestQuest Home Assistant integration, ready to implement.
Source of truth for visuals: the mockup file `NestQuest Mockups.dc.html` (9 screens,
option ids `1a`–`1j`). This folder is the written form of those screens.
## What is in here
| File | Contents |
|---|---|
| `PANEL-SPEC.md` | Kids' DAKOS wall panel: party board, quest log, confirm, quest complete |
| `ADMIN-SPEC.md` | Parent phone admin: today, definitions, schedule, history, overrides |
| `ENTITIES-AND-SERVICES.md` | Every screen mapped to the HA entities, services and events it needs |
| `IMPLEMENTATION-ORDER.md` | Build order, with the decisions that change existing code first |
| `tokens/nestquest-panel-tokens.css` | Panel card custom properties |
| `tokens/nestquest-admin-tokens.css` | Admin card custom properties |
## Screen index
| id | Screen | Surface | Spec |
|---|---|---|---|
| 1a | The Party (attract) | panel 1920×1080 | PANEL §2 |
| 1b | Quest Log | panel 1920×1080 | PANEL §3 |
| 1j | Complete confirm | panel 1920×1080 | PANEL §4 |
| 1c | Quest complete | panel 1920×1080 | PANEL §5 |
| 1d | Today (compact) | phone 390×844 | ADMIN §2 |
| 1e | Definitions + edit sheet | phone 390×844 | ADMIN §3 |
| 1f | Schedule + custody | phone 390×844 | ADMIN §4 |
| 1g | History + CSV | phone 390×844 | ADMIN §5 |
| 1h | Today (roomy) | phone 390×844 | ADMIN §2.4 |
| 1i | Presence override | phone 390×844 | ADMIN §6 |
## Design decisions taken during this handoff
These were open in CTXD and are now settled. Several contradict the current repo and
docs — read `IMPLEMENTATION-ORDER.md` §1 before writing code.
1. **`quest_*` naming throughout.** The domain model is renamed from `task_*` to
`quest_*` in DB, services, entities and UI. Migration note in IMPLEMENTATION-ORDER §1.1.
2. **Instance key becomes `(definition_id, child_id, due_date, window)`.** Required by
multi-assignee definitions and by twice-daily quests. Supersedes the documented
`(definition_id, due_date)`.
3. **A definition may have multiple assignees**, and materialises one instance per
assigned child per date per window. Each child completes their own.
4. **A definition may declare more than one window** (morning / afternoon / evening),
producing one instance per window per day.
5. **Missed quests vanish from the panel at rollover.** Kids never see a wall of failure;
the missed event is in the parent's history only.
6. **Three Lovelace cards**: `nestquest-party-board-card`, `nestquest-quest-log-card`,
`nestquest-admin-card`. Board and log live in separate Lovelace views; a crest tap
navigates.
7. **Completion needs a confirm step** (screen 1j) because children cannot undo.
8. **The quest log owns idle return**: 40 s of no touch navigates back to the board.
TouchHub Auto-Return must be configured longer than 40 s so the two do not fight.
9. **Actor on a panel completion** is `panel` plus the tapped child profile, per glossary.
10. **Weather is a configured entity** on the party board card (`weather_entity`), not
NestQuest data.
11. **Fonts are bundled** as local woff2 in the card's `www/` folder — the panel may be
offline. Nunito (400/600/700/800) and Cinzel + Cinzel Decorative.
## Vocabulary
The panel speaks tabletop; the admin speaks plainly. Both sit on the same `quest_*` model.
| Model / admin | Panel display string |
|---|---|
| quest instance | quest |
| child | adventurer |
| child's task list | Quest Log |
| household | The Party |
| all complete | Quest complete |
| absent | On travels |
## Open questions
- Empty and error states are unspecified by request (child with no quests, everyone away,
integration unconfigured, DB unreachable). Panel must not render a blank parchment —
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).
+117
View File
@@ -0,0 +1,117 @@
# Screens → HA entities, services and events
Planned surface from CTXD CONTEXT.MD, adjusted for the design decisions in
`DESIGN-HANDOFF.md`. Names use the `quest_*` model.
## 1. Entities the cards read
| Entity | Type | Used by |
|---|---|---|
| `sensor.nestquest_<child>_quests_due_today` | int | 1a plate progress, 1b counts, 1d stats |
| `sensor.nestquest_<child>_quests_completed_today` | int | 1a, 1b, 1c, 1d, 1h |
| `sensor.nestquest_<child>_quests_remaining_today` | int | 1b remaining block, 1d |
| `sensor.nestquest_<child>_completion_pct_today` | float | progress bars |
| `sensor.nestquest_<child>_next_quest` | str + attrs | optional panel hint |
| `binary_sensor.nestquest_<child>_all_done` | bool | 1c trigger, 1d "all done" |
| `binary_sensor.nestquest_<child>_present_today` | bool | 1a away plate, 1d away row |
| `sensor.nestquest_household_quests_due_today` | int | 1b party line, 1c party stat |
| `sensor.nestquest_household_quests_completed_today` | int | same |
| `sensor.nestquest_cycle_day` | int | 1d sub-line, 1f header |
| *configured* `weather.<entity>` | HA core | panel dock |
Per-instance data is too granular for entity state. The cards read the day's instance list
from an attribute payload on the per-child sensor (or a websocket command —
implementer's call), shaped:
```json
{
"instances": [
{ "id": 4821, "definition_id": 12, "child_id": 3, "title": "Fill bird feeder",
"icon": "bird", "window": "afternoon", "due_time": "17:00",
"state": "open", "overdue": true, "completed_at": null, "on_time": null }
]
}
```
`state` ∈ `open` | `completed`. Missed instances are **omitted** from panel payloads
(decision 5) and included in admin payloads.
## 2. Services the cards call
| Service | Admin only | Called from |
|---|---|---|
| `nestquest.complete_quest` | no (panel allowed) | 1j confirm |
| `nestquest.uncomplete_quest` | **yes** | 1d `Undo` |
| `nestquest.create_quest_definition` | **yes** | 1e `New` |
| `nestquest.update_quest_definition` | **yes** | 1e sheet `Save changes` |
| `nestquest.set_quest_definition_active` | **yes** | 1e row action |
| `nestquest.set_presence_pattern` | **yes** | 1f presence card |
| `nestquest.create_presence_override` | **yes** | 1i `Save override` |
| `nestquest.delete_presence_override` | **yes** | 1f override row `trash-2` |
| `nestquest.export_history_csv` | **yes** | 1g `CSV` |
| `nestquest.manage_child` | **yes** | settings |
`complete_quest` payload from the panel:
```yaml
service: nestquest.complete_quest
data:
instance_id: 4821
actor: panel # kiosk user context
actor_child_id: 3 # the tapped profile (decision 9)
```
Every service resolves `call.context.user_id` against the admin allowlist and **fails
closed** with no user context (D-003). The panel's kiosk user is never allowlisted, so
`uncomplete_quest` is unreachable from the wall even though the card never renders the
control.
## 3. Events the cards listen for
| Event | Effect |
|---|---|
| `nestquest_quest_completed` | stamp the seal, re-sort the column, refresh counts |
| `nestquest_quest_uncompleted` | un-seal, restore the card (admin action, panel must reflect it) |
| `nestquest_quest_missed` | remove the instance from the panel; add a history row |
| `nestquest_child_day_complete` | show screen 1c |
All four come off the `DataUpdateCoordinator`; cards should also refresh on
`state_changed` for their sensors so they recover if an event is dropped.
## 4. Card configuration
```yaml
# kids' panel, view 1
type: custom:nestquest-party-board-card
weather_entity: weather.home # optional; dock falls back to date/time
quest_log_path: /nestquest/log # view the crest tap navigates to
child_order: [declan, jordyn, chloe]
# kids' panel, view 2
type: custom:nestquest-quest-log-card
board_path: /nestquest/board
idle_return_seconds: 40 # must be shorter than TouchHub Auto-Return
confirm_timeout_seconds: 15
complete_screen_seconds: 12
# parent phone
type: custom:nestquest-admin-card
density: compact # compact | roomy
default_tab: today
```
The quest log reads the selected child from the view's URL/`hass` path parameter so the
board → log hop is a plain Lovelace navigation (decision 6).
## 5. Data-shape consequences of the design
1. Instance key is `(definition_id, child_id, due_date, window)` — the schema's
`task_instances` unique index must widen.
2. `quest_definitions` needs an **assignees** join table, replacing the single
`child_id` column.
3. `quest_definitions` needs a **windows** set (morning/afternoon/evening) plus an
optional due time per window.
4. The materialiser emits one instance per (assignee × window × firing date), still
idempotent on the widened key, still only when the child is present.
5. `completion_events` gains `actor_child_id` so a panel completion records which
profile was tapped, while `actor` stays `panel`.
+80
View File
@@ -0,0 +1,80 @@
# Implementation order
Written for an agent working in `code.cubecraftlabs.com/Maniacs_Mansion/NestQuest`.
Steps 1.x change existing code and must land first; the frontend is step 4 onward.
## 1. Model changes (breaking, pre-release)
### 1.1 Rename `task_*` → `quest_*`
Nothing is released (main/dev are at "first commit", version 0.1.0), so rename in place
rather than aliasing.
- `schema.py`: `task_definitions` → `quest_definitions`, `task_instances` →
`quest_instances`. Keep `completion_events`, `children`, `admin_users`,
`schedule_rules`, `presence_schedules`, `presence_overrides`.
- `db.py` / `store.py`: rename DAO methods and any SQL literals.
- `const.py`: no table names live there today; keep it that way.
- `tests/test_schema.py`, `tests/test_store.py`, `tests/test_db.py`: update expectations.
- **Migration note:** the versioned migration runner has no released version to upgrade
from. Bump the schema version, and make migration 1 create the `quest_*` tables
directly. If any dev machine already holds a `nestquest.db` with `task_*` tables, the
runner should detect the legacy names and `ALTER TABLE ... RENAME TO` before applying
the widened index — a dev-only path, worth ~20 lines and a test.
### 1.2 Widen the instance key
Unique index becomes `(definition_id, child_id, due_date, window)`. Materialisation stays
idempotent on that tuple. Add a test for the twice-daily case (one definition, two windows,
one child, one date → two rows) and the shared case (one definition, three assignees, one
window → three rows).
### 1.3 Multi-assignee definitions
Add `quest_definition_assignees(definition_id, child_id)`. Drop the single `child_id`
column on the definition. The "exactly one assigned child" rule in GLOSSARY.MD no longer
holds — CTXD has been updated.
### 1.4 Windows
Add `quest_definition_windows(definition_id, window, due_time)` with
`window ∈ ('morning','afternoon','evening')`. Window clock ranges are fixed for now:
morning ≤ 11:59, afternoon 12:00–17:00, evening 17:00–21:00. Store the ranges in
`const.py` so they are configurable later.
### 1.5 `actor_child_id` on completion events
Nullable int. Set for panel completions, null for admin ones. Still append-only.
## 2. Engines and materialisation
Unchanged in intent: pure recurrence engine, pure presence engine with anchor-date
arithmetic, daily materialiser over `horizon_days` at `day_rollover_time`. Only the
instance fan-out changes (assignees × windows).
## 3. Services, permissions, entities
Implement the service table in `ENTITIES-AND-SERVICES.md` §2 behind the permission
decorator, then the sensors and binary sensors in §1, then the four events in §3.
`complete_quest` is the only service a non-admin context may call.
## 4. Frontend groundwork
1. Create `custom_components/nestquest/www/`.
2. Bundle fonts as woff2: Nunito 400/600/700/800; Cinzel 600/700/900; Cinzel Decorative
700/900. Declare `@font-face` with `font-display: swap` in a shared
`nestquest-fonts.css`. No CDN — the panel may be offline.
3. Copy `design/tokens/nestquest-panel-tokens.css` and
`design/tokens/nestquest-admin-tokens.css` in as the cards' `:host` blocks.
4. Register the three cards with `customCards` and ship editors later; hand-written YAML
is fine for v1.
## 5. Cards, in this order
1. `nestquest-party-board-card` (screen 1a) — read-only, proves the entity payload.
2. `nestquest-quest-log-card` (1b) — read-only first, no completion.
3. Confirm dialog + `complete_quest` (1j) with optimistic seal and revert-on-failure.
4. Quest complete screen (1c) driven by `nestquest_child_day_complete`.
5. Idle return (40 s) and the documented TouchHub Auto-Return constraint.
6. `nestquest-admin-card`: Today (1d) → Definitions + sheet (1e) → Schedule (1f) →
History + CSV (1g) → Presence override (1i).
## 6. Before shipping
- `use_x_frame_options: false` is required for TouchHub embedding — document the
clickjacking trade-off next to the setting, per D-006.
- Verify no NestQuest control in the dock strip is tappable.
- Verify the kiosk HA user is absent from the admin allowlist, and that
`uncomplete_quest` denies it with a logged denial.
- Empty and error states are still unspecified (see DESIGN-HANDOFF "Open questions").
Do not ship a card that renders a blank parchment.
+170
View File
@@ -0,0 +1,170 @@
# Panel spec — kids' DAKOS wall panel
Screens `1a`, `1b`, `1j`, `1c` of the mockups. Two cards:
`nestquest-party-board-card` (§2) and `nestquest-quest-log-card` (§3–5).
## 1. Frame and constraints
- **Canvas** 1920×1080, landscape, fixed. No scrolling — content must fit. Every screen in
the mockups measures exactly 1920×1080 with no overflow; keep it that way.
- **Viewing distance** ~3 ft. **No text below 22px.** Meta lines are 22px/600 Nunito;
quest titles 30px/700 Cinzel; section heads 32px.
- **Contrast** every ink token is ≥4.5:1 on parchment. Do not introduce alpha-muted text.
- **Ground** `--nq-p-parchment` + `--nq-p-vignette`, CSS only, no image assets.
- **Double frame** inset 26px (`--nq-p-frame-outer`, radius 14px) and inset 36px
(`--nq-p-frame-inner`, radius 8px), both `pointer-events: none`.
- **Dock** bottom strip, 84px, inset 46px, radius 14px, `--nq-p-dock-bg`. Weather only.
**Nothing in the dock is tappable** (D-006).
- **No login.** Every action comes from the kiosk HA user; the kiosk user is never in the
admin allowlist.
## 2. The Party — attract state (1a)
Default view on the wall. Layout top to bottom:
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.
4. **Hint line** `Tap your crest to open your Quest Log`, 27px/600 Nunito, y≈930.
5. **Dock.**
### 2.1 Adventurer plate (present)
Radius 20px, `--nq-p-card-panel`, 2px `--nq-p-panel-border`, `--nq-p-panel-shadow`,
padding 44px 28px 40px, contents centred, 26px gap. **The whole plate is the tap target**
(≥188px wide, ≥600px tall — far above minimum).
- **Crest** 188×214, shield via `clip-path: polygon(0 0, 100% 0, 100% 62%, 50% 100%, 0 62%)`,
`--nq-p-crest`, inset ring `inset 0 0 0 4px rgba(255,255,255,.22)`, child's initial
82px/700 display in white.
- **Name** 48px/700 Cinzel, `--nq-p-ink`, tracking .02em.
- **Presence pill** radius 9999px, `#e8f4ea` on `rgba(22,120,60,.35)`, `check-circle` 22px
`#157a3c`, label `Home today` 19px/600 Cinzel uppercase `#146b36`.
- **Progress line** `N of M quests claimed` 26px/700 Cinzel `--nq-p-ink-secondary`.
- **Progress bar** full width, 16px, radius 9999px, track `rgba(92,62,26,.18)` with 1px
border, fill `--nq-brand-gradient-h`.
### 2.2 Adventurer plate (away)
Same geometry, muted: `linear-gradient(#f2ecdd, #e6dcc6)`, **2px dashed** border, no lift
shadow. Crest uses `--nq-p-crest-away`; initial stays **full-opacity white** (never alpha).
Name in `--nq-p-ink-away`. Pill: `tent` icon, label `On travels`, neutral fill. Progress
line reads `Returns <weekday>, <Mon D>`; empty progress track, no fill. **Not tappable** —
the plate must not navigate, since the child has no instances.
### 2.3 Dock
`cloud-sun` 40px · temperature 38px/800 Nunito `--nq-p-dock-ink` · 1px divider ·
condition + high/low 26px/600 `--nq-p-dock-ink-secondary` · divider · one short forecast
phrase. All read from the configured `weather_entity`; if unset, render the dock with date
and time only rather than removing it (the strip is part of the frame).
## 3. Quest Log (1b)
Reached by tapping a present crest. Header at y=56, page inset 62px:
- Small crest 96×110 (initial 44px/700 display).
- Title `<Name>'s Quest Log` 56px/700 display.
- Sub `<weekday>, <Month D> · N of M claimed` 20px/600 Cinzel, tracking .24em, uppercase.
- **Remaining block** right-aligned: radius 14px panel, d20 mark 44px with the remaining
count, `N quests left` 28px/700 Cinzel, and a party line
`Party progress · X of Y today` 22px/600 Nunito.
### 3.1 Window columns
Three equal columns, 34px gap, top y=236, one per window — **Morning** (until 11:59 AM),
**Afternoon** (12:00–5:00 PM), **Evening** (5:00–9:00 PM). Column head: icon
(`sunrise` / `sun` / `moon`) 34px, name 32px/700 Cinzel, sub-label 21px/600 Cinzel
uppercase with the window's clock range, right-aligned `done/total` 26px/700 Cinzel,
14px bottom padding, `--nq-p-rule` underline.
Cards stack at `--nq-p-quest-gap` (24px). Order within a column: **incomplete first,
sealed below**. A window with no quests renders its head and nothing else — do not
collapse the column, the three-column rhythm is the layout.
### 3.2 Quest card — open
`--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.
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,
`--nq-p-ink-late`.
3. **Complete button** `--nq-p-button-height` 72px × min 150px, radius 10px,
`--nq-brand-gradient`, `check` 30px + `Complete` 24px/700 Cinzel white,
`box-shadow: 0 3px 0 rgba(40,20,60,.35)`.
Both the card body and the button open the confirm dialog (§4). Press feedback:
`scale(.98)`, `--nq-dur-micro`.
### 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-ink-muted` with `text-decoration: line-through`. Meta reads
`Claimed <h:mm A>` 22px/600 Nunito `--nq-p-ink-secondary`.
**Wax seal** `--nq-p-seal-size` 78px circle, `--nq-p-seal`, `--nq-p-seal-shadow`,
`check` 38px in `rgba(255,235,235,.95)`, absolutely positioned `right: 16px; top: -12px`,
rotated a few degrees — **vary the rotation per card** (−9°, +6°, −4° in the mockup) so the
board looks stamped by hand, not templated. Entrance: fade + `scale(1.15 → 1)`,
`--nq-dur-base`, `--nq-ease-out`.
### 3.4 Party roll-up
An optional dashed panel in the afternoon column: label `Party roll-up` 18px/600 Cinzel
uppercase, body 23px/600 Nunito `--nq-p-ink-secondary` — one or two sentences on the other
children ("Jordyn has finished her log. Chloe is on travels until Sunday."). Read-only.
### 3.5 Idle return
40 s with no touch → navigate back to the party board view. Reset on any pointer or touch
event. The card owns this; TouchHub Auto-Return must be set longer than 40 s.
## 4. Complete confirm (1j)
Modal over the dimmed log — scrim `rgba(30,20,10,.62)` over the live board.
Dialog 1000px wide at `top: 196px; left: 460px`, radius 20px, parchment panel with 3px
`rgba(92,62,26,.5)`, `box-shadow: 0 26px 60px rgba(20,10,0,.5)`, padding 64px 68px 56px.
- Header row: 104px icon tile (radius 12px, brand gradient, glyph 56px) · window kicker
21px/600 Cinzel uppercase tracking .24em · quest title 58px/700 display.
- Body 32px/600 Nunito `#3f2f1c`: `Mark this quest complete? Once the seal is set, only a
parent can undo it.`
- Buttons, 24px gap, both `--nq-p-confirm-button` 108px tall:
**Not yet** flex 1, `rgba(92,62,26,.1)` with 2px border, `x` 34px + 30px/700 Cinzel;
**Complete** flex 2, brand gradient, `check` 38px + 32px/700 Cinzel white.
- Dialog enters with fade + `scale(.96 → 1)`, `--nq-dur-modal`, `--nq-ease-out`.
- Dismiss: **Not yet**, scrim tap, or 15 s of no input.
On confirm, call the completion service optimistically, stamp the seal, and re-sort the
column. On service failure, revert the card and surface a single-line parchment toast.
## 5. Quest complete (1c)
Shown when the last instance for a child's day is completed; auto-returns to the board
after 12 s (countdown line `Returning to The Party in N seconds`, 25px/600 Nunito).
- Large seal 168px at y=96 (`check` 86px), rotated −6°, `0 10px 24px rgba(60,10,20,.42)`.
- `Quest complete` 86px/900 display, brand-gradient text fill.
- Sub 34px/600 Nunito `--nq-p-ink-secondary`, max-width 1000px, centred, `text-wrap: pretty`:
names the child, the date, and how the day went.
- **Stat blocks** 4-up at y=540, inset 200px, 26px gap: radius 16px parchment panels,
numeral 58px/900 Cinzel, label 17px/600 Cinzel uppercase tracking .14em —
`Quests claimed`, `On time`, `Late`, `The Party` (household X/Y).
- Dock switches to the evening variant (`cloud-moon`).
## 6. Accessibility and input
- Minimum tap target on this surface is the quest card itself (116px) and the 72px button;
nothing interactive is smaller than 72px.
- 24px between adjacent tap targets so a stray finger cannot claim the wrong quest.
- No hover states — this is a touch-only surface. Provide `:active` feedback only.
- No animation loops. Entrances only, per the CubeCraft motion rules.
+76
View File
@@ -0,0 +1,76 @@
/* ============================================================
NestQuest — Parent admin card tokens
Surface: phone, 390x844 reference. Plain CubeCraft styling,
no tabletop treatment, plain domain words in the UI.
Scope on the card host: nestquest-admin-card.
Derived from the approved mockups (admin screens 1d-1i).
Two densities were built; COMPACT is the default. Flip
--nq-a-density-* to the roomy values for the alternative.
============================================================ */
:host {
/* ── Brand + neutrals (CubeCraft tokens, verbatim) ─────── */
--nq-brand-blue: #3B3AB8;
--nq-brand-purple: #A035CC;
--nq-brand-gradient: linear-gradient(135deg, var(--nq-brand-blue), var(--nq-brand-purple));
--nq-brand-gradient-h: linear-gradient(90deg, var(--nq-brand-blue), var(--nq-brand-purple));
--nq-a-page: #F4F4F6;
--nq-a-surface: #FFFFFF;
--nq-a-surface-subtle: #FAFAFA;
--nq-a-selected: #EBEBF8; /* row being edited */
--nq-a-border: #E4E4EA;
--nq-a-divider: #EDEDF1;
--nq-a-ink: #18181D;
--nq-a-ink-secondary: #52525E;
--nq-a-ink-tertiary: #70707E; /* inactive rows, unselected nav icons */
--nq-a-success: #15803D;
--nq-a-danger: #DC2626;
--nq-a-danger-strong: #B91C1C;
--nq-a-danger-bg: #FEE2E2;
--nq-a-warning-ink: #92400E;
--nq-a-warning-ink-2: #78350F;
--nq-a-warning-bg: #FEF3C7;
--nq-a-warning-border: rgba(217,119,6,.35);
--nq-a-info-bg: #DBEAFE;
--nq-a-info-ink: #1E3A8A;
--nq-a-info-icon: #1D4ED8;
--nq-a-reversal: #7D28A0; /* un-complete events in history */
/* ── Type ─────────────────────────────────────────────── */
--nq-a-font: Nunito, system-ui, sans-serif;
--nq-a-size-screen: 24px; /* 800, screen title */
--nq-a-size-hero: 32px; /* 800, roomy "8 of 12 done" */
--nq-a-size-card-title: 19px; /* 800, roomy child card */
--nq-a-size-stat: 22px; /* 800, compact stat number */
--nq-a-size-row: 13.5px;/* 700, list row title */
--nq-a-size-meta: 11px; /* 600, row meta */
--nq-a-size-label: 10px; /* 700, uppercase field label */
--nq-a-track-label: .10em;
/* ── Density: COMPACT (default) ───────────────────────── */
--nq-a-density-page-pad: 14px 16px 92px; /* bottom clears the 70px tab bar */
--nq-a-density-gap: 10px;
--nq-a-density-card-pad: 13px;
--nq-a-density-row-pad: 11px 13px;
--nq-a-density-radius: 8px;
--nq-a-density-shadow: none;
/* Density: ROOMY — page-pad 18px 20px 92px; gap 16px;
card-pad 18px; radius 12px; shadow 0 4px 12px rgba(0,0,0,.06) */
/* ── Metrics ──────────────────────────────────────────── */
--nq-a-tabbar-height: 70px; /* + 12px safe-area padding */
--nq-a-tap-min: 44px; /* every row/control clears this */
--nq-a-radius-pill: 9999px;
--nq-a-radius-sheet: 20px 20px 0 0;
--nq-a-sheet-shadow: 0 -8px 28px rgba(9,9,11,.18);
--nq-a-progress-height: 6px; /* 8px roomy, 10px hero */
/* ── Motion ───────────────────────────────────────────── */
--nq-ease-out: cubic-bezier(0, 0, .2, 1);
--nq-dur-micro: 120ms;
--nq-dur-base: 200ms;
--nq-dur-modal: 350ms;
}
+94
View File
@@ -0,0 +1,94 @@
/* ============================================================
NestQuest — Kids' panel card tokens
Surface: DAKboard DAKOS wall panel, 1920x1080 landscape,
viewed from ~3 ft, no login, read/complete only.
Scope these on the card host: nestquest-quest-log-card,
nestquest-party-board-card.
Derived from the approved mockups (panel screens 1a, 1b, 1c, 1j).
============================================================ */
:host {
/* ── Parchment ground ─────────────────────────────────── */
--nq-p-parchment-top: #f0e4c7;
--nq-p-parchment-bottom: #ddcca3;
--nq-p-parchment: radial-gradient(ellipse at 25% 10%, rgba(255,255,255,.5), transparent 55%),
radial-gradient(ellipse at 85% 90%, rgba(120,86,44,.28), transparent 60%),
repeating-linear-gradient(93deg, rgba(150,115,70,.05) 0 2px, transparent 2px 6px),
repeating-linear-gradient(2deg, rgba(150,115,70,.04) 0 3px, transparent 3px 7px),
linear-gradient(var(--nq-p-parchment-top), var(--nq-p-parchment-bottom));
--nq-p-vignette: inset 0 0 200px rgba(80,52,20,.35);
/* Card surfaces sitting on parchment */
--nq-p-card-open: linear-gradient(#fdf7e6, #f2e7c9); /* incomplete quest */
--nq-p-card-done: linear-gradient(#f0e8d3, #e6dcc2); /* sealed quest */
--nq-p-card-panel: linear-gradient(#fcf6e6, #f1e6ca); /* adventurer plate, stat block */
--nq-p-card-border: rgba(120,88,48,.38);
--nq-p-panel-border: rgba(92,62,26,.40);
--nq-p-card-shadow: 0 3px 0 rgba(120,88,48,.20), inset 0 1px 0 rgba(255,255,255,.70);
--nq-p-panel-shadow: 0 6px 0 rgba(92,62,26,.18), inset 0 2px 0 rgba(255,255,255,.70);
--nq-p-frame-outer: 2px solid rgba(92,62,26,.45);
--nq-p-frame-inner: 1px solid rgba(92,62,26,.28);
--nq-p-rule: 2px solid rgba(92,62,26,.35);
/* ── Ink (all values >= 4.5:1 on parchment) ───────────── */
--nq-p-ink: #2b1f14; /* titles, quest names */
--nq-p-ink-secondary: #5c452a; /* meta, window labels, counts */
--nq-p-ink-muted: #6f6455; /* sealed quest titles only */
--nq-p-ink-away: #4d4433; /* away adventurer name */
--nq-p-ink-late: #8f1526; /* overdue line */
/* ── Brand (CubeCraft, toned down: accents only) ──────── */
--nq-brand-blue: #3B3AB8;
--nq-brand-purple: #A035CC;
--nq-brand-gradient: linear-gradient(135deg, var(--nq-brand-blue), var(--nq-brand-purple));
--nq-p-icon-tile: var(--nq-brand-gradient); /* open quest icon tile */
--nq-p-icon-tile-done: rgba(92,62,26,.16);
--nq-p-crest: var(--nq-brand-gradient);
--nq-p-crest-away: linear-gradient(135deg, #6b6b7a, #7a7286);
/* ── Wax seal (completed marker) ──────────────────────── */
--nq-p-seal: radial-gradient(circle at 35% 30%, #a8283a, #6d1322);
--nq-p-seal-shadow: 0 4px 10px rgba(60,10,20,.40), inset 0 0 0 4px rgba(255,255,255,.14);
--nq-p-seal-size: 78px; /* 168px on the quest-complete screen */
/* ── Dock (bottom strip, non-interactive) ─────────────── */
--nq-p-dock-bg: rgba(43,31,20,.90);
--nq-p-dock-ink: #f7efdb;
--nq-p-dock-ink-secondary: #d8c9a6;
--nq-p-dock-divider: rgba(233,220,189,.30);
--nq-p-dock-height: 84px;
/* ── Type ─────────────────────────────────────────────── */
--nq-p-font-display: 'Cinzel Decorative', Cinzel, serif; /* NestQuest, names, Quest complete */
--nq-p-font-heading: Cinzel, serif; /* section heads, quest titles, numerals */
--nq-p-font-body: Nunito, system-ui, sans-serif; /* meta lines, dock */
--nq-p-size-hero: 86px; /* 900 weight, "Quest complete" */
--nq-p-size-wordmark: 66px; /* 900 weight */
--nq-p-size-title: 56px; /* 700, "Declan's Quest Log" */
--nq-p-size-name: 48px; /* 700, adventurer name */
--nq-p-size-section: 32px; /* 700, Morning / Afternoon / Evening */
--nq-p-size-quest: 30px; /* 700, quest title */
--nq-p-size-body: 22px; /* 600 Nunito, meta — PANEL MINIMUM */
--nq-p-size-label: 21px; /* 600 Cinzel, uppercase, tracked */
--nq-p-track-label: .14em;
--nq-p-track-kicker: .30em;
/* ── Metrics: touch ───────────────────────────────────── */
--nq-p-quest-min-height: 116px; /* whole card is the tap target */
--nq-p-quest-gap: 24px; /* separation between tap targets */
--nq-p-button-height: 72px; /* "Complete" button on a quest card */
--nq-p-button-min-width: 150px;
--nq-p-confirm-button: 108px; /* confirm dialog buttons */
--nq-p-radius-card: 12px;
--nq-p-radius-panel: 20px;
--nq-p-radius-pill: 9999px;
--nq-p-page-inset: 62px;
--nq-p-frame-inset: 26px;
/* ── Motion (CubeCraft easing) ────────────────────────── */
--nq-ease-out: cubic-bezier(0, 0, .2, 1);
--nq-dur-micro: 120ms;
--nq-dur-base: 200ms;
--nq-dur-modal: 350ms;
}
+15 -15
View File
@@ -1,4 +1,4 @@
"""Tests for dao_instances.py: task_instances + append-only completion_events.""" """Tests for dao_instances.py: quest_instances + append-only completion_events."""
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
@@ -11,10 +11,10 @@ from custom_components.nestquest.dao_children import ChildrenDao
from custom_components.nestquest.dao_instances import ( from custom_components.nestquest.dao_instances import (
CompletionEventRecord, CompletionEventRecord,
CompletionEventsDao, CompletionEventsDao,
TaskInstanceRecord, QuestInstanceRecord,
TaskInstancesDao, QuestInstancesDao,
) )
from custom_components.nestquest.dao_rules import ScheduleRulesDao, TaskDefinitionsDao from custom_components.nestquest.dao_rules import ScheduleRulesDao, QuestDefinitionsDao
from custom_components.nestquest.db import NestQuestDatabase from custom_components.nestquest.db import NestQuestDatabase
from custom_components.nestquest.migrations import apply_migrations from custom_components.nestquest.migrations import apply_migrations
@@ -76,8 +76,8 @@ async def _prepare(path) -> tuple:
await apply_migrations(database) await apply_migrations(database)
children = ChildrenDao(database) children = ChildrenDao(database)
rules = ScheduleRulesDao(database) rules = ScheduleRulesDao(database)
definitions = TaskDefinitionsDao(database) definitions = QuestDefinitionsDao(database)
instances = TaskInstancesDao(database) instances = QuestInstancesDao(database)
events = CompletionEventsDao(database) events = CompletionEventsDao(database)
child = await children.create("Ada", _now_stamp()) child = await children.create("Ada", _now_stamp())
rule = await rules.create("daily", D1) rule = await rules.create("daily", D1)
@@ -111,7 +111,7 @@ def _with_db(tmp_path, name):
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# task_instances: upsert idempotency # quest_instances: upsert idempotency
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -121,13 +121,13 @@ def test_upsert_creates_then_is_idempotent(tmp_path) -> None:
first = await instances.upsert( first = await instances.upsert(
definition.id, child.id, D1, _now_stamp() definition.id, child.id, D1, _now_stamp()
) )
assert isinstance(first, TaskInstanceRecord) assert isinstance(first, QuestInstanceRecord)
second = await instances.upsert( second = await instances.upsert(
definition.id, child.id, D1, _now_stamp() definition.id, child.id, D1, _now_stamp()
) )
assert second.id == first.id assert second.id == first.id
rows = await database.fetch_one( rows = await database.fetch_one(
"SELECT COUNT(*) FROM task_instances" "SELECT COUNT(*) FROM quest_instances"
) )
assert rows == (1,) assert rows == (1,)
return second return second
@@ -323,8 +323,8 @@ def test_upsert_no_past_check_uses_execution_date_not_call_date(
await apply_migrations(database) await apply_migrations(database)
children = ChildrenDao(database) children = ChildrenDao(database)
rules = ScheduleRulesDao(database) rules = ScheduleRulesDao(database)
definitions = TaskDefinitionsDao(database) definitions = QuestDefinitionsDao(database)
instances = TaskInstancesDao(database) instances = QuestInstancesDao(database)
events = CompletionEventsDao(database) events = CompletionEventsDao(database)
child = await children.create("Ada", _now_stamp()) child = await children.create("Ada", _now_stamp())
rule = await rules.create("daily", D1) rule = await rules.create("daily", D1)
@@ -369,7 +369,7 @@ def test_upsert_refuses_immutable_completed_instance(tmp_path) -> None:
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# task_instances: get / list # quest_instances: get / list
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -453,7 +453,7 @@ def test_list_by_date_range_rejects_inverted_range(tmp_path) -> None:
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# task_instances: delete_future_uncompleted # quest_instances: delete_future_uncompleted
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -1084,8 +1084,8 @@ def test_append_concurrent_events_serialize(tmp_path) -> None:
async def _prepare_in(database): async def _prepare_in(database):
children = ChildrenDao(database) children = ChildrenDao(database)
rules = ScheduleRulesDao(database) rules = ScheduleRulesDao(database)
definitions = TaskDefinitionsDao(database) definitions = QuestDefinitionsDao(database)
instances = TaskInstancesDao(database) instances = QuestInstancesDao(database)
events = CompletionEventsDao(database) events = CompletionEventsDao(database)
child = await children.create("Ada", _now_stamp()) child = await children.create("Ada", _now_stamp())
rule = await rules.create("daily", D1) rule = await rules.create("daily", D1)
+13 -13
View File
@@ -27,7 +27,7 @@ from custom_components.nestquest.dao_children import (
) )
from custom_components.nestquest.dao_instances import ( from custom_components.nestquest.dao_instances import (
CompletionEventsDao, CompletionEventsDao,
TaskInstancesDao, QuestInstancesDao,
) )
from custom_components.nestquest.dao_presence import ( from custom_components.nestquest.dao_presence import (
PresenceOverridesDao, PresenceOverridesDao,
@@ -35,7 +35,7 @@ from custom_components.nestquest.dao_presence import (
) )
from custom_components.nestquest.dao_rules import ( from custom_components.nestquest.dao_rules import (
ScheduleRulesDao, ScheduleRulesDao,
TaskDefinitionsDao, QuestDefinitionsDao,
) )
from custom_components.nestquest.db import NestQuestDatabase from custom_components.nestquest.db import NestQuestDatabase
from custom_components.nestquest.migrations import apply_migrations from custom_components.nestquest.migrations import apply_migrations
@@ -70,10 +70,10 @@ ALL_TABLES = (
"children", "children",
"admin_users", "admin_users",
"schedule_rules", "schedule_rules",
"task_definitions", "quest_definitions",
"presence_schedules", "presence_schedules",
"presence_overrides", "presence_overrides",
"task_instances", "quest_instances",
"completion_events", "completion_events",
) )
@@ -86,10 +86,10 @@ class World:
self.children = ChildrenDao(database) self.children = ChildrenDao(database)
self.admins = AdminUsersDao(database) self.admins = AdminUsersDao(database)
self.rules = ScheduleRulesDao(database) self.rules = ScheduleRulesDao(database)
self.definitions = TaskDefinitionsDao(database) self.definitions = QuestDefinitionsDao(database)
self.schedules = PresenceSchedulesDao(database) self.schedules = PresenceSchedulesDao(database)
self.overrides = PresenceOverridesDao(database) self.overrides = PresenceOverridesDao(database)
self.instances = TaskInstancesDao(database) self.instances = QuestInstancesDao(database)
self.events = CompletionEventsDao(database) self.events = CompletionEventsDao(database)
async def seed(self) -> None: async def seed(self) -> None:
@@ -201,7 +201,7 @@ def test_matrix_schedule_rules_crud_and_constraints(tmp_path) -> None:
assert _with_world(tmp_path / "m-rules.db")(_body) is True assert _with_world(tmp_path / "m-rules.db")(_body) is True
def test_matrix_task_definitions_crud_and_constraints(tmp_path) -> None: def test_matrix_quest_definitions_crud_and_constraints(tmp_path) -> None:
async def _body(w: World): async def _body(w: World):
# insert (seeded) + read # insert (seeded) + read
fetched = await w.definitions.get(w.definition.id) fetched = await w.definitions.get(w.definition.id)
@@ -271,7 +271,7 @@ def test_matrix_presence_overrides_crud_and_constraints(tmp_path) -> None:
assert _with_world(tmp_path / "m-overrides.db")(_body) is True assert _with_world(tmp_path / "m-overrides.db")(_body) is True
def test_matrix_task_instances_crud_and_constraints(tmp_path) -> None: def test_matrix_quest_instances_crud_and_constraints(tmp_path) -> None:
async def _body(w: World): async def _body(w: World):
# insert (seeded) + read # insert (seeded) + read
fetched = await w.instances.get(w.definition.id, D1) fetched = await w.instances.get(w.definition.id, D1)
@@ -292,20 +292,20 @@ def test_matrix_task_instances_crud_and_constraints(tmp_path) -> None:
assert _with_world(tmp_path / "m-instances.db")(_body) is True assert _with_world(tmp_path / "m-instances.db")(_body) is True
def test_matrix_task_instances_constraints(tmp_path) -> None: def test_matrix_quest_instances_constraints(tmp_path) -> None:
async def _body(w: World): async def _body(w: World):
# D20 has no instance yet: a raw insert succeeds, then an # D20 has no instance yet: a raw insert succeeds, then an
# identical second insert must fail on the UNIQUE constraint — # identical second insert must fail on the UNIQUE constraint —
# proving the schema backs the DAO's idempotency. # proving the schema backs the DAO's idempotency.
await w.database.execute( await w.database.execute(
"INSERT INTO task_instances (definition_id, child_id, " "INSERT INTO quest_instances (definition_id, child_id, "
"due_date, due_time, generated_at) " "due_date, due_time, generated_at) "
"VALUES (?, ?, ?, NULL, ?)", "VALUES (?, ?, ?, NULL, ?)",
(w.definition.id, w.child.id, D20, _stamp()), (w.definition.id, w.child.id, D20, _stamp()),
) )
with pytest.raises(sqlite3.IntegrityError): with pytest.raises(sqlite3.IntegrityError):
await w.database.execute( await w.database.execute(
"INSERT INTO task_instances (definition_id, child_id, " "INSERT INTO quest_instances (definition_id, child_id, "
"due_date, due_time, generated_at) " "due_date, due_time, generated_at) "
"VALUES (?, ?, ?, NULL, ?)", "VALUES (?, ?, ?, NULL, ?)",
(w.definition.id, w.child.id, D20, _stamp()), (w.definition.id, w.child.id, D20, _stamp()),
@@ -320,7 +320,7 @@ def test_matrix_task_instances_constraints(tmp_path) -> None:
assert _with_world(tmp_path / "m-instances-constraints.db")(_body) is True assert _with_world(tmp_path / "m-instances-constraints.db")(_body) is True
def test_matrix_task_instances_upsert_idempotency(tmp_path) -> None: def test_matrix_quest_instances_upsert_idempotency(tmp_path) -> None:
async def _body(w: World): async def _body(w: World):
first = await w.instances.upsert( first = await w.instances.upsert(
w.definition.id, w.child.id, D2, _stamp() w.definition.id, w.child.id, D2, _stamp()
@@ -330,7 +330,7 @@ def test_matrix_task_instances_upsert_idempotency(tmp_path) -> None:
) )
assert second.id == first.id assert second.id == first.id
count = await w.database.fetch_one( count = await w.database.fetch_one(
"SELECT COUNT(*) FROM task_instances" "SELECT COUNT(*) FROM quest_instances"
) )
# Seed created one (D1) plus this one (D2) — no duplicate. # Seed created one (D1) plus this one (D2) — no duplicate.
assert count == (2,) assert count == (2,)
+25 -25
View File
@@ -1,4 +1,4 @@
"""Tests for dao_rules.py: typed DAO for schedule_rules and task_definitions.""" """Tests for dao_rules.py: typed DAO for schedule_rules and quest_definitions."""
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
@@ -10,8 +10,8 @@ from custom_components.nestquest.dao_children import ChildrenDao
from custom_components.nestquest.dao_rules import ( from custom_components.nestquest.dao_rules import (
ScheduleRuleRecord, ScheduleRuleRecord,
ScheduleRulesDao, ScheduleRulesDao,
TaskDefinitionRecord, QuestDefinitionRecord,
TaskDefinitionsDao, QuestDefinitionsDao,
) )
from custom_components.nestquest.db import NestQuestDatabase from custom_components.nestquest.db import NestQuestDatabase
from custom_components.nestquest.migrations import apply_migrations from custom_components.nestquest.migrations import apply_migrations
@@ -37,7 +37,7 @@ async def _prepare(path) -> tuple:
await database.open(path) await database.open(path)
await apply_migrations(database) await apply_migrations(database)
rules = ScheduleRulesDao(database) rules = ScheduleRulesDao(database)
definitions = TaskDefinitionsDao(database) definitions = QuestDefinitionsDao(database)
children = ChildrenDao(database) children = ChildrenDao(database)
child = await children.create("Ada", NOW) child = await children.create("Ada", NOW)
return database, rules, definitions, children, child return database, rules, definitions, children, child
@@ -322,7 +322,7 @@ def test_rule_delete_concurrent_with_definition_create_is_safe(
await apply_migrations(database) await apply_migrations(database)
children = ChildrenDao(database) children = ChildrenDao(database)
rules = ScheduleRulesDao(database) rules = ScheduleRulesDao(database)
definitions = TaskDefinitionsDao(database) definitions = QuestDefinitionsDao(database)
child = await children.create("Ada", NOW) child = await children.create("Ada", NOW)
rule_a = await rules.create("daily", "2026-09-14") rule_a = await rules.create("daily", "2026-09-14")
@@ -335,7 +335,7 @@ def test_rule_delete_concurrent_with_definition_create_is_safe(
import custom_components.nestquest.dao_rules as dao_rules import custom_components.nestquest.dao_rules as dao_rules
original_validate = ( original_validate = (
dao_rules.TaskDefinitionsDao._validate_assignable dao_rules.QuestDefinitionsDao._validate_assignable
) )
release = asyncio.Event() release = asyncio.Event()
started = asyncio.Event() started = asyncio.Event()
@@ -353,7 +353,7 @@ def test_rule_delete_concurrent_with_definition_create_is_safe(
started.set() started.set()
await release.wait() await release.wait()
dao_rules.TaskDefinitionsDao._validate_assignable = ( dao_rules.QuestDefinitionsDao._validate_assignable = (
_pausing_validate _pausing_validate
) )
try: try:
@@ -373,7 +373,7 @@ def test_rule_delete_concurrent_with_definition_create_is_safe(
delete_task, create_task, return_exceptions=True delete_task, create_task, return_exceptions=True
) )
finally: finally:
dao_rules.TaskDefinitionsDao._validate_assignable = ( dao_rules.QuestDefinitionsDao._validate_assignable = (
original_validate original_validate
) )
else: else:
@@ -405,7 +405,7 @@ def test_rule_delete_concurrent_with_definition_create_is_safe(
assert rule_row is None assert rule_row is None
else: else:
# Create won: the delete was rejected, both rows exist. # Create won: the delete was rejected, both rows exist.
assert isinstance(creation, TaskDefinitionRecord), ( assert isinstance(creation, QuestDefinitionRecord), (
f"create must succeed, got {creation!r}" f"create must succeed, got {creation!r}"
) )
assert rule_row is not None assert rule_row is not None
@@ -425,7 +425,7 @@ def test_rule_delete_concurrent_with_definition_create_is_safe(
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# task_definitions: create / get # quest_definitions: create / get
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -441,7 +441,7 @@ def test_definition_create_returns_typed_record(tmp_path) -> None:
icon="mdi:tooth", icon="mdi:tooth",
due_time="08:00", due_time="08:00",
) )
assert isinstance(definition, TaskDefinitionRecord) assert isinstance(definition, QuestDefinitionRecord)
assert definition.title == "Brush teeth" assert definition.title == "Brush teeth"
assert definition.child_id == child.id assert definition.child_id == child.id
assert definition.schedule_rule_id == rule.id assert definition.schedule_rule_id == rule.id
@@ -497,7 +497,7 @@ def test_definition_create_unknown_rule_raises_value_error(
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# task_definitions: list by child / list active # quest_definitions: list by child / list active
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -539,7 +539,7 @@ def test_definition_list_active_excludes_inactive(tmp_path) -> None:
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# task_definitions: update / set_active / set_assignee # quest_definitions: update / set_active / set_assignee
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -673,7 +673,7 @@ def test_definition_no_delete_method_exists(tmp_path) -> None:
methods = { methods = {
name name
for name, _ in inspect.getmembers( for name, _ in inspect.getmembers(
TaskDefinitionsDao, inspect.isfunction QuestDefinitionsDao, inspect.isfunction
) )
} }
assert "delete" not in methods assert "delete" not in methods
@@ -748,13 +748,13 @@ def test_definition_set_assignee_leaves_history_rows_alone(
# Simulate an already-generated instance (materialization owns # Simulate an already-generated instance (materialization owns
# this table, but its row shape proves reassignment isolation). # this table, but its row shape proves reassignment isolation).
await database.execute( await database.execute(
"INSERT INTO task_instances (definition_id, child_id, " "INSERT INTO quest_instances (definition_id, child_id, "
"due_date, generated_at) VALUES (?, ?, ?, ?)", "due_date, generated_at) VALUES (?, ?, ?, ?)",
(definition.id, child.id, "2026-09-15", NOW), (definition.id, child.id, "2026-09-15", NOW),
) )
await definitions.set_assignee(definition.id, other.id) await definitions.set_assignee(definition.id, other.id)
row = await database.fetch_one( row = await database.fetch_one(
"SELECT child_id FROM task_instances WHERE definition_id = ?", "SELECT child_id FROM quest_instances WHERE definition_id = ?",
(definition.id,), (definition.id,),
) )
assert row == (child.id,) assert row == (child.id,)
@@ -769,7 +769,7 @@ def test_definition_set_assignee_leaves_history_rows_alone(
def test_rules_and_definitions_sql_lives_only_in_dao_module() -> None: def test_rules_and_definitions_sql_lives_only_in_dao_module() -> None:
"""Guardrail: schedule_rules/task_definitions SQL may appear only """Guardrail: schedule_rules/quest_definitions SQL may appear only
in the DAO modules (and the schema DDL declarations). RECURSIVE in the DAO modules (and the schema DDL declarations). RECURSIVE
scan of package and tests, FROM/INTO/UPDATE/DELETE FROM/JOIN scan of package and tests, FROM/INTO/UPDATE/DELETE FROM/JOIN
pattern, with exact repo-relative exclusions justified by role: pattern, with exact repo-relative exclusions justified by role:
@@ -801,7 +801,7 @@ def test_rules_and_definitions_sql_lives_only_in_dao_module() -> None:
} }
sql_pattern = re.compile( sql_pattern = re.compile(
r"(FROM|INTO|UPDATE|DELETE\s+FROM|JOIN)\s+[`'\"]*(\[)?" r"(FROM|INTO|UPDATE|DELETE\s+FROM|JOIN)\s+[`'\"]*(\[)?"
r"(schedule_rules|task_definitions)\b", r"(schedule_rules|quest_definitions)\b",
re.IGNORECASE, re.IGNORECASE,
) )
offenders: list[str] = [] offenders: list[str] = []
@@ -813,16 +813,16 @@ def test_rules_and_definitions_sql_lives_only_in_dao_module() -> None:
if sql_pattern.search(py.read_text()): if sql_pattern.search(py.read_text()):
offenders.append(relative) offenders.append(relative)
assert offenders == [], ( assert offenders == [], (
f"SQL touching schedule_rules/task_definitions leaked into: " f"SQL touching schedule_rules/quest_definitions leaked into: "
f"{offenders}" f"{offenders}"
) )
def test_dao_instances_test_file_uses_dao_not_raw_rules_sql() -> None: def test_dao_instances_test_file_uses_dao_not_raw_rules_sql() -> None:
"""Compensating self-scan for the test_dao_instances.py exemption: """Compensating self-scan for the test_dao_instances.py exemption:
that file may query task_instances/completion_events raw (its own that file may query quest_instances/completion_events raw (its own
guard's scope) but must go through the DAO for schedule_rules and guard's scope) but must go through the DAO for schedule_rules and
task_definitions, except its single sanctioned definition-create quest_definitions, except its single sanctioned definition-create
helper usage and guard spans. helper usage and guard spans.
""" """
import ast import ast
@@ -853,12 +853,12 @@ def test_dao_instances_test_file_uses_dao_not_raw_rules_sql() -> None:
) )
sql_pattern = re.compile( sql_pattern = re.compile(
r"(SELECT\s[^\"']*?FROM|INSERT\s+INTO|UPDATE|DELETE\s+FROM|" r"(SELECT\s[^\"']*?FROM|INSERT\s+INTO|UPDATE|DELETE\s+FROM|"
r"FROM|JOIN)\s+[`'\"]*(\[)?(schedule_rules|task_definitions)\b", r"FROM|JOIN)\s+[`'\"]*(\[)?(schedule_rules|quest_definitions)\b",
re.IGNORECASE, re.IGNORECASE,
) )
assert sql_pattern.search(remaining) is None, ( assert sql_pattern.search(remaining) is None, (
"test_dao_instances.py must go through the DAO, not raw SQL, " "test_dao_instances.py must go through the DAO, not raw SQL, "
"for schedule_rules/task_definitions" "for schedule_rules/quest_definitions"
) )
@@ -866,7 +866,7 @@ def test_dao_rules_test_file_uses_dao_not_raw_table_sql() -> None:
"""This test module must exercise the DAO, not raw SQL, for these """This test module must exercise the DAO, not raw SQL, for these
tables. Exceptions: the guard functions' own spans (their regexes tables. Exceptions: the guard functions' own spans (their regexes
mention the names), and the reassignment-isolation test, which mention the names), and the reassignment-isolation test, which
legitimately touches task_instances — a table this DAO does not legitimately touches quest_instances — a table this DAO does not
own — to prove the guardrail that reassignment leaves it alone. own — to prove the guardrail that reassignment leaves it alone.
""" """
import ast import ast
@@ -898,7 +898,7 @@ def test_dao_rules_test_file_uses_dao_not_raw_table_sql() -> None:
) )
sql_pattern = re.compile( sql_pattern = re.compile(
r"(SELECT\s[^\"']*?FROM|INSERT\s+INTO|UPDATE|DELETE\s+FROM|" r"(SELECT\s[^\"']*?FROM|INSERT\s+INTO|UPDATE|DELETE\s+FROM|"
r"FROM|JOIN)\s+[`'\"]*(\[)?(schedule_rules|task_definitions)\b", r"FROM|JOIN)\s+[`'\"]*(\[)?(schedule_rules|quest_definitions)\b",
re.IGNORECASE, re.IGNORECASE,
) )
assert sql_pattern.search(remaining) is None, ( assert sql_pattern.search(remaining) is None, (
+2 -2
View File
@@ -247,12 +247,12 @@ async def test_setup_applies_all_v1_tables(hass, make_entry) -> None:
rows = await database.fetch_all( rows = await database.fetch_all(
"SELECT name FROM sqlite_master WHERE type = 'table' " "SELECT name FROM sqlite_master WHERE type = 'table' "
"AND name IN ('children', 'admin_users', 'schedule_rules', " "AND name IN ('children', 'admin_users', 'schedule_rules', "
"'task_definitions') ORDER BY name" "'quest_definitions') ORDER BY name"
) )
assert [row[0] for row in rows] == [ assert [row[0] for row in rows] == [
"admin_users", "admin_users",
"children", "children",
"quest_definitions",
"schedule_rules", "schedule_rules",
"task_definitions",
] ]
await async_unload_entry(hass, entry) await async_unload_entry(hass, entry)
+283 -32
View File
@@ -62,10 +62,10 @@ def _counts(database) -> dict[str, int]:
"children", "children",
"admin_users", "admin_users",
"schedule_rules", "schedule_rules",
"task_definitions", "quest_definitions",
"presence_schedules", "presence_schedules",
"presence_overrides", "presence_overrides",
"task_instances", "quest_instances",
"completion_events", "completion_events",
) )
counts = {} counts = {}
@@ -76,16 +76,16 @@ def _counts(database) -> dict[str, int]:
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Fresh file: version 0 -> version 1 # Fresh file: version 0 -> latest version
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def test_fresh_file_migrates_to_version_1(tmp_path) -> None: def test_fresh_file_migrates_to_latest_version(tmp_path) -> None:
database = _open_db(tmp_path / "fresh.db") database = _open_db(tmp_path / "fresh.db")
try: try:
final = _migrate(database) final = _migrate(database)
assert final == 1 assert final == len(MIGRATIONS)
assert _version(database) == 1 assert _version(database) == len(MIGRATIONS)
finally: finally:
_run(database.close()) _run(database.close())
@@ -98,10 +98,10 @@ def test_fresh_file_migration_creates_all_v1_tables(tmp_path) -> None:
"children", "children",
"admin_users", "admin_users",
"schedule_rules", "schedule_rules",
"task_definitions", "quest_definitions",
"presence_schedules", "presence_schedules",
"presence_overrides", "presence_overrides",
"task_instances", "quest_instances",
"completion_events", "completion_events",
VERSION_TABLE, VERSION_TABLE,
} }
@@ -135,9 +135,249 @@ def test_empty_version_table_reads_as_version_0(tmp_path) -> None:
_run(database.close()) _run(database.close())
def test_migration_1_is_the_full_v1_ddl() -> None: def test_migration_list_shape() -> None:
"""Migration 1 is the v1 DDL; migration 2 is the legacy rename step.
Nothing ever shipped, so migration 1 was rewritten pre-release
(D-007) to create the quest_* tables directly; migration 2 exists
for dev databases stamped version 1 by the pre-rename runner.
"""
assert MIGRATIONS[0] == SCHEMA_V1_STATEMENTS assert MIGRATIONS[0] == SCHEMA_V1_STATEMENTS
assert len(MIGRATIONS) == 1 assert callable(MIGRATIONS[1])
assert len(MIGRATIONS) == 2
# ---------------------------------------------------------------------------
# Legacy dev database: task_* tables stamped version 1 -> quest_*
# ---------------------------------------------------------------------------
_LEGACY_DDL = [
"""
CREATE TABLE IF NOT EXISTS children (
id INTEGER PRIMARY KEY AUTOINCREMENT,
display_name TEXT NOT NULL,
colour TEXT,
avatar_ref TEXT,
sort_order INTEGER NOT NULL DEFAULT 0,
is_active INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL
)
""",
"""
CREATE TABLE IF NOT EXISTS admin_users (
ha_user_id TEXT PRIMARY KEY NOT NULL,
added_at TEXT NOT NULL
)
""",
"""
CREATE TABLE IF NOT EXISTS schedule_rules (
id INTEGER PRIMARY KEY AUTOINCREMENT,
rule_type TEXT NOT NULL,
interval INTEGER NOT NULL DEFAULT 1,
weekday_set TEXT,
day_of_month INTEGER,
nth_weekday INTEGER,
month INTEGER,
start_date TEXT NOT NULL,
end_date TEXT
)
""",
"""
CREATE TABLE IF NOT EXISTS task_definitions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
description TEXT,
icon TEXT,
child_id INTEGER NOT NULL REFERENCES children(id),
schedule_rule_id INTEGER NOT NULL REFERENCES schedule_rules(id),
due_time TEXT,
is_active INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL
)
""",
"""
CREATE TABLE IF NOT EXISTS presence_schedules (
id INTEGER PRIMARY KEY AUTOINCREMENT,
child_id INTEGER NOT NULL UNIQUE REFERENCES children(id),
cycle_length_weeks INTEGER NOT NULL,
anchor_date TEXT NOT NULL,
pattern TEXT NOT NULL
)
""",
"""
CREATE TABLE IF NOT EXISTS presence_overrides (
id INTEGER PRIMARY KEY AUTOINCREMENT,
child_id INTEGER NOT NULL REFERENCES children(id),
start_date TEXT NOT NULL,
end_date TEXT NOT NULL,
is_present INTEGER NOT NULL,
note TEXT
)
""",
"""
CREATE TABLE IF NOT EXISTS task_instances (
id INTEGER PRIMARY KEY AUTOINCREMENT,
definition_id INTEGER NOT NULL REFERENCES task_definitions(id),
child_id INTEGER NOT NULL REFERENCES children(id),
due_date TEXT NOT NULL,
due_time TEXT,
generated_at TEXT NOT NULL,
UNIQUE (definition_id, due_date)
)
""",
"""
CREATE TABLE IF NOT EXISTS completion_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
instance_id INTEGER NOT NULL REFERENCES task_instances(id),
child_id INTEGER NOT NULL REFERENCES children(id),
event_type TEXT NOT NULL,
actor_source TEXT NOT NULL,
actor_user_id TEXT,
occurred_at TEXT NOT NULL,
was_on_time INTEGER
)
""",
]
def _seed_legacy_database(database) -> None:
"""Create a database indistinguishable from a pre-rename dev file.
Applies the legacy task_* DDL, seeds one row in every renamed
table, and stamps version 1 exactly the way the old runner did.
"""
for sql in _LEGACY_DDL:
_run(database.execute(sql))
_run(database.execute(VERSION_TABLE_DDL))
_run(
database.execute(
f"INSERT INTO {VERSION_TABLE} (id, version) VALUES (1, 1)"
)
)
_run(
database.execute(
"INSERT INTO children (display_name, created_at) "
"VALUES ('Ada', '2026-09-14T00:00:00+00:00')"
)
)
_run(
database.execute(
"INSERT INTO schedule_rules (rule_type, start_date) "
"VALUES ('daily', '2026-09-14')"
)
)
_run(
database.execute(
"INSERT INTO task_definitions (title, child_id, "
"schedule_rule_id, created_at) "
"VALUES ('Dishes', 1, 1, '2026-09-14T00:00:00+00:00')"
)
)
_run(
database.execute(
"INSERT INTO task_instances (definition_id, child_id, "
"due_date, generated_at) "
"VALUES (1, 1, '2026-09-15', '2026-09-14T00:00:00+00:00')"
)
)
def test_legacy_task_tables_are_renamed_and_keep_their_rows(tmp_path) -> None:
database = _open_db(tmp_path / "legacy.db")
try:
_seed_legacy_database(database)
final = _migrate(database)
assert final == len(MIGRATIONS)
assert _version(database) == len(MIGRATIONS)
tables = _tables(database)
assert "task_definitions" not in tables
assert "task_instances" not in tables
assert {"quest_definitions", "quest_instances"} <= tables
# Every seeded row survived the rename.
assert _run(database.fetch_one("SELECT COUNT(*) FROM children")) == (1,)
assert (
_run(
database.fetch_one(
"SELECT title FROM quest_definitions WHERE id = 1"
)
)
== ("Dishes",)
)
assert (
_run(
database.fetch_one(
"SELECT due_date FROM quest_instances WHERE id = 1"
)
)
== ("2026-09-15",)
)
# The untouched completion_events table still exists and is empty.
assert "completion_events" in tables
assert (
_run(database.fetch_one("SELECT COUNT(*) FROM completion_events"))
== (0,)
)
finally:
_run(database.close())
def test_renamed_instances_foreign_key_follows_the_rename(tmp_path) -> None:
database = _open_db(tmp_path / "legacy-fk.db")
try:
_seed_legacy_database(database)
_migrate(database)
fks = _run(database.fetch_all("PRAGMA foreign_key_list(quest_instances)"))
referenced = {row[2] for row in fks}
assert "quest_definitions" in referenced
assert "task_definitions" not in referenced
# The renamed table is live: a new row validates its FKs.
_run(
database.execute(
"INSERT INTO children (display_name, created_at) "
"VALUES ('Ben', '2026-09-14T00:00:00+00:00')"
)
)
with pytest.raises(sqlite3.IntegrityError):
_run(
database.execute(
"INSERT INTO quest_instances (definition_id, child_id, "
"due_date, generated_at) "
"VALUES (999, 2, '2026-09-16', "
"'2026-09-14T00:00:00+00:00')"
)
)
finally:
_run(database.close())
def test_legacy_rename_is_idempotent_across_restarts(tmp_path) -> None:
database = _open_db(tmp_path / "legacy-restart.db")
try:
_seed_legacy_database(database)
_migrate(database)
final = _migrate(database)
assert final == len(MIGRATIONS)
tables = _tables(database)
assert "quest_definitions" in tables
assert "task_definitions" not in tables
assert (
_run(database.fetch_one("SELECT COUNT(*) FROM quest_definitions"))
== (1,)
)
finally:
_run(database.close())
def test_fresh_database_never_holds_legacy_table_names(tmp_path) -> None:
database = _open_db(tmp_path / "fresh-legacy.db")
try:
_migrate(database)
tables = _tables(database)
assert not any(name.startswith("task_") for name in tables)
assert _version(database) == len(MIGRATIONS)
finally:
_run(database.close())
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -162,8 +402,8 @@ def test_second_run_applies_nothing_and_keeps_rows(tmp_path) -> None:
) )
) )
final = _migrate(database) final = _migrate(database)
assert final == 1 assert final == len(MIGRATIONS)
assert _version(database) == 1 assert _version(database) == len(MIGRATIONS)
assert _counts(database)["children"] == 1 assert _counts(database)["children"] == 1
assert _counts(database)["admin_users"] == 1 assert _counts(database)["admin_users"] == 1
finally: finally:
@@ -228,7 +468,7 @@ def test_noop_run_is_logged_at_debug_level(caplog, tmp_path) -> None:
with caplog.at_level(logging.DEBUG, logger="custom_components.nestquest.migrations"): with caplog.at_level(logging.DEBUG, logger="custom_components.nestquest.migrations"):
_migrate(database) _migrate(database)
assert any( assert any(
"already at version 1" in r.getMessage() f"already at version {len(MIGRATIONS)}" in r.getMessage()
for r in caplog.records for r in caplog.records
) )
finally: finally:
@@ -241,9 +481,17 @@ def test_noop_run_is_logged_at_debug_level(caplog, tmp_path) -> None:
def _failing_migrations(prior_statements: list[str]) -> list[list[str]]: def _failing_migrations(prior_statements: list[str]) -> list[list[str]]:
"""Two-migration list where migration 2 fails mid-application.""" """Three-migration list where migration 3 fails mid-application.
Migration 2 is a harmless no-op filler so the real runner — which
has already stamped version 2 by the time these tests re-run it —
still has a pending migration to fail on.
"""
return [ return [
prior_statements, prior_statements,
[
"CREATE TABLE IF NOT EXISTS filler_table (id INTEGER PRIMARY KEY)"
],
[ [
"CREATE TABLE IF NOT EXISTS later_table (id INTEGER PRIMARY KEY)", "CREATE TABLE IF NOT EXISTS later_table (id INTEGER PRIMARY KEY)",
"CREATE TABLE broken (", "CREATE TABLE broken (",
@@ -254,20 +502,20 @@ def _failing_migrations(prior_statements: list[str]) -> list[list[str]]:
def test_failing_migration_rolls_back_to_prior_version(tmp_path) -> None: def test_failing_migration_rolls_back_to_prior_version(tmp_path) -> None:
database = _open_db(tmp_path / "rollback.db") database = _open_db(tmp_path / "rollback.db")
try: try:
# Manually simulate a database at version 1: apply migration 1's # Manually simulate a fully-migrated database: run the real
# statements directly and stamp version 1 through the runner. # migration list, which stamps the latest version.
_migrate(database) _migrate(database)
version_row = _run( version_row = _run(
database.fetch_one(f"SELECT version FROM {VERSION_TABLE}") database.fetch_one(f"SELECT version FROM {VERSION_TABLE}")
) )
assert version_row == (1,) assert version_row == (len(MIGRATIONS),)
with pytest.raises(sqlite3.OperationalError): with pytest.raises(sqlite3.OperationalError):
_migrate(database, _failing_migrations(SCHEMA_V1_STATEMENTS)) _migrate(database, _failing_migrations(SCHEMA_V1_STATEMENTS))
# Version stamp unchanged, statements from the failed migration # Version stamp unchanged, statements from the failed migration
# absent, and the v1 tables untouched. # absent, and the v1 tables untouched.
assert _version(database) == 1 assert _version(database) == len(MIGRATIONS)
assert "later_table" not in _tables(database) assert "later_table" not in _tables(database)
assert _counts(database)["children"] == 0 assert _counts(database)["children"] == 0
assert _counts(database)["completion_events"] == 0 assert _counts(database)["completion_events"] == 0
@@ -275,21 +523,24 @@ def test_failing_migration_rolls_back_to_prior_version(tmp_path) -> None:
_run(database.close()) _run(database.close())
def test_failing_migration_on_fresh_file_leaves_version_0(tmp_path) -> None: def test_failing_migration_on_fresh_file_leaves_last_good_version(
tmp_path,
) -> None:
database = _open_db(tmp_path / "fresh-rollback.db") database = _open_db(tmp_path / "fresh-rollback.db")
try: try:
# The fresh file still reaches migration 2 (the failing one): # The fresh file reaches the failing migration (the last one):
# migration 1 commits, stamping version 1, before migration 2 # earlier migrations commit and stamp before it fails and rolls
# fails and rolls back. The run aborts at version 1 — the last # back. The run aborts at the last fully-applied migration —
# fully-applied migration — not at version 0. # not at version 0.
broken = _failing_migrations(SCHEMA_V1_STATEMENTS)
with pytest.raises(sqlite3.OperationalError): with pytest.raises(sqlite3.OperationalError):
_migrate(database, _failing_migrations(SCHEMA_V1_STATEMENTS)) _migrate(database, broken)
assert _version(database) == 1 assert _version(database) == len(broken) - 1
# Migration 2's tables must not survive the failed attempt. # The failing migration's tables must not survive the attempt.
assert "later_table" not in _tables(database) assert "later_table" not in _tables(database)
# Migration 1's tables do: it committed before the failure. # Migration 1's tables do: it committed before the failure.
assert "children" in _tables(database) assert "children" in _tables(database)
assert "task_instances" in _tables(database) assert "quest_instances" in _tables(database)
finally: finally:
_run(database.close()) _run(database.close())
@@ -302,12 +553,12 @@ def test_failed_migration_can_be_retried_after_fix(tmp_path) -> None:
with pytest.raises(sqlite3.OperationalError): with pytest.raises(sqlite3.OperationalError):
_migrate(database, broken) _migrate(database, broken)
# "Fix" the migration and retry: it now applies cleanly. # "Fix" the migration and retry: it now applies cleanly.
broken[1] = [ broken[-1] = [
"CREATE TABLE IF NOT EXISTS later_table (id INTEGER PRIMARY KEY)" "CREATE TABLE IF NOT EXISTS later_table (id INTEGER PRIMARY KEY)"
] ]
final = _migrate(database, broken) final = _migrate(database, broken)
assert final == 2 assert final == len(broken)
assert _version(database) == 2 assert _version(database) == len(broken)
assert "later_table" in _tables(database) assert "later_table" in _tables(database)
finally: finally:
_run(database.close()) _run(database.close())
@@ -575,7 +826,7 @@ async def test_setup_entry_migrates_fresh_database(hass, make_entry) -> None:
entry = await _setup_entry(hass, make_entry(), hass.registry) entry = await _setup_entry(hass, make_entry(), hass.registry)
database = entry.runtime_data.database database = entry.runtime_data.database
assert await read_schema_version(database) == 1 assert await read_schema_version(database) == len(MIGRATIONS)
# The v1 tables exist because the runner applied migration 1. # The v1 tables exist because the runner applied migration 1.
names = { names = {
row[0] row[0]
@@ -626,7 +877,7 @@ async def test_options_reload_reopens_and_rechecks_version(
database_b = entry_reloaded.runtime_data.database database_b = entry_reloaded.runtime_data.database
assert database_b is not database_a assert database_b is not database_a
try: try:
assert await read_schema_version(database_b) == 1 assert await read_schema_version(database_b) == len(MIGRATIONS)
row = await database_b.fetch_one("SELECT COUNT(*) FROM children") row = await database_b.fetch_one("SELECT COUNT(*) FROM children")
assert row == (1,) assert row == (1,)
finally: finally:
+56 -56
View File
@@ -15,8 +15,8 @@ from custom_components.nestquest.schema import (
SCHEMA_V1_PRESENCE_SCHEDULES_DDL, SCHEMA_V1_PRESENCE_SCHEDULES_DDL,
SCHEMA_V1_SCHEDULE_RULES_DDL, SCHEMA_V1_SCHEDULE_RULES_DDL,
SCHEMA_V1_STATEMENTS, SCHEMA_V1_STATEMENTS,
SCHEMA_V1_TASK_DEFINITIONS_DDL, SCHEMA_V1_QUEST_DEFINITIONS_DDL,
SCHEMA_V1_TASK_INSTANCES_DDL, SCHEMA_V1_QUEST_INSTANCES_DDL,
async_apply_ddl, async_apply_ddl,
) )
@@ -91,7 +91,7 @@ def _insert_definition(database, child_id=1, rule_id=1, **overrides):
placeholders = ", ".join("?" for _ in values) placeholders = ", ".join("?" for _ in values)
result = _run( result = _run(
database.execute( database.execute(
f"INSERT INTO task_definitions ({columns}) VALUES ({placeholders})", f"INSERT INTO quest_definitions ({columns}) VALUES ({placeholders})",
tuple(values.values()), tuple(values.values()),
) )
) )
@@ -161,10 +161,10 @@ def test_schema_v1_statements_compose_the_six_tables() -> None:
*SCHEMA_V1_CHILDREN_DDL, *SCHEMA_V1_CHILDREN_DDL,
*SCHEMA_V1_ADMIN_USERS_DDL, *SCHEMA_V1_ADMIN_USERS_DDL,
*SCHEMA_V1_SCHEDULE_RULES_DDL, *SCHEMA_V1_SCHEDULE_RULES_DDL,
*SCHEMA_V1_TASK_DEFINITIONS_DDL, *SCHEMA_V1_QUEST_DEFINITIONS_DDL,
*SCHEMA_V1_PRESENCE_SCHEDULES_DDL, *SCHEMA_V1_PRESENCE_SCHEDULES_DDL,
*SCHEMA_V1_PRESENCE_OVERRIDES_DDL, *SCHEMA_V1_PRESENCE_OVERRIDES_DDL,
*SCHEMA_V1_TASK_INSTANCES_DDL, *SCHEMA_V1_QUEST_INSTANCES_DDL,
*SCHEMA_V1_COMPLETION_EVENTS_DDL, *SCHEMA_V1_COMPLETION_EVENTS_DDL,
] ]
@@ -256,17 +256,17 @@ def test_schedule_rules_table_exists_after_applying_ddl(tmp_path) -> None:
_run(database.close()) _run(database.close())
def test_task_definitions_table_exists_after_applying_ddl(tmp_path) -> None: def test_quest_definitions_table_exists_after_applying_ddl(tmp_path) -> None:
database = _open_db(tmp_path / "definitions.db") database = _open_db(tmp_path / "definitions.db")
try: try:
_apply(database) _apply(database)
row = _run( row = _run(
database.fetch_one( database.fetch_one(
"SELECT name FROM sqlite_master WHERE type = 'table' " "SELECT name FROM sqlite_master WHERE type = 'table' "
"AND name = 'task_definitions'" "AND name = 'quest_definitions'"
) )
) )
assert row is not None and row[0] == "task_definitions" assert row is not None and row[0] == "quest_definitions"
finally: finally:
_run(database.close()) _run(database.close())
@@ -292,11 +292,11 @@ def test_schedule_rules_columns_types_and_constraints(tmp_path) -> None:
_run(database.close()) _run(database.close())
def test_task_definitions_columns_types_and_constraints(tmp_path) -> None: def test_quest_definitions_columns_types_and_constraints(tmp_path) -> None:
database = _open_db(tmp_path / "definitions-columns.db") database = _open_db(tmp_path / "definitions-columns.db")
try: try:
_apply(database) _apply(database)
columns = _run(database.fetch_all("PRAGMA table_info(task_definitions)")) columns = _run(database.fetch_all("PRAGMA table_info(quest_definitions)"))
assert [(row[1], row[2], row[3], row[4], row[5]) for row in columns] == [ assert [(row[1], row[2], row[3], row[4], row[5]) for row in columns] == [
("id", "INTEGER", 0, None, 1), ("id", "INTEGER", 0, None, 1),
("title", "TEXT", 1, None, 0), ("title", "TEXT", 1, None, 0),
@@ -312,11 +312,11 @@ def test_task_definitions_columns_types_and_constraints(tmp_path) -> None:
_run(database.close()) _run(database.close())
def test_task_definitions_foreign_keys_declared(tmp_path) -> None: def test_quest_definitions_foreign_keys_declared(tmp_path) -> None:
database = _open_db(tmp_path / "definitions-fks.db") database = _open_db(tmp_path / "definitions-fks.db")
try: try:
_apply(database) _apply(database)
fks = _run(database.fetch_all("PRAGMA foreign_key_list(task_definitions)")) fks = _run(database.fetch_all("PRAGMA foreign_key_list(quest_definitions)"))
declared = {(row[2], row[3], row[4]) for row in fks} declared = {(row[2], row[3], row[4]) for row in fks}
assert declared == { assert declared == {
("children", "child_id", "id"), ("children", "child_id", "id"),
@@ -519,7 +519,7 @@ def test_failed_statement_rolls_back_whole_application(tmp_path) -> None:
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# schedule_rules and task_definitions constraint enforcement # schedule_rules and quest_definitions constraint enforcement
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -813,7 +813,7 @@ def test_schedule_rules_start_date_not_null_enforced(tmp_path) -> None:
_run(database.close()) _run(database.close())
def test_task_definitions_insert_requires_existing_child_and_rule( def test_quest_definitions_insert_requires_existing_child_and_rule(
tmp_path, tmp_path,
) -> None: ) -> None:
database = _open_db(tmp_path / "definitions-fk-unknown.db") database = _open_db(tmp_path / "definitions-fk-unknown.db")
@@ -831,7 +831,7 @@ def test_task_definitions_insert_requires_existing_child_and_rule(
) )
row = _run( row = _run(
database.fetch_one( database.fetch_one(
"SELECT title, child_id, schedule_rule_id FROM task_definitions " "SELECT title, child_id, schedule_rule_id FROM quest_definitions "
"WHERE id = ?", "WHERE id = ?",
(definition_id,), (definition_id,),
) )
@@ -841,7 +841,7 @@ def test_task_definitions_insert_requires_existing_child_and_rule(
_run(database.close()) _run(database.close())
def test_task_definitions_unknown_child_id_fails(tmp_path) -> None: def test_quest_definitions_unknown_child_id_fails(tmp_path) -> None:
database = _open_db(tmp_path / "definitions-bad-child.db") database = _open_db(tmp_path / "definitions-bad-child.db")
try: try:
_apply(database) _apply(database)
@@ -852,7 +852,7 @@ def test_task_definitions_unknown_child_id_fails(tmp_path) -> None:
_run(database.close()) _run(database.close())
def test_task_definitions_unknown_schedule_rule_id_fails(tmp_path) -> None: def test_quest_definitions_unknown_schedule_rule_id_fails(tmp_path) -> None:
database = _open_db(tmp_path / "definitions-bad-rule.db") database = _open_db(tmp_path / "definitions-bad-rule.db")
try: try:
_apply(database) _apply(database)
@@ -868,7 +868,7 @@ def test_task_definitions_unknown_schedule_rule_id_fails(tmp_path) -> None:
_run(database.close()) _run(database.close())
def test_task_definitions_is_active_check_enforced(tmp_path) -> None: def test_quest_definitions_is_active_check_enforced(tmp_path) -> None:
database = _open_db(tmp_path / "definitions-is-active.db") database = _open_db(tmp_path / "definitions-is-active.db")
try: try:
_apply(database) _apply(database)
@@ -880,7 +880,7 @@ def test_task_definitions_is_active_check_enforced(tmp_path) -> None:
@pytest.mark.parametrize("bad_is_active", ["abc", 1.5]) @pytest.mark.parametrize("bad_is_active", ["abc", 1.5])
def test_task_definitions_is_active_non_integer_fails( def test_quest_definitions_is_active_non_integer_fails(
tmp_path, bad_is_active tmp_path, bad_is_active
) -> None: ) -> None:
database = _open_db(tmp_path / "definitions-is-active-typeof.db") database = _open_db(tmp_path / "definitions-is-active-typeof.db")
@@ -901,7 +901,7 @@ def test_task_definitions_is_active_non_integer_fails(
_run(database.close()) _run(database.close())
def test_task_definitions_title_not_null_enforced(tmp_path) -> None: def test_quest_definitions_title_not_null_enforced(tmp_path) -> None:
database = _open_db(tmp_path / "definitions-title.db") database = _open_db(tmp_path / "definitions-title.db")
try: try:
_apply(database) _apply(database)
@@ -912,7 +912,7 @@ def test_task_definitions_title_not_null_enforced(tmp_path) -> None:
_run(database.close()) _run(database.close())
def test_task_definitions_created_at_not_null_enforced(tmp_path) -> None: def test_quest_definitions_created_at_not_null_enforced(tmp_path) -> None:
database = _open_db(tmp_path / "definitions-created-at.db") database = _open_db(tmp_path / "definitions-created-at.db")
try: try:
_apply(database) _apply(database)
@@ -925,7 +925,7 @@ def test_task_definitions_created_at_not_null_enforced(tmp_path) -> None:
_run(database.close()) _run(database.close())
def test_task_definitions_defaults_apply_on_insert(tmp_path) -> None: def test_quest_definitions_defaults_apply_on_insert(tmp_path) -> None:
database = _open_db(tmp_path / "definitions-defaults.db") database = _open_db(tmp_path / "definitions-defaults.db")
try: try:
_apply(database) _apply(database)
@@ -941,7 +941,7 @@ def test_task_definitions_defaults_apply_on_insert(tmp_path) -> None:
) )
row = _run( row = _run(
database.fetch_one( database.fetch_one(
"SELECT is_active, due_time FROM task_definitions WHERE id = ?", "SELECT is_active, due_time FROM quest_definitions WHERE id = ?",
(definition_id,), (definition_id,),
) )
) )
@@ -969,7 +969,7 @@ def test_applying_extended_ddl_twice_keeps_new_table_rows(tmp_path) -> None:
_insert_definition(database, child_id=1, rule_id=1) _insert_definition(database, child_id=1, rule_id=1)
_apply(database) _apply(database)
counts = [] counts = []
for table in ("children", "schedule_rules", "task_definitions"): for table in ("children", "schedule_rules", "quest_definitions"):
row = _run(database.fetch_one(f"SELECT COUNT(*) FROM {table}")) row = _run(database.fetch_one(f"SELECT COUNT(*) FROM {table}"))
counts.append(row[0]) counts.append(row[0])
assert counts == [1, 1, 1] assert counts == [1, 1, 1]
@@ -1406,7 +1406,7 @@ def test_applying_presence_ddl_twice_keeps_rows(tmp_path) -> None:
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# task_instances and completion_events: helpers # quest_instances and completion_events: helpers
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -1428,7 +1428,7 @@ def _insert_instance(database, definition_id=1, **overrides):
placeholders = ", ".join("?" for _ in values) placeholders = ", ".join("?" for _ in values)
result = _run( result = _run(
database.execute( database.execute(
f"INSERT INTO task_instances ({columns}) VALUES ({placeholders})", f"INSERT INTO quest_instances ({columns}) VALUES ({placeholders})",
tuple(values.values()), tuple(values.values()),
) )
) )
@@ -1466,28 +1466,28 @@ def _insert_event(database, instance_id=1, **overrides):
def _setup_definition(database) -> None: def _setup_definition(database) -> None:
"""Create one child, one schedule rule and one task definition.""" """Create one child, one schedule rule and one quest definition."""
_child(database) _child(database)
_insert_rule(database) _insert_rule(database)
_insert_definition(database, child_id=1, rule_id=1) _insert_definition(database, child_id=1, rule_id=1)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# task_instances and completion_events: DDL shape # quest_instances and completion_events: DDL shape
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def test_task_instances_table_exists_after_applying_ddl(tmp_path) -> None: def test_quest_instances_table_exists_after_applying_ddl(tmp_path) -> None:
database = _open_db(tmp_path / "task-instances.db") database = _open_db(tmp_path / "task-instances.db")
try: try:
_apply(database) _apply(database)
row = _run( row = _run(
database.fetch_one( database.fetch_one(
"SELECT name FROM sqlite_master WHERE type = 'table' " "SELECT name FROM sqlite_master WHERE type = 'table' "
"AND name = 'task_instances'" "AND name = 'quest_instances'"
) )
) )
assert row is not None and row[0] == "task_instances" assert row is not None and row[0] == "quest_instances"
finally: finally:
_run(database.close()) _run(database.close())
@@ -1507,11 +1507,11 @@ def test_completion_events_table_exists_after_applying_ddl(tmp_path) -> None:
_run(database.close()) _run(database.close())
def test_task_instances_columns_types_and_constraints(tmp_path) -> None: def test_quest_instances_columns_types_and_constraints(tmp_path) -> None:
database = _open_db(tmp_path / "task-instances-columns.db") database = _open_db(tmp_path / "task-instances-columns.db")
try: try:
_apply(database) _apply(database)
columns = _run(database.fetch_all("PRAGMA table_info(task_instances)")) columns = _run(database.fetch_all("PRAGMA table_info(quest_instances)"))
# cid, name, type, notnull, dflt_value, pk # cid, name, type, notnull, dflt_value, pk
assert [(row[1], row[2], row[3], row[4], row[5]) for row in columns] == [ assert [(row[1], row[2], row[3], row[4], row[5]) for row in columns] == [
("id", "INTEGER", 0, None, 1), ("id", "INTEGER", 0, None, 1),
@@ -1544,14 +1544,14 @@ def test_completion_events_columns_types_and_constraints(tmp_path) -> None:
_run(database.close()) _run(database.close())
def test_task_instances_foreign_keys_declared(tmp_path) -> None: def test_quest_instances_foreign_keys_declared(tmp_path) -> None:
database = _open_db(tmp_path / "task-instances-fks.db") database = _open_db(tmp_path / "task-instances-fks.db")
try: try:
_apply(database) _apply(database)
fks = _run(database.fetch_all("PRAGMA foreign_key_list(task_instances)")) fks = _run(database.fetch_all("PRAGMA foreign_key_list(quest_instances)"))
declared = {(row[2], row[3], row[4]) for row in fks} declared = {(row[2], row[3], row[4]) for row in fks}
assert declared == { assert declared == {
("task_definitions", "definition_id", "id"), ("quest_definitions", "definition_id", "id"),
("children", "child_id", "id"), ("children", "child_id", "id"),
} }
finally: finally:
@@ -1565,14 +1565,14 @@ def test_completion_events_foreign_keys_declared(tmp_path) -> None:
fks = _run(database.fetch_all("PRAGMA foreign_key_list(completion_events)")) fks = _run(database.fetch_all("PRAGMA foreign_key_list(completion_events)"))
declared = {(row[2], row[3], row[4]) for row in fks} declared = {(row[2], row[3], row[4]) for row in fks}
assert declared == { assert declared == {
("task_instances", "instance_id", "id"), ("quest_instances", "instance_id", "id"),
("children", "child_id", "id"), ("children", "child_id", "id"),
} }
finally: finally:
_run(database.close()) _run(database.close())
def test_task_instances_unique_index_on_definition_and_date(tmp_path) -> None: def test_quest_instances_unique_index_on_definition_and_date(tmp_path) -> None:
database = _open_db(tmp_path / "task-instances-unique.db") database = _open_db(tmp_path / "task-instances-unique.db")
try: try:
_apply(database) _apply(database)
@@ -1580,7 +1580,7 @@ def test_task_instances_unique_index_on_definition_and_date(tmp_path) -> None:
rows = _run( rows = _run(
database.fetch_all( database.fetch_all(
"SELECT name, sql FROM sqlite_master WHERE type = 'index' " "SELECT name, sql FROM sqlite_master WHERE type = 'index' "
"AND tbl_name = 'task_instances'" "AND tbl_name = 'quest_instances'"
) )
) )
implicit = [(name, sql) for name, sql in rows if sql is None] implicit = [(name, sql) for name, sql in rows if sql is None]
@@ -1599,7 +1599,7 @@ def test_task_instances_unique_index_on_definition_and_date(tmp_path) -> None:
row = _run( row = _run(
database.fetch_one( database.fetch_one(
"SELECT COUNT(DISTINCT name) FROM pragma_index_list" "SELECT COUNT(DISTINCT name) FROM pragma_index_list"
"('task_instances') WHERE \"unique\" = 1" "('quest_instances') WHERE \"unique\" = 1"
) )
) )
assert row == (1,) assert row == (1,)
@@ -1608,11 +1608,11 @@ def test_task_instances_unique_index_on_definition_and_date(tmp_path) -> None:
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# task_instances: constraint enforcement # quest_instances: constraint enforcement
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def test_task_instances_valid_insert_round_trips(tmp_path) -> None: def test_quest_instances_valid_insert_round_trips(tmp_path) -> None:
database = _open_db(tmp_path / "task-instances-valid.db") database = _open_db(tmp_path / "task-instances-valid.db")
try: try:
_apply(database) _apply(database)
@@ -1621,7 +1621,7 @@ def test_task_instances_valid_insert_round_trips(tmp_path) -> None:
row = _run( row = _run(
database.fetch_one( database.fetch_one(
"SELECT definition_id, child_id, due_date, due_time, generated_at " "SELECT definition_id, child_id, due_date, due_time, generated_at "
"FROM task_instances WHERE id = ?", "FROM quest_instances WHERE id = ?",
(instance_id,), (instance_id,),
) )
) )
@@ -1630,7 +1630,7 @@ def test_task_instances_valid_insert_round_trips(tmp_path) -> None:
_run(database.close()) _run(database.close())
def test_task_instances_due_time_round_trips(tmp_path) -> None: def test_quest_instances_due_time_round_trips(tmp_path) -> None:
database = _open_db(tmp_path / "task-instances-due-time.db") database = _open_db(tmp_path / "task-instances-due-time.db")
try: try:
_apply(database) _apply(database)
@@ -1640,7 +1640,7 @@ def test_task_instances_due_time_round_trips(tmp_path) -> None:
) )
row = _run( row = _run(
database.fetch_one( database.fetch_one(
"SELECT due_time FROM task_instances WHERE id = ?", "SELECT due_time FROM quest_instances WHERE id = ?",
(instance_id,), (instance_id,),
) )
) )
@@ -1649,7 +1649,7 @@ def test_task_instances_due_time_round_trips(tmp_path) -> None:
_run(database.close()) _run(database.close())
def test_task_instances_duplicate_definition_date_fails(tmp_path) -> None: def test_quest_instances_duplicate_definition_date_fails(tmp_path) -> None:
database = _open_db(tmp_path / "task-instances-duplicate.db") database = _open_db(tmp_path / "task-instances-duplicate.db")
try: try:
_apply(database) _apply(database)
@@ -1661,7 +1661,7 @@ def test_task_instances_duplicate_definition_date_fails(tmp_path) -> None:
_run(database.close()) _run(database.close())
def test_task_instances_same_date_different_definitions_allowed( def test_quest_instances_same_date_different_definitions_allowed(
tmp_path, tmp_path,
) -> None: ) -> None:
database = _open_db(tmp_path / "task-instances-multi-def.db") database = _open_db(tmp_path / "task-instances-multi-def.db")
@@ -1678,26 +1678,26 @@ def test_task_instances_same_date_different_definitions_allowed(
_insert_definition(database, child_id=2, rule_id=rule_id) _insert_definition(database, child_id=2, rule_id=rule_id)
_insert_instance(database, definition_id=1, due_date="2026-09-14") _insert_instance(database, definition_id=1, due_date="2026-09-14")
_insert_instance(database, definition_id=2, due_date="2026-09-14") _insert_instance(database, definition_id=2, due_date="2026-09-14")
row = _run(database.fetch_one("SELECT COUNT(*) FROM task_instances")) row = _run(database.fetch_one("SELECT COUNT(*) FROM quest_instances"))
assert row == (2,) assert row == (2,)
finally: finally:
_run(database.close()) _run(database.close())
def test_task_instances_same_definition_different_dates_allowed(tmp_path) -> None: def test_quest_instances_same_definition_different_dates_allowed(tmp_path) -> None:
database = _open_db(tmp_path / "task-instances-multi-date.db") database = _open_db(tmp_path / "task-instances-multi-date.db")
try: try:
_apply(database) _apply(database)
_setup_definition(database) _setup_definition(database)
_insert_instance(database, definition_id=1, due_date="2026-09-14") _insert_instance(database, definition_id=1, due_date="2026-09-14")
_insert_instance(database, definition_id=1, due_date="2026-09-15") _insert_instance(database, definition_id=1, due_date="2026-09-15")
row = _run(database.fetch_one("SELECT COUNT(*) FROM task_instances")) row = _run(database.fetch_one("SELECT COUNT(*) FROM quest_instances"))
assert row == (2,) assert row == (2,)
finally: finally:
_run(database.close()) _run(database.close())
def test_task_instances_unknown_definition_fails(tmp_path) -> None: def test_quest_instances_unknown_definition_fails(tmp_path) -> None:
database = _open_db(tmp_path / "task-instances-bad-def.db") database = _open_db(tmp_path / "task-instances-bad-def.db")
try: try:
_apply(database) _apply(database)
@@ -1708,7 +1708,7 @@ def test_task_instances_unknown_definition_fails(tmp_path) -> None:
_run(database.close()) _run(database.close())
def test_task_instances_unknown_child_fails(tmp_path) -> None: def test_quest_instances_unknown_child_fails(tmp_path) -> None:
database = _open_db(tmp_path / "task-instances-bad-child.db") database = _open_db(tmp_path / "task-instances-bad-child.db")
try: try:
_apply(database) _apply(database)
@@ -1719,7 +1719,7 @@ def test_task_instances_unknown_child_fails(tmp_path) -> None:
_run(database.close()) _run(database.close())
def test_task_instances_due_date_not_null_enforced(tmp_path) -> None: def test_quest_instances_due_date_not_null_enforced(tmp_path) -> None:
database = _open_db(tmp_path / "task-instances-due-null.db") database = _open_db(tmp_path / "task-instances-due-null.db")
try: try:
_apply(database) _apply(database)
@@ -1730,7 +1730,7 @@ def test_task_instances_due_date_not_null_enforced(tmp_path) -> None:
_run(database.close()) _run(database.close())
def test_task_instances_generated_at_not_null_enforced(tmp_path) -> None: def test_quest_instances_generated_at_not_null_enforced(tmp_path) -> None:
database = _open_db(tmp_path / "task-instances-generated-null.db") database = _open_db(tmp_path / "task-instances-generated-null.db")
try: try:
_apply(database) _apply(database)
@@ -2026,8 +2026,8 @@ def test_applying_full_ddl_twice_keeps_rows(tmp_path) -> None:
for table in ( for table in (
"children", "children",
"schedule_rules", "schedule_rules",
"task_definitions", "quest_definitions",
"task_instances", "quest_instances",
"completion_events", "completion_events",
): ):
row = _run(database.fetch_one(f"SELECT COUNT(*) FROM {table}")) row = _run(database.fetch_one(f"SELECT COUNT(*) FROM {table}"))
+6 -4
View File
@@ -18,6 +18,7 @@ from custom_components.nestquest import (
) )
from custom_components.nestquest.db import NestQuestDatabase from custom_components.nestquest.db import NestQuestDatabase
from custom_components.nestquest.migrations import ( from custom_components.nestquest.migrations import (
MIGRATIONS,
VERSION_TABLE, VERSION_TABLE,
apply_migrations, apply_migrations,
) )
@@ -114,12 +115,13 @@ def test_missing_file_opens_fresh(tmp_path) -> None:
assert not db_path.exists() assert not db_path.exists()
database = _run(_async_open_database(_make_hass_mock(), db_path)) database = _run(_async_open_database(_make_hass_mock(), db_path))
try: try:
# The file was created fresh and migrations stamped version 1. # The file was created fresh and migrations stamped the
# latest schema version.
assert db_path.exists() assert db_path.exists()
row = _run( row = _run(
database.fetch_one(f"SELECT version FROM {VERSION_TABLE}") database.fetch_one(f"SELECT version FROM {VERSION_TABLE}")
) )
assert row == (1,) assert row == (len(MIGRATIONS),)
finally: finally:
_run(database.close()) _run(database.close())
@@ -151,7 +153,7 @@ def test_valid_file_opens_and_migrations_noop(tmp_path) -> None:
row = _run( row = _run(
database.fetch_one(f"SELECT version FROM {VERSION_TABLE}") database.fetch_one(f"SELECT version FROM {VERSION_TABLE}")
) )
assert row == (1,) assert row == (len(MIGRATIONS),)
finally: finally:
_run(database.close()) _run(database.close())
# Opening a valid file must not rewrite it (the no-op path). # Opening a valid file must not rewrite it (the no-op path).
@@ -401,7 +403,7 @@ async def test_setup_with_missing_file_creates_and_loads(
row = await database.fetch_one( row = await database.fetch_one(
f"SELECT version FROM {VERSION_TABLE}" f"SELECT version FROM {VERSION_TABLE}"
) )
assert row == (1,) assert row == (len(MIGRATIONS),)
finally: finally:
await database.close() await database.close()
assert await async_unload_entry(hass, entry) is True assert await async_unload_entry(hass, entry) is True