test_printer_manager.py 41 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130
  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. printer_state_to_dict,
  13. supports_chamber_temp,
  14. )
  15. class TestPrinterManager:
  16. """Tests for PrinterManager class."""
  17. @pytest.fixture
  18. def manager(self):
  19. """Create a fresh PrinterManager instance."""
  20. return PrinterManager()
  21. @pytest.fixture
  22. def mock_printer(self):
  23. """Create a mock Printer object."""
  24. printer = MagicMock()
  25. printer.id = 1
  26. printer.ip_address = "192.168.1.100"
  27. printer.serial_number = "00M09A123456789"
  28. printer.access_code = "12345678"
  29. printer.is_active = True
  30. return printer
  31. @pytest.fixture
  32. def mock_client(self):
  33. """Create a mock BambuMQTTClient."""
  34. client = MagicMock()
  35. client.state = MagicMock()
  36. client.state.connected = True
  37. client.state.state = "IDLE"
  38. client.state.progress = 0
  39. client.state.temperatures = {"nozzle": 25, "bed": 25}
  40. client.state.raw_data = {}
  41. client.logging_enabled = False
  42. return client
  43. # ========================================================================
  44. # Tests for initialization
  45. # ========================================================================
  46. def test_init_creates_empty_clients_dict(self, manager):
  47. """Verify manager initializes with empty clients dict."""
  48. assert manager._clients == {}
  49. def test_init_callbacks_are_none(self, manager):
  50. """Verify all callbacks are initially None."""
  51. assert manager._on_print_start is None
  52. assert manager._on_print_complete is None
  53. assert manager._on_status_change is None
  54. assert manager._on_ams_change is None
  55. def test_init_loop_is_none(self, manager):
  56. """Verify event loop is initially None."""
  57. assert manager._loop is None
  58. # ========================================================================
  59. # Tests for callback setters
  60. # ========================================================================
  61. def test_set_event_loop(self, manager):
  62. """Verify event loop can be set."""
  63. mock_loop = MagicMock()
  64. manager.set_event_loop(mock_loop)
  65. assert manager._loop == mock_loop
  66. def test_set_print_start_callback(self, manager):
  67. """Verify print start callback can be set."""
  68. callback = MagicMock()
  69. manager.set_print_start_callback(callback)
  70. assert manager._on_print_start == callback
  71. def test_set_print_complete_callback(self, manager):
  72. """Verify print complete callback can be set."""
  73. callback = MagicMock()
  74. manager.set_print_complete_callback(callback)
  75. assert manager._on_print_complete == callback
  76. def test_set_status_change_callback(self, manager):
  77. """Verify status change callback can be set."""
  78. callback = MagicMock()
  79. manager.set_status_change_callback(callback)
  80. assert manager._on_status_change == callback
  81. def test_set_ams_change_callback(self, manager):
  82. """Verify AMS change callback can be set."""
  83. callback = MagicMock()
  84. manager.set_ams_change_callback(callback)
  85. assert manager._on_ams_change == callback
  86. # ========================================================================
  87. # Tests for _schedule_async
  88. # ========================================================================
  89. def test_schedule_async_with_running_loop(self, manager):
  90. """Verify async coroutine is scheduled when loop is running."""
  91. mock_loop = MagicMock()
  92. mock_loop.is_running.return_value = True
  93. manager._loop = mock_loop
  94. async def dummy_coro():
  95. pass
  96. coro = dummy_coro()
  97. manager._schedule_async(coro)
  98. mock_loop.is_running.assert_called_once()
  99. # Clean up the coroutine
  100. coro.close()
  101. def test_schedule_async_without_loop(self, manager):
  102. """Verify nothing happens when no loop is set."""
  103. async def dummy_coro():
  104. pass
  105. coro = dummy_coro()
  106. # Should not raise
  107. manager._schedule_async(coro)
  108. coro.close()
  109. def test_schedule_async_with_stopped_loop(self, manager):
  110. """Verify nothing happens when loop is not running."""
  111. mock_loop = MagicMock()
  112. mock_loop.is_running.return_value = False
  113. manager._loop = mock_loop
  114. async def dummy_coro():
  115. pass
  116. coro = dummy_coro()
  117. manager._schedule_async(coro)
  118. coro.close()
  119. # ========================================================================
  120. # Tests for connect_printer
  121. # ========================================================================
  122. @pytest.mark.asyncio
  123. async def test_connect_printer_creates_client(self, manager, mock_printer):
  124. """Verify connecting creates an MQTT client."""
  125. with patch("backend.app.services.printer_manager.BambuMQTTClient") as MockClient:
  126. mock_instance = MagicMock()
  127. mock_instance.state = MagicMock()
  128. mock_instance.state.connected = True
  129. MockClient.return_value = mock_instance
  130. result = await manager.connect_printer(mock_printer)
  131. MockClient.assert_called_once()
  132. mock_instance.connect.assert_called_once()
  133. assert mock_printer.id in manager._clients
  134. assert result is True
  135. @pytest.mark.asyncio
  136. async def test_connect_printer_disconnects_existing(self, manager, mock_printer, mock_client):
  137. """Verify connecting disconnects existing client first."""
  138. manager._clients[mock_printer.id] = mock_client
  139. with patch("backend.app.services.printer_manager.BambuMQTTClient") as MockClient:
  140. new_client = MagicMock()
  141. new_client.state = MagicMock()
  142. new_client.state.connected = True
  143. MockClient.return_value = new_client
  144. await manager.connect_printer(mock_printer)
  145. mock_client.disconnect.assert_called_once()
  146. @pytest.mark.asyncio
  147. async def test_connect_printer_returns_false_on_failure(self, manager, mock_printer):
  148. """Verify returns False when connection fails."""
  149. with patch("backend.app.services.printer_manager.BambuMQTTClient") as MockClient:
  150. mock_instance = MagicMock()
  151. mock_instance.state = MagicMock()
  152. mock_instance.state.connected = False
  153. MockClient.return_value = mock_instance
  154. result = await manager.connect_printer(mock_printer)
  155. assert result is False
  156. # ========================================================================
  157. # Tests for disconnect_printer
  158. # ========================================================================
  159. def test_disconnect_printer_removes_client(self, manager, mock_client):
  160. """Verify disconnecting removes and disconnects client."""
  161. manager._clients[1] = mock_client
  162. manager.disconnect_printer(1)
  163. mock_client.disconnect.assert_called_once()
  164. assert 1 not in manager._clients
  165. def test_disconnect_printer_handles_missing(self, manager):
  166. """Verify disconnecting non-existent printer doesn't raise."""
  167. manager.disconnect_printer(999) # Should not raise
  168. # ========================================================================
  169. # Tests for disconnect_all
  170. # ========================================================================
  171. def test_disconnect_all_disconnects_all_clients(self, manager):
  172. """Verify all clients are disconnected."""
  173. client1 = MagicMock()
  174. client2 = MagicMock()
  175. manager._clients[1] = client1
  176. manager._clients[2] = client2
  177. manager.disconnect_all()
  178. client1.disconnect.assert_called_once()
  179. client2.disconnect.assert_called_once()
  180. assert len(manager._clients) == 0
  181. # ========================================================================
  182. # Tests for get_status
  183. # ========================================================================
  184. def test_get_status_returns_state(self, manager, mock_client):
  185. """Verify get_status returns client state."""
  186. manager._clients[1] = mock_client
  187. result = manager.get_status(1)
  188. mock_client.check_staleness.assert_called_once()
  189. assert result == mock_client.state
  190. def test_get_status_returns_none_for_unknown(self, manager):
  191. """Verify get_status returns None for unknown printer."""
  192. result = manager.get_status(999)
  193. assert result is None
  194. # ========================================================================
  195. # Tests for get_all_statuses
  196. # ========================================================================
  197. def test_get_all_statuses_returns_all(self, manager):
  198. """Verify all statuses are returned."""
  199. client1 = MagicMock()
  200. client1.state = MagicMock(connected=True)
  201. client2 = MagicMock()
  202. client2.state = MagicMock(connected=False)
  203. manager._clients[1] = client1
  204. manager._clients[2] = client2
  205. result = manager.get_all_statuses()
  206. assert len(result) == 2
  207. assert 1 in result
  208. assert 2 in result
  209. client1.check_staleness.assert_called_once()
  210. client2.check_staleness.assert_called_once()
  211. # ========================================================================
  212. # Tests for is_connected
  213. # ========================================================================
  214. def test_is_connected_returns_true(self, manager, mock_client):
  215. """Verify is_connected returns True for connected printer."""
  216. mock_client.check_staleness.return_value = True
  217. manager._clients[1] = mock_client
  218. result = manager.is_connected(1)
  219. assert result is True
  220. def test_is_connected_returns_false_for_unknown(self, manager):
  221. """Verify is_connected returns False for unknown printer."""
  222. result = manager.is_connected(999)
  223. assert result is False
  224. # ========================================================================
  225. # Tests for get_client
  226. # ========================================================================
  227. def test_get_client_returns_client(self, manager, mock_client):
  228. """Verify get_client returns the client."""
  229. manager._clients[1] = mock_client
  230. result = manager.get_client(1)
  231. assert result == mock_client
  232. def test_get_client_returns_none_for_unknown(self, manager):
  233. """Verify get_client returns None for unknown printer."""
  234. result = manager.get_client(999)
  235. assert result is None
  236. # ========================================================================
  237. # Tests for mark_printer_offline
  238. # ========================================================================
  239. def test_mark_printer_offline_updates_state(self, manager, mock_client):
  240. """Verify mark_printer_offline updates client state."""
  241. mock_client.state.connected = True
  242. manager._clients[1] = mock_client
  243. manager.mark_printer_offline(1)
  244. assert mock_client.state.connected is False
  245. assert mock_client.state.state == "unknown"
  246. def test_mark_printer_offline_triggers_callback(self, manager, mock_client):
  247. """Verify mark_printer_offline triggers status callback."""
  248. mock_client.state.connected = True
  249. manager._clients[1] = mock_client
  250. # Callback must return a coroutine
  251. async def async_callback(printer_id, state):
  252. pass
  253. manager._on_status_change = async_callback
  254. # Need a running loop for callback
  255. mock_loop = MagicMock()
  256. mock_loop.is_running.return_value = True
  257. manager._loop = mock_loop
  258. manager.mark_printer_offline(1)
  259. # Callback should be scheduled via run_coroutine_threadsafe
  260. mock_loop.is_running.assert_called()
  261. # State should be updated
  262. assert mock_client.state.connected is False
  263. def test_mark_printer_offline_handles_unknown(self, manager):
  264. """Verify mark_printer_offline handles unknown printer."""
  265. manager.mark_printer_offline(999) # Should not raise
  266. def test_mark_printer_offline_skips_already_offline(self, manager, mock_client):
  267. """Verify mark_printer_offline skips already offline printer."""
  268. mock_client.state.connected = False
  269. manager._clients[1] = mock_client
  270. manager.mark_printer_offline(1)
  271. # State should remain unchanged
  272. assert mock_client.state.connected is False
  273. # ========================================================================
  274. # Tests for start_print
  275. # ========================================================================
  276. def test_start_print_calls_client(self, manager, mock_client):
  277. """Verify start_print calls client method."""
  278. mock_client.start_print.return_value = True
  279. manager._clients[1] = mock_client
  280. result = manager.start_print(1, "test.gcode")
  281. mock_client.start_print.assert_called_once_with(
  282. "test.gcode",
  283. 1,
  284. ams_mapping=None,
  285. timelapse=False,
  286. bed_levelling=True,
  287. flow_cali=False,
  288. vibration_cali=True,
  289. layer_inspect=False,
  290. use_ams=True,
  291. )
  292. assert result is True
  293. def test_start_print_returns_false_for_unknown(self, manager):
  294. """Verify start_print returns False for unknown printer."""
  295. result = manager.start_print(999, "test.gcode")
  296. assert result is False
  297. def test_start_print_logs_print_command_with_caller(self, manager, mock_client, caplog):
  298. """Verify start_print logs PRINT COMMAND with caller info (#374)."""
  299. mock_client.start_print.return_value = True
  300. manager._clients[1] = mock_client
  301. with caplog.at_level(logging.INFO, logger="backend.app.services.printer_manager"):
  302. manager.start_print(1, "benchy.3mf")
  303. print_cmd_logs = [r for r in caplog.records if "PRINT COMMAND" in r.message]
  304. assert len(print_cmd_logs) == 1
  305. log_msg = print_cmd_logs[0].message
  306. assert "printer=1" in log_msg
  307. assert "file=benchy.3mf" in log_msg
  308. assert "caller=" in log_msg
  309. def test_start_print_logs_even_when_printer_unknown(self, manager, caplog):
  310. """Verify PRINT COMMAND is logged even for unknown printers (#374)."""
  311. with caplog.at_level(logging.INFO, logger="backend.app.services.printer_manager"):
  312. result = manager.start_print(999, "ghost.3mf")
  313. assert result is False
  314. print_cmd_logs = [r for r in caplog.records if "PRINT COMMAND" in r.message]
  315. assert len(print_cmd_logs) == 1
  316. # ========================================================================
  317. # Tests for stop_print
  318. # ========================================================================
  319. def test_stop_print_calls_client(self, manager, mock_client):
  320. """Verify stop_print calls client method."""
  321. mock_client.stop_print.return_value = True
  322. manager._clients[1] = mock_client
  323. result = manager.stop_print(1)
  324. mock_client.stop_print.assert_called_once()
  325. assert result is True
  326. def test_stop_print_returns_false_for_unknown(self, manager):
  327. """Verify stop_print returns False for unknown printer."""
  328. result = manager.stop_print(999)
  329. assert result is False
  330. # ========================================================================
  331. # Tests for wait_for_cooldown
  332. # ========================================================================
  333. @pytest.mark.asyncio
  334. async def test_wait_for_cooldown_returns_true_when_cool(self, manager, mock_client):
  335. """Verify wait_for_cooldown returns True when printer is cool."""
  336. mock_client.state.connected = True
  337. mock_client.state.temperatures = {"nozzle": 40, "bed": 30}
  338. mock_client.check_staleness.return_value = True
  339. manager._clients[1] = mock_client
  340. result = await manager.wait_for_cooldown(1, target_temp=50)
  341. assert result is True
  342. @pytest.mark.asyncio
  343. async def test_wait_for_cooldown_returns_false_on_disconnect(self, manager, mock_client):
  344. """Verify wait_for_cooldown returns False when printer disconnects."""
  345. mock_client.state.connected = False
  346. mock_client.check_staleness.return_value = False
  347. manager._clients[1] = mock_client
  348. result = await manager.wait_for_cooldown(1, target_temp=50, timeout=1)
  349. assert result is False
  350. @pytest.mark.asyncio
  351. async def test_wait_for_cooldown_returns_false_for_unknown(self, manager):
  352. """Verify wait_for_cooldown returns False for unknown printer."""
  353. result = await manager.wait_for_cooldown(999, target_temp=50, timeout=1)
  354. assert result is False
  355. @pytest.mark.asyncio
  356. async def test_wait_for_cooldown_checks_both_nozzles(self, manager, mock_client):
  357. """Verify wait_for_cooldown checks both nozzles for dual extruders."""
  358. mock_client.state.connected = True
  359. mock_client.state.temperatures = {"nozzle": 40, "nozzle_2": 45, "bed": 30}
  360. mock_client.check_staleness.return_value = True
  361. manager._clients[1] = mock_client
  362. result = await manager.wait_for_cooldown(1, target_temp=50)
  363. assert result is True
  364. # ========================================================================
  365. # Tests for logging methods
  366. # ========================================================================
  367. def test_enable_logging_calls_client(self, manager, mock_client):
  368. """Verify enable_logging calls client method."""
  369. manager._clients[1] = mock_client
  370. result = manager.enable_logging(1, True)
  371. mock_client.enable_logging.assert_called_once_with(True)
  372. assert result is True
  373. def test_enable_logging_returns_false_for_unknown(self, manager):
  374. """Verify enable_logging returns False for unknown printer."""
  375. result = manager.enable_logging(999, True)
  376. assert result is False
  377. def test_get_logs_returns_logs(self, manager, mock_client):
  378. """Verify get_logs returns client logs."""
  379. mock_logs = [MagicMock(), MagicMock()]
  380. mock_client.get_logs.return_value = mock_logs
  381. manager._clients[1] = mock_client
  382. result = manager.get_logs(1)
  383. assert result == mock_logs
  384. def test_get_logs_returns_empty_for_unknown(self, manager):
  385. """Verify get_logs returns empty list for unknown printer."""
  386. result = manager.get_logs(999)
  387. assert result == []
  388. def test_clear_logs_calls_client(self, manager, mock_client):
  389. """Verify clear_logs calls client method."""
  390. manager._clients[1] = mock_client
  391. result = manager.clear_logs(1)
  392. mock_client.clear_logs.assert_called_once()
  393. assert result is True
  394. def test_clear_logs_returns_false_for_unknown(self, manager):
  395. """Verify clear_logs returns False for unknown printer."""
  396. result = manager.clear_logs(999)
  397. assert result is False
  398. def test_is_logging_enabled_returns_status(self, manager, mock_client):
  399. """Verify is_logging_enabled returns client status."""
  400. mock_client.logging_enabled = True
  401. manager._clients[1] = mock_client
  402. result = manager.is_logging_enabled(1)
  403. assert result is True
  404. def test_is_logging_enabled_returns_false_for_unknown(self, manager):
  405. """Verify is_logging_enabled returns False for unknown printer."""
  406. result = manager.is_logging_enabled(999)
  407. assert result is False
  408. # ========================================================================
  409. # Tests for request_status_update
  410. # ========================================================================
  411. def test_request_status_update_calls_client(self, manager, mock_client):
  412. """Verify request_status_update calls client method."""
  413. mock_client.request_status_update.return_value = True
  414. manager._clients[1] = mock_client
  415. result = manager.request_status_update(1)
  416. mock_client.request_status_update.assert_called_once()
  417. assert result is True
  418. def test_request_status_update_returns_false_for_unknown(self, manager):
  419. """Verify request_status_update returns False for unknown printer."""
  420. result = manager.request_status_update(999)
  421. assert result is False
  422. # ========================================================================
  423. # Tests for test_connection
  424. # ========================================================================
  425. @pytest.mark.asyncio
  426. async def test_test_connection_success(self, manager):
  427. """Verify test_connection returns success on connection."""
  428. with patch("backend.app.services.printer_manager.BambuMQTTClient") as MockClient:
  429. mock_instance = MagicMock()
  430. mock_instance.state = MagicMock()
  431. mock_instance.state.connected = True
  432. mock_instance.state.state = "IDLE"
  433. mock_instance.state.raw_data = {"device_model": "X1C"}
  434. MockClient.return_value = mock_instance
  435. result = await manager.test_connection("192.168.1.100", "00M09A123456789", "12345678")
  436. assert result["success"] is True
  437. assert result["state"] == "IDLE"
  438. assert result["model"] == "X1C"
  439. mock_instance.disconnect.assert_called_once()
  440. @pytest.mark.asyncio
  441. async def test_test_connection_failure(self, manager):
  442. """Verify test_connection returns failure on connection error."""
  443. with patch("backend.app.services.printer_manager.BambuMQTTClient") as MockClient:
  444. mock_instance = MagicMock()
  445. mock_instance.state = MagicMock()
  446. mock_instance.state.connected = False
  447. MockClient.return_value = mock_instance
  448. result = await manager.test_connection("192.168.1.100", "00M09A123456789", "12345678")
  449. assert result["success"] is False
  450. assert result["state"] is None
  451. mock_instance.disconnect.assert_called_once()
  452. # ========================================================================
  453. # Tests for current print user tracking (Issue #206)
  454. # ========================================================================
  455. def test_set_current_print_user(self, manager):
  456. """Verify current print user can be set."""
  457. manager.set_current_print_user(1, 42, "testuser")
  458. assert 1 in manager._current_print_user
  459. assert manager._current_print_user[1]["user_id"] == 42
  460. assert manager._current_print_user[1]["username"] == "testuser"
  461. def test_get_current_print_user_returns_user(self, manager):
  462. """Verify get_current_print_user returns the stored user."""
  463. manager.set_current_print_user(1, 42, "testuser")
  464. result = manager.get_current_print_user(1)
  465. assert result is not None
  466. assert result["user_id"] == 42
  467. assert result["username"] == "testuser"
  468. def test_get_current_print_user_returns_none_for_unknown(self, manager):
  469. """Verify get_current_print_user returns None for unknown printer."""
  470. result = manager.get_current_print_user(999)
  471. assert result is None
  472. def test_clear_current_print_user(self, manager):
  473. """Verify current print user can be cleared."""
  474. manager.set_current_print_user(1, 42, "testuser")
  475. manager.clear_current_print_user(1)
  476. result = manager.get_current_print_user(1)
  477. assert result is None
  478. def test_clear_current_print_user_no_error_for_unknown(self, manager):
  479. """Verify clearing unknown printer doesn't raise error."""
  480. # Should not raise
  481. manager.clear_current_print_user(999)
  482. def test_set_current_print_user_overwrites_existing(self, manager):
  483. """Verify setting user overwrites existing value."""
  484. manager.set_current_print_user(1, 42, "user1")
  485. manager.set_current_print_user(1, 99, "user2")
  486. result = manager.get_current_print_user(1)
  487. assert result["user_id"] == 99
  488. assert result["username"] == "user2"
  489. def test_multiple_printers_have_separate_users(self, manager):
  490. """Verify each printer tracks its own user separately."""
  491. manager.set_current_print_user(1, 42, "user1")
  492. manager.set_current_print_user(2, 99, "user2")
  493. result1 = manager.get_current_print_user(1)
  494. result2 = manager.get_current_print_user(2)
  495. assert result1["username"] == "user1"
  496. assert result2["username"] == "user2"
  497. class TestPrinterStateToDict:
  498. """Tests for printer_state_to_dict helper function."""
  499. @pytest.fixture
  500. def mock_state(self):
  501. """Create a mock PrinterState."""
  502. state = MagicMock()
  503. state.connected = True
  504. state.state = "RUNNING"
  505. state.current_print = "test.3mf"
  506. state.subtask_name = "Test Print"
  507. state.gcode_file = "/sdcard/test.gcode"
  508. state.progress = 50
  509. state.remaining_time = 3600
  510. state.layer_num = 10
  511. state.total_layers = 20
  512. state.temperatures = {"nozzle": 200, "bed": 60}
  513. state.hms_errors = []
  514. state.ams_status_main = 0
  515. state.ams_status_sub = 0
  516. state.tray_now = "1"
  517. state.wifi_signal = -50
  518. state.raw_data = {}
  519. state.stg_cur = -1 # No calibration stage active
  520. return state
  521. def test_basic_conversion(self, mock_state):
  522. """Verify basic state fields are converted."""
  523. result = printer_state_to_dict(mock_state)
  524. assert result["connected"] is True
  525. assert result["state"] == "RUNNING"
  526. assert result["progress"] == 50
  527. assert result["temperatures"] == {"nozzle": 200, "bed": 60}
  528. def test_ams_data_parsing(self, mock_state):
  529. """Verify AMS data is parsed correctly."""
  530. mock_state.raw_data = {
  531. "ams": [
  532. {
  533. "id": 0,
  534. "humidity_raw": 45,
  535. "temp": 25,
  536. "tray": [
  537. {
  538. "id": 0,
  539. "tray_color": "FF0000",
  540. "tray_type": "PLA",
  541. "tray_sub_brands": "Generic",
  542. "remain": 80,
  543. "k": 0.5,
  544. "tag_uid": "ABC123",
  545. "tray_uuid": "uuid-123",
  546. }
  547. ],
  548. }
  549. ]
  550. }
  551. result = printer_state_to_dict(mock_state)
  552. assert result["ams"] is not None
  553. assert len(result["ams"]) == 1
  554. assert result["ams"][0]["humidity"] == 45
  555. assert len(result["ams"][0]["tray"]) == 1
  556. assert result["ams"][0]["tray"][0]["tray_color"] == "FF0000"
  557. def test_empty_tag_uid_becomes_none(self, mock_state):
  558. """Verify empty tag_uid is converted to None."""
  559. mock_state.raw_data = {
  560. "ams": [
  561. {
  562. "id": 0,
  563. "tray": [
  564. {
  565. "id": 0,
  566. "tag_uid": "",
  567. "tray_uuid": "00000000000000000000000000000000",
  568. }
  569. ],
  570. }
  571. ]
  572. }
  573. result = printer_state_to_dict(mock_state)
  574. assert result["ams"][0]["tray"][0]["tag_uid"] is None
  575. assert result["ams"][0]["tray"][0]["tray_uuid"] is None
  576. def test_zero_tag_uid_becomes_none(self, mock_state):
  577. """Verify zero tag_uid is converted to None."""
  578. mock_state.raw_data = {
  579. "ams": [
  580. {
  581. "id": 0,
  582. "tray": [
  583. {
  584. "id": 0,
  585. "tag_uid": "0000000000000000",
  586. }
  587. ],
  588. }
  589. ]
  590. }
  591. result = printer_state_to_dict(mock_state)
  592. assert result["ams"][0]["tray"][0]["tag_uid"] is None
  593. def test_vt_tray_parsing(self, mock_state):
  594. """Verify virtual tray is parsed correctly as a list."""
  595. mock_state.raw_data = {
  596. "vt_tray": [
  597. {
  598. "tray_color": "00FF00",
  599. "tray_type": "PETG",
  600. "tray_sub_brands": "Generic",
  601. "remain": 60,
  602. "tag_uid": "VT123",
  603. }
  604. ]
  605. }
  606. result = printer_state_to_dict(mock_state)
  607. assert isinstance(result["vt_tray"], list)
  608. assert len(result["vt_tray"]) == 1
  609. assert result["vt_tray"][0]["id"] == 254
  610. assert result["vt_tray"][0]["tray_color"] == "00FF00"
  611. assert result["vt_tray"][0]["tray_type"] == "PETG"
  612. def test_hms_errors_conversion(self, mock_state):
  613. """Verify HMS errors are converted correctly."""
  614. error = MagicMock()
  615. error.code = "0700_0100"
  616. error.attr = 1
  617. error.module = "AMS"
  618. error.severity = 2
  619. mock_state.hms_errors = [error]
  620. result = printer_state_to_dict(mock_state)
  621. assert len(result["hms_errors"]) == 1
  622. assert result["hms_errors"][0]["code"] == "0700_0100"
  623. assert result["hms_errors"][0]["module"] == "AMS"
  624. def test_cover_url_added_for_running_print(self, mock_state):
  625. """Verify cover_url is added for running prints."""
  626. result = printer_state_to_dict(mock_state, printer_id=1)
  627. assert result["cover_url"] == "/api/v1/printers/1/cover"
  628. def test_cover_url_none_when_not_running(self, mock_state):
  629. """Verify cover_url is None when not printing."""
  630. mock_state.state = "IDLE"
  631. result = printer_state_to_dict(mock_state, printer_id=1)
  632. assert result["cover_url"] is None
  633. def test_ams_ht_detection(self, mock_state):
  634. """Verify AMS-HT is detected (1 tray vs 4)."""
  635. mock_state.raw_data = {
  636. "ams": [
  637. {
  638. "id": 0,
  639. "tray": [{"id": 0}], # Only 1 tray = AMS-HT
  640. }
  641. ]
  642. }
  643. result = printer_state_to_dict(mock_state)
  644. assert result["ams"][0]["is_ams_ht"] is True
  645. def test_regular_ams_detection(self, mock_state):
  646. """Verify regular AMS is detected (4 trays)."""
  647. mock_state.raw_data = {"ams": [{"id": 0, "tray": [{"id": 0}, {"id": 1}, {"id": 2}, {"id": 3}]}]}
  648. result = printer_state_to_dict(mock_state)
  649. assert result["ams"][0]["is_ams_ht"] is False
  650. def test_chamber_temp_filtered_for_p1s(self, mock_state):
  651. """Verify chamber temperature is filtered out for P1S (no chamber sensor)."""
  652. mock_state.temperatures = {
  653. "nozzle": 200,
  654. "bed": 60,
  655. "chamber": 5,
  656. "chamber_target": 0,
  657. "chamber_heating": False,
  658. }
  659. result = printer_state_to_dict(mock_state, model="P1S")
  660. assert "chamber" not in result["temperatures"]
  661. assert "chamber_target" not in result["temperatures"]
  662. assert "chamber_heating" not in result["temperatures"]
  663. assert result["temperatures"]["nozzle"] == 200
  664. assert result["temperatures"]["bed"] == 60
  665. def test_chamber_temp_kept_for_x1c(self, mock_state):
  666. """Verify chamber temperature is kept for X1C (has chamber sensor)."""
  667. mock_state.temperatures = {
  668. "nozzle": 200,
  669. "bed": 60,
  670. "chamber": 25,
  671. "chamber_target": 45,
  672. "chamber_heating": True,
  673. }
  674. result = printer_state_to_dict(mock_state, model="X1C")
  675. assert result["temperatures"]["chamber"] == 25
  676. assert result["temperatures"]["chamber_target"] == 45
  677. assert result["temperatures"]["chamber_heating"] is True
  678. def test_chamber_temp_filtered_for_a1(self, mock_state):
  679. """Verify chamber temperature is filtered out for A1 (no chamber sensor)."""
  680. mock_state.temperatures = {"nozzle": 200, "bed": 60, "chamber": 5}
  681. result = printer_state_to_dict(mock_state, model="A1")
  682. assert "chamber" not in result["temperatures"]
  683. def test_chamber_temp_kept_when_no_model(self, mock_state):
  684. """Verify chamber temperature is kept when model is not specified (conservative approach)."""
  685. mock_state.temperatures = {"nozzle": 200, "bed": 60, "chamber": 25}
  686. result = printer_state_to_dict(mock_state) # No model specified
  687. # When model is unknown, we can't filter - leave as is
  688. # Actually supports_chamber_temp returns False for None, so it will filter
  689. # Let's check the actual behavior
  690. assert "chamber" not in result["temperatures"]
  691. class TestSupportsChamberTemp:
  692. """Tests for supports_chamber_temp helper function."""
  693. def test_x1_series_supported(self):
  694. """Verify X1 series printers support chamber temp."""
  695. assert supports_chamber_temp("X1") is True
  696. assert supports_chamber_temp("X1C") is True
  697. assert supports_chamber_temp("X1E") is True
  698. def test_p2_series_supported(self):
  699. """Verify P2 series printers support chamber temp."""
  700. assert supports_chamber_temp("P2S") is True
  701. def test_h2_series_supported(self):
  702. """Verify H2 series printers support chamber temp."""
  703. assert supports_chamber_temp("H2C") is True
  704. assert supports_chamber_temp("H2D") is True
  705. assert supports_chamber_temp("H2DPRO") is True
  706. assert supports_chamber_temp("H2S") is True
  707. def test_p1_series_not_supported(self):
  708. """Verify P1 series printers do NOT support chamber temp."""
  709. assert supports_chamber_temp("P1P") is False
  710. assert supports_chamber_temp("P1S") is False
  711. def test_a1_series_not_supported(self):
  712. """Verify A1 series printers do NOT support chamber temp."""
  713. assert supports_chamber_temp("A1") is False
  714. assert supports_chamber_temp("A1MINI") is False
  715. def test_none_model_not_supported(self):
  716. """Verify None model returns False."""
  717. assert supports_chamber_temp(None) is False
  718. def test_case_insensitive(self):
  719. """Verify model matching is case-insensitive."""
  720. assert supports_chamber_temp("x1c") is True
  721. assert supports_chamber_temp("X1c") is True
  722. assert supports_chamber_temp("p1s") is False
  723. def test_internal_model_codes_supported(self):
  724. """Verify internal model codes from MQTT/SSDP are recognized."""
  725. # X1/X1C
  726. assert supports_chamber_temp("BL-P001") is True
  727. # X1E
  728. assert supports_chamber_temp("C13") is True
  729. # H2D
  730. assert supports_chamber_temp("O1D") is True
  731. # H2C
  732. assert supports_chamber_temp("O1C") is True
  733. # H2S
  734. assert supports_chamber_temp("O1S") is True
  735. # H2D Pro
  736. assert supports_chamber_temp("O1E") is True
  737. # P2S
  738. assert supports_chamber_temp("N7") is True
  739. def test_internal_model_codes_not_supported(self):
  740. """Verify A1/P1 internal codes are NOT supported."""
  741. # P1P
  742. assert supports_chamber_temp("C11") is False
  743. # P1S
  744. assert supports_chamber_temp("C12") is False
  745. # A1
  746. assert supports_chamber_temp("N2S") is False
  747. # A1 Mini
  748. assert supports_chamber_temp("N1") is False
  749. class TestGetDerivedStatusName:
  750. """Tests for get_derived_status_name function."""
  751. def test_stg_cur_255_returns_none(self):
  752. """Verify stg_cur=255 (A1/P1 idle) returns None, not 'Unknown stage (255)'."""
  753. state = MagicMock()
  754. state.stg_cur = 255
  755. state.state = "IDLE"
  756. result = get_derived_status_name(state)
  757. assert result is None
  758. def test_stg_cur_negative_one_returns_none_when_idle(self):
  759. """Verify stg_cur=-1 (X1 idle) returns None."""
  760. state = MagicMock()
  761. state.stg_cur = -1
  762. state.state = "IDLE"
  763. result = get_derived_status_name(state)
  764. assert result is None
  765. def test_valid_stage_returns_name(self):
  766. """Verify valid stg_cur values return stage name."""
  767. state = MagicMock()
  768. state.stg_cur = 1 # Auto bed leveling
  769. result = get_derived_status_name(state)
  770. assert result == "Auto bed leveling"
  771. def test_stg_cur_zero_returns_printing(self):
  772. """Verify stg_cur=0 returns 'Printing' when no model specified."""
  773. state = MagicMock()
  774. state.stg_cur = 0
  775. result = get_derived_status_name(state)
  776. assert result == "Printing"
  777. def test_a1_idle_with_stg_cur_zero_returns_none(self):
  778. """Verify A1 with IDLE state and stg_cur=0 returns None (bug workaround)."""
  779. state = MagicMock()
  780. state.stg_cur = 0
  781. state.state = "IDLE"
  782. # Test various A1 model names
  783. for model in ["A1", "A1 Mini", "A1-Mini", "A1MINI", "N1", "N2S"]:
  784. result = get_derived_status_name(state, model)
  785. assert result is None, f"Expected None for model {model}"
  786. def test_a1_running_with_stg_cur_zero_returns_printing(self):
  787. """Verify A1 with RUNNING state and stg_cur=0 still returns 'Printing'."""
  788. state = MagicMock()
  789. state.stg_cur = 0
  790. state.state = "RUNNING"
  791. result = get_derived_status_name(state, "A1")
  792. assert result == "Printing"
  793. def test_non_a1_idle_with_stg_cur_zero_returns_printing(self):
  794. """Verify non-A1 models with IDLE and stg_cur=0 still return 'Printing'."""
  795. state = MagicMock()
  796. state.stg_cur = 0
  797. state.state = "IDLE"
  798. # X1C should not get the workaround
  799. result = get_derived_status_name(state, "X1C")
  800. assert result == "Printing"
  801. class TestHasStgCurIdleBug:
  802. """Tests for has_stg_cur_idle_bug function."""
  803. def test_a1_models_return_true(self):
  804. """Verify A1 model variants return True."""
  805. assert has_stg_cur_idle_bug("A1") is True
  806. assert has_stg_cur_idle_bug("A1 Mini") is True
  807. assert has_stg_cur_idle_bug("A1-Mini") is True
  808. assert has_stg_cur_idle_bug("A1MINI") is True
  809. assert has_stg_cur_idle_bug("a1") is True # case insensitive
  810. assert has_stg_cur_idle_bug("a1 mini") is True
  811. def test_a1_internal_codes_return_true(self):
  812. """Verify A1 internal model codes return True."""
  813. assert has_stg_cur_idle_bug("N1") is True # A1 Mini
  814. assert has_stg_cur_idle_bug("N2S") is True # A1
  815. def test_non_a1_models_return_false(self):
  816. """Verify non-A1 models return False."""
  817. assert has_stg_cur_idle_bug("X1C") is False
  818. assert has_stg_cur_idle_bug("X1") is False
  819. assert has_stg_cur_idle_bug("P1P") is False
  820. assert has_stg_cur_idle_bug("P1S") is False
  821. assert has_stg_cur_idle_bug("H2D") is False
  822. def test_none_model_returns_false(self):
  823. """Verify None model returns False."""
  824. assert has_stg_cur_idle_bug(None) is False
  825. def test_empty_model_returns_false(self):
  826. """Verify empty model returns False."""
  827. assert has_stg_cur_idle_bug("") is False
  828. class TestInitPrinterConnections:
  829. """Tests for init_printer_connections function."""
  830. @pytest.mark.asyncio
  831. async def test_connects_all_active_printers(self):
  832. """Verify all active printers are connected."""
  833. mock_db = AsyncMock()
  834. mock_printer1 = MagicMock(id=1, is_active=True)
  835. mock_printer2 = MagicMock(id=2, is_active=True)
  836. mock_result = MagicMock()
  837. mock_result.scalars.return_value.all.return_value = [mock_printer1, mock_printer2]
  838. mock_db.execute.return_value = mock_result
  839. with patch("backend.app.services.printer_manager.printer_manager") as mock_manager:
  840. mock_manager.connect_printer = AsyncMock()
  841. await init_printer_connections(mock_db)
  842. assert mock_manager.connect_printer.call_count == 2
  843. @pytest.mark.asyncio
  844. async def test_handles_empty_printer_list(self):
  845. """Verify empty printer list is handled."""
  846. mock_db = AsyncMock()
  847. mock_result = MagicMock()
  848. mock_result.scalars.return_value.all.return_value = []
  849. mock_db.execute.return_value = mock_result
  850. with patch("backend.app.services.printer_manager.printer_manager") as mock_manager:
  851. mock_manager.connect_printer = AsyncMock()
  852. await init_printer_connections(mock_db)
  853. mock_manager.connect_printer.assert_not_called()
  854. class TestAmsChangeCallback:
  855. """Tests for AMS change callback functionality."""
  856. @pytest.fixture
  857. def manager(self):
  858. """Create a fresh PrinterManager instance."""
  859. return PrinterManager()
  860. def test_ams_change_callback_is_triggered(self, manager):
  861. """Verify AMS change callback is called when AMS data changes."""
  862. callback = MagicMock()
  863. manager.set_ams_change_callback(callback)
  864. # Verify callback was set
  865. assert manager._on_ams_change == callback
  866. def test_ams_change_callback_receives_correct_data(self, manager):
  867. """Verify AMS change callback receives the correct AMS data format."""
  868. received_data = []
  869. def capture_callback(printer_id, ams_data):
  870. received_data.append((printer_id, ams_data))
  871. manager.set_ams_change_callback(capture_callback)
  872. # The callback should accept printer_id and ams_data
  873. # This tests the callback signature
  874. assert manager._on_ams_change is not None
  875. assert callable(manager._on_ams_change)