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") _RES_B = ReservationData(guest_name="Bob") def test_override_data_requires_current(): o = OverrideData(current=_RES_A) assert o.current.guest_name == "Alice" def test_override_data_next_defaults_to_none(): o = OverrideData(current=_RES_A) assert o.next is None def test_override_data_with_next(): o = OverrideData(current=_RES_A, next=_RES_B) assert o.current.guest_name == "Alice" assert o.next.guest_name == "Bob" # --------------------------------------------------------------------------- # 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, ) _DURING = datetime(2026, 7, 1, 15, 0) # 15:00 — inside a 14:00–12:00 window _BEFORE = datetime(2026, 7, 1, 12, 0) # 12:00 — before 14:00 check-in _AFTER = datetime(2026, 7, 1, 22, 0) # 22:00 — after typical check-out def test_diff_both_inactive_returns_no_changes(): diff = reservation_diff(None, None, _DURING) assert not diff.has_changes def test_diff_old_none_new_active_returns_checkin(): res = _res(14, 20) diff = reservation_diff(None, res, _DURING) 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, _DURING) assert diff.checkout_needed assert not diff.checkin_needed assert diff.departing is res def test_diff_both_elapsed_returns_no_changes(): # Window already passed for both — time-based handler owns this transition, not diff. old = _res(14, 20) diff = reservation_diff(old, old, _AFTER) assert not diff.has_changes def test_diff_backend_clears_active_reservation_returns_checkout(): # Backend returns None while we're still inside the window → checkout needed. old = _res(14, 20) now = datetime(2026, 7, 1, 16, 0) diff = reservation_diff(old, None, now) 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, _DURING) 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, _DURING) 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, _DURING) 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, _DURING) 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", ) now = datetime(2026, 7, 1, 15, 0) # after both check-ins diff = reservation_diff(old, new, now) assert diff.checkout_needed assert diff.checkin_needed assert diff.departing is old def test_diff_inactive_old_becomes_active_new_returns_checkin(): res = _res(14, 20) diff = reservation_diff(res, res, _BEFORE) # same record, but we're before check-in # old is inactive (before window), new is inactive too → no changes assert not diff.has_changes def test_diff_no_checkin_time_treated_as_inactive(): res = ReservationData(check_in_date=date(2026, 7, 1)) # no time fields diff = reservation_diff(None, res, _DURING) 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