Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e9dc0a1445 | ||
|
|
1bb0617471 |
@@ -319,6 +319,13 @@ def cycle_week_index(
|
|||||||
return (days // 7) % schedule.cycle_length_weeks
|
return (days // 7) % schedule.cycle_length_weeks
|
||||||
|
|
||||||
|
|
||||||
|
#: Search cap for :meth:`PresenceEngine.next_present_dates`: two full
|
||||||
|
#: years of daily checks. A child absent every day (an all-absent
|
||||||
|
#: pattern with a blocking override) would otherwise loop forever; the
|
||||||
|
#: cap bounds the scan and surfaces the situation as an error instead.
|
||||||
|
MAX_PREVIEW_SCAN_DAYS = 366 * 2
|
||||||
|
|
||||||
|
|
||||||
class PresenceEngine:
|
class PresenceEngine:
|
||||||
"""Answers "is this child at this house on this date?".
|
"""Answers "is this child at this house on this date?".
|
||||||
|
|
||||||
@@ -424,6 +431,45 @@ class PresenceEngine:
|
|||||||
week = cycle_week_index(schedule, target)
|
week = cycle_week_index(schedule, target)
|
||||||
return target.weekday() in schedule.pattern[week]
|
return target.weekday() in schedule.pattern[week]
|
||||||
|
|
||||||
|
def next_present_dates(
|
||||||
|
self,
|
||||||
|
child_id: object,
|
||||||
|
start_date: object,
|
||||||
|
count: int,
|
||||||
|
) -> list[datetime.date]:
|
||||||
|
"""Return the next ``count`` dates the child is present.
|
||||||
|
|
||||||
|
Walks forward from ``start_date`` (INCLUSIVE — a parent
|
||||||
|
previewing "starting Monday" expects Monday itself when the
|
||||||
|
child is present) checking :meth:`is_present` for every day,
|
||||||
|
so overrides and the pattern are honoured identically. The
|
||||||
|
admin Schedule tab renders this preview so a parent can confirm
|
||||||
|
a custody pattern is right before saving it.
|
||||||
|
|
||||||
|
A child absent every scanned day raises ValueError after the
|
||||||
|
two-year search cap — an always-absent child would otherwise
|
||||||
|
loop forever. ``count`` must be a positive integer.
|
||||||
|
"""
|
||||||
|
_validate_child_id(child_id)
|
||||||
|
start = _parse_date(start_date, "start_date")
|
||||||
|
if isinstance(count, bool) or not isinstance(count, int):
|
||||||
|
raise ValueError(f"count must be an integer, got {count!r}")
|
||||||
|
if count < 1:
|
||||||
|
raise ValueError(f"count must be at least 1, got {count}")
|
||||||
|
found: list[datetime.date] = []
|
||||||
|
day = start
|
||||||
|
for _ in range(MAX_PREVIEW_SCAN_DAYS):
|
||||||
|
if self.is_present(child_id, day):
|
||||||
|
found.append(day)
|
||||||
|
if len(found) == count:
|
||||||
|
return found
|
||||||
|
day += datetime.timedelta(days=1)
|
||||||
|
raise ValueError(
|
||||||
|
f"child {child_id} is not present on any of the "
|
||||||
|
f"{MAX_PREVIEW_SCAN_DAYS} days from {start.isoformat()}; "
|
||||||
|
f"{count} present day(s) cannot be previewed"
|
||||||
|
)
|
||||||
|
|
||||||
def __setattr__(self, name: object, value: object) -> None:
|
def __setattr__(self, name: object, value: object) -> None:
|
||||||
raise AttributeError(
|
raise AttributeError(
|
||||||
"PresenceEngine is an immutable snapshot; build a new engine "
|
"PresenceEngine is an immutable snapshot; build a new engine "
|
||||||
|
|||||||
@@ -599,3 +599,122 @@ def test_engine_override_lists_are_frozen() -> None:
|
|||||||
engine._overrides[1].append(entry)
|
engine._overrides[1].append(entry)
|
||||||
with pytest.raises(AttributeError, match="immutable snapshot"):
|
with pytest.raises(AttributeError, match="immutable snapshot"):
|
||||||
engine._overrides = {}
|
engine._overrides = {}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# next_present_dates: the parent's pattern preview
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _preview_engine() -> PresenceEngine:
|
||||||
|
"""Declan: home all of week 0, away all of week 1 (anchor
|
||||||
|
2026-01-05, a Monday); an away override Tue Jan 13 (his away week)
|
||||||
|
would matter only for the override test."""
|
||||||
|
return PresenceEngine(
|
||||||
|
{
|
||||||
|
1: PresenceSchedule(
|
||||||
|
1, 2, datetime.date(2026, 1, 5),
|
||||||
|
{0: {0, 1, 2, 3, 4, 5, 6}, 1: set()},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_preview_returns_the_next_present_days_of_a_known_pattern() -> None:
|
||||||
|
engine = _preview_engine()
|
||||||
|
# Starting Monday Jan 5 (present week 0): five straight days.
|
||||||
|
preview = engine.next_present_dates(1, "2026-01-05", 5)
|
||||||
|
assert preview == [
|
||||||
|
datetime.date(2026, 1, 5),
|
||||||
|
datetime.date(2026, 1, 6),
|
||||||
|
datetime.date(2026, 1, 7),
|
||||||
|
datetime.date(2026, 1, 8),
|
||||||
|
datetime.date(2026, 1, 9),
|
||||||
|
]
|
||||||
|
# Starting mid-week: the rest of week 0 first, then the absent
|
||||||
|
# week 1 is skipped and the next present week picks up.
|
||||||
|
preview = engine.next_present_dates(1, "2026-01-07", 3)
|
||||||
|
assert preview == [
|
||||||
|
datetime.date(2026, 1, 7),
|
||||||
|
datetime.date(2026, 1, 8),
|
||||||
|
datetime.date(2026, 1, 9),
|
||||||
|
]
|
||||||
|
preview = engine.next_present_dates(1, "2026-01-07", 6)
|
||||||
|
# Week 0 runs Mon Jan 5 .. Sun Jan 11 (all present); the away week
|
||||||
|
# (Jan 12-18) is skipped; week 2 resumes Jan 19.
|
||||||
|
assert preview == [
|
||||||
|
datetime.date(2026, 1, 7),
|
||||||
|
datetime.date(2026, 1, 8),
|
||||||
|
datetime.date(2026, 1, 9),
|
||||||
|
datetime.date(2026, 1, 10),
|
||||||
|
datetime.date(2026, 1, 11),
|
||||||
|
datetime.date(2026, 1, 19),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_preview_start_date_is_inclusive() -> None:
|
||||||
|
engine = _preview_engine()
|
||||||
|
# Jan 12 (Monday of the away week) is absent: the first present day
|
||||||
|
# is Jan 19 — the start date itself when present.
|
||||||
|
preview = engine.next_present_dates(1, "2026-01-19", 1)
|
||||||
|
assert preview == [datetime.date(2026, 1, 19)]
|
||||||
|
|
||||||
|
|
||||||
|
def test_preview_honours_overrides() -> None:
|
||||||
|
schedule = PresenceSchedule(
|
||||||
|
1, 2, datetime.date(2026, 1, 5),
|
||||||
|
{0: {0, 1, 2, 3, 4, 5, 6}, 1: set()},
|
||||||
|
)
|
||||||
|
engine = PresenceEngine(
|
||||||
|
{1: schedule},
|
||||||
|
overrides={
|
||||||
|
1: [
|
||||||
|
PresenceOverride(
|
||||||
|
1, "2026-01-05", "2026-01-06", False, note="holiday"
|
||||||
|
)
|
||||||
|
]
|
||||||
|
},
|
||||||
|
)
|
||||||
|
# Jan 5-6 blocked by the override: the preview starts Jan 7.
|
||||||
|
preview = engine.next_present_dates(1, "2026-01-04", 3)
|
||||||
|
assert preview == [
|
||||||
|
datetime.date(2026, 1, 7),
|
||||||
|
datetime.date(2026, 1, 8),
|
||||||
|
datetime.date(2026, 1, 9),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_preview_schedule_free_child_returns_consecutive_days() -> None:
|
||||||
|
engine = PresenceEngine({})
|
||||||
|
preview = engine.next_present_dates(3, "2026-03-01", 3)
|
||||||
|
assert preview == [
|
||||||
|
datetime.date(2026, 3, 1),
|
||||||
|
datetime.date(2026, 3, 2),
|
||||||
|
datetime.date(2026, 3, 3),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_preview_rejects_bad_inputs() -> None:
|
||||||
|
engine = _preview_engine()
|
||||||
|
with pytest.raises(ValueError, match="count must be an integer"):
|
||||||
|
engine.next_present_dates(1, "2026-01-05", "3")
|
||||||
|
with pytest.raises(ValueError, match="count must be at least 1"):
|
||||||
|
engine.next_present_dates(1, "2026-01-05", 0)
|
||||||
|
with pytest.raises(ValueError, match="child_id must be an integer"):
|
||||||
|
engine.next_present_dates(True, "2026-01-05", 1)
|
||||||
|
with pytest.raises(ValueError, match="plain calendar date"):
|
||||||
|
engine.next_present_dates(1, datetime.datetime(2026, 1, 5), 1)
|
||||||
|
|
||||||
|
|
||||||
|
def test_preview_always_absent_child_raises_not_loops() -> None:
|
||||||
|
"""A child absent every scanned day must surface as an error, not
|
||||||
|
hang the admin preview."""
|
||||||
|
engine = PresenceEngine(
|
||||||
|
{
|
||||||
|
1: PresenceSchedule(
|
||||||
|
1, 1, datetime.date(2026, 1, 5), {0: set()}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
with pytest.raises(ValueError, match="not present on any"):
|
||||||
|
engine.next_present_dates(1, "2026-01-05", 2)
|
||||||
|
|||||||
Reference in New Issue
Block a user