Files
homeassistant-rental-control/tests/integration/test_refresh_cycle.py
Andrew Grimberg 8d8fe0ad7f Feat(diagnostics): Expose slot plan state
Build per-refresh DesiredPlan diagnostics snapshots in
reconciliation.py so every managed slot's desired identity key,
actual classification, pending action, blocked reason, retry count,
and last error are captured in plan.diagnostics.  Per-reservation
entries record selected/protected/overflow status, missing_count,
assigned slot, uid_aliases, and booking_aliases without any raw
slot codes or PINs.  Optional entry_id, lockname, and start_slot
keyword arguments supply coordinator-scope metadata.  The diagnostics
building is extracted into _build_plan_diagnostics_snapshot to keep
compute_desired_plan within maintainable bounds.

Add last_error to ManagedSlot so failed-operation errors from the
apply phase survive into the next refresh cycle and propagate into
the per-slot diagnostics snapshot.

In EventOverrides, add _last_slot_errors and _diagnostics_snapshot
fields.  Record errors in _apply_clear and _apply_set on failure;
clear them on confirmed success.  update_diagnostics_snapshot builds
a structured snapshot covering matched slots, pending corrections,
blocked clear reasons, slot_retry_counts, pending_clear_slots, and
last_slot_errors, and is called automatically at the end of
async_apply_plan via the finally block.  diagnostics_snapshot and
get_last_slot_error expose the data read-only for the coordinator
and HA diagnostics collection.

In the coordinator, _observe_managed_slots now uses an explicit
persisted_mapping variable to avoid chained dict.get fallbacks,
_apply_checkin_protection resolves hass.data lookup in two explicit
steps, compute_desired_plan receives entry_id/lockname/start_slot
for metadata context, and latest_reconciliation_diagnostics merges
plan diagnostics with the EventOverrides snapshot while stripping
slot_code/pin/code keys as a safety net.

Also add .aislop/baseline.json (set at score 100 on HEAD) and the
corresponding REUSE.toml annotation to keep the REUSE Specification
3.3 compliance intact.

Tests (T091-T094):
- test_event_overrides.py: TestDiagnosticsSnapshot verifies matched
  slots, pending corrections, blocked reasons, retry counts, last
  errors, error clearing on success, no raw codes, plan_id/timestamp,
  and pending_clear_slots list.
- test_slot_reconciliation.py: TestComputeDesiredPlanDiagnostics
  verifies plan_id/generated_at, optional entry_id/lockname/start_slot,
  per-reservation selected/protected/overflow/missing_count/assigned_
  slot/uid_aliases/booking_aliases, slot_code absent, per-slot
  last_error from ManagedSlot, and overflow_details preservation.
- test_keymaster_event_diagnostics.py: TestRedactionCompatibility
  verifies the keymaster event buffer has no slot_code/PIN fields
  and both the EventOverrides snapshot and compute_desired_plan
  diagnostics exclude raw codes.
- test_refresh_cycle.py: TestDiagnosticsDesiredVsActual runs a
  focused mock integration scenario with a matched slot, overflow
  reservation, and pending-clear slot to confirm diagnostics capture
  all states and contain no raw codes; also covers the manual-drift
  (CLEAR action for wrong occupant) diagnostic path.

Co-authored-by: Claude <claude@anthropic.com>
Signed-off-by: Andrew Grimberg <tykeal@bardicgrove.org>
2026-06-20 00:43:04 -07:00

