1
0

test_execution.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342
  1. """
  2. Unit tests for modules/core/execution.py — the firmware-delegation layer.
  3. Covers:
  4. - translate_status: raw /sand_status fixtures -> the /ws/status contract
  5. - BoardObserver edge detection: history logging, hold accounting,
  6. clear-speed shim, run-end reset, reboot guard
  7. - start_playlist call order (stop-first, NVS params, mirror, run)
  8. - skip routing (playlist vs single pattern)
  9. """
  10. import json
  11. import pytest
  12. from unittest.mock import MagicMock, patch
  13. from modules.core import execution
  14. from modules.core.execution import BoardObserver, RunContext, map_clear_mode, _from_sd_path, translate_status
  15. from modules.core.state import state
  16. class FakeConn:
  17. """Records calls; scriptable status."""
  18. def __init__(self, statuses=None):
  19. self.calls = []
  20. self.statuses = list(statuses or [])
  21. self._stopped = False
  22. def is_connected(self):
  23. return True
  24. def get_status(self):
  25. self.calls.append(("get_status",))
  26. if self._stopped:
  27. return {"state": "Idle", "running": False, "playlist": {"active": False}}
  28. if self.statuses:
  29. return self.statuses.pop(0)
  30. return {"state": "Idle", "running": False, "playlist": {"active": False}}
  31. def stop(self):
  32. self.calls.append(("stop",))
  33. self._stopped = True
  34. def skip(self):
  35. self.calls.append(("skip",))
  36. def pause(self):
  37. self.calls.append(("pause",))
  38. def resume(self):
  39. self.calls.append(("resume",))
  40. def set_setting(self, key, value):
  41. self.calls.append(("set_setting", key, str(value)))
  42. def set_feed(self, mm=None, **kw):
  43. self.calls.append(("set_feed", mm))
  44. def run_command(self, plain):
  45. self.calls.append(("run_command", plain))
  46. return "ok"
  47. def run_pattern(self, sd_path, clear=None):
  48. self.calls.append(("run_pattern", sd_path, clear))
  49. def file_exists(self, sd_path):
  50. return True
  51. def upload_file(self, sd_path, data, directory):
  52. self.calls.append(("upload_file", sd_path))
  53. return {}
  54. @pytest.fixture(autouse=True)
  55. def clean_state():
  56. saved = (state.conn, state.current_playing_file, state.current_playlist,
  57. state.current_playlist_name, state.speed, state.clear_pattern_speed,
  58. execution.current_run)
  59. state.conn = None
  60. execution.current_run = None
  61. with patch.object(state, "save"):
  62. yield
  63. (state.conn, state.current_playing_file, state.current_playlist,
  64. state.current_playlist_name, state.speed, state.clear_pattern_speed,
  65. execution.current_run) = saved
  66. def _status(**over):
  67. base = {
  68. "state": "Run", "running": True, "file": "/patterns/star.thr",
  69. "progress": 0.5, "theta": 1.0, "rho": 0.5, "feed": 500, "uptime": 1000,
  70. "fw": "v0.1.3",
  71. "playlist": {"active": False, "index": 0, "total": 0, "name": "",
  72. "clearing": False, "quiet": False,
  73. "pause_remaining": -1, "pause_total": -1},
  74. }
  75. pl_over = over.pop("playlist", {})
  76. base.update(over)
  77. base["playlist"].update(pl_over)
  78. return base
  79. class TestMapping:
  80. def test_clear_mode_mapping(self):
  81. assert map_clear_mode("clear_from_in") == "in"
  82. assert map_clear_mode("clear_from_out") == "out"
  83. assert map_clear_mode("clear_sideway") == "sideway"
  84. assert map_clear_mode("adaptive") == "adaptive"
  85. assert map_clear_mode(None) == "none"
  86. assert map_clear_mode("bogus") == "none"
  87. def test_sd_path_mapping(self):
  88. assert _from_sd_path("/patterns/a/b.thr") == "./patterns/a/b.thr"
  89. assert _from_sd_path("/sd/patterns/x.thr") == "./patterns/x.thr"
  90. assert _from_sd_path("") is None
  91. class TestTranslateStatus:
  92. def test_offline(self):
  93. out = translate_status(None, BoardObserver())
  94. assert out["connection_status"] is False
  95. assert out["is_running"] is False
  96. assert out["playlist"] is None
  97. assert out["progress"] is None
  98. def test_running_pattern(self):
  99. obs = BoardObserver()
  100. obs.file_started_at = 90.0
  101. state.conn = FakeConn()
  102. out = translate_status(_status(progress=0.425), obs, now=100.0)
  103. assert out["is_running"] is True
  104. assert out["current_file"] == "./patterns/star.thr"
  105. assert out["progress"]["percentage"] == 42.5
  106. assert out["progress"]["elapsed_time"] == pytest.approx(10.0)
  107. # remaining = elapsed/fraction - elapsed
  108. assert out["progress"]["remaining_time"] == pytest.approx(10 / 0.425 - 10)
  109. assert out["is_paused"] is False
  110. def test_hold_is_paused(self):
  111. state.conn = FakeConn()
  112. out = translate_status(_status(state="Hold"), BoardObserver())
  113. assert out["is_paused"] is True
  114. def test_hold_substate_is_paused(self):
  115. # GRBL reports a substate suffix ("Hold:0") that must still read as paused.
  116. state.conn = FakeConn()
  117. out = translate_status(_status(state="Hold:0"), BoardObserver())
  118. assert out["is_paused"] is True
  119. def test_playlist_pause_countdown(self):
  120. state.conn = FakeConn()
  121. execution.current_run = RunContext(kind="playlist", playlist_name="fav",
  122. run_mode="indefinite")
  123. state.current_playlist = ["./patterns/a.thr", "./patterns/b.thr"]
  124. out = translate_status(_status(
  125. running=False, state="Idle", file="",
  126. playlist={"active": True, "index": 0, "total": 2, "name": "fav",
  127. "pause_remaining": 30, "pause_total": 60},
  128. ), BoardObserver())
  129. assert out["is_running"] is False
  130. assert out["pause_time_remaining"] == 30
  131. assert out["original_pause_time"] == 60
  132. assert out["playlist"]["name"] == "fav"
  133. assert out["playlist"]["mode"] == "indefinite"
  134. assert out["playlist"]["total_files"] == 2
  135. assert out["playlist"]["next_file"] == "./patterns/b.thr"
  136. assert out["playlist"]["shuffled"] is False
  137. def test_playlist_clearing_next_file(self):
  138. state.conn = FakeConn()
  139. execution.current_run = RunContext(kind="playlist", playlist_name="fav")
  140. state.current_playlist = ["./patterns/a.thr", "./patterns/b.thr"]
  141. out = translate_status(_status(
  142. playlist={"active": True, "index": 1, "total": 2, "name": "fav",
  143. "clearing": True},
  144. ), BoardObserver())
  145. # While clearing, "next" is the pattern the clear precedes.
  146. assert out["playlist"]["next_file"] == "./patterns/b.thr"
  147. assert out["is_clearing"] is True
  148. def test_shuffled_playlist_hides_next(self):
  149. state.conn = FakeConn()
  150. execution.current_run = RunContext(kind="playlist", playlist_name="fav",
  151. shuffle=True)
  152. state.current_playlist = ["./patterns/a.thr", "./patterns/b.thr"]
  153. out = translate_status(_status(
  154. playlist={"active": True, "index": 0, "total": 2, "name": "fav"},
  155. ), BoardObserver())
  156. assert out["playlist"]["shuffled"] is True
  157. assert out["playlist"]["next_file"] is None
  158. assert out["playlist"]["files"] # still served read-only
  159. class TestObserverEdges:
  160. @pytest.fixture
  161. def log_file(self, tmp_path):
  162. path = tmp_path / "execution_times.jsonl"
  163. with patch("modules.core.pattern_manager.EXECUTION_LOG_FILE", str(path)):
  164. yield path
  165. async def test_file_transition_logs_history(self, log_file):
  166. state.conn = FakeConn()
  167. obs = BoardObserver()
  168. await obs.process(_status(file="/patterns/a.thr", progress=0.2), now=0.0)
  169. await obs.process(_status(file="/patterns/a.thr", progress=0.99), now=100.0)
  170. await obs.process(_status(file="/patterns/b.thr", progress=0.0), now=110.0)
  171. rows = [json.loads(l) for l in log_file.read_text().splitlines()]
  172. assert len(rows) == 1
  173. assert rows[0]["pattern_name"] == "a.thr"
  174. assert rows[0]["completed"] is True
  175. assert state.current_playing_file == "./patterns/b.thr"
  176. async def test_aborted_run_not_completed(self, log_file):
  177. state.conn = FakeConn()
  178. obs = BoardObserver()
  179. await obs.process(_status(file="/patterns/a.thr", progress=0.3), now=0.0)
  180. await obs.process(_status(running=False, state="Idle", file=""), now=50.0)
  181. rows = [json.loads(l) for l in log_file.read_text().splitlines()]
  182. assert len(rows) == 1
  183. assert rows[0]["completed"] is False
  184. async def test_hold_time_excluded(self, log_file):
  185. state.conn = FakeConn()
  186. obs = BoardObserver()
  187. await obs.process(_status(file="/patterns/a.thr"), now=0.0)
  188. await obs.process(_status(file="/patterns/a.thr", state="Hold"), now=10.0)
  189. await obs.process(_status(file="/patterns/a.thr", state="Run", progress=0.99), now=40.0)
  190. await obs.process(_status(running=False, state="Idle", file=""), now=50.0)
  191. rows = [json.loads(l) for l in log_file.read_text().splitlines()]
  192. # 50s wall clock minus 30s hold = 20s
  193. assert rows[0]["actual_time_seconds"] == pytest.approx(20.0, abs=0.5)
  194. async def test_clear_files_not_logged(self, log_file):
  195. state.conn = FakeConn()
  196. obs = BoardObserver()
  197. await obs.process(_status(file="/patterns/clear_from_in.thr",
  198. playlist={"active": True, "clearing": True, "total": 2}), now=0.0)
  199. await obs.process(_status(file="/patterns/a.thr",
  200. playlist={"active": True, "total": 2}), now=30.0)
  201. assert not log_file.exists()
  202. async def test_clear_speed_shim(self, log_file):
  203. conn = FakeConn()
  204. state.conn = conn
  205. state.speed = 400
  206. state.clear_pattern_speed = 150
  207. obs = BoardObserver()
  208. await obs.process(_status(file="/patterns/clear_from_in.thr",
  209. playlist={"active": True, "clearing": True, "total": 1}), now=0.0)
  210. assert ("set_feed", 150) in conn.calls
  211. conn.calls.clear()
  212. await obs.process(_status(file="/patterns/a.thr",
  213. playlist={"active": True, "total": 1}), now=30.0)
  214. assert ("set_feed", 400) in conn.calls
  215. async def test_run_end_resets_state(self, log_file):
  216. state.conn = FakeConn()
  217. execution.current_run = RunContext(kind="playlist", playlist_name="fav")
  218. state.current_playlist = ["./patterns/a.thr"]
  219. obs = BoardObserver()
  220. await obs.process(_status(file="/patterns/a.thr",
  221. playlist={"active": True, "total": 1}), now=0.0)
  222. await obs.process(_status(running=False, state="Idle", file="",
  223. playlist={"active": False}), now=60.0)
  224. assert execution.current_run is None
  225. assert state.current_playlist is None
  226. assert state.current_playing_file is None
  227. async def test_reboot_guard(self, log_file):
  228. state.conn = FakeConn()
  229. obs = BoardObserver()
  230. await obs.process(_status(file="/patterns/a.thr", uptime=5000), now=0.0)
  231. await obs.process(_status(running=False, state="Idle", file="", uptime=10), now=10.0)
  232. # Reboot detected: context reset, no completion logged
  233. assert not log_file.exists()
  234. assert obs.prev is not None # keeps observing after reset
  235. class TestCommands:
  236. async def test_start_playlist_call_order(self):
  237. conn = FakeConn(statuses=[{"state": "Run", "running": True, "playlist": {"active": True}}])
  238. state.conn = conn
  239. state.speed = 300
  240. with patch("modules.core.playlist_manager.get_playlist",
  241. return_value={"name": "fav", "files": ["patterns/a.thr", "patterns/b.thr"]}), \
  242. patch("modules.core.pattern_manager._ensure_on_board"):
  243. await execution.start_playlist("fav", run_mode="indefinite", pause_time=30,
  244. clear_pattern="clear_from_in", shuffle=True)
  245. names = [c[0] for c in conn.calls]
  246. # stop-first (board was running), then NVS params, mirror, run
  247. assert names.index("stop") < names.index("set_setting")
  248. settings = [(c[1], c[2]) for c in conn.calls if c[0] == "set_setting"]
  249. assert ("Playlist/Mode", "loop") in settings
  250. assert ("Playlist/Shuffle", "ON") in settings
  251. assert ("Playlist/PauseTime", "30") in settings
  252. assert ("Playlist/ClearPattern", "in") in settings
  253. assert ("upload_file", "/playlists/fav.txt") in conn.calls
  254. assert ("run_command", "$Playlist/Run=fav") in conn.calls
  255. assert conn.calls.index(("upload_file", "/playlists/fav.txt")) < \
  256. conn.calls.index(("run_command", "$Playlist/Run=fav"))
  257. assert execution.current_run.kind == "playlist"
  258. assert state.current_playlist_name == "fav"
  259. async def test_start_playlist_empty_raises(self):
  260. state.conn = FakeConn()
  261. with patch("modules.core.playlist_manager.get_playlist",
  262. return_value={"name": "e", "files": []}):
  263. with pytest.raises(execution.ExecutionError):
  264. await execution.start_playlist("e")
  265. async def test_skip_routes_playlist_vs_single(self):
  266. conn = FakeConn()
  267. state.conn = conn
  268. execution.observer.last_raw = _status(playlist={"active": True, "total": 2})
  269. assert await execution.skip() is True
  270. assert ("skip",) in conn.calls
  271. conn.calls.clear()
  272. execution.observer.last_raw = _status() # single pattern running
  273. assert await execution.skip() is True
  274. assert ("stop",) in conn.calls
  275. conn.calls.clear()
  276. execution.observer.last_raw = _status(running=False, state="Idle")
  277. assert await execution.skip() is False
  278. async def test_run_pattern_uses_sand_run(self):
  279. conn = FakeConn()
  280. state.conn = conn
  281. state.speed = 250
  282. with patch("modules.core.pattern_manager._ensure_on_board"):
  283. await execution.run_pattern("./patterns/star.thr", "adaptive")
  284. assert ("run_pattern", "/patterns/star.thr", "adaptive") in conn.calls
  285. assert execution.current_run.kind == "pattern"
  286. async def test_force_stop_resets_even_on_error(self):
  287. conn = FakeConn()
  288. conn.stop = MagicMock(side_effect=RuntimeError("boom"))
  289. state.conn = conn
  290. state.current_playing_file = "./patterns/a.thr"
  291. execution.current_run = RunContext(kind="pattern")
  292. assert await execution.stop(force=True) is True
  293. assert execution.current_run is None
  294. assert state.current_playing_file is None