test_printer_manager.py 51 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375
  1. """Unit tests for PrinterManager service.
  2. Tests printer connection management, status tracking, and print control.
  3. """
  4. import logging
  5. from unittest.mock import AsyncMock, MagicMock, patch
  6. import pytest
  7. from backend.app.services.printer_manager import (
  8. PrinterManager,
  9. get_derived_status_name,
  10. has_stg_cur_idle_bug,
  11. init_printer_connections,
  12. parse_plate_id,
  13. printer_state_to_dict,
  14. supports_chamber_temp,
  15. supports_drying,
  16. )
  17. class TestPrinterManager:
  18. """Tests for PrinterManager class."""
  19. @pytest.fixture
  20. def manager(self):
  21. """Create a fresh PrinterManager instance."""
  22. return PrinterManager()
  23. @pytest.fixture
  24. def mock_printer(self):
  25. """Create a mock Printer object."""
  26. printer = MagicMock()
  27. printer.id = 1
  28. printer.ip_address = "192.168.1.100"
  29. printer.serial_number = "00M09A123456789"
  30. printer.access_code = "12345678"
  31. printer.is_active = True
  32. return printer
  33. @pytest.fixture
  34. def mock_client(self):
  35. """Create a mock BambuMQTTClient."""
  36. client = MagicMock()
  37. client.state = MagicMock()
  38. client.state.connected = True
  39. client.state.state = "IDLE"
  40. client.state.progress = 0
  41. client.state.temperatures = {"nozzle": 25, "bed": 25}
  42. client.state.raw_data = {}
  43. client.logging_enabled = False
  44. return client
  45. # ========================================================================
  46. # Tests for initialization
  47. # ========================================================================
  48. def test_init_creates_empty_clients_dict(self, manager):
  49. """Verify manager initializes with empty clients dict."""
  50. assert manager._clients == {}
  51. def test_init_callbacks_are_none(self, manager):
  52. """Verify all callbacks are initially None."""
  53. assert manager._on_print_start is None
  54. assert manager._on_print_complete is None
  55. assert manager._on_status_change is None
  56. assert manager._on_ams_change is None
  57. def test_init_loop_is_none(self, manager):
  58. """Verify event loop is initially None."""
  59. assert manager._loop is None
  60. # ========================================================================
  61. # Tests for callback setters
  62. # ========================================================================
  63. def test_set_event_loop(self, manager):
  64. """Verify event loop can be set."""
  65. mock_loop = MagicMock()
  66. manager.set_event_loop(mock_loop)
  67. assert manager._loop == mock_loop
  68. def test_set_print_start_callback(self, manager):
  69. """Verify print start callback can be set."""
  70. callback = MagicMock()
  71. manager.set_print_start_callback(callback)
  72. assert manager._on_print_start == callback
  73. def test_set_print_complete_callback(self, manager):
  74. """Verify print complete callback can be set."""
  75. callback = MagicMock()
  76. manager.set_print_complete_callback(callback)
  77. assert manager._on_print_complete == callback
  78. def test_set_status_change_callback(self, manager):
  79. """Verify status change callback can be set."""
  80. callback = MagicMock()
  81. manager.set_status_change_callback(callback)
  82. assert manager._on_status_change == callback
  83. def test_set_ams_change_callback(self, manager):
  84. """Verify AMS change callback can be set."""
  85. callback = MagicMock()
  86. manager.set_ams_change_callback(callback)
  87. assert manager._on_ams_change == callback
  88. # ========================================================================
  89. # Tests for _schedule_async
  90. # ========================================================================
  91. def test_schedule_async_with_running_loop(self, manager):
  92. """Verify async coroutine is scheduled when loop is running."""
  93. mock_loop = MagicMock()
  94. mock_loop.is_running.return_value = True
  95. manager._loop = mock_loop
  96. async def dummy_coro():
  97. pass
  98. coro = dummy_coro()
  99. manager._schedule_async(coro)
  100. mock_loop.is_running.assert_called_once()
  101. # Clean up the coroutine
  102. coro.close()
  103. def test_schedule_async_without_loop(self, manager):
  104. """Verify nothing happens when no loop is set."""
  105. async def dummy_coro():
  106. pass
  107. coro = dummy_coro()
  108. # Should not raise
  109. manager._schedule_async(coro)
  110. coro.close()
  111. def test_schedule_async_with_stopped_loop(self, manager):
  112. """Verify nothing happens when loop is not running."""
  113. mock_loop = MagicMock()
  114. mock_loop.is_running.return_value = False
  115. manager._loop = mock_loop
  116. async def dummy_coro():
  117. pass
  118. coro = dummy_coro()
  119. manager._schedule_async(coro)
  120. coro.close()
  121. # ========================================================================
  122. # Tests for connect_printer
  123. # ========================================================================
  124. @pytest.mark.asyncio
  125. async def test_connect_printer_creates_client(self, manager, mock_printer):
  126. """Verify connecting creates an MQTT client."""
  127. with patch("backend.app.services.printer_manager.BambuMQTTClient") as MockClient:
  128. mock_instance = MagicMock()
  129. mock_instance.state = MagicMock()
  130. mock_instance.state.connected = True
  131. MockClient.return_value = mock_instance
  132. result = await manager.connect_printer(mock_printer)
  133. MockClient.assert_called_once()
  134. mock_instance.connect.assert_called_once()
  135. assert mock_printer.id in manager._clients
  136. assert result is True
  137. @pytest.mark.asyncio
  138. async def test_connect_printer_disconnects_existing(self, manager, mock_printer, mock_client):
  139. """Verify connecting disconnects existing client first."""
  140. manager._clients[mock_printer.id] = mock_client
  141. with patch("backend.app.services.printer_manager.BambuMQTTClient") as MockClient:
  142. new_client = MagicMock()
  143. new_client.state = MagicMock()
  144. new_client.state.connected = True
  145. MockClient.return_value = new_client
  146. await manager.connect_printer(mock_printer)
  147. mock_client.disconnect.assert_called_once()
  148. @pytest.mark.asyncio
  149. async def test_connect_printer_returns_false_on_failure(self, manager, mock_printer):
  150. """Verify returns False when connection fails."""
  151. with patch("backend.app.services.printer_manager.BambuMQTTClient") as MockClient:
  152. mock_instance = MagicMock()
  153. mock_instance.state = MagicMock()
  154. mock_instance.state.connected = False
  155. MockClient.return_value = mock_instance
  156. result = await manager.connect_printer(mock_printer)
  157. assert result is False
  158. # ========================================================================
  159. # Tests for disconnect_printer
  160. # ========================================================================
  161. def test_disconnect_printer_removes_client(self, manager, mock_client):
  162. """Verify disconnecting removes and disconnects client."""
  163. manager._clients[1] = mock_client
  164. manager.disconnect_printer(1)
  165. mock_client.disconnect.assert_called_once()
  166. assert 1 not in manager._clients
  167. def test_disconnect_printer_handles_missing(self, manager):
  168. """Verify disconnecting non-existent printer doesn't raise."""
  169. manager.disconnect_printer(999) # Should not raise
  170. # ========================================================================
  171. # Tests for disconnect_all
  172. # ========================================================================
  173. def test_disconnect_all_disconnects_all_clients(self, manager):
  174. """Verify all clients are disconnected."""
  175. client1 = MagicMock()
  176. client2 = MagicMock()
  177. manager._clients[1] = client1
  178. manager._clients[2] = client2
  179. manager.disconnect_all()
  180. client1.disconnect.assert_called_once()
  181. client2.disconnect.assert_called_once()
  182. assert len(manager._clients) == 0
  183. # ========================================================================
  184. # Tests for get_status
  185. # ========================================================================
  186. def test_get_status_returns_state(self, manager, mock_client):
  187. """Verify get_status returns client state."""
  188. manager._clients[1] = mock_client
  189. result = manager.get_status(1)
  190. mock_client.check_staleness.assert_called_once()
  191. assert result == mock_client.state
  192. def test_get_status_returns_none_for_unknown(self, manager):
  193. """Verify get_status returns None for unknown printer."""
  194. result = manager.get_status(999)
  195. assert result is None
  196. # ========================================================================
  197. # Tests for get_all_statuses
  198. # ========================================================================
  199. def test_get_all_statuses_returns_all(self, manager):
  200. """Verify all statuses are returned."""
  201. client1 = MagicMock()
  202. client1.state = MagicMock(connected=True)
  203. client2 = MagicMock()
  204. client2.state = MagicMock(connected=False)
  205. manager._clients[1] = client1
  206. manager._clients[2] = client2
  207. result = manager.get_all_statuses()
  208. assert len(result) == 2
  209. assert 1 in result
  210. assert 2 in result
  211. client1.check_staleness.assert_called_once()
  212. client2.check_staleness.assert_called_once()
  213. # ========================================================================
  214. # Tests for is_connected
  215. # ========================================================================
  216. def test_is_connected_returns_true(self, manager, mock_client):
  217. """Verify is_connected returns True for connected printer."""
  218. mock_client.check_staleness.return_value = True
  219. manager._clients[1] = mock_client
  220. result = manager.is_connected(1)
  221. assert result is True
  222. def test_is_connected_returns_false_for_unknown(self, manager):
  223. """Verify is_connected returns False for unknown printer."""
  224. result = manager.is_connected(999)
  225. assert result is False
  226. # ========================================================================
  227. # Tests for get_client
  228. # ========================================================================
  229. def test_get_client_returns_client(self, manager, mock_client):
  230. """Verify get_client returns the client."""
  231. manager._clients[1] = mock_client
  232. result = manager.get_client(1)
  233. assert result == mock_client
  234. def test_get_client_returns_none_for_unknown(self, manager):
  235. """Verify get_client returns None for unknown printer."""
  236. result = manager.get_client(999)
  237. assert result is None
  238. # ========================================================================
  239. # Tests for mark_printer_offline
  240. # ========================================================================
  241. def test_mark_printer_offline_updates_state(self, manager, mock_client):
  242. """Verify mark_printer_offline updates client state."""
  243. mock_client.state.connected = True
  244. manager._clients[1] = mock_client
  245. manager.mark_printer_offline(1)
  246. assert mock_client.state.connected is False
  247. assert mock_client.state.state == "unknown"
  248. def test_mark_printer_offline_triggers_callback(self, manager, mock_client):
  249. """Verify mark_printer_offline triggers status callback."""
  250. mock_client.state.connected = True
  251. manager._clients[1] = mock_client
  252. # Callback must return a coroutine
  253. async def async_callback(printer_id, state):
  254. pass
  255. manager._on_status_change = async_callback
  256. # Need a running loop for callback
  257. mock_loop = MagicMock()
  258. mock_loop.is_running.return_value = True
  259. manager._loop = mock_loop
  260. manager.mark_printer_offline(1)
  261. # Callback should be scheduled via run_coroutine_threadsafe
  262. mock_loop.is_running.assert_called()
  263. # State should be updated
  264. assert mock_client.state.connected is False
  265. def test_mark_printer_offline_handles_unknown(self, manager):
  266. """Verify mark_printer_offline handles unknown printer."""
  267. manager.mark_printer_offline(999) # Should not raise
  268. def test_mark_printer_offline_skips_already_offline(self, manager, mock_client):
  269. """Verify mark_printer_offline skips already offline printer."""
  270. mock_client.state.connected = False
  271. manager._clients[1] = mock_client
  272. manager.mark_printer_offline(1)
  273. # State should remain unchanged
  274. assert mock_client.state.connected is False
  275. # ========================================================================
  276. # Tests for start_print
  277. # ========================================================================
  278. def test_start_print_calls_client(self, manager, mock_client):
  279. """Verify start_print calls client method."""
  280. mock_client.start_print.return_value = True
  281. manager._clients[1] = mock_client
  282. result = manager.start_print(1, "test.gcode")
  283. mock_client.start_print.assert_called_once_with(
  284. "test.gcode",
  285. 1,
  286. ams_mapping=None,
  287. timelapse=False,
  288. bed_levelling=True,
  289. flow_cali=False,
  290. vibration_cali=True,
  291. layer_inspect=False,
  292. use_ams=True,
  293. )
  294. assert result is True
  295. def test_start_print_returns_false_for_unknown(self, manager):
  296. """Verify start_print returns False for unknown printer."""
  297. result = manager.start_print(999, "test.gcode")
  298. assert result is False
  299. def test_start_print_logs_print_command_with_caller(self, manager, mock_client, caplog):
  300. """Verify start_print logs PRINT COMMAND with caller info (#374)."""
  301. mock_client.start_print.return_value = True
  302. manager._clients[1] = mock_client
  303. with caplog.at_level(logging.INFO, logger="backend.app.services.printer_manager"):
  304. manager.start_print(1, "benchy.3mf")
  305. print_cmd_logs = [r for r in caplog.records if "PRINT COMMAND" in r.message]
  306. assert len(print_cmd_logs) == 1
  307. log_msg = print_cmd_logs[0].message
  308. assert "printer=1" in log_msg
  309. assert "file=benchy.3mf" in log_msg
  310. assert "caller=" in log_msg
  311. def test_start_print_logs_even_when_printer_unknown(self, manager, caplog):
  312. """Verify PRINT COMMAND is logged even for unknown printers (#374)."""
  313. with caplog.at_level(logging.INFO, logger="backend.app.services.printer_manager"):
  314. result = manager.start_print(999, "ghost.3mf")
  315. assert result is False
  316. print_cmd_logs = [r for r in caplog.records if "PRINT COMMAND" in r.message]
  317. assert len(print_cmd_logs) == 1
  318. # ========================================================================
  319. # Tests for stop_print
  320. # ========================================================================
  321. def test_stop_print_calls_client(self, manager, mock_client):
  322. """Verify stop_print calls client method."""
  323. mock_client.stop_print.return_value = True
  324. manager._clients[1] = mock_client
  325. result = manager.stop_print(1)
  326. mock_client.stop_print.assert_called_once()
  327. assert result is True
  328. def test_stop_print_returns_false_for_unknown(self, manager):
  329. """Verify stop_print returns False for unknown printer."""
  330. result = manager.stop_print(999)
  331. assert result is False
  332. # ========================================================================
  333. # Tests for wait_for_cooldown
  334. # ========================================================================
  335. @pytest.mark.asyncio
  336. async def test_wait_for_cooldown_returns_true_when_cool(self, manager, mock_client):
  337. """Verify wait_for_cooldown returns True when printer is cool."""
  338. mock_client.state.connected = True
  339. mock_client.state.temperatures = {"nozzle": 40, "bed": 30}
  340. mock_client.check_staleness.return_value = True
  341. manager._clients[1] = mock_client
  342. result = await manager.wait_for_cooldown(1, target_temp=50)
  343. assert result is True
  344. @pytest.mark.asyncio
  345. async def test_wait_for_cooldown_returns_false_on_disconnect(self, manager, mock_client):
  346. """Verify wait_for_cooldown returns False when printer disconnects."""
  347. mock_client.state.connected = False
  348. mock_client.check_staleness.return_value = False
  349. manager._clients[1] = mock_client
  350. result = await manager.wait_for_cooldown(1, target_temp=50, timeout=1)
  351. assert result is False
  352. @pytest.mark.asyncio
  353. async def test_wait_for_cooldown_returns_false_for_unknown(self, manager):
  354. """Verify wait_for_cooldown returns False for unknown printer."""
  355. result = await manager.wait_for_cooldown(999, target_temp=50, timeout=1)
  356. assert result is False
  357. @pytest.mark.asyncio
  358. async def test_wait_for_cooldown_checks_both_nozzles(self, manager, mock_client):
  359. """Verify wait_for_cooldown checks both nozzles for dual extruders."""
  360. mock_client.state.connected = True
  361. mock_client.state.temperatures = {"nozzle": 40, "nozzle_2": 45, "bed": 30}
  362. mock_client.check_staleness.return_value = True
  363. manager._clients[1] = mock_client
  364. result = await manager.wait_for_cooldown(1, target_temp=50)
  365. assert result is True
  366. # ========================================================================
  367. # Tests for logging methods
  368. # ========================================================================
  369. def test_enable_logging_calls_client(self, manager, mock_client):
  370. """Verify enable_logging calls client method."""
  371. manager._clients[1] = mock_client
  372. result = manager.enable_logging(1, True)
  373. mock_client.enable_logging.assert_called_once_with(True)
  374. assert result is True
  375. def test_enable_logging_returns_false_for_unknown(self, manager):
  376. """Verify enable_logging returns False for unknown printer."""
  377. result = manager.enable_logging(999, True)
  378. assert result is False
  379. def test_get_logs_returns_logs(self, manager, mock_client):
  380. """Verify get_logs returns client logs."""
  381. mock_logs = [MagicMock(), MagicMock()]
  382. mock_client.get_logs.return_value = mock_logs
  383. manager._clients[1] = mock_client
  384. result = manager.get_logs(1)
  385. assert result == mock_logs
  386. def test_get_logs_returns_empty_for_unknown(self, manager):
  387. """Verify get_logs returns empty list for unknown printer."""
  388. result = manager.get_logs(999)
  389. assert result == []
  390. def test_clear_logs_calls_client(self, manager, mock_client):
  391. """Verify clear_logs calls client method."""
  392. manager._clients[1] = mock_client
  393. result = manager.clear_logs(1)
  394. mock_client.clear_logs.assert_called_once()
  395. assert result is True
  396. def test_clear_logs_returns_false_for_unknown(self, manager):
  397. """Verify clear_logs returns False for unknown printer."""
  398. result = manager.clear_logs(999)
  399. assert result is False
  400. def test_is_logging_enabled_returns_status(self, manager, mock_client):
  401. """Verify is_logging_enabled returns client status."""
  402. mock_client.logging_enabled = True
  403. manager._clients[1] = mock_client
  404. result = manager.is_logging_enabled(1)
  405. assert result is True
  406. def test_is_logging_enabled_returns_false_for_unknown(self, manager):
  407. """Verify is_logging_enabled returns False for unknown printer."""
  408. result = manager.is_logging_enabled(999)
  409. assert result is False
  410. # ========================================================================
  411. # Tests for request_status_update
  412. # ========================================================================
  413. def test_request_status_update_calls_client(self, manager, mock_client):
  414. """Verify request_status_update calls client method."""
  415. mock_client.request_status_update.return_value = True
  416. manager._clients[1] = mock_client
  417. result = manager.request_status_update(1)
  418. mock_client.request_status_update.assert_called_once()
  419. assert result is True
  420. def test_request_status_update_returns_false_for_unknown(self, manager):
  421. """Verify request_status_update returns False for unknown printer."""
  422. result = manager.request_status_update(999)
  423. assert result is False
  424. # ========================================================================
  425. # Tests for test_connection
  426. # ========================================================================
  427. @pytest.mark.asyncio
  428. async def test_test_connection_success(self, manager):
  429. """Verify test_connection returns success on connection."""
  430. with patch("backend.app.services.printer_manager.BambuMQTTClient") as MockClient:
  431. mock_instance = MagicMock()
  432. mock_instance.state = MagicMock()
  433. mock_instance.state.connected = True
  434. mock_instance.state.state = "IDLE"
  435. mock_instance.state.raw_data = {"device_model": "X1C"}
  436. MockClient.return_value = mock_instance
  437. result = await manager.test_connection("192.168.1.100", "00M09A123456789", "12345678")
  438. assert result["success"] is True
  439. assert result["state"] == "IDLE"
  440. assert result["model"] == "X1C"
  441. mock_instance.disconnect.assert_called_once()
  442. @pytest.mark.asyncio
  443. async def test_test_connection_failure(self, manager):
  444. """Verify test_connection returns failure on connection error."""
  445. with patch("backend.app.services.printer_manager.BambuMQTTClient") as MockClient:
  446. mock_instance = MagicMock()
  447. mock_instance.state = MagicMock()
  448. mock_instance.state.connected = False
  449. MockClient.return_value = mock_instance
  450. result = await manager.test_connection("192.168.1.100", "00M09A123456789", "12345678")
  451. assert result["success"] is False
  452. assert result["state"] is None
  453. mock_instance.disconnect.assert_called_once()
  454. # ========================================================================
  455. # Tests for current print user tracking (Issue #206)
  456. # ========================================================================
  457. def test_set_current_print_user(self, manager):
  458. """Verify current print user can be set."""
  459. manager.set_current_print_user(1, 42, "testuser")
  460. assert 1 in manager._current_print_user
  461. assert manager._current_print_user[1]["user_id"] == 42
  462. assert manager._current_print_user[1]["username"] == "testuser"
  463. def test_get_current_print_user_returns_user(self, manager):
  464. """Verify get_current_print_user returns the stored user."""
  465. manager.set_current_print_user(1, 42, "testuser")
  466. result = manager.get_current_print_user(1)
  467. assert result is not None
  468. assert result["user_id"] == 42
  469. assert result["username"] == "testuser"
  470. def test_get_current_print_user_returns_none_for_unknown(self, manager):
  471. """Verify get_current_print_user returns None for unknown printer."""
  472. result = manager.get_current_print_user(999)
  473. assert result is None
  474. def test_clear_current_print_user(self, manager):
  475. """Verify current print user can be cleared."""
  476. manager.set_current_print_user(1, 42, "testuser")
  477. manager.clear_current_print_user(1)
  478. result = manager.get_current_print_user(1)
  479. assert result is None
  480. def test_clear_current_print_user_no_error_for_unknown(self, manager):
  481. """Verify clearing unknown printer doesn't raise error."""
  482. # Should not raise
  483. manager.clear_current_print_user(999)
  484. def test_set_current_print_user_overwrites_existing(self, manager):
  485. """Verify setting user overwrites existing value."""
  486. manager.set_current_print_user(1, 42, "user1")
  487. manager.set_current_print_user(1, 99, "user2")
  488. result = manager.get_current_print_user(1)
  489. assert result["user_id"] == 99
  490. assert result["username"] == "user2"
  491. def test_multiple_printers_have_separate_users(self, manager):
  492. """Verify each printer tracks its own user separately."""
  493. manager.set_current_print_user(1, 42, "user1")
  494. manager.set_current_print_user(2, 99, "user2")
  495. result1 = manager.get_current_print_user(1)
  496. result2 = manager.get_current_print_user(2)
  497. assert result1["username"] == "user1"
  498. assert result2["username"] == "user2"
  499. class TestPrinterStateToDict:
  500. """Tests for printer_state_to_dict helper function."""
  501. @pytest.fixture
  502. def mock_state(self):
  503. """Create a mock PrinterState."""
  504. state = MagicMock()
  505. state.connected = True
  506. state.state = "RUNNING"
  507. state.current_print = "test.3mf"
  508. state.subtask_name = "Test Print"
  509. state.gcode_file = "/sdcard/test.gcode"
  510. state.progress = 50
  511. state.remaining_time = 3600
  512. state.layer_num = 10
  513. state.total_layers = 20
  514. state.temperatures = {"nozzle": 200, "bed": 60}
  515. state.hms_errors = []
  516. state.ams_status_main = 0
  517. state.ams_status_sub = 0
  518. state.tray_now = "1"
  519. state.wifi_signal = -50
  520. state.raw_data = {}
  521. state.stg_cur = -1 # No calibration stage active
  522. state.firmware_version = None
  523. return state
  524. def test_basic_conversion(self, mock_state):
  525. """Verify basic state fields are converted."""
  526. result = printer_state_to_dict(mock_state)
  527. assert result["connected"] is True
  528. assert result["state"] == "RUNNING"
  529. assert result["progress"] == 50
  530. assert result["temperatures"] == {"nozzle": 200, "bed": 60}
  531. def test_ams_data_parsing(self, mock_state):
  532. """Verify AMS data is parsed correctly."""
  533. mock_state.raw_data = {
  534. "ams": [
  535. {
  536. "id": 0,
  537. "humidity_raw": 45,
  538. "temp": 25,
  539. "tray": [
  540. {
  541. "id": 0,
  542. "tray_color": "FF0000",
  543. "tray_type": "PLA",
  544. "tray_sub_brands": "Generic",
  545. "remain": 80,
  546. "k": 0.5,
  547. "tag_uid": "ABC123",
  548. "tray_uuid": "uuid-123",
  549. }
  550. ],
  551. }
  552. ]
  553. }
  554. result = printer_state_to_dict(mock_state)
  555. assert result["ams"] is not None
  556. assert len(result["ams"]) == 1
  557. assert result["ams"][0]["humidity"] == 45
  558. assert len(result["ams"][0]["tray"]) == 1
  559. assert result["ams"][0]["tray"][0]["tray_color"] == "FF0000"
  560. def test_empty_tag_uid_becomes_none(self, mock_state):
  561. """Verify empty tag_uid is converted to None."""
  562. mock_state.raw_data = {
  563. "ams": [
  564. {
  565. "id": 0,
  566. "tray": [
  567. {
  568. "id": 0,
  569. "tag_uid": "",
  570. "tray_uuid": "00000000000000000000000000000000",
  571. }
  572. ],
  573. }
  574. ]
  575. }
  576. result = printer_state_to_dict(mock_state)
  577. assert result["ams"][0]["tray"][0]["tag_uid"] is None
  578. assert result["ams"][0]["tray"][0]["tray_uuid"] is None
  579. def test_zero_tag_uid_becomes_none(self, mock_state):
  580. """Verify zero tag_uid is converted to None."""
  581. mock_state.raw_data = {
  582. "ams": [
  583. {
  584. "id": 0,
  585. "tray": [
  586. {
  587. "id": 0,
  588. "tag_uid": "0000000000000000",
  589. }
  590. ],
  591. }
  592. ]
  593. }
  594. result = printer_state_to_dict(mock_state)
  595. assert result["ams"][0]["tray"][0]["tag_uid"] is None
  596. def test_vt_tray_parsing(self, mock_state):
  597. """Verify virtual tray is parsed correctly as a list."""
  598. mock_state.raw_data = {
  599. "vt_tray": [
  600. {
  601. "tray_color": "00FF00",
  602. "tray_type": "PETG",
  603. "tray_sub_brands": "Generic",
  604. "remain": 60,
  605. "tag_uid": "VT123",
  606. }
  607. ]
  608. }
  609. result = printer_state_to_dict(mock_state)
  610. assert isinstance(result["vt_tray"], list)
  611. assert len(result["vt_tray"]) == 1
  612. assert result["vt_tray"][0]["id"] == 254
  613. assert result["vt_tray"][0]["tray_color"] == "00FF00"
  614. assert result["vt_tray"][0]["tray_type"] == "PETG"
  615. def test_vt_tray_dict_normalized_to_list(self, mock_state):
  616. """Verify vt_tray as a raw dict (from MQTT) is normalized to a list."""
  617. mock_state.raw_data = {
  618. "vt_tray": {
  619. "id": "254",
  620. "tray_color": "FF0000",
  621. "tray_type": "PLA",
  622. "tray_sub_brands": "Generic",
  623. "tag_uid": "0000000000000000",
  624. "tray_uuid": "00000000000000000000000000000000",
  625. "remain": 0,
  626. }
  627. }
  628. result = printer_state_to_dict(mock_state)
  629. assert isinstance(result["vt_tray"], list)
  630. assert len(result["vt_tray"]) == 1
  631. assert result["vt_tray"][0]["tray_color"] == "FF0000"
  632. assert result["vt_tray"][0]["tray_type"] == "PLA"
  633. assert result["vt_tray"][0]["tag_uid"] is None
  634. assert result["vt_tray"][0]["tray_uuid"] is None
  635. def test_vt_tray_non_list_non_dict_ignored(self, mock_state):
  636. """Verify unexpected vt_tray types (e.g. string) produce empty list."""
  637. mock_state.raw_data = {"vt_tray": "unexpected_string"}
  638. result = printer_state_to_dict(mock_state)
  639. assert result["vt_tray"] == []
  640. def test_hms_errors_conversion(self, mock_state):
  641. """Verify HMS errors are converted correctly."""
  642. error = MagicMock()
  643. error.code = "0700_0100"
  644. error.attr = 1
  645. error.module = "AMS"
  646. error.severity = 2
  647. mock_state.hms_errors = [error]
  648. result = printer_state_to_dict(mock_state)
  649. assert len(result["hms_errors"]) == 1
  650. assert result["hms_errors"][0]["code"] == "0700_0100"
  651. assert result["hms_errors"][0]["module"] == "AMS"
  652. def test_cover_url_added_for_running_print(self, mock_state):
  653. """Verify cover_url is added for running prints."""
  654. result = printer_state_to_dict(mock_state, printer_id=1)
  655. assert result["cover_url"] == "/api/v1/printers/1/cover"
  656. def test_current_plate_id_extracted_from_gcode_file(self, mock_state):
  657. """Verify current_plate_id is parsed from a Bambu plate path (#881)."""
  658. mock_state.gcode_file = "/Metadata/plate_3.gcode"
  659. result = printer_state_to_dict(mock_state)
  660. assert result["current_plate_id"] == 3
  661. def test_current_plate_id_none_when_no_plate_segment(self, mock_state):
  662. """Verify current_plate_id stays None when gcode_file has no plate marker."""
  663. mock_state.gcode_file = "/sdcard/test.gcode"
  664. result = printer_state_to_dict(mock_state)
  665. assert result["current_plate_id"] is None
  666. def test_cover_url_none_when_not_running(self, mock_state):
  667. """Verify cover_url is None when not printing."""
  668. mock_state.state = "IDLE"
  669. result = printer_state_to_dict(mock_state, printer_id=1)
  670. assert result["cover_url"] is None
  671. def test_ams_ht_detection(self, mock_state):
  672. """Verify AMS-HT is detected (1 tray vs 4)."""
  673. mock_state.raw_data = {
  674. "ams": [
  675. {
  676. "id": 0,
  677. "tray": [{"id": 0}], # Only 1 tray = AMS-HT
  678. }
  679. ]
  680. }
  681. result = printer_state_to_dict(mock_state)
  682. assert result["ams"][0]["is_ams_ht"] is True
  683. def test_regular_ams_detection(self, mock_state):
  684. """Verify regular AMS is detected (4 trays)."""
  685. mock_state.raw_data = {"ams": [{"id": 0, "tray": [{"id": 0}, {"id": 1}, {"id": 2}, {"id": 3}]}]}
  686. result = printer_state_to_dict(mock_state)
  687. assert result["ams"][0]["is_ams_ht"] is False
  688. def test_chamber_temp_filtered_for_p1s(self, mock_state):
  689. """Verify chamber temperature is filtered out for P1S (no chamber sensor)."""
  690. mock_state.temperatures = {
  691. "nozzle": 200,
  692. "bed": 60,
  693. "chamber": 5,
  694. "chamber_target": 0,
  695. "chamber_heating": False,
  696. }
  697. result = printer_state_to_dict(mock_state, model="P1S")
  698. assert "chamber" not in result["temperatures"]
  699. assert "chamber_target" not in result["temperatures"]
  700. assert "chamber_heating" not in result["temperatures"]
  701. assert result["temperatures"]["nozzle"] == 200
  702. assert result["temperatures"]["bed"] == 60
  703. def test_chamber_temp_kept_for_x1c(self, mock_state):
  704. """Verify chamber temperature is kept for X1C (has chamber sensor)."""
  705. mock_state.temperatures = {
  706. "nozzle": 200,
  707. "bed": 60,
  708. "chamber": 25,
  709. "chamber_target": 45,
  710. "chamber_heating": True,
  711. }
  712. result = printer_state_to_dict(mock_state, model="X1C")
  713. assert result["temperatures"]["chamber"] == 25
  714. assert result["temperatures"]["chamber_target"] == 45
  715. assert result["temperatures"]["chamber_heating"] is True
  716. def test_chamber_temp_filtered_for_a1(self, mock_state):
  717. """Verify chamber temperature is filtered out for A1 (no chamber sensor)."""
  718. mock_state.temperatures = {"nozzle": 200, "bed": 60, "chamber": 5}
  719. result = printer_state_to_dict(mock_state, model="A1")
  720. assert "chamber" not in result["temperatures"]
  721. def test_chamber_temp_kept_when_no_model(self, mock_state):
  722. """Verify chamber temperature is kept when model is not specified (conservative approach)."""
  723. mock_state.temperatures = {"nozzle": 200, "bed": 60, "chamber": 25}
  724. result = printer_state_to_dict(mock_state) # No model specified
  725. # When model is unknown, we can't filter - leave as is
  726. # Actually supports_chamber_temp returns False for None, so it will filter
  727. # Let's check the actual behavior
  728. assert "chamber" not in result["temperatures"]
  729. def test_ams_drying_fields_included(self, mock_state):
  730. """Verify AMS drying fields (dry_time, module_type) are included in output."""
  731. mock_state.raw_data = {
  732. "ams": [
  733. {
  734. "id": 0,
  735. "dry_time": 42,
  736. "module_type": "n3f",
  737. "tray": [
  738. {
  739. "id": 0,
  740. "tray_color": "FF0000",
  741. "tray_type": "PLA",
  742. "drying_temp": 55,
  743. "drying_time": 240,
  744. }
  745. ],
  746. }
  747. ]
  748. }
  749. result = printer_state_to_dict(mock_state)
  750. ams_unit = result["ams"][0]
  751. assert ams_unit["dry_time"] == 42
  752. assert ams_unit["module_type"] == "n3f"
  753. # Tray-level drying fields
  754. tray = ams_unit["tray"][0]
  755. assert tray["drying_temp"] == 55
  756. assert tray["drying_time"] == 240
  757. def test_awaiting_plate_clear_defaults_false(self, mock_state):
  758. """Without a printer_id, awaiting_plate_clear is False (no lookup possible)."""
  759. result = printer_state_to_dict(mock_state)
  760. assert result["awaiting_plate_clear"] is False
  761. def test_awaiting_plate_clear_surfaced_when_set(self, mock_state):
  762. """With printer_id, awaiting_plate_clear reflects PrinterManager state.
  763. Regression: PR #939 left this flag off the WebSocket payload, so the
  764. "Clear Plate" button only appeared after the 30 s REST fallback poll.
  765. """
  766. from backend.app.services.printer_manager import printer_manager
  767. printer_manager.set_awaiting_plate_clear(12345, True)
  768. try:
  769. result = printer_state_to_dict(mock_state, printer_id=12345)
  770. assert result["awaiting_plate_clear"] is True
  771. finally:
  772. printer_manager.set_awaiting_plate_clear(12345, False)
  773. class TestStatusKeyDryingDedup:
  774. """Regression tests for WebSocket dedup including drying fields.
  775. The WebSocket broadcast deduplication uses printer_state_to_dict output
  776. to detect changes. If drying fields (like dry_time) are missing from
  777. the dict, changes to those fields won't trigger broadcasts.
  778. """
  779. def test_dry_time_change_changes_status_key(self):
  780. """Verify dry_time is present in AMS unit data so dedup can detect changes."""
  781. state = MagicMock()
  782. state.connected = True
  783. state.state = "IDLE"
  784. state.current_print = None
  785. state.subtask_name = None
  786. state.gcode_file = None
  787. state.progress = 0
  788. state.remaining_time = 0
  789. state.layer_num = 0
  790. state.total_layers = 0
  791. state.temperatures = {"nozzle": 25, "bed": 25}
  792. state.hms_errors = []
  793. state.ams_status_main = 0
  794. state.ams_status_sub = 0
  795. state.tray_now = None
  796. state.wifi_signal = -50
  797. state.stg_cur = -1
  798. # First state: drying active with 30 minutes remaining
  799. state.raw_data = {"ams": [{"id": 0, "dry_time": 30, "module_type": "n3f", "tray": [{"id": 0}]}]}
  800. result1 = printer_state_to_dict(state)
  801. # Second state: drying time decreased
  802. state.raw_data = {"ams": [{"id": 0, "dry_time": 29, "module_type": "n3f", "tray": [{"id": 0}]}]}
  803. result2 = printer_state_to_dict(state)
  804. # The dicts should differ — dry_time changed
  805. assert result1["ams"][0]["dry_time"] == 30
  806. assert result2["ams"][0]["dry_time"] == 29
  807. assert result1["ams"] != result2["ams"]
  808. class TestSupportsChamberTemp:
  809. """Tests for supports_chamber_temp helper function."""
  810. def test_x1_series_supported(self):
  811. """Verify X1 series printers support chamber temp."""
  812. assert supports_chamber_temp("X1") is True
  813. assert supports_chamber_temp("X1C") is True
  814. assert supports_chamber_temp("X1E") is True
  815. def test_p2_series_supported(self):
  816. """Verify P2 series printers support chamber temp."""
  817. assert supports_chamber_temp("P2S") is True
  818. def test_h2_series_supported(self):
  819. """Verify H2 series printers support chamber temp."""
  820. assert supports_chamber_temp("H2C") is True
  821. assert supports_chamber_temp("H2D") is True
  822. assert supports_chamber_temp("H2DPRO") is True
  823. assert supports_chamber_temp("H2S") is True
  824. def test_p1_series_not_supported(self):
  825. """Verify P1 series printers do NOT support chamber temp."""
  826. assert supports_chamber_temp("P1P") is False
  827. assert supports_chamber_temp("P1S") is False
  828. def test_a1_series_not_supported(self):
  829. """Verify A1 series printers do NOT support chamber temp."""
  830. assert supports_chamber_temp("A1") is False
  831. assert supports_chamber_temp("A1MINI") is False
  832. def test_none_model_not_supported(self):
  833. """Verify None model returns False."""
  834. assert supports_chamber_temp(None) is False
  835. def test_case_insensitive(self):
  836. """Verify model matching is case-insensitive."""
  837. assert supports_chamber_temp("x1c") is True
  838. assert supports_chamber_temp("X1c") is True
  839. assert supports_chamber_temp("p1s") is False
  840. def test_internal_model_codes_supported(self):
  841. """Verify internal model codes from MQTT/SSDP are recognized."""
  842. # X1/X1C
  843. assert supports_chamber_temp("BL-P001") is True
  844. # X1E
  845. assert supports_chamber_temp("C13") is True
  846. # H2D
  847. assert supports_chamber_temp("O1D") is True
  848. # H2C
  849. assert supports_chamber_temp("O1C") is True
  850. # H2S
  851. assert supports_chamber_temp("O1S") is True
  852. # H2D Pro
  853. assert supports_chamber_temp("O1E") is True
  854. # P2S
  855. assert supports_chamber_temp("N7") is True
  856. def test_internal_model_codes_not_supported(self):
  857. """Verify A1/P1 internal codes are NOT supported."""
  858. # P1P
  859. assert supports_chamber_temp("C11") is False
  860. # P1S
  861. assert supports_chamber_temp("C12") is False
  862. # A1
  863. assert supports_chamber_temp("N2S") is False
  864. # A1 Mini
  865. assert supports_chamber_temp("N1") is False
  866. class TestSupportsDrying:
  867. """Tests for supports_drying helper function."""
  868. def test_known_supported_with_firmware(self):
  869. """Verify known models with sufficient firmware return True."""
  870. assert supports_drying("X1C", "01.09.00.00") is True
  871. assert supports_drying("P1S", "01.08.00.00") is True
  872. assert supports_drying("H2D", "01.02.30.00") is True
  873. assert supports_drying("H2S", "01.02.00.00") is True
  874. assert supports_drying("P2S", "01.02.00.00") is True
  875. assert supports_drying("N7", "01.02.00.00") is True
  876. def test_known_supported_old_firmware(self):
  877. """Verify known models with old firmware return False."""
  878. assert supports_drying("X1C", "01.08.00.00") is False
  879. assert supports_drying("P1S", "01.07.00.00") is False
  880. assert supports_drying("H2S", "01.01.00.00") is False
  881. assert supports_drying("P2S", "01.01.99.99") is False
  882. assert supports_drying("N7", "01.01.99.99") is False
  883. def test_known_supported_no_firmware(self):
  884. """Verify known models with no firmware return False."""
  885. assert supports_drying("X1C", None) is False
  886. assert supports_drying("P2S", None) is False
  887. def test_unsupported_models(self):
  888. """Verify models without AMS drying support return False regardless of firmware."""
  889. for model in ["A1", "A1MINI", "A1-MINI", "H2C", "N1", "N2S"]:
  890. assert supports_drying(model, "99.99.99.99") is False, f"Expected False for {model}"
  891. def test_unknown_models_allowed(self):
  892. """Verify unknown models are allowed (graceful fallback).
  893. Models not in the unsupported set AND not matching any known firmware-gated
  894. model substring get the benefit of the doubt and return True.
  895. "H2D Pro" contains "H2D" so it IS firmware-gated (needs firmware).
  896. """
  897. # Truly unknown models: no substring match in _DRYING_MIN_FIRMWARE
  898. assert supports_drying("FUTURE_MODEL", None) is True
  899. # X1E contains "X1" substring, so it IS firmware-gated
  900. assert supports_drying("X1E", "01.09.00.00") is True
  901. # H2D Pro contains "H2D" substring, so it IS firmware-gated
  902. assert supports_drying("H2D Pro", "01.02.30.00") is True
  903. def test_none_model(self):
  904. """Verify None model returns False."""
  905. assert supports_drying(None, "01.09.00.00") is False
  906. def test_case_insensitive(self):
  907. """Verify model matching is case-insensitive."""
  908. assert supports_drying("x1c", "01.09.00.00") is True
  909. assert supports_drying("p2s", "01.02.00.00") is True
  910. assert supports_drying("a1", "99.99.99.99") is False
  911. class TestGetDerivedStatusName:
  912. """Tests for get_derived_status_name function."""
  913. def test_stg_cur_255_returns_none(self):
  914. """Verify stg_cur=255 (A1/P1 idle) returns None, not 'Unknown stage (255)'."""
  915. state = MagicMock()
  916. state.stg_cur = 255
  917. state.state = "IDLE"
  918. result = get_derived_status_name(state)
  919. assert result is None
  920. def test_stg_cur_negative_one_returns_none_when_idle(self):
  921. """Verify stg_cur=-1 (X1 idle) returns None."""
  922. state = MagicMock()
  923. state.stg_cur = -1
  924. state.state = "IDLE"
  925. result = get_derived_status_name(state)
  926. assert result is None
  927. def test_valid_stage_returns_name(self):
  928. """Verify valid stg_cur values return stage name."""
  929. state = MagicMock()
  930. state.stg_cur = 1 # Auto bed leveling
  931. result = get_derived_status_name(state)
  932. assert result == "Auto bed leveling"
  933. def test_stg_cur_zero_returns_printing(self):
  934. """Verify stg_cur=0 returns 'Printing' when no model specified."""
  935. state = MagicMock()
  936. state.stg_cur = 0
  937. result = get_derived_status_name(state)
  938. assert result == "Printing"
  939. def test_a1_idle_with_stg_cur_zero_returns_none(self):
  940. """Verify A1 with IDLE state and stg_cur=0 returns None (bug workaround)."""
  941. state = MagicMock()
  942. state.stg_cur = 0
  943. state.state = "IDLE"
  944. # Test various A1 model names
  945. for model in ["A1", "A1 Mini", "A1-Mini", "A1MINI", "N1", "N2S"]:
  946. result = get_derived_status_name(state, model)
  947. assert result is None, f"Expected None for model {model}"
  948. def test_a1_running_with_stg_cur_zero_returns_printing(self):
  949. """Verify A1 with RUNNING state and stg_cur=0 still returns 'Printing'."""
  950. state = MagicMock()
  951. state.stg_cur = 0
  952. state.state = "RUNNING"
  953. result = get_derived_status_name(state, "A1")
  954. assert result == "Printing"
  955. def test_non_a1_idle_with_stg_cur_zero_returns_printing(self):
  956. """Verify non-A1 models with IDLE and stg_cur=0 still return 'Printing'."""
  957. state = MagicMock()
  958. state.stg_cur = 0
  959. state.state = "IDLE"
  960. # X1C should not get the workaround
  961. result = get_derived_status_name(state, "X1C")
  962. assert result == "Printing"
  963. class TestHasStgCurIdleBug:
  964. """Tests for has_stg_cur_idle_bug function."""
  965. def test_a1_models_return_true(self):
  966. """Verify A1 model variants return True."""
  967. assert has_stg_cur_idle_bug("A1") is True
  968. assert has_stg_cur_idle_bug("A1 Mini") is True
  969. assert has_stg_cur_idle_bug("A1-Mini") is True
  970. assert has_stg_cur_idle_bug("A1MINI") is True
  971. assert has_stg_cur_idle_bug("a1") is True # case insensitive
  972. assert has_stg_cur_idle_bug("a1 mini") is True
  973. def test_p1_models_return_true(self):
  974. """Verify P1P/P1S model variants return True."""
  975. assert has_stg_cur_idle_bug("P1P") is True
  976. assert has_stg_cur_idle_bug("P1S") is True
  977. assert has_stg_cur_idle_bug("p1p") is True # case insensitive
  978. def test_internal_codes_return_true(self):
  979. """Verify internal model codes return True."""
  980. assert has_stg_cur_idle_bug("N1") is True # A1 Mini
  981. assert has_stg_cur_idle_bug("N2S") is True # A1
  982. assert has_stg_cur_idle_bug("C11") is True # P1P
  983. assert has_stg_cur_idle_bug("C12") is True # P1S
  984. def test_non_affected_models_return_false(self):
  985. """Verify non-affected models return False."""
  986. assert has_stg_cur_idle_bug("X1C") is False
  987. assert has_stg_cur_idle_bug("X1") is False
  988. assert has_stg_cur_idle_bug("H2D") is False
  989. def test_none_model_returns_false(self):
  990. """Verify None model returns False."""
  991. assert has_stg_cur_idle_bug(None) is False
  992. def test_empty_model_returns_false(self):
  993. """Verify empty model returns False."""
  994. assert has_stg_cur_idle_bug("") is False
  995. class TestInitPrinterConnections:
  996. """Tests for init_printer_connections function."""
  997. @pytest.mark.asyncio
  998. async def test_connects_all_active_printers(self):
  999. """Verify all active printers are connected."""
  1000. mock_db = AsyncMock()
  1001. mock_printer1 = MagicMock(id=1, is_active=True)
  1002. mock_printer2 = MagicMock(id=2, is_active=True)
  1003. mock_result = MagicMock()
  1004. mock_result.scalars.return_value.all.return_value = [mock_printer1, mock_printer2]
  1005. mock_db.execute.return_value = mock_result
  1006. with patch("backend.app.services.printer_manager.printer_manager") as mock_manager:
  1007. mock_manager.connect_printer = AsyncMock()
  1008. await init_printer_connections(mock_db)
  1009. assert mock_manager.connect_printer.call_count == 2
  1010. @pytest.mark.asyncio
  1011. async def test_handles_empty_printer_list(self):
  1012. """Verify empty printer list is handled."""
  1013. mock_db = AsyncMock()
  1014. mock_result = MagicMock()
  1015. mock_result.scalars.return_value.all.return_value = []
  1016. mock_db.execute.return_value = mock_result
  1017. with patch("backend.app.services.printer_manager.printer_manager") as mock_manager:
  1018. mock_manager.connect_printer = AsyncMock()
  1019. await init_printer_connections(mock_db)
  1020. mock_manager.connect_printer.assert_not_called()
  1021. class TestAmsChangeCallback:
  1022. """Tests for AMS change callback functionality."""
  1023. @pytest.fixture
  1024. def manager(self):
  1025. """Create a fresh PrinterManager instance."""
  1026. return PrinterManager()
  1027. def test_ams_change_callback_is_triggered(self, manager):
  1028. """Verify AMS change callback is called when AMS data changes."""
  1029. callback = MagicMock()
  1030. manager.set_ams_change_callback(callback)
  1031. # Verify callback was set
  1032. assert manager._on_ams_change == callback
  1033. def test_ams_change_callback_receives_correct_data(self, manager):
  1034. """Verify AMS change callback receives the correct AMS data format."""
  1035. received_data = []
  1036. def capture_callback(printer_id, ams_data):
  1037. received_data.append((printer_id, ams_data))
  1038. manager.set_ams_change_callback(capture_callback)
  1039. # The callback should accept printer_id and ams_data
  1040. # This tests the callback signature
  1041. assert manager._on_ams_change is not None
  1042. assert callable(manager._on_ams_change)
  1043. class TestParsePlateId:
  1044. """Tests for parse_plate_id() — active-print plate extraction from gcode paths.
  1045. Regression coverage for #881 follow-up: the REST /status endpoint and the
  1046. WebSocket push path both use this helper, so they must agree on the plate
  1047. number the frontend sees.
  1048. """
  1049. def test_bambu_metadata_path(self):
  1050. # Canonical path that Bambu Studio / OrcaSlicer stamp into the 3MF.
  1051. assert parse_plate_id("/Metadata/plate_2.gcode") == 2
  1052. def test_plate_one(self):
  1053. assert parse_plate_id("/Metadata/plate_1.gcode") == 1
  1054. def test_double_digit_plate(self):
  1055. assert parse_plate_id("/Metadata/plate_12.gcode") == 12
  1056. def test_none_input(self):
  1057. assert parse_plate_id(None) is None
  1058. def test_empty_string(self):
  1059. assert parse_plate_id("") is None
  1060. def test_path_without_plate_segment(self):
  1061. # Some firmware / slicers report a bare filename without the plate marker.
  1062. assert parse_plate_id("/upload/my-model.gcode") is None
  1063. def test_similar_but_non_matching_names(self):
  1064. # "plate.gcode" (no number) and "nameplate_2.gcode" (substring) must not
  1065. # be mistaken for real plate markers. The regex anchors on `plate_<num>`.
  1066. assert parse_plate_id("/Metadata/plate.gcode") is None
  1067. assert parse_plate_id("/plates/3.gcode") is None
  1068. def test_substring_match_still_extracts(self):
  1069. # The regex isn't anchored to the start of a segment — any occurrence
  1070. # wins. This matches real Bambu paths where the segment is preceded by
  1071. # arbitrary directory noise, and matches the equivalent frontend regex.
  1072. assert parse_plate_id("/uploads/project/plate_5.gcode.md5") == 5