from datetime import date, datetime, time import pytest from custom_components.room_cal_ha.models import GuestStateDiff, OverrideData, ReservationData, is_window_active, reservation_diff # --------------------------------------------------------------------------- # ReservationData — serialization # --------------------------------------------------------------------------- def test_to_dict_serializes_dates_and_times(): res = ReservationData( guest_name="Alice", check_in_date=date(2026, 7, 1), check_in_time=time(14, 0), check_out_date=date(2026, 7, 5), check_out_time=time(12, 0), wifi_key="abc", door_code="1234", ) d = res.to_dict() assert d["guest_name"] == "Alice" assert d["check_in_date"] == "2026-07-01" assert d["check_in_time"] == "14:00:00" assert d["check_out_date"] == "2026-07-05" assert d["check_out_time"] == "12:00:00" assert d["wifi_key"] == "abc" assert d["door_code"] == "1234" def test_to_dict_none_fields_stay_none(): res = ReservationData(guest_name=None, check_in_date=None, wifi_key=None) d = res.to_dict() assert d["guest_name"] is None assert d["check_in_date"] is None assert d["wifi_key"] is None def test_from_dict_deserializes_iso_strings(): d = { "guest_name": "Bob", "check_in_date": "2026-08-01", "check_in_time": "15:00:00", "check_out_date": "2026-08-04", "check_out_time": "11:00:00", "wifi_ssid": "Net", "wifi_key": "xyz", "door_code": "9876", } res = ReservationData.from_dict(d) assert res.guest_name == "Bob" assert res.check_in_date == date(2026, 8, 1) assert res.check_in_time == time(15, 0) assert res.check_out_date == date(2026, 8, 4) assert res.check_out_time == time(11, 0) assert res.wifi_key == "xyz" assert res.door_code == "9876" def test_from_dict_none_fields(): d = { "guest_name": None, "check_in_date": None, "check_in_time": None, "check_out_date": None, "check_out_time": None, "wifi_ssid": None, "wifi_key": None, "door_code": None, } res = ReservationData.from_dict(d) assert res.guest_name is None assert res.check_in_date is None assert res.check_in_time is None def test_round_trip(): original = ReservationData( guest_name="Carol", check_in_date=date(2026, 9, 10), check_in_time=time(14, 30), check_out_date=date(2026, 9, 14), check_out_time=time(11, 0), wifi_ssid="HomeNet", wifi_key="pw", door_code="0000", ) assert ReservationData.from_dict(original.to_dict()) == original # --------------------------------------------------------------------------- # ReservationData — apply_partial # --------------------------------------------------------------------------- SAMPLE = ReservationData( guest_name="Alice", check_in_date=date(2026, 7, 1), check_in_time=time(14, 0), check_out_date=date(2026, 7, 5), check_out_time=time(12, 0), wifi_ssid="4114", wifi_key="stone", door_code="0472", ) def test_apply_partial_overlays_specified_fields(): updated = SAMPLE.apply_partial({"wifi_ssid": "GuestNet", "door_code": "9999"}) assert updated.wifi_ssid == "GuestNet" assert updated.door_code == "9999" def test_apply_partial_leaves_other_fields_unchanged(): updated = SAMPLE.apply_partial({"door_code": "1111"}) assert updated.guest_name == "Alice" assert updated.check_in_date == date(2026, 7, 1) assert updated.wifi_key == "stone" def test_apply_partial_can_clear_a_field_with_none(): updated = SAMPLE.apply_partial({"guest_name": None}) assert updated.guest_name is None def test_apply_partial_ignores_unknown_keys(): updated = SAMPLE.apply_partial({"nonexistent_field": "x"}) assert updated == SAMPLE def test_apply_partial_empty_dict_returns_equivalent(): updated = SAMPLE.apply_partial({}) assert updated == SAMPLE # --------------------------------------------------------------------------- # ReservationData — defaults # --------------------------------------------------------------------------- def test_default_values(): res = ReservationData() assert res.guest_name is None assert res.check_in_date is None assert res.check_in_time is None assert res.check_out_date is None assert res.check_out_time is None assert res.wifi_ssid is None assert res.wifi_key is None assert res.door_code is None # --------------------------------------------------------------------------- # OverrideData # --------------------------------------------------------------------------- _RES_A = ReservationData(guest_name="Alice") def test_override_data_requires_active(): o = OverrideData(active=_RES_A) assert o.active.guest_name == "Alice" # --------------------------------------------------------------------------- # reservation_diff # --------------------------------------------------------------------------- def _res(checkin_h: int, checkout_h: int, *, wifi_key="key", wifi_ssid="ssid", door_code=None) -> ReservationData: """Build a reservation on 2026-07-01 with given check-in/out hours.""" return ReservationData( guest_name="Guest", check_in_date=date(2026, 7, 1), check_in_time=time(checkin_h, 0), check_out_date=date(2026, 7, 1), check_out_time=time(checkout_h, 0), wifi_ssid=wifi_ssid, wifi_key=wifi_key, door_code=door_code, ) def test_diff_both_inactive_returns_no_changes(): diff = reservation_diff(None, None) assert not diff.has_changes def test_diff_old_none_new_active_returns_checkin(): res = _res(14, 20) diff = reservation_diff(None, res) assert diff.checkin_needed assert not diff.checkout_needed assert diff.departing is None def test_diff_old_active_new_none_returns_checkout(): res = _res(14, 20) diff = reservation_diff(res, None) assert diff.checkout_needed assert not diff.checkin_needed assert diff.departing is res def test_diff_unchanged_record_returns_no_changes(): old = _res(14, 20) diff = reservation_diff(old, old) assert not diff.has_changes def test_diff_checkout_needed_even_when_now_is_past_check_out(): # Regression test: a scheduled natural checkout runs at/after old's check-out time, # so the diff must not re-derive "was old active" from wall-clock time — it must # trigger checkout purely because old was occupied and new (cleared) is not. old = _res(14, 20) diff = reservation_diff(old, None) assert diff.checkout_needed assert not diff.checkin_needed assert diff.departing is old def test_diff_same_window_no_changes_returns_empty(): res = _res(14, 20) diff = reservation_diff(res, res) assert not diff.has_changes def test_diff_same_window_wifi_key_changed(): old = _res(14, 20, wifi_key="old-key") new = _res(14, 20, wifi_key="new-key") diff = reservation_diff(old, new) assert diff.wifi_key_changed assert not diff.door_code_changed assert not diff.checkin_needed assert not diff.checkout_needed def test_diff_same_window_door_code_changed(): old = _res(14, 20, door_code="1234") new = _res(14, 20, door_code="5678") diff = reservation_diff(old, new) assert diff.door_code_changed assert not diff.wifi_key_changed assert not diff.checkin_needed assert not diff.checkout_needed def test_diff_same_window_no_change(): old = _res(14, 20, wifi_key="key", door_code="1234") new = _res(14, 20, wifi_key="key", door_code="1234") diff = reservation_diff(old, new) assert not diff.has_changes def test_diff_different_window_both_active_returns_checkout_and_checkin(): old = ReservationData( check_in_date=date(2026, 7, 1), check_in_time=time(14, 0), check_out_date=date(2026, 7, 2), check_out_time=time(12, 0), wifi_ssid="s", wifi_key="k", ) new = ReservationData( check_in_date=date(2026, 7, 1), check_in_time=time(10, 0), check_out_date=date(2026, 7, 2), check_out_time=time(12, 0), wifi_ssid="s", wifi_key="k", ) diff = reservation_diff(old, new) assert diff.checkout_needed assert diff.checkin_needed assert diff.departing is old def test_diff_no_checkin_time_treated_as_unoccupied(): res = ReservationData(check_in_date=date(2026, 7, 1)) # no time fields diff = reservation_diff(None, res) assert not diff.has_changes def test_guest_state_diff_has_changes_false_when_empty(): assert not GuestStateDiff().has_changes def test_guest_state_diff_has_changes_true_for_checkin(): assert GuestStateDiff(checkin_needed=True).has_changes # --------------------------------------------------------------------------- # is_window_active — boundary conditions # # These directly guard the fix for the midnight-boundary bug where date-only # comparisons would make a 14:00 check-in appear "active" at 00:00 that day. # --------------------------------------------------------------------------- def _stay(checkin_date, checkin_time, checkout_date=None, checkout_time=None) -> ReservationData: return ReservationData( check_in_date=checkin_date, check_in_time=checkin_time, check_out_date=checkout_date, check_out_time=checkout_time, ) _CHECKIN_DAY = date(2026, 7, 1) _CHECKOUT_DAY = date(2026, 7, 5) _CHECKIN_TIME = time(14, 0) _CHECKOUT_TIME = time(11, 0) _STAY = _stay(_CHECKIN_DAY, _CHECKIN_TIME, _CHECKOUT_DAY, _CHECKOUT_TIME) def test_is_window_active_false_at_midnight_before_checkin(): # 00:00 on check-in day — hours before the actual check-in time. This is the # exact scenario that triggered the midnight-boundary bug. midnight = datetime(_CHECKIN_DAY.year, _CHECKIN_DAY.month, _CHECKIN_DAY.day, 0, 0) assert is_window_active(_STAY, midnight) is False def test_is_window_active_false_just_before_checkin(): one_minute_before = datetime(_CHECKIN_DAY.year, _CHECKIN_DAY.month, _CHECKIN_DAY.day, 13, 59) assert is_window_active(_STAY, one_minute_before) is False def test_is_window_active_true_at_checkin_time(): at_checkin = datetime.combine(_CHECKIN_DAY, _CHECKIN_TIME) assert is_window_active(_STAY, at_checkin) is True def test_is_window_active_true_during_stay(): mid_stay = datetime(2026, 7, 3, 10, 0) assert is_window_active(_STAY, mid_stay) is True def test_is_window_active_false_at_checkout_time(): # Checkout boundary is exclusive: exactly at checkout_time → no longer active. at_checkout = datetime.combine(_CHECKOUT_DAY, _CHECKOUT_TIME) assert is_window_active(_STAY, at_checkout) is False def test_is_window_active_true_just_before_checkout(): one_minute_before = datetime(_CHECKOUT_DAY.year, _CHECKOUT_DAY.month, _CHECKOUT_DAY.day, 10, 59) assert is_window_active(_STAY, one_minute_before) is True def test_is_window_active_false_when_no_checkin_time(): # Date present but no time — treated as inactive (guards against date-only comparisons). date_only = ReservationData(check_in_date=_CHECKIN_DAY) assert is_window_active(date_only, datetime(_CHECKIN_DAY.year, _CHECKIN_DAY.month, _CHECKIN_DAY.day, 15, 0)) is False def test_is_window_active_false_when_reservation_is_none(): assert is_window_active(None, datetime(2026, 7, 1, 15, 0)) is False