Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6d48f433b7 | ||
|
|
540f59a353 | ||
|
|
77c4db7b3c | ||
|
|
5885d5e972 |
@@ -298,3 +298,80 @@ class PresenceOverride:
|
||||
def covers(self, date: datetime.date) -> bool:
|
||||
"""Return True when the override applies to ``date``."""
|
||||
return self.start_date <= date <= self.end_date
|
||||
|
||||
|
||||
def cycle_week_index(
|
||||
schedule: PresenceSchedule, target_date: object
|
||||
) -> int:
|
||||
"""Return the cycle week index ``target_date`` falls in.
|
||||
|
||||
Anchor-date arithmetic ONLY (D-004): ``((target_date -
|
||||
anchor_date).days // 7) % cycle_length_weeks``. Python's floor
|
||||
division makes dates BEFORE the anchor behave correctly — they
|
||||
walk the cycle backwards (day -1 is the last week of the cycle,
|
||||
day -8 wraps one further) instead of inverting, which is exactly
|
||||
what ISO week parity does wrong in 53-week years. The modulo of
|
||||
a negative in Python is non-negative, so the index is always a
|
||||
valid week index.
|
||||
"""
|
||||
target = _parse_date(target_date, "target_date")
|
||||
days = (target - schedule.anchor_date).days
|
||||
return (days // 7) % schedule.cycle_length_weeks
|
||||
|
||||
|
||||
class PresenceEngine:
|
||||
"""Answers "is this child at this house on this date?".
|
||||
|
||||
Pure and side-effect-free: built from an immutable snapshot of the
|
||||
children's schedules (``child_id -> PresenceSchedule``), it never
|
||||
touches the database — callers build a snapshot from the DAO layer
|
||||
and pass it in. A child with NO schedule entry is present every
|
||||
day (the third household child's case); a child WITH a schedule is
|
||||
present exactly when the date's weekday is in that schedule's
|
||||
pattern for the date's cycle week index (anchor-date arithmetic —
|
||||
:func:`cycle_week_index`, never ISO week parity).
|
||||
"""
|
||||
|
||||
def __init__(self, schedules: dict[int, PresenceSchedule]) -> None:
|
||||
if not isinstance(schedules, dict):
|
||||
raise ValueError(
|
||||
f"schedules must map child_id to PresenceSchedule, got "
|
||||
f"{schedules!r}"
|
||||
)
|
||||
for child_id, schedule in schedules.items():
|
||||
# Validate the KEY as a model child id too: True and 1.0
|
||||
# compare equal to 1, so {True: schedule(1)} would be
|
||||
# accepted and then behave differently on lookup.
|
||||
_validate_child_id(child_id)
|
||||
if not isinstance(schedule, PresenceSchedule):
|
||||
raise ValueError(
|
||||
f"schedules[{child_id!r}] must be a PresenceSchedule, "
|
||||
f"got {schedule!r}"
|
||||
)
|
||||
if schedule.child_id != child_id:
|
||||
raise ValueError(
|
||||
f"schedules is keyed {child_id!r} but holds the "
|
||||
f"schedule of child {schedule.child_id}"
|
||||
)
|
||||
# Read-only snapshot: reassignment is refused and no mutator
|
||||
# exists, so a built engine answers consistently for its
|
||||
# lifetime.
|
||||
object.__setattr__(self, "_schedules", MappingProxyType(dict(schedules)))
|
||||
|
||||
def is_present(self, child_id: object, date: object) -> bool:
|
||||
"""Return whether ``child_id`` is present on ``date``."""
|
||||
if isinstance(child_id, bool) or not isinstance(child_id, int):
|
||||
raise ValueError(f"child_id must be an integer, got {child_id!r}")
|
||||
target = _parse_date(date, "date")
|
||||
schedule = self._schedules.get(child_id)
|
||||
if schedule is None:
|
||||
# No schedule at all: present every day (household default).
|
||||
return True
|
||||
week = cycle_week_index(schedule, target)
|
||||
return target.weekday() in schedule.pattern[week]
|
||||
|
||||
def __setattr__(self, name: object, value: object) -> None:
|
||||
raise AttributeError(
|
||||
"PresenceEngine is an immutable snapshot; build a new engine "
|
||||
"instead of mutating this one"
|
||||
)
|
||||
|
||||
@@ -7,8 +7,10 @@ import pytest
|
||||
|
||||
from custom_components.nestquest.presence import (
|
||||
MAX_CYCLE_LENGTH_WEEKS,
|
||||
PresenceEngine,
|
||||
PresenceOverride,
|
||||
PresenceSchedule,
|
||||
cycle_week_index,
|
||||
)
|
||||
|
||||
|
||||
@@ -260,3 +262,217 @@ def test_schedule_pattern_is_immutable_after_construction() -> None:
|
||||
# The validated contents are still readable and encode correctly.
|
||||
assert schedule.pattern[0] == frozenset({0, 2})
|
||||
assert schedule.encode() == "0,2|1"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Cycle arithmetic: anchor-date only, never ISO week parity
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_cycle_week_index_follows_the_anchor_formula() -> None:
|
||||
schedule = PresenceSchedule(1, 2, ANCHOR, {0: {0}, 1: {1}})
|
||||
# The anchor day starts week 0; each whole 7 days steps the cycle.
|
||||
offsets = {0: 0, 1: 0, 6: 0, 7: 1, 8: 1, 13: 1, 14: 0, 15: 0, 22: 1}
|
||||
for offset, expected in offsets.items():
|
||||
assert (
|
||||
cycle_week_index(
|
||||
schedule, ANCHOR + datetime.timedelta(days=offset)
|
||||
)
|
||||
== expected
|
||||
), f"offset {offset}"
|
||||
|
||||
|
||||
def test_cycle_week_index_before_the_anchor_walks_backwards() -> None:
|
||||
"""Python floor division: day -1 is the LAST week of the cycle,
|
||||
wrapping backwards — never inverting."""
|
||||
schedule = PresenceSchedule(1, 2, ANCHOR, {0: {0}, 1: {1}})
|
||||
assert cycle_week_index(schedule, ANCHOR - datetime.timedelta(days=1)) == 1
|
||||
assert cycle_week_index(schedule, ANCHOR - datetime.timedelta(days=7)) == 1
|
||||
assert cycle_week_index(schedule, ANCHOR - datetime.timedelta(days=8)) == 0
|
||||
|
||||
|
||||
def test_cycle_week_index_three_week_pattern() -> None:
|
||||
schedule = PresenceSchedule(
|
||||
1, 3, ANCHOR, {0: {0}, 1: {1}, 2: {2}}
|
||||
)
|
||||
offsets = [0, 7, 14, 21, 28]
|
||||
assert [cycle_week_index(schedule, ANCHOR + datetime.timedelta(days=o)) for o in offsets] == [
|
||||
0, 1, 2, 0, 1
|
||||
]
|
||||
|
||||
|
||||
def test_cycle_week_index_stable_across_three_year_boundaries() -> None:
|
||||
"""The regression this exists for: walking day by day through a
|
||||
December 31st -> January 1st boundary, the cycle index steps by 0
|
||||
or +1 (mod cycle length) and NEVER resets or inverts — including
|
||||
2020, a 53-ISO-week year, where ISO week parity flips and would
|
||||
invert a two-week custody schedule on January 1st."""
|
||||
schedule = PresenceSchedule(
|
||||
1, 2, datetime.date(2019, 1, 6), {0: {0, 2, 4}, 1: {1, 3}}
|
||||
)
|
||||
# Independently derived expectations (anchor 2019-01-06, index =
|
||||
# floor(days-since-anchor / 7) % 2, hand-verified) — hardcoded so
|
||||
# even an implementation that resets or inverts at January 1st
|
||||
# cannot pass by coincidence:
|
||||
expected = {
|
||||
# 2019 -> 2020 boundary
|
||||
datetime.date(2019, 12, 26): 0,
|
||||
datetime.date(2019, 12, 27): 0,
|
||||
datetime.date(2019, 12, 28): 0,
|
||||
datetime.date(2019, 12, 29): 1,
|
||||
datetime.date(2019, 12, 30): 1,
|
||||
datetime.date(2019, 12, 31): 1,
|
||||
datetime.date(2020, 1, 1): 1,
|
||||
datetime.date(2020, 1, 2): 1,
|
||||
datetime.date(2020, 1, 3): 1,
|
||||
datetime.date(2020, 1, 4): 1,
|
||||
datetime.date(2020, 1, 5): 0,
|
||||
datetime.date(2020, 1, 6): 0,
|
||||
datetime.date(2020, 1, 7): 0,
|
||||
# 2020 (53 ISO weeks) -> 2021 boundary
|
||||
datetime.date(2020, 12, 27): 1,
|
||||
datetime.date(2020, 12, 28): 1,
|
||||
datetime.date(2020, 12, 29): 1,
|
||||
datetime.date(2020, 12, 30): 1,
|
||||
datetime.date(2020, 12, 31): 1,
|
||||
datetime.date(2021, 1, 1): 1,
|
||||
datetime.date(2021, 1, 2): 1,
|
||||
datetime.date(2021, 1, 3): 0,
|
||||
datetime.date(2021, 1, 4): 0,
|
||||
datetime.date(2021, 1, 5): 0,
|
||||
datetime.date(2021, 1, 6): 0,
|
||||
datetime.date(2021, 1, 7): 0,
|
||||
# 2021 -> 2022 boundary
|
||||
datetime.date(2021, 12, 27): 1,
|
||||
datetime.date(2021, 12, 28): 1,
|
||||
datetime.date(2021, 12, 29): 1,
|
||||
datetime.date(2021, 12, 30): 1,
|
||||
datetime.date(2021, 12, 31): 1,
|
||||
datetime.date(2022, 1, 1): 1,
|
||||
datetime.date(2022, 1, 2): 0,
|
||||
datetime.date(2022, 1, 3): 0,
|
||||
datetime.date(2022, 1, 4): 0,
|
||||
datetime.date(2022, 1, 5): 0,
|
||||
datetime.date(2022, 1, 6): 0,
|
||||
datetime.date(2022, 1, 7): 0,
|
||||
}
|
||||
for day, wanted in expected.items():
|
||||
got = cycle_week_index(schedule, day)
|
||||
assert got == wanted, f"{day}: expected week {wanted}, got {got}"
|
||||
# And 2020 really is the 53-ISO-week year this guards against.
|
||||
assert datetime.date(2020, 12, 31).isocalendar()[1] == 53
|
||||
|
||||
|
||||
def test_module_uses_no_iso_week_functions() -> None:
|
||||
"""D-004 guardrail: cycle math never reads ISO week numbers."""
|
||||
from pathlib import Path
|
||||
|
||||
source = Path(
|
||||
__import__("custom_components.nestquest.presence", fromlist=["__file__"]).__file__
|
||||
).read_text()
|
||||
assert "isocalendar" not in source
|
||||
assert "isoweek" not in source
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# PresenceEngine: is_present with the always-present default
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _three_child_engine() -> PresenceEngine:
|
||||
"""Two children on opposite weeks of one two-week rotation, one
|
||||
with no schedule at all (the always-present sibling)."""
|
||||
anchor = datetime.date(2026, 1, 5) # Monday, week 0
|
||||
declan = PresenceSchedule(
|
||||
1, 2, anchor, {0: {0, 1, 2, 3, 4, 5, 6}, 1: set()}
|
||||
)
|
||||
jordyn = PresenceSchedule(
|
||||
2, 2, anchor, {0: set(), 1: {0, 1, 2, 3, 4, 5, 6}}
|
||||
)
|
||||
return PresenceEngine({1: declan, 2: jordyn})
|
||||
|
||||
|
||||
def test_engine_child_without_schedule_is_always_present() -> None:
|
||||
"""Every day of the four-week span, all three children get the
|
||||
expected verdict: Chloe (no schedule) always present; the two
|
||||
rotation children complementary per the anchor pattern."""
|
||||
engine = _three_child_engine()
|
||||
anchor = datetime.date(2026, 1, 5)
|
||||
for offset in range(28):
|
||||
day = anchor + datetime.timedelta(days=offset)
|
||||
week = ((day - anchor).days // 7) % 2
|
||||
assert engine.is_present(3, day) is True, f"offset {offset}: Chloe"
|
||||
assert engine.is_present(1, day) is (week == 0), (
|
||||
f"offset {offset}: Declan should follow week {week}"
|
||||
)
|
||||
assert engine.is_present(2, day) is (week == 1), (
|
||||
f"offset {offset}: Jordyn should follow week {week}"
|
||||
)
|
||||
|
||||
|
||||
def test_engine_alternating_children_are_complementary_for_four_weeks() -> None:
|
||||
"""Every day of a four-week span: the two rotation children swap
|
||||
presence exactly per the anchor pattern — when one is home the
|
||||
other is away."""
|
||||
engine = _three_child_engine()
|
||||
anchor = datetime.date(2026, 1, 5)
|
||||
for offset in range(28):
|
||||
day = anchor + datetime.timedelta(days=offset)
|
||||
d = engine.is_present(1, day)
|
||||
j = engine.is_present(2, day)
|
||||
assert d == (not j), f"offset {offset}: {d}/{j} not complementary"
|
||||
expected_week = ((day - anchor).days // 7) % 2
|
||||
expected_present = day.weekday() in {0, 1, 2, 3, 4, 5, 6}
|
||||
assert engine.is_present(1, day) == (expected_week == 0), (
|
||||
f"offset {offset}: Declan week {expected_week}"
|
||||
)
|
||||
# Spot-check the swap days.
|
||||
assert engine.is_present(1, anchor) is True
|
||||
assert engine.is_present(2, anchor) is False
|
||||
assert engine.is_present(1, anchor + datetime.timedelta(days=7)) is False
|
||||
assert engine.is_present(2, anchor + datetime.timedelta(days=7)) is True
|
||||
|
||||
|
||||
def test_engine_weekend_absence_respects_the_pattern() -> None:
|
||||
"""A weekday-set pattern excludes days outside the set even in the
|
||||
child's present week."""
|
||||
engine = PresenceEngine(
|
||||
{
|
||||
1: PresenceSchedule(
|
||||
1, 2, datetime.date(2026, 1, 5), {0: {1, 3}, 1: {2, 4}}
|
||||
)
|
||||
}
|
||||
)
|
||||
# 2026-01-06 (Tue) is week 0, weekday 1 -> present.
|
||||
assert engine.is_present(1, datetime.date(2026, 1, 6)) is True
|
||||
# 2026-01-07 (Wed) is week 0, weekday 2 -> absent.
|
||||
assert engine.is_present(1, datetime.date(2026, 1, 7)) is False
|
||||
# 2026-01-14 (Wed) is week 1, weekday 2 -> present.
|
||||
assert engine.is_present(1, datetime.date(2026, 1, 14)) is True
|
||||
|
||||
|
||||
def test_engine_rejects_bad_construction_and_inputs() -> None:
|
||||
schedule = PresenceSchedule(1, 1, ANCHOR, {0: {0}})
|
||||
with pytest.raises(ValueError, match="must map child_id"):
|
||||
PresenceEngine([schedule])
|
||||
with pytest.raises(ValueError, match="must be a PresenceSchedule"):
|
||||
PresenceEngine({1: "not-a-schedule"})
|
||||
with pytest.raises(ValueError, match="holds the schedule"):
|
||||
PresenceEngine({2: schedule})
|
||||
with pytest.raises(ValueError, match="child_id must be an integer"):
|
||||
PresenceEngine({True: schedule})
|
||||
with pytest.raises(ValueError, match="child_id must be an integer"):
|
||||
PresenceEngine({1.0: PresenceSchedule(1, 1, ANCHOR, {0: {0}})})
|
||||
engine = PresenceEngine({1: schedule})
|
||||
with pytest.raises(ValueError, match="child_id must be an integer"):
|
||||
engine.is_present(True, ANCHOR)
|
||||
with pytest.raises(ValueError, match="plain calendar date"):
|
||||
engine.is_present(1, datetime.datetime(2026, 1, 5))
|
||||
|
||||
|
||||
def test_engine_is_immutable() -> None:
|
||||
engine = _three_child_engine()
|
||||
with pytest.raises(AttributeError, match="immutable snapshot"):
|
||||
engine._schedules = {}
|
||||
with pytest.raises(AttributeError, match="immutable snapshot"):
|
||||
engine.something_new = 1
|
||||
|
||||
Reference in New Issue
Block a user