From d76cde1af5282acb93907df7d87f3a22c78ee539 Mon Sep 17 00:00:00 2001 From: Joshua King Date: Tue, 15 Sep 2026 17:43:09 -0400 Subject: [PATCH] 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)