test_external_spool_mapping_3087.py 6.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156
  1. """Which mapping entries a plate actually prints, and what the builder does
  2. with the answer (#3087).
  3. The dispatch-level behaviour is covered in
  4. ``backend/tests/integration/test_external_spool_use_ams_3087.py``. This is the
  5. pure part: separating a padding ``-1`` from a slot that never resolved, which
  6. is the distinction the MQTT command builder cannot make and the reason the
  7. decision was put in the scheduler.
  8. """
  9. import json
  10. from types import SimpleNamespace
  11. from unittest.mock import MagicMock
  12. import pytest
  13. from backend.app.services.bambu_mqtt import BambuMQTTClient
  14. from backend.app.services.print_scheduler import (
  15. _consumed_mapping_entries,
  16. _is_external_tray,
  17. _might_be_dual_nozzle,
  18. )
  19. def _required(*slot_ids):
  20. """What `extract_filament_requirements` returns: one entry per filament the
  21. plate consumes, keyed by its project-wide slot_id. Anything with
  22. `used_g <= 0` is already dropped there, so everything here is printed."""
  23. return [{"slot_id": s, "type": "PLA", "used_grams": 10.0} for s in slot_ids]
  24. class TestConsumedMappingEntries:
  25. def test_it_picks_out_the_slot_the_plate_prints(self):
  26. # The reporter's plate: seven project filaments, only #7 printed.
  27. assert _consumed_mapping_entries([-1, -1, -1, -1, -1, -1, 254], _required(7)) == [254]
  28. def test_padding_is_not_reported_as_unresolved(self):
  29. assert _consumed_mapping_entries([-1, 5, -1], _required(2)) == [5]
  30. def test_a_slot_the_plate_prints_reports_its_own_unresolved_entry(self):
  31. assert _consumed_mapping_entries([-1, -1, -1, -1, -1, -1, 254], _required(1, 7)) == [-1, 254]
  32. def test_several_printed_slots_come_back_in_slot_order(self):
  33. assert _consumed_mapping_entries([4, -1, 254], _required(1, 3)) == [4, 254]
  34. @pytest.mark.parametrize(
  35. "mapping,required",
  36. [
  37. (None, _required(1)),
  38. ([], _required(1)),
  39. ([254], None),
  40. ([254], []),
  41. ],
  42. )
  43. def test_it_declines_to_answer_without_both_halves(self, mapping, required):
  44. # No evidence, no decision — the caller then leaves use_ams alone.
  45. assert _consumed_mapping_entries(mapping, required) is None
  46. def test_a_requirement_the_mapping_is_too_short_for_declines(self):
  47. # The two were read at different times and disagree about the file.
  48. assert _consumed_mapping_entries([254], _required(7)) is None
  49. def test_a_junk_slot_id_declines(self):
  50. assert _consumed_mapping_entries([254], [{"slot_id": "7"}]) is None
  51. assert _consumed_mapping_entries([254], [{"slot_id": 0}]) is None
  52. assert _consumed_mapping_entries([254], [{}]) is None
  53. class TestIsExternalTray:
  54. @pytest.mark.parametrize("tray_id", [254, 255, "254"])
  55. def test_the_spool_holder(self, tray_id):
  56. assert _is_external_tray(tray_id) is True
  57. @pytest.mark.parametrize("tray_id", [None, -1, 0, 5, 253, 128, "", "x", 1.5])
  58. def test_everything_else(self, tray_id):
  59. # 128-253 are AMS-HT units, -1/None unresolved, and junk is not a
  60. # licence to redirect a print to the spool holder.
  61. assert _is_external_tray(tray_id) is False
  62. class TestMightBeDualNozzle:
  63. """Over-eager on purpose: a wrong yes only leaves a printer with the
  64. behaviour it already had, a wrong no rewrites which nozzle prints."""
  65. def _status(self, *, nozzles=(), **raw):
  66. return SimpleNamespace(nozzles=list(nozzles), raw_data=dict(raw))
  67. @pytest.mark.parametrize("model", ["H2D", "H2C", "X2D", "H2D Pro"])
  68. def test_the_model_name_is_enough(self, model):
  69. assert _might_be_dual_nozzle(model, self._status()) is True
  70. @pytest.mark.parametrize("model", ["P1S", "X1C", "A1", "P2S", "H2S", None, ""])
  71. def test_single_nozzle_models_pass(self, model):
  72. # H2S is the #1386 case: H2 family, one extruder.
  73. assert _might_be_dual_nozzle(model, self._status()) is False
  74. def test_two_external_feeds_give_it_away(self):
  75. # Only a two-extruder printer reports more than one vt_tray.
  76. assert _might_be_dual_nozzle("P1S", self._status(vt_tray=[{"id": "254"}, {"id": "255"}])) is True
  77. def test_one_external_feed_does_not(self):
  78. assert _might_be_dual_nozzle("P1S", self._status(vt_tray=[{"id": "254"}])) is False
  79. def test_a_second_nozzle_with_a_diameter_gives_it_away(self):
  80. nozzles = [SimpleNamespace(nozzle_diameter="0.4"), SimpleNamespace(nozzle_diameter="0.4")]
  81. assert _might_be_dual_nozzle("P1S", self._status(nozzles=nozzles)) is True
  82. def test_a_stub_second_nozzle_does_not(self):
  83. # The status model can carry placeholder NozzleInfo entries; only a
  84. # populated diameter means real hardware.
  85. nozzles = [SimpleNamespace(nozzle_diameter="0.4"), SimpleNamespace(nozzle_diameter="")]
  86. assert _might_be_dual_nozzle("P1S", self._status(nozzles=nozzles)) is False
  87. def test_an_extruder_map_gives_it_away(self):
  88. assert _might_be_dual_nozzle("P1S", self._status(ams_extruder_map={"0": 1})) is True
  89. def test_no_status_at_all_is_not_evidence_of_two(self):
  90. assert _might_be_dual_nozzle("P1S", None) is False
  91. def test_a_vt_tray_dict_is_not_counted_as_many_trays(self):
  92. # bambu_mqtt normalises vt_tray to a list, but a dict here would
  93. # otherwise count its keys and read as dual-nozzle.
  94. assert _might_be_dual_nozzle("P1S", self._status(vt_tray={"id": "254", "tray_type": "PLA"})) is False
  95. class TestTheBuilderHonoursTheDecision:
  96. """The scheduler's answer has to survive the command builder, which has its
  97. own use_ams reconcile (#2589/#2595). It must not promote the flag back."""
  98. @pytest.fixture
  99. def mqtt_client(self):
  100. client = BambuMQTTClient(ip_address="192.168.1.100", serial_number="01P00A452600691", access_code="x")
  101. client.model = "P1S"
  102. client._client = MagicMock()
  103. client.state.connected = True
  104. return client
  105. def _sent(self, mqtt_client):
  106. return json.loads(mqtt_client._client.publish.call_args.args[1])["print"]
  107. def test_the_reporters_command_now_goes_out_printable(self, mqtt_client):
  108. mqtt_client.start_print("plate_4.3mf", ams_mapping=[-1] * 6 + [254], use_ams=False)
  109. cmd = self._sent(mqtt_client)
  110. assert cmd["use_ams"] is False
  111. # 254 is still never sent raw in the flat array — the firmware reads it
  112. # as AMS tray 0 — and ams_mapping2 still carries the spool holder.
  113. assert cmd["ams_mapping"] == [-1] * 7
  114. assert cmd["ams_mapping2"][6] == {"ams_id": 255, "slot_id": 0}
  115. assert cmd["ams_mapping2"][0] == {"ams_id": 255, "slot_id": 255}
  116. def test_the_builder_still_rejects_an_unresolved_mapping_as_external(self, mqtt_client):
  117. """The #2589 contract, unchanged: nothing here treats -1 as the spool."""
  118. mqtt_client.start_print("plate_4.3mf", ams_mapping=[-1] * 6 + [254], use_ams=True)
  119. assert self._sent(mqtt_client)["use_ams"] is True