test_slot_nozzle_resolution.py 5.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120
  1. """Which nozzle an AMS slot feeds.
  2. Every path that configures a slot needs the extruder and that nozzle's
  3. diameter, and both the filament preset and the K profile are stored per
  4. diameter -- so a wrong answer here silently selects the wrong preset AND the
  5. wrong K value. Seven call sites used to work it out independently, each reading
  6. ``nozzles[0]`` for every slot on the machine.
  7. ``nozzles[0]`` is right on a single-nozzle printer, and right on a dual-nozzle
  8. printer with matching nozzles, which is why it survived. These pin the case it
  9. is wrong for: two different sizes fitted, which is the machine the per-nozzle
  10. feature exists for.
  11. """
  12. from __future__ import annotations
  13. from backend.app.services.slot_nozzle import (
  14. DEFAULT_NOZZLE_DIAMETER,
  15. nozzle_diameter_for_extruder,
  16. resolve_slot_nozzle,
  17. )
  18. class _Nozzle:
  19. def __init__(self, diameter: str):
  20. self.nozzle_diameter = diameter
  21. class _State:
  22. def __init__(self, diameters, ams_extruder_map=None, ams_switch_inlet=None):
  23. self.nozzles = [_Nozzle(d) for d in diameters]
  24. self.ams_extruder_map = ams_extruder_map
  25. self.ams_switch_inlet = ams_switch_inlet
  26. # extruder 0 is the RIGHT hotend, 1 the left.
  27. MIXED = ["0.4", "0.2"]
  28. class TestDualNozzleMixedDiameters:
  29. """The case the old shortcut got wrong."""
  30. def test_each_hotend_reports_its_own_diameter(self):
  31. state = _State(MIXED)
  32. assert nozzle_diameter_for_extruder(state, 0, "H2C") == "0.4"
  33. assert nozzle_diameter_for_extruder(state, 1, "H2C") == "0.2"
  34. def test_the_slots_extruder_decides_which_one(self):
  35. # AMS 0 is bound to the left hotend, AMS 1 to the right.
  36. state = _State(MIXED, ams_extruder_map={"0": 1, "1": 0})
  37. left = resolve_slot_nozzle(state, 0, 0, "H2C")
  38. right = resolve_slot_nozzle(state, 1, 0, "H2C")
  39. assert (left.extruder, left.diameter) == (1, "0.2")
  40. assert (right.extruder, right.diameter) == (0, "0.4")
  41. def test_external_slots_name_their_side_by_tray_id(self):
  42. # Ext-L is tray 0 -> extruder 1, Ext-R is tray 1 -> extruder 0.
  43. state = _State(MIXED)
  44. assert resolve_slot_nozzle(state, 255, 0, "H2D").diameter == "0.2"
  45. assert resolve_slot_nozzle(state, 255, 1, "H2D").diameter == "0.4"
  46. def test_an_fts_inlet_answers_when_there_is_no_extruder_map(self):
  47. state = _State(MIXED, ams_switch_inlet={"0": "A"})
  48. resolved = resolve_slot_nozzle(state, 0, 0, "H2C")
  49. assert resolved.extruder is not None
  50. assert resolved.diameter in {"0.4", "0.2"}
  51. class TestSingleNozzle:
  52. """Must behave exactly as the old shortcut did."""
  53. def test_index_zero_whatever_the_map_says(self):
  54. # A single-nozzle model has one entry; an extruder id from a stale map
  55. # must not index past it.
  56. state = _State(["0.6"], ams_extruder_map={"0": 1})
  57. assert nozzle_diameter_for_extruder(state, 1, "X1C") == "0.6"
  58. assert resolve_slot_nozzle(state, 0, 0, "X1C").diameter == "0.6"
  59. def test_no_map_means_unknown_extruder_not_zero(self):
  60. # "I don't know" and "the right-hand nozzle" are different answers; the
  61. # caller's own default of 0 is correct on a single-nozzle machine, but
  62. # storing it as a fact is what bound a left K-profile to a right slot.
  63. state = _State(["0.4"])
  64. resolved = resolve_slot_nozzle(state, 0, 0, "X1C")
  65. assert resolved.extruder is None
  66. assert resolved.extruder_or_default == 0
  67. class TestMissingHardware:
  68. """Called on every assign, so it must never raise."""
  69. def test_no_state_at_all(self):
  70. resolved = resolve_slot_nozzle(None, 0, 0, "H2C")
  71. assert (resolved.extruder, resolved.diameter) == (None, DEFAULT_NOZZLE_DIAMETER)
  72. def test_printer_has_reported_no_nozzles(self):
  73. assert nozzle_diameter_for_extruder(_State([]), 0, "H2C") == DEFAULT_NOZZLE_DIAMETER
  74. def test_the_second_hotend_is_absent_from_the_report(self):
  75. # H2C parks a nozzle back in its rack and the entry can go missing;
  76. # falling back to the other hotend beats returning nothing.
  77. assert nozzle_diameter_for_extruder(_State(["0.4"]), 1, "H2C") == "0.4"
  78. def test_a_blank_diameter_is_not_a_diameter(self):
  79. # NozzleInfo starts life with an empty string before MQTT fills it in.
  80. # Falling back to the OTHER hotend's size would invent a fact about a
  81. # different nozzle, so a blank primary takes the default instead --
  82. # which is what every call site did before this module existed.
  83. assert nozzle_diameter_for_extruder(_State(["", "0.6"]), 0, "H2C") == DEFAULT_NOZZLE_DIAMETER
  84. assert nozzle_diameter_for_extruder(_State(["", ""]), 0, "H2C") == DEFAULT_NOZZLE_DIAMETER
  85. class TestMatchingNozzles:
  86. """The common fleet: both hotends the same size."""
  87. def test_both_conventions_agree_so_the_answer_cannot_be_wrong(self):
  88. state = _State(["0.4", "0.4"], ams_extruder_map={"0": 1})
  89. assert nozzle_diameter_for_extruder(state, 0, "H2C") == "0.4"
  90. assert nozzle_diameter_for_extruder(state, 1, "H2C") == "0.4"