mirror of
https://github.com/tykeal/homeassistant-rental-control.git
synced 2026-06-24 08:47:51 +00:00
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>
750 lines
26 KiB
Python
750 lines
26 KiB
Python
# 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)
|