From d76cde1af5282acb93907df7d87f3a22c78ee539 Mon Sep 17 00:00:00 2001 From: Joshua King Date: Tue, 15 Sep 2026 17:43:09 -0400 Subject: [PATCH 01/18] Presence and custody model with total validation (Feature 05, task 1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit presence.py — a PURE module (no HA imports, no DB access, no timezone logic): PresenceSchedule (child_id, cycle_length_weeks, anchor_date, pattern mapping week index -> frozenset of present weekdays) and PresenceOverride (child_id, inclusive start/end range, real-bool is_present, optional note) with construction that rejects every invalid shape up front: - cycle length: integer 1..4 (the 4 cap mirrors schema.py's documented CHECK); bools/floats/strings rejected. - pattern: dict required; week indices validated as non-bool ints covering 0..n-1 exactly (gaps and extras rejected); weekday sets validated as ints 0..6 (bools rejected — True would silently mean Monday; strings rejected — '02' would silently mean {0,2}); an explicitly empty week is valid (absent all week — distinct from no schedule, the engine's default). - anchor/override dates: datetime.date or strict YYYY-MM-DD (strptime round-trip; '2026-1-5' rejected). - override range: end >= start; is_present must be a real bool; note optional string. - child ids: non-bool ints (SQLite binds True as 1). Serialization: encode() emits the database's pipe-separated CSV (sorted weekdays per week, empty segment for an absent week); decode() derives the cycle length from the segment count (mirroring the schema's segment-count CHECK) and validates strictly, so decode(encode(x)) round-trips losslessly and equal patterns encode identically. Tests: 59 (construction matrix, round-trips, encode shapes, override coverage). Suite: 972 passed. --- custom_components/nestquest/presence.py | 283 ++++++++++++++++++++++++ tests/test_presence.py | 229 +++++++++++++++++++ 2 files changed, 512 insertions(+) create mode 100644 custom_components/nestquest/presence.py create mode 100644 tests/test_presence.py diff --git a/custom_components/nestquest/presence.py b/custom_components/nestquest/presence.py new file mode 100644 index 0000000..88e6f10 --- /dev/null +++ b/custom_components/nestquest/presence.py @@ -0,0 +1,283 @@ +"""The presence and custody model (Feature 05). + +PURE module: no Home Assistant imports, no database access, no +timezone logic — it operates on plain calendar dates only (feature +guardrail). The later engine (cycle position, ``is_present``, +overrides, previews) builds directly on this model. + +A :class:`PresenceSchedule` answers "on which weekdays of which cycle +week is this child at this house?" for an N-week repeating custody +pattern. Two of the household's three children follow a two-week +rotation; a child with NO schedule row is present every day (that +default lives in the engine, not here). + +Pattern encoding: the model's canonical serialization is the SAME +pipe-separated CSV the database stores (see schema.py's +``presence_schedules.pattern``): segment ``i`` (week index ``i``) is a +comma-separated list of present weekdays, 0 = Monday .. 6 = Sunday, +e.g. ``'0,2,4|1,3'`` = Mon/Wed/Fri in week 0, Tue/Thu in week 1. An +empty segment means absent every day of that week. Encoding sorts +each segment's weekdays and uses the exact segment order, so +``decode(encode(schedule))`` round-trips losslessly and two schedules +with equal patterns produce equal strings. + +Anchor-date policy: ``anchor_date`` pins the cycle. Cycle position is +computed from anchor-date arithmetic ONLY — never ISO week numbers or +week parity (D-004): 53-week years silently invert parity on January +1st. The arithmetic lives in the engine task; this module validates +that the anchor is a strict calendar date. + +Validation policy: construction is total — every reject happens in +``__init__`` so no invalid schedule can ever exist: +- ``cycle_length_weeks`` must be an integer 1..4. The 4 cap matches + the database CHECK (schema.py) and keeps anchor arithmetic and the + encoded pattern human-readable; revisit both together if a longer + cycle is ever needed. +- ``pattern`` must cover week indices 0..cycle_length_weeks-1 exactly + (no gaps, no extra weeks) with sets of integers 0..6 (bools + rejected: True would silently mean Monday). +- ``anchor_date`` must be a strict ISO YYYY-MM-DD calendar date (or a + ``datetime.date``). +""" +from __future__ import annotations + +import datetime +from dataclasses import dataclass + +#: The schema's documented cap for cycle_length_weeks (schema.py keeps +#: the pattern human-readable and the anchor arithmetic small). +MAX_CYCLE_LENGTH_WEEKS = 4 + +_DATE_FORMAT = "%Y-%m-%d" + + +def _parse_date(value: object, field_name: str) -> datetime.date: + """Accept a datetime.date or a strict YYYY-MM-DD string.""" + if isinstance(value, datetime.date): + return value + if not isinstance(value, str): + raise ValueError( + f"{field_name} must be a datetime.date or an ISO date string, " + f"got {value!r}" + ) + try: + parsed = datetime.datetime.strptime(value, _DATE_FORMAT).date() + except (TypeError, ValueError): + raise ValueError( + f"{field_name} must be an ISO date (YYYY-MM-DD), got {value!r}" + ) from None + if parsed.isoformat() != value: + raise ValueError( + f"{field_name} must be a strict YYYY-MM-DD date, got {value!r}" + ) + return parsed + + +def _validate_week_index(week: object, cycle_length_weeks: int) -> int: + """Reject non-int week indices (bools included) and out-of-cycle.""" + if isinstance(week, bool) or not isinstance(week, int): + raise ValueError( + f"pattern week index must be an integer, got {week!r}" + ) + if not 0 <= week < cycle_length_weeks: + raise ValueError( + f"pattern week index must be in 0..{cycle_length_weeks - 1}, " + f"got {week}" + ) + return week + + +def _validate_weekday_set(week: int, weekdays: object) -> frozenset[int]: + """Validate one week's present-weekday set.""" + if isinstance(weekdays, (str, bytes)) or weekdays is None: + raise ValueError( + f"pattern[{week}] must be an iterable of weekday ints 0..6, " + f"got {weekdays!r}" + ) + try: + members = list(weekdays) + except TypeError: + raise ValueError( + f"pattern[{week}] must be an iterable of weekday ints 0..6, " + f"got {weekdays!r}" + ) from None + for member in members: + if isinstance(member, bool) or not isinstance(member, int): + raise ValueError( + f"pattern[{week}] weekday must be an int 0..6, got {member!r}" + ) + if not 0 <= member <= 6: + raise ValueError( + f"pattern[{week}] weekday must be in 0..6 (Monday=0), " + f"got {member}" + ) + return frozenset(members) + + +def _validate_cycle_length(value: object) -> int: + """Reject non-int and out-of-range cycle lengths (bools included).""" + if isinstance(value, bool) or not isinstance(value, int): + raise ValueError( + f"cycle_length_weeks must be an integer, got {value!r}" + ) + if not 1 <= value <= MAX_CYCLE_LENGTH_WEEKS: + raise ValueError( + f"cycle_length_weeks must be between 1 and " + f"{MAX_CYCLE_LENGTH_WEEKS}, got {value}" + ) + return value + + +def _validate_child_id(value: object) -> int: + """Reject non-int child ids (bools would silently address child 1).""" + if isinstance(value, bool) or not isinstance(value, int): + raise ValueError(f"child_id must be an integer, got {value!r}") + return value + + +@dataclass(frozen=True, init=False) +class PresenceSchedule: + """One child's N-week repeating presence pattern. + + ``pattern`` maps each week index of the cycle (``0`` .. ``n-1``) + to the set of present weekdays (ints, Monday=0). A week with an + empty set means the child is absent every day of that week — which + is distinct from having no schedule at all (the engine treats a + missing schedule as present every day). + """ + + child_id: int + cycle_length_weeks: int + anchor_date: datetime.date + pattern: dict[int, frozenset[int]] + + def __init__( + self, + child_id: int, + cycle_length_weeks: int, + anchor_date: object, + pattern: dict[int, object], + ) -> None: + object.__setattr__(self, "child_id", _validate_child_id(child_id)) + cycle = _validate_cycle_length(cycle_length_weeks) + object.__setattr__(self, "cycle_length_weeks", cycle) + object.__setattr__( + self, "anchor_date", _parse_date(anchor_date, "anchor_date") + ) + if not isinstance(pattern, dict): + raise ValueError( + "pattern must map week index to a set of present " + f"weekdays, got {pattern!r}" + ) + normalised: dict[int, frozenset[int]] = {} + for week, weekdays in pattern.items(): + index = _validate_week_index(week, cycle) + normalised[index] = _validate_weekday_set(index, weekdays) + missing = [ + week for week in range(cycle) if week not in normalised + ] + if missing: + raise ValueError( + f"pattern must cover week indices 0..{cycle - 1}; " + f"missing {missing}" + ) + object.__setattr__(self, "pattern", normalised) + + def encode(self) -> str: + """Serialize to the database's pipe-separated CSV shape. + + Segments appear in week order; each segment is the week's + present weekdays as a sorted comma-separated list (an empty set + encodes as an empty segment). Two schedules with equal + patterns encode identically. + """ + segments = [ + ",".join(str(day) for day in sorted(self.pattern[week])) + for week in range(self.cycle_length_weeks) + ] + return "|".join(segments) + + @classmethod + def decode( + cls, + child_id: int, + anchor_date: object, + encoded: str, + ) -> "PresenceSchedule": + """Build a schedule from its encoded pattern. + + The cycle length is derived from the segment count, mirroring + the database's segment-count CHECK. Decoding is strict: wrong + shapes, non-numeric or out-of-range weekdays are rejected — + the database CHECKs make such rows unrepresentable, and this + is the same contract on the model side. A segment count above + the documented cycle cap is rejected by the ordinary + construction validation. + """ + if not isinstance(encoded, str): + raise ValueError( + f"encoded pattern must be a string, got {encoded!r}" + ) + segments = encoded.split("|") + pattern: dict[int, set[int]] = {} + for week, segment in enumerate(segments): + weekdays: set[int] = set() + if segment: + for part in segment.split(","): + if len(part) != 1 or part not in "0123456": + raise ValueError( + f"encoded segment {week} carries an invalid " + f"weekday {part!r} (single digits 0-6 only)" + ) + weekdays.add(int(part)) + pattern[week] = weekdays + return cls(child_id, len(segments), anchor_date, pattern) + + +@dataclass(frozen=True, init=False) +class PresenceOverride: + """A date-specific present/absent flag beating any pattern. + + Covers a single date (``end_date == start_date``) or an inclusive + range. A concrete range is the storage shape + (``presence_overrides.end_date`` is NOT NULL), so the model + requires both bounds and validates start <= end. Construction and + validation live here so the engine can trust every instance. + """ + + child_id: int + start_date: datetime.date + end_date: datetime.date + is_present: bool + note: str | None + + def __init__( + self, + child_id: int, + start_date: object, + end_date: object, + is_present: bool, + note: str | None = None, + ) -> None: + object.__setattr__(self, "child_id", _validate_child_id(child_id)) + start = _parse_date(start_date, "start_date") + end = _parse_date(end_date, "end_date") + if end < start: + raise ValueError( + f"end_date must be on or after start_date, got " + f"{end.isoformat()} < {start.isoformat()}" + ) + object.__setattr__(self, "start_date", start) + object.__setattr__(self, "end_date", end) + if not isinstance(is_present, bool): + raise ValueError( + f"is_present must be a real bool, got {is_present!r}" + ) + object.__setattr__(self, "is_present", is_present) + if note is not None and not isinstance(note, str): + raise ValueError(f"note must be a string or None, got {note!r}") + object.__setattr__(self, "note", note) + + def covers(self, date: datetime.date) -> bool: + """Return True when the override applies to ``date``.""" + return self.start_date <= date <= self.end_date diff --git a/tests/test_presence.py b/tests/test_presence.py new file mode 100644 index 0000000..1c8e44d --- /dev/null +++ b/tests/test_presence.py @@ -0,0 +1,229 @@ +"""Tests for presence.py: the Feature 05 custody model.""" +from __future__ import annotations + +import datetime + +import pytest + +from custom_components.nestquest.presence import ( + MAX_CYCLE_LENGTH_WEEKS, + PresenceOverride, + PresenceSchedule, +) + + +ANCHOR = datetime.date(2026, 1, 5) # a Monday + +TWO_WEEK_PATTERN = {0: {0, 2, 4}, 1: {1, 3}} + + +# --------------------------------------------------------------------------- +# PresenceSchedule construction +# --------------------------------------------------------------------------- + + +def test_schedule_holds_validated_fields() -> None: + schedule = PresenceSchedule( + 3, 2, "2026-01-05", {0: {0, 2, 4}, 1: {1, 3}} + ) + assert schedule.child_id == 3 + assert schedule.cycle_length_weeks == 2 + assert schedule.anchor_date == ANCHOR + assert schedule.pattern == {0: frozenset({0, 2, 4}), 1: frozenset({1, 3})} + + +def test_schedule_accepts_date_objects() -> None: + schedule = PresenceSchedule(1, 1, ANCHOR, {0: {0}}) + assert schedule.anchor_date is ANCHOR + + +def test_schedule_rejects_cycle_length_below_one() -> None: + with pytest.raises(ValueError, match="between 1 and"): + PresenceSchedule(1, 0, ANCHOR, {}) + with pytest.raises(ValueError, match="between 1 and"): + PresenceSchedule(1, -1, ANCHOR, {}) + + +def test_schedule_rejects_cycle_length_above_documented_cap() -> None: + """The 4 cap matches the schema's CHECK — both documents agree.""" + with pytest.raises(ValueError, match="between 1 and"): + PresenceSchedule(1, MAX_CYCLE_LENGTH_WEEKS + 1, ANCHOR, {}) + assert MAX_CYCLE_LENGTH_WEEKS == 4 + + +@pytest.mark.parametrize("bad", [True, "2", 1.0, None]) +def test_schedule_rejects_non_int_cycle_length(bad) -> None: + with pytest.raises(ValueError, match="cycle_length_weeks must be an integer"): + PresenceSchedule(1, bad, ANCHOR, {0: {0}}) + + +def test_schedule_rejects_pattern_missing_week_indices() -> None: + with pytest.raises(ValueError, match="missing \\[1\\]"): + PresenceSchedule(1, 2, ANCHOR, {0: {0, 2}}) + + +def test_schedule_rejects_pattern_with_extra_week_indices() -> None: + with pytest.raises(ValueError, match="week index must be in 0"): + PresenceSchedule(1, 2, ANCHOR, {0: {0}, 1: {1}, 2: {2}}) + + +def test_schedule_rejects_pattern_with_non_int_week_keys() -> None: + with pytest.raises(ValueError, match="week index must be an integer"): + PresenceSchedule(1, 1, ANCHOR, {"0": {0}}) + + +def test_schedule_rejects_pattern_with_bool_week_key() -> None: + """True would silently be week 1; bools are never valid indices.""" + with pytest.raises(ValueError, match="week index must be an integer"): + PresenceSchedule(1, 2, ANCHOR, {0: {0}, True: {1}}) + + +@pytest.mark.parametrize("bad_weekday", [-1, 7, 100]) +def test_schedule_rejects_weekday_outside_zero_to_six(bad_weekday) -> None: + with pytest.raises(ValueError, match="weekday must be in 0..6"): + PresenceSchedule(1, 1, ANCHOR, {0: {bad_weekday}}) + + +@pytest.mark.parametrize("bad", [True, "1", 1.5, None]) +def test_schedule_rejects_non_int_weekdays(bad) -> None: + with pytest.raises(ValueError, match="weekday must be an int"): + PresenceSchedule(1, 1, ANCHOR, {0: [bad]}) + + +def test_schedule_rejects_string_and_none_week_sets() -> None: + """A string is iterable — "02" would silently mean {0, 2}.""" + with pytest.raises(ValueError, match="iterable of weekday ints"): + PresenceSchedule(1, 1, ANCHOR, {0: "02"}) + with pytest.raises(ValueError, match="iterable of weekday ints"): + PresenceSchedule(1, 1, ANCHOR, {0: None}) + + +def test_schedule_rejects_non_dict_pattern() -> None: + with pytest.raises(ValueError, match="must map week index"): + PresenceSchedule(1, 1, ANCHOR, "0,2") + + +@pytest.mark.parametrize("bad", [True, 1.0, "2026-01-05", None]) +def test_schedule_rejects_non_int_child_id(bad) -> None: + with pytest.raises(ValueError, match="child_id must be an integer"): + PresenceSchedule(bad, 1, ANCHOR, {0: {0}}) + + +@pytest.mark.parametrize( + "bad", ["2026-1-5", "2026/01/05", "not-a-date", 5, None, True] +) +def test_schedule_rejects_non_strict_anchor_dates(bad) -> None: + with pytest.raises(ValueError, match="anchor_date"): + PresenceSchedule(1, 1, bad, {0: {0}}) + + +def test_schedule_empty_week_means_absent_week() -> None: + """An explicitly empty week is valid: absent every day of it — + distinct from having no schedule (the engine's default).""" + schedule = PresenceSchedule(1, 2, ANCHOR, {0: {0, 2}, 1: set()}) + assert schedule.pattern[1] == frozenset() + + +def test_schedule_empty_week_encodes_as_empty_segment() -> None: + schedule = PresenceSchedule(1, 2, ANCHOR, {0: {0, 2}, 1: set()}) + assert schedule.encode() == "0,2|" + + +def test_schedule_equal_patterns_are_equal_and_encode_identically() -> None: + one = PresenceSchedule(1, 2, ANCHOR, {0: {4, 2, 0}, 1: {3, 1}}) + two = PresenceSchedule(2, 2, ANCHOR, {0: {0, 2, 4}, 1: {1, 3}}) + assert one.encode() == two.encode() == "0,2,4|1,3" + + +# --------------------------------------------------------------------------- +# Serialization round-trip +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "encoded", + [ + "0,2,4|1,3", + "0|1,2,3,4,5,6", + "|", + "0,2|", + "|0,2", + "6", + "0,1,2,3,4,5,6|", + ], +) +def test_schedule_decode_encode_round_trips_losslessly(encoded) -> None: + schedule = PresenceSchedule.decode(1, ANCHOR, encoded) + assert schedule.encode() == encoded + again = PresenceSchedule.decode(1, ANCHOR, schedule.encode()) + assert again == schedule + + +def test_schedule_decode_sets_cycle_length_from_segments() -> None: + one_week = PresenceSchedule.decode(1, ANCHOR, "0,2,4") + assert one_week.cycle_length_weeks == 1 + four_week = PresenceSchedule.decode(1, ANCHOR, "0|1|2|3,4") + assert four_week.cycle_length_weeks == 4 + + +def test_schedule_decode_rejects_malformed_segments() -> None: + for bad in ("0,12|1", "0,,2|1", "x|1", "0|-1|2", "8", "0,|1"): + with pytest.raises(ValueError, match="invalid weekday"): + PresenceSchedule.decode(1, ANCHOR, bad) + + +def test_schedule_decode_rejects_non_string() -> None: + with pytest.raises(ValueError, match="must be a string"): + PresenceSchedule.decode(1, ANCHOR, None) + + +def test_schedule_decode_rejects_too_many_segments(tmp_path) -> None: + with pytest.raises(ValueError, match="between 1 and"): + PresenceSchedule.decode(1, ANCHOR, "0|1|2|3|4") + + +# --------------------------------------------------------------------------- +# PresenceOverride +# --------------------------------------------------------------------------- + + +def test_override_single_date_and_range_cover() -> None: + single = PresenceOverride(1, "2026-07-20", "2026-07-20", False) + assert single.covers(datetime.date(2026, 7, 20)) + assert not single.covers(datetime.date(2026, 7, 21)) + span = PresenceOverride(1, "2026-07-20", "2026-07-24", True, note="trade") + assert span.covers(datetime.date(2026, 7, 22)) + assert not span.covers(datetime.date(2026, 7, 19)) + assert not span.covers(datetime.date(2026, 7, 25)) + assert span.note == "trade" + + +def test_override_end_before_start_rejected() -> None: + with pytest.raises(ValueError, match="end_date must be on or after"): + PresenceOverride(1, "2026-07-24", "2026-07-20", True) + + +@pytest.mark.parametrize("bad", ["1", 0, 1.0, None]) +def test_override_rejects_non_bool_is_present(bad) -> None: + with pytest.raises(ValueError, match="is_present must be a real bool"): + PresenceOverride(1, "2026-07-20", "2026-07-20", bad) + + +@pytest.mark.parametrize("bad", [5, ["note"], 1.5]) +def test_override_rejects_non_string_note(bad) -> None: + with pytest.raises(ValueError, match="note must be a string or None"): + PresenceOverride(1, "2026-07-20", "2026-07-20", True, note=bad) + + +@pytest.mark.parametrize("bad", [True, 1.0, "1"]) +def test_override_rejects_non_int_child_id(bad) -> None: + with pytest.raises(ValueError, match="child_id must be an integer"): + PresenceOverride(bad, "2026-07-20", "2026-07-20", True) + + +@pytest.mark.parametrize( + ("start", "end"), [("2026-7-20", "2026-07-20"), ("nope", "2026-07-20")] +) +def test_override_rejects_non_strict_dates(start, end) -> None: + with pytest.raises(ValueError, match="date"): + PresenceOverride(1, start, end, True) From cb3c347d6130136c6ee228ea29b80f676e54bafa Mon Sep 17 00:00:00 2001 From: Joshua King Date: Tue, 15 Sep 2026 17:46:30 -0400 Subject: [PATCH 02/18] Review fix: reject datetime anchors, freeze the pattern mapping MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review round 1 (Codex gpt-5.6-terra, 2 MEDIUM): datetime.datetime subclasses datetime.date and slipped past the anchor check, storing a time component that would TypeError every later calendar comparison — rejected explicitly now (both model classes). The frozen schedule's pattern dict let callers inject unvalidated weekdays or drop required weeks after construction; the validated mapping is now exposed as a read-only MappingProxyType over frozenset weeks. Tests: datetime rejection matrix and immutability assertions. Suite: 974 passed. --- custom_components/nestquest/presence.py | 23 ++++++++++++++--- tests/test_presence.py | 33 +++++++++++++++++++++++++ 2 files changed, 53 insertions(+), 3 deletions(-) diff --git a/custom_components/nestquest/presence.py b/custom_components/nestquest/presence.py index 88e6f10..ffc6841 100644 --- a/custom_components/nestquest/presence.py +++ b/custom_components/nestquest/presence.py @@ -43,6 +43,7 @@ from __future__ import annotations import datetime from dataclasses import dataclass +from types import MappingProxyType #: The schema's documented cap for cycle_length_weeks (schema.py keeps #: the pattern human-readable and the anchor arithmetic small). @@ -52,7 +53,18 @@ _DATE_FORMAT = "%Y-%m-%d" def _parse_date(value: object, field_name: str) -> datetime.date: - """Accept a datetime.date or a strict YYYY-MM-DD string.""" + """Accept a datetime.date or a strict YYYY-MM-DD string. + + ``datetime.datetime`` is rejected explicitly: it SUBCLASSES + datetime.date, so a bare isinstance check would store a value with + a time component and every later calendar-date comparison (e.g. + ``PresenceOverride.covers``) would raise TypeError. + """ + if isinstance(value, datetime.datetime): + raise ValueError( + f"{field_name} must be a plain calendar date, not a " + f"datetime with a time component: {value!r}" + ) if isinstance(value, datetime.date): return value if not isinstance(value, str): @@ -149,7 +161,7 @@ class PresenceSchedule: child_id: int cycle_length_weeks: int anchor_date: datetime.date - pattern: dict[int, frozenset[int]] + pattern: MappingProxyType # week index -> frozenset of weekdays def __init__( self, @@ -181,7 +193,12 @@ class PresenceSchedule: f"pattern must cover week indices 0..{cycle - 1}; " f"missing {missing}" ) - object.__setattr__(self, "pattern", normalised) + # Frozen must mean frozen: a bare dict would let callers inject + # unvalidated weekdays (or drop required weeks) AFTER + # construction, bypassing total validation. The mapping + # proxy exposes the validated contents read-only; the + # per-week sets are already frozensets. + object.__setattr__(self, "pattern", MappingProxyType(normalised)) def encode(self) -> str: """Serialize to the database's pipe-separated CSV shape. diff --git a/tests/test_presence.py b/tests/test_presence.py index 1c8e44d..202980a 100644 --- a/tests/test_presence.py +++ b/tests/test_presence.py @@ -227,3 +227,36 @@ def test_override_rejects_non_int_child_id(bad) -> None: def test_override_rejects_non_strict_dates(start, end) -> None: with pytest.raises(ValueError, match="date"): PresenceOverride(1, start, end, True) + + +def test_schedule_rejects_datetime_anchor() -> None: + """datetime.datetime subclasses date: a bare isinstance check would + store a time component and every date comparison would TypeError.""" + with pytest.raises(ValueError, match="plain calendar date"): + PresenceSchedule(1, 1, datetime.datetime(2026, 1, 5, 9, 30), {0: {0}}) + + +def test_override_rejects_datetime_bounds() -> None: + with pytest.raises(ValueError, match="plain calendar date"): + PresenceOverride( + 1, datetime.datetime(2026, 7, 20), "2026-07-20", True + ) + + +def test_schedule_pattern_is_immutable_after_construction() -> None: + """The frozen schedule must not leak a mutable dict: injecting + unvalidated weekdays or dropping required weeks after construction + would bypass total validation.""" + schedule = PresenceSchedule(1, 2, ANCHOR, {0: {0, 2}, 1: {1}}) + with pytest.raises(TypeError): + schedule.pattern[0] = {9} + with pytest.raises(TypeError): + del schedule.pattern[1] + # mappingproxy exposes no mutators at all. + assert not any( + hasattr(schedule.pattern, name) + for name in ("clear", "pop", "popitem", "setdefault", "update") + ) + # The validated contents are still readable and encode correctly. + assert schedule.pattern[0] == frozenset({0, 2}) + assert schedule.encode() == "0,2|1" From 5885d5e972b6caf509b57d4f929cfa74f0e70431 Mon Sep 17 00:00:00 2001 From: Joshua King Date: Tue, 15 Sep 2026 17:51:28 -0400 Subject: [PATCH 03/18] Anchor-date cycle position arithmetic (Feature 05, task 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit presence.cycle_week_index: ((target_date - anchor_date).days // 7) % cycle_length_weeks — anchor-date arithmetic ONLY (D-004). Python floor division makes pre-anchor dates walk the cycle backwards (day -1 is the last week), never inverting; the modulo of a negative stays non-negative, so the index is always a valid week index. The target date passes through the model's strict plain-date parsing. Tests: formula offsets, three-week pattern wrap, backwards walking, day-by-day stability across three Dec-31 -> Jan-1 boundaries including 2020 (verified 53 ISO weeks — the parity-inversion regression), and a source guard that the module never reads ISO week numbers. Suite: 980 passed. --- custom_components/nestquest/presence.py | 19 +++++++ tests/test_presence.py | 74 +++++++++++++++++++++++++ 2 files changed, 93 insertions(+) diff --git a/custom_components/nestquest/presence.py b/custom_components/nestquest/presence.py index ffc6841..5c65590 100644 --- a/custom_components/nestquest/presence.py +++ b/custom_components/nestquest/presence.py @@ -298,3 +298,22 @@ 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 diff --git a/tests/test_presence.py b/tests/test_presence.py index 202980a..6beca28 100644 --- a/tests/test_presence.py +++ b/tests/test_presence.py @@ -9,6 +9,7 @@ from custom_components.nestquest.presence import ( MAX_CYCLE_LENGTH_WEEKS, PresenceOverride, PresenceSchedule, + cycle_week_index, ) @@ -260,3 +261,76 @@ 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}} + ) + for year in (2019, 2020, 2021): + day = datetime.date(year, 12, 24) + while day < datetime.date(year + 1, 1, 8): + current = cycle_week_index(schedule, day) + following = cycle_week_index( + schedule, day + datetime.timedelta(days=1) + ) + assert following in (current, (current + 1) % 2), ( + f"{day} -> {day + datetime.timedelta(days=1)}: {current} " + f"-> {following} breaks the cycle across the year boundary" + ) + day += datetime.timedelta(days=1) + # 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 From 77c4db7b3c6a4edef92f7c398cdeb02651555e0d Mon Sep 17 00:00:00 2001 From: Joshua King Date: Tue, 15 Sep 2026 17:54:25 -0400 Subject: [PATCH 04/18] Review fix: hardcode independently derived boundary expectations Review round 1 (Codex gpt-5.6-terra, LOW): the boundary walk asserted transitions in {(same, +1)} which even a reset/inverting implementation could satisfy on a two-week cycle. The test now asserts a hand-verified table of expected indices for every day around each of the three Dec-31/Jan-1 boundaries (2019/2020/2021, anchor 2019-01-06), so a parity bug cannot pass by coincidence. Suite: 980 passed. --- tests/test_presence.py | 61 +++++++++++++++++++++++++++++++++--------- 1 file changed, 49 insertions(+), 12 deletions(-) diff --git a/tests/test_presence.py b/tests/test_presence.py index 6beca28..96fe211 100644 --- a/tests/test_presence.py +++ b/tests/test_presence.py @@ -309,18 +309,55 @@ def test_cycle_week_index_stable_across_three_year_boundaries() -> None: schedule = PresenceSchedule( 1, 2, datetime.date(2019, 1, 6), {0: {0, 2, 4}, 1: {1, 3}} ) - for year in (2019, 2020, 2021): - day = datetime.date(year, 12, 24) - while day < datetime.date(year + 1, 1, 8): - current = cycle_week_index(schedule, day) - following = cycle_week_index( - schedule, day + datetime.timedelta(days=1) - ) - assert following in (current, (current + 1) % 2), ( - f"{day} -> {day + datetime.timedelta(days=1)}: {current} " - f"-> {following} breaks the cycle across the year boundary" - ) - day += datetime.timedelta(days=1) + # 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 From 540f59a3534deb935b052aba8a4fc444a4c98eb0 Mon Sep 17 00:00:00 2001 From: Joshua King Date: Tue, 15 Sep 2026 17:57:02 -0400 Subject: [PATCH 05/18] PresenceEngine with is_present and the always-present default (Feature 05, task 3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PresenceEngine is an immutable, database-free snapshot of the children's schedules answering is_present(child_id, date): a child with NO schedule entry is present every day (the household's always-present sibling); a scheduled child is present exactly when the date's weekday is in the pattern for that date's cycle week (anchor-date arithmetic via cycle_week_index — never ISO parity). Construction validates the snapshot shape (dict keyed by the schedule's own child_id, values are PresenceSchedules) and exposes it read-only (MappingProxyType + __setattr__ refusal). is_present rejects non-int child ids and non-plain dates. Tests: the three-child fixture (two complementary rotation children, one schedule-free) asserted for every day of a four-week span plus spot-checked swap days; weekend/weekday-set behaviour across weeks; construction/input rejection matrix; engine immutability. Suite: 985 passed. --- custom_components/nestquest/presence.py | 54 +++++++++++++++ tests/test_presence.py | 91 +++++++++++++++++++++++++ 2 files changed, 145 insertions(+) diff --git a/custom_components/nestquest/presence.py b/custom_components/nestquest/presence.py index 5c65590..797081c 100644 --- a/custom_components/nestquest/presence.py +++ b/custom_components/nestquest/presence.py @@ -317,3 +317,57 @@ def cycle_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(): + 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" + ) diff --git a/tests/test_presence.py b/tests/test_presence.py index 96fe211..fd2570e 100644 --- a/tests/test_presence.py +++ b/tests/test_presence.py @@ -7,6 +7,7 @@ import pytest from custom_components.nestquest.presence import ( MAX_CYCLE_LENGTH_WEEKS, + PresenceEngine, PresenceOverride, PresenceSchedule, cycle_week_index, @@ -371,3 +372,93 @@ def test_module_uses_no_iso_week_functions() -> None: ).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: + engine = _three_child_engine() + # Chloe has no schedule: present every day, including weekends. + for offset in range(0, 28, 3): + day = datetime.date(2026, 1, 5) + datetime.timedelta(days=offset) + assert engine.is_present(3, day) is True, f"offset {offset}" + + +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}) + 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 From 6d48f433b7c906e95eec31530ee04ee284eb8c8d Mon Sep 17 00:00:00 2001 From: Joshua King Date: Tue, 15 Sep 2026 17:58:51 -0400 Subject: [PATCH 06/18] Review fix: validate engine keys as child ids; every-day three-child walk Review round 1 (Codex gpt-5.6-terra, 2 LOW): engine snapshot keys now pass _validate_child_id (True/1.0 compare equal to 1 and would be accepted then behave differently on lookup); the always-present test now walks all 28 days asserting all three children's expected verdicts instead of sampling every third day. Suite: 985 passed. --- custom_components/nestquest/presence.py | 4 ++++ tests/test_presence.py | 22 ++++++++++++++++++---- 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/custom_components/nestquest/presence.py b/custom_components/nestquest/presence.py index 797081c..907ad8d 100644 --- a/custom_components/nestquest/presence.py +++ b/custom_components/nestquest/presence.py @@ -339,6 +339,10 @@ class PresenceEngine: 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, " diff --git a/tests/test_presence.py b/tests/test_presence.py index fd2570e..b38727e 100644 --- a/tests/test_presence.py +++ b/tests/test_presence.py @@ -393,11 +393,21 @@ def _three_child_engine() -> PresenceEngine: 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() - # Chloe has no schedule: present every day, including weekends. - for offset in range(0, 28, 3): - day = datetime.date(2026, 1, 5) + datetime.timedelta(days=offset) - assert engine.is_present(3, day) is True, f"offset {offset}" + 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: @@ -449,6 +459,10 @@ def test_engine_rejects_bad_construction_and_inputs() -> None: 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) From 744a40dcb5d216b980218ed74a6c3bb8d5f8ef55 Mon Sep 17 00:00:00 2001 From: Joshua King Date: Tue, 15 Sep 2026 18:03:00 -0400 Subject: [PATCH 07/18] Presence override overlap rejection naming the conflict (Feature 05, task 4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PresenceOverridesDao.create now refuses an override that overlaps any existing override of the SAME child (inclusive boundaries): the check runs inside the same transaction as the INSERT under the connection lock, so a racing create cannot slip a conflict between check and write — a deterministic race test proves exactly one of two concurrent overlapping creates lands. The ValueError names the conflicting override's id, its range, and its flag. Round-trip, per-child scope, and remove-then-recreate tests added. Suite: 990 passed. --- custom_components/nestquest/dao_presence.py | 24 ++++ tests/test_dao_presence.py | 149 +++++++++++++++++++- 2 files changed, 172 insertions(+), 1 deletion(-) diff --git a/custom_components/nestquest/dao_presence.py b/custom_components/nestquest/dao_presence.py index c19e821..6bb5ddd 100644 --- a/custom_components/nestquest/dao_presence.py +++ b/custom_components/nestquest/dao_presence.py @@ -186,6 +186,15 @@ class PresenceOverridesDao: The child must exist (validated atomically under the connection lock). Date shape is validated strictly, and the schema CHECK rejects end < start. + + Overlap policy (Feature 05): an override for a child may not + overlap another override of the SAME child — two different + present/absent answers for one date are unresolvable. The + overlap check (inclusive boundaries) runs inside the same + transaction as the INSERT, so a racing create cannot slip a + conflicting override between the check and the write; a + conflict raises ValueError naming the conflicting override's + id and range. """ _validate_date(start_date, "start_date") _validate_date(end_date, "end_date") @@ -196,6 +205,21 @@ class PresenceOverridesDao: ) if child is None: raise ValueError(f"child {child_id} does not exist") + conflicting = await self._database.fetch_one( + f"SELECT {_OVERRIDE_COLUMNS} FROM presence_overrides " + "WHERE child_id = ? AND end_date >= ? " + "AND start_date <= ? ORDER BY id LIMIT 1", + (child_id, start_date, end_date), + ) + if conflicting is not None: + conflict = _override_from_row(conflicting) + raise ValueError( + f"override for child {child_id} " + f"[{start_date}, {end_date}] overlaps existing " + f"override {conflict.id} " + f"[{conflict.start_date}, {conflict.end_date}] " + f"(is_present={conflict.is_present})" + ) result = await self._database.execute( "INSERT INTO presence_overrides (child_id, " "start_date, end_date, is_present, note) " diff --git a/tests/test_dao_presence.py b/tests/test_dao_presence.py index c4d183e..85e381d 100644 --- a/tests/test_dao_presence.py +++ b/tests/test_dao_presence.py @@ -619,4 +619,151 @@ def test_dao_presence_test_file_uses_dao_not_raw_table_sql() -> None: ) assert sql_pattern.search(remaining) is None, ( "tests must go through the DAO, not raw SQL, for these tables" - ) \ No newline at end of file + ) + +# --------------------------------------------------------------------------- +# Presence overrides: overlap rejection naming the conflict +# --------------------------------------------------------------------------- + + +def test_override_create_round_trip_single_and_range(tmp_path) -> None: + async def _body(database, schedules, overrides, children, child): + single = await overrides.create( + child.id, "2026-07-20", "2026-07-20", False + ) + assert (single.start_date, single.end_date) == ("2026-07-20",) * 2 + span = await overrides.create( + child.id, "2026-08-01", "2026-08-05", True, note="traded week" + ) + assert span.is_present is True + assert span.note == "traded week" + # Distinct, non-overlapping ranges coexist. + listed = await overrides.list_by_child_and_range( + child.id, "2026-01-01", "2026-12-31" + ) + assert [row.id for row in listed] == [single.id, span.id] + return listed + + _with_db(tmp_path, "override-round-trip.db")(_body) + + +def test_override_overlap_rejected_and_names_the_conflict(tmp_path) -> None: + async def _body(database, schedules, overrides, children, child): + existing = await overrides.create( + child.id, "2026-07-20", "2026-07-24", False, note="holiday" + ) + for start, end in ( + ("2026-07-24", "2026-07-26"), # touches the existing end + ("2026-07-18", "2026-07-20"), # touches the existing start + ("2026-07-21", "2026-07-22"), # fully inside + ("2026-07-01", "2026-07-30"), # fully covering + ): + with pytest.raises(ValueError, match="overlaps existing"): + await overrides.create( + child.id, start, end, True, note="swap" + ) + # The error NAMES the conflicting override (id + its range). + with pytest.raises( + ValueError, + match=( + rf"override {existing.id} " + rf"\[2026-07-20, 2026-07-24\].*is_present=False" + ), + ): + await overrides.create(child.id, "2026-07-22", "2026-07-22", True) + # Nothing was written by any rejected attempt. + listed = await overrides.list_by_child_and_range( + child.id, "2026-01-01", "2026-12-31" + ) + assert [row.id for row in listed] == [existing.id] + return existing + + _with_db(tmp_path, "override-overlap.db")(_body) + + +def test_override_overlap_is_per_child(tmp_path) -> None: + """Different children's overrides may cover the same dates: the + conflict rule is per child.""" + async def _body(database, schedules, overrides, children, child): + other = await children.create("Bo", NOW) + await overrides.create(child.id, "2026-07-20", "2026-07-24", False) + twin = await overrides.create( + other.id, "2026-07-20", "2026-07-24", True + ) + assert twin.child_id == other.id + listed = await overrides.list_by_child_and_range( + other.id, "2026-01-01", "2026-12-31" + ) + assert [row.id for row in listed] == [twin.id] + return twin + + _with_db(tmp_path, "override-per-child.db")(_body) + + +def test_override_remove_then_recreate_overlapping(tmp_path) -> None: + async def _body(database, schedules, overrides, children, child): + first = await overrides.create( + child.id, "2026-07-20", "2026-07-24", False + ) + assert await overrides.delete(first.id) is True + assert await overrides.delete(first.id) is False + replacement = await overrides.create( + child.id, "2026-07-20", "2026-07-24", True, note="swap back" + ) + assert replacement.is_present is True + return replacement + + _with_db(tmp_path, "override-recreate.db")(_body) + + +def test_override_create_racing_conflicts_serialize(tmp_path) -> None: + """Two racing creates for the same child and dates: exactly one + lands, the other is rejected with the overlap error (the check and + the INSERT share one transaction under the connection lock).""" + import asyncio as asyncio_module + + async def _main(): + from unittest.mock import AsyncMock, MagicMock + + from custom_components.nestquest.dao_children import ChildrenDao + from custom_components.nestquest.dao_presence import ( + PresenceOverridesDao, + ) + from custom_components.nestquest.db import NestQuestDatabase + from custom_components.nestquest.migrations import apply_migrations + + hass = MagicMock() + hass.async_add_executor_job = AsyncMock( + side_effect=(lambda fn, *a: fn(*a)) + ) + database = NestQuestDatabase(hass) + await database.open(tmp_path / "override-race.db") + try: + await apply_migrations(database) + children = ChildrenDao(database) + overrides = PresenceOverridesDao(database) + child = await children.create("Ada", NOW) + results = await asyncio_module.gather( + overrides.create( + child.id, "2026-07-20", "2026-07-24", True + ), + overrides.create( + child.id, "2026-07-22", "2026-07-26", False + ), + return_exceptions=True, + ) + outcomes = sorted( + "ok" if not isinstance(r, BaseException) else type(r).__name__ + for r in results + ) + assert outcomes == ["ValueError", "ok"], ( + f"exactly one create must land: {outcomes}" + ) + listed = await overrides.list_by_child_and_range( + child.id, "2026-01-01", "2026-12-31" + ) + assert len(listed) == 1 + finally: + await database.close() + + asyncio.new_event_loop().run_until_complete(_main()) From 51b1388233fc5cb9ed05653985fcec275da273ff Mon Sep 17 00:00:00 2001 From: Joshua King Date: Tue, 15 Sep 2026 18:03:49 -0400 Subject: [PATCH 08/18] Fix: inverted ranges skip the overlap probe and hit the schema CHECK The matrix test's end-before-start probe (expecting the schema's end>=start IntegrityError) hit the new overlap query first, since an inverted range lexically 'overlaps' the seeded row. The overlap check now runs only for well-ordered ranges; invalid ones fall through to the INSERT and keep the IntegrityError contract. Suite: 990 passed. --- custom_components/nestquest/dao_presence.py | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/custom_components/nestquest/dao_presence.py b/custom_components/nestquest/dao_presence.py index 6bb5ddd..5279a25 100644 --- a/custom_components/nestquest/dao_presence.py +++ b/custom_components/nestquest/dao_presence.py @@ -205,12 +205,20 @@ class PresenceOverridesDao: ) if child is None: raise ValueError(f"child {child_id} does not exist") - conflicting = await self._database.fetch_one( - f"SELECT {_OVERRIDE_COLUMNS} FROM presence_overrides " - "WHERE child_id = ? AND end_date >= ? " - "AND start_date <= ? ORDER BY id LIMIT 1", - (child_id, start_date, end_date), - ) + # Only a well-ordered range has overlap semantics: + # inverted ranges fall through to the INSERT and hit + # the schema's end>=start CHECK, preserving the + # IntegrityError contract the matrix tests rely on. + if end_date >= start_date: + conflicting = await self._database.fetch_one( + f"SELECT {_OVERRIDE_COLUMNS} " + "FROM presence_overrides " + "WHERE child_id = ? AND end_date >= ? " + "AND start_date <= ? ORDER BY id LIMIT 1", + (child_id, start_date, end_date), + ) + else: + conflicting = None if conflicting is not None: conflict = _override_from_row(conflicting) raise ValueError( From 94f54523f931dd68b081c4e7071f968485eb0376 Mon Sep 17 00:00:00 2001 From: Joshua King Date: Tue, 15 Sep 2026 18:06:44 -0400 Subject: [PATCH 09/18] Review fix: gate the override race test inside A's transaction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review round 1 (Codex gpt-5.6-terra, LOW): the racing test completed synchronously and proved nothing. The executor now gates task A's INSERT — inside its open transaction, after its overlap probe — so task B queues on the connection lock, re-runs the probe against A's committed row, and is rejected; exactly one lands. Suite: 990 passed. --- tests/test_dao_presence.py | 64 +++++++++++++++++++++++++------------- 1 file changed, 43 insertions(+), 21 deletions(-) diff --git a/tests/test_dao_presence.py b/tests/test_dao_presence.py index 85e381d..0d86cb8 100644 --- a/tests/test_dao_presence.py +++ b/tests/test_dao_presence.py @@ -718,13 +718,15 @@ def test_override_remove_then_recreate_overlapping(tmp_path) -> None: def test_override_create_racing_conflicts_serialize(tmp_path) -> None: """Two racing creates for the same child and dates: exactly one - lands, the other is rejected with the overlap error (the check and - the INSERT share one transaction under the connection lock).""" + lands, the other is rejected with the overlap error. DETERMINISTIC + gate: task A is paused at its INSERT — inside its open transaction, + after its overlap probe — so task B is created while A provably + holds the connection lock and must queue; B then re-runs the probe + against A's committed row and is rejected.""" import asyncio as asyncio_module + from unittest.mock import MagicMock async def _main(): - from unittest.mock import AsyncMock, MagicMock - from custom_components.nestquest.dao_children import ChildrenDao from custom_components.nestquest.dao_presence import ( PresenceOverridesDao, @@ -732,10 +734,24 @@ def test_override_create_racing_conflicts_serialize(tmp_path) -> None: from custom_components.nestquest.db import NestQuestDatabase from custom_components.nestquest.migrations import apply_migrations + armed = {"active": False, "gated": False} + gate_open = asyncio_module.Event() + a_started = asyncio_module.Event() + hass = MagicMock() - hass.async_add_executor_job = AsyncMock( - side_effect=(lambda fn, *a: fn(*a)) - ) + + async def _gated_executor(fn, *args): + if armed["active"]: + # Gate the FIRST _execute after arming: task A's INSERT, + # which runs inside its open transaction (BEGIN already + # executed) after the overlap probe. + if getattr(fn, "__name__", "") == "_execute" and not armed["gated"]: + armed["gated"] = True + a_started.set() + await gate_open.wait() + return fn(*args) + + hass.async_add_executor_job = _gated_executor database = NestQuestDatabase(hass) await database.open(tmp_path / "override-race.db") try: @@ -743,26 +759,32 @@ def test_override_create_racing_conflicts_serialize(tmp_path) -> None: children = ChildrenDao(database) overrides = PresenceOverridesDao(database) child = await children.create("Ada", NOW) - results = await asyncio_module.gather( - overrides.create( - child.id, "2026-07-20", "2026-07-24", True - ), - overrides.create( - child.id, "2026-07-22", "2026-07-26", False - ), - return_exceptions=True, + + armed["active"] = True + task_a = asyncio_module.ensure_future( + overrides.create(child.id, "2026-07-20", "2026-07-24", True) ) - outcomes = sorted( - "ok" if not isinstance(r, BaseException) else type(r).__name__ - for r in results + await a_started.wait() + armed["active"] = False + task_b = asyncio_module.ensure_future( + overrides.create(child.id, "2026-07-22", "2026-07-26", False) ) - assert outcomes == ["ValueError", "ok"], ( - f"exactly one create must land: {outcomes}" + await asyncio_module.sleep(0) + gate_open.set() + record_a, error_b = await asyncio_module.gather( + task_a, task_b, return_exceptions=True ) + assert isinstance(record_a, object) and not isinstance( + record_a, BaseException + ), f"A must land: {record_a!r}" + assert isinstance(error_b, ValueError), ( + f"B must be rejected with the overlap error: {error_b!r}" + ) + assert "overlaps existing" in str(error_b) listed = await overrides.list_by_child_and_range( child.id, "2026-01-01", "2026-12-31" ) - assert len(listed) == 1 + assert [row.id for row in listed] == [record_a.id] finally: await database.close() From 8f04d93363d815119df5d658fb80b52c1b3fc677 Mon Sep 17 00:00:00 2001 From: Joshua King Date: Tue, 15 Sep 2026 18:12:16 -0400 Subject: [PATCH 10/18] Overrides take precedence over the pattern (Feature 05, task 5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PresenceEngine gains an overrides snapshot (child_id -> list of PresenceOverride): is_present checks covering overrides BEFORE the pattern and returns the override's flag as the answer — an absent override on a normally-present day returns False, a present one on a normally-absent day returns True, and an override on a child with no schedule still applies (holidays work for the always-present sibling). Snapshot validation mirrors the schedules': dict of lists keyed by the override's own child id, exposed read-only. The DAO layer guarantees no overlapping overrides per child; a hand-built snapshot that carries them resolves deterministically to the LATEST start date (documented). Tests: all three precedence cases, span edges (outside days stay on the pattern), snapshot rejection matrix, deterministic overlap tie-break. Suite: 995 passed. --- custom_components/nestquest/presence.py | 51 ++++++++++++- tests/test_presence.py | 98 +++++++++++++++++++++++++ 2 files changed, 147 insertions(+), 2 deletions(-) diff --git a/custom_components/nestquest/presence.py b/custom_components/nestquest/presence.py index 907ad8d..9dabf0d 100644 --- a/custom_components/nestquest/presence.py +++ b/custom_components/nestquest/presence.py @@ -332,7 +332,11 @@ class PresenceEngine: :func:`cycle_week_index`, never ISO week parity). """ - def __init__(self, schedules: dict[int, PresenceSchedule]) -> None: + def __init__( + self, + schedules: dict[int, PresenceSchedule], + overrides: dict[int, list[PresenceOverride]] | None = None, + ) -> None: if not isinstance(schedules, dict): raise ValueError( f"schedules must map child_id to PresenceSchedule, got " @@ -353,16 +357,59 @@ class PresenceEngine: f"schedules is keyed {child_id!r} but holds the " f"schedule of child {schedule.child_id}" ) + if overrides is None: + overrides = {} + if not isinstance(overrides, dict): + raise ValueError( + "overrides must map child_id to a list of " + f"PresenceOverride, got {overrides!r}" + ) + for child_id, entries in overrides.items(): + _validate_child_id(child_id) + if not isinstance(entries, list): + raise ValueError( + f"overrides[{child_id!r}] must be a list of " + f"PresenceOverride, got {entries!r}" + ) + for entry in entries: + if not isinstance(entry, PresenceOverride): + raise ValueError( + f"overrides[{child_id!r}] entries must be " + f"PresenceOverride, got {entry!r}" + ) + if entry.child_id != child_id: + raise ValueError( + f"overrides is keyed {child_id!r} but holds an " + f"override of child {entry.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))) + object.__setattr__( + self, "_overrides", MappingProxyType(dict(overrides)) + ) def is_present(self, child_id: object, date: object) -> bool: - """Return whether ``child_id`` is present on ``date``.""" + """Return whether ``child_id`` is present on ``date``. + + Overrides take precedence over the repeating pattern: when an + override covers the date, its flag IS the answer — including + for a child with no schedule at all. The DAO layer guarantees + a child's overrides never overlap; if a snapshot still carries + two covering the same date, the one with the LATEST start date + wins (deterministic, and matches "the most specific swap wins"). + """ 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") + entries = self._overrides.get(child_id) or [] + covering = [ + entry for entry in entries if entry.covers(target) + ] + if covering: + winner = max(covering, key=lambda entry: entry.start_date) + return winner.is_present schedule = self._schedules.get(child_id) if schedule is None: # No schedule at all: present every day (household default). diff --git a/tests/test_presence.py b/tests/test_presence.py index b38727e..284eadf 100644 --- a/tests/test_presence.py +++ b/tests/test_presence.py @@ -476,3 +476,101 @@ def test_engine_is_immutable() -> None: engine._schedules = {} with pytest.raises(AttributeError, match="immutable snapshot"): engine.something_new = 1 + + +# --------------------------------------------------------------------------- +# Overrides take precedence over the pattern +# --------------------------------------------------------------------------- + + +def _engine_with_overrides(*overrides_for_declan) -> PresenceEngine: + anchor = datetime.date(2026, 1, 5) # Monday + return PresenceEngine( + { + 1: PresenceSchedule( + 1, 2, anchor, {0: {0, 1, 2, 3, 4, 5, 6}, 1: set()} + ) + }, + overrides={1: list(overrides_for_declan)} if overrides_for_declan else {}, + ) + + +def test_absent_override_on_normally_present_day_wins() -> None: + # 2026-01-06 is Tuesday of Declan's week 0: normally present. + engine = _engine_with_overrides( + PresenceOverride(1, "2026-01-06", "2026-01-06", False, note="away") + ) + assert engine.is_present(1, datetime.date(2026, 1, 6)) is False + # The pattern resumes outside the override. + assert engine.is_present(1, datetime.date(2026, 1, 7)) is True + + +def test_present_override_on_normally_absent_day_wins() -> None: + # 2026-01-13 is in Declan's week 1 (away all week); a present + # override on the Tuesday of week 1 brings him home for that day. + engine = _engine_with_overrides( + PresenceOverride(1, "2026-01-13", "2026-01-13", True, note="trade") + ) + assert engine.is_present(1, datetime.date(2026, 1, 13)) is True + assert engine.is_present(1, datetime.date(2026, 1, 14)) is False + + +def test_override_applies_without_a_schedule() -> None: + """An override on a schedule-free child still applies: holidays and + swaps must work for the always-present sibling too.""" + engine = PresenceEngine( + {}, + overrides={ + 3: [ + PresenceOverride( + 3, "2026-07-20", "2026-07-24", False, note="holiday" + ) + ] + }, + ) + assert engine.is_present(3, datetime.date(2026, 7, 22)) is False + assert engine.is_present(3, datetime.date(2026, 7, 19)) is True + assert engine.is_present(3, datetime.date(2026, 7, 25)) is True + + +def test_override_range_beats_pattern_for_its_span() -> None: + engine = _engine_with_overrides( + PresenceOverride(1, "2026-01-13", "2026-01-17", True, note="traded week") + ) + for offset in range(12, 17): + day = datetime.date(2026, 1, 1) + datetime.timedelta(days=offset) + assert engine.is_present(1, day) is True, f"{day}" + # Days outside the span stay on the pattern (week 1: absent). + assert engine.is_present(1, datetime.date(2026, 1, 12)) is False + assert engine.is_present(1, datetime.date(2026, 1, 18)) is False + + +def test_engine_override_snapshot_validation() -> None: + schedule = PresenceSchedule(1, 1, ANCHOR, {0: {0}}) + entry = PresenceOverride(1, "2026-07-20", "2026-07-20", True) + with pytest.raises(ValueError, match="must map child_id"): + PresenceEngine({1: schedule}, overrides=[entry]) + with pytest.raises(ValueError, match="must be a list"): + PresenceEngine({1: schedule}, overrides={1: entry}) + with pytest.raises(ValueError, match="must be"): + PresenceEngine({1: schedule}, overrides={1: ["nope"]}) + with pytest.raises(ValueError, match="holds an override"): + PresenceEngine({1: schedule}, overrides={2: [entry]}) + with pytest.raises(ValueError, match="child_id must be an integer"): + PresenceEngine({1: schedule}, overrides={True: [entry]}) + + +def test_engine_overlapping_snapshot_uses_latest_start() -> None: + """The DAO layer refuses overlapping overrides per child; if a + snapshot carries them anyway (hand-built), the latest start date + wins deterministically.""" + engine = _engine_with_overrides( + PresenceOverride(1, "2026-01-06", "2026-01-10", False), + PresenceOverride(1, "2026-01-08", "2026-01-12", True), + ) + # Jan 6-7: only the earlier override covers -> absent. + assert engine.is_present(1, datetime.date(2026, 1, 6)) is False + # Jan 8-10: both cover; the LATER start (present) wins. + assert engine.is_present(1, datetime.date(2026, 1, 9)) is True + # Jan 11-12: only the later override covers -> present. + assert engine.is_present(1, datetime.date(2026, 1, 11)) is True From bff2f4ff5e8c0702a489b2cd9280c63876836ce7 Mon Sep 17 00:00:00 2001 From: Joshua King Date: Tue, 15 Sep 2026 18:13:48 -0400 Subject: [PATCH 11/18] Review fix: freeze the engine's overrides snapshot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review round 1 (Codex gpt-5.6-terra, MEDIUM): the overrides mapping was shallow-copied, leaving caller-owned lists mutable through _overrides — a later append would change is_present results. Each validated list is copied into an immutable tuple before wrapping; tests prove post-construction appends change nothing and the snapshot refuses mutation. Suite: 996 passed. --- custom_components/nestquest/presence.py | 11 +++++++++-- tests/test_presence.py | 16 ++++++++++++++++ 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/custom_components/nestquest/presence.py b/custom_components/nestquest/presence.py index 9dabf0d..ff233b4 100644 --- a/custom_components/nestquest/presence.py +++ b/custom_components/nestquest/presence.py @@ -384,10 +384,17 @@ class PresenceEngine: ) # Read-only snapshot: reassignment is refused and no mutator # exists, so a built engine answers consistently for its - # lifetime. + # lifetime. The overrides lists are copied to immutable + # tuples — a shallow mapping copy alone would leave caller- + # owned lists reachable through _overrides, and mutating one + # later would change is_present results. object.__setattr__(self, "_schedules", MappingProxyType(dict(schedules))) object.__setattr__( - self, "_overrides", MappingProxyType(dict(overrides)) + self, + "_overrides", + MappingProxyType( + {child_id: tuple(entries) for child_id, entries in overrides.items()} + ), ) def is_present(self, child_id: object, date: object) -> bool: diff --git a/tests/test_presence.py b/tests/test_presence.py index 284eadf..c47ada6 100644 --- a/tests/test_presence.py +++ b/tests/test_presence.py @@ -574,3 +574,19 @@ def test_engine_overlapping_snapshot_uses_latest_start() -> None: assert engine.is_present(1, datetime.date(2026, 1, 9)) is True # Jan 11-12: only the later override covers -> present. assert engine.is_present(1, datetime.date(2026, 1, 11)) is True + + +def test_engine_override_lists_are_frozen() -> None: + """The overrides snapshot must be truly read-only: mutating the + caller's list after construction must not change answers.""" + entry = PresenceOverride(1, "2026-01-06", "2026-01-06", False) + entries = [entry] + engine = _engine_with_overrides(*entries) + assert engine.is_present(1, datetime.date(2026, 1, 6)) is False + entries.append(PresenceOverride(1, "2026-01-07", "2026-01-07", False)) + # The late append must NOT change the engine's answers... + assert engine.is_present(1, datetime.date(2026, 1, 7)) is True + with pytest.raises(TypeError): + engine._overrides[1].append(entry) + with pytest.raises(AttributeError, match="immutable snapshot"): + engine._overrides = {} From 830d663a457c9b0a12364dc18459458cb98972de Mon Sep 17 00:00:00 2001 From: Joshua King Date: Tue, 15 Sep 2026 18:15:13 -0400 Subject: [PATCH 12/18] Test fix: tuples raise AttributeError for append, not TypeError The frozen-snapshot test expected TypeError from tuple.append; tuples have no append at all. Suite green BEFORE commit: 997 passed. --- tests/test_presence.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/test_presence.py b/tests/test_presence.py index c47ada6..d08b108 100644 --- a/tests/test_presence.py +++ b/tests/test_presence.py @@ -586,7 +586,9 @@ def test_engine_override_lists_are_frozen() -> None: entries.append(PresenceOverride(1, "2026-01-07", "2026-01-07", False)) # The late append must NOT change the engine's answers... assert engine.is_present(1, datetime.date(2026, 1, 7)) is True - with pytest.raises(TypeError): + # Tuples have no append at all (AttributeError), and the mapping + # itself refuses reassignment. + with pytest.raises(AttributeError): engine._overrides[1].append(entry) with pytest.raises(AttributeError, match="immutable snapshot"): engine._overrides = {} From bdf3513609065fdb16af4b12be29466991923954 Mon Sep 17 00:00:00 2001 From: Joshua King Date: Tue, 15 Sep 2026 18:16:40 -0400 Subject: [PATCH 13/18] Test fix: build the frozen-snapshot engine on the caller's list Review round 3 (Codex gpt-5.6-terra, LOW): the helper copied the list, so the mutation could never have affected even a shallow snapshot. The engine is now constructed directly on the caller's list, making the freeze regression-proof. Suite green before commit: 997 passed. --- tests/test_presence.py | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/tests/test_presence.py b/tests/test_presence.py index d08b108..ffcb7c7 100644 --- a/tests/test_presence.py +++ b/tests/test_presence.py @@ -577,11 +577,18 @@ def test_engine_overlapping_snapshot_uses_latest_start() -> None: def test_engine_override_lists_are_frozen() -> None: - """The overrides snapshot must be truly read-only: mutating the - caller's list after construction must not change answers.""" + """The overrides snapshot must be truly read-only: the engine is + built directly on the CALLER's list, so mutating that list after + construction would change answers under a shallow copy — under the + frozen snapshot it must not.""" entry = PresenceOverride(1, "2026-01-06", "2026-01-06", False) entries = [entry] - engine = _engine_with_overrides(*entries) + schedule = PresenceSchedule( + 1, 2, ANCHOR, {0: {0, 1, 2, 3, 4, 5, 6}, 1: set()} + ) + engine = PresenceEngine( + {1: schedule}, overrides={1: entries} # caller-owned list + ) assert engine.is_present(1, datetime.date(2026, 1, 6)) is False entries.append(PresenceOverride(1, "2026-01-07", "2026-01-07", False)) # The late append must NOT change the engine's answers... From 1bb061747122c5da0bc193dc7578ff8e53cfdff3 Mon Sep 17 00:00:00 2001 From: Joshua King Date: Tue, 15 Sep 2026 18:19:53 -0400 Subject: [PATCH 14/18] Next-N-present-days preview (Feature 05, task 6) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PresenceEngine.next_present_dates(child_id, start_date, count): walks forward from the INCLUSIVE start date through is_present, so overrides and the pattern are honoured identically — this is the preview the admin Schedule tab renders before a parent saves a custody pattern. Count must be a positive int; the scan is capped at two years (MAX_PREVIEW_SCAN_DAYS) and an always-absent child raises a clear ValueError instead of looping forever. Tests: known two-week pattern (straight five-day week, mid-week start skipping the absent week), inclusive start, override blocking the preview start, schedule-free child, input validation, always-absent error. Suite: 1002 passed. --- custom_components/nestquest/presence.py | 46 ++++++++++ tests/test_presence.py | 117 ++++++++++++++++++++++++ 2 files changed, 163 insertions(+) diff --git a/custom_components/nestquest/presence.py b/custom_components/nestquest/presence.py index ff233b4..465c105 100644 --- a/custom_components/nestquest/presence.py +++ b/custom_components/nestquest/presence.py @@ -319,6 +319,13 @@ def cycle_week_index( 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: """Answers "is this child at this house on this date?". @@ -424,6 +431,45 @@ class PresenceEngine: week = cycle_week_index(schedule, target) 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: raise AttributeError( "PresenceEngine is an immutable snapshot; build a new engine " diff --git a/tests/test_presence.py b/tests/test_presence.py index ffcb7c7..6ff8d06 100644 --- a/tests/test_presence.py +++ b/tests/test_presence.py @@ -599,3 +599,120 @@ def test_engine_override_lists_are_frozen() -> None: engine._overrides[1].append(entry) with pytest.raises(AttributeError, match="immutable snapshot"): 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) + assert preview == [ + datetime.date(2026, 1, 7), + datetime.date(2026, 1, 8), + datetime.date(2026, 1, 9), + datetime.date(2026, 1, 14), + datetime.date(2026, 1, 15), + datetime.date(2026, 1, 16), + ] + + +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) From e9dc0a144564050bad7234f304b472dbfc5749ed Mon Sep 17 00:00:00 2001 From: Joshua King Date: Tue, 15 Sep 2026 18:20:42 -0400 Subject: [PATCH 15/18] Test fix: week 0 runs Mon Jan 5 through Sun Jan 11 The six-day preview expectation missed that Jan 10-11 are still in the present week (the away week starts Jan 12). Suite green before commit: 1003 passed. --- tests/test_presence.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/tests/test_presence.py b/tests/test_presence.py index 6ff8d06..250c7a8 100644 --- a/tests/test_presence.py +++ b/tests/test_presence.py @@ -640,13 +640,15 @@ def test_preview_returns_the_next_present_days_of_a_known_pattern() -> None: 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, 14), - datetime.date(2026, 1, 15), - datetime.date(2026, 1, 16), + datetime.date(2026, 1, 10), + datetime.date(2026, 1, 11), + datetime.date(2026, 1, 19), ] From 20d7efb04bf96e4515eb88ca3bb42b506f102f00 Mon Sep 17 00:00:00 2001 From: Joshua King Date: Tue, 15 Sep 2026 18:25:32 -0400 Subject: [PATCH 16/18] Year-boundary and leap-year regression guard (Feature 05, task 7) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The week-parity regression test (D-004): walks a two-week alternating rotation day by day across a FULL 366-day leap year (2020) and a full 365-day year with 53 ISO weeks (2015), asserting the present-day sequence never breaks or inverts, including January 1st. The expectation is derived from the anchor's 7-day blocks — each block uniform, consecutive blocks alternating — not from the engine's own index formula, so a parity-based implementation that resets on January 1st breaks block uniformity or alternation (most visibly in the block spanning the boundary). Year facts asserted: 2020 = 366 days and ISO week 53; 2015 = 365 days and ISO week 53. The test docstring names itself as the regression guard. Suite: 1004 passed. --- tests/test_presence.py | 74 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 74 insertions(+) diff --git a/tests/test_presence.py b/tests/test_presence.py index 250c7a8..d545548 100644 --- a/tests/test_presence.py +++ b/tests/test_presence.py @@ -718,3 +718,77 @@ 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}), asserting the present/absent + sequence never breaks or inverts at any point, including January + 1st. ISO week parity silently flips in 53-ISO-week years and would + invert every custody schedule on January 1st; 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 + + anchor = datetime.date(year, 1, 6) # a Monday of an on-week + 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. + day = datetime.date(year, 1, 1) + days_seen = 0 + while day.year == year: + 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 (even outside the year) must agree — + # this is what catches an inversion hidden at a year boundary. + for offset in range(7): + block_day = block_start + datetime.timedelta(days=offset) + if block_day.year == year: + 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) + assert days_seen == length, ( + f"walked {days_seen} days of {year}, expected {length}" + ) From a18c0060e4af88ab563bf886b9fd2e79b94c8d31 Mon Sep 17 00:00:00 2001 From: Joshua King Date: Tue, 15 Sep 2026 18:28:39 -0400 Subject: [PATCH 17/18] Review fix: extend the parity walk through the following January Review round 1 (Codex gpt-5.6-terra, MEDIUM): the walk stopped at December 31st, so the boundary block's next-January days went unchecked and a year-resetting implementation would pass. The walk now runs through January 14th of the following year, asserting block uniformity across the December 31st -> January 1st transition (including the post-53-week jump), and the walked-year day count is verified independently of the walk. Suite: 1005 passed. --- tests/test_presence.py | 27 ++++++++++++++++++++------- 1 file changed, 20 insertions(+), 7 deletions(-) diff --git a/tests/test_presence.py b/tests/test_presence.py index d545548..dcfe73b 100644 --- a/tests/test_presence.py +++ b/tests/test_presence.py @@ -765,9 +765,16 @@ def test_custody_rotation_walks_full_years_without_inversion( # 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. - day = datetime.date(year, 1, 1) + # 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 - while day.year == year: + 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) @@ -779,16 +786,22 @@ def test_custody_rotation_walks_full_years_without_inversion( f"{(day - block_start).days}): present=" f"{engine.is_present(1, day)}, expected {expected}" ) - # The block's OTHER days (even outside the year) must agree — - # this is what catches an inversion hidden at a year boundary. + # 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 block_day.year == year: + 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) - assert days_seen == length, ( - f"walked {days_seen} days of {year}, expected {length}" + # 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}" ) From fec607f039f3e58a7e88ae7411959f7e7844cdbe Mon Sep 17 00:00:00 2001 From: Joshua King Date: Tue, 15 Sep 2026 18:31:38 -0400 Subject: [PATCH 18/18] Review fixes: correct 2015 anchor weekday and ISO rollover facts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review round 2 (Codex gpt-5.6-terra, 2 LOW): 2015-01-06 is a Tuesday, not a Monday — the anchor is now the computed first Monday of January (asserted weekday 0); the docstring names the ISO-week rollover precisely (the Monday on or after January 4th — January 4th in both walked years, which end ISO week 53) instead of 'January 1st'. Suite: 1005 passed. --- tests/test_presence.py | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/tests/test_presence.py b/tests/test_presence.py index dcfe73b..14db444 100644 --- a/tests/test_presence.py +++ b/tests/test_presence.py @@ -738,10 +738,12 @@ def test_custody_rotation_walks_full_years_without_inversion( """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}), asserting the present/absent - sequence never breaks or inverts at any point, including January - 1st. ISO week parity silently flips in 53-ISO-week years and would - invert every custody schedule on January 1st; anchor-date + {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: @@ -751,7 +753,12 @@ def test_custody_rotation_walks_full_years_without_inversion( assert datetime.date(2015, 12, 31).isocalendar()[1] == 53 assert (datetime.date(2016, 1, 1) - datetime.date(2015, 1, 1)).days == 365 - anchor = datetime.date(year, 1, 6) # a Monday of an on-week + # 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()} )