Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
98e89f8b29 | ||
|
|
e92c79b211 |
@@ -40,6 +40,7 @@ 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
|
||||
from .recurrence import RuleType, RuleValidationError, ScheduleRule
|
||||
|
||||
#: Sentinel distinguishing "argument omitted" from "explicit SQL NULL"
|
||||
#: in update methods: passing ``None`` must mean clearing a nullable
|
||||
@@ -119,6 +120,26 @@ class ScheduleRuleRecord:
|
||||
end_date: str | None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ScheduleRuleStorage:
|
||||
"""The eight value columns a ScheduleRule maps to (no id column).
|
||||
|
||||
``id`` is assigned by the database on insert, so the model<->storage
|
||||
mapping concerns only the value columns. ``nth_weekday_weekday`` is
|
||||
deliberately absent here: for MONTHLY_WEEKDAY its value is folded
|
||||
into ``weekday_set`` as that column's single CSV element.
|
||||
"""
|
||||
|
||||
rule_type: str
|
||||
interval: int
|
||||
weekday_set: str | None
|
||||
day_of_month: int | None
|
||||
nth_weekday: int | None
|
||||
month: int | None
|
||||
start_date: str
|
||||
end_date: str | None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class QuestDefinitionRecord:
|
||||
"""One row of ``quest_definitions`` (without its assignees)."""
|
||||
@@ -188,6 +209,197 @@ def _window_from_row(row: tuple) -> QuestDefinitionWindowRecord:
|
||||
)
|
||||
|
||||
|
||||
#: Storage columns that are forbidden for each rule type. A populated
|
||||
#: forbidden column means the row's shape is invalid and must raise on
|
||||
#: read, never be silently dropped. ``weekday_set`` is absent for
|
||||
#: DAILY/MONTHLY_DAY/YEARLY; MONTHLY_WEEKDAY is the only shape allowed a
|
||||
#: non-NULL ``nth_weekday``; YEARLY is the only shape allowed ``month``.
|
||||
_FORBIDDEN_STORAGE_FIELDS: dict[str, tuple[RuleType, ...]] = {
|
||||
"weekday_set": (RuleType.DAILY, RuleType.MONTHLY_DAY, RuleType.YEARLY),
|
||||
"day_of_month": (
|
||||
RuleType.DAILY,
|
||||
RuleType.WEEKLY,
|
||||
RuleType.CUSTOM_DAYS,
|
||||
RuleType.MONTHLY_WEEKDAY,
|
||||
),
|
||||
"nth_weekday": (
|
||||
RuleType.DAILY,
|
||||
RuleType.WEEKLY,
|
||||
RuleType.CUSTOM_DAYS,
|
||||
RuleType.MONTHLY_DAY,
|
||||
RuleType.YEARLY,
|
||||
),
|
||||
"month": (
|
||||
RuleType.DAILY,
|
||||
RuleType.WEEKLY,
|
||||
RuleType.CUSTOM_DAYS,
|
||||
RuleType.MONTHLY_DAY,
|
||||
RuleType.MONTHLY_WEEKDAY,
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def schedule_rule_to_storage(rule: ScheduleRule) -> ScheduleRuleStorage:
|
||||
"""Map a validated ScheduleRule onto its storage columns.
|
||||
|
||||
``rule_type`` stores as ``RuleType.storage_value`` (MONTHLY_DAY and
|
||||
MONTHLY_WEEKDAY both store ``'monthly'``); ``weekday_set`` stores as
|
||||
a sorted CSV list. MONTHLY_WEEKDAY folds its model-only
|
||||
``nth_weekday_weekday`` into ``weekday_set`` as that list's single
|
||||
element; every other type leaves the column as its own set
|
||||
(WEEKLY/CUSTOM_DAYS) or NULL.
|
||||
"""
|
||||
shape = rule.rule_type
|
||||
if shape is RuleType.MONTHLY_WEEKDAY:
|
||||
weekday_set = str(rule.nth_weekday_weekday)
|
||||
elif shape is RuleType.WEEKLY or shape is RuleType.CUSTOM_DAYS:
|
||||
weekday_set = ",".join(str(w) for w in sorted(rule.weekday_set))
|
||||
else:
|
||||
weekday_set = None
|
||||
return ScheduleRuleStorage(
|
||||
rule_type=shape.storage_value,
|
||||
interval=rule.interval,
|
||||
weekday_set=weekday_set,
|
||||
day_of_month=rule.day_of_month,
|
||||
nth_weekday=rule.nth_weekday,
|
||||
month=rule.month,
|
||||
start_date=rule.start_date,
|
||||
end_date=rule.end_date,
|
||||
)
|
||||
|
||||
|
||||
def schedule_rule_from_storage(storage: ScheduleRuleStorage) -> ScheduleRule:
|
||||
"""Reconstruct a ScheduleRule from storage columns.
|
||||
|
||||
Disambiguates MONTHLY_DAY from MONTHLY_WEEKDAY by which field is
|
||||
populated, and raises RuleValidationError rather than silently
|
||||
mis-decoding an ambiguous or invalid shape.
|
||||
"""
|
||||
rule_type = storage.rule_type
|
||||
if rule_type == "daily":
|
||||
shape = RuleType.DAILY
|
||||
elif rule_type == "weekly":
|
||||
shape = RuleType.WEEKLY
|
||||
elif rule_type == "monthly":
|
||||
shape = _disambiguate_monthly(storage)
|
||||
elif rule_type == "yearly":
|
||||
shape = RuleType.YEARLY
|
||||
elif rule_type == "custom":
|
||||
shape = RuleType.CUSTOM_DAYS
|
||||
else:
|
||||
raise RuleValidationError(
|
||||
f"unknown storage rule_type {rule_type!r}"
|
||||
)
|
||||
|
||||
_reject_forbidden_storage_fields(storage, shape)
|
||||
|
||||
weekday_set = None
|
||||
nth_weekday_weekday = None
|
||||
if shape is RuleType.WEEKLY or shape is RuleType.CUSTOM_DAYS:
|
||||
weekday_set = _weekdays_from_csv(storage.weekday_set)
|
||||
elif shape is RuleType.MONTHLY_WEEKDAY:
|
||||
nth_weekday_weekday = _single_weekday_from_csv(storage.weekday_set)
|
||||
|
||||
return ScheduleRule(
|
||||
rule_type=shape,
|
||||
interval=storage.interval,
|
||||
weekday_set=weekday_set,
|
||||
day_of_month=storage.day_of_month,
|
||||
nth_weekday=storage.nth_weekday,
|
||||
nth_weekday_weekday=nth_weekday_weekday,
|
||||
month=storage.month,
|
||||
start_date=storage.start_date,
|
||||
end_date=storage.end_date,
|
||||
)
|
||||
|
||||
|
||||
def _reject_forbidden_storage_fields(
|
||||
storage: ScheduleRuleStorage, shape: RuleType
|
||||
) -> None:
|
||||
"""Raise unless every populated column is permitted for ``shape``.
|
||||
|
||||
The model constructor rejects a forbidden ``day_of_month``,
|
||||
``nth_weekday``, or ``month``, but ``weekday_set`` never reaches it
|
||||
(it is folded for MONTHLY_WEEKDAY and dropped otherwise), so a daily
|
||||
row carrying ``weekday_set="0"`` would otherwise decode as a valid
|
||||
daily rule and silently lose the stored data. This validates every
|
||||
shape column explicitly so a populated-forbidden column raises
|
||||
instead of being discarded.
|
||||
"""
|
||||
for field, forbidden_shapes in _FORBIDDEN_STORAGE_FIELDS.items():
|
||||
value = getattr(storage, field)
|
||||
if value is not None and shape in forbidden_shapes:
|
||||
raise RuleValidationError(
|
||||
f"{shape.value} storage rows must not set {field}, "
|
||||
f"got {value!r}"
|
||||
)
|
||||
|
||||
|
||||
def _disambiguate_monthly(storage: ScheduleRuleStorage) -> RuleType:
|
||||
"""Resolve the shared 'monthly' storage value to a model type.
|
||||
|
||||
MONTHLY_DAY names ``day_of_month`` only; MONTHLY_WEEKDAY names
|
||||
``nth_weekday`` only. Neither, or both, is ambiguous and raises.
|
||||
"""
|
||||
day_of_month = storage.day_of_month
|
||||
nth_weekday = storage.nth_weekday
|
||||
if day_of_month is not None and nth_weekday is None:
|
||||
return RuleType.MONTHLY_DAY
|
||||
if nth_weekday is not None and day_of_month is None:
|
||||
return RuleType.MONTHLY_WEEKDAY
|
||||
raise RuleValidationError(
|
||||
"storage rule_type 'monthly' is ambiguous: must name exactly one "
|
||||
"of day_of_month or nth_weekday, got "
|
||||
f"day_of_month={day_of_month!r}, nth_weekday={nth_weekday!r}"
|
||||
)
|
||||
|
||||
|
||||
def _weekdays_from_csv(value: str | None) -> frozenset[int]:
|
||||
"""Parse a weekday CSV column into a frozenset of ints.
|
||||
|
||||
``None`` and ``""`` both yield an empty frozenset (the caller decides
|
||||
whether empty is legal); a non-integer token raises
|
||||
RuleValidationError rather than leaking a raw ValueError.
|
||||
"""
|
||||
if value is None or value == "":
|
||||
return frozenset()
|
||||
try:
|
||||
entries = [int(token) for token in value.split(",")]
|
||||
except ValueError:
|
||||
raise RuleValidationError(
|
||||
f"weekday_set must be a CSV of integers, got {value!r}"
|
||||
) from None
|
||||
return frozenset(entries)
|
||||
|
||||
|
||||
def _single_weekday_from_csv(value: str | None) -> int:
|
||||
"""The one weekday a MONTHLY_WEEKDAY stores in weekday_set.
|
||||
|
||||
Parses the CSV into tokens FIRST and requires exactly one token,
|
||||
so a duplicate pair like ``"1,1"`` is rejected rather than silently
|
||||
canonicalized to a single weekday on re-storage. An empty or
|
||||
multi-element column raises RuleValidationError.
|
||||
"""
|
||||
if value is None or value == "":
|
||||
raise RuleValidationError(
|
||||
"MONTHLY_WEEKDAY storage weekday_set must hold exactly one "
|
||||
f"weekday, got {value!r}"
|
||||
)
|
||||
tokens = value.split(",")
|
||||
if len(tokens) != 1:
|
||||
raise RuleValidationError(
|
||||
"MONTHLY_WEEKDAY storage weekday_set must hold exactly one "
|
||||
f"weekday, got {value!r}"
|
||||
)
|
||||
try:
|
||||
return int(tokens[0])
|
||||
except ValueError:
|
||||
raise RuleValidationError(
|
||||
"MONTHLY_WEEKDAY storage weekday_set must be an integer, "
|
||||
f"got {value!r}"
|
||||
) from None
|
||||
|
||||
|
||||
class ScheduleRulesDao:
|
||||
"""Typed async access to the ``schedule_rules`` table."""
|
||||
|
||||
|
||||
@@ -9,13 +9,21 @@ import pytest
|
||||
from custom_components.nestquest.dao_children import ChildrenDao
|
||||
from custom_components.nestquest.dao_rules import (
|
||||
ScheduleRuleRecord,
|
||||
ScheduleRuleStorage,
|
||||
ScheduleRulesDao,
|
||||
QuestDefinitionRecord,
|
||||
QuestDefinitionWindowRecord,
|
||||
QuestDefinitionsDao,
|
||||
schedule_rule_from_storage,
|
||||
schedule_rule_to_storage,
|
||||
)
|
||||
from custom_components.nestquest.db import NestQuestDatabase
|
||||
from custom_components.nestquest.migrations import apply_migrations
|
||||
from custom_components.nestquest.recurrence import (
|
||||
RuleType,
|
||||
RuleValidationError,
|
||||
ScheduleRule,
|
||||
)
|
||||
|
||||
|
||||
def _run(coro):
|
||||
@@ -423,6 +431,249 @@ def test_rule_delete_concurrent_with_definition_create_is_safe(
|
||||
assert create_won is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ScheduleRule <-> storage mapping
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
_RULE_STORAGE_CASES = [
|
||||
(
|
||||
ScheduleRule(rule_type=RuleType.DAILY, start_date="2026-09-01"),
|
||||
ScheduleRuleStorage(
|
||||
"daily", 1, None, None, None, None, "2026-09-01", None
|
||||
),
|
||||
),
|
||||
(
|
||||
ScheduleRule(
|
||||
rule_type=RuleType.DAILY,
|
||||
interval=3,
|
||||
start_date="2026-09-01",
|
||||
end_date="2026-12-31",
|
||||
),
|
||||
ScheduleRuleStorage(
|
||||
"daily", 3, None, None, None, None, "2026-09-01", "2026-12-31"
|
||||
),
|
||||
),
|
||||
(
|
||||
ScheduleRule(
|
||||
rule_type=RuleType.WEEKLY,
|
||||
weekday_set={2, 0, 4},
|
||||
start_date="2026-09-01",
|
||||
),
|
||||
ScheduleRuleStorage(
|
||||
"weekly", 1, "0,2,4", None, None, None, "2026-09-01", None
|
||||
),
|
||||
),
|
||||
(
|
||||
ScheduleRule(
|
||||
rule_type=RuleType.CUSTOM_DAYS,
|
||||
weekday_set={1, 3, 5},
|
||||
interval=2,
|
||||
start_date="2026-09-01",
|
||||
),
|
||||
ScheduleRuleStorage(
|
||||
"custom", 2, "1,3,5", None, None, None, "2026-09-01", None
|
||||
),
|
||||
),
|
||||
(
|
||||
ScheduleRule(
|
||||
rule_type=RuleType.MONTHLY_DAY,
|
||||
day_of_month=15,
|
||||
start_date="2026-09-01",
|
||||
),
|
||||
ScheduleRuleStorage(
|
||||
"monthly", 1, None, 15, None, None, "2026-09-01", None
|
||||
),
|
||||
),
|
||||
(
|
||||
ScheduleRule(
|
||||
rule_type=RuleType.MONTHLY_DAY,
|
||||
day_of_month=31,
|
||||
interval=3,
|
||||
start_date="2026-09-01",
|
||||
),
|
||||
ScheduleRuleStorage(
|
||||
"monthly", 3, None, 31, None, None, "2026-09-01", None
|
||||
),
|
||||
),
|
||||
(
|
||||
ScheduleRule(
|
||||
rule_type=RuleType.MONTHLY_WEEKDAY,
|
||||
nth_weekday=2,
|
||||
nth_weekday_weekday=1,
|
||||
start_date="2026-09-01",
|
||||
),
|
||||
ScheduleRuleStorage(
|
||||
"monthly", 1, "1", None, 2, None, "2026-09-01", None
|
||||
),
|
||||
),
|
||||
(
|
||||
ScheduleRule(
|
||||
rule_type=RuleType.MONTHLY_WEEKDAY,
|
||||
nth_weekday=-1,
|
||||
nth_weekday_weekday=4,
|
||||
interval=2,
|
||||
start_date="2026-09-01",
|
||||
),
|
||||
ScheduleRuleStorage(
|
||||
"monthly", 2, "4", None, -1, None, "2026-09-01", None
|
||||
),
|
||||
),
|
||||
(
|
||||
ScheduleRule(
|
||||
rule_type=RuleType.YEARLY, month=6, start_date="2026-09-01"
|
||||
),
|
||||
ScheduleRuleStorage(
|
||||
"yearly", 1, None, None, None, 6, "2026-09-01", None
|
||||
),
|
||||
),
|
||||
(
|
||||
ScheduleRule(
|
||||
rule_type=RuleType.YEARLY,
|
||||
month=2,
|
||||
day_of_month=29,
|
||||
start_date="2026-09-01",
|
||||
),
|
||||
ScheduleRuleStorage(
|
||||
"yearly", 1, None, 29, None, 2, "2026-09-01", None
|
||||
),
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("rule, storage", _RULE_STORAGE_CASES)
|
||||
def test_schedule_rule_to_storage_maps_each_type(rule, storage) -> None:
|
||||
assert schedule_rule_to_storage(rule) == storage
|
||||
|
||||
|
||||
@pytest.mark.parametrize("rule, storage", _RULE_STORAGE_CASES)
|
||||
def test_schedule_rule_from_storage_rebuilds_each_type(rule, storage) -> None:
|
||||
assert schedule_rule_from_storage(storage) == rule
|
||||
|
||||
|
||||
@pytest.mark.parametrize("rule, storage", _RULE_STORAGE_CASES)
|
||||
def test_schedule_rule_round_trips_losslessly_both_directions(
|
||||
rule, storage
|
||||
) -> None:
|
||||
assert schedule_rule_from_storage(schedule_rule_to_storage(rule)) == rule
|
||||
assert schedule_rule_to_storage(schedule_rule_from_storage(storage)) == storage
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"storage",
|
||||
[
|
||||
# monthly with neither day_of_month nor nth_weekday
|
||||
ScheduleRuleStorage(
|
||||
"monthly", 1, None, None, None, None, "2026-09-01", None
|
||||
),
|
||||
# monthly with both day_of_month and nth_weekday
|
||||
ScheduleRuleStorage(
|
||||
"monthly", 1, None, 15, 2, None, "2026-09-01", None
|
||||
),
|
||||
# MONTHLY_WEEKDAY whose weekday_set is None
|
||||
ScheduleRuleStorage(
|
||||
"monthly", 1, None, None, 2, None, "2026-09-01", None
|
||||
),
|
||||
# MONTHLY_WEEKDAY whose weekday_set is empty
|
||||
ScheduleRuleStorage("monthly", 1, "", None, 2, None, "2026-09-01", None),
|
||||
# MONTHLY_WEEKDAY whose weekday_set is multi-element
|
||||
ScheduleRuleStorage(
|
||||
"monthly", 1, "1,3", None, 2, None, "2026-09-01", None
|
||||
),
|
||||
# unknown storage rule_type
|
||||
ScheduleRuleStorage(
|
||||
"sometimes", 1, None, None, None, None, "2026-09-01", None
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_schedule_rule_from_storage_rejects_ambiguous_or_invalid(
|
||||
storage,
|
||||
) -> None:
|
||||
with pytest.raises(RuleValidationError):
|
||||
schedule_rule_from_storage(storage)
|
||||
|
||||
|
||||
def test_schedule_rule_from_storage_rejects_malformed_weekday_csv() -> None:
|
||||
with pytest.raises(RuleValidationError):
|
||||
schedule_rule_from_storage(
|
||||
ScheduleRuleStorage(
|
||||
"weekly", 1, "0,2,x", None, None, None, "2026-09-01", None
|
||||
)
|
||||
)
|
||||
with pytest.raises(RuleValidationError):
|
||||
schedule_rule_from_storage(
|
||||
ScheduleRuleStorage(
|
||||
"weekly", 1, "7", None, None, None, "2026-09-01", None
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"storage",
|
||||
[
|
||||
# DAILY carrying shape columns it must not hold.
|
||||
ScheduleRuleStorage(
|
||||
"daily", 1, "0", None, None, None, "2026-09-01", None
|
||||
),
|
||||
ScheduleRuleStorage(
|
||||
"daily", 1, None, 15, None, None, "2026-09-01", None
|
||||
),
|
||||
ScheduleRuleStorage(
|
||||
"daily", 1, None, None, 2, None, "2026-09-01", None
|
||||
),
|
||||
ScheduleRuleStorage(
|
||||
"daily", 1, None, None, None, 6, "2026-09-01", None
|
||||
),
|
||||
# WEEKLY carrying shape columns it must not hold.
|
||||
ScheduleRuleStorage(
|
||||
"weekly", 1, "0,2", 15, None, None, "2026-09-01", None
|
||||
),
|
||||
ScheduleRuleStorage(
|
||||
"weekly", 1, "0,2", None, None, 6, "2026-09-01", None
|
||||
),
|
||||
# CUSTOM_DAYS carrying shape columns it must not hold.
|
||||
ScheduleRuleStorage(
|
||||
"custom", 1, "0,2", 15, None, None, "2026-09-01", None
|
||||
),
|
||||
ScheduleRuleStorage(
|
||||
"custom", 1, "0,2", None, None, 6, "2026-09-01", None
|
||||
),
|
||||
# MONTHLY_DAY carrying weekday_set (folded-only column) or month.
|
||||
ScheduleRuleStorage(
|
||||
"monthly", 1, "1", 15, None, None, "2026-09-01", None
|
||||
),
|
||||
ScheduleRuleStorage(
|
||||
"monthly", 1, None, 15, None, 6, "2026-09-01", None
|
||||
),
|
||||
# MONTHLY_WEEKDAY carrying a forbidden month.
|
||||
ScheduleRuleStorage(
|
||||
"monthly", 1, "1", None, 2, 6, "2026-09-01", None
|
||||
),
|
||||
# YEARLY carrying weekday_set or nth_weekday.
|
||||
ScheduleRuleStorage(
|
||||
"yearly", 1, "0", None, None, 6, "2026-09-01", None
|
||||
),
|
||||
ScheduleRuleStorage(
|
||||
"yearly", 1, None, None, 2, 6, "2026-09-01", None
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_schedule_rule_from_storage_rejects_forbidden_fields(storage) -> None:
|
||||
with pytest.raises(RuleValidationError):
|
||||
schedule_rule_from_storage(storage)
|
||||
|
||||
|
||||
def test_schedule_rule_from_storage_rejects_duplicate_weekday_token() -> None:
|
||||
"""A MONTHLY_WEEKDAY with a duplicate CSV token ("1,1") must raise,
|
||||
never silently canonicalize to a single weekday on re-storage."""
|
||||
with pytest.raises(RuleValidationError):
|
||||
schedule_rule_from_storage(
|
||||
ScheduleRuleStorage(
|
||||
"monthly", 1, "1,1", None, 2, None, "2026-09-01", None
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# quest_definitions: create / get
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user