| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235 |
- """The inventory mode toggle is no longer destructive (#2812).
- Turning Spoolman mode on ran an unfiltered ``delete(SpoolAssignment)`` across
- every printer. Turning it straight back off cleared the *other* table instead,
- so the built-in assignments were simply gone -- and the setting auto-saves on a
- 500 ms debounce with no confirmation, so inspecting the mode destroyed the
- configuration. The reporter toggled four times in 85 seconds and never got
- their assignments back.
- The deletion had a real reason: checks that read both assignment tables would
- otherwise let a row in the mode you are *not* using answer for the mode you
- are. The fix is to make those checks ask which mode is active, which is where
- that decision belongs, and then stop deleting.
- """
- from types import SimpleNamespace
- from unittest.mock import AsyncMock, patch
- import pytest
- from backend.app.services.slot_kprofile import find_slot_kprofile_for_extruder
- class _Result:
- def __init__(self, value):
- self._value = value
- def scalar_one_or_none(self):
- return self._value
- def scalars(self):
- return self
- def first(self):
- return self._value
- def all(self):
- return [self._value] if self._value is not None else []
- class _TableRoutingSession:
- """Returns a row per model, so a test can populate either table or both."""
- def __init__(self, rows: dict):
- self._rows = rows
- self.queried = []
- async def execute(self, stmt):
- entity = stmt.column_descriptions[0]["entity"]
- name = entity.__name__
- self.queried.append(name)
- return _Result(self._rows.get(name))
- class TestKProfileIgnoresTheInactiveModesTable:
- """slot_kprofile checked the built-in table first and, on a hit with no
- matching profile, deliberately returned None rather than falling through to
- Spoolman. That was safe only while the built-in table was guaranteed empty
- in Spoolman mode. A leftover row would otherwise shadow the Spoolman
- binding -- the symptom #1556 reported from the other direction.
- """
- @pytest.mark.asyncio
- async def test_spoolman_mode_does_not_read_the_built_in_table(self):
- session = _TableRoutingSession(
- {
- # A leftover from before the user switched modes.
- "SpoolAssignment": SimpleNamespace(spool_id=7),
- "SpoolmanSlotAssignment": None,
- }
- )
- with patch(
- "backend.app.services.slot_kprofile.spoolman_owns_assignments",
- new_callable=AsyncMock,
- return_value=True,
- ):
- result = await find_slot_kprofile_for_extruder(
- session, printer_id=1, ams_id=0, tray_id=0, extruder=0, nozzle_diameter="0.4"
- )
- assert result is None
- assert "SpoolAssignment" not in session.queried
- @pytest.mark.asyncio
- async def test_built_in_mode_does_not_read_the_spoolman_table(self):
- session = _TableRoutingSession(
- {
- "SpoolAssignment": None,
- "SpoolmanSlotAssignment": SimpleNamespace(spoolman_spool_id=9),
- }
- )
- with patch(
- "backend.app.services.slot_kprofile.spoolman_owns_assignments",
- new_callable=AsyncMock,
- return_value=False,
- ):
- result = await find_slot_kprofile_for_extruder(
- session, printer_id=1, ams_id=0, tray_id=0, extruder=0, nozzle_diameter="0.4"
- )
- assert result is None
- assert "SpoolmanSlotAssignment" not in session.queried
- class TestCostEstimateIgnoresTheInactiveModesTable:
- """A leftover built-in assignment must not price a pre-print estimate from
- a spool the printer is not drawing on. The default rate is the honest
- answer once the mode has moved on."""
- @staticmethod
- async def _estimate(spoolman_mode: bool):
- from backend.app.services import print_cost_estimate as pce
- library_file = SimpleNamespace(
- file_path="nowhere/never.3mf",
- file_metadata={"filament_used_grams": 100.0},
- source_folder=None,
- )
- db = AsyncMock()
- db.execute = AsyncMock(side_effect=AssertionError("the built-in table must not be queried"))
- with (
- patch(
- "backend.app.services.print_cost_estimate.spoolman_owns_assignments",
- new_callable=AsyncMock,
- return_value=spoolman_mode,
- ),
- patch(
- "backend.app.services.print_cost_estimate._default_cost_per_kg",
- new_callable=AsyncMock,
- return_value=25.0,
- ),
- patch(
- "backend.app.services.print_cost_estimate._source_path",
- return_value=__import__("pathlib").Path("/nonexistent/never.3mf"),
- ),
- ):
- return await pce.estimate_queue_source_cost(
- db,
- library_file=library_file,
- ams_mapping=[0],
- printer_id=1,
- )
- @pytest.mark.asyncio
- async def test_spoolman_mode_does_not_price_from_built_in_spools(self):
- # 100 g at the 25/kg default. The AsyncMock would raise if the
- # built-in assignment table were queried.
- assert await self._estimate(spoolman_mode=True) == pytest.approx(2.5)
- @pytest.mark.asyncio
- async def test_built_in_mode_still_reads_its_own_table(self):
- """The guard must not switch the built-in path off as well."""
- with pytest.raises(AssertionError, match="must not be queried"):
- await self._estimate(spoolman_mode=False)
- class TestCompletionNotifiesTheLostDebit:
- """The half that turned a mis-click into lost filament.
- A print whose assignments existed at print start and were gone by the time
- it finished resolved its 3MF, resolved its grams, resolved its tray, and
- then skipped the debit because the row was missing -- at INFO, with no
- notification, while the completion notification fired as usual. The
- reporter's 65.49 g was never deducted and nothing surfaced it.
- The print-start check cannot cover this: it runs before the job and was
- correct to stay quiet, because at that moment the assignments existed.
- """
- @pytest.mark.asyncio
- async def test_a_skipped_debit_notifies_at_completion(self):
- from backend.app.services.spool_assignment_notifications import (
- notify_missing_spool_assignments_on_print_complete,
- )
- db = AsyncMock()
- db.get = AsyncMock(return_value=SimpleNamespace(name="Printer A"))
- logger = __import__("logging").getLogger(__name__)
- with (
- patch(
- "backend.app.services.spool_assignment_notifications.printer_manager.get_status",
- return_value=None,
- ),
- patch(
- "backend.app.services.spool_assignment_notifications.ws_manager.send_missing_spool_assignment",
- new_callable=AsyncMock,
- ) as mock_ws,
- patch(
- "backend.app.services.spool_assignment_notifications.notification_service."
- "on_print_missing_spool_assignment",
- new_callable=AsyncMock,
- ) as mock_notify,
- ):
- await notify_missing_spool_assignments_on_print_complete(1, [2], db, logger)
- mock_ws.assert_awaited_once()
- assert mock_ws.await_args.kwargs["missing_slots"] == [{"slot": "A3", "profile": "Unknown", "color": "Unknown"}]
- mock_notify.assert_awaited_once()
- @pytest.mark.asyncio
- async def test_a_print_that_debited_everything_stays_quiet(self):
- from backend.app.services.spool_assignment_notifications import (
- notify_missing_spool_assignments_on_print_complete,
- )
- db = AsyncMock()
- logger = __import__("logging").getLogger(__name__)
- with patch(
- "backend.app.services.spool_assignment_notifications.ws_manager.send_missing_spool_assignment",
- new_callable=AsyncMock,
- ) as mock_ws:
- await notify_missing_spool_assignments_on_print_complete(1, [], db, logger)
- mock_ws.assert_not_awaited()
- @pytest.mark.asyncio
- async def test_a_failure_here_never_fails_the_completed_print(self):
- """The print is already done and its spools already written. A
- notification that cannot be sent must not surface as a failed
- completion."""
- from backend.app.services.spool_assignment_notifications import (
- notify_missing_spool_assignments_on_print_complete,
- )
- db = AsyncMock()
- db.get = AsyncMock(side_effect=RuntimeError("db gone"))
- logger = __import__("logging").getLogger(__name__)
- await notify_missing_spool_assignments_on_print_complete(1, [2], db, logger)
|