Rename task_* tables to quest_* with legacy-dev migration path (D-007)
Per design/IMPLEMENTATION-ORDER.md 1.1 and decision D-007: the domain model takes the quest vocabulary pre-release. SCHEMA_V1 now creates quest_definitions/quest_instances directly; DAO classes, records and error messages follow (QuestDefinitionsDao, QuestInstancesDao). The migration runner gains migration 2, an async-callable step that detects surviving task_* tables on dev machines and ALTER TABLE RENAMES them inside the migration transaction, with FK clauses rewritten by SQLite. Migration 1's content rewrite is legal because nothing was ever shipped; dev databases stamped version 1 ride migration 2 up to 2.
This commit is contained in:
@@ -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
|
||||
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
|
||||
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
|
||||
whose instance already exists updates nothing that matters
|
||||
(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)
|
||||
class TaskInstanceRecord:
|
||||
"""One row of ``task_instances``."""
|
||||
class QuestInstanceRecord:
|
||||
"""One row of ``quest_instances``."""
|
||||
|
||||
id: int
|
||||
definition_id: int
|
||||
@@ -121,8 +121,8 @@ EVENT_COMPLETED = "completed"
|
||||
EVENT_UNCOMPLETED = "uncompleted"
|
||||
|
||||
|
||||
def _instance_from_row(row: tuple) -> TaskInstanceRecord:
|
||||
return TaskInstanceRecord(
|
||||
def _instance_from_row(row: tuple) -> QuestInstanceRecord:
|
||||
return QuestInstanceRecord(
|
||||
id=row[0],
|
||||
definition_id=row[1],
|
||||
child_id=row[2],
|
||||
@@ -145,8 +145,8 @@ def _event_from_row(row: tuple) -> CompletionEventRecord:
|
||||
)
|
||||
|
||||
|
||||
class TaskInstancesDao:
|
||||
"""Typed async access to the ``task_instances`` table."""
|
||||
class QuestInstancesDao:
|
||||
"""Typed async access to the ``quest_instances`` table."""
|
||||
|
||||
def __init__(self, database: NestQuestDatabase) -> None:
|
||||
self._database = database
|
||||
@@ -159,7 +159,7 @@ class TaskInstancesDao:
|
||||
generated_at: str,
|
||||
*,
|
||||
due_time: str | None = None,
|
||||
) -> TaskInstanceRecord:
|
||||
) -> QuestInstanceRecord:
|
||||
"""Create the dated instance for (definition_id, due_date) idempotently.
|
||||
|
||||
Running materialization twice for the same definition/date
|
||||
@@ -207,16 +207,16 @@ class TaskInstancesDao:
|
||||
f"{today!r}"
|
||||
)
|
||||
definition = await self._database.fetch_one(
|
||||
"SELECT child_id FROM task_definitions WHERE id = ?",
|
||||
"SELECT child_id FROM quest_definitions WHERE id = ?",
|
||||
(definition_id,),
|
||||
)
|
||||
if definition is None:
|
||||
raise ValueError(
|
||||
f"task definition {definition_id} does not exist"
|
||||
f"quest definition {definition_id} does not exist"
|
||||
)
|
||||
if definition[0] != child_id:
|
||||
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 "
|
||||
"must carry the definition's assignee"
|
||||
)
|
||||
@@ -227,7 +227,7 @@ class TaskInstancesDao:
|
||||
raise ValueError(f"child {child_id} does not exist")
|
||||
existing = await self._database.fetch_one(
|
||||
"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 = ?)",
|
||||
(definition_id, due_date),
|
||||
)
|
||||
@@ -238,7 +238,7 @@ class TaskInstancesDao:
|
||||
"event and is immutable; it cannot be regenerated"
|
||||
)
|
||||
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) "
|
||||
"VALUES (?, ?, ?, ?, ?) "
|
||||
"ON CONFLICT (definition_id, due_date) DO UPDATE SET "
|
||||
@@ -259,30 +259,30 @@ class TaskInstancesDao:
|
||||
|
||||
async def get(
|
||||
self, definition_id: int, due_date: str
|
||||
) -> TaskInstanceRecord | None:
|
||||
) -> QuestInstanceRecord | None:
|
||||
"""Return the instance for (definition_id, due_date), or None."""
|
||||
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 = ?",
|
||||
(definition_id, due_date),
|
||||
)
|
||||
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."""
|
||||
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,),
|
||||
)
|
||||
return _instance_from_row(row) if row is not None else None
|
||||
|
||||
async def list_by_child_and_date(
|
||||
self, child_id: int, due_date: str
|
||||
) -> list[TaskInstanceRecord]:
|
||||
) -> list[QuestInstanceRecord]:
|
||||
"""Return the child's instances due exactly on ``due_date``."""
|
||||
_validate_date(due_date, "due_date")
|
||||
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",
|
||||
(child_id, due_date),
|
||||
)
|
||||
@@ -293,7 +293,7 @@ class TaskInstancesDao:
|
||||
child_id: int,
|
||||
range_start: str,
|
||||
range_end: str,
|
||||
) -> list[TaskInstanceRecord]:
|
||||
) -> list[QuestInstanceRecord]:
|
||||
"""Return the child's instances with due_date in the closed range."""
|
||||
_validate_date(range_start, "range_start")
|
||||
_validate_date(range_end, "range_end")
|
||||
@@ -303,7 +303,7 @@ class TaskInstancesDao:
|
||||
f"{range_end!r} < {range_start!r}"
|
||||
)
|
||||
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 <= ? "
|
||||
"ORDER BY due_date, id",
|
||||
(child_id, range_start, range_end),
|
||||
@@ -338,10 +338,10 @@ class TaskInstancesDao:
|
||||
today = datetime.date.today().isoformat()
|
||||
effective_cutoff = max(cutoff_date, today)
|
||||
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 ("
|
||||
" SELECT 1 FROM completion_events "
|
||||
" WHERE instance_id = task_instances.id"
|
||||
" WHERE instance_id = quest_instances.id"
|
||||
")",
|
||||
(definition_id, effective_cutoff),
|
||||
)
|
||||
@@ -418,16 +418,16 @@ class CompletionEventsDao:
|
||||
async with _connection_lock(self._database):
|
||||
async with self._database.transaction():
|
||||
instance = await self._database.fetch_one(
|
||||
"SELECT child_id FROM task_instances WHERE id = ?",
|
||||
"SELECT child_id FROM quest_instances WHERE id = ?",
|
||||
(instance_id,),
|
||||
)
|
||||
if instance is None:
|
||||
raise ValueError(
|
||||
f"task instance {instance_id} does not exist"
|
||||
f"quest instance {instance_id} does not exist"
|
||||
)
|
||||
if instance[0] != child_id:
|
||||
raise ValueError(
|
||||
f"task instance {instance_id} belongs to child "
|
||||
f"quest instance {instance_id} belongs to child "
|
||||
f"{instance[0]}, not {child_id}"
|
||||
)
|
||||
child = await self._database.fetch_one(
|
||||
@@ -493,7 +493,7 @@ class CompletionEventsDao:
|
||||
)
|
||||
if due_date_scope:
|
||||
predicate = (
|
||||
"SELECT id FROM task_instances WHERE child_id = ? "
|
||||
"SELECT id FROM quest_instances WHERE child_id = ? "
|
||||
"AND due_date >= ? AND due_date <= ?"
|
||||
)
|
||||
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
|
||||
and never see raw rows or SQL. Every method is async and runs through
|
||||
:class:`~.db.NestQuestDatabase`, so each statement executes on the HA
|
||||
@@ -9,16 +9,16 @@ executor and the event loop never blocks.
|
||||
Guardrail mapping:
|
||||
|
||||
- ``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.
|
||||
- ``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.
|
||||
- ``set_assignee`` changes future instances only — the DAO writes the
|
||||
definition row and nothing else; reassignment never rewrites
|
||||
existing instances or completion history (Feature 06 guardrail).
|
||||
- Validation of rule well-formedness and child activeness lives partly
|
||||
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.
|
||||
|
||||
Concurrency: ``delete_rule_if_unreferenced`` performs its reference
|
||||
@@ -81,8 +81,8 @@ class ScheduleRuleRecord:
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TaskDefinitionRecord:
|
||||
"""One row of ``task_definitions``."""
|
||||
class QuestDefinitionRecord:
|
||||
"""One row of ``quest_definitions``."""
|
||||
|
||||
id: int
|
||||
title: str
|
||||
@@ -119,8 +119,8 @@ def _rule_from_row(row: tuple) -> ScheduleRuleRecord:
|
||||
)
|
||||
|
||||
|
||||
def _definition_from_row(row: tuple) -> TaskDefinitionRecord:
|
||||
return TaskDefinitionRecord(
|
||||
def _definition_from_row(row: tuple) -> QuestDefinitionRecord:
|
||||
return QuestDefinitionRecord(
|
||||
id=row[0],
|
||||
title=row[1],
|
||||
description=row[2],
|
||||
@@ -258,7 +258,7 @@ class ScheduleRulesDao:
|
||||
async with _connection_lock(self._database):
|
||||
async with self._database.transaction():
|
||||
row = await self._database.fetch_one(
|
||||
"SELECT 1 FROM task_definitions "
|
||||
"SELECT 1 FROM quest_definitions "
|
||||
"WHERE schedule_rule_id = ? LIMIT 1",
|
||||
(rule_id,),
|
||||
)
|
||||
@@ -271,8 +271,8 @@ class ScheduleRulesDao:
|
||||
return result.rowcount > 0
|
||||
|
||||
|
||||
class TaskDefinitionsDao:
|
||||
"""Typed async access to the ``task_definitions`` table.
|
||||
class QuestDefinitionsDao:
|
||||
"""Typed async access to the ``quest_definitions`` table.
|
||||
|
||||
No delete method exists: definitions are deactivated via
|
||||
:meth:`set_active`, never hard-deleted, so completion history keeps
|
||||
@@ -293,7 +293,7 @@ class TaskDefinitionsDao:
|
||||
icon: str | None = None,
|
||||
due_time: str | None = None,
|
||||
is_active: bool = True,
|
||||
) -> TaskDefinitionRecord:
|
||||
) -> QuestDefinitionRecord:
|
||||
"""Insert one definition and return the record as stored.
|
||||
|
||||
Validates the assignment target as part of the same serialized
|
||||
@@ -312,7 +312,7 @@ class TaskDefinitionsDao:
|
||||
child_id, schedule_rule_id
|
||||
)
|
||||
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, "
|
||||
"is_active, created_at) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
@@ -349,28 +349,28 @@ class TaskDefinitionsDao:
|
||||
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."""
|
||||
row = await self._database.fetch_one(
|
||||
f"SELECT {_DEFINITION_COLUMNS} FROM task_definitions "
|
||||
f"SELECT {_DEFINITION_COLUMNS} FROM quest_definitions "
|
||||
"WHERE id = ?",
|
||||
(definition_id,),
|
||||
)
|
||||
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."""
|
||||
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",
|
||||
(child_id,),
|
||||
)
|
||||
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)."""
|
||||
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"
|
||||
)
|
||||
return [_definition_from_row(row) for row in rows]
|
||||
@@ -409,7 +409,7 @@ class TaskDefinitionsDao:
|
||||
return 0
|
||||
parameters.append(definition_id)
|
||||
result = await self._database.execute(
|
||||
f"UPDATE task_definitions SET {', '.join(assignments)} "
|
||||
f"UPDATE quest_definitions SET {', '.join(assignments)} "
|
||||
"WHERE id = ?",
|
||||
tuple(parameters),
|
||||
)
|
||||
@@ -422,7 +422,7 @@ class TaskDefinitionsDao:
|
||||
existing instances and completion history untouched.
|
||||
"""
|
||||
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),
|
||||
)
|
||||
return result.rowcount
|
||||
@@ -442,7 +442,7 @@ class TaskDefinitionsDao:
|
||||
async with self._database.transaction():
|
||||
await self._validate_assignable_child(child_id)
|
||||
result = await self._database.execute(
|
||||
"UPDATE task_definitions SET child_id = ? "
|
||||
"UPDATE quest_definitions SET child_id = ? "
|
||||
"WHERE 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
|
||||
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
|
||||
transaction, and the stamp is written as DELETE + INSERT rather than
|
||||
UPDATE because a version-0 database has no version row to update yet.
|
||||
@@ -43,13 +59,18 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from collections.abc import Sequence
|
||||
from collections.abc import Awaitable, Callable, Sequence
|
||||
from typing import Union
|
||||
|
||||
from .db import NestQuestDatabase
|
||||
from .schema import SCHEMA_V1_STATEMENTS
|
||||
|
||||
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
|
||||
#: integration domain so a config directory shared with other tools can
|
||||
#: never collide with a foreign ``schema_version`` table.
|
||||
@@ -73,10 +94,48 @@ VERSION_TABLE_DDL = f"""
|
||||
#: version ``n-1`` to version ``n``.
|
||||
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
|
||||
#: entry, add the next one instead.
|
||||
MIGRATIONS: Sequence[Sequence[str]] = [
|
||||
#: entry, add the next one instead. (Migration 1's content was
|
||||
#: rewritten pre-release per D-007 — no version 1 database shipped.)
|
||||
MIGRATIONS: Sequence[MigrationStep] = [
|
||||
MIGRATION_1_V1_DDL,
|
||||
_rename_legacy_task_tables,
|
||||
]
|
||||
|
||||
|
||||
@@ -140,7 +199,7 @@ def _migration_lock(database: NestQuestDatabase) -> asyncio.Lock:
|
||||
|
||||
async def apply_migrations(
|
||||
database: NestQuestDatabase,
|
||||
migrations: Sequence[Sequence[str]] = MIGRATIONS,
|
||||
migrations: Sequence[MigrationStep] = MIGRATIONS,
|
||||
) -> int:
|
||||
"""Bring the database schema up to the latest version.
|
||||
|
||||
@@ -208,8 +267,14 @@ async def apply_migrations(
|
||||
f"{latest}); downgrades are not supported"
|
||||
)
|
||||
if in_tx_version < target:
|
||||
for sql in migrations[target - 1]:
|
||||
await database.execute(sql)
|
||||
step = migrations[target - 1]
|
||||
if callable(step):
|
||||
await step(database)
|
||||
applied_statements = 0
|
||||
else:
|
||||
for sql in step:
|
||||
await database.execute(sql)
|
||||
applied_statements = len(step)
|
||||
# INSERT keyed on the singleton row (id = 1): a
|
||||
# fresh file has no row yet, and migration 1 is the
|
||||
# only migration a version-0 database can run, so
|
||||
@@ -225,12 +290,18 @@ async def apply_migrations(
|
||||
)
|
||||
applied = True
|
||||
if applied:
|
||||
LOGGER.info(
|
||||
"NestQuest schema migrated to version %d "
|
||||
"(%d statement%s)",
|
||||
target,
|
||||
len(migrations[target - 1]),
|
||||
"s" if len(migrations[target - 1]) != 1 else "",
|
||||
)
|
||||
if applied_statements == 0:
|
||||
LOGGER.info(
|
||||
"NestQuest schema migrated to version %d",
|
||||
target,
|
||||
)
|
||||
else:
|
||||
LOGGER.info(
|
||||
"NestQuest schema migrated to version %d "
|
||||
"(%d statement%s)",
|
||||
target,
|
||||
applied_statements,
|
||||
"s" if applied_statements != 1 else "",
|
||||
)
|
||||
|
||||
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;
|
||||
``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
|
||||
in ``completion_events`` (Feature 08), so materialization (Feature 07)
|
||||
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
|
||||
table-level ``UNIQUE`` constraint rather than a separate ``CREATE
|
||||
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
|
||||
silently dropped the way a standalone index can, so the duplicate door
|
||||
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.
|
||||
|
||||
``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
|
||||
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,
|
||||
title TEXT NOT NULL,
|
||||
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,
|
||||
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),
|
||||
due_date TEXT NOT NULL,
|
||||
due_time TEXT,
|
||||
@@ -289,7 +289,7 @@ SCHEMA_V1_COMPLETION_EVENTS_DDL: list[str] = [
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS completion_events (
|
||||
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),
|
||||
event_type TEXT NOT NULL CHECK (event_type IN ('completed', 'uncompleted')),
|
||||
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_ADMIN_USERS_DDL,
|
||||
*SCHEMA_V1_SCHEDULE_RULES_DDL,
|
||||
*SCHEMA_V1_TASK_DEFINITIONS_DDL,
|
||||
*SCHEMA_V1_QUEST_DEFINITIONS_DDL,
|
||||
*SCHEMA_V1_PRESENCE_SCHEDULES_DDL,
|
||||
*SCHEMA_V1_PRESENCE_OVERRIDES_DDL,
|
||||
*SCHEMA_V1_TASK_INSTANCES_DDL,
|
||||
*SCHEMA_V1_QUEST_INSTANCES_DDL,
|
||||
*SCHEMA_V1_COMPLETION_EVENTS_DDL,
|
||||
]
|
||||
|
||||
|
||||
+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
|
||||
|
||||
import asyncio
|
||||
@@ -11,10 +11,10 @@ from custom_components.nestquest.dao_children import ChildrenDao
|
||||
from custom_components.nestquest.dao_instances import (
|
||||
CompletionEventRecord,
|
||||
CompletionEventsDao,
|
||||
TaskInstanceRecord,
|
||||
TaskInstancesDao,
|
||||
QuestInstanceRecord,
|
||||
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.migrations import apply_migrations
|
||||
|
||||
@@ -76,8 +76,8 @@ async def _prepare(path) -> tuple:
|
||||
await apply_migrations(database)
|
||||
children = ChildrenDao(database)
|
||||
rules = ScheduleRulesDao(database)
|
||||
definitions = TaskDefinitionsDao(database)
|
||||
instances = TaskInstancesDao(database)
|
||||
definitions = QuestDefinitionsDao(database)
|
||||
instances = QuestInstancesDao(database)
|
||||
events = CompletionEventsDao(database)
|
||||
child = await children.create("Ada", _now_stamp())
|
||||
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(
|
||||
definition.id, child.id, D1, _now_stamp()
|
||||
)
|
||||
assert isinstance(first, TaskInstanceRecord)
|
||||
assert isinstance(first, QuestInstanceRecord)
|
||||
second = await instances.upsert(
|
||||
definition.id, child.id, D1, _now_stamp()
|
||||
)
|
||||
assert second.id == first.id
|
||||
rows = await database.fetch_one(
|
||||
"SELECT COUNT(*) FROM task_instances"
|
||||
"SELECT COUNT(*) FROM quest_instances"
|
||||
)
|
||||
assert rows == (1,)
|
||||
return second
|
||||
@@ -323,8 +323,8 @@ def test_upsert_no_past_check_uses_execution_date_not_call_date(
|
||||
await apply_migrations(database)
|
||||
children = ChildrenDao(database)
|
||||
rules = ScheduleRulesDao(database)
|
||||
definitions = TaskDefinitionsDao(database)
|
||||
instances = TaskInstancesDao(database)
|
||||
definitions = QuestDefinitionsDao(database)
|
||||
instances = QuestInstancesDao(database)
|
||||
events = CompletionEventsDao(database)
|
||||
child = await children.create("Ada", _now_stamp())
|
||||
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):
|
||||
children = ChildrenDao(database)
|
||||
rules = ScheduleRulesDao(database)
|
||||
definitions = TaskDefinitionsDao(database)
|
||||
instances = TaskInstancesDao(database)
|
||||
definitions = QuestDefinitionsDao(database)
|
||||
instances = QuestInstancesDao(database)
|
||||
events = CompletionEventsDao(database)
|
||||
child = await children.create("Ada", _now_stamp())
|
||||
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 (
|
||||
CompletionEventsDao,
|
||||
TaskInstancesDao,
|
||||
QuestInstancesDao,
|
||||
)
|
||||
from custom_components.nestquest.dao_presence import (
|
||||
PresenceOverridesDao,
|
||||
@@ -35,7 +35,7 @@ from custom_components.nestquest.dao_presence import (
|
||||
)
|
||||
from custom_components.nestquest.dao_rules import (
|
||||
ScheduleRulesDao,
|
||||
TaskDefinitionsDao,
|
||||
QuestDefinitionsDao,
|
||||
)
|
||||
from custom_components.nestquest.db import NestQuestDatabase
|
||||
from custom_components.nestquest.migrations import apply_migrations
|
||||
@@ -70,10 +70,10 @@ ALL_TABLES = (
|
||||
"children",
|
||||
"admin_users",
|
||||
"schedule_rules",
|
||||
"task_definitions",
|
||||
"quest_definitions",
|
||||
"presence_schedules",
|
||||
"presence_overrides",
|
||||
"task_instances",
|
||||
"quest_instances",
|
||||
"completion_events",
|
||||
)
|
||||
|
||||
@@ -86,10 +86,10 @@ class World:
|
||||
self.children = ChildrenDao(database)
|
||||
self.admins = AdminUsersDao(database)
|
||||
self.rules = ScheduleRulesDao(database)
|
||||
self.definitions = TaskDefinitionsDao(database)
|
||||
self.definitions = QuestDefinitionsDao(database)
|
||||
self.schedules = PresenceSchedulesDao(database)
|
||||
self.overrides = PresenceOverridesDao(database)
|
||||
self.instances = TaskInstancesDao(database)
|
||||
self.instances = QuestInstancesDao(database)
|
||||
self.events = CompletionEventsDao(database)
|
||||
|
||||
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
|
||||
|
||||
|
||||
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):
|
||||
# insert (seeded) + read
|
||||
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
|
||||
|
||||
|
||||
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):
|
||||
# insert (seeded) + read
|
||||
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
|
||||
|
||||
|
||||
def test_matrix_task_instances_constraints(tmp_path) -> None:
|
||||
def test_matrix_quest_instances_constraints(tmp_path) -> None:
|
||||
async def _body(w: World):
|
||||
# D20 has no instance yet: a raw insert succeeds, then an
|
||||
# identical second insert must fail on the UNIQUE constraint —
|
||||
# proving the schema backs the DAO's idempotency.
|
||||
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) "
|
||||
"VALUES (?, ?, ?, NULL, ?)",
|
||||
(w.definition.id, w.child.id, D20, _stamp()),
|
||||
)
|
||||
with pytest.raises(sqlite3.IntegrityError):
|
||||
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) "
|
||||
"VALUES (?, ?, ?, NULL, ?)",
|
||||
(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
|
||||
|
||||
|
||||
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):
|
||||
first = await w.instances.upsert(
|
||||
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
|
||||
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.
|
||||
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
|
||||
|
||||
import asyncio
|
||||
@@ -10,8 +10,8 @@ from custom_components.nestquest.dao_children import ChildrenDao
|
||||
from custom_components.nestquest.dao_rules import (
|
||||
ScheduleRuleRecord,
|
||||
ScheduleRulesDao,
|
||||
TaskDefinitionRecord,
|
||||
TaskDefinitionsDao,
|
||||
QuestDefinitionRecord,
|
||||
QuestDefinitionsDao,
|
||||
)
|
||||
from custom_components.nestquest.db import NestQuestDatabase
|
||||
from custom_components.nestquest.migrations import apply_migrations
|
||||
@@ -37,7 +37,7 @@ async def _prepare(path) -> tuple:
|
||||
await database.open(path)
|
||||
await apply_migrations(database)
|
||||
rules = ScheduleRulesDao(database)
|
||||
definitions = TaskDefinitionsDao(database)
|
||||
definitions = QuestDefinitionsDao(database)
|
||||
children = ChildrenDao(database)
|
||||
child = await children.create("Ada", NOW)
|
||||
return database, rules, definitions, children, child
|
||||
@@ -322,7 +322,7 @@ def test_rule_delete_concurrent_with_definition_create_is_safe(
|
||||
await apply_migrations(database)
|
||||
children = ChildrenDao(database)
|
||||
rules = ScheduleRulesDao(database)
|
||||
definitions = TaskDefinitionsDao(database)
|
||||
definitions = QuestDefinitionsDao(database)
|
||||
child = await children.create("Ada", NOW)
|
||||
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
|
||||
|
||||
original_validate = (
|
||||
dao_rules.TaskDefinitionsDao._validate_assignable
|
||||
dao_rules.QuestDefinitionsDao._validate_assignable
|
||||
)
|
||||
release = asyncio.Event()
|
||||
started = asyncio.Event()
|
||||
@@ -353,7 +353,7 @@ def test_rule_delete_concurrent_with_definition_create_is_safe(
|
||||
started.set()
|
||||
await release.wait()
|
||||
|
||||
dao_rules.TaskDefinitionsDao._validate_assignable = (
|
||||
dao_rules.QuestDefinitionsDao._validate_assignable = (
|
||||
_pausing_validate
|
||||
)
|
||||
try:
|
||||
@@ -373,7 +373,7 @@ def test_rule_delete_concurrent_with_definition_create_is_safe(
|
||||
delete_task, create_task, return_exceptions=True
|
||||
)
|
||||
finally:
|
||||
dao_rules.TaskDefinitionsDao._validate_assignable = (
|
||||
dao_rules.QuestDefinitionsDao._validate_assignable = (
|
||||
original_validate
|
||||
)
|
||||
else:
|
||||
@@ -405,7 +405,7 @@ def test_rule_delete_concurrent_with_definition_create_is_safe(
|
||||
assert rule_row is None
|
||||
else:
|
||||
# Create won: the delete was rejected, both rows exist.
|
||||
assert isinstance(creation, TaskDefinitionRecord), (
|
||||
assert isinstance(creation, QuestDefinitionRecord), (
|
||||
f"create must succeed, got {creation!r}"
|
||||
)
|
||||
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",
|
||||
due_time="08:00",
|
||||
)
|
||||
assert isinstance(definition, TaskDefinitionRecord)
|
||||
assert isinstance(definition, QuestDefinitionRecord)
|
||||
assert definition.title == "Brush teeth"
|
||||
assert definition.child_id == child.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 = {
|
||||
name
|
||||
for name, _ in inspect.getmembers(
|
||||
TaskDefinitionsDao, inspect.isfunction
|
||||
QuestDefinitionsDao, inspect.isfunction
|
||||
)
|
||||
}
|
||||
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
|
||||
# this table, but its row shape proves reassignment isolation).
|
||||
await database.execute(
|
||||
"INSERT INTO task_instances (definition_id, child_id, "
|
||||
"INSERT INTO quest_instances (definition_id, child_id, "
|
||||
"due_date, generated_at) VALUES (?, ?, ?, ?)",
|
||||
(definition.id, child.id, "2026-09-15", NOW),
|
||||
)
|
||||
await definitions.set_assignee(definition.id, other.id)
|
||||
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,),
|
||||
)
|
||||
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:
|
||||
"""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
|
||||
scan of package and tests, FROM/INTO/UPDATE/DELETE FROM/JOIN
|
||||
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(
|
||||
r"(FROM|INTO|UPDATE|DELETE\s+FROM|JOIN)\s+[`'\"]*(\[)?"
|
||||
r"(schedule_rules|task_definitions)\b",
|
||||
r"(schedule_rules|quest_definitions)\b",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
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()):
|
||||
offenders.append(relative)
|
||||
assert offenders == [], (
|
||||
f"SQL touching schedule_rules/task_definitions leaked into: "
|
||||
f"SQL touching schedule_rules/quest_definitions leaked into: "
|
||||
f"{offenders}"
|
||||
)
|
||||
|
||||
|
||||
def test_dao_instances_test_file_uses_dao_not_raw_rules_sql() -> None:
|
||||
"""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
|
||||
task_definitions, except its single sanctioned definition-create
|
||||
quest_definitions, except its single sanctioned definition-create
|
||||
helper usage and guard spans.
|
||||
"""
|
||||
import ast
|
||||
@@ -853,12 +853,12 @@ def test_dao_instances_test_file_uses_dao_not_raw_rules_sql() -> None:
|
||||
)
|
||||
sql_pattern = re.compile(
|
||||
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,
|
||||
)
|
||||
assert sql_pattern.search(remaining) is None, (
|
||||
"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
|
||||
tables. Exceptions: the guard functions' own spans (their regexes
|
||||
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.
|
||||
"""
|
||||
import ast
|
||||
@@ -898,7 +898,7 @@ def test_dao_rules_test_file_uses_dao_not_raw_table_sql() -> None:
|
||||
)
|
||||
sql_pattern = re.compile(
|
||||
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,
|
||||
)
|
||||
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(
|
||||
"SELECT name FROM sqlite_master WHERE type = 'table' "
|
||||
"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] == [
|
||||
"admin_users",
|
||||
"children",
|
||||
"quest_definitions",
|
||||
"schedule_rules",
|
||||
"task_definitions",
|
||||
]
|
||||
await async_unload_entry(hass, entry)
|
||||
+283
-32
@@ -62,10 +62,10 @@ def _counts(database) -> dict[str, int]:
|
||||
"children",
|
||||
"admin_users",
|
||||
"schedule_rules",
|
||||
"task_definitions",
|
||||
"quest_definitions",
|
||||
"presence_schedules",
|
||||
"presence_overrides",
|
||||
"task_instances",
|
||||
"quest_instances",
|
||||
"completion_events",
|
||||
)
|
||||
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")
|
||||
try:
|
||||
final = _migrate(database)
|
||||
assert final == 1
|
||||
assert _version(database) == 1
|
||||
assert final == len(MIGRATIONS)
|
||||
assert _version(database) == len(MIGRATIONS)
|
||||
finally:
|
||||
_run(database.close())
|
||||
|
||||
@@ -98,10 +98,10 @@ def test_fresh_file_migration_creates_all_v1_tables(tmp_path) -> None:
|
||||
"children",
|
||||
"admin_users",
|
||||
"schedule_rules",
|
||||
"task_definitions",
|
||||
"quest_definitions",
|
||||
"presence_schedules",
|
||||
"presence_overrides",
|
||||
"task_instances",
|
||||
"quest_instances",
|
||||
"completion_events",
|
||||
VERSION_TABLE,
|
||||
}
|
||||
@@ -135,9 +135,249 @@ def test_empty_version_table_reads_as_version_0(tmp_path) -> None:
|
||||
_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 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)
|
||||
assert final == 1
|
||||
assert _version(database) == 1
|
||||
assert final == len(MIGRATIONS)
|
||||
assert _version(database) == len(MIGRATIONS)
|
||||
assert _counts(database)["children"] == 1
|
||||
assert _counts(database)["admin_users"] == 1
|
||||
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"):
|
||||
_migrate(database)
|
||||
assert any(
|
||||
"already at version 1" in r.getMessage()
|
||||
f"already at version {len(MIGRATIONS)}" in r.getMessage()
|
||||
for r in caplog.records
|
||||
)
|
||||
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]]:
|
||||
"""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 [
|
||||
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 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:
|
||||
database = _open_db(tmp_path / "rollback.db")
|
||||
try:
|
||||
# Manually simulate a database at version 1: apply migration 1's
|
||||
# statements directly and stamp version 1 through the runner.
|
||||
# Manually simulate a fully-migrated database: run the real
|
||||
# migration list, which stamps the latest version.
|
||||
_migrate(database)
|
||||
version_row = _run(
|
||||
database.fetch_one(f"SELECT version FROM {VERSION_TABLE}")
|
||||
)
|
||||
assert version_row == (1,)
|
||||
assert version_row == (len(MIGRATIONS),)
|
||||
|
||||
with pytest.raises(sqlite3.OperationalError):
|
||||
_migrate(database, _failing_migrations(SCHEMA_V1_STATEMENTS))
|
||||
|
||||
# Version stamp unchanged, statements from the failed migration
|
||||
# absent, and the v1 tables untouched.
|
||||
assert _version(database) == 1
|
||||
assert _version(database) == len(MIGRATIONS)
|
||||
assert "later_table" not in _tables(database)
|
||||
assert _counts(database)["children"] == 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())
|
||||
|
||||
|
||||
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")
|
||||
try:
|
||||
# The fresh file still reaches migration 2 (the failing one):
|
||||
# migration 1 commits, stamping version 1, before migration 2
|
||||
# fails and rolls back. The run aborts at version 1 — the last
|
||||
# fully-applied migration — not at version 0.
|
||||
# The fresh file reaches the failing migration (the last one):
|
||||
# earlier migrations commit and stamp before it fails and rolls
|
||||
# back. The run aborts at the last fully-applied migration —
|
||||
# not at version 0.
|
||||
broken = _failing_migrations(SCHEMA_V1_STATEMENTS)
|
||||
with pytest.raises(sqlite3.OperationalError):
|
||||
_migrate(database, _failing_migrations(SCHEMA_V1_STATEMENTS))
|
||||
assert _version(database) == 1
|
||||
# Migration 2's tables must not survive the failed attempt.
|
||||
_migrate(database, broken)
|
||||
assert _version(database) == len(broken) - 1
|
||||
# The failing migration's tables must not survive the attempt.
|
||||
assert "later_table" not in _tables(database)
|
||||
# Migration 1's tables do: it committed before the failure.
|
||||
assert "children" in _tables(database)
|
||||
assert "task_instances" in _tables(database)
|
||||
assert "quest_instances" in _tables(database)
|
||||
finally:
|
||||
_run(database.close())
|
||||
|
||||
@@ -302,12 +553,12 @@ def test_failed_migration_can_be_retried_after_fix(tmp_path) -> None:
|
||||
with pytest.raises(sqlite3.OperationalError):
|
||||
_migrate(database, broken)
|
||||
# "Fix" the migration and retry: it now applies cleanly.
|
||||
broken[1] = [
|
||||
broken[-1] = [
|
||||
"CREATE TABLE IF NOT EXISTS later_table (id INTEGER PRIMARY KEY)"
|
||||
]
|
||||
final = _migrate(database, broken)
|
||||
assert final == 2
|
||||
assert _version(database) == 2
|
||||
assert final == len(broken)
|
||||
assert _version(database) == len(broken)
|
||||
assert "later_table" in _tables(database)
|
||||
finally:
|
||||
_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)
|
||||
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.
|
||||
names = {
|
||||
row[0]
|
||||
@@ -626,7 +877,7 @@ async def test_options_reload_reopens_and_rechecks_version(
|
||||
database_b = entry_reloaded.runtime_data.database
|
||||
assert database_b is not database_a
|
||||
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")
|
||||
assert row == (1,)
|
||||
finally:
|
||||
|
||||
+56
-56
@@ -15,8 +15,8 @@ from custom_components.nestquest.schema import (
|
||||
SCHEMA_V1_PRESENCE_SCHEDULES_DDL,
|
||||
SCHEMA_V1_SCHEDULE_RULES_DDL,
|
||||
SCHEMA_V1_STATEMENTS,
|
||||
SCHEMA_V1_TASK_DEFINITIONS_DDL,
|
||||
SCHEMA_V1_TASK_INSTANCES_DDL,
|
||||
SCHEMA_V1_QUEST_DEFINITIONS_DDL,
|
||||
SCHEMA_V1_QUEST_INSTANCES_DDL,
|
||||
async_apply_ddl,
|
||||
)
|
||||
|
||||
@@ -91,7 +91,7 @@ def _insert_definition(database, child_id=1, rule_id=1, **overrides):
|
||||
placeholders = ", ".join("?" for _ in values)
|
||||
result = _run(
|
||||
database.execute(
|
||||
f"INSERT INTO task_definitions ({columns}) VALUES ({placeholders})",
|
||||
f"INSERT INTO quest_definitions ({columns}) VALUES ({placeholders})",
|
||||
tuple(values.values()),
|
||||
)
|
||||
)
|
||||
@@ -161,10 +161,10 @@ def test_schema_v1_statements_compose_the_six_tables() -> None:
|
||||
*SCHEMA_V1_CHILDREN_DDL,
|
||||
*SCHEMA_V1_ADMIN_USERS_DDL,
|
||||
*SCHEMA_V1_SCHEDULE_RULES_DDL,
|
||||
*SCHEMA_V1_TASK_DEFINITIONS_DDL,
|
||||
*SCHEMA_V1_QUEST_DEFINITIONS_DDL,
|
||||
*SCHEMA_V1_PRESENCE_SCHEDULES_DDL,
|
||||
*SCHEMA_V1_PRESENCE_OVERRIDES_DDL,
|
||||
*SCHEMA_V1_TASK_INSTANCES_DDL,
|
||||
*SCHEMA_V1_QUEST_INSTANCES_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())
|
||||
|
||||
|
||||
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")
|
||||
try:
|
||||
_apply(database)
|
||||
row = _run(
|
||||
database.fetch_one(
|
||||
"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:
|
||||
_run(database.close())
|
||||
|
||||
@@ -292,11 +292,11 @@ def test_schedule_rules_columns_types_and_constraints(tmp_path) -> None:
|
||||
_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")
|
||||
try:
|
||||
_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] == [
|
||||
("id", "INTEGER", 0, None, 1),
|
||||
("title", "TEXT", 1, None, 0),
|
||||
@@ -312,11 +312,11 @@ def test_task_definitions_columns_types_and_constraints(tmp_path) -> None:
|
||||
_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")
|
||||
try:
|
||||
_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}
|
||||
assert declared == {
|
||||
("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())
|
||||
|
||||
|
||||
def test_task_definitions_insert_requires_existing_child_and_rule(
|
||||
def test_quest_definitions_insert_requires_existing_child_and_rule(
|
||||
tmp_path,
|
||||
) -> None:
|
||||
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(
|
||||
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 = ?",
|
||||
(definition_id,),
|
||||
)
|
||||
@@ -841,7 +841,7 @@ def test_task_definitions_insert_requires_existing_child_and_rule(
|
||||
_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")
|
||||
try:
|
||||
_apply(database)
|
||||
@@ -852,7 +852,7 @@ def test_task_definitions_unknown_child_id_fails(tmp_path) -> None:
|
||||
_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")
|
||||
try:
|
||||
_apply(database)
|
||||
@@ -868,7 +868,7 @@ def test_task_definitions_unknown_schedule_rule_id_fails(tmp_path) -> None:
|
||||
_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")
|
||||
try:
|
||||
_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])
|
||||
def test_task_definitions_is_active_non_integer_fails(
|
||||
def test_quest_definitions_is_active_non_integer_fails(
|
||||
tmp_path, bad_is_active
|
||||
) -> None:
|
||||
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())
|
||||
|
||||
|
||||
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")
|
||||
try:
|
||||
_apply(database)
|
||||
@@ -912,7 +912,7 @@ def test_task_definitions_title_not_null_enforced(tmp_path) -> None:
|
||||
_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")
|
||||
try:
|
||||
_apply(database)
|
||||
@@ -925,7 +925,7 @@ def test_task_definitions_created_at_not_null_enforced(tmp_path) -> None:
|
||||
_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")
|
||||
try:
|
||||
_apply(database)
|
||||
@@ -941,7 +941,7 @@ def test_task_definitions_defaults_apply_on_insert(tmp_path) -> None:
|
||||
)
|
||||
row = _run(
|
||||
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,),
|
||||
)
|
||||
)
|
||||
@@ -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)
|
||||
_apply(database)
|
||||
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}"))
|
||||
counts.append(row[0])
|
||||
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)
|
||||
result = _run(
|
||||
database.execute(
|
||||
f"INSERT INTO task_instances ({columns}) VALUES ({placeholders})",
|
||||
f"INSERT INTO quest_instances ({columns}) VALUES ({placeholders})",
|
||||
tuple(values.values()),
|
||||
)
|
||||
)
|
||||
@@ -1466,28 +1466,28 @@ def _insert_event(database, instance_id=1, **overrides):
|
||||
|
||||
|
||||
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)
|
||||
_insert_rule(database)
|
||||
_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")
|
||||
try:
|
||||
_apply(database)
|
||||
row = _run(
|
||||
database.fetch_one(
|
||||
"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:
|
||||
_run(database.close())
|
||||
|
||||
@@ -1507,11 +1507,11 @@ def test_completion_events_table_exists_after_applying_ddl(tmp_path) -> None:
|
||||
_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")
|
||||
try:
|
||||
_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
|
||||
assert [(row[1], row[2], row[3], row[4], row[5]) for row in columns] == [
|
||||
("id", "INTEGER", 0, None, 1),
|
||||
@@ -1544,14 +1544,14 @@ def test_completion_events_columns_types_and_constraints(tmp_path) -> None:
|
||||
_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")
|
||||
try:
|
||||
_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}
|
||||
assert declared == {
|
||||
("task_definitions", "definition_id", "id"),
|
||||
("quest_definitions", "definition_id", "id"),
|
||||
("children", "child_id", "id"),
|
||||
}
|
||||
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)"))
|
||||
declared = {(row[2], row[3], row[4]) for row in fks}
|
||||
assert declared == {
|
||||
("task_instances", "instance_id", "id"),
|
||||
("quest_instances", "instance_id", "id"),
|
||||
("children", "child_id", "id"),
|
||||
}
|
||||
finally:
|
||||
_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")
|
||||
try:
|
||||
_apply(database)
|
||||
@@ -1580,7 +1580,7 @@ def test_task_instances_unique_index_on_definition_and_date(tmp_path) -> None:
|
||||
rows = _run(
|
||||
database.fetch_all(
|
||||
"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]
|
||||
@@ -1599,7 +1599,7 @@ def test_task_instances_unique_index_on_definition_and_date(tmp_path) -> None:
|
||||
row = _run(
|
||||
database.fetch_one(
|
||||
"SELECT COUNT(DISTINCT name) FROM pragma_index_list"
|
||||
"('task_instances') WHERE \"unique\" = 1"
|
||||
"('quest_instances') WHERE \"unique\" = 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")
|
||||
try:
|
||||
_apply(database)
|
||||
@@ -1621,7 +1621,7 @@ def test_task_instances_valid_insert_round_trips(tmp_path) -> None:
|
||||
row = _run(
|
||||
database.fetch_one(
|
||||
"SELECT definition_id, child_id, due_date, due_time, generated_at "
|
||||
"FROM task_instances WHERE id = ?",
|
||||
"FROM quest_instances WHERE id = ?",
|
||||
(instance_id,),
|
||||
)
|
||||
)
|
||||
@@ -1630,7 +1630,7 @@ def test_task_instances_valid_insert_round_trips(tmp_path) -> None:
|
||||
_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")
|
||||
try:
|
||||
_apply(database)
|
||||
@@ -1640,7 +1640,7 @@ def test_task_instances_due_time_round_trips(tmp_path) -> None:
|
||||
)
|
||||
row = _run(
|
||||
database.fetch_one(
|
||||
"SELECT due_time FROM task_instances WHERE id = ?",
|
||||
"SELECT due_time FROM quest_instances WHERE id = ?",
|
||||
(instance_id,),
|
||||
)
|
||||
)
|
||||
@@ -1649,7 +1649,7 @@ def test_task_instances_due_time_round_trips(tmp_path) -> None:
|
||||
_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")
|
||||
try:
|
||||
_apply(database)
|
||||
@@ -1661,7 +1661,7 @@ def test_task_instances_duplicate_definition_date_fails(tmp_path) -> None:
|
||||
_run(database.close())
|
||||
|
||||
|
||||
def test_task_instances_same_date_different_definitions_allowed(
|
||||
def test_quest_instances_same_date_different_definitions_allowed(
|
||||
tmp_path,
|
||||
) -> None:
|
||||
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_instance(database, definition_id=1, 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,)
|
||||
finally:
|
||||
_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")
|
||||
try:
|
||||
_apply(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-15")
|
||||
row = _run(database.fetch_one("SELECT COUNT(*) FROM task_instances"))
|
||||
row = _run(database.fetch_one("SELECT COUNT(*) FROM quest_instances"))
|
||||
assert row == (2,)
|
||||
finally:
|
||||
_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")
|
||||
try:
|
||||
_apply(database)
|
||||
@@ -1708,7 +1708,7 @@ def test_task_instances_unknown_definition_fails(tmp_path) -> None:
|
||||
_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")
|
||||
try:
|
||||
_apply(database)
|
||||
@@ -1719,7 +1719,7 @@ def test_task_instances_unknown_child_fails(tmp_path) -> None:
|
||||
_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")
|
||||
try:
|
||||
_apply(database)
|
||||
@@ -1730,7 +1730,7 @@ def test_task_instances_due_date_not_null_enforced(tmp_path) -> None:
|
||||
_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")
|
||||
try:
|
||||
_apply(database)
|
||||
@@ -2026,8 +2026,8 @@ def test_applying_full_ddl_twice_keeps_rows(tmp_path) -> None:
|
||||
for table in (
|
||||
"children",
|
||||
"schedule_rules",
|
||||
"task_definitions",
|
||||
"task_instances",
|
||||
"quest_definitions",
|
||||
"quest_instances",
|
||||
"completion_events",
|
||||
):
|
||||
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.migrations import (
|
||||
MIGRATIONS,
|
||||
VERSION_TABLE,
|
||||
apply_migrations,
|
||||
)
|
||||
@@ -114,12 +115,13 @@ def test_missing_file_opens_fresh(tmp_path) -> None:
|
||||
assert not db_path.exists()
|
||||
database = _run(_async_open_database(_make_hass_mock(), db_path))
|
||||
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()
|
||||
row = _run(
|
||||
database.fetch_one(f"SELECT version FROM {VERSION_TABLE}")
|
||||
)
|
||||
assert row == (1,)
|
||||
assert row == (len(MIGRATIONS),)
|
||||
finally:
|
||||
_run(database.close())
|
||||
|
||||
@@ -151,7 +153,7 @@ def test_valid_file_opens_and_migrations_noop(tmp_path) -> None:
|
||||
row = _run(
|
||||
database.fetch_one(f"SELECT version FROM {VERSION_TABLE}")
|
||||
)
|
||||
assert row == (1,)
|
||||
assert row == (len(MIGRATIONS),)
|
||||
finally:
|
||||
_run(database.close())
|
||||
# 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(
|
||||
f"SELECT version FROM {VERSION_TABLE}"
|
||||
)
|
||||
assert row == (1,)
|
||||
assert row == (len(MIGRATIONS),)
|
||||
finally:
|
||||
await database.close()
|
||||
assert await async_unload_entry(hass, entry) is True
|
||||
|
||||
Reference in New Issue
Block a user