diff --git a/custom_components/grow_conductor/core/__init__.py b/custom_components/grow_conductor/core/__init__.py new file mode 100644 index 0000000..9696d67 --- /dev/null +++ b/custom_components/grow_conductor/core/__init__.py @@ -0,0 +1 @@ +"""Pure scheduling core — no homeassistant imports allowed (see docs/ENGINE_SPEC.md).""" diff --git a/custom_components/grow_conductor/core/budget.py b/custom_components/grow_conductor/core/budget.py new file mode 100644 index 0000000..7d70c8b --- /dev/null +++ b/custom_components/grow_conductor/core/budget.py @@ -0,0 +1,103 @@ +"""Plant-day budget ledger (ENGINE_SPEC §2, §5).""" + +from __future__ import annotations + +from datetime import datetime, timedelta + +from .tunables import Tunables + + +class PlantDayLedger: + """Tracks delivered light per plant day, accruing from reported truth (2.2). + + All methods take aware ``datetime``s supplied by the caller; the + ledger never reads clocks. Callers must feed time monotonically — + the adapter guarantees this by stamping every event on arrival. + """ + + def __init__(self, tunables: Tunables) -> None: + self._tunables = tunables + self._day_start: datetime | None = None + self._spent_s = 0.0 + self._lit_since: datetime | None = None + + # -- plant-day geometry ------------------------------------------------- + + def anchor_before(self, now: datetime) -> datetime: + """The most recent anchor at or before ``now`` (rule 2.1).""" + minutes = self._tunables.anchor_minutes + candidate = now.replace(hour=minutes // 60, minute=minutes % 60, second=0, microsecond=0) + if candidate > now: + candidate -= timedelta(days=1) + return candidate + + def next_anchor(self, now: datetime) -> datetime: + return self.anchor_before(now) + timedelta(days=1) + + @property + def day_start(self) -> datetime | None: + return self._day_start + + @property + def lit(self) -> bool: + return self._lit_since is not None + + # -- accrual -------------------------------------------------------------- + + def roll(self, now: datetime) -> None: + """Advance to the plant day containing ``now``. + + Crossing an anchor charges the pre-anchor part of an open block to + the old day and resets spent for the new one; the block itself + keeps burning (rule 2.4). + """ + current = self.anchor_before(now) + if self._day_start is None: + self._day_start = current + return + if current > self._day_start: + if self._lit_since is not None: + self._lit_since = max(self._lit_since, current) + self._spent_s = 0.0 + self._day_start = current + + def light_reported(self, on: bool | None, now: datetime) -> None: + """Fold a physical light-state report into the ledger (rule 2.2). + + ``None`` (unknown) closes any open block: nothing accrues while + the light state is unknown. + """ + self.roll(now) + if on and self._lit_since is None: + self._lit_since = now + elif not on and self._lit_since is not None: + self._spent_s += (now - self._lit_since).total_seconds() + self._lit_since = None + + def spent_s(self, now: datetime) -> float: + """Seconds delivered so far this plant day, including any open block.""" + self.roll(now) + open_s = (now - self._lit_since).total_seconds() if self._lit_since is not None else 0.0 + return self._spent_s + open_s + + def headroom_s(self, now: datetime, target_s: float) -> float: + return max(0.0, target_s - self.spent_s(now)) + + def cap_instant(self, now: datetime, target_s: float) -> datetime: + """The instant the budget runs out if the light burns from ``now`` on.""" + return now + timedelta(seconds=self.headroom_s(now, target_s)) + + # -- persistence (rule 5.1) ------------------------------------------------ + + def seed(self, day_start: datetime, spent_s: float, now: datetime) -> bool: + """Adopt a persisted (day_start, spent) pair if it is still current. + + Returns True when adopted. A seed from a different plant day is + discarded and the ledger starts fresh (rule 5.1). + """ + if day_start != self.anchor_before(now): + self.roll(now) + return False + self._day_start = day_start + self._spent_s = max(0.0, spent_s) + return True diff --git a/custom_components/grow_conductor/core/engine.py b/custom_components/grow_conductor/core/engine.py new file mode 100644 index 0000000..9104c38 --- /dev/null +++ b/custom_components/grow_conductor/core/engine.py @@ -0,0 +1,216 @@ +"""The event-driven scheduling engine (ENGINE_SPEC §1-§5). + +The adapter feeds events stamped with aware datetimes; the engine never +reads clocks (deterministic and directly testable). Every mutation +returns the fresh :class:`Decision`; between events the decision is +stable until ``next_review`` (rule 3.2). +""" + +from __future__ import annotations + +from datetime import datetime, timedelta + +from .budget import PlantDayLedger +from .model import Decision, InputSnapshot, LightState, Reason +from .tunables import DEFAULT_TARGET_HOURS, Tunables, clamp_target_hours +from .visibility import RefugeView, Verdict, ViewerMemory, assess + + +class Engine: + def __init__( + self, + tunables: Tunables | None = None, + target_hours: float = DEFAULT_TARGET_HOURS, + ) -> None: + self._tunables = tunables or Tunables() + self._target_s = clamp_target_hours(target_hours) * 3600.0 + self._ledger = PlantDayLedger(self._tunables) + self._snapshot = InputSnapshot() + # Viewer memory (rules 1.3/1.3b/1.3c). + self._viewer_active_since: datetime | None = None + self._viewer_last_end: datetime | None = None + self._viewer_sustained_end: datetime | None = None + # Refuge memory (rule 1.7). + self._refuge_since: datetime | None = None + self._refuge_last_active: datetime | None = None + self._refuge_confirmed = False + self._enabled = True + self._light_on: bool | None = None + self._decision: Decision | None = None + + # -- read surface --------------------------------------------------------- + + @property + def tunables(self) -> Tunables: + return self._tunables + + @property + def enabled(self) -> bool: + return self._enabled + + @property + def target_hours(self) -> float: + return self._target_s / 3600.0 + + @property + def snapshot(self) -> InputSnapshot: + return self._snapshot + + @property + def light_on(self) -> bool | None: + return self._light_on + + @property + def decision(self) -> Decision | None: + """The most recent decision (None before the first event).""" + return self._decision + + def spent_s(self, now: datetime) -> float: + return self._ledger.spent_s(now) + + def day_start(self, now: datetime) -> datetime: + self._ledger.roll(now) + assert self._ledger.day_start is not None + return self._ledger.day_start + + # -- events ----------------------------------------------------------------- + + def handle_snapshot(self, snapshot: InputSnapshot, now: datetime) -> Decision: + """Fold a normalized input snapshot into the engine.""" + previous = self._snapshot + # Rule 1.3: track viewer episodes edge-to-edge, so holds count + # from the end of activity and 1.3c can measure episode length. + if snapshot.viewer_active and not previous.viewer_active: + self._viewer_active_since = now + elif not snapshot.viewer_active and previous.viewer_active: + self._viewer_last_end = now + started = self._viewer_active_since + if ( + started is not None + and (now - started).total_seconds() >= self._tunables.exposure_grace_s + ): + self._viewer_sustained_end = now + self._viewer_active_since = None + # Rule 1.7: an unconfirmed engagement restarts its confirmation + # clock on every rising edge (continuity requirement); a stale + # confirmed engagement (gap > refuge_hold) starts over entirely. + if snapshot.refuge_active and not previous.refuge_active: + lapsed = ( + self._refuge_last_active is None + or (now - self._refuge_last_active).total_seconds() >= self._tunables.refuge_hold_s + ) + if lapsed or not self._refuge_confirmed: + self._refuge_since = now + self._refuge_confirmed = False + elif not snapshot.refuge_active and previous.refuge_active: + # Falling edge: the evidence was present up to this instant — + # the rule-1.7 hold counts from here. + self._refuge_last_active = now + self._snapshot = snapshot + return self._decide(now) + + def activity_pulse(self, now: datetime) -> Decision: + """Instantaneous viewer activity from a trigger entity (rule 1.3b). + + Stamps rule 1.3's activity clock — cutting the light and starting + the clear-hold — without sustaining activity; the trigger's level + is never part of the snapshot. During confirmed refuge, rule 1.3c + ignores pulses entirely (they are by definition transient). + """ + self._viewer_last_end = now + return self._decide(now) + + def light_reported(self, on: bool | None, now: datetime) -> Decision: + """Fold the physical light state into the ledger (rule 2.2).""" + self._light_on = on + self._ledger.light_reported(on, now) + return self._decide(now) + + def set_target(self, hours: float, now: datetime) -> Decision: + self._target_s = clamp_target_hours(hours) * 3600.0 + return self._decide(now) + + def set_enabled(self, enabled: bool, now: datetime) -> Decision: + self._enabled = enabled + return self._decide(now) + + def tick(self, now: datetime) -> Decision: + """Re-evaluate with no input change (the ``next_review`` timer, 3.2).""" + return self._decide(now) + + def seed_budget(self, day_start: datetime, spent_s: float, now: datetime) -> bool: + """Adopt persisted budget state if still current (rule 5.1).""" + return self._ledger.seed(day_start, spent_s, now) + + # -- decision (rule 3.1) ------------------------------------------------------ + + def _refuge_view(self, now: datetime) -> RefugeView: + """Advance the rule-1.7 confirm latch / hold expiry, then report.""" + if self._snapshot.refuge_active: + self._refuge_last_active = now + if ( + self._refuge_since is not None + and (now - self._refuge_since).total_seconds() >= self._tunables.refuge_confirm_s + ): + self._refuge_confirmed = True + elif ( + self._refuge_last_active is None + or (now - self._refuge_last_active).total_seconds() >= self._tunables.refuge_hold_s + ): + self._refuge_since = None + self._refuge_confirmed = False + + ready_at = None + if self._snapshot.refuge_active and not self._refuge_confirmed: + assert self._refuge_since is not None + ready_at = self._refuge_since + timedelta(seconds=self._tunables.refuge_confirm_s) + held_until = None + if self._refuge_confirmed and not self._snapshot.refuge_active: + assert self._refuge_last_active is not None + held_until = self._refuge_last_active + timedelta(seconds=self._tunables.refuge_hold_s) + return RefugeView(engaged=self._refuge_confirmed, ready_at=ready_at, held_until=held_until) + + def _decide(self, now: datetime) -> Decision: + self._ledger.roll(now) + verdict = assess( + self._snapshot, + now, + ViewerMemory( + active_since=self._viewer_active_since, + last_end=self._viewer_last_end, + sustained_end=self._viewer_sustained_end, + ), + self._refuge_view(now), + self._tunables, + ) + reviews = [self._ledger.next_anchor(now)] + + if not self._enabled: + decision = Decision(False, LightState.STANDBY, None, min(reviews)) + elif not verdict.may_light: + decision = self._decide_dark(verdict, reviews) + else: + decision = self._decide_light(verdict, now, reviews) + + self._decision = decision + return decision + + def _decide_dark(self, verdict: Verdict, reviews: list[datetime]) -> Decision: + if verdict.review_at is not None: + reviews.append(verdict.review_at) + if verdict.cooldown_until is not None: + reviews.append(verdict.cooldown_until) + return Decision(False, LightState.COOLDOWN, None, min(reviews)) + return Decision(False, LightState.OBSERVED, verdict.reason, min(reviews)) + + def _decide_light(self, verdict: Verdict, now: datetime, reviews: list[datetime]) -> Decision: + spent = self._ledger.spent_s(now) + if spent >= self._target_s: # rule 2.3 + return Decision(False, LightState.SATED, Reason.TARGET_REACHED, min(reviews)) + headroom = self._target_s - spent + if self._light_on is not True and headroom < self._tunables.min_block_s: # rule 2.5 + return Decision(False, LightState.SATED, Reason.LOW_HEADROOM, min(reviews)) + if verdict.review_at is not None: # a tolerated exposure may mature (1.3c) + reviews.append(verdict.review_at) + reviews.append(self._ledger.cap_instant(now, self._target_s)) + return Decision(True, LightState.LIT, verdict.reason, min(reviews)) diff --git a/custom_components/grow_conductor/core/model.py b/custom_components/grow_conductor/core/model.py new file mode 100644 index 0000000..0f92502 --- /dev/null +++ b/custom_components/grow_conductor/core/model.py @@ -0,0 +1,59 @@ +"""Value types shared across the core (ENGINE_SPEC §0, §3).""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime +from enum import StrEnum + + +class LightState(StrEnum): + """Display state (rule 3.3).""" + + LIT = "lit" + OBSERVED = "observed" + COOLDOWN = "cooldown" + SATED = "sated" + STANDBY = "standby" + + +class Reason(StrEnum): + """Why the engine is in its display state (rule 3.3).""" + + # LIT + ASLEEP = "asleep" + AWAY = "away" + REFUGE = "refuge" + # OBSERVED + VETO = "veto" + VIEWERS = "viewers" + AWAKE_HOME = "awake_home" + # SATED + TARGET_REACHED = "target_reached" + LOW_HEADROOM = "low_headroom" + + +@dataclass(frozen=True) +class InputSnapshot: + """Normalized input signals (ENGINE_SPEC §1). + + ``None`` means unknown; rule 1.8 defines the fail-safe resolution. + Viewer/refuge/veto aggregation over entity lists happens in the + adapter — the core only sees the OR. + """ + + asleep: bool | None = None + anyone_home: bool | None = None + viewer_active: bool = False + refuge_active: bool = False + veto_active: bool = False + + +@dataclass(frozen=True) +class Decision: + """The engine's verdict, stable until an event or ``next_review`` (3.1, 3.2).""" + + want_on: bool + state: LightState + reason: Reason | None + next_review: datetime | None diff --git a/custom_components/grow_conductor/core/tunables.py b/custom_components/grow_conductor/core/tunables.py new file mode 100644 index 0000000..662f2e7 --- /dev/null +++ b/custom_components/grow_conductor/core/tunables.py @@ -0,0 +1,38 @@ +"""Engine tunables (ENGINE_SPEC §6).""" + +from __future__ import annotations + +from dataclasses import dataclass + +MAX_TARGET_HOURS = 20.0 # keeps a >= 4 h dark period every plant day (§6) +DEFAULT_TARGET_HOURS = 12.0 + + +@dataclass(frozen=True) +class Tunables: + """Validated engine settings; adapter defaults live in const.py.""" + + anchor_minutes: int = 22 * 60 # plant-day boundary, minutes after local midnight + clear_hold_s: float = 600.0 # rule 1.3 + refuge_confirm_s: float = 180.0 # rule 1.7 + refuge_hold_s: float = 600.0 # rule 1.7 + exposure_grace_s: float = 300.0 # rule 1.3c + min_block_s: float = 900.0 # rule 2.5 + + def __post_init__(self) -> None: + if not 0 <= self.anchor_minutes < 24 * 60: + raise ValueError(f"anchor_minutes out of range: {self.anchor_minutes}") + for name in ( + "clear_hold_s", + "refuge_confirm_s", + "refuge_hold_s", + "exposure_grace_s", + "min_block_s", + ): + if getattr(self, name) < 0: + raise ValueError(f"{name} must be >= 0") + + +def clamp_target_hours(hours: float) -> float: + """Clamp a requested daily target into the photoperiod-safe range (§6).""" + return min(max(hours, 0.0), MAX_TARGET_HOURS) diff --git a/custom_components/grow_conductor/core/visibility.py b/custom_components/grow_conductor/core/visibility.py new file mode 100644 index 0000000..8ee73a2 --- /dev/null +++ b/custom_components/grow_conductor/core/visibility.py @@ -0,0 +1,118 @@ +"""Visibility verdict (ENGINE_SPEC §1).""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime, timedelta + +from .model import InputSnapshot, Reason +from .tunables import Tunables + + +@dataclass(frozen=True) +class ViewerMemory: + """What the engine remembers about viewer activity (rules 1.3/1.3b/1.3c). + + ``active_since`` — start of the ongoing episode (None when quiet). + ``last_end`` — falling edge of the most recent episode or pulse; the + hold reference for the asleep/away contexts. + ``sustained_end`` — falling edge of the most recent episode that + reached ``exposure_grace``; the hold reference inside refuge (1.3c). + """ + + active_since: datetime | None = None + last_end: datetime | None = None + sustained_end: datetime | None = None + + +@dataclass(frozen=True) +class RefugeView: + """The engine's rule-1.7 refuge status at one instant. + + ``engaged`` — confirmed and not lapsed (active or within refuge_hold). + ``ready_at`` — set while an unconfirmed engagement is confirming. + ``held_until`` — set while engaged on held (inactive) evidence: the + instant the hold lapses if no refuge zone reactivates. + """ + + engaged: bool = False + ready_at: datetime | None = None + held_until: datetime | None = None + + +@dataclass(frozen=True) +class Verdict: + """Outcome of the §1 priority chain at one instant. + + ``reason`` is the lit-reason when ``may_light`` and the observed-reason + otherwise. ``cooldown_until`` is set when the household is UNOBSERVED + but a clear-hold is still running. ``review_at`` is a future instant + at which the verdict may flip with no input change (refuge + confirmation, or a tolerated exposure reaching ``exposure_grace``). + """ + + may_light: bool + reason: Reason + cooldown_until: datetime | None = None + review_at: datetime | None = None + + +def assess( + snapshot: InputSnapshot, + now: datetime, + viewers: ViewerMemory, + refuge: RefugeView, + tunables: Tunables, +) -> Verdict: + """Evaluate rules 1.2-1.8 in priority order.""" + if snapshot.veto_active: # 1.2 + return Verdict(False, Reason.VETO) + + # Underlying context, rules 1.4-1.7 (unknowns resolved per 1.8). + if snapshot.asleep is True: # 1.4 + underlying: Reason | None = Reason.ASLEEP + elif snapshot.anyone_home is False: # 1.5 + underlying = Reason.AWAY + elif refuge.engaged: # 1.6 + 1.7 + underlying = Reason.REFUGE + else: # 1.6, no (confirmed) refuge evidence + underlying = None + + if underlying is None: + reason = Reason.VIEWERS if snapshot.viewer_active else Reason.AWAKE_HOME + return Verdict(False, reason, review_at=refuge.ready_at) + + if underlying is Reason.REFUGE: + return _assess_refuge(snapshot, now, viewers, refuge, tunables) + + # Asleep/away: every blip cuts instantly (1.3, 1.3b). + if snapshot.viewer_active: + return Verdict(False, Reason.VIEWERS) + if viewers.last_end is not None: + hold_until = viewers.last_end + timedelta(seconds=tunables.clear_hold_s) + if now < hold_until: + return Verdict(False, underlying, cooldown_until=hold_until) + return Verdict(True, underlying) + + +def _assess_refuge( + snapshot: InputSnapshot, + now: datetime, + viewers: ViewerMemory, + refuge: RefugeView, + tunables: Tunables, +) -> Verdict: + """Rule 1.3c: transient exposure is tolerated during confirmed refuge.""" + reviews = [t for t in (refuge.held_until,) if t is not None] + if snapshot.viewer_active and viewers.active_since is not None: + exposed_at = viewers.active_since + timedelta(seconds=tunables.exposure_grace_s) + if now >= exposed_at: + return Verdict(False, Reason.VIEWERS) + reviews.append(exposed_at) + return Verdict(True, Reason.REFUGE, review_at=min(reviews)) + review_at = min(reviews) if reviews else None + if viewers.sustained_end is not None: + hold_until = viewers.sustained_end + timedelta(seconds=tunables.clear_hold_s) + if now < hold_until: + return Verdict(False, Reason.REFUGE, cooldown_until=hold_until, review_at=review_at) + return Verdict(True, Reason.REFUGE, review_at=review_at) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..53146a3 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ +"""Tests for Grow Conductor.""" diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..fc5df22 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,11 @@ +"""Shared fixtures for Grow Conductor tests.""" + +from __future__ import annotations + +import pytest + + +@pytest.fixture(autouse=True) +def auto_enable_custom_integrations(enable_custom_integrations): + """Allow loading custom_components in every test.""" + yield diff --git a/tests/core/__init__.py b/tests/core/__init__.py new file mode 100644 index 0000000..0054065 --- /dev/null +++ b/tests/core/__init__.py @@ -0,0 +1 @@ +"""Pure-core tests.""" diff --git a/tests/core/helpers.py b/tests/core/helpers.py new file mode 100644 index 0000000..092a2f3 --- /dev/null +++ b/tests/core/helpers.py @@ -0,0 +1,31 @@ +"""Shared helpers for core tests: fixed-timezone clocks and snapshots.""" + +from __future__ import annotations + +from datetime import UTC, datetime, timedelta + +from custom_components.grow_conductor.core.model import InputSnapshot + +__all__ = ["at", "snap", "timedelta"] + + +def at(day: int, hour: int, minute: int = 0, second: int = 0) -> datetime: + """An aware instant in January 2026; anchors resolve in this same tz.""" + return datetime(2026, 1, day, hour, minute, second, tzinfo=UTC) + + +def snap( + *, + asleep: bool | None = None, + anyone_home: bool | None = None, + viewer_active: bool = False, + refuge_active: bool = False, + veto_active: bool = False, +) -> InputSnapshot: + return InputSnapshot( + asleep=asleep, + anyone_home=anyone_home, + viewer_active=viewer_active, + refuge_active=refuge_active, + veto_active=veto_active, + ) diff --git a/tests/core/test_budget.py b/tests/core/test_budget.py new file mode 100644 index 0000000..85e989f --- /dev/null +++ b/tests/core/test_budget.py @@ -0,0 +1,105 @@ +"""PlantDayLedger — ENGINE_SPEC §2 and rule 5.1.""" + +from __future__ import annotations + +from custom_components.grow_conductor.core.budget import PlantDayLedger +from custom_components.grow_conductor.core.tunables import Tunables + +from .helpers import at, timedelta + +H = 3600.0 + + +def make_ledger() -> PlantDayLedger: + return PlantDayLedger(Tunables()) + + +def test_anchor_geometry() -> None: + """Rule 2.1: the plant day pivots at the anchor (22:00).""" + ledger = make_ledger() + assert ledger.anchor_before(at(5, 23, 30)) == at(5, 22) + assert ledger.anchor_before(at(5, 21, 59)) == at(4, 22) + assert ledger.anchor_before(at(5, 22, 0)) == at(5, 22) + assert ledger.next_anchor(at(5, 23, 30)) == at(6, 22) + + +def test_midnight_anchor() -> None: + ledger = PlantDayLedger(Tunables(anchor_minutes=0)) + assert ledger.anchor_before(at(5, 0, 0)) == at(5, 0) + assert ledger.anchor_before(at(5, 23, 59)) == at(5, 0) + + +def test_accrual_open_and_closed_blocks() -> None: + """Rule 2.2: spent includes the open block up to 'now'.""" + ledger = make_ledger() + ledger.light_reported(True, at(5, 23, 0)) + assert ledger.spent_s(at(5, 23, 30)) == 30 * 60 + ledger.light_reported(False, at(6, 1, 0)) + assert ledger.spent_s(at(6, 5, 0)) == 2 * H + # A second block adds on top. + ledger.light_reported(True, at(6, 6, 0)) + assert ledger.spent_s(at(6, 7, 0)) == 3 * H + + +def test_redundant_reports_are_idempotent() -> None: + """Rule 4.2: repeating the same report changes nothing.""" + ledger = make_ledger() + ledger.light_reported(True, at(5, 23, 0)) + ledger.light_reported(True, at(5, 23, 40)) # still counts from 23:00 + assert ledger.spent_s(at(6, 0, 0)) == H + ledger.light_reported(False, at(6, 0, 0)) + ledger.light_reported(False, at(6, 0, 30)) + assert ledger.spent_s(at(6, 1, 0)) == H + + +def test_unknown_light_state_accrues_nothing() -> None: + """Rule 2.2: unknown closes the open block.""" + ledger = make_ledger() + ledger.light_reported(True, at(5, 23, 0)) + ledger.light_reported(None, at(6, 0, 0)) + assert ledger.spent_s(at(6, 3, 0)) == H + ledger.light_reported(True, at(6, 4, 0)) + assert ledger.spent_s(at(6, 5, 0)) == 2 * H + + +def test_open_block_splits_at_anchor() -> None: + """Rule 2.4: pre-anchor time to the old day, post-anchor to the new.""" + ledger = make_ledger() + ledger.light_reported(True, at(5, 21, 0)) + assert ledger.spent_s(at(5, 21, 59)) == 59 * 60 # old day + assert ledger.spent_s(at(5, 23, 0)) == H # new day: 22:00-23:00 only + assert ledger.day_start == at(5, 22) + assert ledger.lit # the block itself keeps burning + + +def test_multi_day_gap_rolls_to_current_day() -> None: + ledger = make_ledger() + ledger.light_reported(True, at(5, 23, 0)) + ledger.light_reported(False, at(5, 23, 30)) + # Nothing happens for two days; spent resets to the current plant day. + assert ledger.spent_s(at(8, 10, 0)) == 0.0 + assert ledger.day_start == at(7, 22) + + +def test_headroom_and_cap_instant() -> None: + ledger = make_ledger() + ledger.light_reported(True, at(5, 23, 0)) + now = at(6, 0, 0) + assert ledger.headroom_s(now, 12 * H) == 11 * H + assert ledger.cap_instant(now, 12 * H) == now + timedelta(hours=11) + assert ledger.headroom_s(now, 0.5 * H) == 0.0 + + +def test_seed_adopted_when_same_plant_day() -> None: + """Rule 5.1: a seed from the current plant day restores spent.""" + ledger = make_ledger() + assert ledger.seed(at(5, 22), 3 * H, now=at(6, 8, 0)) + assert ledger.spent_s(at(6, 8, 0)) == 3 * H + + +def test_seed_discarded_when_stale() -> None: + """Rule 5.1: a seed from another plant day starts fresh.""" + ledger = make_ledger() + assert not ledger.seed(at(4, 22), 3 * H, now=at(6, 8, 0)) + assert ledger.spent_s(at(6, 8, 0)) == 0.0 + assert ledger.day_start == at(5, 22) diff --git a/tests/core/test_engine.py b/tests/core/test_engine.py new file mode 100644 index 0000000..0acaa0e --- /dev/null +++ b/tests/core/test_engine.py @@ -0,0 +1,376 @@ +"""Engine scenarios — ENGINE_SPEC §2-§5 through realistic days.""" + +from __future__ import annotations + +from datetime import datetime + +from custom_components.grow_conductor.core.engine import Engine +from custom_components.grow_conductor.core.model import Decision, LightState, Reason +from custom_components.grow_conductor.core.tunables import Tunables + +from .helpers import at, snap + +H = 3600.0 + + +def follow(engine: Engine, decision: Decision, now: datetime) -> Decision: + """Play the adapter: make the physical light match ``want_on`` (rule 4.1).""" + if decision.want_on != (engine.light_on is True): + return engine.light_reported(decision.want_on, now) + return decision + + +def test_workday_end_to_end() -> None: + """A full nominal workday: sleep block, morning routine, away block, cap.""" + engine = Engine(target_hours=12.0) + + # Evening at home, TV-free, someone in the living room recently. + d = engine.handle_snapshot(snap(asleep=False, anyone_home=True, viewer_active=True), at(5, 21)) + assert d.state is LightState.OBSERVED and d.reason is Reason.VIEWERS + + # Living room clears, still awake at home: plain OBSERVED (no hold shown). + d = engine.handle_snapshot(snap(asleep=False, anyone_home=True), at(5, 22, 50)) + assert d.state is LightState.OBSERVED and d.reason is Reason.AWAKE_HOME + + # Sleep mode on at 23:00 — viewer fell at 22:50, so cooldown until 23:00. + d = engine.handle_snapshot(snap(asleep=True, anyone_home=True), at(5, 22, 55)) + assert d.state is LightState.COOLDOWN + assert d.next_review == at(5, 23, 0) + + # Hold expires: prime time. + d = engine.tick(at(5, 23, 0)) + assert d.want_on and d.state is LightState.LIT and d.reason is Reason.ASLEEP + d = follow(engine, d, at(5, 23, 0)) + + # Wake at 07:00: observed immediately, light cut (morning routine covered). + d = engine.handle_snapshot(snap(asleep=False, anyone_home=True), at(6, 7, 0)) + assert not d.want_on and d.state is LightState.OBSERVED and d.reason is Reason.AWAKE_HOME + d = follow(engine, d, at(6, 7, 0)) + assert engine.spent_s(at(6, 7, 0)) == 8 * H + + # Leaving for work at 08:00: away block starts immediately (no viewer + # activity was recorded on the way out in this scenario). + d = engine.handle_snapshot(snap(asleep=False, anyone_home=False), at(6, 8, 0)) + assert d.want_on and d.reason is Reason.AWAY + # Remaining budget is 4 h: the decision schedules its own cap review. + d = follow(engine, d, at(6, 8, 0)) + assert d.next_review == at(6, 12, 0) + + # Cap hits while away: sated until the next anchor. + d = engine.tick(at(6, 12, 0)) + assert not d.want_on and d.state is LightState.SATED and d.reason is Reason.TARGET_REACHED + assert d.next_review == at(6, 22, 0) + d = follow(engine, d, at(6, 12, 0)) + + # Anchor crossing resets the budget; still away, lights again. + d = engine.tick(at(6, 22, 0)) + assert d.want_on and d.reason is Reason.AWAY + assert engine.spent_s(at(6, 22, 0)) == 0.0 + + +def test_night_movement_cuts_and_recovers() -> None: + """Rules 1.3 + 3.3: bathroom trip cuts the light; it returns after the hold.""" + engine = Engine(target_hours=12.0) + d = engine.handle_snapshot(snap(asleep=True, anyone_home=True), at(5, 23)) + d = follow(engine, d, at(5, 23)) + assert d.want_on + + # Movement through a viewer zone at 03:00. + d = engine.handle_snapshot(snap(asleep=True, anyone_home=True, viewer_active=True), at(6, 3, 0)) + assert not d.want_on and d.state is LightState.OBSERVED and d.reason is Reason.VIEWERS + d = follow(engine, d, at(6, 3, 0)) + + # Movement stops at 03:04 — hold runs from the falling edge. + d = engine.handle_snapshot(snap(asleep=True, anyone_home=True), at(6, 3, 4)) + assert d.state is LightState.COOLDOWN + assert d.next_review == at(6, 3, 14) + + d = engine.tick(at(6, 3, 14)) + assert d.want_on and d.reason is Reason.ASLEEP + + +def test_viewer_hold_counts_from_end_of_sustained_activity() -> None: + """Rule 1.3: activity spanning several snapshots holds from its end.""" + engine = Engine() + engine.handle_snapshot(snap(asleep=True, viewer_active=True), at(6, 3, 0)) + # Unrelated churn while still active bumps the activity stamp. + engine.handle_snapshot(snap(asleep=True, viewer_active=True), at(6, 3, 20)) + d = engine.handle_snapshot(snap(asleep=True), at(6, 3, 30)) + assert d.state is LightState.COOLDOWN + assert d.next_review == at(6, 3, 40) + + +def test_veto_cuts_immediately_and_releases_without_hold() -> None: + """Rule 1.2 during prime time.""" + engine = Engine() + d = engine.handle_snapshot(snap(asleep=True), at(5, 23)) + d = follow(engine, d, at(5, 23)) + d = engine.handle_snapshot(snap(asleep=True, veto_active=True), at(6, 0)) + assert not d.want_on and d.reason is Reason.VETO + d = follow(engine, d, at(6, 0)) + d = engine.handle_snapshot(snap(asleep=True), at(6, 0, 30)) + assert d.want_on and d.reason is Reason.ASLEEP + + +def test_work_from_home_via_refuge() -> None: + """Rules 1.6 + 1.7: office presence lights the plants while awake at home.""" + engine = Engine(target_hours=12.0) + base = snap(asleep=False, anyone_home=True) + + d = engine.handle_snapshot(base, at(6, 9, 0)) + assert d.state is LightState.OBSERVED and d.reason is Reason.AWAKE_HOME + + # Settling into the office: confirmation clock starts, review scheduled. + refuge = snap(asleep=False, anyone_home=True, refuge_active=True) + d = engine.handle_snapshot(refuge, at(6, 9, 5)) + assert not d.want_on and d.reason is Reason.AWAKE_HOME + assert d.next_review == at(6, 9, 8) + + # Unrelated churn must not reset the confirmation clock (rule 1.7). + d = engine.handle_snapshot(refuge, at(6, 9, 6)) + assert d.next_review == at(6, 9, 8) + + d = engine.tick(at(6, 9, 8)) + assert d.want_on and d.state is LightState.LIT and d.reason is Reason.REFUGE + d = follow(engine, d, at(6, 9, 8)) + + # Office still occupied just before the coffee run (in reality the + # publish tick and sensor churn keep bumping refuge evidence). + d = engine.tick(at(6, 11, 58)) + assert d.want_on + + # Coffee run at 12:00 (rule 1.3c): kitchen viewer lights up AND the + # office sensor decays — the light STAYS ON, exposure review at 12:05. + d = engine.handle_snapshot( + snap(asleep=False, anyone_home=True, viewer_active=True), at(6, 12, 0) + ) + assert d.want_on and d.reason is Reason.REFUGE + assert d.next_review == at(6, 12, 5) + + # Back toward the desk at 12:03: episode stayed under exposure_grace, + # so no cut ever happened and no hold applies (still on refuge hold). + d = engine.handle_snapshot(base, at(6, 12, 3)) + assert d.want_on and d.reason is Reason.REFUGE + d = engine.handle_snapshot(refuge, at(6, 12, 4)) + assert d.want_on and d.reason is Reason.REFUGE + assert engine.light_on is True # never toggled through the whole trip + + +def test_sustained_exposure_in_refuge_cuts_at_grace() -> None: + """Rule 1.3c: settling within sight still cuts, with the normal hold.""" + engine = Engine() + both = snap(asleep=False, anyone_home=True, refuge_active=True, viewer_active=True) + refuge = snap(asleep=False, anyone_home=True, refuge_active=True) + engine.handle_snapshot(refuge, at(6, 9, 0)) + d = engine.tick(at(6, 9, 3)) + d = follow(engine, d, at(6, 9, 3)) + assert d.want_on + + # Someone settles in the living room at 12:00 while the office stays + # occupied: tolerated until 12:05, then OBSERVED. + d = engine.handle_snapshot(both, at(6, 12, 0)) + assert d.want_on and d.next_review == at(6, 12, 5) + d = engine.tick(at(6, 12, 5)) + assert not d.want_on and d.state is LightState.OBSERVED and d.reason is Reason.VIEWERS + d = follow(engine, d, at(6, 12, 5)) + + # They leave at 12:20: sustained episode imposes the clear_hold. + d = engine.handle_snapshot(refuge, at(6, 12, 20)) + assert d.state is LightState.COOLDOWN + assert d.next_review == at(6, 12, 30) + d = engine.tick(at(6, 12, 30)) + assert d.want_on and d.reason is Reason.REFUGE + + +def test_refuge_holds_across_gaps_and_releases_after_hold() -> None: + """Rule 1.7: confirmed refuge survives dropouts up to refuge_hold.""" + engine = Engine() + refuge = snap(asleep=False, anyone_home=True, refuge_active=True) + base = snap(asleep=False, anyone_home=True) + engine.handle_snapshot(refuge, at(6, 9, 0)) + d = engine.tick(at(6, 9, 3)) + d = follow(engine, d, at(6, 9, 3)) + assert d.want_on + + # Sensor decays at 10:00: held, with the lapse instant as the review. + d = engine.handle_snapshot(base, at(6, 10, 0)) + assert d.want_on and d.reason is Reason.REFUGE + assert d.next_review == at(6, 10, 10) + + # Reactivation within the hold: no re-confirmation needed. + d = engine.handle_snapshot(refuge, at(6, 10, 5)) + assert d.want_on and d.reason is Reason.REFUGE + + # A gap that outlives the hold releases refuge. + d = engine.handle_snapshot(base, at(6, 10, 6)) + d = engine.tick(at(6, 10, 16)) + assert not d.want_on and d.state is LightState.OBSERVED and d.reason is Reason.AWAKE_HOME + + # Re-engagement afterwards must re-confirm from zero (rule 1.7). + d = engine.handle_snapshot(refuge, at(6, 10, 20)) + assert not d.want_on and d.next_review == at(6, 10, 23) + + +def test_unconfirmed_refuge_dropout_resets_confirmation() -> None: + """Rule 1.7: while unconfirmed, any gap restarts the clock.""" + engine = Engine() + refuge = snap(asleep=False, anyone_home=True, refuge_active=True) + base = snap(asleep=False, anyone_home=True) + engine.handle_snapshot(refuge, at(6, 9, 0)) + engine.handle_snapshot(base, at(6, 9, 1)) + d = engine.handle_snapshot(refuge, at(6, 9, 2)) + assert not d.want_on + assert d.next_review == at(6, 9, 5) # confirmation restarted at 9:02 + + +def test_trigger_pulse_is_ignored_during_refuge() -> None: + """Rules 1.3b + 1.3c: pulses are transient by definition.""" + engine = Engine() + refuge = snap(asleep=False, anyone_home=True, refuge_active=True) + engine.handle_snapshot(refuge, at(6, 9, 0)) + d = engine.tick(at(6, 9, 3)) + d = follow(engine, d, at(6, 9, 3)) + assert d.want_on + + d = engine.activity_pulse(at(6, 11, 0)) + assert d.want_on and d.state is LightState.LIT and d.reason is Reason.REFUGE + + +def test_cap_is_exact_and_min_block_guards_restarts() -> None: + """Rules 2.3 + 2.5.""" + engine = Engine(target_hours=1.0, tunables=Tunables(min_block_s=900)) + d = engine.handle_snapshot(snap(asleep=False, anyone_home=False), at(5, 23, 0)) + d = follow(engine, d, at(5, 23, 0)) + assert d.next_review == at(6, 0, 0) # cap after exactly 1 h + + # A burning block runs exactly to the cap, even past min_block. + d = engine.tick(at(6, 0, 0)) + assert not d.want_on and d.state is LightState.SATED and d.reason is Reason.TARGET_REACHED + d = follow(engine, d, at(6, 0, 0)) + + # Manual light later in the day eats into nothing — already sated. + assert engine.spent_s(at(6, 1, 0)) == H + + # Next day with target lowered so headroom < min_block from the start: + # 10 minutes of manual burn leaves 5 < 15 min headroom. + engine.set_target(0.25, at(6, 23, 0)) + engine.light_reported(True, at(6, 23, 0)) + engine.light_reported(False, at(6, 23, 10)) + d = engine.tick(at(6, 23, 10)) + assert not d.want_on and d.state is LightState.SATED and d.reason is Reason.LOW_HEADROOM + + +def test_manual_light_consumes_budget() -> None: + """Rules 2.2 + 4.3: external on-time counts, even while disabled.""" + engine = Engine(target_hours=2.0) + d = engine.set_enabled(False, at(5, 22, 30)) + assert d.state is LightState.STANDBY and not d.want_on + + # Someone shows the plants to guests for an hour, manually. + engine.light_reported(True, at(5, 23, 0)) + d = engine.light_reported(False, at(6, 0, 0)) + assert d.state is LightState.STANDBY # still no commands while disabled + + # Re-enable during sleep: only one hour of headroom remains. + d = engine.set_enabled(True, at(6, 1, 0)) + d = engine.handle_snapshot(snap(asleep=True, anyone_home=True), at(6, 1, 0)) + assert d.want_on + d = follow(engine, d, at(6, 1, 0)) + assert d.next_review == at(6, 2, 0) # cap: 1 h left of the 2 h target + + +def test_target_clamped_and_zero_target_is_sated() -> None: + """§6 clamp; rule 2.3 with target 0.""" + engine = Engine() + engine.set_target(30.0, at(5, 23)) + assert engine.target_hours == 20.0 + d = engine.set_target(0.0, at(5, 23)) + assert engine.target_hours == 0.0 + d = engine.handle_snapshot(snap(asleep=True), at(5, 23, 5)) + assert not d.want_on and d.state is LightState.SATED + + +def test_decisions_are_idempotent() -> None: + """Rule 4.2: unchanged inputs produce equal decisions.""" + engine = Engine() + s = snap(asleep=True, anyone_home=True) + first = engine.handle_snapshot(s, at(5, 23, 0)) + again = engine.handle_snapshot(s, at(5, 23, 0)) + assert first == again + assert engine.light_reported(None, at(5, 23, 0)) == first + + +def test_seed_budget_restores_current_day_only() -> None: + """Rule 5.1 through the engine surface.""" + engine = Engine(target_hours=12.0) + assert engine.seed_budget(at(5, 22), 11.9 * H, now=at(6, 8, 0)) + d = engine.handle_snapshot(snap(asleep=False, anyone_home=False), at(6, 8, 0)) + # 6 min headroom < min_block: not worth a restart dribble. + assert not d.want_on and d.reason is Reason.LOW_HEADROOM + + stale = Engine(target_hours=12.0) + assert not stale.seed_budget(at(3, 22), 11.9 * H, now=at(6, 8, 0)) + d = stale.handle_snapshot(snap(asleep=False, anyone_home=False), at(6, 8, 0)) + assert d.want_on # fresh day, full budget + + +def test_restart_during_sleep_lights_without_hold() -> None: + """Rule 5.2: no viewer memory across restarts.""" + engine = Engine() + d = engine.handle_snapshot(snap(asleep=True, anyone_home=True), at(6, 2, 0)) + assert d.want_on + + +def test_next_review_defaults_to_next_anchor() -> None: + """Rule 3.2: even a stable OBSERVED decision reviews at the anchor.""" + engine = Engine() + d = engine.handle_snapshot(snap(asleep=False, anyone_home=True), at(6, 9, 0)) + assert d.next_review == at(6, 22, 0) + + +def test_anchor_split_keeps_burning_block() -> None: + """Rule 2.4 through the engine: block spanning the anchor is split.""" + engine = Engine(target_hours=12.0) + d = engine.handle_snapshot(snap(asleep=False, anyone_home=False), at(5, 20, 0)) + d = follow(engine, d, at(5, 20, 0)) + assert d.want_on + # At 21:59 the old day has 1:59 spent; at 22:01 the new day has 0:01. + assert engine.spent_s(at(5, 21, 59)) == 119 * 60 + assert engine.spent_s(at(5, 22, 1)) == 60 + d = engine.tick(at(5, 22, 1)) + assert d.want_on # still burning, fresh budget + + +def test_trigger_pulse_cuts_and_recovers() -> None: + """Rule 1.3b: a door-open pulse cuts instantly, relights after the hold.""" + engine = Engine(target_hours=12.0) + d = engine.handle_snapshot(snap(asleep=True, anyone_home=True), at(5, 23)) + d = follow(engine, d, at(5, 23)) + assert d.want_on + + d = engine.activity_pulse(at(6, 3, 0)) + assert not d.want_on and d.state is LightState.COOLDOWN + assert d.next_review == at(6, 3, 10) + d = follow(engine, d, at(6, 3, 0)) + + # Repeated pulses (door swings) extend the hold from the last one. + d = engine.activity_pulse(at(6, 3, 5)) + assert d.state is LightState.COOLDOWN + assert d.next_review == at(6, 3, 15) + + d = engine.tick(at(6, 3, 15)) + assert d.want_on and d.reason is Reason.ASLEEP + + +def test_trigger_pulse_while_observed_still_arms_the_hold() -> None: + """Rule 1.3b: a pulse while awake matters once the household sleeps.""" + engine = Engine() + d = engine.handle_snapshot(snap(asleep=False, anyone_home=True), at(5, 22, 50)) + assert d.state is LightState.OBSERVED + d = engine.activity_pulse(at(5, 22, 55)) + assert d.state is LightState.OBSERVED # still just observed, no change + + # Sleep three minutes later: the pulse's hold is still running. + d = engine.handle_snapshot(snap(asleep=True, anyone_home=True), at(5, 22, 58)) + assert d.state is LightState.COOLDOWN + assert d.next_review == at(5, 23, 5) diff --git a/tests/core/test_visibility.py b/tests/core/test_visibility.py new file mode 100644 index 0000000..e834151 --- /dev/null +++ b/tests/core/test_visibility.py @@ -0,0 +1,164 @@ +"""Visibility verdict — ENGINE_SPEC §1 priority chain.""" + +from __future__ import annotations + +from custom_components.grow_conductor.core.model import Reason +from custom_components.grow_conductor.core.tunables import Tunables +from custom_components.grow_conductor.core.visibility import RefugeView, ViewerMemory, assess + +from .helpers import at, snap, timedelta + +TUNABLES = Tunables() +NOW = at(5, 23, 0) +QUIET = ViewerMemory() +NO_REFUGE = RefugeView() +ENGAGED = RefugeView(engaged=True) + + +def test_veto_wins_over_everything() -> None: + """Rule 1.2: veto beats asleep, away, refuge — and has no release hold.""" + verdict = assess( + snap(asleep=True, anyone_home=False, refuge_active=True, veto_active=True), + NOW, + QUIET, + ENGAGED, + TUNABLES, + ) + assert not verdict.may_light + assert verdict.reason is Reason.VETO + # Veto released, nothing else observed: light may return immediately. + verdict = assess(snap(asleep=True), NOW, QUIET, NO_REFUGE, TUNABLES) + assert verdict.may_light + assert verdict.reason is Reason.ASLEEP + + +def test_viewer_activity_observes_while_asleep() -> None: + """Rule 1.3 (active half): any viewer activity means OBSERVED.""" + viewers = ViewerMemory(active_since=NOW) + verdict = assess(snap(asleep=True, viewer_active=True), NOW, viewers, NO_REFUGE, TUNABLES) + assert not verdict.may_light + assert verdict.reason is Reason.VIEWERS + + +def test_clear_hold_after_viewer_activity() -> None: + """Rule 1.3 (hold half): UNOBSERVED waits out clear_hold, with expiry.""" + last_end = NOW - timedelta(seconds=300) + viewers = ViewerMemory(last_end=last_end) + verdict = assess(snap(asleep=True), NOW, viewers, NO_REFUGE, TUNABLES) + assert not verdict.may_light + assert verdict.reason is Reason.ASLEEP # the underlying lit-reason + assert verdict.cooldown_until == last_end + timedelta(seconds=600) + # Hold expired. + viewers = ViewerMemory(last_end=NOW - timedelta(seconds=600)) + verdict = assess(snap(asleep=True), NOW, viewers, NO_REFUGE, TUNABLES) + assert verdict.may_light + + +def test_hold_does_not_apply_while_observed() -> None: + """The hold only delays UNOBSERVED verdicts; OBSERVED stays plain OBSERVED.""" + viewers = ViewerMemory(last_end=NOW) + verdict = assess(snap(asleep=False, anyone_home=True), NOW, viewers, NO_REFUGE, TUNABLES) + assert not verdict.may_light + assert verdict.reason is Reason.AWAKE_HOME + assert verdict.cooldown_until is None + + +def test_asleep_is_prime_time() -> None: + """Rule 1.4.""" + verdict = assess(snap(asleep=True, anyone_home=True), NOW, QUIET, NO_REFUGE, TUNABLES) + assert verdict.may_light + assert verdict.reason is Reason.ASLEEP + + +def test_away_lights() -> None: + """Rule 1.5.""" + verdict = assess(snap(asleep=False, anyone_home=False), NOW, QUIET, NO_REFUGE, TUNABLES) + assert verdict.may_light + assert verdict.reason is Reason.AWAY + + +def test_awake_home_is_observed_without_refuge() -> None: + """Rule 1.6: no weekend latch, no exceptions.""" + verdict = assess(snap(asleep=False, anyone_home=True), NOW, QUIET, NO_REFUGE, TUNABLES) + assert not verdict.may_light + assert verdict.reason is Reason.AWAKE_HOME + + +def test_confirming_refuge_reports_ready_instant() -> None: + """Rule 1.7: not engaged yet — observed, with the confirmation review.""" + ready = NOW + timedelta(seconds=120) + verdict = assess( + snap(asleep=False, anyone_home=True, refuge_active=True), + NOW, + QUIET, + RefugeView(engaged=False, ready_at=ready), + TUNABLES, + ) + assert not verdict.may_light + assert verdict.reason is Reason.AWAKE_HOME + assert verdict.review_at == ready + + +def test_engaged_refuge_lights() -> None: + """Rule 1.7.""" + verdict = assess( + snap(asleep=False, anyone_home=True, refuge_active=True), NOW, QUIET, ENGAGED, TUNABLES + ) + assert verdict.may_light + assert verdict.reason is Reason.REFUGE + + +def test_refuge_tolerates_transient_exposure() -> None: + """Rule 1.3c: viewer activity under exposure_grace keeps the light on.""" + viewers = ViewerMemory(active_since=NOW - timedelta(seconds=120)) + verdict = assess( + snap(asleep=False, anyone_home=True, viewer_active=True), NOW, viewers, ENGAGED, TUNABLES + ) + assert verdict.may_light + assert verdict.reason is Reason.REFUGE + assert verdict.review_at == viewers.active_since + timedelta(seconds=300) + + # Sustained exposure matures: OBSERVED. + viewers = ViewerMemory(active_since=NOW - timedelta(seconds=300)) + verdict = assess( + snap(asleep=False, anyone_home=True, viewer_active=True), NOW, viewers, ENGAGED, TUNABLES + ) + assert not verdict.may_light + assert verdict.reason is Reason.VIEWERS + + +def test_refuge_hold_uses_sustained_episodes_only() -> None: + """Rule 1.3c: only grace-exceeding episodes impose a hold in refuge.""" + # A short episode ended recently: no hold inside refuge. + viewers = ViewerMemory(last_end=NOW - timedelta(seconds=10)) + verdict = assess(snap(asleep=False, anyone_home=True), NOW, viewers, ENGAGED, TUNABLES) + assert verdict.may_light + + # A sustained episode ended recently: normal clear_hold applies. + viewers = ViewerMemory( + last_end=NOW - timedelta(seconds=10), sustained_end=NOW - timedelta(seconds=10) + ) + verdict = assess(snap(asleep=False, anyone_home=True), NOW, viewers, ENGAGED, TUNABLES) + assert not verdict.may_light + assert verdict.cooldown_until == viewers.sustained_end + timedelta(seconds=600) + + +def test_asleep_still_cuts_instantly_despite_refuge() -> None: + """Rule 1.3c softens only the refuge context: asleep outranks refuge.""" + viewers = ViewerMemory(active_since=NOW - timedelta(seconds=5)) + verdict = assess( + snap(asleep=True, anyone_home=True, viewer_active=True), NOW, viewers, ENGAGED, TUNABLES + ) + assert not verdict.may_light + assert verdict.reason is Reason.VIEWERS + + +def test_unknowns_fail_safe() -> None: + """Rule 1.8: unknown sleep counts as awake, unknown home as home.""" + verdict = assess(snap(), NOW, QUIET, NO_REFUGE, TUNABLES) + assert not verdict.may_light + assert verdict.reason is Reason.AWAKE_HOME + # Unknown sleep but definitively away still lights (1.5). + verdict = assess(snap(anyone_home=False), NOW, QUIET, NO_REFUGE, TUNABLES) + assert verdict.may_light + assert verdict.reason is Reason.AWAY diff --git a/tests/test_purity.py b/tests/test_purity.py new file mode 100644 index 0000000..696acad --- /dev/null +++ b/tests/test_purity.py @@ -0,0 +1,37 @@ +"""The core package must not import homeassistant — ever. + +This test poisons ``homeassistant`` in ``sys.modules`` and then imports +every module under ``custom_components.grow_conductor.core``. Any HA +import inside the core raises immediately. +""" + +from __future__ import annotations + +import importlib +import pkgutil +import sys + +import pytest + +CORE_PACKAGE = "custom_components.grow_conductor.core" + + +class _PoisonedModule: + def __getattr__(self, name: str): # pragma: no cover + raise AssertionError("core/ must not touch homeassistant") + + +def test_core_is_pure(monkeypatch: pytest.MonkeyPatch) -> None: + # Evict anything already imported, then poison homeassistant. + for name in list(sys.modules): + if name.startswith((CORE_PACKAGE, "homeassistant")): + monkeypatch.delitem(sys.modules, name) + monkeypatch.setitem(sys.modules, "homeassistant", _PoisonedModule()) + + package = importlib.import_module(CORE_PACKAGE) + for module_info in pkgutil.iter_modules(package.__path__): + module = importlib.import_module(f"{CORE_PACKAGE}.{module_info.name}") + for attr in vars(module).values(): + assert not getattr(attr, "__module__", "").startswith("homeassistant"), ( + f"{module.__name__} leaked a homeassistant symbol: {attr!r}" + )