Quest windows via quest_definition_windows (D-008)
Per IMPLEMENTATION-ORDER.md 1.4: a quest_definition_windows table (definition_id, window, due_time) declares which day windows a definition spans, one row per definition+window via the composite primary key; window is CHECK-constrained to morning/afternoon/evening. - const.py: WINDOW_MORNING/AFTERNOON/EVENING, QUEST_WINDOWS canonical order, WINDOW_CLOCK_RANGES with the documented boundaries and the boundary-to-later-window rule documented for Feature 07. - schema.py: windows DDL joins SCHEMA_V1_STATEMENTS; due_time is caller-validated strict HH:MM (DAO layer), matching the date policy. - migrations.py: migration 4 is a plain CREATE IF NOT EXISTS statement list (no rebuild needed for a new table). - dao_rules.py: QuestDefinitionsDao gains upsert_window (idempotent per window, due_time refresh in place), list_windows (const order), remove_window; _validate_time/_validate_window helpers. - tests: windows DDL shape/constraints (CHECK matrix, duplicate, unknown definition), DAO round-trips, const range shapes; leak guards extended to the new table.
This commit is contained in:
@@ -20,3 +20,28 @@ DEFAULT_DAY_ROLLOVER_TIME = "00:00"
|
||||
|
||||
CONF_PANEL_IDLE_TIMEOUT = "panel_idle_timeout"
|
||||
DEFAULT_PANEL_IDLE_TIMEOUT = 300
|
||||
|
||||
# Quest windows (D-008): a definition may declare several day windows;
|
||||
# each produces its own instance per day and groups the panel's Quest
|
||||
# Log into three columns. The clock ranges are stored here so they stay
|
||||
# configurable later. Boundaries: a window's end equals the next
|
||||
# window's start; classification (Feature 07 materialisation) treats a
|
||||
# due time exactly on the boundary as belonging to the LATER window, so
|
||||
# 12:00 is afternoon (not morning) and 17:00 is evening (not
|
||||
# afternoon). Times after 21:00 belong to no window.
|
||||
WINDOW_MORNING = "morning"
|
||||
WINDOW_AFTERNOON = "afternoon"
|
||||
WINDOW_EVENING = "evening"
|
||||
|
||||
QUEST_WINDOWS: tuple[str, ...] = (
|
||||
WINDOW_MORNING,
|
||||
WINDOW_AFTERNOON,
|
||||
WINDOW_EVENING,
|
||||
)
|
||||
|
||||
#: Inclusive "HH:MM" bounds per window, in 24-hour local time.
|
||||
WINDOW_CLOCK_RANGES: dict[str, tuple[str, str]] = {
|
||||
WINDOW_MORNING: ("00:00", "11:59"),
|
||||
WINDOW_AFTERNOON: ("12:00", "17:00"),
|
||||
WINDOW_EVENING: ("17:00", "21:00"),
|
||||
}
|
||||
|
||||
@@ -36,6 +36,7 @@ from __future__ import annotations
|
||||
import datetime
|
||||
from dataclasses import dataclass
|
||||
|
||||
from .const import QUEST_WINDOWS
|
||||
from .dao_children import ChildRecord, _child_from_row, _connection_lock
|
||||
from .dao_children import _CHILD_COLUMNS
|
||||
from .db import NestQuestDatabase
|
||||
@@ -71,6 +72,38 @@ def _validate_date(value: str, field: str) -> None:
|
||||
)
|
||||
|
||||
|
||||
_TIME_FORMAT = "%H:%M"
|
||||
|
||||
|
||||
def _validate_time(value: str, field: str) -> None:
|
||||
"""Raise ValueError unless ``value`` is a strict 24-hour HH:MM.
|
||||
|
||||
Same strictness policy as _validate_date: parse with the exact
|
||||
format, then round-trip so '9:30' (non-padded) is rejected. Time
|
||||
shapes are caller-side policy (the schema stores TEXT): windows'
|
||||
optional ``due_time`` and definition ``due_time`` both use this.
|
||||
"""
|
||||
try:
|
||||
parsed = datetime.datetime.strptime(value, _TIME_FORMAT).time()
|
||||
except (TypeError, ValueError):
|
||||
raise ValueError(
|
||||
f"{field} must be a 24-hour HH:MM time, got {value!r}"
|
||||
) from None
|
||||
if parsed.strftime(_TIME_FORMAT) != value:
|
||||
raise ValueError(
|
||||
f"{field} must be a strict HH:MM time, got {value!r}"
|
||||
)
|
||||
|
||||
|
||||
def _validate_window(window: str) -> None:
|
||||
"""Raise ValueError unless ``window`` is a ``const`` window name."""
|
||||
if window not in QUEST_WINDOWS:
|
||||
raise ValueError(
|
||||
f"window must be one of {', '.join(QUEST_WINDOWS)}, "
|
||||
f"got {window!r}"
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ScheduleRuleRecord:
|
||||
"""One row of ``schedule_rules``."""
|
||||
@@ -100,6 +133,15 @@ class QuestDefinitionRecord:
|
||||
created_at: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class QuestDefinitionWindowRecord:
|
||||
"""One row of ``quest_definition_windows``."""
|
||||
|
||||
definition_id: int
|
||||
window: str
|
||||
due_time: str | None
|
||||
|
||||
|
||||
_RULE_COLUMNS = (
|
||||
"id, rule_type, interval, weekday_set, day_of_month, nth_weekday, "
|
||||
"month, start_date, end_date"
|
||||
@@ -108,6 +150,7 @@ _DEFINITION_COLUMNS = (
|
||||
"id, title, description, icon, schedule_rule_id, due_time, "
|
||||
"is_active, created_at"
|
||||
)
|
||||
_WINDOW_COLUMNS = "definition_id, window, due_time"
|
||||
|
||||
|
||||
def _rule_from_row(row: tuple) -> ScheduleRuleRecord:
|
||||
@@ -137,6 +180,14 @@ def _definition_from_row(row: tuple) -> QuestDefinitionRecord:
|
||||
)
|
||||
|
||||
|
||||
def _window_from_row(row: tuple) -> QuestDefinitionWindowRecord:
|
||||
return QuestDefinitionWindowRecord(
|
||||
definition_id=row[0],
|
||||
window=row[1],
|
||||
due_time=row[2],
|
||||
)
|
||||
|
||||
|
||||
class ScheduleRulesDao:
|
||||
"""Typed async access to the ``schedule_rules`` table."""
|
||||
|
||||
@@ -507,6 +558,92 @@ class QuestDefinitionsDao:
|
||||
)
|
||||
return [_child_from_row(row) for row in rows]
|
||||
|
||||
async def upsert_window(
|
||||
self,
|
||||
definition_id: int,
|
||||
window: str,
|
||||
*,
|
||||
due_time: str | None = None,
|
||||
) -> QuestDefinitionWindowRecord:
|
||||
"""Declare (or re-declare) ``window`` on the definition.
|
||||
|
||||
The composite primary key makes the write idempotent per
|
||||
(definition, window): re-declaring updates ``due_time`` in
|
||||
place instead of duplicating. The window name must be one of
|
||||
``const.QUEST_WINDOWS`` (the schema CHECK backs this up) and a
|
||||
provided ``due_time`` must be strict 24-hour HH:MM. The
|
||||
definition must exist — validated under the connection lock so
|
||||
the verdict cannot go stale before the write.
|
||||
"""
|
||||
_validate_window(window)
|
||||
if due_time is not None:
|
||||
_validate_time(due_time, "due_time")
|
||||
async with _connection_lock(self._database):
|
||||
async with self._database.transaction():
|
||||
definition = await self._database.fetch_one(
|
||||
"SELECT 1 FROM quest_definitions WHERE id = ?",
|
||||
(definition_id,),
|
||||
)
|
||||
if definition is None:
|
||||
raise ValueError(
|
||||
f"quest definition {definition_id} does not exist"
|
||||
)
|
||||
await self._database.execute(
|
||||
"INSERT INTO quest_definition_windows "
|
||||
"(definition_id, window, due_time) VALUES (?, ?, ?) "
|
||||
"ON CONFLICT (definition_id, window) DO UPDATE SET "
|
||||
"due_time = excluded.due_time",
|
||||
(definition_id, window, due_time),
|
||||
)
|
||||
row = await self._database.fetch_one(
|
||||
f"SELECT {_WINDOW_COLUMNS} "
|
||||
"FROM quest_definition_windows "
|
||||
"WHERE definition_id = ? AND window = ?",
|
||||
(definition_id, window),
|
||||
)
|
||||
assert row is not None
|
||||
return _window_from_row(row)
|
||||
|
||||
async def list_windows(
|
||||
self, definition_id: int
|
||||
) -> list[QuestDefinitionWindowRecord]:
|
||||
"""Return the definition's windows in ``const`` order.
|
||||
|
||||
Ordered by the canonical window sequence (morning, afternoon,
|
||||
evening) rather than insertion order, so callers always render
|
||||
the Quest Log's three columns consistently. A definition with
|
||||
no windows returns [].
|
||||
"""
|
||||
rows = await self._database.fetch_all(
|
||||
f"SELECT {_WINDOW_COLUMNS} FROM quest_definition_windows "
|
||||
"WHERE definition_id = ?",
|
||||
(definition_id,),
|
||||
)
|
||||
by_name = {row[1]: _window_from_row(row) for row in rows}
|
||||
return [
|
||||
by_name[name]
|
||||
for name in QUEST_WINDOWS
|
||||
if name in by_name
|
||||
]
|
||||
|
||||
async def remove_window(
|
||||
self, definition_id: int, window: str
|
||||
) -> bool:
|
||||
"""Remove the window declaration; True when a row was removed.
|
||||
|
||||
Future materialization stops producing instances for this
|
||||
(definition, window); existing instances and completion history
|
||||
are never touched here (Feature 06 guardrail).
|
||||
"""
|
||||
_validate_window(window)
|
||||
async with _connection_lock(self._database):
|
||||
result = await self._database.execute(
|
||||
"DELETE FROM quest_definition_windows "
|
||||
"WHERE definition_id = ? AND window = ?",
|
||||
(definition_id, window),
|
||||
)
|
||||
return result.rowcount > 0
|
||||
|
||||
async def _validate_assignable_child(self, child_id: int) -> None:
|
||||
"""Raise ValueError unless the child exists and is active.
|
||||
|
||||
|
||||
@@ -73,6 +73,7 @@ from .schema import (
|
||||
QUEST_DEFINITIONS_TABLE_SQL,
|
||||
SCHEMA_V1_STATEMENTS,
|
||||
SCHEMA_V1_QUEST_DEFINITION_ASSIGNEES_DDL,
|
||||
SCHEMA_V1_QUEST_DEFINITION_WINDOWS_DDL,
|
||||
)
|
||||
|
||||
LOGGER = logging.getLogger(__name__)
|
||||
@@ -256,6 +257,10 @@ MIGRATIONS: Sequence[MigrationStep] = [
|
||||
MIGRATION_1_V1_DDL,
|
||||
_rename_legacy_task_tables,
|
||||
_add_definition_assignees,
|
||||
# Migration 4 (D-008): the windows table is a plain new table — a
|
||||
# CREATE IF NOT EXISTS suffices for both fresh databases (which
|
||||
# already made it in migration 1) and pre-window databases.
|
||||
list(SCHEMA_V1_QUEST_DEFINITION_WINDOWS_DDL),
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -95,6 +95,15 @@ migration runner rebuilds a pre-D-008 table into this shape and copies
|
||||
its single-assignee column across. The composite primary key makes
|
||||
duplicate assignment impossible at the storage layer.
|
||||
|
||||
Quest windows (D-008): ``quest_definition_windows`` declares which day
|
||||
windows a definition spans — one row per (definition, window), the
|
||||
composite primary key making a window idempotent per definition.
|
||||
``window`` is CHECK-constrained to the three spellings ``const`` owns;
|
||||
``due_time`` optionally pins a due time inside the window and is
|
||||
caller-validated (strict HH:MM) by the DAO layer, matching the date
|
||||
policy above. The window clock ranges live in ``const.py``, not here:
|
||||
this layer stores what the caller declares and never interprets clocks.
|
||||
|
||||
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)
|
||||
@@ -260,6 +269,17 @@ SCHEMA_V1_QUEST_DEFINITION_ASSIGNEES_DDL: list[str] = [
|
||||
""",
|
||||
]
|
||||
|
||||
SCHEMA_V1_QUEST_DEFINITION_WINDOWS_DDL: list[str] = [
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS quest_definition_windows (
|
||||
definition_id INTEGER NOT NULL REFERENCES quest_definitions(id),
|
||||
window TEXT NOT NULL CHECK (window IN ('morning', 'afternoon', 'evening')),
|
||||
due_time TEXT,
|
||||
PRIMARY KEY (definition_id, window)
|
||||
)
|
||||
""",
|
||||
]
|
||||
|
||||
SCHEMA_V1_PRESENCE_SCHEDULES_DDL: list[str] = [
|
||||
f"""
|
||||
CREATE TABLE IF NOT EXISTS presence_schedules (
|
||||
@@ -342,6 +362,7 @@ SCHEMA_V1_STATEMENTS: list[str] = [
|
||||
*SCHEMA_V1_SCHEDULE_RULES_DDL,
|
||||
*SCHEMA_V1_QUEST_DEFINITIONS_DDL,
|
||||
*SCHEMA_V1_QUEST_DEFINITION_ASSIGNEES_DDL,
|
||||
*SCHEMA_V1_QUEST_DEFINITION_WINDOWS_DDL,
|
||||
*SCHEMA_V1_PRESENCE_SCHEDULES_DDL,
|
||||
*SCHEMA_V1_PRESENCE_OVERRIDES_DDL,
|
||||
*SCHEMA_V1_QUEST_INSTANCES_DDL,
|
||||
|
||||
@@ -73,6 +73,36 @@ def test_panel_idle_timeout_config() -> None:
|
||||
assert const.DEFAULT_PANEL_IDLE_TIMEOUT > 0
|
||||
|
||||
|
||||
def test_quest_windows_canonical_order() -> None:
|
||||
"""QUEST_WINDOWS is exactly the three spellings in canonical order."""
|
||||
assert const.QUEST_WINDOWS == ("morning", "afternoon", "evening")
|
||||
assert const.WINDOW_MORNING == "morning"
|
||||
assert const.WINDOW_AFTERNOON == "afternoon"
|
||||
assert const.WINDOW_EVENING == "evening"
|
||||
# No duplicates, every name covered by its individual constant.
|
||||
assert len(set(const.QUEST_WINDOWS)) == 3
|
||||
|
||||
|
||||
def test_window_clock_ranges_cover_every_window() -> None:
|
||||
"""Each window has a strict HH:MM (start, end) range.
|
||||
|
||||
The ranges follow the documented boundaries (D-008): morning ends
|
||||
11:59, afternoon spans 12:00-17:00, evening 17:00-21:00, and each
|
||||
window's end equals the next window's start (the boundary belongs
|
||||
to the later window at classification time).
|
||||
"""
|
||||
assert const.WINDOW_CLOCK_RANGES == {
|
||||
"morning": ("00:00", "11:59"),
|
||||
"afternoon": ("12:00", "17:00"),
|
||||
"evening": ("17:00", "21:00"),
|
||||
}
|
||||
for window in const.QUEST_WINDOWS:
|
||||
start, end = const.WINDOW_CLOCK_RANGES[window]
|
||||
for value in (start, end):
|
||||
parsed = datetime.strptime(value, "%H:%M").time()
|
||||
assert parsed.strftime("%H:%M") == value
|
||||
|
||||
|
||||
def test_no_hardcoded_domain_or_db_filename_in_modules() -> None:
|
||||
"""Ensure no module other than const.py hardcodes DOMAIN string or SQLITE_DB_FILENAME."""
|
||||
pkg_dir = Path(const.__file__).parent
|
||||
|
||||
+94
-3
@@ -11,6 +11,7 @@ from custom_components.nestquest.dao_rules import (
|
||||
ScheduleRuleRecord,
|
||||
ScheduleRulesDao,
|
||||
QuestDefinitionRecord,
|
||||
QuestDefinitionWindowRecord,
|
||||
QuestDefinitionsDao,
|
||||
)
|
||||
from custom_components.nestquest.db import NestQuestDatabase
|
||||
@@ -790,6 +791,96 @@ def test_definition_list_assignees_orders_by_child_sort_order(
|
||||
_with_db(tmp_path, "definition-assignees-order.db")(_body)
|
||||
|
||||
|
||||
def test_definition_upsert_window_round_trip(tmp_path) -> None:
|
||||
async def _body(database, rules, definitions, children, child):
|
||||
rule = await rules.create("daily", "2026-09-14")
|
||||
definition = await definitions.create("A", rule.id, NOW)
|
||||
window = await definitions.upsert_window(
|
||||
definition.id, "morning", due_time="09:00"
|
||||
)
|
||||
assert window == QuestDefinitionWindowRecord(
|
||||
definition.id, "morning", "09:00"
|
||||
)
|
||||
# Upsert: re-declaring the same window updates in place, never
|
||||
# duplicates (one row per definition+window).
|
||||
updated = await definitions.upsert_window(
|
||||
definition.id, "morning", due_time="09:30"
|
||||
)
|
||||
assert updated.due_time == "09:30"
|
||||
rows = await definitions.list_windows(definition.id)
|
||||
assert [w.window for w in rows] == ["morning"]
|
||||
# All three windows list in const order regardless of insertion.
|
||||
await definitions.upsert_window(definition.id, "evening")
|
||||
await definitions.upsert_window(definition.id, "afternoon")
|
||||
rows = await definitions.list_windows(definition.id)
|
||||
assert [w.window for w in rows] == [
|
||||
"morning",
|
||||
"afternoon",
|
||||
"evening",
|
||||
]
|
||||
assert rows[1].due_time is None
|
||||
return rows
|
||||
|
||||
_with_db(tmp_path, "definition-window-roundtrip.db")(_body)
|
||||
|
||||
|
||||
def test_definition_window_unknown_name_raises(tmp_path) -> None:
|
||||
async def _body(database, rules, definitions, children, child):
|
||||
rule = await rules.create("daily", "2026-09-14")
|
||||
definition = await definitions.create("A", rule.id, NOW)
|
||||
with pytest.raises(ValueError, match="window must be one of"):
|
||||
await definitions.upsert_window(definition.id, "noon")
|
||||
with pytest.raises(ValueError, match="window must be one of"):
|
||||
await definitions.remove_window(definition.id, "MORNING")
|
||||
return None
|
||||
|
||||
_with_db(tmp_path, "definition-window-bad-name.db")(_body)
|
||||
|
||||
|
||||
def test_definition_window_bad_due_time_raises(tmp_path) -> None:
|
||||
async def _body(database, rules, definitions, children, child):
|
||||
rule = await rules.create("daily", "2026-09-14")
|
||||
definition = await definitions.create("A", rule.id, NOW)
|
||||
with pytest.raises(ValueError, match="HH:MM"):
|
||||
await definitions.upsert_window(
|
||||
definition.id, "morning", due_time="9:30"
|
||||
)
|
||||
with pytest.raises(ValueError, match="HH:MM"):
|
||||
await definitions.upsert_window(
|
||||
definition.id, "morning", due_time="25:00"
|
||||
)
|
||||
return None
|
||||
|
||||
_with_db(tmp_path, "definition-window-bad-time.db")(_body)
|
||||
|
||||
|
||||
def test_definition_window_missing_definition_raises(tmp_path) -> None:
|
||||
async def _body(database, rules, definitions, children, child):
|
||||
with pytest.raises(ValueError, match="does not exist"):
|
||||
await definitions.upsert_window(999, "morning")
|
||||
return None
|
||||
|
||||
_with_db(tmp_path, "definition-window-missing.db")(_body)
|
||||
|
||||
|
||||
def test_definition_remove_window_round_trip(tmp_path) -> None:
|
||||
async def _body(database, rules, definitions, children, child):
|
||||
rule = await rules.create("daily", "2026-09-14")
|
||||
definition = await definitions.create("A", rule.id, NOW)
|
||||
await definitions.upsert_window(definition.id, "morning")
|
||||
assert await definitions.remove_window(
|
||||
definition.id, "morning"
|
||||
) is True
|
||||
assert await definitions.list_windows(definition.id) == []
|
||||
# Removing an absent window reports False.
|
||||
assert await definitions.remove_window(
|
||||
definition.id, "morning"
|
||||
) is False
|
||||
return None
|
||||
|
||||
_with_db(tmp_path, "definition-window-remove.db")(_body)
|
||||
|
||||
|
||||
def test_definition_unassigned_has_no_assignees(tmp_path) -> None:
|
||||
async def _body(database, rules, definitions, children, child):
|
||||
rule = await rules.create("daily", "2026-09-14")
|
||||
@@ -889,7 +980,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|quest_definitions|quest_definition_assignees)\b",
|
||||
r"(schedule_rules|quest_definitions|quest_definition_assignees|quest_definition_windows)\b",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
offenders: list[str] = []
|
||||
@@ -941,7 +1032,7 @@ 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|quest_definitions|quest_definition_assignees)\b",
|
||||
r"FROM|JOIN)\s+[`'\"]*(\[)?(schedule_rules|quest_definitions|quest_definition_assignees|quest_definition_windows)\b",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
assert sql_pattern.search(remaining) is None, (
|
||||
@@ -986,7 +1077,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|quest_definitions|quest_definition_assignees)\b",
|
||||
r"FROM|JOIN)\s+[`'\"]*(\[)?(schedule_rules|quest_definitions|quest_definition_assignees|quest_definition_windows)\b",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
assert sql_pattern.search(remaining) is None, (
|
||||
|
||||
@@ -17,7 +17,10 @@ from custom_components.nestquest.migrations import (
|
||||
apply_migrations,
|
||||
read_schema_version,
|
||||
)
|
||||
from custom_components.nestquest.schema import SCHEMA_V1_STATEMENTS
|
||||
from custom_components.nestquest.schema import (
|
||||
SCHEMA_V1_STATEMENTS,
|
||||
SCHEMA_V1_QUEST_DEFINITION_WINDOWS_DDL,
|
||||
)
|
||||
|
||||
|
||||
def _run(coro):
|
||||
@@ -64,6 +67,7 @@ def _counts(database) -> dict[str, int]:
|
||||
"schedule_rules",
|
||||
"quest_definitions",
|
||||
"quest_definition_assignees",
|
||||
"quest_definition_windows",
|
||||
"presence_schedules",
|
||||
"presence_overrides",
|
||||
"quest_instances",
|
||||
@@ -101,6 +105,7 @@ def test_fresh_file_migration_creates_all_v1_tables(tmp_path) -> None:
|
||||
"schedule_rules",
|
||||
"quest_definitions",
|
||||
"quest_definition_assignees",
|
||||
"quest_definition_windows",
|
||||
"presence_schedules",
|
||||
"presence_overrides",
|
||||
"quest_instances",
|
||||
@@ -138,18 +143,19 @@ def test_empty_version_table_reads_as_version_0(tmp_path) -> None:
|
||||
|
||||
|
||||
def test_migration_list_shape() -> None:
|
||||
"""Migration 1 is the v1 DDL; 2 and 3 are the callable steps.
|
||||
"""Migration 1 is the v1 DDL; 2 and 3 are callable steps.
|
||||
|
||||
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, and
|
||||
for dev databases stamped version 1 by the pre-rename runner,
|
||||
migration 3 brings pre-D-008 definitions to the multi-assignee
|
||||
model.
|
||||
model, and migration 4 adds the windows table.
|
||||
"""
|
||||
assert MIGRATIONS[0] == SCHEMA_V1_STATEMENTS
|
||||
assert callable(MIGRATIONS[1])
|
||||
assert callable(MIGRATIONS[2])
|
||||
assert len(MIGRATIONS) == 3
|
||||
assert MIGRATIONS[3] == SCHEMA_V1_QUEST_DEFINITION_WINDOWS_DDL
|
||||
assert len(MIGRATIONS) == 4
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
+163
-1
@@ -17,6 +17,7 @@ from custom_components.nestquest.schema import (
|
||||
SCHEMA_V1_STATEMENTS,
|
||||
SCHEMA_V1_QUEST_DEFINITIONS_DDL,
|
||||
SCHEMA_V1_QUEST_DEFINITION_ASSIGNEES_DDL,
|
||||
SCHEMA_V1_QUEST_DEFINITION_WINDOWS_DDL,
|
||||
SCHEMA_V1_QUEST_INSTANCES_DDL,
|
||||
async_apply_ddl,
|
||||
)
|
||||
@@ -167,13 +168,14 @@ def _insert_override(database, child_id, **overrides):
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_schema_v1_statements_compose_the_seven_tables() -> None:
|
||||
def test_schema_v1_statements_compose_the_eight_tables() -> None:
|
||||
assert SCHEMA_V1_STATEMENTS == [
|
||||
*SCHEMA_V1_CHILDREN_DDL,
|
||||
*SCHEMA_V1_ADMIN_USERS_DDL,
|
||||
*SCHEMA_V1_SCHEDULE_RULES_DDL,
|
||||
*SCHEMA_V1_QUEST_DEFINITIONS_DDL,
|
||||
*SCHEMA_V1_QUEST_DEFINITION_ASSIGNEES_DDL,
|
||||
*SCHEMA_V1_QUEST_DEFINITION_WINDOWS_DDL,
|
||||
*SCHEMA_V1_PRESENCE_SCHEDULES_DDL,
|
||||
*SCHEMA_V1_PRESENCE_OVERRIDES_DDL,
|
||||
*SCHEMA_V1_QUEST_INSTANCES_DDL,
|
||||
@@ -974,6 +976,166 @@ def test_quest_definition_assignees_duplicate_pair_fails(tmp_path) -> None:
|
||||
_run(database.close())
|
||||
|
||||
|
||||
def test_quest_definition_windows_table_exists_after_applying_ddl(
|
||||
tmp_path,
|
||||
) -> None:
|
||||
database = _open_db(tmp_path / "windows.db")
|
||||
try:
|
||||
_apply(database)
|
||||
row = _run(
|
||||
database.fetch_one(
|
||||
"SELECT name FROM sqlite_master WHERE type = 'table' "
|
||||
"AND name = 'quest_definition_windows'"
|
||||
)
|
||||
)
|
||||
assert row is not None and row[0] == "quest_definition_windows"
|
||||
finally:
|
||||
_run(database.close())
|
||||
|
||||
|
||||
def test_quest_definition_windows_columns_types_and_constraints(
|
||||
tmp_path,
|
||||
) -> None:
|
||||
database = _open_db(tmp_path / "windows-columns.db")
|
||||
try:
|
||||
_apply(database)
|
||||
columns = _run(
|
||||
database.fetch_all("PRAGMA table_info(quest_definition_windows)")
|
||||
)
|
||||
# cid, name, type, notnull, dflt_value, pk — the composite
|
||||
# primary key spans definition_id and window.
|
||||
assert [(row[1], row[2], row[3], row[4], row[5]) for row in columns] == [
|
||||
("definition_id", "INTEGER", 1, None, 1),
|
||||
("window", "TEXT", 1, None, 2),
|
||||
("due_time", "TEXT", 0, None, 0),
|
||||
]
|
||||
finally:
|
||||
_run(database.close())
|
||||
|
||||
|
||||
def test_quest_definition_windows_foreign_keys_declared(tmp_path) -> None:
|
||||
database = _open_db(tmp_path / "windows-fks.db")
|
||||
try:
|
||||
_apply(database)
|
||||
fks = _run(
|
||||
database.fetch_all(
|
||||
"PRAGMA foreign_key_list(quest_definition_windows)"
|
||||
)
|
||||
)
|
||||
declared = {(row[2], row[3], row[4]) for row in fks}
|
||||
assert declared == {
|
||||
("quest_definitions", "definition_id", "id"),
|
||||
}
|
||||
finally:
|
||||
_run(database.close())
|
||||
|
||||
|
||||
def test_quest_definition_windows_valid_rows_round_trip(tmp_path) -> None:
|
||||
database = _open_db(tmp_path / "windows-roundtrip.db")
|
||||
try:
|
||||
_apply(database)
|
||||
_run(
|
||||
database.execute(
|
||||
"INSERT INTO children (display_name, created_at) VALUES (?, ?)",
|
||||
("Ada", "2026-09-13T00:00:00+00:00"),
|
||||
)
|
||||
)
|
||||
_insert_rule(database)
|
||||
definition_id = _insert_definition(database, assignees=None)
|
||||
for window, due in (
|
||||
("morning", "09:00"),
|
||||
("afternoon", None),
|
||||
("evening", "19:30"),
|
||||
):
|
||||
_run(
|
||||
database.execute(
|
||||
"INSERT INTO quest_definition_windows "
|
||||
"(definition_id, window, due_time) VALUES (?, ?, ?)",
|
||||
(definition_id, window, due),
|
||||
)
|
||||
)
|
||||
rows = _run(
|
||||
database.fetch_all(
|
||||
"SELECT window, due_time FROM quest_definition_windows "
|
||||
"WHERE definition_id = ? ORDER BY window",
|
||||
(definition_id,),
|
||||
)
|
||||
)
|
||||
assert rows == [
|
||||
("afternoon", None),
|
||||
("evening", "19:30"),
|
||||
("morning", "09:00"),
|
||||
]
|
||||
finally:
|
||||
_run(database.close())
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"window", ["Midnight", "MORNING", "noon", "", "evenings"]
|
||||
)
|
||||
def test_quest_definition_windows_window_check_enforced(
|
||||
tmp_path, window
|
||||
) -> None:
|
||||
database = _open_db(tmp_path / "windows-check.db")
|
||||
try:
|
||||
_apply(database)
|
||||
_insert_rule(database)
|
||||
definition_id = _insert_definition(database, assignees=None)
|
||||
with pytest.raises(sqlite3.IntegrityError, match="CHECK"):
|
||||
_run(
|
||||
database.execute(
|
||||
"INSERT INTO quest_definition_windows "
|
||||
"(definition_id, window, due_time) VALUES (?, ?, NULL)",
|
||||
(definition_id, window),
|
||||
)
|
||||
)
|
||||
finally:
|
||||
_run(database.close())
|
||||
|
||||
|
||||
def test_quest_definition_windows_duplicate_window_fails(tmp_path) -> None:
|
||||
database = _open_db(tmp_path / "windows-duplicate.db")
|
||||
try:
|
||||
_apply(database)
|
||||
_insert_rule(database)
|
||||
definition_id = _insert_definition(database, assignees=None)
|
||||
_run(
|
||||
database.execute(
|
||||
"INSERT INTO quest_definition_windows "
|
||||
"(definition_id, window, due_time) "
|
||||
"VALUES (?, 'morning', NULL)",
|
||||
(definition_id,),
|
||||
)
|
||||
)
|
||||
with pytest.raises(sqlite3.IntegrityError):
|
||||
_run(
|
||||
database.execute(
|
||||
"INSERT INTO quest_definition_windows "
|
||||
"(definition_id, window, due_time) "
|
||||
"VALUES (?, 'morning', '09:00')",
|
||||
(definition_id,),
|
||||
)
|
||||
)
|
||||
finally:
|
||||
_run(database.close())
|
||||
|
||||
|
||||
def test_quest_definition_windows_unknown_definition_fails(tmp_path) -> None:
|
||||
database = _open_db(tmp_path / "windows-bad-definition.db")
|
||||
try:
|
||||
_apply(database)
|
||||
with pytest.raises(sqlite3.IntegrityError, match="FOREIGN KEY"):
|
||||
_run(
|
||||
database.execute(
|
||||
"INSERT INTO quest_definition_windows "
|
||||
"(definition_id, window, due_time) "
|
||||
"VALUES (999, 'morning', NULL)"
|
||||
)
|
||||
)
|
||||
finally:
|
||||
_run(database.close())
|
||||
|
||||
|
||||
def test_quest_definition_assignees_multiple_children_allowed(
|
||||
tmp_path,
|
||||
) -> None:
|
||||
|
||||
Reference in New Issue
Block a user