Compare commits
2
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 ChildRecord, _child_from_row, _connection_lock
|
||||||
from .dao_children import _CHILD_COLUMNS
|
from .dao_children import _CHILD_COLUMNS
|
||||||
from .db import NestQuestDatabase
|
from .db import NestQuestDatabase
|
||||||
|
from .recurrence import RuleType, RuleValidationError, ScheduleRule
|
||||||
|
|
||||||
#: Sentinel distinguishing "argument omitted" from "explicit SQL NULL"
|
#: Sentinel distinguishing "argument omitted" from "explicit SQL NULL"
|
||||||
#: in update methods: passing ``None`` must mean clearing a nullable
|
#: in update methods: passing ``None`` must mean clearing a nullable
|
||||||
@@ -119,6 +120,26 @@ class ScheduleRuleRecord:
|
|||||||
end_date: str | None
|
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)
|
@dataclass(frozen=True)
|
||||||
class QuestDefinitionRecord:
|
class QuestDefinitionRecord:
|
||||||
"""One row of ``quest_definitions`` (without its assignees)."""
|
"""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:
|
class ScheduleRulesDao:
|
||||||
"""Typed async access to the ``schedule_rules`` table."""
|
"""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_children import ChildrenDao
|
||||||
from custom_components.nestquest.dao_rules import (
|
from custom_components.nestquest.dao_rules import (
|
||||||
ScheduleRuleRecord,
|
ScheduleRuleRecord,
|
||||||
|
ScheduleRuleStorage,
|
||||||
ScheduleRulesDao,
|
ScheduleRulesDao,
|
||||||
QuestDefinitionRecord,
|
QuestDefinitionRecord,
|
||||||
QuestDefinitionWindowRecord,
|
QuestDefinitionWindowRecord,
|
||||||
QuestDefinitionsDao,
|
QuestDefinitionsDao,
|
||||||
|
schedule_rule_from_storage,
|
||||||
|
schedule_rule_to_storage,
|
||||||
)
|
)
|
||||||
from custom_components.nestquest.db import NestQuestDatabase
|
from custom_components.nestquest.db import NestQuestDatabase
|
||||||
from custom_components.nestquest.migrations import apply_migrations
|
from custom_components.nestquest.migrations import apply_migrations
|
||||||
|
from custom_components.nestquest.recurrence import (
|
||||||
|
RuleType,
|
||||||
|
RuleValidationError,
|
||||||
|
ScheduleRule,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _run(coro):
|
def _run(coro):
|
||||||
@@ -423,6 +431,249 @@ def test_rule_delete_concurrent_with_definition_create_is_safe(
|
|||||||
assert create_won is False
|
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
|
# quest_definitions: create / get
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
Reference in New Issue
Block a user