Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c988f6508b | ||
|
|
3ecca2d212 | ||
|
|
30d103aefb | ||
|
|
658afec491 | ||
|
|
b1157b3d6d |
@@ -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]
|
||||||
|
|||||||
@@ -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),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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,
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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).
|
||||||
@@ -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`.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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
@@ -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
@@ -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
@@ -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, (
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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}"))
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user