test_execution.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355
  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. def test_sd_path_refinds_relocated_pattern(self, tmp_path, monkeypatch):
  92. # A host custom pattern uploads to SD as 'patterns/<basename>'; the
  93. # board reports that SD path, and the mapping must re-find the host
  94. # copy (custom_patterns/...) rather than a path that doesn't exist.
  95. from modules.core import execution, pattern_manager
  96. (tmp_path / "custom_patterns").mkdir()
  97. (tmp_path / "custom_patterns" / "capybara.thr").write_text("0 0\n")
  98. monkeypatch.setattr(pattern_manager, "THETA_RHO_DIR", str(tmp_path))
  99. monkeypatch.setattr(execution, "_sd_path_cache", {})
  100. assert _from_sd_path("/sd/patterns/capybara.thr") == "./patterns/custom_patterns/capybara.thr"
  101. # Unknown-everywhere paths keep the literal mapping as fallback
  102. assert _from_sd_path("/sd/patterns/nope.thr") == "./patterns/nope.thr"
  103. class TestTranslateStatus:
  104. def test_offline(self):
  105. out = translate_status(None, BoardObserver())
  106. assert out["connection_status"] is False
  107. assert out["is_running"] is False
  108. assert out["playlist"] is None
  109. assert out["progress"] is None
  110. def test_running_pattern(self):
  111. obs = BoardObserver()
  112. obs.file_started_at = 90.0
  113. state.conn = FakeConn()
  114. out = translate_status(_status(progress=0.425), obs, now=100.0)
  115. assert out["is_running"] is True
  116. assert out["current_file"] == "./patterns/star.thr"
  117. assert out["progress"]["percentage"] == 42.5
  118. assert out["progress"]["elapsed_time"] == pytest.approx(10.0)
  119. # remaining = elapsed/fraction - elapsed
  120. assert out["progress"]["remaining_time"] == pytest.approx(10 / 0.425 - 10)
  121. assert out["is_paused"] is False
  122. def test_hold_is_paused(self):
  123. state.conn = FakeConn()
  124. out = translate_status(_status(state="Hold"), BoardObserver())
  125. assert out["is_paused"] is True
  126. def test_hold_substate_is_paused(self):
  127. # GRBL reports a substate suffix ("Hold:0") that must still read as paused.
  128. state.conn = FakeConn()
  129. out = translate_status(_status(state="Hold:0"), BoardObserver())
  130. assert out["is_paused"] is True
  131. def test_playlist_pause_countdown(self):
  132. state.conn = FakeConn()
  133. execution.current_run = RunContext(kind="playlist", playlist_name="fav",
  134. run_mode="indefinite")
  135. state.current_playlist = ["./patterns/a.thr", "./patterns/b.thr"]
  136. out = translate_status(_status(
  137. running=False, state="Idle", file="",
  138. playlist={"active": True, "index": 0, "total": 2, "name": "fav",
  139. "pause_remaining": 30, "pause_total": 60},
  140. ), BoardObserver())
  141. assert out["is_running"] is False
  142. assert out["pause_time_remaining"] == 30
  143. assert out["original_pause_time"] == 60
  144. assert out["playlist"]["name"] == "fav"
  145. assert out["playlist"]["mode"] == "indefinite"
  146. assert out["playlist"]["total_files"] == 2
  147. assert out["playlist"]["next_file"] == "./patterns/b.thr"
  148. assert out["playlist"]["shuffled"] is False
  149. def test_playlist_clearing_next_file(self):
  150. state.conn = FakeConn()
  151. execution.current_run = RunContext(kind="playlist", playlist_name="fav")
  152. state.current_playlist = ["./patterns/a.thr", "./patterns/b.thr"]
  153. out = translate_status(_status(
  154. playlist={"active": True, "index": 1, "total": 2, "name": "fav",
  155. "clearing": True},
  156. ), BoardObserver())
  157. # While clearing, "next" is the pattern the clear precedes.
  158. assert out["playlist"]["next_file"] == "./patterns/b.thr"
  159. assert out["is_clearing"] is True
  160. def test_shuffled_playlist_hides_next(self):
  161. state.conn = FakeConn()
  162. execution.current_run = RunContext(kind="playlist", playlist_name="fav",
  163. shuffle=True)
  164. state.current_playlist = ["./patterns/a.thr", "./patterns/b.thr"]
  165. out = translate_status(_status(
  166. playlist={"active": True, "index": 0, "total": 2, "name": "fav"},
  167. ), BoardObserver())
  168. assert out["playlist"]["shuffled"] is True
  169. assert out["playlist"]["next_file"] is None
  170. assert out["playlist"]["files"] # still served read-only
  171. class TestObserverEdges:
  172. @pytest.fixture
  173. def log_file(self, tmp_path):
  174. path = tmp_path / "execution_times.jsonl"
  175. with patch("modules.core.pattern_manager.EXECUTION_LOG_FILE", str(path)):
  176. yield path
  177. async def test_file_transition_logs_history(self, log_file):
  178. state.conn = FakeConn()
  179. obs = BoardObserver()
  180. await obs.process(_status(file="/patterns/a.thr", progress=0.2), now=0.0)
  181. await obs.process(_status(file="/patterns/a.thr", progress=0.99), now=100.0)
  182. await obs.process(_status(file="/patterns/b.thr", progress=0.0), now=110.0)
  183. rows = [json.loads(l) for l in log_file.read_text().splitlines()]
  184. assert len(rows) == 1
  185. assert rows[0]["pattern_name"] == "a.thr"
  186. assert rows[0]["completed"] is True
  187. assert state.current_playing_file == "./patterns/b.thr"
  188. async def test_aborted_run_not_completed(self, log_file):
  189. state.conn = FakeConn()
  190. obs = BoardObserver()
  191. await obs.process(_status(file="/patterns/a.thr", progress=0.3), now=0.0)
  192. await obs.process(_status(running=False, state="Idle", file=""), now=50.0)
  193. rows = [json.loads(l) for l in log_file.read_text().splitlines()]
  194. assert len(rows) == 1
  195. assert rows[0]["completed"] is False
  196. async def test_hold_time_excluded(self, log_file):
  197. state.conn = FakeConn()
  198. obs = BoardObserver()
  199. await obs.process(_status(file="/patterns/a.thr"), now=0.0)
  200. await obs.process(_status(file="/patterns/a.thr", state="Hold"), now=10.0)
  201. await obs.process(_status(file="/patterns/a.thr", state="Run", progress=0.99), now=40.0)
  202. await obs.process(_status(running=False, state="Idle", file=""), now=50.0)
  203. rows = [json.loads(l) for l in log_file.read_text().splitlines()]
  204. # 50s wall clock minus 30s hold = 20s
  205. assert rows[0]["actual_time_seconds"] == pytest.approx(20.0, abs=0.5)
  206. async def test_clear_files_not_logged(self, log_file):
  207. state.conn = FakeConn()
  208. obs = BoardObserver()
  209. await obs.process(_status(file="/patterns/clear_from_in.thr",
  210. playlist={"active": True, "clearing": True, "total": 2}), now=0.0)
  211. await obs.process(_status(file="/patterns/a.thr",
  212. playlist={"active": True, "total": 2}), now=30.0)
  213. assert not log_file.exists()
  214. async def test_clear_speed_shim(self, log_file):
  215. conn = FakeConn()
  216. state.conn = conn
  217. state.speed = 400
  218. state.clear_pattern_speed = 150
  219. obs = BoardObserver()
  220. await obs.process(_status(file="/patterns/clear_from_in.thr",
  221. playlist={"active": True, "clearing": True, "total": 1}), now=0.0)
  222. assert ("set_feed", 150) in conn.calls
  223. conn.calls.clear()
  224. await obs.process(_status(file="/patterns/a.thr",
  225. playlist={"active": True, "total": 1}), now=30.0)
  226. assert ("set_feed", 400) in conn.calls
  227. async def test_run_end_resets_state(self, log_file):
  228. state.conn = FakeConn()
  229. execution.current_run = RunContext(kind="playlist", playlist_name="fav")
  230. state.current_playlist = ["./patterns/a.thr"]
  231. obs = BoardObserver()
  232. await obs.process(_status(file="/patterns/a.thr",
  233. playlist={"active": True, "total": 1}), now=0.0)
  234. await obs.process(_status(running=False, state="Idle", file="",
  235. playlist={"active": False}), now=60.0)
  236. assert execution.current_run is None
  237. assert state.current_playlist is None
  238. assert state.current_playing_file is None
  239. async def test_reboot_guard(self, log_file):
  240. state.conn = FakeConn()
  241. obs = BoardObserver()
  242. await obs.process(_status(file="/patterns/a.thr", uptime=5000), now=0.0)
  243. await obs.process(_status(running=False, state="Idle", file="", uptime=10), now=10.0)
  244. # Reboot detected: context reset, no completion logged
  245. assert not log_file.exists()
  246. assert obs.prev is not None # keeps observing after reset
  247. class TestCommands:
  248. async def test_start_playlist_call_order(self):
  249. conn = FakeConn(statuses=[{"state": "Run", "running": True, "playlist": {"active": True}}])
  250. state.conn = conn
  251. state.speed = 300
  252. with patch("modules.core.playlist_manager.get_playlist",
  253. return_value={"name": "fav", "files": ["patterns/a.thr", "patterns/b.thr"]}), \
  254. patch("modules.core.pattern_manager._ensure_on_board"):
  255. await execution.start_playlist("fav", run_mode="indefinite", pause_time=30,
  256. clear_pattern="clear_from_in", shuffle=True)
  257. names = [c[0] for c in conn.calls]
  258. # stop-first (board was running), then NVS params, mirror, run
  259. assert names.index("stop") < names.index("set_setting")
  260. settings = [(c[1], c[2]) for c in conn.calls if c[0] == "set_setting"]
  261. assert ("Playlist/Mode", "loop") in settings
  262. assert ("Playlist/Shuffle", "ON") in settings
  263. assert ("Playlist/PauseTime", "30") in settings
  264. assert ("Playlist/ClearPattern", "in") in settings
  265. assert ("upload_file", "/playlists/fav.txt") in conn.calls
  266. assert ("run_command", "$Playlist/Run=fav") in conn.calls
  267. assert conn.calls.index(("upload_file", "/playlists/fav.txt")) < \
  268. conn.calls.index(("run_command", "$Playlist/Run=fav"))
  269. assert execution.current_run.kind == "playlist"
  270. assert state.current_playlist_name == "fav"
  271. async def test_start_playlist_empty_raises(self):
  272. state.conn = FakeConn()
  273. with patch("modules.core.playlist_manager.get_playlist",
  274. return_value={"name": "e", "files": []}):
  275. with pytest.raises(execution.ExecutionError):
  276. await execution.start_playlist("e")
  277. async def test_skip_routes_playlist_vs_single(self):
  278. conn = FakeConn()
  279. state.conn = conn
  280. execution.observer.last_raw = _status(playlist={"active": True, "total": 2})
  281. assert await execution.skip() is True
  282. assert ("skip",) in conn.calls
  283. conn.calls.clear()
  284. execution.observer.last_raw = _status() # single pattern running
  285. assert await execution.skip() is True
  286. assert ("stop",) in conn.calls
  287. conn.calls.clear()
  288. execution.observer.last_raw = _status(running=False, state="Idle")
  289. assert await execution.skip() is False
  290. async def test_run_pattern_uses_sand_run(self):
  291. conn = FakeConn()
  292. state.conn = conn
  293. state.speed = 250
  294. with patch("modules.core.pattern_manager._ensure_on_board"):
  295. await execution.run_pattern("./patterns/star.thr", "adaptive")
  296. assert ("run_pattern", "/patterns/star.thr", "adaptive") in conn.calls
  297. assert execution.current_run.kind == "pattern"
  298. async def test_force_stop_resets_even_on_error(self):
  299. conn = FakeConn()
  300. conn.stop = MagicMock(side_effect=RuntimeError("boom"))
  301. state.conn = conn
  302. state.current_playing_file = "./patterns/a.thr"
  303. execution.current_run = RunContext(kind="pattern")
  304. assert await execution.stop(force=True) is True
  305. assert execution.current_run is None
  306. assert state.current_playing_file is None