test_smart_plug_manager.py 52 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225
  1. """Unit tests for SmartPlugManager service.
  2. These tests specifically target the auto-off behavior and toggle functionality
  3. that were identified as common regression points.
  4. """
  5. import asyncio
  6. from datetime import datetime, timezone
  7. from unittest.mock import AsyncMock, MagicMock, patch
  8. import pytest
  9. from backend.app.services.smart_plug_manager import SmartPlugManager
  10. class TestSmartPlugManager:
  11. """Tests for SmartPlugManager class."""
  12. @pytest.fixture
  13. def manager(self):
  14. """Create a fresh SmartPlugManager instance."""
  15. return SmartPlugManager()
  16. @pytest.fixture
  17. def mock_plug(self):
  18. """Create a mock SmartPlug object."""
  19. plug = MagicMock()
  20. plug.id = 1
  21. plug.name = "Test Plug"
  22. plug.ip_address = "192.168.1.100"
  23. plug.username = None
  24. plug.password = None
  25. plug.enabled = True
  26. plug.auto_on = True
  27. plug.auto_off = True
  28. plug.off_delay_mode = "time"
  29. plug.off_delay_minutes = 5
  30. plug.off_temp_threshold = 70
  31. plug.printer_id = 1
  32. plug.auto_off_executed = False
  33. plug.auto_off_pending = False
  34. plug.last_state = "ON"
  35. plug.last_checked = None
  36. # #1349: drying defaults match the new schema — both off until the
  37. # user opts in, so existing tests don't accidentally activate the
  38. # post-drying path.
  39. plug.plug_type = "tasmota"
  40. plug.ha_entity_id = None
  41. plug.auto_off_after_drying = False
  42. plug.off_delay_after_drying_minutes = 10
  43. return plug
  44. @pytest.fixture
  45. def mock_db(self):
  46. """Create a mock database session."""
  47. db = AsyncMock()
  48. db.commit = AsyncMock()
  49. db.refresh = AsyncMock()
  50. return db
  51. # ========================================================================
  52. # Tests for on_print_start
  53. # ========================================================================
  54. @pytest.mark.asyncio
  55. async def test_on_print_start_turns_on_plug(self, manager, mock_plug, mock_db):
  56. """Verify plug is turned ON when print starts with auto_on enabled."""
  57. with (
  58. patch.object(manager, "_get_plugs_for_printer", new_callable=AsyncMock) as mock_get_plug,
  59. patch("backend.app.services.smart_plug_manager.tasmota_service") as mock_tasmota,
  60. ):
  61. mock_get_plug.return_value = [mock_plug]
  62. mock_tasmota.turn_on = AsyncMock(return_value=True)
  63. await manager.on_print_start(printer_id=1, db=mock_db)
  64. mock_tasmota.turn_on.assert_called_once_with(mock_plug)
  65. @pytest.mark.asyncio
  66. async def test_on_print_start_skipped_when_auto_on_disabled(self, manager, mock_plug, mock_db):
  67. """Verify plug is NOT turned on when auto_on is disabled."""
  68. mock_plug.auto_on = False
  69. with (
  70. patch.object(manager, "_get_plugs_for_printer", new_callable=AsyncMock) as mock_get_plug,
  71. patch("backend.app.services.smart_plug_manager.tasmota_service") as mock_tasmota,
  72. ):
  73. mock_get_plug.return_value = [mock_plug]
  74. mock_tasmota.turn_on = AsyncMock()
  75. await manager.on_print_start(printer_id=1, db=mock_db)
  76. mock_tasmota.turn_on.assert_not_called()
  77. @pytest.mark.asyncio
  78. async def test_on_print_start_skipped_when_plug_disabled(self, manager, mock_plug, mock_db):
  79. """Verify plug is NOT turned on when plug.enabled is False."""
  80. mock_plug.enabled = False
  81. with (
  82. patch.object(manager, "_get_plugs_for_printer", new_callable=AsyncMock) as mock_get_plug,
  83. patch("backend.app.services.smart_plug_manager.tasmota_service") as mock_tasmota,
  84. ):
  85. mock_get_plug.return_value = [mock_plug]
  86. mock_tasmota.turn_on = AsyncMock()
  87. await manager.on_print_start(printer_id=1, db=mock_db)
  88. mock_tasmota.turn_on.assert_not_called()
  89. @pytest.mark.asyncio
  90. async def test_on_print_start_skipped_when_no_plug_found(self, manager, mock_db):
  91. """Verify graceful handling when no plug is linked to printer."""
  92. with (
  93. patch.object(manager, "_get_plugs_for_printer", new_callable=AsyncMock) as mock_get_plug,
  94. patch("backend.app.services.smart_plug_manager.tasmota_service") as mock_tasmota,
  95. ):
  96. mock_get_plug.return_value = []
  97. mock_tasmota.turn_on = AsyncMock()
  98. # Should not raise any exception
  99. await manager.on_print_start(printer_id=999, db=mock_db)
  100. mock_tasmota.turn_on.assert_not_called()
  101. @pytest.mark.asyncio
  102. async def test_on_print_start_cancels_pending_off(self, manager, mock_plug, mock_db):
  103. """Verify starting a new print cancels any pending auto-off."""
  104. # Set up a pending task
  105. mock_task = MagicMock()
  106. manager._pending_off[mock_plug.id] = mock_task
  107. with (
  108. patch.object(manager, "_get_plugs_for_printer", new_callable=AsyncMock) as mock_get_plug,
  109. patch.object(manager, "_mark_auto_off_pending", new_callable=AsyncMock),
  110. patch("backend.app.services.smart_plug_manager.tasmota_service") as mock_tasmota,
  111. ):
  112. mock_get_plug.return_value = [mock_plug]
  113. mock_tasmota.turn_on = AsyncMock(return_value=True)
  114. await manager.on_print_start(printer_id=1, db=mock_db)
  115. mock_task.cancel.assert_called_once()
  116. assert mock_plug.id not in manager._pending_off
  117. @pytest.mark.asyncio
  118. async def test_on_print_start_resets_auto_off_executed_flag(self, manager, mock_plug, mock_db):
  119. """Verify auto_off_executed flag is reset when turning on."""
  120. mock_plug.auto_off_executed = True
  121. with (
  122. patch.object(manager, "_get_plugs_for_printer", new_callable=AsyncMock) as mock_get_plug,
  123. patch("backend.app.services.smart_plug_manager.tasmota_service") as mock_tasmota,
  124. ):
  125. mock_get_plug.return_value = [mock_plug]
  126. mock_tasmota.turn_on = AsyncMock(return_value=True)
  127. await manager.on_print_start(printer_id=1, db=mock_db)
  128. assert mock_plug.auto_off_executed is False
  129. # ========================================================================
  130. # Tests for on_print_complete
  131. # ========================================================================
  132. @pytest.mark.asyncio
  133. async def test_on_print_complete_schedules_time_based_off(self, manager, mock_plug, mock_db):
  134. """Verify time-based auto-off is scheduled when print completes."""
  135. mock_plug.off_delay_mode = "time"
  136. mock_plug.off_delay_minutes = 5
  137. with (
  138. patch.object(manager, "_get_plugs_for_printer", new_callable=AsyncMock) as mock_get_plug,
  139. patch.object(manager, "_schedule_delayed_off") as mock_schedule,
  140. ):
  141. mock_get_plug.return_value = [mock_plug]
  142. await manager.on_print_complete(printer_id=1, status="completed", db=mock_db)
  143. mock_schedule.assert_called_once_with(mock_plug, 1, 300) # 5 min * 60 sec
  144. @pytest.mark.asyncio
  145. async def test_on_print_complete_schedules_temp_based_off(self, manager, mock_plug, mock_db):
  146. """Verify temperature-based auto-off is scheduled when print completes."""
  147. mock_plug.off_delay_mode = "temperature"
  148. mock_plug.off_temp_threshold = 70
  149. with (
  150. patch.object(manager, "_get_plugs_for_printer", new_callable=AsyncMock) as mock_get_plug,
  151. patch.object(manager, "_schedule_temp_based_off") as mock_schedule,
  152. ):
  153. mock_get_plug.return_value = [mock_plug]
  154. await manager.on_print_complete(printer_id=1, status="completed", db=mock_db)
  155. mock_schedule.assert_called_once_with(mock_plug, 1, 70)
  156. @pytest.mark.asyncio
  157. async def test_on_print_complete_skipped_when_auto_off_disabled(self, manager, mock_plug, mock_db):
  158. """CRITICAL: Verify auto-off does NOT trigger when auto_off is False.
  159. This is a key regression test - the toggle must respect the setting.
  160. """
  161. mock_plug.auto_off = False
  162. with (
  163. patch.object(manager, "_get_plugs_for_printer", new_callable=AsyncMock) as mock_get_plug,
  164. patch.object(manager, "_schedule_delayed_off") as mock_schedule,
  165. patch.object(manager, "_schedule_temp_based_off") as mock_temp,
  166. ):
  167. mock_get_plug.return_value = [mock_plug]
  168. await manager.on_print_complete(printer_id=1, status="completed", db=mock_db)
  169. mock_schedule.assert_not_called()
  170. mock_temp.assert_not_called()
  171. @pytest.mark.asyncio
  172. async def test_on_print_complete_skipped_when_plug_disabled(self, manager, mock_plug, mock_db):
  173. """Verify auto-off does NOT trigger when plug is disabled."""
  174. mock_plug.enabled = False
  175. with (
  176. patch.object(manager, "_get_plugs_for_printer", new_callable=AsyncMock) as mock_get_plug,
  177. patch.object(manager, "_schedule_delayed_off") as mock_schedule,
  178. ):
  179. mock_get_plug.return_value = [mock_plug]
  180. await manager.on_print_complete(printer_id=1, status="completed", db=mock_db)
  181. mock_schedule.assert_not_called()
  182. @pytest.mark.asyncio
  183. async def test_on_print_complete_skipped_on_failed_print(self, manager, mock_plug, mock_db):
  184. """Verify auto-off does NOT trigger on failed prints for investigation."""
  185. with (
  186. patch.object(manager, "_get_plugs_for_printer", new_callable=AsyncMock) as mock_get_plug,
  187. patch.object(manager, "_schedule_delayed_off") as mock_schedule,
  188. ):
  189. mock_get_plug.return_value = [mock_plug]
  190. await manager.on_print_complete(printer_id=1, status="failed", db=mock_db)
  191. mock_schedule.assert_not_called()
  192. @pytest.mark.asyncio
  193. async def test_on_print_complete_skipped_on_aborted_print(self, manager, mock_plug, mock_db):
  194. """Verify auto-off does NOT trigger on aborted prints."""
  195. with (
  196. patch.object(manager, "_get_plugs_for_printer", new_callable=AsyncMock) as mock_get_plug,
  197. patch.object(manager, "_schedule_delayed_off") as mock_schedule,
  198. ):
  199. mock_get_plug.return_value = [mock_plug]
  200. await manager.on_print_complete(printer_id=1, status="aborted", db=mock_db)
  201. mock_schedule.assert_not_called()
  202. # ========================================================================
  203. # Tests for on_drying_complete (#1349)
  204. # ========================================================================
  205. @pytest.mark.asyncio
  206. async def test_on_drying_complete_schedules_delayed_off_when_enabled(self, manager, mock_plug, mock_db):
  207. """Plug with ``auto_off_after_drying=True`` gets a delayed-off scheduled
  208. using its drying-specific delay (independent of print-finish delay)."""
  209. mock_plug.auto_off_after_drying = True
  210. mock_plug.off_delay_after_drying_minutes = 15
  211. with (
  212. patch.object(manager, "_get_plugs_for_printer", new_callable=AsyncMock) as mock_get_plug,
  213. patch.object(manager, "_schedule_delayed_off") as mock_schedule,
  214. ):
  215. mock_get_plug.return_value = [mock_plug]
  216. await manager.on_drying_complete(printer_id=1, db=mock_db)
  217. mock_schedule.assert_called_once_with(mock_plug, 1, 15 * 60)
  218. @pytest.mark.asyncio
  219. async def test_on_drying_complete_skipped_when_toggle_off(self, manager, mock_plug, mock_db):
  220. """Default state — toggle off → nothing scheduled. This is the regression
  221. guard for users who only enable the print-finish auto-off and don't
  222. want the AMS-drying path silently running on the same plug."""
  223. mock_plug.auto_off_after_drying = False
  224. # auto_off itself is True (existing print-finish behaviour) — the
  225. # drying path must still be a no-op without its own toggle.
  226. mock_plug.auto_off = True
  227. with (
  228. patch.object(manager, "_get_plugs_for_printer", new_callable=AsyncMock) as mock_get_plug,
  229. patch.object(manager, "_schedule_delayed_off") as mock_schedule,
  230. ):
  231. mock_get_plug.return_value = [mock_plug]
  232. await manager.on_drying_complete(printer_id=1, db=mock_db)
  233. mock_schedule.assert_not_called()
  234. @pytest.mark.asyncio
  235. async def test_on_drying_complete_skipped_when_plug_disabled(self, manager, mock_plug, mock_db):
  236. """Drying auto-off honours the master ``enabled`` flag."""
  237. mock_plug.auto_off_after_drying = True
  238. mock_plug.enabled = False
  239. with (
  240. patch.object(manager, "_get_plugs_for_printer", new_callable=AsyncMock) as mock_get_plug,
  241. patch.object(manager, "_schedule_delayed_off") as mock_schedule,
  242. ):
  243. mock_get_plug.return_value = [mock_plug]
  244. await manager.on_drying_complete(printer_id=1, db=mock_db)
  245. mock_schedule.assert_not_called()
  246. @pytest.mark.asyncio
  247. async def test_on_drying_complete_skipped_for_ha_script_entity(self, manager, mock_plug, mock_db):
  248. """HA script entities can be triggered but not turned off — same
  249. guard the print-finish path has."""
  250. mock_plug.auto_off_after_drying = True
  251. mock_plug.plug_type = "homeassistant"
  252. mock_plug.ha_entity_id = "script.lights_off"
  253. with (
  254. patch.object(manager, "_get_plugs_for_printer", new_callable=AsyncMock) as mock_get_plug,
  255. patch.object(manager, "_schedule_delayed_off") as mock_schedule,
  256. ):
  257. mock_get_plug.return_value = [mock_plug]
  258. await manager.on_drying_complete(printer_id=1, db=mock_db)
  259. mock_schedule.assert_not_called()
  260. @pytest.mark.asyncio
  261. async def test_on_drying_complete_no_op_when_no_plugs(self, manager, mock_db):
  262. """Printer without any linked plugs is a silent no-op (not an error)."""
  263. with (
  264. patch.object(manager, "_get_plugs_for_printer", new_callable=AsyncMock) as mock_get_plug,
  265. patch.object(manager, "_schedule_delayed_off") as mock_schedule,
  266. ):
  267. mock_get_plug.return_value = []
  268. await manager.on_drying_complete(printer_id=1, db=mock_db)
  269. mock_schedule.assert_not_called()
  270. # ========================================================================
  271. # Tests for _cancel_pending_off
  272. # ========================================================================
  273. @pytest.mark.asyncio
  274. async def test_cancel_pending_off_removes_task(self, manager, mock_plug):
  275. """Verify pending off tasks can be cancelled."""
  276. mock_task = MagicMock()
  277. manager._pending_off[mock_plug.id] = mock_task
  278. with patch.object(manager, "_mark_auto_off_pending", new_callable=AsyncMock):
  279. manager._cancel_pending_off(mock_plug.id)
  280. assert mock_plug.id not in manager._pending_off
  281. mock_task.cancel.assert_called_once()
  282. @pytest.mark.asyncio
  283. async def test_cancel_pending_off_handles_missing_task(self, manager):
  284. """Verify no error when cancelling non-existent task."""
  285. # Should not raise any exception
  286. with patch.object(manager, "_mark_auto_off_pending", new_callable=AsyncMock):
  287. manager._cancel_pending_off(999) # Non-existent plug ID
  288. # ========================================================================
  289. # Tests for scheduler
  290. # ========================================================================
  291. def test_start_scheduler(self, manager):
  292. """Verify scheduler can be started."""
  293. assert manager._scheduler_task is None
  294. # The patched loops are AsyncMocks, so each call still builds a
  295. # coroutine: the stand-in create_task closes it, or it is reported
  296. # unawaited in a later test.
  297. def _close_and_stand_in(coro, **_):
  298. coro.close()
  299. return MagicMock()
  300. with (
  301. patch.object(manager, "_schedule_loop") as mock_loop,
  302. patch.object(manager, "_snapshot_loop"),
  303. patch.object(manager, "_power_alert_loop") as mock_power_alert,
  304. patch.object(manager, "_relay_energy_loop") as mock_relay_energy,
  305. patch("asyncio.create_task", side_effect=_close_and_stand_in),
  306. ):
  307. manager.start_scheduler()
  308. assert manager._scheduler_task is not None
  309. mock_loop.assert_called_once()
  310. # Power alerts are checked in the background, not only while a page is open.
  311. assert manager._power_alert_task is not None
  312. mock_power_alert.assert_called_once()
  313. # Feeds the MQTT relay's smart_plugs/energy topic.
  314. assert manager._relay_energy_task is not None
  315. mock_relay_energy.assert_called_once()
  316. def test_stop_scheduler(self, manager):
  317. """Verify scheduler can be stopped."""
  318. mock_task = MagicMock()
  319. manager._scheduler_task = mock_task
  320. manager.stop_scheduler()
  321. mock_task.cancel.assert_called_once()
  322. assert manager._scheduler_task is None
  323. def test_start_scheduler_idempotent(self, manager):
  324. """Verify starting scheduler twice doesn't create multiple tasks."""
  325. mock_schedule_task = MagicMock()
  326. mock_snapshot_task = MagicMock()
  327. manager._scheduler_task = mock_schedule_task
  328. manager._snapshot_task = mock_snapshot_task
  329. manager._power_alert_task = MagicMock()
  330. manager._relay_energy_task = MagicMock()
  331. # Mock the loop coroutines to avoid unawaited coroutine warnings
  332. with (
  333. patch.object(manager, "_schedule_loop") as mock_loop,
  334. patch.object(manager, "_snapshot_loop") as mock_snapshot,
  335. patch.object(manager, "_power_alert_loop") as mock_power_alert,
  336. patch.object(manager, "_relay_energy_loop") as mock_relay_energy,
  337. patch("asyncio.create_task") as mock_create,
  338. ):
  339. manager.start_scheduler()
  340. mock_create.assert_not_called() # Should not create new tasks
  341. mock_loop.assert_not_called()
  342. mock_snapshot.assert_not_called()
  343. mock_power_alert.assert_not_called()
  344. mock_relay_energy.assert_not_called()
  345. def test_stop_scheduler_cancels_snapshot_task(self, manager):
  346. """Verify stopping scheduler also cancels the snapshot loop (#941)."""
  347. mock_schedule_task = MagicMock()
  348. mock_snapshot_task = MagicMock()
  349. manager._scheduler_task = mock_schedule_task
  350. manager._snapshot_task = mock_snapshot_task
  351. mock_power_alert_task = MagicMock()
  352. manager._power_alert_task = mock_power_alert_task
  353. mock_relay_energy_task = MagicMock()
  354. manager._relay_energy_task = mock_relay_energy_task
  355. manager.stop_scheduler()
  356. mock_schedule_task.cancel.assert_called_once()
  357. mock_snapshot_task.cancel.assert_called_once()
  358. mock_power_alert_task.cancel.assert_called_once()
  359. mock_relay_energy_task.cancel.assert_called_once()
  360. assert manager._power_alert_task is None
  361. assert manager._relay_energy_task is None
  362. assert manager._scheduler_task is None
  363. assert manager._snapshot_task is None
  364. class TestGetPlugsForPrinter:
  365. """Tests for _get_plugs_for_printer — returns all plugs for a printer (#903)."""
  366. @pytest.fixture
  367. def manager(self):
  368. return SmartPlugManager()
  369. @pytest.mark.asyncio
  370. async def test_returns_empty_list_when_no_plugs(self, manager):
  371. """Verify empty list is returned when no plugs are linked to printer."""
  372. mock_db = AsyncMock()
  373. mock_result = MagicMock()
  374. mock_result.scalars.return_value.all.return_value = []
  375. mock_db.execute = AsyncMock(return_value=mock_result)
  376. result = await manager._get_plugs_for_printer(1, mock_db)
  377. assert result == []
  378. @pytest.mark.asyncio
  379. async def test_returns_single_plug_as_list(self, manager):
  380. """Verify single plug is returned in a list."""
  381. plug = MagicMock()
  382. plug.plug_type = "tasmota"
  383. mock_db = AsyncMock()
  384. mock_result = MagicMock()
  385. mock_result.scalars.return_value.all.return_value = [plug]
  386. mock_db.execute = AsyncMock(return_value=mock_result)
  387. result = await manager._get_plugs_for_printer(1, mock_db)
  388. assert result == [plug]
  389. @pytest.mark.asyncio
  390. async def test_returns_all_plugs(self, manager):
  391. """Verify all plugs are returned when multiple exist (#903)."""
  392. plug1 = MagicMock()
  393. plug1.plug_type = "homeassistant"
  394. plug1.ha_entity_id = "switch.printer"
  395. plug2 = MagicMock()
  396. plug2.plug_type = "homeassistant"
  397. plug2.ha_entity_id = "switch.filter"
  398. mock_db = AsyncMock()
  399. mock_result = MagicMock()
  400. mock_result.scalars.return_value.all.return_value = [plug1, plug2]
  401. mock_db.execute = AsyncMock(return_value=mock_result)
  402. result = await manager._get_plugs_for_printer(1, mock_db)
  403. assert result == [plug1, plug2]
  404. class TestAutoOffPersistent:
  405. """Tests for persistent auto-off behavior (Issue #826).
  406. When auto_off_persistent is True, auto_off should remain enabled after
  407. execution instead of being disabled (one-shot default).
  408. """
  409. @pytest.fixture
  410. def manager(self):
  411. return SmartPlugManager()
  412. @pytest.mark.asyncio
  413. async def test_mark_auto_off_executed_one_shot_disables_auto_off(self, manager):
  414. """Default one-shot: auto_off should be set to False after execution."""
  415. mock_plug = MagicMock()
  416. mock_plug.id = 1
  417. mock_plug.auto_off = True
  418. mock_plug.auto_off_persistent = False
  419. mock_plug.auto_off_executed = False
  420. mock_plug.auto_off_pending = True
  421. mock_plug.auto_off_pending_since = datetime.now(timezone.utc)
  422. with patch("backend.app.core.database.async_session") as mock_session_ctx:
  423. mock_db = AsyncMock()
  424. mock_result = MagicMock()
  425. mock_result.scalar_one_or_none.return_value = mock_plug
  426. mock_db.execute = AsyncMock(return_value=mock_result)
  427. mock_db.commit = AsyncMock()
  428. mock_session_ctx.return_value.__aenter__ = AsyncMock(return_value=mock_db)
  429. mock_session_ctx.return_value.__aexit__ = AsyncMock()
  430. await manager._mark_auto_off_executed(1)
  431. assert mock_plug.auto_off is False, "One-shot: auto_off should be disabled"
  432. assert mock_plug.auto_off_pending is False
  433. assert mock_plug.auto_off_pending_since is None
  434. mock_db.commit.assert_called_once()
  435. @pytest.mark.asyncio
  436. async def test_mark_auto_off_executed_persistent_keeps_auto_off_enabled(self, manager):
  437. """Persistent mode: auto_off should remain True after execution."""
  438. mock_plug = MagicMock()
  439. mock_plug.id = 2
  440. mock_plug.auto_off = True
  441. mock_plug.auto_off_persistent = True
  442. mock_plug.auto_off_executed = False
  443. mock_plug.auto_off_pending = True
  444. mock_plug.auto_off_pending_since = datetime.now(timezone.utc)
  445. with patch("backend.app.core.database.async_session") as mock_session_ctx:
  446. mock_db = AsyncMock()
  447. mock_result = MagicMock()
  448. mock_result.scalar_one_or_none.return_value = mock_plug
  449. mock_db.execute = AsyncMock(return_value=mock_result)
  450. mock_db.commit = AsyncMock()
  451. mock_session_ctx.return_value.__aenter__ = AsyncMock(return_value=mock_db)
  452. mock_session_ctx.return_value.__aexit__ = AsyncMock()
  453. await manager._mark_auto_off_executed(2)
  454. assert mock_plug.auto_off is True, "Persistent: auto_off should stay enabled"
  455. assert mock_plug.auto_off_pending is False
  456. assert mock_plug.auto_off_pending_since is None
  457. mock_db.commit.assert_called_once()
  458. @pytest.mark.asyncio
  459. async def test_persistent_auto_off_full_cycle(self, manager):
  460. """Verify persistent auto-off survives a full print cycle.
  461. Simulates: print start → print complete → auto-off executes → next print start.
  462. auto_off should remain True throughout for persistent plugs.
  463. """
  464. mock_plug = MagicMock()
  465. mock_plug.id = 3
  466. mock_plug.name = "HA BentoBox Filter"
  467. mock_plug.plug_type = "homeassistant"
  468. mock_plug.ha_entity_id = "switch.bentobox_filter"
  469. mock_plug.ip_address = None
  470. mock_plug.username = None
  471. mock_plug.password = None
  472. mock_plug.enabled = True
  473. mock_plug.auto_on = True
  474. mock_plug.auto_off = True
  475. mock_plug.auto_off_persistent = True
  476. mock_plug.off_delay_mode = "time"
  477. mock_plug.off_delay_minutes = 1
  478. mock_plug.off_temp_threshold = 70
  479. mock_plug.printer_id = 1
  480. mock_plug.auto_off_executed = False
  481. mock_plug.auto_off_pending = False
  482. mock_plug.last_state = "OFF"
  483. mock_plug.last_checked = None
  484. mock_db = AsyncMock()
  485. mock_db.commit = AsyncMock()
  486. # Step 1: Print starts — plug turns on
  487. with (
  488. patch.object(manager, "_get_plugs_for_printer", new_callable=AsyncMock) as mock_get,
  489. patch.object(manager, "get_service_for_plug", new_callable=AsyncMock) as mock_svc,
  490. ):
  491. mock_get.return_value = [mock_plug]
  492. mock_service = AsyncMock()
  493. mock_service.turn_on = AsyncMock(return_value=True)
  494. mock_svc.return_value = mock_service
  495. await manager.on_print_start(printer_id=1, db=mock_db)
  496. assert mock_plug.auto_off_executed is False
  497. assert mock_plug.auto_off is True # Still enabled
  498. # Step 2: Print completes — auto-off is scheduled
  499. with (
  500. patch.object(manager, "_get_plugs_for_printer", new_callable=AsyncMock) as mock_get,
  501. patch.object(manager, "_schedule_delayed_off") as mock_schedule,
  502. ):
  503. mock_get.return_value = [mock_plug]
  504. await manager.on_print_complete(printer_id=1, status="completed", db=mock_db)
  505. mock_schedule.assert_called_once()
  506. assert mock_plug.auto_off is True # Still enabled after scheduling
  507. # Step 3: Auto-off executes via _mark_auto_off_executed
  508. with patch("backend.app.core.database.async_session") as mock_session_ctx:
  509. mock_db2 = AsyncMock()
  510. mock_result = MagicMock()
  511. mock_result.scalar_one_or_none.return_value = mock_plug
  512. mock_db2.execute = AsyncMock(return_value=mock_result)
  513. mock_db2.commit = AsyncMock()
  514. mock_session_ctx.return_value.__aenter__ = AsyncMock(return_value=mock_db2)
  515. mock_session_ctx.return_value.__aexit__ = AsyncMock()
  516. await manager._mark_auto_off_executed(3)
  517. # KEY ASSERTION: auto_off stays True for persistent mode
  518. assert mock_plug.auto_off is True, "Persistent auto_off must survive execution"
  519. assert mock_plug.auto_off_pending is False
  520. class TestScheduleLoop:
  521. """Tests for the schedule-based plug control."""
  522. @pytest.fixture
  523. def manager(self):
  524. return SmartPlugManager()
  525. @pytest.mark.asyncio
  526. async def test_check_schedules_turns_on_at_scheduled_time(self, manager):
  527. """Verify scheduled on-time turns plug on."""
  528. mock_plug = MagicMock()
  529. mock_plug.id = 1
  530. mock_plug.name = "Test Plug"
  531. mock_plug.enabled = True
  532. mock_plug.schedule_enabled = True
  533. mock_plug.schedule_on_time = "08:00"
  534. mock_plug.schedule_off_time = "22:00"
  535. mock_plug.printer_id = None
  536. mock_plug.last_state = "OFF"
  537. with (
  538. patch("backend.app.services.smart_plug_manager.datetime") as mock_datetime,
  539. patch("backend.app.core.database.async_session") as mock_session_ctx,
  540. patch("backend.app.services.smart_plug_manager.tasmota_service") as mock_tasmota,
  541. ):
  542. # Set current time to 08:00
  543. mock_now = MagicMock()
  544. mock_now.strftime.return_value = "08:00"
  545. mock_datetime.now.return_value = mock_now
  546. mock_datetime.utcnow.return_value = datetime.now(timezone.utc)
  547. # Set up async session mock
  548. mock_db = AsyncMock()
  549. mock_result = MagicMock()
  550. mock_result.scalars.return_value.all.return_value = [mock_plug]
  551. mock_db.execute = AsyncMock(return_value=mock_result)
  552. mock_db.commit = AsyncMock()
  553. mock_session_ctx.return_value.__aenter__ = AsyncMock(return_value=mock_db)
  554. mock_session_ctx.return_value.__aexit__ = AsyncMock()
  555. mock_tasmota.turn_on = AsyncMock(return_value=True)
  556. await manager._check_schedules()
  557. mock_tasmota.turn_on.assert_called_once_with(mock_plug)
  558. @pytest.mark.asyncio
  559. async def test_check_schedules_turns_off_at_scheduled_time(self, manager):
  560. """Verify scheduled off-time turns plug off."""
  561. mock_plug = MagicMock()
  562. mock_plug.id = 1
  563. mock_plug.name = "Test Plug"
  564. mock_plug.enabled = True
  565. mock_plug.schedule_enabled = True
  566. mock_plug.schedule_on_time = "08:00"
  567. mock_plug.schedule_off_time = "22:00"
  568. mock_plug.printer_id = 1
  569. mock_plug.last_state = "ON"
  570. with (
  571. patch("backend.app.services.smart_plug_manager.datetime") as mock_datetime,
  572. patch("backend.app.core.database.async_session") as mock_session_ctx,
  573. patch("backend.app.services.smart_plug_manager.tasmota_service") as mock_tasmota,
  574. patch("backend.app.services.smart_plug_manager.printer_manager") as mock_pm,
  575. ):
  576. # Set current time to 22:00
  577. mock_now = MagicMock()
  578. mock_now.strftime.return_value = "22:00"
  579. mock_datetime.now.return_value = mock_now
  580. mock_datetime.utcnow.return_value = datetime.now(timezone.utc)
  581. # Set up async session mock
  582. mock_db = AsyncMock()
  583. mock_result = MagicMock()
  584. mock_result.scalars.return_value.all.return_value = [mock_plug]
  585. mock_db.execute = AsyncMock(return_value=mock_result)
  586. mock_db.commit = AsyncMock()
  587. mock_session_ctx.return_value.__aenter__ = AsyncMock(return_value=mock_db)
  588. mock_session_ctx.return_value.__aexit__ = AsyncMock()
  589. mock_tasmota.turn_off = AsyncMock(return_value=True)
  590. mock_pm.mark_printer_offline = MagicMock()
  591. await manager._check_schedules()
  592. mock_tasmota.turn_off.assert_called_once_with(mock_plug)
  593. @pytest.mark.asyncio
  594. async def test_check_schedules_skipped_when_disabled(self, manager):
  595. """Verify schedule is skipped when schedule_enabled is False."""
  596. mock_plug = MagicMock()
  597. mock_plug.id = 1
  598. mock_plug.enabled = True
  599. mock_plug.schedule_enabled = False # Disabled
  600. with (
  601. patch("backend.app.services.smart_plug_manager.datetime") as mock_datetime,
  602. patch("backend.app.core.database.async_session") as mock_session_ctx,
  603. patch("backend.app.services.smart_plug_manager.tasmota_service") as mock_tasmota,
  604. ):
  605. mock_now = MagicMock()
  606. mock_now.strftime.return_value = "08:00"
  607. mock_datetime.now.return_value = mock_now
  608. # Set up async session mock - returns no plugs (filtered by schedule_enabled)
  609. mock_db = AsyncMock()
  610. mock_result = MagicMock()
  611. mock_result.scalars.return_value.all.return_value = []
  612. mock_db.execute = AsyncMock(return_value=mock_result)
  613. mock_db.commit = AsyncMock()
  614. mock_session_ctx.return_value.__aenter__ = AsyncMock(return_value=mock_db)
  615. mock_session_ctx.return_value.__aexit__ = AsyncMock()
  616. mock_tasmota.turn_on = AsyncMock()
  617. await manager._check_schedules()
  618. mock_tasmota.turn_on.assert_not_called()
  619. class TestPendingAutoOffPersistence:
  620. """Tests for auto-off pending state persistence (restart recovery)."""
  621. @pytest.fixture
  622. def manager(self):
  623. return SmartPlugManager()
  624. @pytest.mark.asyncio
  625. async def test_resume_pending_auto_offs_temperature_mode(self, manager):
  626. """Verify temperature-based pending auto-offs are resumed on startup."""
  627. mock_plug = MagicMock()
  628. mock_plug.id = 1
  629. mock_plug.name = "Test Plug"
  630. mock_plug.ip_address = "192.168.1.100"
  631. mock_plug.username = None
  632. mock_plug.password = None
  633. mock_plug.printer_id = 1
  634. mock_plug.auto_off_pending = True
  635. mock_plug.auto_off_pending_since = datetime.now(timezone.utc)
  636. mock_plug.off_delay_mode = "temperature"
  637. mock_plug.off_temp_threshold = 70
  638. with (
  639. patch("backend.app.core.database.async_session") as mock_session_ctx,
  640. patch.object(manager, "_schedule_temp_based_off") as mock_schedule,
  641. ):
  642. mock_db = AsyncMock()
  643. mock_result = MagicMock()
  644. mock_result.scalars.return_value.all.return_value = [mock_plug]
  645. mock_db.execute = AsyncMock(return_value=mock_result)
  646. mock_session_ctx.return_value.__aenter__ = AsyncMock(return_value=mock_db)
  647. mock_session_ctx.return_value.__aexit__ = AsyncMock()
  648. await manager.resume_pending_auto_offs()
  649. mock_schedule.assert_called_once_with(mock_plug, 1, 70)
  650. @pytest.mark.asyncio
  651. async def test_resume_pending_auto_offs_time_mode_immediate_off(self, manager):
  652. """Verify time-based pending auto-offs turn off immediately on resume."""
  653. mock_plug = MagicMock()
  654. mock_plug.id = 1
  655. mock_plug.name = "Test Plug"
  656. mock_plug.ip_address = "192.168.1.100"
  657. mock_plug.username = None
  658. mock_plug.password = None
  659. mock_plug.printer_id = 1
  660. mock_plug.auto_off_pending = True
  661. mock_plug.auto_off_pending_since = datetime.now(timezone.utc)
  662. mock_plug.off_delay_mode = "time"
  663. with (
  664. patch("backend.app.core.database.async_session") as mock_session_ctx,
  665. patch("backend.app.services.smart_plug_manager.tasmota_service") as mock_tasmota,
  666. patch.object(manager, "_mark_auto_off_executed", new_callable=AsyncMock) as mock_mark,
  667. patch("backend.app.services.smart_plug_manager.printer_manager") as mock_pm,
  668. ):
  669. mock_db = AsyncMock()
  670. mock_result = MagicMock()
  671. mock_result.scalars.return_value.all.return_value = [mock_plug]
  672. mock_db.execute = AsyncMock(return_value=mock_result)
  673. mock_session_ctx.return_value.__aenter__ = AsyncMock(return_value=mock_db)
  674. mock_session_ctx.return_value.__aexit__ = AsyncMock()
  675. mock_tasmota.turn_off = AsyncMock(return_value=True)
  676. mock_pm.is_print_active.return_value = False # printer idle on restart
  677. await manager.resume_pending_auto_offs()
  678. mock_tasmota.turn_off.assert_called_once()
  679. mock_mark.assert_called_once_with(1)
  680. @pytest.mark.asyncio
  681. async def test_resume_pending_auto_off_skipped_when_printing(self, manager):
  682. """#1890: on restart, a stale pending off must NOT power off a live print;
  683. the pending flag is cleared instead."""
  684. mock_plug = MagicMock()
  685. mock_plug.id = 1
  686. mock_plug.name = "Test Plug"
  687. mock_plug.printer_id = 1
  688. mock_plug.auto_off_pending = True
  689. mock_plug.auto_off_pending_since = datetime.now(timezone.utc)
  690. mock_plug.off_delay_mode = "time"
  691. with (
  692. patch("backend.app.core.database.async_session") as mock_session_ctx,
  693. patch("backend.app.services.smart_plug_manager.tasmota_service") as mock_tasmota,
  694. patch("backend.app.services.smart_plug_manager.printer_manager") as mock_pm,
  695. patch.object(manager, "_schedule_temp_based_off") as mock_temp,
  696. ):
  697. mock_db = AsyncMock()
  698. mock_result = MagicMock()
  699. mock_result.scalars.return_value.all.return_value = [mock_plug]
  700. mock_db.execute = AsyncMock(return_value=mock_result)
  701. mock_session_ctx.return_value.__aenter__ = AsyncMock(return_value=mock_db)
  702. mock_session_ctx.return_value.__aexit__ = AsyncMock()
  703. mock_tasmota.turn_off = AsyncMock(return_value=True)
  704. mock_pm.is_print_active.return_value = True # printer printing again on restart
  705. mock_pm.get_status.return_value = MagicMock(state="RUNNING")
  706. await manager.resume_pending_auto_offs()
  707. mock_tasmota.turn_off.assert_not_called() # never cut power on the live print
  708. mock_temp.assert_not_called()
  709. assert mock_plug.auto_off_pending is False # stale pending cleared
  710. class TestActivePrintGuard:
  711. """#1890 — auto-off must never cut power while a print is loaded/running.
  712. Covers the two off-executors (`_delayed_off`, `_temp_based_off`), the new
  713. queue-override scheduler that honours per-plug settings, and the
  714. on_print_start cancellation gap.
  715. """
  716. @pytest.fixture
  717. def manager(self):
  718. return SmartPlugManager()
  719. @pytest.fixture
  720. def mock_plug(self):
  721. plug = MagicMock()
  722. plug.id = 1
  723. plug.name = "Test Plug"
  724. plug.ip_address = "192.168.1.100"
  725. plug.username = None
  726. plug.password = None
  727. plug.enabled = True
  728. plug.auto_on = True
  729. plug.auto_off = True
  730. plug.off_delay_mode = "time"
  731. plug.off_delay_minutes = 5
  732. plug.off_temp_threshold = 70
  733. plug.printer_id = 1
  734. plug.plug_type = "tasmota"
  735. plug.ha_entity_id = None
  736. return plug
  737. # ---- _delayed_off (time mode) ----------------------------------------
  738. @pytest.mark.asyncio
  739. async def test_delayed_off_skips_when_printer_printing_again(self, manager):
  740. """Time-delay fires after N min; if a reprint is running, skip the off."""
  741. with (
  742. patch("backend.app.services.smart_plug_manager.printer_manager") as mock_pm,
  743. patch.object(manager, "get_service_for_plug", new_callable=AsyncMock) as mock_get_svc,
  744. patch.object(manager, "_mark_auto_off_pending", new_callable=AsyncMock) as mock_mark_pending,
  745. patch.object(manager, "_mark_auto_off_executed", new_callable=AsyncMock) as mock_mark_exec,
  746. ):
  747. mock_pm.is_print_active.return_value = True
  748. mock_pm.get_status.return_value = MagicMock(state="RUNNING")
  749. await manager._delayed_off(1, "tasmota", "1.2.3.4", None, None, None, printer_id=1, delay_seconds=0)
  750. mock_get_svc.assert_not_called() # never even resolved a service to turn off
  751. mock_mark_exec.assert_not_called()
  752. mock_mark_pending.assert_awaited_with(1, False) # pending flag cleared
  753. @pytest.mark.asyncio
  754. async def test_delayed_off_powers_off_when_idle(self, manager):
  755. """When the printer is genuinely idle, the delayed off still fires."""
  756. mock_service = AsyncMock()
  757. mock_service.turn_off = AsyncMock(return_value=True)
  758. with (
  759. patch("backend.app.services.smart_plug_manager.printer_manager") as mock_pm,
  760. patch.object(manager, "get_service_for_plug", new_callable=AsyncMock, return_value=mock_service),
  761. patch.object(manager, "_mark_auto_off_executed", new_callable=AsyncMock),
  762. ):
  763. mock_pm.is_print_active.return_value = False
  764. await manager._delayed_off(1, "tasmota", "1.2.3.4", None, None, None, printer_id=1, delay_seconds=0)
  765. mock_service.turn_off.assert_awaited_once()
  766. mock_pm.mark_printer_offline.assert_called_once_with(1)
  767. # ---- _temp_based_off (temperature mode) ------------------------------
  768. @pytest.mark.asyncio
  769. async def test_temp_based_off_defers_while_printing_even_if_cool(self, manager):
  770. """Nozzle can dip below threshold during a reprint's PREPARE/heat phase;
  771. the guard must defer rather than cut power on the loaded print."""
  772. with (
  773. patch("backend.app.services.smart_plug_manager.printer_manager") as mock_pm,
  774. patch("backend.app.services.smart_plug_manager.asyncio.sleep", new_callable=AsyncMock) as mock_sleep,
  775. patch.object(manager, "get_service_for_plug", new_callable=AsyncMock) as mock_get_svc,
  776. ):
  777. # Cool enough to trip the threshold, but a print is active.
  778. mock_pm.get_status.return_value = MagicMock(state="PREPARE", temperatures={"nozzle": 30})
  779. mock_pm.is_print_active.return_value = True
  780. # Break the poll loop after the first deferral so the test terminates.
  781. mock_sleep.side_effect = asyncio.CancelledError()
  782. await manager._temp_based_off(1, "tasmota", "1.2.3.4", None, None, None, printer_id=1, temp_threshold=70)
  783. mock_get_svc.assert_not_called() # never turned off despite temp < threshold
  784. @pytest.mark.asyncio
  785. async def test_temp_based_off_powers_off_when_cool_and_idle(self, manager):
  786. """Cool nozzle + idle printer → turn off using the plug's threshold."""
  787. mock_service = AsyncMock()
  788. mock_service.turn_off = AsyncMock(return_value=True)
  789. with (
  790. patch("backend.app.services.smart_plug_manager.printer_manager") as mock_pm,
  791. patch("backend.app.services.smart_plug_manager.asyncio.sleep", new_callable=AsyncMock),
  792. patch.object(manager, "get_service_for_plug", new_callable=AsyncMock, return_value=mock_service),
  793. patch.object(manager, "_mark_auto_off_executed", new_callable=AsyncMock),
  794. ):
  795. mock_pm.get_status.return_value = MagicMock(state="FINISH", temperatures={"nozzle": 40})
  796. mock_pm.is_print_active.return_value = False
  797. await manager._temp_based_off(1, "tasmota", "1.2.3.4", None, None, None, printer_id=1, temp_threshold=55)
  798. mock_service.turn_off.assert_awaited_once()
  799. # ---- schedule_off_after_queue_job (uses plug settings, not hardcoded 50/600)
  800. @pytest.mark.asyncio
  801. async def test_queue_off_uses_time_mode_regardless_of_global_auto_off(self, manager, mock_plug):
  802. """Queue 'auto off after this job' is a per-job override — it schedules
  803. even when the plug's global auto_off is disabled, and honours the plug's
  804. configured time-delay mode."""
  805. mock_plug.auto_off = False
  806. mock_plug.off_delay_mode = "time"
  807. mock_plug.off_delay_minutes = 8
  808. with (
  809. patch.object(manager, "_get_plugs_for_printer", new_callable=AsyncMock, return_value=[mock_plug]),
  810. patch.object(manager, "_schedule_delayed_off") as mock_delayed,
  811. patch.object(manager, "_schedule_temp_based_off") as mock_temp,
  812. ):
  813. await manager.schedule_off_after_queue_job(printer_id=1, db=AsyncMock())
  814. mock_delayed.assert_called_once_with(mock_plug, 1, 8 * 60) # plug's minutes, not hardcoded
  815. mock_temp.assert_not_called()
  816. @pytest.mark.asyncio
  817. async def test_queue_off_uses_configured_temp_threshold(self, manager, mock_plug):
  818. """Temperature mode passes the plug's off_temp_threshold, not a hardcoded 50."""
  819. mock_plug.off_delay_mode = "temperature"
  820. mock_plug.off_temp_threshold = 65
  821. with (
  822. patch.object(manager, "_get_plugs_for_printer", new_callable=AsyncMock, return_value=[mock_plug]),
  823. patch.object(manager, "_schedule_delayed_off") as mock_delayed,
  824. patch.object(manager, "_schedule_temp_based_off") as mock_temp,
  825. ):
  826. await manager.schedule_off_after_queue_job(printer_id=1, db=AsyncMock())
  827. mock_temp.assert_called_once_with(mock_plug, 1, 65)
  828. mock_delayed.assert_not_called()
  829. @pytest.mark.asyncio
  830. async def test_queue_off_skips_disabled_and_ha_script_plugs(self, manager, mock_plug):
  831. """Disabled plugs and HA-script entities are never scheduled."""
  832. disabled = MagicMock(id=2, name="disabled", enabled=False, plug_type="tasmota", ha_entity_id=None)
  833. ha_script = MagicMock(
  834. id=3, name="ha", enabled=True, plug_type="homeassistant", ha_entity_id="script.printer_off"
  835. )
  836. with (
  837. patch.object(manager, "_get_plugs_for_printer", new_callable=AsyncMock, return_value=[disabled, ha_script]),
  838. patch.object(manager, "_schedule_off_per_mode") as mock_sched,
  839. ):
  840. await manager.schedule_off_after_queue_job(printer_id=1, db=AsyncMock())
  841. mock_sched.assert_not_called()
  842. # ---- on_print_start cancellation gap ---------------------------------
  843. @pytest.mark.asyncio
  844. async def test_reprint_cancels_pending_off_even_when_auto_on_disabled(self, manager, mock_plug):
  845. """A reprint must abort a scheduled auto-off regardless of auto_on (#1890).
  846. Previously the cancel lived behind the auto_on gate, so a plug with
  847. auto_on disabled kept its pending off and cut power mid-reprint.
  848. """
  849. mock_plug.auto_on = False
  850. mock_task = MagicMock()
  851. manager._pending_off[mock_plug.id] = mock_task
  852. with (
  853. patch.object(manager, "_get_plugs_for_printer", new_callable=AsyncMock, return_value=[mock_plug]),
  854. patch.object(manager, "_mark_auto_off_pending", new_callable=AsyncMock),
  855. patch("backend.app.services.smart_plug_manager.tasmota_service") as mock_tasmota,
  856. ):
  857. mock_tasmota.turn_on = AsyncMock()
  858. await manager.on_print_start(printer_id=1, db=AsyncMock())
  859. mock_task.cancel.assert_called_once() # cancelled despite auto_on=False
  860. assert mock_plug.id not in manager._pending_off
  861. mock_tasmota.turn_on.assert_not_called() # but not powered on
  862. class TestAccessoryPlugDoesNotMarkPrinterOffline:
  863. """#2629 — a plug linked to a printer is not necessarily its power supply.
  864. Filter fans, chamber lights and enclosure heaters are linked so they follow
  865. the print cycle. Marking the printer offline when one of those switches off
  866. blanks the printer state and stalls the queue until a manual Force Refresh.
  867. """
  868. @pytest.fixture
  869. def manager(self):
  870. return SmartPlugManager()
  871. @pytest.fixture
  872. def accessory_plug(self):
  873. plug = MagicMock()
  874. plug.id = 1
  875. plug.name = "BentoBox Filter"
  876. plug.ip_address = "192.168.1.100"
  877. plug.username = None
  878. plug.password = None
  879. plug.enabled = True
  880. plug.auto_off = True
  881. plug.off_delay_mode = "time"
  882. plug.off_delay_minutes = 1
  883. plug.off_temp_threshold = 70
  884. plug.printer_id = 1
  885. plug.plug_type = "tasmota"
  886. plug.ha_entity_id = None
  887. plug.controls_printer_power = False
  888. return plug
  889. @pytest.mark.asyncio
  890. async def test_delayed_off_skips_offline_mark_for_accessory(self, manager):
  891. mock_service = AsyncMock()
  892. mock_service.turn_off = AsyncMock(return_value=True)
  893. with (
  894. patch("backend.app.services.smart_plug_manager.printer_manager") as mock_pm,
  895. patch.object(manager, "get_service_for_plug", new_callable=AsyncMock, return_value=mock_service),
  896. patch.object(manager, "_mark_auto_off_executed", new_callable=AsyncMock),
  897. ):
  898. mock_pm.is_print_active.return_value = False
  899. await manager._delayed_off(
  900. 1, "tasmota", "1.2.3.4", None, None, None, printer_id=1, delay_seconds=0, controls_printer_power=False
  901. )
  902. mock_service.turn_off.assert_awaited_once() # the plug still switches off
  903. mock_pm.mark_printer_offline.assert_not_called() # but the printer is untouched
  904. @pytest.mark.asyncio
  905. async def test_temp_based_off_skips_offline_mark_for_accessory(self, manager):
  906. mock_service = AsyncMock()
  907. mock_service.turn_off = AsyncMock(return_value=True)
  908. with (
  909. patch("backend.app.services.smart_plug_manager.printer_manager") as mock_pm,
  910. patch("backend.app.services.smart_plug_manager.asyncio.sleep", new_callable=AsyncMock),
  911. patch.object(manager, "get_service_for_plug", new_callable=AsyncMock, return_value=mock_service),
  912. patch.object(manager, "_mark_auto_off_executed", new_callable=AsyncMock),
  913. ):
  914. mock_pm.get_status.return_value = MagicMock(state="FINISH", temperatures={"nozzle": 40})
  915. mock_pm.is_print_active.return_value = False
  916. await manager._temp_based_off(
  917. 1,
  918. "tasmota",
  919. "1.2.3.4",
  920. None,
  921. None,
  922. None,
  923. printer_id=1,
  924. temp_threshold=55,
  925. controls_printer_power=False,
  926. )
  927. mock_service.turn_off.assert_awaited_once()
  928. mock_pm.mark_printer_offline.assert_not_called()
  929. @pytest.mark.asyncio
  930. async def test_schedulers_forward_the_flag(self, manager, accessory_plug):
  931. """The flag lives on the plug row; both schedulers must pass it into the
  932. detached task, which only receives primitives."""
  933. with (
  934. patch.object(manager, "_mark_auto_off_pending", new_callable=AsyncMock),
  935. patch.object(manager, "_delayed_off", new_callable=AsyncMock) as mock_delayed,
  936. patch.object(manager, "_temp_based_off", new_callable=AsyncMock) as mock_temp,
  937. ):
  938. manager._schedule_delayed_off(accessory_plug, 1, 60)
  939. manager._schedule_temp_based_off(accessory_plug, 1, 70)
  940. assert mock_delayed.call_args.kwargs["controls_printer_power"] is False
  941. assert mock_temp.call_args.kwargs["controls_printer_power"] is False
  942. @pytest.mark.asyncio
  943. async def test_scheduled_off_skips_offline_mark_for_accessory(self, manager, accessory_plug):
  944. """The time-of-day schedule path has its own turn-off + offline mark."""
  945. accessory_plug.schedule_enabled = True
  946. accessory_plug.schedule_on_time = None
  947. accessory_plug.schedule_off_time = "22:00"
  948. with (
  949. patch("backend.app.services.smart_plug_manager.datetime") as mock_datetime,
  950. patch("backend.app.core.database.async_session") as mock_session_ctx,
  951. patch("backend.app.services.smart_plug_manager.tasmota_service") as mock_tasmota,
  952. patch("backend.app.services.smart_plug_manager.printer_manager") as mock_pm,
  953. ):
  954. mock_now = MagicMock()
  955. mock_now.strftime.return_value = "22:00"
  956. mock_datetime.now.return_value = mock_now
  957. mock_db = AsyncMock()
  958. mock_result = MagicMock()
  959. mock_result.scalars.return_value.all.return_value = [accessory_plug]
  960. mock_db.execute = AsyncMock(return_value=mock_result)
  961. mock_db.commit = AsyncMock()
  962. mock_session_ctx.return_value.__aenter__ = AsyncMock(return_value=mock_db)
  963. mock_session_ctx.return_value.__aexit__ = AsyncMock()
  964. mock_tasmota.turn_off = AsyncMock(return_value=True)
  965. await manager._check_schedules()
  966. mock_tasmota.turn_off.assert_awaited_once_with(accessory_plug)
  967. mock_pm.mark_printer_offline.assert_not_called()
  968. @pytest.mark.asyncio
  969. async def test_scheduled_off_still_marks_offline_for_power_plug(self, manager, accessory_plug):
  970. """Default (a plug that really feeds the printer) keeps the old behaviour."""
  971. accessory_plug.controls_printer_power = True
  972. accessory_plug.schedule_enabled = True
  973. accessory_plug.schedule_on_time = None
  974. accessory_plug.schedule_off_time = "22:00"
  975. with (
  976. patch("backend.app.services.smart_plug_manager.datetime") as mock_datetime,
  977. patch("backend.app.core.database.async_session") as mock_session_ctx,
  978. patch("backend.app.services.smart_plug_manager.tasmota_service") as mock_tasmota,
  979. patch("backend.app.services.smart_plug_manager.printer_manager") as mock_pm,
  980. ):
  981. mock_now = MagicMock()
  982. mock_now.strftime.return_value = "22:00"
  983. mock_datetime.now.return_value = mock_now
  984. mock_db = AsyncMock()
  985. mock_result = MagicMock()
  986. mock_result.scalars.return_value.all.return_value = [accessory_plug]
  987. mock_db.execute = AsyncMock(return_value=mock_result)
  988. mock_db.commit = AsyncMock()
  989. mock_session_ctx.return_value.__aenter__ = AsyncMock(return_value=mock_db)
  990. mock_session_ctx.return_value.__aexit__ = AsyncMock()
  991. mock_tasmota.turn_off = AsyncMock(return_value=True)
  992. await manager._check_schedules()
  993. mock_pm.mark_printer_offline.assert_called_once_with(1)