| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207 |
- """An X2D print from the right-hand external spool, sent without ams_mapping2 (#3405).
- The X2D has two external holders and lists both in ``vt_tray``, loaded or not.
- A print command that carries no ``ams_mapping2`` leaves the external spool as -1
- in the flat mapping (#3166 resolves it only from ams_mapping2). Until #3166's
- follow-up, Spoolman mode read that -1 as the first external holder present,
- 254, and charged the empty left holder; since then it charged nothing. The
- built-in inventory drops a mapping that names no tray for any used slot and
- lets the printer's ``tray_now`` decide. Spoolman mode now does the same, and
- reads ``tray_now`` 255 as the right-hand holder on a printer that has one, as
- the built-in inventory does.
- Values are the reporter's ``/status``: 254 empty, 255 loaded with red PLA,
- ``tray_now`` 255.
- """
- from types import SimpleNamespace
- from unittest.mock import AsyncMock, MagicMock, patch
- import pytest
- from backend.app.services import spoolman_tracking
- from backend.app.services.spoolman_tracking import _single_slot_tray_from_state
- from backend.app.services.usage_tracker import has_second_external_holder, mapping_names_no_used_tray
- X2D_VT_TRAY = [
- {"id": 254, "tray_type": "", "tray_color": "00000000", "tray_info_idx": ""},
- {"id": 255, "tray_type": "PLA", "tray_color": "F72323FF", "tray_info_idx": "GFA01"},
- ]
- USAGE = [{"slot_id": 1, "used_g": 12.5, "type": "PLA", "color": "#F72323"}]
- RIGHT_HOLDER_SPOOL = 77
- def _x2d_state(tray_now=255, vt_tray=X2D_VT_TRAY, last_loaded_tray=-1, tray_change_log=(), **raw):
- return SimpleNamespace(
- raw_data={"vt_tray": vt_tray, **raw},
- layer_num=0,
- total_layers=0,
- tray_change_log=list(tray_change_log),
- tray_now=tray_now,
- last_loaded_tray=last_loaded_tray,
- )
- class TestTheRules:
- def test_a_mapping_of_only_minus_one_names_no_used_tray(self):
- assert mapping_names_no_used_tray([-1], USAGE)
- assert not mapping_names_no_used_tray([255], USAGE)
- # Another slot is mapped: the -1 is a slot the print does not feed.
- two = [*USAGE, {"slot_id": 2, "used_g": 3.0}]
- assert not mapping_names_no_used_tray([-1, 5], two)
- assert not mapping_names_no_used_tray(None, USAGE)
- def test_255_is_a_tray_only_on_a_printer_with_two_holders(self):
- assert has_second_external_holder({"vt_tray": X2D_VT_TRAY})
- assert not has_second_external_holder({"vt_tray": [X2D_VT_TRAY[0]]})
- assert not has_second_external_holder(None)
- def test_tray_now_255_is_the_right_holder(self):
- assert _single_slot_tray_from_state(_x2d_state(), USAGE, 255) == (1, 255)
- @pytest.mark.parametrize("stale", [3, 254])
- def test_the_right_holder_beats_a_tray_an_earlier_print_loaded(self, stale):
- """A print from 255 never updates last_loaded_tray: it still names the
- AMS tray, or the left holder, an earlier print used."""
- state = _x2d_state(last_loaded_tray=stale)
- assert _single_slot_tray_from_state(state, USAGE, 255) == (1, 255)
- def test_a_tray_loaded_during_the_print_still_wins_over_255(self):
- state = _x2d_state(last_loaded_tray=3, tray_change_log=[(3, 2)])
- assert _single_slot_tray_from_state(state, USAGE, 255) == (1, 3)
- def test_last_loaded_254_is_no_survivor(self):
- """Same cap as the built-in inventory: 0-253 only."""
- state = _x2d_state(vt_tray=[X2D_VT_TRAY[0]], last_loaded_tray=254)
- assert _single_slot_tray_from_state(state, USAGE, 255) is None
- def test_tray_now_255_on_a_one_holder_printer_is_nothing(self):
- assert _single_slot_tray_from_state(_x2d_state(vt_tray=[X2D_VT_TRAY[0]]), USAGE, 255) is None
- class _AsyncCtx:
- def __init__(self, db):
- self._db = db
- async def __aenter__(self):
- return self._db
- async def __aexit__(self, *exc):
- return False
- def _slot_assignment(_printer_id, ams_id, tray_id):
- # The reporter's spool, assigned to the holder it sits on: 255 is (255, 1).
- return RIGHT_HOLDER_SPOOL if (ams_id, tray_id) == (255, 1) else None
- def _client():
- client = AsyncMock()
- client.find_spool_by_tag = AsyncMock(return_value=None)
- client.get_spool = AsyncMock(
- return_value={"id": RIGHT_HOLDER_SPOOL, "filament": {"color_hex": "F72323", "material": "PLA"}}
- )
- client.use_spool = AsyncMock()
- return client
- def _patches(client, state, db):
- pm = MagicMock()
- pm.get_status.return_value = state
- return (
- patch.object(spoolman_tracking, "async_session", lambda: _AsyncCtx(db)),
- patch("backend.app.api.routes.settings.get_setting", AsyncMock(return_value="true")),
- patch.object(spoolman_tracking, "_get_spoolman_client_with_fallback", AsyncMock(return_value=client)),
- patch.object(spoolman_tracking, "_get_printer_serial", AsyncMock(return_value="SER")),
- patch.object(
- spoolman_tracking, "_resolve_spool_id_via_slot_assignment", AsyncMock(side_effect=_slot_assignment)
- ),
- patch("backend.app.services.printer_manager.printer_manager", pm),
- )
- def _tracking(slot_to_tray):
- return SimpleNamespace(
- filament_usage=list(USAGE),
- ams_trays={
- "254": {"tray_uuid": "", "tag_uid": "", "tray_type": ""},
- "255": {"tray_uuid": "", "tag_uid": "", "tray_type": "PLA"},
- },
- slot_to_tray=slot_to_tray,
- tray_remain_start=None,
- layer_usage=None,
- filament_properties=None,
- tray_now_at_start=255,
- archive_id=12,
- )
- class TestReportUsage:
- @staticmethod
- async def _run(slot_to_tray, state=None):
- archive = SimpleNamespace(filament_color="#F72323", filament_type="PLA")
- rows = iter([_tracking(slot_to_tray)])
- def _next_row(*_args, **_kwargs):
- result = MagicMock()
- result.scalar_one_or_none.return_value = next(rows, archive)
- return result
- db = AsyncMock()
- db.execute = AsyncMock(side_effect=_next_row)
- db.delete = AsyncMock()
- db.commit = AsyncMock()
- client = _client()
- p = _patches(client, state or _x2d_state(), db)
- with p[0], p[1], p[2], p[3], p[4], p[5]:
- await spoolman_tracking.report_usage(printer_id=1, archive_id=12)
- return client
- @pytest.mark.asyncio
- async def test_the_loaded_right_holder_is_charged(self):
- client = await self._run([-1])
- client.use_spool.assert_awaited_once_with(RIGHT_HOLDER_SPOOL, 12.5)
- @pytest.mark.asyncio
- async def test_a_same_coloured_ams_tray_is_not_charged_instead(self):
- """The colour match knows no external holder; the built-in inventory
- never runs it for a mapping that named no tray."""
- ams = [{"id": 0, "tray": [{"id": 0, "tray_color": "F72323FF", "tray_type": "PLA"}]}]
- client = await self._run([-1], state=_x2d_state(ams=ams))
- client.use_spool.assert_awaited_once_with(RIGHT_HOLDER_SPOOL, 12.5)
- @pytest.mark.asyncio
- async def test_a_resolved_mapping_is_unchanged(self):
- """With ams_mapping2 the captured mapping already says 255 (#3166)."""
- client = await self._run([255])
- client.use_spool.assert_awaited_once_with(RIGHT_HOLDER_SPOOL, 12.5)
- class TestPartialUsage:
- @pytest.mark.asyncio
- async def test_an_aborted_print_charges_the_right_holder_too(self):
- tracking = _tracking([-1])
- tracking.layer_usage = {}
- # A same-coloured AMS tray must not take it either, as on completion.
- ams = [{"id": 0, "tray": [{"id": 0, "tray_color": "F72323FF", "tray_type": "PLA"}]}]
- state = _x2d_state(ams=ams)
- state.layer_num, state.total_layers = 50, 100
- report = AsyncMock(return_value=1)
- p = _patches(_client(), state, AsyncMock())
- with (
- p[0],
- p[1],
- p[2],
- p[3],
- p[4],
- p[5],
- patch.object(spoolman_tracking, "_report_spool_usage_for_slots", report),
- ):
- await spoolman_tracking._report_partial_usage(1, tracking, last_progress=50)
- report.assert_awaited()
- slot_to_tray = report.await_args.args[3]
- assert spoolman_tracking._resolve_global_tray_id(1, slot_to_tray, {254: {}, 255: {}}) == 255
|