750 lines
26 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# SPDX-FileCopyrightText: 2025 Andrew Grimberg <tykeal@bardicgrove.org>
# SPDX-License-Identifier: Apache-2.0
"""Integration tests for calendar refresh cycles.
These tests verify that the coordinator refresh pipeline works end-to-end:
initial data load, scheduled refresh, sensor/calendar state propagation,
door-code generation, and independent multi-entry updates.
"""
from __future__ import annotations
from datetime import timedelta
from typing import TYPE_CHECKING
from unittest.mock import patch
from aioresponses import aioresponses
from homeassistant.helpers import entity_registry as er
import homeassistant.util.dt as dt_util
from pytest_homeassistant_custom_component.common import MockConfigEntry
from custom_components.rental_control.const import COORDINATOR
from custom_components.rental_control.const import DOMAIN
from tests.fixtures import calendar_data
from tests.integration.helpers import FROZEN_START_OF_DAY
from tests.integration.helpers import FROZEN_TIME
from tests.integration.helpers import future_ics
if TYPE_CHECKING:
from homeassistant.core import HomeAssistant
# ---------------------------------------------------------------------------
# T112 initial data load
# ---------------------------------------------------------------------------
async def test_initial_data_load(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
) -> None:
"""Verify first refresh fetches and processes calendar data.
After integration setup the coordinator should have loaded the ICS
feed and populated its calendar list with parsed events.
"""
mock_config_entry.add_to_hass(hass)
with (
aioresponses() as mock_session,
patch.object(dt_util, "now", return_value=FROZEN_TIME),
patch.object(dt_util, "start_of_local_day", return_value=FROZEN_START_OF_DAY),
):
mock_session.get(
mock_config_entry.data["url"],
status=200,
body=calendar_data.AIRBNB_ICS_CALENDAR,
repeat=True,
)
assert await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
coordinator = hass.data[DOMAIN][mock_config_entry.entry_id][COORDINATOR]
assert coordinator.data is not None
assert len(coordinator.data) > 0
# ---------------------------------------------------------------------------
# T113 scheduled refresh
# ---------------------------------------------------------------------------
async def test_scheduled_refresh(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
) -> None:
"""Verify automatic refresh happens after the refresh interval elapses.
After initial setup, calls async_refresh() with time advanced past
the refresh interval to confirm a second fetch occurs successfully.
"""
mock_config_entry.add_to_hass(hass)
with (
aioresponses() as mock_session,
patch.object(dt_util, "now", return_value=FROZEN_TIME),
patch.object(dt_util, "start_of_local_day", return_value=FROZEN_START_OF_DAY),
):
mock_session.get(
mock_config_entry.data["url"],
status=200,
body=calendar_data.AIRBNB_ICS_CALENDAR,
repeat=True,
)
assert await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
coordinator = hass.data[DOMAIN][mock_config_entry.entry_id][COORDINATOR]
# Advance past the refresh interval and trigger update
future = FROZEN_TIME + timedelta(minutes=coordinator.refresh_frequency + 1)
with (
aioresponses() as mock_session,
patch.object(dt_util, "now", return_value=future),
patch.object(
dt_util,
"start_of_local_day",
return_value=future.replace(hour=0, minute=0, second=0, microsecond=0),
),
):
mock_session.get(
mock_config_entry.data["url"],
status=200,
body=calendar_data.AIRBNB_ICS_CALENDAR,
repeat=True,
)
await coordinator.async_refresh()
await hass.async_block_till_done()
assert coordinator.data is not None
assert coordinator.last_update_success is True
# ---------------------------------------------------------------------------
# T114 sensor state updates on refresh
# ---------------------------------------------------------------------------
async def test_sensor_updates_on_refresh(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
) -> None:
"""Verify sensor entity states reflect data after coordinator refresh.
After setup, sensors are created but not yet updated with event data.
A subsequent async_refresh() (with time advanced past the refresh
interval) triggers sensor updates so their state includes the guest
name.
"""
mock_config_entry.add_to_hass(hass)
ics_body = future_ics()
with (
aioresponses() as mock_session,
patch.object(dt_util, "now", return_value=FROZEN_TIME),
patch.object(dt_util, "start_of_local_day", return_value=FROZEN_START_OF_DAY),
):
mock_session.get(
mock_config_entry.data["url"],
status=200,
body=ics_body,
repeat=True,
)
assert await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
coordinator = hass.data[DOMAIN][mock_config_entry.entry_id][COORDINATOR]
# Advance past the refresh interval so a second refresh triggers
# sensor updates with event data
future = FROZEN_TIME + timedelta(minutes=coordinator.refresh_frequency + 1)
with (
aioresponses() as mock_session,
patch.object(dt_util, "now", return_value=future),
patch.object(
dt_util,
"start_of_local_day",
return_value=future.replace(hour=0, minute=0, second=0, microsecond=0),
),
):
mock_session.get(
mock_config_entry.data["url"],
status=200,
body=ics_body,
repeat=True,
)
await coordinator.async_refresh()
await hass.async_block_till_done()
registry = er.async_get(hass)
entries = er.async_entries_for_config_entry(registry, mock_config_entry.entry_id)
event_0 = next(
(e for e in entries if e.domain == "sensor" and "event_0" in e.entity_id),
None,
)
assert event_0 is not None, "event_0 sensor not found in entity registry"
sensor_state = hass.states.get(event_0.entity_id)
assert sensor_state is not None
assert "Test Guest" in sensor_state.state
# ---------------------------------------------------------------------------
# T115 calendar entity reflects new events
# ---------------------------------------------------------------------------
async def test_calendar_updates_on_refresh(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
) -> None:
"""Verify calendar entity reflects events after refresh.
The coordinator.event should be set to the next upcoming event
after the initial data load.
"""
mock_config_entry.add_to_hass(hass)
ics_body = future_ics()
with (
aioresponses() as mock_session,
patch.object(dt_util, "now", return_value=FROZEN_TIME),
patch.object(dt_util, "start_of_local_day", return_value=FROZEN_START_OF_DAY),
):
mock_session.get(
mock_config_entry.data["url"],
status=200,
body=ics_body,
repeat=True,
)
assert await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
coordinator = hass.data[DOMAIN][mock_config_entry.entry_id][COORDINATOR]
assert coordinator.event is not None
assert coordinator.event.summary == "Reserved: Test Guest"
# ---------------------------------------------------------------------------
# T116 door code generation on refresh
# ---------------------------------------------------------------------------
async def test_door_code_generation_on_refresh(
hass: HomeAssistant,
) -> None:
"""Verify door codes are generated during refresh when configured.
Uses a config entry with code_generation enabled. After the initial
setup, a second async_refresh() (past the refresh interval) triggers
sensor updates which generate door codes from event data.
"""
entry = MockConfigEntry(
domain=DOMAIN,
title="Code Test",
version=8,
unique_id="test-code-unique-id",
data={
"name": "Code Test",
"url": "https://example.com/calendar.ics",
"timezone": "America/New_York",
"checkin": "16:00",
"checkout": "11:00",
"start_slot": 10,
"max_events": 3,
"days": 90,
"verify_ssl": True,
"ignore_non_reserved": False,
"code_generation": "date_based",
"code_length": 4,
},
entry_id="test_code_entry",
)
entry.add_to_hass(hass)
ics_body = future_ics()
with (
aioresponses() as mock_session,
patch.object(dt_util, "now", return_value=FROZEN_TIME),
patch.object(dt_util, "start_of_local_day", return_value=FROZEN_START_OF_DAY),
):
mock_session.get(
entry.data["url"],
status=200,
body=ics_body,
repeat=True,
)
assert await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
coordinator = hass.data[DOMAIN][entry.entry_id][COORDINATOR]
# Trigger a second refresh to populate sensors with event data
future = FROZEN_TIME + timedelta(minutes=coordinator.refresh_frequency + 1)
with (
aioresponses() as mock_session,
patch.object(dt_util, "now", return_value=future),
patch.object(
dt_util,
"start_of_local_day",
return_value=future.replace(hour=0, minute=0, second=0, microsecond=0),
),
):
mock_session.get(
entry.data["url"],
status=200,
body=ics_body,
repeat=True,
)
await coordinator.async_refresh()
await hass.async_block_till_done()
registry = er.async_get(hass)
entries = er.async_entries_for_config_entry(registry, entry.entry_id)
event_0 = next(
(e for e in entries if e.domain == "sensor" and "event_0" in e.entity_id),
None,
)
assert event_0 is not None, "event_0 sensor not found in entity registry"
sensor_state = hass.states.get(event_0.entity_id)
assert sensor_state is not None
# Sensor with an event should have generated a door code (stored as slot_code)
attrs = sensor_state.attributes
assert attrs.get("slot_code") is not None
assert len(attrs["slot_code"]) == 4
assert attrs["slot_code"].isdigit()
# ---------------------------------------------------------------------------
# T117 concurrent calendar updates (multiple entries)
# ---------------------------------------------------------------------------
async def test_concurrent_calendar_updates(
hass: HomeAssistant,
) -> None:
"""Verify multiple config entries update independently.
Sets up two separate integration entries, each with its own calendar
URL and ICS data, and confirms they maintain independent state.
Version is set to 7 to skip migrations that would overwrite
unique_id with gen_uuid(dt.now()) and cause a collision.
"""
entry_a = MockConfigEntry(
domain=DOMAIN,
title="Rental A",
unique_id="unique_rental_a",
version=8,
data={
"name": "Rental A",
"url": "https://example.com/a.ics",
"timezone": "America/New_York",
"checkin": "16:00",
"checkout": "11:00",
"start_slot": 10,
"max_events": 2,
"days": 90,
"verify_ssl": True,
"ignore_non_reserved": False,
"creation_datetime": "2025-01-01T00:00:00",
},
entry_id="entry_a",
)
entry_b = MockConfigEntry(
domain=DOMAIN,
title="Rental B",
unique_id="unique_rental_b",
version=8,
data={
"name": "Rental B",
"url": "https://example.com/b.ics",
"timezone": "America/Chicago",
"checkin": "15:00",
"checkout": "10:00",
"start_slot": 20,
"max_events": 3,
"days": 180,
"verify_ssl": True,
"ignore_non_reserved": False,
"creation_datetime": "2025-02-01T00:00:00",
},
entry_id="entry_b",
)
ics_a = future_ics(summary="Reserved: Guest A")
ics_b = future_ics(summary="Reserved: Guest B", days_ahead=10)
entry_a.add_to_hass(hass)
entry_b.add_to_hass(hass)
with (
aioresponses() as mock_session,
patch.object(dt_util, "now", return_value=FROZEN_TIME),
patch.object(dt_util, "start_of_local_day", return_value=FROZEN_START_OF_DAY),
):
mock_session.get(entry_a.data["url"], status=200, body=ics_a, repeat=True)
mock_session.get(entry_b.data["url"], status=200, body=ics_b, repeat=True)
# HA component setup auto-loads all registered config entries for the
# domain. Calling async_setup on entry_a triggers async_setup_component
# which in turn sets up every entry added to hass for this domain.
assert await hass.config_entries.async_setup(entry_a.entry_id)
await hass.async_block_till_done()
coord_a = hass.data[DOMAIN][entry_a.entry_id][COORDINATOR]
coord_b = hass.data[DOMAIN][entry_b.entry_id][COORDINATOR]
assert coord_a.name == "Rental A"
assert coord_b.name == "Rental B"
assert coord_a.max_events == 2
assert coord_b.max_events == 3
# Each coordinator loaded its own calendar independently
assert coord_a.data is not None
assert coord_b.data is not None
assert coord_a.event.summary == "Reserved: Guest A"
assert coord_b.event.summary == "Reserved: Guest B"
class TestClearFailureSlotNotReused:
"""Verify failed clear prevents slot reuse."""
async def test_clear_failure_slot_not_reused(self) -> None:
"""A slot that fails to clear is not assigned to a new reservation."""
from datetime import datetime
from datetime import timezone
from unittest.mock import AsyncMock
from unittest.mock import MagicMock
from unittest.mock import patch
from custom_components.rental_control.event_overrides import EventOverrides
from custom_components.rental_control.reconciliation import ActionKind
from custom_components.rental_control.reconciliation import DesiredPlan
from custom_components.rental_control.reconciliation import SlotAction
from custom_components.rental_control.util import OperationResult
eo = EventOverrides(start_slot=1, max_slots=2)
now = datetime(2026, 1, 1, tzinfo=timezone.utc)
eo.update(1, "c1", "OldGuest", now, now)
eo.update(2, "c2", "Slot2", now, now)
plan = DesiredPlan(plan_id="test-t063", generated_at=now)
plan.actions = [SlotAction(kind=ActionKind.CLEAR, slot=1, identity_key=None)]
coordinator = MagicMock()
coordinator.lockname = "test_lock"
coordinator.hass.services.async_call = AsyncMock()
failed_result = OperationResult(
kind="clear",
slot=1,
failed=True,
error="lock offline",
)
with patch(
"custom_components.rental_control.event_overrides.async_fire_clear_code",
return_value=failed_result,
):
await eo.async_apply_plan(coordinator, plan, {})
assert eo.overrides[1] is not None
assert eo.overrides[1]["slot_name"] == "OldGuest"
assert 1 in eo.pending_fences
async def test_no_double_assignment_after_failed_clear(self) -> None:
"""A slot with failed clear is not available for new assignment."""
from datetime import datetime
from datetime import timezone
from unittest.mock import AsyncMock
from unittest.mock import MagicMock
from unittest.mock import patch
from custom_components.rental_control.event_overrides import EventOverrides
from custom_components.rental_control.reconciliation import ActionKind
from custom_components.rental_control.reconciliation import DesiredPlan
from custom_components.rental_control.reconciliation import Reservation
from custom_components.rental_control.reconciliation import SlotAction
from custom_components.rental_control.util import OperationResult
eo = EventOverrides(start_slot=1, max_slots=2)
now = datetime(2026, 1, 1, tzinfo=timezone.utc)
eo.update(1, "c1", "OldGuest", now, now)
eo.update(2, "", "", now, now)
start = datetime(2026, 8, 1, 14, tzinfo=timezone.utc)
end = datetime(2026, 8, 8, 11, tzinfo=timezone.utc)
new_res = Reservation(
identity_key="new-res",
start=start,
end=end,
buffered_start=start,
buffered_end=end,
summary="New Guest",
slot_name="New Guest",
display_slot_name="RC New Guest",
slot_code="1234",
)
plan = DesiredPlan(plan_id="t063-no-double", generated_at=now)
plan.actions = [
SlotAction(kind=ActionKind.CLEAR, slot=1, identity_key=None),
SlotAction(kind=ActionKind.SET, slot=2, identity_key="new-res"),
]
coordinator = MagicMock()
coordinator.lockname = "test_lock"
coordinator.event_prefix = ""
coordinator.trim_names = False
coordinator.code_buffer_before = 0
coordinator.code_buffer_after = 0
coordinator.hass.services.async_call = AsyncMock()
coordinator.event_overrides = eo
name_state = MagicMock()
name_state.state = "New Guest"
coordinator.hass.states.get.return_value = name_state
failed_result = OperationResult(
kind="clear",
slot=1,
failed=True,
error="lock offline",
)
with patch(
"custom_components.rental_control.event_overrides.async_fire_clear_code",
return_value=failed_result,
):
await eo.async_apply_plan(coordinator, plan, {"new-res": new_res})
assert eo.overrides[1] is not None
assert eo.overrides[1]["slot_name"] == "OldGuest"
assert eo.overrides[2] is not None
assert eo.overrides[2]["slot_name"] == "New Guest"
# ---------------------------------------------------------------------------
# T093 Diagnostics desired-vs-actual completeness scenario
# ---------------------------------------------------------------------------
class TestDiagnosticsDesiredVsActual:
"""T093: Integration scenario proving diagnostics capture all significant
slot states: matched slot, overflow reservation, manual drift, and
pending clear.
Uses focused mock-based integration (same pattern as TestClearFailureSlotNotReused)
so no HA infrastructure is required.
"""
async def test_diagnostics_captures_all_states(self) -> None:
"""Diagnostics snapshot covers matched, overflow, drift, and pending clear.
Scenario:
- Slot 1 occupied by matching reservation r-match (NOOP action)
- Slot 2 pending_clear from a previous failed clear (RETRY_CLEAR)
- Reservation r-overflow exceeds capacity and lands in plan.overflow
- plan.diagnostics contains per-slot and per-reservation detail
- No raw PIN codes appear anywhere in diagnostics
"""
from datetime import datetime
from datetime import timezone
from custom_components.rental_control.event_overrides import EventOverrides
from custom_components.rental_control.reconciliation import ActionKind
from custom_components.rental_control.reconciliation import DesiredPlan
from custom_components.rental_control.reconciliation import ManagedSlot
from custom_components.rental_control.reconciliation import PlannedSlot
from custom_components.rental_control.reconciliation import Reservation
from custom_components.rental_control.reconciliation import SlotStatus
from custom_components.rental_control.reconciliation import compute_desired_plan
_TZ = timezone.utc
def _mk(key: str, day: int) -> Reservation:
"""Build a minimal August Reservation with *key* starting on *day*."""
from datetime import timedelta
s = datetime(2026, 8, day, 14, tzinfo=_TZ)
e = s + timedelta(days=7)
return Reservation(
identity_key=key,
start=s,
end=e,
buffered_start=s,
buffered_end=e,
summary=f"Guest {key}",
slot_name=f"Guest {key}",
display_slot_name=f"RC Guest {key}",
slot_code="SECRETCODE",
)
r_match = _mk("r-match", 1)
r_overflow = _mk("r-overflow", 8)
# Slot 1: occupied by r-match (persisted_identity_key matches)
# Slot 2: pending_clear from a prior failed attempt
ms1 = ManagedSlot(
slot=1,
managed=True,
status=SlotStatus.OCCUPIED,
actual_name="Guest r-match",
actual_code_present=True,
persisted_identity_key="r-match",
last_error=None,
)
ms2 = ManagedSlot(
slot=2,
managed=True,
status=SlotStatus.PENDING_CLEAR,
actual_name="OldGuest",
actual_code_present=True,
retry_count=1,
last_error="prior clear failed",
blocked_reason="prior clear unconfirmed",
)
generated = datetime(2026, 8, 1, tzinfo=_TZ)
plan = compute_desired_plan(
[r_match, r_overflow],
[ms1, ms2],
max_events=1, # capacity=1 → r_overflow overflows
plan_id="t093-diag",
generated_at=generated,
entry_id="entry-t093",
lockname="test_lock",
start_slot=1,
)
diag = plan.diagnostics
# --- Plan metadata present ---
assert diag["plan_id"] == "t093-diag"
assert diag["entry_id"] == "entry-t093"
assert diag["lockname"] == "test_lock"
assert diag["start_slot"] == 1
# --- Matched slot: r-match in slot 1 ---
assert "r-match" in plan.selected
assert diag["reservations"]["r-match"]["selected"] is True
assert diag["reservations"]["r-match"]["assigned_slot"] == 1
# --- Overflow reservation: r-overflow ---
assert "r-overflow" in plan.overflow
assert diag["reservations"]["r-overflow"]["selected"] is False
assert diag["reservations"]["r-overflow"]["overflow_reason"] is not None
# --- Pending clear: slot 2 has RETRY_CLEAR action ---
assert diag["slots"][2]["action"] == ActionKind.RETRY_CLEAR.value
assert diag["slots"][2]["retry_count"] == 1
assert diag["slots"][2]["last_error"] == "prior clear failed"
# --- EventOverrides snapshot also captures this ---
eo = EventOverrides(start_slot=1, max_slots=2)
eo._pending_clear_slots[2] = "op-token"
eo._record_slot_error(2, "prior clear failed")
# Build a matching plan for the snapshot
snap_plan = DesiredPlan(plan_id="t093-snap", generated_at=generated)
snap_plan.slots[1] = PlannedSlot(
slot=1,
desired_identity_key="r-match",
actual_classification="occupied",
action=ActionKind.NOOP,
)
snap_plan.slots[2] = PlannedSlot(
slot=2,
desired_identity_key=None,
actual_classification="pending_clear",
action=ActionKind.RETRY_CLEAR,
pending_reason="prior clear unconfirmed",
retry_count=1,
)
eo.update_diagnostics_snapshot(snap_plan)
snap = eo.diagnostics_snapshot
assert 1 in snap["matched_slots"]
assert snap["matched_slots"][1]["identity_key"] == "r-match"
assert 2 in snap["pending_corrections"]
assert 2 in snap["pending_clear_slots"]
assert snap["last_slot_errors"][2] == "prior clear failed"
# --- No raw codes anywhere ---
assert "SECRETCODE" not in str(diag)
assert "SECRETCODE" not in str(snap)
assert "slot_code" not in str(diag)
assert "slot_code" not in str(snap)
async def test_diagnostics_manual_drift_detected(self) -> None:
"""Manual drift (OVERWRITE_MANUAL_CHANGE) shows in per-slot diagnostics."""
from datetime import datetime
from datetime import timedelta
from datetime import timezone
from custom_components.rental_control.reconciliation import ManagedSlot
from custom_components.rental_control.reconciliation import Reservation
from custom_components.rental_control.reconciliation import SlotStatus
from custom_components.rental_control.reconciliation import compute_desired_plan
_TZ = timezone.utc
s = datetime(2026, 8, 1, 14, tzinfo=_TZ)
e = s + timedelta(days=7)
r = Reservation(
identity_key="r-drift",
start=s,
end=e,
buffered_start=s,
buffered_end=e,
summary="Guest Drift",
slot_name="Guest Drift",
display_slot_name="RC Guest Drift",
slot_code="DRIFT_CODE",
)
# Slot occupied with DIFFERENT persisted key → planner will CLEAR then SET
# (not OVERWRITE_MANUAL_CHANGE since that happens in apply, but the CLEAR
# action + wrong persisted key is visible in diagnostics)
ms = ManagedSlot(
slot=5,
managed=True,
status=SlotStatus.OCCUPIED,
actual_name="WrongGuest",
actual_code_present=True,
persisted_identity_key="r-wrong", # different from r-drift
)
plan = compute_desired_plan(
[r],
[ms],
max_events=3,
plan_id="t093-drift",
generated_at=s,
)
slot_diag = plan.diagnostics["slots"][5]
# CLEAR because persisted key doesn't match desired key
assert slot_diag["action"] == "clear"
assert slot_diag["actual_classification"] == "occupied"
assert "DRIFT_CODE" not in str(plan.diagnostics)