test_queued_plate_bed_temperature.py 6.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171
  1. """Preheat heats the bed for the plate the queued job prints (PL-C7).
  2. The archive stores one bed temperature, the highest any filament of the
  3. project asks for, and preheat and keep-warm used it for every job: a PLA plate
  4. of a project that also holds PETG was preheated to the PETG temperature. They
  5. now read the job's own plate from the archive's file.
  6. """
  7. import json
  8. import zipfile
  9. from pathlib import Path
  10. from types import SimpleNamespace
  11. import pytest
  12. from backend.app.services.archive import ThreeMFParser
  13. from backend.app.services.print_scheduler import _queued_plate_bed_temperature
  14. from backend.app.utils.threemf_tools import plate_bed_temperature
  15. PLA, PETG_PLATE_FILAMENTS = [1], [2, 3]
  16. def _3mf(path: Path, plates: dict[int, list[int]], gcode: tuple[int, ...] = ()) -> Path:
  17. """Plate N prints the filaments listed for it (1-based ids), PLA at 55,
  18. PLA at 60 and PETG at 70; ``gcode`` are the plates sliced."""
  19. plate_xml = "".join(
  20. f"<plate><metadata key='index' value='{index}'/>"
  21. + "".join(f"<filament id='{fid}' type='PLA' color='#FFFFFF' used_g='5'/>" for fid in filaments)
  22. + "</plate>"
  23. for index, filaments in plates.items()
  24. )
  25. settings = {"curr_bed_type": "Textured PEI Plate", "textured_plate_temp": ["55", "60", "70"]}
  26. with zipfile.ZipFile(path, "w") as zf:
  27. zf.writestr("Metadata/slice_info.config", f"<?xml version='1.0'?><config>{plate_xml}</config>")
  28. zf.writestr("Metadata/project_settings.config", json.dumps(settings))
  29. zf.writestr("3D/3dmodel.model", "<model/>")
  30. for index in gcode:
  31. zf.writestr(f"Metadata/plate_{index}.gcode", "; plate")
  32. return path
  33. def test_each_plate_has_its_own_filaments_temperature(tmp_path):
  34. path = _3mf(tmp_path / "a.3mf", {1: PLA, 2: PETG_PLATE_FILAMENTS})
  35. assert plate_bed_temperature(path, 1) == 55
  36. assert plate_bed_temperature(path, 2) == 70
  37. def test_without_a_plate_the_one_dispatched_decides(tmp_path):
  38. """A job without a plate prints the file's default (lowest sliced) plate."""
  39. path = _3mf(tmp_path / "a.3mf", {1: PLA, 2: PETG_PLATE_FILAMENTS}, gcode=(1, 2))
  40. assert plate_bed_temperature(path, None) == 55
  41. assert plate_bed_temperature(path, 0) == 55
  42. def test_a_plate_whose_filaments_are_unknown_takes_the_highest(tmp_path):
  43. assert plate_bed_temperature(_3mf(tmp_path / "a.3mf", {1: [], 2: []}), None) == 70
  44. def test_an_unreadable_file_is_none(tmp_path):
  45. (tmp_path / "broken.3mf").write_bytes(b"not a zip")
  46. assert plate_bed_temperature(tmp_path / "broken.3mf", 1) is None
  47. assert plate_bed_temperature(tmp_path / "missing.3mf", 1) is None
  48. def test_a_damaged_member_is_none(tmp_path):
  49. """Corrupt deflate data raises zlib.error, which is no OSError: preheat
  50. runs this on every dispatch, so nothing may escape it."""
  51. path = tmp_path / "a.3mf"
  52. with zipfile.ZipFile(path, "w", zipfile.ZIP_DEFLATED) as zf:
  53. zf.writestr("Metadata/project_settings.config", json.dumps({"bed_temperature": ["60"] * 200}))
  54. raw = bytearray(path.read_bytes())
  55. start = raw.index(b"PK\x03\x04") + 30 + len("Metadata/project_settings.config")
  56. raw[start : start + 8] = b"\xff" * 8
  57. path.write_bytes(bytes(raw))
  58. assert plate_bed_temperature(path, 1) is None
  59. def test_an_infinite_temperature_is_none(tmp_path):
  60. path = tmp_path / "a.3mf"
  61. with zipfile.ZipFile(path, "w") as zf:
  62. zf.writestr("Metadata/project_settings.config", json.dumps({"bed_temperature": ["inf"]}))
  63. assert plate_bed_temperature(path, 1) is None
  64. def test_the_archive_keeps_the_projects_highest(tmp_path):
  65. """The stored value stays the safe upper bound for any plate of it."""
  66. path = _3mf(tmp_path / "a.3mf", {1: PLA, 2: PETG_PLATE_FILAMENTS})
  67. assert ThreeMFParser(path, plate_number=1).parse().get("bed_temperature") == 70
  68. @pytest.fixture
  69. def base_dir(monkeypatch, tmp_path) -> Path:
  70. from backend.app.core.config import settings
  71. monkeypatch.setattr(settings, "base_dir", tmp_path)
  72. return tmp_path
  73. @pytest.mark.asyncio
  74. @pytest.mark.parametrize(("plate_id", "expected"), [(1, 55), (2, 70)])
  75. async def test_a_job_is_heated_for_its_plate(base_dir, plate_id, expected):
  76. _3mf(base_dir / "a.3mf", {1: PLA, 2: PETG_PLATE_FILAMENTS})
  77. archive = SimpleNamespace(file_path="a.3mf", bed_temperature=70)
  78. assert await _queued_plate_bed_temperature(SimpleNamespace(plate_id=plate_id), archive) == expected
  79. @pytest.mark.asyncio
  80. async def test_an_unreadable_file_falls_back_to_the_stored_value(base_dir):
  81. archive = SimpleNamespace(file_path="gone.3mf", bed_temperature=65)
  82. assert await _queued_plate_bed_temperature(SimpleNamespace(plate_id=2), archive) == 65
  83. @pytest.mark.asyncio
  84. async def test_no_archive_is_zero():
  85. assert await _queued_plate_bed_temperature(SimpleNamespace(plate_id=None), None) == 0
  86. @pytest.mark.asyncio
  87. async def test_preheat_heats_for_the_job_s_plate(base_dir):
  88. from unittest.mock import AsyncMock, patch
  89. from backend.app.services.print_scheduler import PrintScheduler
  90. from backend.tests.unit.test_scheduler_preheat import _ints, _make_client, _make_printer, _make_state
  91. _3mf(base_dir / "a.3mf", {1: PLA, 2: PETG_PLATE_FILAMENTS})
  92. scheduler = PrintScheduler()
  93. item = SimpleNamespace(id=42, plate_id=1, preheat_override="on", preheat_chamber_target_override=0)
  94. client = _make_client()
  95. with (
  96. patch.object(scheduler, "_get_bool_setting", AsyncMock(return_value=False)),
  97. patch.object(scheduler, "_get_int_setting", _ints(preheat_soak_seconds=0)),
  98. patch("backend.app.services.print_scheduler.printer_manager") as pm,
  99. patch("backend.app.services.print_scheduler.asyncio.sleep", AsyncMock()),
  100. ):
  101. pm.get_client.return_value = client
  102. pm.get_status.return_value = _make_state(55.0, 0.0)
  103. await scheduler._preheat_and_soak(
  104. AsyncMock(), item, _make_printer("H2D"), SimpleNamespace(file_path="a.3mf", bed_temperature=70)
  105. )
  106. client.set_bed_temperature.assert_called_once_with(55)
  107. @pytest.mark.asyncio
  108. async def test_keep_warm_holds_for_the_next_job_s_plate(base_dir):
  109. from unittest.mock import patch
  110. from backend.app.services.print_scheduler import PrintScheduler
  111. from backend.tests.unit.test_scheduler_keep_warm import _make_client, _make_item, _make_state, _run
  112. _3mf(base_dir / "a.3mf", {1: PLA, 2: PETG_PLATE_FILAMENTS})
  113. item = _make_item()
  114. item.plate_id = 2
  115. item.archive = SimpleNamespace(file_path="a.3mf", bed_temperature=110)
  116. client = _make_client()
  117. with patch("backend.app.services.print_scheduler.printer_manager") as pm:
  118. pm.get_status.return_value = _make_state()
  119. pm.get_client.return_value = client
  120. await _run(PrintScheduler(), items=[item], hold_temp=40)
  121. client.set_bed_temperature.assert_called_once_with(70)