Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
98e89f8b29 | ||
|
|
e92c79b211 | ||
|
|
58b11b59cf | ||
|
|
9d21f29eea | ||
|
|
fec607f039 | ||
|
|
a18c0060e4 | ||
|
|
20d7efb04b | ||
|
|
6551a87bd6 | ||
|
|
221ab2c20b | ||
|
|
d9326deeb5 | ||
|
|
e15648232f | ||
|
|
ee994a3938 | ||
|
|
7ae4018e59 |
@@ -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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -718,3 +718,97 @@ def test_preview_always_absent_child_raises_not_loops() -> None:
|
||||
)
|
||||
with pytest.raises(ValueError, match="not present on any"):
|
||||
engine.next_present_dates(1, "2026-01-05", 2)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# The week-parity regression guard (Feature 05, D-004)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("year", "length", "reason"),
|
||||
[
|
||||
(2020, 366, "leap year, 53 ISO weeks"),
|
||||
(2015, 365, "365 days, 53 ISO weeks"),
|
||||
],
|
||||
)
|
||||
def test_custody_rotation_walks_full_years_without_inversion(
|
||||
year, length, reason
|
||||
) -> None:
|
||||
"""REGRESSION GUARD for the week-parity bug (D-004).
|
||||
|
||||
Walks a two-week alternating rotation day by day across a FULL
|
||||
{length}-day year ({year} — {reason}) plus the following January,
|
||||
asserting the present/absent sequence never breaks or inverts at
|
||||
any point, including January 1st. ISO week parity silently flips
|
||||
at the ISO-week rollover (the Monday on or after January 4th —
|
||||
January 4th itself in both of these years, which end ISO week 53)
|
||||
and would invert every custody schedule there; anchor-date
|
||||
arithmetic must not.
|
||||
""".format(length=length, year=year, reason=reason)
|
||||
if year == 2020:
|
||||
assert datetime.date(2020, 12, 31).isocalendar()[1] == 53
|
||||
assert (datetime.date(2021, 1, 1) - datetime.date(2020, 1, 1)).days == 366
|
||||
if year == 2015:
|
||||
assert datetime.date(2015, 12, 31).isocalendar()[1] == 53
|
||||
assert (datetime.date(2016, 1, 1) - datetime.date(2015, 1, 1)).days == 365
|
||||
|
||||
# The first Monday of January (2020: Jan 6; 2015: Jan 5) — an
|
||||
# on-week anchor, whatever the year.
|
||||
anchor = datetime.date(year, 1, 1) + datetime.timedelta(
|
||||
days=(7 - datetime.date(year, 1, 1).weekday()) % 7
|
||||
)
|
||||
assert anchor.weekday() == 0
|
||||
rotation = PresenceSchedule(
|
||||
1, 2, anchor, {0: {0, 1, 2, 3, 4, 5, 6}, 1: set()}
|
||||
)
|
||||
engine = PresenceEngine({1: rotation})
|
||||
|
||||
# Walk EVERY day of the year. The expectation is derived from the
|
||||
# anchor's 7-day BLOCKS, not from the engine: each block of seven
|
||||
# days starting at the anchor is uniformly present (even block
|
||||
# index) or uniformly absent (odd block index), and consecutive
|
||||
# blocks alternate. A parity-based implementation resets on
|
||||
# January 1st and breaks either the uniformity of a block or the
|
||||
# alternation between them — most visibly in the block spanning
|
||||
# December 31st -> January 1st of a 53-ISO-week year.
|
||||
# Walk the FULL year AND the first two weeks of the following
|
||||
# January: the block spanning December 31st -> January 1st must
|
||||
# stay uniform ACROSS the boundary, so an implementation that
|
||||
# resets when the target year differs from the anchor's fails
|
||||
# here (the post-boundary days of its final block disagree).
|
||||
first_day = datetime.date(year, 1, 1)
|
||||
last_day = datetime.date(year + 1, 1, 14)
|
||||
days_seen = 0
|
||||
day = first_day
|
||||
while day <= last_day:
|
||||
days_since_anchor = (day - anchor).days
|
||||
block = days_since_anchor // 7 # which 7-day block of the cycle
|
||||
block_start = anchor + datetime.timedelta(days=block * 7)
|
||||
# Uniformity within the block: every day of this 7-day block
|
||||
# must answer exactly like the block's first day.
|
||||
expected = ((block % 2) == 0)
|
||||
assert engine.is_present(1, day) is expected, (
|
||||
f"{day} (block {block}, day offset "
|
||||
f"{(day - block_start).days}): present="
|
||||
f"{engine.is_present(1, day)}, expected {expected}"
|
||||
)
|
||||
# The block's OTHER days must agree — including the days on
|
||||
# the far side of January 1st.
|
||||
for offset in range(7):
|
||||
block_day = block_start + datetime.timedelta(days=offset)
|
||||
if first_day <= block_day <= last_day:
|
||||
assert engine.is_present(1, block_day) is expected, (
|
||||
f"{block_day} disagrees with its block {block}"
|
||||
)
|
||||
days_seen += 1
|
||||
day += datetime.timedelta(days=1)
|
||||
# The walked year itself contributed exactly its full length.
|
||||
year_days = sum(
|
||||
1
|
||||
for offset in range((last_day - first_day).days + 1)
|
||||
if (first_day + datetime.timedelta(days=offset)).year == year
|
||||
)
|
||||
assert year_days == length, (
|
||||
f"walked {year_days} days of {year}, expected {length}"
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user