test_ams_mapping_unresolved_2589.py 7.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179
  1. """Regression tests for the unresolved-mapping -> silent-external bug (#2589).
  2. A P1S queue item persisted with ``ams_mapping=[-1]`` (an unresolved mapping from
  3. a frontend status-load race) was silently dispatched with ``use_ams=False`` — the
  4. print then started against the empty external feed and paused with a filament
  5. runout. Two behaviours combined:
  6. 1. ``start_print`` treated an all-``-1`` mapping as "all external spool" and
  7. forced ``use_ams=False``. Only an explicit external selection (``>=254``) may
  8. do that; unresolved ``-1`` must not.
  9. 2. The scheduler trusted a stored ``[-1]`` (non-empty, so "already resolved") and
  10. skipped the recompute that would have matched the live AMS trays.
  11. These tests lock in the fix at both layers.
  12. """
  13. import json
  14. from unittest.mock import AsyncMock, MagicMock
  15. import pytest
  16. from backend.app.services.bambu_mqtt import BambuMQTTClient
  17. from backend.app.services.print_scheduler import PrintScheduler, _mapping_is_all_unresolved
  18. class TestMappingIsAllUnresolved:
  19. """Unit tests for the ``_mapping_is_all_unresolved`` predicate."""
  20. def test_all_minus_one_is_unresolved(self):
  21. assert _mapping_is_all_unresolved([-1]) is True
  22. assert _mapping_is_all_unresolved([-1, -1]) is True
  23. def test_none_entries_are_unresolved(self):
  24. assert _mapping_is_all_unresolved([None]) is True
  25. assert _mapping_is_all_unresolved([-1, None]) is True
  26. def test_partially_resolved_is_not_unresolved(self):
  27. # A plate that only prints slot 3 pads earlier slots with -1.
  28. assert _mapping_is_all_unresolved([-1, -1, 5]) is False
  29. assert _mapping_is_all_unresolved([5, -1]) is False
  30. def test_explicit_external_is_not_unresolved(self):
  31. # 254/255 are explicit external/virtual spool selections, not unresolved.
  32. assert _mapping_is_all_unresolved([254]) is False
  33. assert _mapping_is_all_unresolved([255, 254]) is False
  34. def test_resolved_ams_is_not_unresolved(self):
  35. assert _mapping_is_all_unresolved([0]) is False
  36. assert _mapping_is_all_unresolved([4, 8]) is False
  37. def test_empty_or_none_is_not_unresolved(self):
  38. # Absent/empty is "needs computing", handled by the missing-mapping path,
  39. # not this predicate — which is specifically about a *bogus* stored value.
  40. assert _mapping_is_all_unresolved([]) is False
  41. assert _mapping_is_all_unresolved(None) is False
  42. class TestStartPrintExternalDowngrade:
  43. """``start_print`` must only force use_ams=False for *explicit* external."""
  44. @pytest.fixture
  45. def mqtt_client(self):
  46. client = BambuMQTTClient(
  47. ip_address="192.168.1.100",
  48. serial_number="01P00A452600691",
  49. access_code="12345678",
  50. )
  51. # Single-nozzle P1S so the dual-nozzle bypass does not apply.
  52. client.model = "P1S"
  53. client._client = MagicMock()
  54. client.state.connected = True
  55. return client
  56. def _sent_command(self, mqtt_client) -> dict:
  57. """Parse the JSON payload the client published."""
  58. assert mqtt_client._client.publish.called, "start_print did not publish"
  59. payload = mqtt_client._client.publish.call_args.args[1]
  60. return json.loads(payload)["print"]
  61. def test_unresolved_mapping_keeps_use_ams_true(self, mqtt_client):
  62. """[-1] is unresolved, NOT external — must not silently go external."""
  63. assert mqtt_client.start_print("Turm.3mf", ams_mapping=[-1], use_ams=True) is True
  64. cmd = self._sent_command(mqtt_client)
  65. assert cmd["use_ams"] is True
  66. def test_explicit_external_forces_use_ams_false(self, mqtt_client):
  67. """An explicit external selection (254) still downgrades to use_ams=False."""
  68. assert mqtt_client.start_print("Turm.3mf", ams_mapping=[254], use_ams=True) is True
  69. cmd = self._sent_command(mqtt_client)
  70. assert cmd["use_ams"] is False
  71. def test_resolved_ams_keeps_use_ams_true(self, mqtt_client):
  72. """A real AMS tray keeps use_ams=True."""
  73. assert mqtt_client.start_print("Turm.3mf", ams_mapping=[5], use_ams=True) is True
  74. cmd = self._sent_command(mqtt_client)
  75. assert cmd["use_ams"] is True
  76. def test_padded_partial_mapping_keeps_use_ams_true(self, mqtt_client):
  77. """A padded mapping ([-1, -1, tray]) is not all-external."""
  78. assert mqtt_client.start_print("Turm.3mf", ams_mapping=[-1, -1, 5], use_ams=True) is True
  79. cmd = self._sent_command(mqtt_client)
  80. assert cmd["use_ams"] is True
  81. class TestEnsureAmsMapping:
  82. """The scheduler must recompute a stored unresolved [-1], not trust it."""
  83. @pytest.fixture
  84. def scheduler(self):
  85. return PrintScheduler()
  86. def _item(self, ams_mapping):
  87. item = MagicMock()
  88. item.id = 92
  89. item.printer_id = 82
  90. item.ams_mapping = ams_mapping
  91. return item
  92. @pytest.mark.asyncio
  93. async def test_stored_unresolved_is_recomputed(self, scheduler):
  94. """A stored [-1] triggers recompute; the live-resolved mapping wins."""
  95. db = AsyncMock()
  96. item = self._item(json.dumps([-1]))
  97. scheduler._compute_ams_mapping_for_printer = AsyncMock(return_value=[5])
  98. await scheduler._ensure_ams_mapping(db, 82, item)
  99. scheduler._compute_ams_mapping_for_printer.assert_awaited_once()
  100. assert json.loads(item.ams_mapping) == [5]
  101. db.commit.assert_awaited()
  102. @pytest.mark.asyncio
  103. async def test_missing_mapping_is_computed(self, scheduler):
  104. """No stored mapping still computes one (existing behaviour preserved)."""
  105. db = AsyncMock()
  106. item = self._item(None)
  107. scheduler._compute_ams_mapping_for_printer = AsyncMock(return_value=[4, 8])
  108. await scheduler._ensure_ams_mapping(db, 82, item)
  109. assert json.loads(item.ams_mapping) == [4, 8]
  110. @pytest.mark.asyncio
  111. async def test_resolved_mapping_is_left_untouched(self, scheduler):
  112. """A resolved stored mapping (e.g. a manual override) is never recomputed."""
  113. db = AsyncMock()
  114. item = self._item(json.dumps([5, 9]))
  115. scheduler._compute_ams_mapping_for_printer = AsyncMock(return_value=[0, 0])
  116. await scheduler._ensure_ams_mapping(db, 82, item)
  117. scheduler._compute_ams_mapping_for_printer.assert_not_awaited()
  118. assert json.loads(item.ams_mapping) == [5, 9]
  119. @pytest.mark.asyncio
  120. async def test_unresolvable_stored_mapping_is_cleared(self, scheduler):
  121. """If recompute also can't resolve it, the bogus [-1] is cleared to None
  122. so dispatch never mistakes it for an explicit external selection."""
  123. db = AsyncMock()
  124. item = self._item(json.dumps([-1]))
  125. # Live status has no compatible tray -> matcher returns another all-[-1].
  126. scheduler._compute_ams_mapping_for_printer = AsyncMock(return_value=[-1])
  127. await scheduler._ensure_ams_mapping(db, 82, item)
  128. assert item.ams_mapping is None
  129. db.commit.assert_awaited()
  130. @pytest.mark.asyncio
  131. async def test_recompute_none_leaves_missing_untouched(self, scheduler):
  132. """Missing mapping + recompute returns None (no status) -> stays None,
  133. no spurious clear-warning path, no crash."""
  134. db = AsyncMock()
  135. item = self._item(None)
  136. scheduler._compute_ams_mapping_for_printer = AsyncMock(return_value=None)
  137. await scheduler._ensure_ams_mapping(db, 82, item)
  138. assert item.ams_mapping is None