test_models.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372
  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. _RES_B = ReservationData(guest_name="Bob")
  126. def test_override_data_requires_current():
  127. o = OverrideData(current=_RES_A)
  128. assert o.current.guest_name == "Alice"
  129. def test_override_data_next_defaults_to_none():
  130. o = OverrideData(current=_RES_A)
  131. assert o.next is None
  132. def test_override_data_with_next():
  133. o = OverrideData(current=_RES_A, next=_RES_B)
  134. assert o.current.guest_name == "Alice"
  135. assert o.next.guest_name == "Bob"
  136. # ---------------------------------------------------------------------------
  137. # reservation_diff
  138. # ---------------------------------------------------------------------------
  139. def _res(checkin_h: int, checkout_h: int, *, wifi_key="key", wifi_ssid="ssid", door_code=None) -> ReservationData:
  140. """Build a reservation on 2026-07-01 with given check-in/out hours."""
  141. return ReservationData(
  142. guest_name="Guest",
  143. check_in_date=date(2026, 7, 1),
  144. check_in_time=time(checkin_h, 0),
  145. check_out_date=date(2026, 7, 1),
  146. check_out_time=time(checkout_h, 0),
  147. wifi_ssid=wifi_ssid,
  148. wifi_key=wifi_key,
  149. door_code=door_code,
  150. )
  151. _DURING = datetime(2026, 7, 1, 15, 0) # 15:00 — inside a 14:00–12:00 window
  152. _BEFORE = datetime(2026, 7, 1, 12, 0) # 12:00 — before 14:00 check-in
  153. _AFTER = datetime(2026, 7, 1, 22, 0) # 22:00 — after typical check-out
  154. def test_diff_both_inactive_returns_no_changes():
  155. diff = reservation_diff(None, None, _DURING)
  156. assert not diff.has_changes
  157. def test_diff_old_none_new_active_returns_checkin():
  158. res = _res(14, 20)
  159. diff = reservation_diff(None, res, _DURING)
  160. assert diff.checkin_needed
  161. assert not diff.checkout_needed
  162. assert diff.departing is None
  163. def test_diff_old_active_new_none_returns_checkout():
  164. res = _res(14, 20)
  165. diff = reservation_diff(res, None, _DURING)
  166. assert diff.checkout_needed
  167. assert not diff.checkin_needed
  168. assert diff.departing is res
  169. def test_diff_both_elapsed_returns_no_changes():
  170. # Window already passed for both — time-based handler owns this transition, not diff.
  171. old = _res(14, 20)
  172. diff = reservation_diff(old, old, _AFTER)
  173. assert not diff.has_changes
  174. def test_diff_backend_clears_active_reservation_returns_checkout():
  175. # Backend returns None while we're still inside the window → checkout needed.
  176. old = _res(14, 20)
  177. now = datetime(2026, 7, 1, 16, 0)
  178. diff = reservation_diff(old, None, now)
  179. assert diff.checkout_needed
  180. assert not diff.checkin_needed
  181. assert diff.departing is old
  182. def test_diff_same_window_no_changes_returns_empty():
  183. res = _res(14, 20)
  184. diff = reservation_diff(res, res, _DURING)
  185. assert not diff.has_changes
  186. def test_diff_same_window_wifi_key_changed():
  187. old = _res(14, 20, wifi_key="old-key")
  188. new = _res(14, 20, wifi_key="new-key")
  189. diff = reservation_diff(old, new, _DURING)
  190. assert diff.wifi_key_changed
  191. assert not diff.door_code_changed
  192. assert not diff.checkin_needed
  193. assert not diff.checkout_needed
  194. def test_diff_same_window_door_code_changed():
  195. old = _res(14, 20, door_code="1234")
  196. new = _res(14, 20, door_code="5678")
  197. diff = reservation_diff(old, new, _DURING)
  198. assert diff.door_code_changed
  199. assert not diff.wifi_key_changed
  200. assert not diff.checkin_needed
  201. assert not diff.checkout_needed
  202. def test_diff_same_window_no_change():
  203. old = _res(14, 20, wifi_key="key", door_code="1234")
  204. new = _res(14, 20, wifi_key="key", door_code="1234")
  205. diff = reservation_diff(old, new, _DURING)
  206. assert not diff.has_changes
  207. def test_diff_different_window_both_active_returns_checkout_and_checkin():
  208. old = ReservationData(
  209. check_in_date=date(2026, 7, 1), check_in_time=time(14, 0),
  210. check_out_date=date(2026, 7, 2), check_out_time=time(12, 0),
  211. wifi_ssid="s", wifi_key="k",
  212. )
  213. new = ReservationData(
  214. check_in_date=date(2026, 7, 1), check_in_time=time(10, 0),
  215. check_out_date=date(2026, 7, 2), check_out_time=time(12, 0),
  216. wifi_ssid="s", wifi_key="k",
  217. )
  218. now = datetime(2026, 7, 1, 15, 0) # after both check-ins
  219. diff = reservation_diff(old, new, now)
  220. assert diff.checkout_needed
  221. assert diff.checkin_needed
  222. assert diff.departing is old
  223. def test_diff_inactive_old_becomes_active_new_returns_checkin():
  224. res = _res(14, 20)
  225. diff = reservation_diff(res, res, _BEFORE) # same record, but we're before check-in
  226. # old is inactive (before window), new is inactive too → no changes
  227. assert not diff.has_changes
  228. def test_diff_no_checkin_time_treated_as_inactive():
  229. res = ReservationData(check_in_date=date(2026, 7, 1)) # no time fields
  230. diff = reservation_diff(None, res, _DURING)
  231. assert not diff.has_changes
  232. def test_guest_state_diff_has_changes_false_when_empty():
  233. assert not GuestStateDiff().has_changes
  234. def test_guest_state_diff_has_changes_true_for_checkin():
  235. assert GuestStateDiff(checkin_needed=True).has_changes
  236. # ---------------------------------------------------------------------------
  237. # is_window_active — boundary conditions
  238. #
  239. # These directly guard the fix for the midnight-boundary bug where date-only
  240. # comparisons would make a 14:00 check-in appear "active" at 00:00 that day.
  241. # ---------------------------------------------------------------------------
  242. def _stay(checkin_date, checkin_time, checkout_date=None, checkout_time=None) -> ReservationData:
  243. return ReservationData(
  244. check_in_date=checkin_date,
  245. check_in_time=checkin_time,
  246. check_out_date=checkout_date,
  247. check_out_time=checkout_time,
  248. )
  249. _CHECKIN_DAY = date(2026, 7, 1)
  250. _CHECKOUT_DAY = date(2026, 7, 5)
  251. _CHECKIN_TIME = time(14, 0)
  252. _CHECKOUT_TIME = time(11, 0)
  253. _STAY = _stay(_CHECKIN_DAY, _CHECKIN_TIME, _CHECKOUT_DAY, _CHECKOUT_TIME)
  254. def test_is_window_active_false_at_midnight_before_checkin():
  255. # 00:00 on check-in day — hours before the actual check-in time. This is the
  256. # exact scenario that triggered the midnight-boundary bug.
  257. midnight = datetime(_CHECKIN_DAY.year, _CHECKIN_DAY.month, _CHECKIN_DAY.day, 0, 0)
  258. assert is_window_active(_STAY, midnight) is False
  259. def test_is_window_active_false_just_before_checkin():
  260. one_minute_before = datetime(_CHECKIN_DAY.year, _CHECKIN_DAY.month, _CHECKIN_DAY.day, 13, 59)
  261. assert is_window_active(_STAY, one_minute_before) is False
  262. def test_is_window_active_true_at_checkin_time():
  263. at_checkin = datetime.combine(_CHECKIN_DAY, _CHECKIN_TIME)
  264. assert is_window_active(_STAY, at_checkin) is True
  265. def test_is_window_active_true_during_stay():
  266. mid_stay = datetime(2026, 7, 3, 10, 0)
  267. assert is_window_active(_STAY, mid_stay) is True
  268. def test_is_window_active_false_at_checkout_time():
  269. # Checkout boundary is exclusive: exactly at checkout_time → no longer active.
  270. at_checkout = datetime.combine(_CHECKOUT_DAY, _CHECKOUT_TIME)
  271. assert is_window_active(_STAY, at_checkout) is False
  272. def test_is_window_active_true_just_before_checkout():
  273. one_minute_before = datetime(_CHECKOUT_DAY.year, _CHECKOUT_DAY.month, _CHECKOUT_DAY.day, 10, 59)
  274. assert is_window_active(_STAY, one_minute_before) is True
  275. def test_is_window_active_false_when_no_checkin_time():
  276. # Date present but no time — treated as inactive (guards against date-only comparisons).
  277. date_only = ReservationData(check_in_date=_CHECKIN_DAY)
  278. assert is_window_active(date_only, datetime(_CHECKIN_DAY.year, _CHECKIN_DAY.month, _CHECKIN_DAY.day, 15, 0)) is False
  279. def test_is_window_active_false_when_reservation_is_none():
  280. assert is_window_active(None, datetime(2026, 7, 1, 15, 0)) is False