1
0

test_board_settings.py 7.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183
  1. """
  2. Unit tests for board_settings — the host<->board settings sync layer.
  3. Covers:
  4. - Still Sands slot conversion (host dicts <-> $Sands/Slots spec string)
  5. - POSIX timezone derivation from TZif files
  6. - Adopting board $Sands/* values into host state
  7. - Autostart mapping to $Playlist/Autostart* commands
  8. - Playlist mirroring content
  9. """
  10. import pytest
  11. from unittest.mock import MagicMock, patch
  12. from modules.core import board_settings
  13. from modules.core.state import state
  14. class TestSlotConversion:
  15. """Host slot dicts <-> firmware '$Sands/Slots' spec string."""
  16. def test_daily_slot_round_trip(self):
  17. slots = [{"start_time": "22:00", "end_time": "06:00", "days": "daily", "custom_days": []}]
  18. spec = board_settings.slots_to_board(slots)
  19. assert spec == "22:00-06:00@daily"
  20. assert board_settings.board_to_slots(spec) == slots
  21. def test_weekdays_and_weekends(self):
  22. slots = [
  23. {"start_time": "21:00", "end_time": "08:00", "days": "weekdays", "custom_days": []},
  24. {"start_time": "23:30", "end_time": "09:00", "days": "weekends", "custom_days": []},
  25. ]
  26. spec = board_settings.slots_to_board(slots)
  27. assert spec == "21:00-08:00@weekdays,23:30-09:00@weekends"
  28. assert board_settings.board_to_slots(spec) == slots
  29. def test_custom_days_round_trip(self):
  30. slots = [{
  31. "start_time": "13:00", "end_time": "14:00", "days": "custom",
  32. "custom_days": ["monday", "friday"],
  33. }]
  34. spec = board_settings.slots_to_board(slots)
  35. assert spec == "13:00-14:00@mon+fri"
  36. assert board_settings.board_to_slots(spec) == slots
  37. def test_custom_days_sorted_in_firmware_order(self):
  38. slots = [{
  39. "start_time": "10:00", "end_time": "11:00", "days": "custom",
  40. "custom_days": ["saturday", "sunday", "wednesday"],
  41. }]
  42. # bit 0 = Sunday in the firmware's day order
  43. assert board_settings.slots_to_board(slots) == "10:00-11:00@sun+wed+sat"
  44. def test_empty_custom_days_falls_back_to_daily(self):
  45. slots = [{"start_time": "10:00", "end_time": "11:00", "days": "custom", "custom_days": []}]
  46. assert board_settings.slots_to_board(slots) == "10:00-11:00@daily"
  47. def test_parse_ignores_malformed_entries(self):
  48. parsed = board_settings.board_to_slots("garbage,22:00-06:00@daily,also-bad@")
  49. assert len(parsed) == 1
  50. assert parsed[0]["start_time"] == "22:00"
  51. def test_parse_bare_times_defaults_daily(self):
  52. parsed = board_settings.board_to_slots("22:00-06:00")
  53. assert parsed == [{"start_time": "22:00", "end_time": "06:00", "days": "daily", "custom_days": []}]
  54. def test_empty_specs(self):
  55. assert board_settings.slots_to_board([]) == ""
  56. assert board_settings.board_to_slots("") == []
  57. class TestPosixTz:
  58. def test_known_iana_zone(self):
  59. # Skip if the zoneinfo database isn't at the standard path.
  60. import os
  61. if not os.path.exists("/usr/share/zoneinfo/America/Toronto"):
  62. pytest.skip("no system zoneinfo db")
  63. tz = board_settings.posix_tz("America/Toronto")
  64. assert tz is not None and tz.startswith("EST5EDT")
  65. def test_unknown_zone_returns_none(self):
  66. assert board_settings.posix_tz("Not/AZone") is None
  67. class TestAdoptStillSands:
  68. @pytest.fixture(autouse=True)
  69. def _snapshot_state(self):
  70. saved = (
  71. state.scheduled_pause_enabled,
  72. state.scheduled_pause_time_slots,
  73. state.scheduled_pause_control_wled,
  74. state.scheduled_pause_finish_pattern,
  75. )
  76. yield
  77. (
  78. state.scheduled_pause_enabled,
  79. state.scheduled_pause_time_slots,
  80. state.scheduled_pause_control_wled,
  81. state.scheduled_pause_finish_pattern,
  82. ) = saved
  83. def test_adopts_board_values(self):
  84. state.scheduled_pause_enabled = False
  85. state.scheduled_pause_time_slots = []
  86. state.scheduled_pause_control_wled = False
  87. state.scheduled_pause_finish_pattern = False
  88. with patch.object(state, "save") as save:
  89. changed = board_settings.adopt_still_sands({
  90. "Sands/Enabled": "ON",
  91. "Sands/Slots": "21:00-08:00@daily",
  92. "Sands/FinishPattern": "OFF",
  93. "Sands/LedOff": "ON",
  94. })
  95. assert changed is True
  96. save.assert_called_once()
  97. assert state.scheduled_pause_enabled is True
  98. assert state.scheduled_pause_finish_pattern is False
  99. assert state.scheduled_pause_control_wled is True
  100. assert state.scheduled_pause_time_slots[0]["start_time"] == "21:00"
  101. def test_no_change_no_save(self):
  102. state.scheduled_pause_enabled = True
  103. state.scheduled_pause_finish_pattern = True
  104. state.scheduled_pause_control_wled = False
  105. state.scheduled_pause_time_slots = board_settings.board_to_slots("21:00-08:00@daily")
  106. with patch.object(state, "save") as save:
  107. changed = board_settings.adopt_still_sands({
  108. "Sands/Enabled": "ON",
  109. "Sands/Slots": "21:00-08:00@daily",
  110. "Sands/FinishPattern": "ON",
  111. "Sands/LedOff": "OFF",
  112. })
  113. assert changed is False
  114. save.assert_not_called()
  115. class TestApplyAutostart:
  116. def test_maps_fields_to_board_settings(self):
  117. conn = MagicMock()
  118. board_settings.apply_autostart({
  119. "playlist": "evening",
  120. "run_mode": "loop",
  121. "shuffle": True,
  122. "pause_seconds": 90,
  123. "pause_from_start": False,
  124. "clear_pattern": "adaptive",
  125. }, conn=conn)
  126. written = {call.args[0]: call.args[1] for call in conn.set_setting.call_args_list}
  127. assert written == {
  128. "Playlist/Autostart": "evening",
  129. "Playlist/AutostartMode": "loop",
  130. "Playlist/AutostartShuffle": "ON",
  131. "Playlist/AutostartPause": 90,
  132. "Playlist/AutostartPauseFromStart": "OFF",
  133. "Playlist/AutostartClear": "adaptive",
  134. }
  135. def test_empty_playlist_disables(self):
  136. conn = MagicMock()
  137. board_settings.apply_autostart({"playlist": ""}, conn=conn)
  138. conn.set_setting.assert_called_once_with("Playlist/Autostart", "")
  139. def test_invalid_clear_mode_coerced_to_none(self):
  140. conn = MagicMock()
  141. board_settings.apply_autostart({"clear_pattern": "clear_from_in"}, conn=conn)
  142. conn.set_setting.assert_called_once_with("Playlist/AutostartClear", "none")
  143. class TestPlaylistMirroring:
  144. def test_playlist_content_uses_sd_paths(self):
  145. from modules.core.pattern_manager import _to_sd_path
  146. content = board_settings._playlist_sd_content(
  147. ["./patterns/star.thr", "patterns/sub/wave.thr"], _to_sd_path
  148. )
  149. assert content == "/patterns/star.thr\n/patterns/sub/wave.thr\n"
  150. def test_mirror_uploads_txt(self):
  151. conn = MagicMock()
  152. board_settings.mirror_playlist("evening", ["./patterns/star.thr"], conn=conn)
  153. conn.upload_file.assert_called_once()
  154. sd_path, data, directory = conn.upload_file.call_args.args
  155. assert sd_path == "/playlists/evening.txt"
  156. assert data == b"/patterns/star.thr\n"
  157. assert directory == "/playlists"