test_models.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347
  1. from datetime import date, datetime, time
  2. import pytest
  3. from custom_components.room_cal_ha.models import GuestStateDiff, OverrideData, ReservationData, is_window_active, reservation_diff
  4. # ---------------------------------------------------------------------------
  5. # ReservationData — serialization
  6. # ---------------------------------------------------------------------------
  7. def test_to_dict_serializes_dates_and_times():
  8. res = ReservationData(
  9. guest_name="Alice",
  10. check_in_date=date(2026, 7, 1),
  11. check_in_time=time(14, 0),
  12. check_out_date=date(2026, 7, 5),
  13. check_out_time=time(12, 0),
  14. wifi_key="abc",
  15. door_code="1234",
  16. )
  17. d = res.to_dict()
  18. assert d["guest_name"] == "Alice"
  19. assert d["check_in_date"] == "2026-07-01"
  20. assert d["check_in_time"] == "14:00:00"
  21. assert d["check_out_date"] == "2026-07-05"
  22. assert d["check_out_time"] == "12:00:00"
  23. assert d["wifi_key"] == "abc"
  24. assert d["door_code"] == "1234"
  25. def test_to_dict_none_fields_stay_none():
  26. res = ReservationData(guest_name=None, check_in_date=None, wifi_key=None)
  27. d = res.to_dict()
  28. assert d["guest_name"] is None
  29. assert d["check_in_date"] is None
  30. assert d["wifi_key"] is None
  31. def test_from_dict_deserializes_iso_strings():
  32. d = {
  33. "guest_name": "Bob",
  34. "check_in_date": "2026-08-01",
  35. "check_in_time": "15:00:00",
  36. "check_out_date": "2026-08-04",
  37. "check_out_time": "11:00:00",
  38. "wifi_ssid": "Net",
  39. "wifi_key": "xyz",
  40. "door_code": "9876",
  41. }
  42. res = ReservationData.from_dict(d)
  43. assert res.guest_name == "Bob"
  44. assert res.check_in_date == date(2026, 8, 1)
  45. assert res.check_in_time == time(15, 0)
  46. assert res.check_out_date == date(2026, 8, 4)
  47. assert res.check_out_time == time(11, 0)
  48. assert res.wifi_key == "xyz"
  49. assert res.door_code == "9876"
  50. def test_from_dict_none_fields():
  51. d = {
  52. "guest_name": None,
  53. "check_in_date": None,
  54. "check_in_time": None,
  55. "check_out_date": None,
  56. "check_out_time": None,
  57. "wifi_ssid": None,
  58. "wifi_key": None,
  59. "door_code": None,
  60. }
  61. res = ReservationData.from_dict(d)
  62. assert res.guest_name is None
  63. assert res.check_in_date is None
  64. assert res.check_in_time is None
  65. def test_round_trip():
  66. original = ReservationData(
  67. guest_name="Carol",
  68. check_in_date=date(2026, 9, 10),
  69. check_in_time=time(14, 30),
  70. check_out_date=date(2026, 9, 14),
  71. check_out_time=time(11, 0),
  72. wifi_ssid="HomeNet",
  73. wifi_key="pw",
  74. door_code="0000",
  75. )
  76. assert ReservationData.from_dict(original.to_dict()) == original
  77. # ---------------------------------------------------------------------------
  78. # ReservationData — apply_partial
  79. # ---------------------------------------------------------------------------
  80. SAMPLE = ReservationData(
  81. guest_name="Alice",
  82. check_in_date=date(2026, 7, 1),
  83. check_in_time=time(14, 0),
  84. check_out_date=date(2026, 7, 5),
  85. check_out_time=time(12, 0),
  86. wifi_ssid="4114",
  87. wifi_key="stone",
  88. door_code="0472",
  89. )
  90. def test_apply_partial_overlays_specified_fields():
  91. updated = SAMPLE.apply_partial({"wifi_ssid": "GuestNet", "door_code": "9999"})
  92. assert updated.wifi_ssid == "GuestNet"
  93. assert updated.door_code == "9999"
  94. def test_apply_partial_leaves_other_fields_unchanged():
  95. updated = SAMPLE.apply_partial({"door_code": "1111"})
  96. assert updated.guest_name == "Alice"
  97. assert updated.check_in_date == date(2026, 7, 1)
  98. assert updated.wifi_key == "stone"
  99. def test_apply_partial_can_clear_a_field_with_none():
  100. updated = SAMPLE.apply_partial({"guest_name": None})
  101. assert updated.guest_name is None
  102. def test_apply_partial_ignores_unknown_keys():
  103. updated = SAMPLE.apply_partial({"nonexistent_field": "x"})
  104. assert updated == SAMPLE
  105. def test_apply_partial_empty_dict_returns_equivalent():
  106. updated = SAMPLE.apply_partial({})
  107. assert updated == SAMPLE
  108. # ---------------------------------------------------------------------------
  109. # ReservationData — defaults
  110. # ---------------------------------------------------------------------------
  111. def test_default_values():
  112. res = ReservationData()
  113. assert res.guest_name is None
  114. assert res.check_in_date is None
  115. assert res.check_in_time is None
  116. assert res.check_out_date is None
  117. assert res.check_out_time is None
  118. assert res.wifi_ssid is None
  119. assert res.wifi_key is None
  120. assert res.door_code is None
  121. # ---------------------------------------------------------------------------
  122. # OverrideData
  123. # ---------------------------------------------------------------------------
  124. _RES_A = ReservationData(guest_name="Alice")
  125. def test_override_data_requires_active():
  126. o = OverrideData(active=_RES_A)
  127. assert o.active.guest_name == "Alice"
  128. # ---------------------------------------------------------------------------
  129. # reservation_diff
  130. # ---------------------------------------------------------------------------
  131. def _res(checkin_h: int, checkout_h: int, *, wifi_key="key", wifi_ssid="ssid", door_code=None) -> ReservationData:
  132. """Build a reservation on 2026-07-01 with given check-in/out hours."""
  133. return ReservationData(
  134. guest_name="Guest",
  135. check_in_date=date(2026, 7, 1),
  136. check_in_time=time(checkin_h, 0),
  137. check_out_date=date(2026, 7, 1),
  138. check_out_time=time(checkout_h, 0),
  139. wifi_ssid=wifi_ssid,
  140. wifi_key=wifi_key,
  141. door_code=door_code,
  142. )
  143. def test_diff_both_inactive_returns_no_changes():
  144. diff = reservation_diff(None, None)
  145. assert not diff.has_changes
  146. def test_diff_old_none_new_active_returns_checkin():
  147. res = _res(14, 20)
  148. diff = reservation_diff(None, res)
  149. assert diff.checkin_needed
  150. assert not diff.checkout_needed
  151. assert diff.departing is None
  152. def test_diff_old_active_new_none_returns_checkout():
  153. res = _res(14, 20)
  154. diff = reservation_diff(res, None)
  155. assert diff.checkout_needed
  156. assert not diff.checkin_needed
  157. assert diff.departing is res
  158. def test_diff_unchanged_record_returns_no_changes():
  159. old = _res(14, 20)
  160. diff = reservation_diff(old, old)
  161. assert not diff.has_changes
  162. def test_diff_checkout_needed_even_when_now_is_past_check_out():
  163. # Regression test: a scheduled natural checkout runs at/after old's check-out time,
  164. # so the diff must not re-derive "was old active" from wall-clock time — it must
  165. # trigger checkout purely because old was occupied and new (cleared) is not.
  166. old = _res(14, 20)
  167. diff = reservation_diff(old, None)
  168. assert diff.checkout_needed
  169. assert not diff.checkin_needed
  170. assert diff.departing is old
  171. def test_diff_same_window_no_changes_returns_empty():
  172. res = _res(14, 20)
  173. diff = reservation_diff(res, res)
  174. assert not diff.has_changes
  175. def test_diff_same_window_wifi_key_changed():
  176. old = _res(14, 20, wifi_key="old-key")
  177. new = _res(14, 20, wifi_key="new-key")
  178. diff = reservation_diff(old, new)
  179. assert diff.wifi_key_changed
  180. assert not diff.door_code_changed
  181. assert not diff.checkin_needed
  182. assert not diff.checkout_needed
  183. def test_diff_same_window_door_code_changed():
  184. old = _res(14, 20, door_code="1234")
  185. new = _res(14, 20, door_code="5678")
  186. diff = reservation_diff(old, new)
  187. assert diff.door_code_changed
  188. assert not diff.wifi_key_changed
  189. assert not diff.checkin_needed
  190. assert not diff.checkout_needed
  191. def test_diff_same_window_no_change():
  192. old = _res(14, 20, wifi_key="key", door_code="1234")
  193. new = _res(14, 20, wifi_key="key", door_code="1234")
  194. diff = reservation_diff(old, new)
  195. assert not diff.has_changes
  196. def test_diff_different_window_both_active_returns_checkout_and_checkin():
  197. old = ReservationData(
  198. check_in_date=date(2026, 7, 1), check_in_time=time(14, 0),
  199. check_out_date=date(2026, 7, 2), check_out_time=time(12, 0),
  200. wifi_ssid="s", wifi_key="k",
  201. )
  202. new = ReservationData(
  203. check_in_date=date(2026, 7, 1), check_in_time=time(10, 0),
  204. check_out_date=date(2026, 7, 2), check_out_time=time(12, 0),
  205. wifi_ssid="s", wifi_key="k",
  206. )
  207. diff = reservation_diff(old, new)
  208. assert diff.checkout_needed
  209. assert diff.checkin_needed
  210. assert diff.departing is old
  211. def test_diff_no_checkin_time_treated_as_unoccupied():
  212. res = ReservationData(check_in_date=date(2026, 7, 1)) # no time fields
  213. diff = reservation_diff(None, res)
  214. assert not diff.has_changes
  215. def test_guest_state_diff_has_changes_false_when_empty():
  216. assert not GuestStateDiff().has_changes
  217. def test_guest_state_diff_has_changes_true_for_checkin():
  218. assert GuestStateDiff(checkin_needed=True).has_changes
  219. # ---------------------------------------------------------------------------
  220. # is_window_active — boundary conditions
  221. #
  222. # These directly guard the fix for the midnight-boundary bug where date-only
  223. # comparisons would make a 14:00 check-in appear "active" at 00:00 that day.
  224. # ---------------------------------------------------------------------------
  225. def _stay(checkin_date, checkin_time, checkout_date=None, checkout_time=None) -> ReservationData:
  226. return ReservationData(
  227. check_in_date=checkin_date,
  228. check_in_time=checkin_time,
  229. check_out_date=checkout_date,
  230. check_out_time=checkout_time,
  231. )
  232. _CHECKIN_DAY = date(2026, 7, 1)
  233. _CHECKOUT_DAY = date(2026, 7, 5)
  234. _CHECKIN_TIME = time(14, 0)
  235. _CHECKOUT_TIME = time(11, 0)
  236. _STAY = _stay(_CHECKIN_DAY, _CHECKIN_TIME, _CHECKOUT_DAY, _CHECKOUT_TIME)
  237. def test_is_window_active_false_at_midnight_before_checkin():
  238. # 00:00 on check-in day — hours before the actual check-in time. This is the
  239. # exact scenario that triggered the midnight-boundary bug.
  240. midnight = datetime(_CHECKIN_DAY.year, _CHECKIN_DAY.month, _CHECKIN_DAY.day, 0, 0)
  241. assert is_window_active(_STAY, midnight) is False
  242. def test_is_window_active_false_just_before_checkin():
  243. one_minute_before = datetime(_CHECKIN_DAY.year, _CHECKIN_DAY.month, _CHECKIN_DAY.day, 13, 59)
  244. assert is_window_active(_STAY, one_minute_before) is False
  245. def test_is_window_active_true_at_checkin_time():
  246. at_checkin = datetime.combine(_CHECKIN_DAY, _CHECKIN_TIME)
  247. assert is_window_active(_STAY, at_checkin) is True
  248. def test_is_window_active_true_during_stay():
  249. mid_stay = datetime(2026, 7, 3, 10, 0)
  250. assert is_window_active(_STAY, mid_stay) is True
  251. def test_is_window_active_false_at_checkout_time():
  252. # Checkout boundary is exclusive: exactly at checkout_time → no longer active.
  253. at_checkout = datetime.combine(_CHECKOUT_DAY, _CHECKOUT_TIME)
  254. assert is_window_active(_STAY, at_checkout) is False
  255. def test_is_window_active_true_just_before_checkout():
  256. one_minute_before = datetime(_CHECKOUT_DAY.year, _CHECKOUT_DAY.month, _CHECKOUT_DAY.day, 10, 59)
  257. assert is_window_active(_STAY, one_minute_before) is True
  258. def test_is_window_active_false_when_no_checkin_time():
  259. # Date present but no time — treated as inactive (guards against date-only comparisons).
  260. date_only = ReservationData(check_in_date=_CHECKIN_DAY)
  261. assert is_window_active(date_only, datetime(_CHECKIN_DAY.year, _CHECKIN_DAY.month, _CHECKIN_DAY.day, 15, 0)) is False
  262. def test_is_window_active_false_when_reservation_is_none():
  263. assert is_window_active(None, datetime(2026, 7, 1, 15, 0)) is False