|
|
@@ -5,7 +5,7 @@ custom presets) takes priority, with slot reuse and generic fallback as
|
|
|
lower-priority fallbacks.
|
|
|
"""
|
|
|
|
|
|
-from unittest.mock import MagicMock, patch
|
|
|
+from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
|
|
import pytest
|
|
|
from httpx import AsyncClient
|
|
|
@@ -1644,11 +1644,13 @@ class TestAutoUnlinkDuringRunout:
|
|
|
async def test_cleared_tray_data_still_unlinks_when_idle(
|
|
|
self, async_client: AsyncClient, printer_factory, spool_factory, db_session: AsyncSession
|
|
|
):
|
|
|
- """Off the print, an emptied slot really does mean the spool is gone."""
|
|
|
+ """Off the print, an emptied slot really does mean the spool is gone --
|
|
|
+ once it has stayed empty for the grace period (#3186)."""
|
|
|
from unittest.mock import AsyncMock
|
|
|
|
|
|
from backend.app.main import on_ams_change
|
|
|
from backend.app.models.spool_assignment import SpoolAssignment
|
|
|
+ from backend.app.services import slot_unlink_grace
|
|
|
|
|
|
printer = await printer_factory(name="H2D")
|
|
|
spool = await spool_factory(material="ABS", rgba="616777FF")
|
|
|
@@ -1679,7 +1681,14 @@ class TestAutoUnlinkDuringRunout:
|
|
|
mock_ws.send_printer_status = AsyncMock()
|
|
|
mock_ws.broadcast = AsyncMock()
|
|
|
|
|
|
- await on_ams_change(printer.id, ams_data)
|
|
|
+ clock = [1000.0]
|
|
|
+ with patch.object(slot_unlink_grace, "_now", lambda: clock[0]):
|
|
|
+ await on_ams_change(printer.id, ams_data)
|
|
|
+ db_session.expunge_all()
|
|
|
+ assert await db_session.get(SpoolAssignment, assignment_id) is not None
|
|
|
+
|
|
|
+ clock[0] += slot_unlink_grace.GRACE_SECONDS
|
|
|
+ await on_ams_change(printer.id, ams_data)
|
|
|
|
|
|
db_session.expunge_all()
|
|
|
assert await db_session.get(SpoolAssignment, assignment_id) is None
|
|
|
@@ -1855,13 +1864,18 @@ class TestAutoUnlinkOccupiedSlot:
|
|
|
the assignment still goes, which is what makes the test above a
|
|
|
distinction rather than a blanket reprieve."""
|
|
|
from backend.app.models.spool_assignment import SpoolAssignment
|
|
|
+ from backend.app.services import slot_unlink_grace
|
|
|
|
|
|
printer = await printer_factory(name="X1C")
|
|
|
spool = await spool_factory(material="PLA", rgba="8A8F92FF")
|
|
|
assignment_id = await self._assignment(db_session, printer, spool)
|
|
|
|
|
|
ams_data = [{"id": 1, "tray": [{"id": 1, "exists": False, "tray_type": "", "tray_color": "", "state": 9}]}]
|
|
|
- await self._run(printer.id, ams_data, _make_printing_status(ams_data, state="IDLE"))
|
|
|
+ clock = [1000.0]
|
|
|
+ with patch.object(slot_unlink_grace, "_now", lambda: clock[0]):
|
|
|
+ await self._run(printer.id, ams_data, _make_printing_status(ams_data, state="IDLE"))
|
|
|
+ clock[0] += slot_unlink_grace.GRACE_SECONDS
|
|
|
+ await self._run(printer.id, ams_data, _make_printing_status(ams_data, state="IDLE"))
|
|
|
|
|
|
db_session.expunge_all()
|
|
|
assert await db_session.get(SpoolAssignment, assignment_id) is None
|
|
|
@@ -1888,6 +1902,377 @@ class TestAutoUnlinkOccupiedSlot:
|
|
|
assert await db_session.get(SpoolAssignment, assignment_id) is None
|
|
|
|
|
|
|
|
|
+class TestAutoUnlinkHeldForGrace:
|
|
|
+ """#3186: one push is not proof a spool was taken out.
|
|
|
+
|
|
|
+ An idle X1 Carbon on X1Plus sent a push that cleared a whole AMS unit --
|
|
|
+ presence bits off, colour and type blank -- and four saved assignments were
|
|
|
+ deleted in the same instant (``Auto-unlinked 4 stale spool assignments``).
|
|
|
+ The spools never moved. A removal is now held for the grace period and
|
|
|
+ unlinked only if the slot is still empty then.
|
|
|
+ """
|
|
|
+
|
|
|
+ @staticmethod
|
|
|
+ async def _assign_unit(db_session, printer, spool):
|
|
|
+ from backend.app.models.spool_assignment import SpoolAssignment
|
|
|
+
|
|
|
+ ids = []
|
|
|
+ for tray_id in range(4):
|
|
|
+ a = SpoolAssignment(
|
|
|
+ spool_id=spool.id,
|
|
|
+ printer_id=printer.id,
|
|
|
+ ams_id=1,
|
|
|
+ tray_id=tray_id,
|
|
|
+ fingerprint_color="858585FF",
|
|
|
+ fingerprint_type="PLA",
|
|
|
+ )
|
|
|
+ db_session.add(a)
|
|
|
+ await db_session.commit()
|
|
|
+ ids.append(a.id)
|
|
|
+ return ids
|
|
|
+
|
|
|
+ @staticmethod
|
|
|
+ def _unit(blank: bool):
|
|
|
+ return [
|
|
|
+ {
|
|
|
+ "id": 1,
|
|
|
+ "tray": [
|
|
|
+ {"id": t, "exists": False, "tray_type": "", "tray_color": "", "state": 9}
|
|
|
+ if blank
|
|
|
+ else {"id": t, "exists": True, "tray_type": "PLA", "tray_color": "858585FF", "state": 11}
|
|
|
+ for t in range(4)
|
|
|
+ ],
|
|
|
+ }
|
|
|
+ ]
|
|
|
+
|
|
|
+ @staticmethod
|
|
|
+ async def _surviving(db_session, ids):
|
|
|
+ from backend.app.models.spool_assignment import SpoolAssignment
|
|
|
+
|
|
|
+ db_session.expunge_all()
|
|
|
+ return [i for i in ids if await db_session.get(SpoolAssignment, i) is not None]
|
|
|
+
|
|
|
+ @pytest.mark.asyncio
|
|
|
+ @pytest.mark.integration
|
|
|
+ async def test_a_whole_unit_blanking_for_one_push_keeps_every_assignment(
|
|
|
+ self, async_client: AsyncClient, printer_factory, spool_factory, db_session: AsyncSession
|
|
|
+ ):
|
|
|
+ from backend.app.services import slot_unlink_grace
|
|
|
+
|
|
|
+ printer = await printer_factory(name="X1C")
|
|
|
+ spool = await spool_factory(material="PLA", rgba="000000FF")
|
|
|
+ ids = await self._assign_unit(db_session, printer, spool)
|
|
|
+
|
|
|
+ clock = [1000.0]
|
|
|
+ with patch.object(slot_unlink_grace, "_now", lambda: clock[0]):
|
|
|
+ blank, back = self._unit(blank=True), self._unit(blank=False)
|
|
|
+ await TestAutoUnlinkOccupiedSlot._run(printer.id, blank, _make_printing_status(blank, state="IDLE"))
|
|
|
+ assert await self._surviving(db_session, ids) == ids
|
|
|
+
|
|
|
+ clock[0] += 30
|
|
|
+ await TestAutoUnlinkOccupiedSlot._run(printer.id, back, _make_printing_status(back, state="IDLE"))
|
|
|
+
|
|
|
+ # The recovery dropped the holds, so a blank long after the first
|
|
|
+ # one starts a fresh clock instead of confirming the old one.
|
|
|
+ clock[0] += slot_unlink_grace.GRACE_SECONDS
|
|
|
+ await TestAutoUnlinkOccupiedSlot._run(printer.id, blank, _make_printing_status(blank, state="IDLE"))
|
|
|
+
|
|
|
+ assert await self._surviving(db_session, ids) == ids
|
|
|
+
|
|
|
+ @pytest.mark.asyncio
|
|
|
+ @pytest.mark.integration
|
|
|
+ async def test_a_unit_missing_from_one_push_keeps_its_assignments(
|
|
|
+ self, async_client: AsyncClient, printer_factory, spool_factory, db_session: AsyncSession
|
|
|
+ ):
|
|
|
+ printer = await printer_factory(name="X1C")
|
|
|
+ spool = await spool_factory(material="PLA", rgba="000000FF")
|
|
|
+ ids = await self._assign_unit(db_session, printer, spool)
|
|
|
+
|
|
|
+ # AMS 1 dropped out of the payload entirely.
|
|
|
+ other_unit = [{"id": 0, "tray": [{"id": 0, "exists": True, "tray_type": "PLA", "tray_color": "FFFFFFFF"}]}]
|
|
|
+ await TestAutoUnlinkOccupiedSlot._run(printer.id, other_unit, _make_printing_status(other_unit, state="IDLE"))
|
|
|
+
|
|
|
+ assert await self._surviving(db_session, ids) == ids
|
|
|
+
|
|
|
+ @pytest.mark.asyncio
|
|
|
+ @pytest.mark.integration
|
|
|
+ async def test_a_slot_that_stays_empty_is_unlinked_by_the_recheck(
|
|
|
+ self, async_client: AsyncClient, printer_factory, spool_factory, db_session: AsyncSession
|
|
|
+ ):
|
|
|
+ """No second push has to arrive: a spool taken out and left out never
|
|
|
+ changes the AMS hash again, so the held removal re-checks itself."""
|
|
|
+ from backend.app.main import _recheck_held_unlinks
|
|
|
+ from backend.app.services import slot_unlink_grace
|
|
|
+
|
|
|
+ printer = await printer_factory(name="X1C")
|
|
|
+ spool = await spool_factory(material="PLA", rgba="000000FF")
|
|
|
+ ids = await self._assign_unit(db_session, printer, spool)
|
|
|
+ blank = self._unit(blank=True)
|
|
|
+
|
|
|
+ clock = [1000.0]
|
|
|
+ with patch.object(slot_unlink_grace, "_now", lambda: clock[0]):
|
|
|
+ await TestAutoUnlinkOccupiedSlot._run(printer.id, blank, _make_printing_status(blank, state="IDLE"))
|
|
|
+ assert printer.id in slot_unlink_grace._recheck_tasks, "a held removal must schedule its re-check"
|
|
|
+ assert await self._surviving(db_session, ids) == ids
|
|
|
+
|
|
|
+ clock[0] += slot_unlink_grace.GRACE_SECONDS
|
|
|
+ with (
|
|
|
+ patch("backend.app.main.printer_manager") as mock_pm_main,
|
|
|
+ patch("backend.app.main.ws_manager") as mock_ws,
|
|
|
+ ):
|
|
|
+ mock_pm_main.get_status.return_value = _make_printing_status(blank, state="IDLE")
|
|
|
+ mock_ws.broadcast = AsyncMock()
|
|
|
+ await _recheck_held_unlinks(printer.id)
|
|
|
+
|
|
|
+ assert await self._surviving(db_session, ids) == []
|
|
|
+
|
|
|
+ @pytest.mark.asyncio
|
|
|
+ @pytest.mark.integration
|
|
|
+ async def test_the_recheck_leaves_a_slot_that_came_back(
|
|
|
+ self, async_client: AsyncClient, printer_factory, spool_factory, db_session: AsyncSession
|
|
|
+ ):
|
|
|
+ from backend.app.main import _recheck_held_unlinks
|
|
|
+ from backend.app.services import slot_unlink_grace
|
|
|
+
|
|
|
+ printer = await printer_factory(name="X1C")
|
|
|
+ spool = await spool_factory(material="PLA", rgba="000000FF")
|
|
|
+ ids = await self._assign_unit(db_session, printer, spool)
|
|
|
+ blank, back = self._unit(blank=True), self._unit(blank=False)
|
|
|
+
|
|
|
+ clock = [1000.0]
|
|
|
+ with patch.object(slot_unlink_grace, "_now", lambda: clock[0]):
|
|
|
+ await TestAutoUnlinkOccupiedSlot._run(printer.id, blank, _make_printing_status(blank, state="IDLE"))
|
|
|
+ clock[0] += slot_unlink_grace.GRACE_SECONDS
|
|
|
+ with (
|
|
|
+ patch("backend.app.main.printer_manager") as mock_pm_main,
|
|
|
+ patch("backend.app.main.ws_manager") as mock_ws,
|
|
|
+ ):
|
|
|
+ mock_pm_main.get_status.return_value = _make_printing_status(back, state="IDLE")
|
|
|
+ mock_ws.broadcast = AsyncMock()
|
|
|
+ await _recheck_held_unlinks(printer.id)
|
|
|
+
|
|
|
+ assert await self._surviving(db_session, ids) == ids
|
|
|
+
|
|
|
+
|
|
|
+class TestAutoUnlinkSwapDuringGrace:
|
|
|
+ """A non-RFID spool swapped out and another put in within the grace period.
|
|
|
+
|
|
|
+ The new spool reports occupied-but-blank, which on its own keeps an
|
|
|
+ assignment (#3100). After an *empty* report it means the spool changed,
|
|
|
+ so the held removal has to keep running rather than be cancelled -- or the
|
|
|
+ old spool would stay assigned to the new one indefinitely.
|
|
|
+ """
|
|
|
+
|
|
|
+ @pytest.mark.asyncio
|
|
|
+ @pytest.mark.integration
|
|
|
+ async def test_a_swap_still_unlinks_once_the_grace_runs_out(
|
|
|
+ self, async_client: AsyncClient, printer_factory, spool_factory, db_session: AsyncSession
|
|
|
+ ):
|
|
|
+ from backend.app.models.spool_assignment import SpoolAssignment
|
|
|
+ from backend.app.services import slot_unlink_grace
|
|
|
+
|
|
|
+ printer = await printer_factory(name="X1C")
|
|
|
+ spool = await spool_factory(material="PLA", rgba="8A8F92FF")
|
|
|
+ assignment_id = await TestAutoUnlinkOccupiedSlot._assignment(db_session, printer, spool)
|
|
|
+
|
|
|
+ taken_out = [{"id": 1, "tray": [{"id": 1, "exists": False, "tray_type": "", "tray_color": "", "state": 9}]}]
|
|
|
+ new_spool = [{"id": 1, "tray": [{"id": 1, "exists": True, "tray_type": "", "tray_color": "", "state": 9}]}]
|
|
|
+ clock = [1000.0]
|
|
|
+ with patch.object(slot_unlink_grace, "_now", lambda: clock[0]):
|
|
|
+ await TestAutoUnlinkOccupiedSlot._run(printer.id, taken_out, _make_printing_status(taken_out, state="IDLE"))
|
|
|
+ clock[0] += 20
|
|
|
+ await TestAutoUnlinkOccupiedSlot._run(printer.id, new_spool, _make_printing_status(new_spool, state="IDLE"))
|
|
|
+ db_session.expunge_all()
|
|
|
+ assert await db_session.get(SpoolAssignment, assignment_id) is not None
|
|
|
+
|
|
|
+ clock[0] += slot_unlink_grace.GRACE_SECONDS
|
|
|
+ await TestAutoUnlinkOccupiedSlot._run(printer.id, new_spool, _make_printing_status(new_spool, state="IDLE"))
|
|
|
+
|
|
|
+ db_session.expunge_all()
|
|
|
+ assert await db_session.get(SpoolAssignment, assignment_id) is None
|
|
|
+
|
|
|
+ @pytest.mark.asyncio
|
|
|
+ @pytest.mark.integration
|
|
|
+ async def test_a_new_assignment_to_the_slot_starts_clean(
|
|
|
+ self, async_client: AsyncClient, printer_factory, spool_factory, db_session: AsyncSession
|
|
|
+ ):
|
|
|
+ """Assigning a spool to the slot after the swap -- even the same spool
|
|
|
+ again -- makes a new row, and the old clock does not carry over to it."""
|
|
|
+ from backend.app.models.spool_assignment import SpoolAssignment
|
|
|
+ from backend.app.services import slot_unlink_grace
|
|
|
+
|
|
|
+ printer = await printer_factory(name="X1C")
|
|
|
+ spool = await spool_factory(material="PLA", rgba="8A8F92FF")
|
|
|
+ old_id = await TestAutoUnlinkOccupiedSlot._assignment(db_session, printer, spool)
|
|
|
+
|
|
|
+ taken_out = [{"id": 1, "tray": [{"id": 1, "exists": False, "tray_type": "", "tray_color": "", "state": 9}]}]
|
|
|
+ back_in = [{"id": 1, "tray": [{"id": 1, "exists": True, "tray_type": "", "tray_color": "", "state": 9}]}]
|
|
|
+ clock = [1000.0]
|
|
|
+ with patch.object(slot_unlink_grace, "_now", lambda: clock[0]):
|
|
|
+ await TestAutoUnlinkOccupiedSlot._run(printer.id, taken_out, _make_printing_status(taken_out, state="IDLE"))
|
|
|
+
|
|
|
+ # The user puts the spool back and assigns it again through the
|
|
|
+ # endpoint, which replaces the row -- on SQLite quite possibly under
|
|
|
+ # the same id, so only dropping the hold keeps the old clock off it.
|
|
|
+ hold_key = ("inventory", 1, 1, spool.id)
|
|
|
+ assert slot_unlink_grace.is_held(printer.id, hold_key)
|
|
|
+ with patch("backend.app.services.printer_manager.printer_manager") as mock_pm:
|
|
|
+ mock_pm.get_client.return_value = None
|
|
|
+ mock_pm.get_status.return_value = _make_mock_status(ams_data=back_in)
|
|
|
+ response = await async_client.post(
|
|
|
+ "/api/v1/inventory/assignments",
|
|
|
+ json={"spool_id": spool.id, "printer_id": printer.id, "ams_id": 1, "tray_id": 1},
|
|
|
+ )
|
|
|
+ assert response.status_code == 200
|
|
|
+ assert not slot_unlink_grace.is_held(printer.id, hold_key), "an assignment must drop the old hold"
|
|
|
+ new_id = response.json()["id"]
|
|
|
+ assert await db_session.get(SpoolAssignment, old_id) is None or new_id == old_id
|
|
|
+
|
|
|
+ clock[0] += slot_unlink_grace.GRACE_SECONDS
|
|
|
+ await TestAutoUnlinkOccupiedSlot._run(printer.id, back_in, _make_printing_status(back_in, state="IDLE"))
|
|
|
+
|
|
|
+ db_session.expunge_all()
|
|
|
+ assert await db_session.get(SpoolAssignment, new_id) is not None
|
|
|
+
|
|
|
+
|
|
|
+class TestSpoolmanSlotUnlinkHeldForGrace:
|
|
|
+ """Spoolman mode's half of #3186: the slot row waits out the same grace."""
|
|
|
+
|
|
|
+ @pytest.mark.asyncio
|
|
|
+ @pytest.mark.integration
|
|
|
+ async def test_the_recheck_deletes_a_row_only_once_the_slot_stayed_empty(
|
|
|
+ self, async_client: AsyncClient, printer_factory, db_session: AsyncSession
|
|
|
+ ):
|
|
|
+ from backend.app.main import _expire_spoolman_empty_slots
|
|
|
+ from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
|
|
|
+ from backend.app.services import slot_unlink_grace
|
|
|
+
|
|
|
+ await TestSpoolmanSlotAssignmentDuringRunout()._enable_spoolman(db_session)
|
|
|
+ printer = await printer_factory(name="H2D")
|
|
|
+ row = SpoolmanSlotAssignment(printer_id=printer.id, ams_id=0, tray_id=2, spoolman_spool_id=41)
|
|
|
+ db_session.add(row)
|
|
|
+ await db_session.commit()
|
|
|
+ row_id = row.id
|
|
|
+
|
|
|
+ blank = [{"id": 0, "tray": [{"id": 2, "exists": False, "tray_type": "", "tray_color": "", "state": 9}]}]
|
|
|
+ clock = [1000.0]
|
|
|
+ with (
|
|
|
+ patch.object(slot_unlink_grace, "_now", lambda: clock[0]),
|
|
|
+ patch("backend.app.main.ws_manager") as mock_ws,
|
|
|
+ ):
|
|
|
+ mock_ws.broadcast = AsyncMock()
|
|
|
+ await _expire_spoolman_empty_slots(printer.id, blank, printing_now=False)
|
|
|
+ db_session.expunge_all()
|
|
|
+ assert await db_session.get(SpoolmanSlotAssignment, row_id) is not None
|
|
|
+
|
|
|
+ clock[0] += slot_unlink_grace.GRACE_SECONDS
|
|
|
+ await _expire_spoolman_empty_slots(printer.id, blank, printing_now=False)
|
|
|
+
|
|
|
+ db_session.expunge_all()
|
|
|
+ assert await db_session.get(SpoolmanSlotAssignment, row_id) is None
|
|
|
+ mock_ws.broadcast.assert_awaited()
|
|
|
+
|
|
|
+ @pytest.mark.asyncio
|
|
|
+ @pytest.mark.integration
|
|
|
+ async def test_a_swap_to_an_unreadable_spool_still_expires_the_row(
|
|
|
+ self, async_client: AsyncClient, printer_factory, db_session: AsyncSession
|
|
|
+ ):
|
|
|
+ from backend.app.main import _expire_spoolman_empty_slots
|
|
|
+ from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
|
|
|
+ from backend.app.services import slot_unlink_grace
|
|
|
+
|
|
|
+ await TestSpoolmanSlotAssignmentDuringRunout()._enable_spoolman(db_session)
|
|
|
+ printer = await printer_factory(name="H2D")
|
|
|
+ row = SpoolmanSlotAssignment(printer_id=printer.id, ams_id=0, tray_id=2, spoolman_spool_id=41)
|
|
|
+ db_session.add(row)
|
|
|
+ await db_session.commit()
|
|
|
+ row_id = row.id
|
|
|
+
|
|
|
+ taken_out = [{"id": 0, "tray": [{"id": 2, "exists": False, "tray_type": "", "tray_color": "", "state": 9}]}]
|
|
|
+ new_spool = [{"id": 0, "tray": [{"id": 2, "exists": True, "tray_type": "", "tray_color": "", "state": 9}]}]
|
|
|
+ clock = [1000.0]
|
|
|
+ with (
|
|
|
+ patch.object(slot_unlink_grace, "_now", lambda: clock[0]),
|
|
|
+ patch("backend.app.main.ws_manager") as mock_ws,
|
|
|
+ ):
|
|
|
+ mock_ws.broadcast = AsyncMock()
|
|
|
+ await _expire_spoolman_empty_slots(printer.id, taken_out, printing_now=False)
|
|
|
+ clock[0] += 20
|
|
|
+ await _expire_spoolman_empty_slots(printer.id, new_spool, printing_now=False)
|
|
|
+ db_session.expunge_all()
|
|
|
+ assert await db_session.get(SpoolmanSlotAssignment, row_id) is not None
|
|
|
+
|
|
|
+ clock[0] += slot_unlink_grace.GRACE_SECONDS
|
|
|
+ await _expire_spoolman_empty_slots(printer.id, new_spool, printing_now=False)
|
|
|
+
|
|
|
+ db_session.expunge_all()
|
|
|
+ assert await db_session.get(SpoolmanSlotAssignment, row_id) is None
|
|
|
+
|
|
|
+ @pytest.mark.asyncio
|
|
|
+ @pytest.mark.integration
|
|
|
+ async def test_an_occupied_unreadable_slot_with_no_hold_keeps_its_row(
|
|
|
+ self, async_client: AsyncClient, printer_factory, db_session: AsyncSession
|
|
|
+ ):
|
|
|
+ """#3100 still holds for the re-check: never reported empty, never unlinked."""
|
|
|
+ from backend.app.main import _expire_spoolman_empty_slots
|
|
|
+ from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
|
|
|
+ from backend.app.services import slot_unlink_grace
|
|
|
+
|
|
|
+ await TestSpoolmanSlotAssignmentDuringRunout()._enable_spoolman(db_session)
|
|
|
+ printer = await printer_factory(name="H2D")
|
|
|
+ row = SpoolmanSlotAssignment(printer_id=printer.id, ams_id=0, tray_id=2, spoolman_spool_id=41)
|
|
|
+ db_session.add(row)
|
|
|
+ await db_session.commit()
|
|
|
+ row_id = row.id
|
|
|
+
|
|
|
+ unreadable = [{"id": 0, "tray": [{"id": 2, "exists": True, "tray_type": "", "tray_color": "", "state": 9}]}]
|
|
|
+ clock = [1000.0]
|
|
|
+ with (
|
|
|
+ patch.object(slot_unlink_grace, "_now", lambda: clock[0]),
|
|
|
+ patch("backend.app.main.ws_manager") as mock_ws,
|
|
|
+ ):
|
|
|
+ mock_ws.broadcast = AsyncMock()
|
|
|
+ for _ in range(3):
|
|
|
+ await _expire_spoolman_empty_slots(printer.id, unreadable, printing_now=False)
|
|
|
+ clock[0] += slot_unlink_grace.GRACE_SECONDS
|
|
|
+
|
|
|
+ db_session.expunge_all()
|
|
|
+ assert await db_session.get(SpoolmanSlotAssignment, row_id) is not None
|
|
|
+
|
|
|
+ @pytest.mark.asyncio
|
|
|
+ @pytest.mark.integration
|
|
|
+ async def test_the_recheck_keeps_a_row_whose_slot_reads_again(
|
|
|
+ self, async_client: AsyncClient, printer_factory, db_session: AsyncSession
|
|
|
+ ):
|
|
|
+ from backend.app.main import _expire_spoolman_empty_slots
|
|
|
+ from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
|
|
|
+ from backend.app.services import slot_unlink_grace
|
|
|
+
|
|
|
+ await TestSpoolmanSlotAssignmentDuringRunout()._enable_spoolman(db_session)
|
|
|
+ printer = await printer_factory(name="H2D")
|
|
|
+ row = SpoolmanSlotAssignment(printer_id=printer.id, ams_id=0, tray_id=2, spoolman_spool_id=41)
|
|
|
+ db_session.add(row)
|
|
|
+ await db_session.commit()
|
|
|
+ row_id = row.id
|
|
|
+
|
|
|
+ blank = [{"id": 0, "tray": [{"id": 2, "exists": False, "tray_type": "", "tray_color": "", "state": 9}]}]
|
|
|
+ back = [{"id": 0, "tray": [{"id": 2, "exists": True, "tray_type": "PLA", "tray_color": "858585FF"}]}]
|
|
|
+ clock = [1000.0]
|
|
|
+ with (
|
|
|
+ patch.object(slot_unlink_grace, "_now", lambda: clock[0]),
|
|
|
+ patch("backend.app.main.ws_manager") as mock_ws,
|
|
|
+ ):
|
|
|
+ mock_ws.broadcast = AsyncMock()
|
|
|
+ await _expire_spoolman_empty_slots(printer.id, blank, printing_now=False)
|
|
|
+ clock[0] += 30
|
|
|
+ await _expire_spoolman_empty_slots(printer.id, back, printing_now=False)
|
|
|
+ clock[0] += slot_unlink_grace.GRACE_SECONDS
|
|
|
+ await _expire_spoolman_empty_slots(printer.id, blank, printing_now=False)
|
|
|
+
|
|
|
+ db_session.expunge_all()
|
|
|
+ assert await db_session.get(SpoolmanSlotAssignment, row_id) is not None
|
|
|
+
|
|
|
+
|
|
|
class TestSpoolmanSlotAssignmentDuringRunout:
|
|
|
"""`spoolman_slot_assignments` is how a tag-less spool assigned through the
|
|
|
Bambuddy UI is resolved at completion (#1459). Deleting the row when a slot
|
|
|
@@ -1992,8 +2377,9 @@ class TestSpoolmanSlotAssignmentDuringRunout:
|
|
|
self, async_client: AsyncClient, printer_factory, db_session: AsyncSession
|
|
|
):
|
|
|
"""Proves the guard is what saved the row above, not an unreachable
|
|
|
- code path."""
|
|
|
+ code path -- once the slot has stayed empty for the grace period."""
|
|
|
from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
|
|
|
+ from backend.app.services import slot_unlink_grace
|
|
|
|
|
|
await self._enable_spoolman(db_session)
|
|
|
printer = await printer_factory(name="H2D")
|
|
|
@@ -2002,7 +2388,14 @@ class TestSpoolmanSlotAssignmentDuringRunout:
|
|
|
await db_session.commit()
|
|
|
row_id = row.id
|
|
|
|
|
|
- await self._run(printer.id, _make_printing_status(None, state="IDLE"))
|
|
|
+ clock = [1000.0]
|
|
|
+ with patch.object(slot_unlink_grace, "_now", lambda: clock[0]):
|
|
|
+ await self._run(printer.id, _make_printing_status(None, state="IDLE"))
|
|
|
+ db_session.expunge_all()
|
|
|
+ assert await db_session.get(SpoolmanSlotAssignment, row_id) is not None
|
|
|
+
|
|
|
+ clock[0] += slot_unlink_grace.GRACE_SECONDS
|
|
|
+ await self._run(printer.id, _make_printing_status(None, state="IDLE"))
|
|
|
|
|
|
db_session.expunge_all()
|
|
|
assert await db_session.get(SpoolmanSlotAssignment, row_id) is None
|