test_filament_requirements.py 5.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144
  1. """Unit tests for `extract_filament_requirements` (#1188).
  2. The helper is the parser the scheduler used to own and the VP queue-mode
  3. write path now also uses. Pin the contract end-to-end so a refactor of one
  4. caller can't silently break the other.
  5. """
  6. from __future__ import annotations
  7. import zipfile
  8. from pathlib import Path
  9. from backend.app.services.filament_requirements import extract_filament_requirements
  10. def _make_3mf(
  11. file_path: Path,
  12. *,
  13. plates: list[tuple[int, list[dict]]] | None = None,
  14. flat_filaments: list[dict] | None = None,
  15. ) -> None:
  16. """Build a minimal 3MF zip. Either ``plates`` (list of
  17. ``(plate_index, filaments)``) or ``flat_filaments`` (no plate wrapper)
  18. drives the slice_info.config shape."""
  19. def _filament_xml(filaments: list[dict]) -> str:
  20. return "".join(
  21. f'<filament id="{f["id"]}" type="{f["type"]}" color="{f["color"]}" '
  22. f'used_g="{f["used_g"]}" tray_info_idx="{f.get("tray_info_idx", "")}"/>'
  23. for f in filaments
  24. )
  25. if plates is not None:
  26. plate_xml = "".join(
  27. f'<plate><metadata key="index" value="{idx}"/>{_filament_xml(fs)}</plate>' for idx, fs in plates
  28. )
  29. body = plate_xml
  30. elif flat_filaments is not None:
  31. body = _filament_xml(flat_filaments)
  32. else:
  33. body = ""
  34. config = f'<?xml version="1.0" encoding="utf-8"?><config>{body}</config>'
  35. with zipfile.ZipFile(file_path, "w") as zf:
  36. zf.writestr("Metadata/slice_info.config", config)
  37. class TestExtractFilamentRequirements:
  38. def test_returns_per_slot_dicts_for_plate(self, tmp_path: Path):
  39. f = tmp_path / "model.3mf"
  40. _make_3mf(
  41. f,
  42. plates=[
  43. (
  44. 1,
  45. [
  46. {"id": "1", "type": "PLA", "color": "#FFFFFF", "used_g": "12.5"},
  47. {"id": "2", "type": "PETG", "color": "#000000", "used_g": "4.2"},
  48. ],
  49. )
  50. ],
  51. )
  52. out = extract_filament_requirements(f, plate_id=1)
  53. assert out == [
  54. {"slot_id": 1, "type": "PLA", "color": "#FFFFFF", "tray_info_idx": "", "used_grams": 12.5},
  55. {"slot_id": 2, "type": "PETG", "color": "#000000", "tray_info_idx": "", "used_grams": 4.2},
  56. ]
  57. def test_skips_zero_use_filaments(self, tmp_path: Path):
  58. """Slot present in slice_info.config but `used_g <= 0` means the
  59. plate doesn't actually consume that filament — must not show up."""
  60. f = tmp_path / "model.3mf"
  61. _make_3mf(
  62. f,
  63. plates=[
  64. (
  65. 1,
  66. [
  67. {"id": "1", "type": "PLA", "color": "#FFFFFF", "used_g": "10.0"},
  68. {"id": "2", "type": "ABS", "color": "#FF0000", "used_g": "0"},
  69. {"id": "3", "type": "PETG", "color": "#00FF00", "used_g": "-1"},
  70. ],
  71. )
  72. ],
  73. )
  74. out = extract_filament_requirements(f, plate_id=1)
  75. assert [r["slot_id"] for r in out] == [1]
  76. def test_filters_to_requested_plate(self, tmp_path: Path):
  77. f = tmp_path / "multi.3mf"
  78. _make_3mf(
  79. f,
  80. plates=[
  81. (1, [{"id": "1", "type": "PLA", "color": "#FFF", "used_g": "5"}]),
  82. (2, [{"id": "1", "type": "PETG", "color": "#000", "used_g": "5"}]),
  83. ],
  84. )
  85. assert extract_filament_requirements(f, plate_id=1)[0]["type"] == "PLA"
  86. assert extract_filament_requirements(f, plate_id=2)[0]["type"] == "PETG"
  87. def test_no_plate_id_walks_flat_filaments(self, tmp_path: Path):
  88. """When the slice_info.config has no plate wrapper (some older
  89. Studio versions), we still pick up flat ``./filament`` children."""
  90. f = tmp_path / "flat.3mf"
  91. _make_3mf(
  92. f,
  93. flat_filaments=[{"id": "1", "type": "PLA", "color": "#FFF", "used_g": "5"}],
  94. )
  95. out = extract_filament_requirements(f, plate_id=None)
  96. assert len(out) == 1
  97. assert out[0]["type"] == "PLA"
  98. def test_returns_empty_list_for_unparseable_file(self, tmp_path: Path):
  99. f = tmp_path / "bad.3mf"
  100. f.write_bytes(b"not a zip")
  101. assert extract_filament_requirements(f, plate_id=1) == []
  102. def test_returns_empty_list_for_missing_file(self, tmp_path: Path):
  103. assert extract_filament_requirements(tmp_path / "nope.3mf", plate_id=1) == []
  104. def test_returns_empty_list_when_slice_info_missing(self, tmp_path: Path):
  105. """3MF without `Metadata/slice_info.config` (e.g. a model-only
  106. export) must degrade gracefully."""
  107. f = tmp_path / "no-config.3mf"
  108. with zipfile.ZipFile(f, "w") as zf:
  109. zf.writestr("3D/3dmodel.model", "<model/>")
  110. assert extract_filament_requirements(f, plate_id=1) == []
  111. def test_results_are_sorted_by_slot_id(self, tmp_path: Path):
  112. f = tmp_path / "unordered.3mf"
  113. _make_3mf(
  114. f,
  115. plates=[
  116. (
  117. 1,
  118. [
  119. {"id": "3", "type": "PLA", "color": "#FFF", "used_g": "1"},
  120. {"id": "1", "type": "PLA", "color": "#000", "used_g": "1"},
  121. {"id": "2", "type": "PLA", "color": "#F00", "used_g": "1"},
  122. ],
  123. )
  124. ],
  125. )
  126. out = extract_filament_requirements(f, plate_id=1)
  127. assert [r["slot_id"] for r in out] == [1, 2, 3]