test_scheduler_effective_plate_id.py 7.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150
  1. """``_effective_plate_id``, the plate resolved once in ``_start_print`` and
  2. reused at every dispatch call site: G-code injection, usage registration,
  3. rack-plan lookup, slot-extruder lookup, the external-spool check, and the
  4. actual print command (#2947).
  5. A single-plate 3MF exported out of a multi-plate project keeps its ORIGINAL
  6. plate number: cutting the right-side plate out of a two-plate project leaves
  7. a file whose only G-code member is ``Metadata/plate_2.gcode``, never
  8. ``plate_1.gcode``. A caller that hardcodes ``item.plate_id or 1`` sends a
  9. print command for a plate the archive doesn't hold. The printer accepts the
  10. command, can't find the G-code, and wedges until power-cycled.
  11. The call sites agreed on this fallback only by accident before this fix
  12. — see the function's own docstring for how a plate mismatch could sneak past
  13. G-code injection specifically.
  14. """
  15. import logging
  16. import zipfile
  17. from backend.app.services.print_scheduler import _effective_plate_id
  18. def _write_gcode_members(path, names):
  19. with zipfile.ZipFile(path, "w") as zf:
  20. for name in names:
  21. zf.writestr(name, "")
  22. return path
  23. class TestEffectivePlateId:
  24. def test_explicit_plate_id_wins_even_when_the_file_cannot_be_read(self, tmp_path):
  25. # A queue item's own choice always wins, and an archive that can't be
  26. # opened must not stop it from being returned.
  27. assert _effective_plate_id(3, tmp_path / "does-not-exist.3mf") == 3
  28. def test_plate_id_zero_is_treated_as_unset_and_resolved_from_the_archive(self, tmp_path):
  29. # Nothing validates the field as positive (no ge= on any plate_id in
  30. # schemas/print_queue.py) and the rest of the queue code reads it
  31. # truthily, so a 0 means "not set" here too. Returning it would
  32. # dispatch Metadata/plate_0.gcode, the exact wedge this fixes.
  33. path = _write_gcode_members(tmp_path / "right.gcode.3mf", ["Metadata/plate_2.gcode"])
  34. assert _effective_plate_id(0, path) == 2
  35. def test_negative_plate_id_is_treated_as_unset_too(self, tmp_path):
  36. path = _write_gcode_members(tmp_path / "right.gcode.3mf", ["Metadata/plate_2.gcode"])
  37. assert _effective_plate_id(-1, path) == 2
  38. def test_single_plate_file_numbered_two_resolves_to_two(self, tmp_path):
  39. # The exact shape of the wedged printer in #2947: one G-code member,
  40. # keeping its original (non-1) plate number.
  41. path = _write_gcode_members(tmp_path / "right.gcode.3mf", ["Metadata/plate_2.gcode"])
  42. assert _effective_plate_id(None, path) == 2
  43. def test_single_plate_file_numbered_one_resolves_to_one(self, tmp_path):
  44. path = _write_gcode_members(tmp_path / "left.gcode.3mf", ["Metadata/plate_1.gcode"])
  45. assert _effective_plate_id(None, path) == 1
  46. def test_reversed_zip_order_picks_the_lowest_plate_not_first_member(self, tmp_path):
  47. # Bambu Studio does not write plates in zip order: a real two-plate
  48. # export stores plate_2.gcode ahead of plate_1.gcode.
  49. path = _write_gcode_members(
  50. tmp_path / "reversed.gcode.3mf",
  51. ["Metadata/plate_2.gcode", "Metadata/plate_1.gcode"],
  52. )
  53. assert _effective_plate_id(None, path) == 1
  54. def test_gcode_md5_sidecar_is_not_mistaken_for_the_toolpath(self, tmp_path):
  55. path = _write_gcode_members(
  56. tmp_path / "sidecar.gcode.3mf",
  57. ["Metadata/plate_1.gcode.md5", "Metadata/plate_2.gcode", "Metadata/plate_1.gcode"],
  58. )
  59. assert _effective_plate_id(None, path) == 1
  60. def test_unsliced_file_falls_back_to_plate_one(self, tmp_path):
  61. path = _write_gcode_members(tmp_path / "unsliced.3mf", ["3D/3dmodel.model"])
  62. assert _effective_plate_id(None, path) == 1
  63. def test_gcode_member_without_plate_naming_falls_back_to_plate_one(self, tmp_path):
  64. # default_plate_number returns None here (no number to dispatch);
  65. # this is the one place that turns that None into the actual
  66. # fallback a print command needs.
  67. path = _write_gcode_members(tmp_path / "custom.gcode.3mf", ["Metadata/print.gcode"])
  68. assert _effective_plate_id(None, path) == 1
  69. def test_unreadable_file_falls_back_to_plate_one(self, tmp_path):
  70. path = tmp_path / "broken.3mf"
  71. path.write_bytes(b"not a zip")
  72. assert _effective_plate_id(None, path) == 1
  73. def test_missing_file_falls_back_to_plate_one(self, tmp_path):
  74. assert _effective_plate_id(None, tmp_path / "does-not-exist.3mf") == 1
  75. class TestWhatGetsLogged:
  76. """The fallbacks are silent recoveries from something that is wrong with
  77. the file, so each one has to leave a trace naming the archive. None of
  78. them may raise: this runs on a dispatch that is otherwise fine.
  79. """
  80. def test_an_unreadable_archive_is_logged_with_the_path_and_the_reason(self, tmp_path, caplog):
  81. path = tmp_path / "broken.3mf"
  82. path.write_bytes(b"not a zip")
  83. with caplog.at_level(logging.WARNING, logger="backend.app.services.print_scheduler"):
  84. assert _effective_plate_id(None, path) == 1
  85. assert "broken.3mf" in caplog.text
  86. assert "BadZipFile" in caplog.text or "not a zip file" in caplog.text.lower()
  87. def test_an_explicit_plate_the_archive_does_not_hold_is_logged_not_redirected(self, tmp_path, caplog):
  88. # The wedge of #2947 seen from the other side: the operator named
  89. # plate 1, the file only has plate 2. Redirecting to 2 would print a
  90. # model nobody asked for, so the command goes out as asked.
  91. path = _write_gcode_members(tmp_path / "right.gcode.3mf", ["Metadata/plate_2.gcode"])
  92. with caplog.at_level(logging.WARNING, logger="backend.app.services.print_scheduler"):
  93. assert _effective_plate_id(1, path) == 1
  94. assert "right.gcode.3mf" in caplog.text
  95. assert "Metadata/plate_2.gcode" in caplog.text
  96. def test_a_plate_the_archive_does_hold_logs_nothing(self, tmp_path, caplog):
  97. path = _write_gcode_members(
  98. tmp_path / "two.gcode.3mf",
  99. ["Metadata/plate_1.gcode", "Metadata/plate_2.gcode"],
  100. )
  101. with caplog.at_level(logging.WARNING, logger="backend.app.services.print_scheduler"):
  102. assert _effective_plate_id(2, path) == 2
  103. assert caplog.text == ""
  104. def test_a_slicer_that_does_not_number_its_plates_is_not_warned_about(self, tmp_path, caplog):
  105. # There is no plate numbering to contradict here, so an explicit plate
  106. # is not evidence of a mismatch and must not be reported as one.
  107. path = _write_gcode_members(tmp_path / "custom.gcode.3mf", ["Metadata/print.gcode"])
  108. with caplog.at_level(logging.WARNING, logger="backend.app.services.print_scheduler"):
  109. assert _effective_plate_id(1, path) == 1
  110. assert caplog.text == ""
  111. def test_an_unsliced_archive_is_not_warned_about_either(self, tmp_path, caplog):
  112. path = _write_gcode_members(tmp_path / "unsliced.3mf", ["3D/3dmodel.model"])
  113. with caplog.at_level(logging.WARNING, logger="backend.app.services.print_scheduler"):
  114. assert _effective_plate_id(1, path) == 1
  115. assert caplog.text == ""