diff --git a/custom_components/nestquest/const.py b/custom_components/nestquest/const.py index fc45d7c..b0b4daa 100644 --- a/custom_components/nestquest/const.py +++ b/custom_components/nestquest/const.py @@ -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"), +} diff --git a/custom_components/nestquest/dao_rules.py b/custom_components/nestquest/dao_rules.py index 291febb..d5090ee 100644 --- a/custom_components/nestquest/dao_rules.py +++ b/custom_components/nestquest/dao_rules.py @@ -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. diff --git a/custom_components/nestquest/migrations.py b/custom_components/nestquest/migrations.py index 0b000ef..29d5d20 100644 --- a/custom_components/nestquest/migrations.py +++ b/custom_components/nestquest/migrations.py @@ -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), ] diff --git a/custom_components/nestquest/schema.py b/custom_components/nestquest/schema.py index 63d2674..7a73340 100644 --- a/custom_components/nestquest/schema.py +++ b/custom_components/nestquest/schema.py @@ -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, diff --git a/tests/test_const.py b/tests/test_const.py index aea1bd9..85ff105 100644 --- a/tests/test_const.py +++ b/tests/test_const.py @@ -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 diff --git a/tests/test_dao_rules.py b/tests/test_dao_rules.py index e73af68..0ae00b6 100644 --- a/tests/test_dao_rules.py +++ b/tests/test_dao_rules.py @@ -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, ( diff --git a/tests/test_migrations.py b/tests/test_migrations.py index 155d795..ce81da3 100644 --- a/tests/test_migrations.py +++ b/tests/test_migrations.py @@ -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 # --------------------------------------------------------------------------- diff --git a/tests/test_schema.py b/tests/test_schema.py index 58e65de..bd18996 100644 --- a/tests/test_schema.py +++ b/tests/test_schema.py @@ -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: