mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-06 22:21:29 +02:00
1185 lines
45 KiB
Python
1185 lines
45 KiB
Python
"""Unit tests for the archive service."""
|
|
|
|
from datetime import datetime
|
|
|
|
|
|
class TestArchiveServiceHelpers:
|
|
"""Tests for archive service helper functions."""
|
|
|
|
def test_parse_print_time_seconds(self):
|
|
"""Test parsing print time to seconds."""
|
|
# Import the actual function if available, otherwise test the logic
|
|
# 2h 30m 15s = 2*3600 + 30*60 + 15 = 9015 seconds
|
|
_time_str = "2h 30m 15s" # Example format
|
|
# Parse hours
|
|
hours = 2
|
|
minutes = 30
|
|
seconds = 15
|
|
total = hours * 3600 + minutes * 60 + seconds
|
|
assert total == 9015
|
|
|
|
def test_parse_filament_grams(self):
|
|
"""Test parsing filament usage to grams."""
|
|
# Example: "150.5g" -> 150.5
|
|
filament_str = "150.5g"
|
|
grams = float(filament_str.replace("g", ""))
|
|
assert grams == 150.5
|
|
|
|
def test_format_duration(self):
|
|
"""Test formatting seconds to human readable duration."""
|
|
# 3661 seconds = 1h 1m 1s
|
|
seconds = 3661
|
|
hours = seconds // 3600
|
|
minutes = (seconds % 3600) // 60
|
|
secs = seconds % 60
|
|
assert hours == 1
|
|
assert minutes == 1
|
|
assert secs == 1
|
|
|
|
|
|
class TestArchiveDataParsing:
|
|
"""Tests for parsing archive data from MQTT messages."""
|
|
|
|
def test_parse_gcode_state(self):
|
|
"""Test parsing gcode state."""
|
|
states = {
|
|
"RUNNING": "printing",
|
|
"FINISH": "completed",
|
|
"FAILED": "failed",
|
|
"IDLE": "idle",
|
|
"PAUSE": "paused",
|
|
}
|
|
for gcode_state, expected in states.items():
|
|
# Simple state mapping
|
|
mapped = gcode_state.lower()
|
|
if gcode_state == "RUNNING":
|
|
mapped = "printing"
|
|
elif gcode_state == "FINISH":
|
|
mapped = "completed"
|
|
elif gcode_state == "FAILED":
|
|
mapped = "failed"
|
|
elif gcode_state == "IDLE":
|
|
mapped = "idle"
|
|
elif gcode_state == "PAUSE":
|
|
mapped = "paused"
|
|
assert mapped == expected
|
|
|
|
def test_parse_progress(self):
|
|
"""Test parsing print progress."""
|
|
# mc_percent is the progress field in MQTT messages
|
|
data = {"mc_percent": 75}
|
|
progress = data.get("mc_percent", 0)
|
|
assert progress == 75
|
|
assert 0 <= progress <= 100
|
|
|
|
def test_parse_layer_info(self):
|
|
"""Test parsing layer information."""
|
|
data = {
|
|
"layer_num": 50,
|
|
"total_layers": 200,
|
|
}
|
|
current_layer = data.get("layer_num", 0)
|
|
total_layers = data.get("total_layers", 0)
|
|
assert current_layer == 50
|
|
assert total_layers == 200
|
|
if total_layers > 0:
|
|
layer_percent = (current_layer / total_layers) * 100
|
|
assert layer_percent == 25.0
|
|
|
|
|
|
class TestArchiveFilePaths:
|
|
"""Tests for archive file path handling."""
|
|
|
|
def test_generate_archive_path(self):
|
|
"""Test generating archive file paths."""
|
|
printer_name = "X1C_01"
|
|
_print_name = "benchy" # Example print name
|
|
timestamp = datetime(2024, 1, 15, 14, 30, 0)
|
|
|
|
# Expected pattern: archives/{printer}/{year}/{month}/{filename}
|
|
year = timestamp.year
|
|
month = f"{timestamp.month:02d}"
|
|
expected_dir = f"archives/{printer_name}/{year}/{month}"
|
|
|
|
assert "archives" in expected_dir
|
|
assert printer_name in expected_dir
|
|
assert str(year) in expected_dir
|
|
|
|
def test_sanitize_filename(self):
|
|
"""Test filename sanitization."""
|
|
# Characters to remove: / \ : * ? " < > |
|
|
dirty_name = "test:file<name>.3mf"
|
|
# Simple sanitization
|
|
safe_chars = []
|
|
for c in dirty_name:
|
|
if c not in '\\/:*?"<>|':
|
|
safe_chars.append(c)
|
|
clean_name = "".join(safe_chars)
|
|
assert ":" not in clean_name
|
|
assert "<" not in clean_name
|
|
assert ">" not in clean_name
|
|
|
|
def test_thumbnail_path(self):
|
|
"""Test thumbnail path generation."""
|
|
archive_path = "archives/X1C_01/2024/01/benchy.3mf"
|
|
# Thumbnail typically has same path with _thumb.png suffix
|
|
base_path = archive_path.rsplit(".", 1)[0]
|
|
thumbnail_path = f"{base_path}_thumb.png"
|
|
assert thumbnail_path.endswith("_thumb.png")
|
|
assert "benchy" in thumbnail_path
|
|
|
|
|
|
class TestArchiveStatus:
|
|
"""Tests for archive status handling."""
|
|
|
|
def test_valid_status_values(self):
|
|
"""Test valid archive status values."""
|
|
valid_statuses = ["completed", "failed", "cancelled", "stopped"]
|
|
for status in valid_statuses:
|
|
assert status in valid_statuses
|
|
|
|
def test_status_from_gcode_state(self):
|
|
"""Test mapping gcode state to archive status."""
|
|
state_mapping = {
|
|
"FINISH": "completed",
|
|
"FAILED": "failed",
|
|
"CANCEL": "cancelled",
|
|
}
|
|
for gcode_state, expected_status in state_mapping.items():
|
|
assert state_mapping[gcode_state] == expected_status
|
|
|
|
|
|
class TestArchiveFilamentData:
|
|
"""Tests for filament data parsing."""
|
|
|
|
def test_parse_ams_filament(self):
|
|
"""Test parsing AMS filament information."""
|
|
ams_data = {
|
|
"ams": {
|
|
"ams": [
|
|
{
|
|
"tray": [
|
|
{"tray_type": "PLA", "tray_color": "FF0000"},
|
|
{"tray_type": "PETG", "tray_color": "00FF00"},
|
|
]
|
|
}
|
|
]
|
|
}
|
|
}
|
|
trays = ams_data["ams"]["ams"][0]["tray"]
|
|
assert trays[0]["tray_type"] == "PLA"
|
|
assert trays[1]["tray_type"] == "PETG"
|
|
|
|
def test_parse_filament_color_hex(self):
|
|
"""Test parsing filament color from hex."""
|
|
color_hex = "FF5500"
|
|
# Should be valid hex
|
|
assert len(color_hex) == 6
|
|
r = int(color_hex[0:2], 16)
|
|
g = int(color_hex[2:4], 16)
|
|
b = int(color_hex[4:6], 16)
|
|
assert r == 255
|
|
assert g == 85
|
|
assert b == 0
|
|
|
|
def test_calculate_filament_cost(self):
|
|
"""Test calculating filament cost."""
|
|
grams_used = 150.0
|
|
cost_per_kg = 25.0 # $25 per kg
|
|
cost = (grams_used / 1000) * cost_per_kg
|
|
assert cost == 3.75
|
|
|
|
|
|
class TestArchiveThumbnails:
|
|
"""Tests for archive thumbnail handling."""
|
|
|
|
def test_thumbnail_file_types(self):
|
|
"""Test supported thumbnail file types."""
|
|
supported_types = [".png", ".jpg", ".jpeg"]
|
|
for ext in supported_types:
|
|
assert ext.startswith(".")
|
|
assert ext.lower() in [".png", ".jpg", ".jpeg"]
|
|
|
|
def test_extract_thumbnail_from_3mf(self):
|
|
"""Test thumbnail extraction concept from 3MF."""
|
|
# 3MF files are ZIP archives containing:
|
|
# - Metadata/thumbnail.png
|
|
# - 3D/3dmodel.model
|
|
expected_thumbnail_paths = [
|
|
"Metadata/thumbnail.png",
|
|
"Metadata/plate_1.png",
|
|
]
|
|
for path in expected_thumbnail_paths:
|
|
assert "png" in path.lower()
|
|
|
|
def test_extract_thumbnail_falls_back_to_auxiliaries(self, tmp_path):
|
|
"""#1493 follow-up: when BambuStudio's CLI runs with --arrange it
|
|
rearranges objects but doesn't always emit a fresh
|
|
``Metadata/plate_N.png`` for the rearranged plate. The project-wide
|
|
thumbnail under ``Auxiliaries/.thumbnails/`` survives though, and
|
|
we use it as a cover-image fallback so re-sliced archive cards
|
|
still render with a thumbnail."""
|
|
import zipfile
|
|
|
|
from backend.app.services.archive import ThreeMFParser
|
|
|
|
threemf_path = tmp_path / "sliced.3mf"
|
|
with zipfile.ZipFile(threemf_path, "w") as zf:
|
|
zf.writestr("3D/3dmodel.model", "<model/>")
|
|
# No Metadata/plate_1.png / thumbnail.png — only the
|
|
# Auxiliaries project-wide thumbnail (what arranged slices
|
|
# carry in practice).
|
|
zf.writestr("Auxiliaries/.thumbnails/thumbnail_middle.png", b"PNGMIDDLE")
|
|
|
|
parser = ThreeMFParser(str(threemf_path), plate_number=1)
|
|
parsed = parser.parse()
|
|
assert parsed.get("_thumbnail_data") == b"PNGMIDDLE"
|
|
assert parsed.get("_thumbnail_ext") == ".png"
|
|
|
|
def test_per_plate_png_wins_over_auxiliaries_fallback(self, tmp_path):
|
|
"""Order matters: when BOTH the per-plate preview and the
|
|
Auxiliaries fallback are present, the per-plate one wins because
|
|
it reflects the actual sliced layout."""
|
|
import zipfile
|
|
|
|
from backend.app.services.archive import ThreeMFParser
|
|
|
|
threemf_path = tmp_path / "sliced.3mf"
|
|
with zipfile.ZipFile(threemf_path, "w") as zf:
|
|
zf.writestr("3D/3dmodel.model", "<model/>")
|
|
zf.writestr("Metadata/plate_1.png", b"PLATE1")
|
|
zf.writestr("Auxiliaries/.thumbnails/thumbnail_middle.png", b"PROJECT_WIDE")
|
|
|
|
parser = ThreeMFParser(str(threemf_path), plate_number=1)
|
|
parsed = parser.parse()
|
|
assert parsed.get("_thumbnail_data") == b"PLATE1"
|
|
|
|
|
|
class TestThreeMFMetadataHTMLUnescape:
|
|
"""3MF `<metadata name="Title">…</metadata>` values are XML-encoded.
|
|
BambuStudio sometimes writes triple-encoded payloads (the
|
|
ProjectPageParser comment documents this). Without an unescape loop,
|
|
a Title like ``Foo & Bar`` lands in the DB as raw ``Foo & Bar`` and
|
|
React then escapes the `&` on render to ``Foo &amp; Bar`` — the
|
|
user-visible symptom reported on #1658."""
|
|
|
|
def test_title_with_ampersand_is_unescaped(self, tmp_path):
|
|
import zipfile
|
|
|
|
from backend.app.services.archive import ThreeMFParser
|
|
|
|
threemf_path = tmp_path / "ampersand.3mf"
|
|
with zipfile.ZipFile(threemf_path, "w") as zf:
|
|
zf.writestr(
|
|
"3D/3dmodel.model",
|
|
'<?xml version="1.0" encoding="UTF-8"?>\n'
|
|
'<model><metadata name="Title">PCB Vise & Solder Station</metadata>'
|
|
'<metadata name="Designer">Chefkoch</metadata></model>',
|
|
)
|
|
|
|
parsed = ThreeMFParser(str(threemf_path)).parse()
|
|
assert parsed.get("print_name") == "PCB Vise & Solder Station"
|
|
assert parsed.get("designer") == "Chefkoch"
|
|
|
|
def test_title_with_triple_encoded_ampersand_is_fully_unescaped(self, tmp_path):
|
|
"""BambuStudio has been observed writing triple-encoded payloads
|
|
(`&amp;amp;`). The decoder loops until the string stops changing
|
|
so all layers get peeled in one pass."""
|
|
import zipfile
|
|
|
|
from backend.app.services.archive import ThreeMFParser
|
|
|
|
threemf_path = tmp_path / "triple.3mf"
|
|
with zipfile.ZipFile(threemf_path, "w") as zf:
|
|
zf.writestr(
|
|
"3D/3dmodel.model",
|
|
'<?xml version="1.0" encoding="UTF-8"?>\n'
|
|
'<model><metadata name="Title">Foo &amp;amp; Bar</metadata></model>',
|
|
)
|
|
|
|
parsed = ThreeMFParser(str(threemf_path)).parse()
|
|
assert parsed.get("print_name") == "Foo & Bar"
|
|
|
|
def test_title_without_entities_passes_through_unchanged(self, tmp_path):
|
|
"""The unescape loop must be a no-op when there's nothing to unescape —
|
|
regression guard against accidentally munging plain ASCII titles."""
|
|
import zipfile
|
|
|
|
from backend.app.services.archive import ThreeMFParser
|
|
|
|
threemf_path = tmp_path / "plain.3mf"
|
|
with zipfile.ZipFile(threemf_path, "w") as zf:
|
|
zf.writestr(
|
|
"3D/3dmodel.model",
|
|
'<?xml version="1.0" encoding="UTF-8"?>\n<model><metadata name="Title">Benchy</metadata></model>',
|
|
)
|
|
|
|
parsed = ThreeMFParser(str(threemf_path)).parse()
|
|
assert parsed.get("print_name") == "Benchy"
|
|
|
|
|
|
class TestModelMetadataSkipsMeshes:
|
|
"""The metadata scans stream past ``<mesh>`` bodies instead of reading the
|
|
whole model entry: a combined plate keeps hundreds of MB of geometry inline
|
|
in 3D/3dmodel.model (#3162). Metadata after ``</build>``, where
|
|
BambuStudio writes its MakerWorld fields, must still be found."""
|
|
|
|
MESH = (
|
|
'<mesh><vertices><vertex x="0" y="0" z="0"/><vertex x="1" y="0" z="0"/>'
|
|
'<vertex x="0" y="1" z="0"/></vertices><triangles><triangle v1="0" v2="1" v3="2"/>'
|
|
"</triangles></mesh>"
|
|
)
|
|
|
|
def _model(self) -> str:
|
|
return (
|
|
'<?xml version="1.0" encoding="UTF-8"?>\n'
|
|
'<model><metadata name="Title">Before & mesh</metadata><resources>'
|
|
f'<object id="1" type="model">{self.MESH}</object>'
|
|
f'<object id="2" type="model">{self.MESH}</object>'
|
|
'</resources><build><item objectid="1"/></build>'
|
|
'<metadata name="Designer">After build</metadata>'
|
|
'<metadata name="CopyRight">https://makerworld.com/en/models/130726</metadata>'
|
|
"</model>"
|
|
)
|
|
|
|
def _write(self, tmp_path, model: str):
|
|
import zipfile
|
|
|
|
path = tmp_path / "plate.3mf"
|
|
with zipfile.ZipFile(path, "w") as zf:
|
|
zf.writestr("3D/3dmodel.model", model)
|
|
return path
|
|
|
|
def test_meshes_are_dropped_and_everything_else_kept(self, tmp_path):
|
|
import zipfile
|
|
|
|
from backend.app.services.archive import read_3dmodel_without_meshes
|
|
|
|
model = self._model()
|
|
with zipfile.ZipFile(self._write(tmp_path, model)) as zf:
|
|
text = read_3dmodel_without_meshes(zf)
|
|
|
|
assert "<vertex" not in text and "<triangle" not in text
|
|
assert text == model.replace(self.MESH, "")
|
|
|
|
def test_tags_split_across_chunks(self, tmp_path, monkeypatch):
|
|
"""Every chunk size from 1 byte up puts ``<mesh`` and ``</mesh>``
|
|
across a boundary somewhere; the result must not change."""
|
|
import zipfile
|
|
|
|
from backend.app.services import archive
|
|
|
|
model = self._model()
|
|
path = self._write(tmp_path, model)
|
|
expected = model.replace(self.MESH, "")
|
|
for chunk in range(1, 40):
|
|
monkeypatch.setattr(archive, "_MODEL_READ_CHUNK", chunk)
|
|
with zipfile.ZipFile(path) as zf:
|
|
assert archive.read_3dmodel_without_meshes(zf) == expected, chunk
|
|
|
|
def test_parsers_still_find_metadata_after_the_build(self, tmp_path):
|
|
from backend.app.services.archive import ProjectPageParser, ThreeMFParser
|
|
|
|
path = self._write(tmp_path, self._model())
|
|
|
|
parsed = ThreeMFParser(str(path)).parse()
|
|
assert parsed.get("print_name") == "Before & mesh"
|
|
assert parsed.get("designer") == "After build"
|
|
assert parsed.get("makerworld_model_id") == "130726"
|
|
|
|
page = ProjectPageParser(str(path)).parse(archive_id=1)
|
|
assert page["title"] == "Before & mesh"
|
|
assert page["designer"] == "After build"
|
|
|
|
|
|
class TestPrintableObjectsExtraction:
|
|
"""Tests for extracting printable objects count from 3MF files."""
|
|
|
|
def test_extract_printable_objects_from_slice_info(self):
|
|
"""Test parsing printable objects from slice_info.config XML."""
|
|
from defusedxml import ElementTree as ET
|
|
|
|
# Example slice_info.config content with 4 objects
|
|
slice_info_xml = """<?xml version="1.0" encoding="UTF-8"?>
|
|
<config>
|
|
<plate plate_idx="1">
|
|
<metadata key="prediction" value="3600" />
|
|
<metadata key="weight" value="50.5" />
|
|
<object identify_id="1" name="Part_A" skipped="false" />
|
|
<object identify_id="2" name="Part_B" skipped="false" />
|
|
<object identify_id="3" name="Part_C" skipped="false" />
|
|
<object identify_id="4" name="Part_D" skipped="true" />
|
|
</plate>
|
|
</config>
|
|
"""
|
|
root = ET.fromstring(slice_info_xml)
|
|
plate = root.find(".//plate")
|
|
|
|
# Count non-skipped objects (should be 3, not 4)
|
|
count = 0
|
|
for obj in plate.findall("object"):
|
|
skipped = obj.get("skipped", "false")
|
|
if skipped.lower() != "true":
|
|
count += 1
|
|
|
|
assert count == 3 # 3 objects (Part_D is skipped)
|
|
|
|
def test_extract_printable_objects_empty_plate(self):
|
|
"""Test handling plate with no objects."""
|
|
from defusedxml import ElementTree as ET
|
|
|
|
slice_info_xml = """<?xml version="1.0" encoding="UTF-8"?>
|
|
<config>
|
|
<plate plate_idx="1">
|
|
<metadata key="prediction" value="0" />
|
|
</plate>
|
|
</config>
|
|
"""
|
|
root = ET.fromstring(slice_info_xml)
|
|
plate = root.find(".//plate")
|
|
|
|
count = 0
|
|
for obj in plate.findall("object"):
|
|
skipped = obj.get("skipped", "false")
|
|
if skipped.lower() != "true":
|
|
count += 1
|
|
|
|
assert count == 0
|
|
|
|
def test_extract_printable_objects_all_skipped(self):
|
|
"""Test handling plate where all objects are skipped."""
|
|
from defusedxml import ElementTree as ET
|
|
|
|
slice_info_xml = """<?xml version="1.0" encoding="UTF-8"?>
|
|
<config>
|
|
<plate plate_idx="1">
|
|
<object identify_id="1" name="Part_A" skipped="true" />
|
|
<object identify_id="2" name="Part_B" skipped="true" />
|
|
</plate>
|
|
</config>
|
|
"""
|
|
root = ET.fromstring(slice_info_xml)
|
|
plate = root.find(".//plate")
|
|
|
|
count = 0
|
|
for obj in plate.findall("object"):
|
|
skipped = obj.get("skipped", "false")
|
|
if skipped.lower() != "true":
|
|
count += 1
|
|
|
|
assert count == 0 # All objects skipped
|
|
|
|
|
|
class TestThreeMFPlateIndexExtraction:
|
|
"""Tests for extracting plate index from multi-plate 3MF exports (Issue #92)."""
|
|
|
|
def test_extract_plate_index_from_slice_info(self):
|
|
"""Test parsing plate index from slice_info.config metadata."""
|
|
from defusedxml import ElementTree as ET
|
|
|
|
# Single-plate export from plate 5 of a multi-plate project
|
|
slice_info_xml = """<?xml version="1.0" encoding="UTF-8"?>
|
|
<config>
|
|
<plate>
|
|
<metadata key="index" value="5" />
|
|
<metadata key="prediction" value="3600" />
|
|
<metadata key="weight" value="50.5" />
|
|
<object identify_id="1" name="Part_A" skipped="false" />
|
|
</plate>
|
|
</config>
|
|
"""
|
|
root = ET.fromstring(slice_info_xml)
|
|
plate = root.find(".//plate")
|
|
|
|
plate_index = None
|
|
for meta in plate.findall("metadata"):
|
|
if meta.get("key") == "index":
|
|
plate_index = int(meta.get("value"))
|
|
break
|
|
|
|
assert plate_index == 5
|
|
|
|
def test_extract_plate_index_plate_1(self):
|
|
"""Test parsing plate index when it's plate 1."""
|
|
from defusedxml import ElementTree as ET
|
|
|
|
slice_info_xml = """<?xml version="1.0" encoding="UTF-8"?>
|
|
<config>
|
|
<plate>
|
|
<metadata key="index" value="1" />
|
|
<metadata key="prediction" value="1800" />
|
|
</plate>
|
|
</config>
|
|
"""
|
|
root = ET.fromstring(slice_info_xml)
|
|
plate = root.find(".//plate")
|
|
|
|
plate_index = None
|
|
for meta in plate.findall("metadata"):
|
|
if meta.get("key") == "index":
|
|
plate_index = int(meta.get("value"))
|
|
break
|
|
|
|
assert plate_index == 1
|
|
|
|
def test_thumbnail_path_uses_plate_number(self):
|
|
"""Test that thumbnail path correctly uses the extracted plate number."""
|
|
plate_number = 5
|
|
thumbnail_paths = []
|
|
|
|
if plate_number:
|
|
thumbnail_paths.append(f"Metadata/plate_{plate_number}.png")
|
|
|
|
thumbnail_paths.extend(
|
|
[
|
|
"Metadata/plate_1.png",
|
|
"Metadata/thumbnail.png",
|
|
]
|
|
)
|
|
|
|
# First priority should be plate_5.png
|
|
assert thumbnail_paths[0] == "Metadata/plate_5.png"
|
|
|
|
@staticmethod
|
|
def _enhance_print_name(print_name: str, plate_index: int) -> str:
|
|
"""Apply plate name enhancement logic from archive.py."""
|
|
if plate_index and plate_index > 1:
|
|
if print_name and f"Plate {plate_index}" not in print_name:
|
|
print_name = f"{print_name} - Plate {plate_index}"
|
|
return print_name
|
|
|
|
def test_print_name_enhanced_for_plate_greater_than_1(self):
|
|
"""Test that print_name is enhanced with plate info for plate > 1."""
|
|
assert self._enhance_print_name("Benchy", 5) == "Benchy - Plate 5"
|
|
|
|
def test_print_name_not_enhanced_for_plate_1(self):
|
|
"""Test that print_name is NOT enhanced for plate 1."""
|
|
assert self._enhance_print_name("Benchy", 1) == "Benchy"
|
|
|
|
def test_print_name_not_duplicated(self):
|
|
"""Test that plate info is not added if already present in print_name."""
|
|
assert self._enhance_print_name("Benchy - Plate 5", 5) == "Benchy - Plate 5"
|
|
|
|
def test_high_plate_number_extraction(self):
|
|
"""Test extracting high plate numbers (e.g., plate 28)."""
|
|
from defusedxml import ElementTree as ET
|
|
|
|
slice_info_xml = """<?xml version="1.0" encoding="UTF-8"?>
|
|
<config>
|
|
<plate>
|
|
<metadata key="index" value="28" />
|
|
<metadata key="prediction" value="7200" />
|
|
</plate>
|
|
</config>
|
|
"""
|
|
root = ET.fromstring(slice_info_xml)
|
|
plate = root.find(".//plate")
|
|
|
|
plate_index = None
|
|
for meta in plate.findall("metadata"):
|
|
if meta.get("key") == "index":
|
|
plate_index = int(meta.get("value"))
|
|
break
|
|
|
|
assert plate_index == 28
|
|
|
|
# Verify thumbnail would use correct plate
|
|
thumbnail_path = f"Metadata/plate_{plate_index}.png"
|
|
assert thumbnail_path == "Metadata/plate_28.png"
|
|
|
|
|
|
class TestMultiPlate3MFParsing:
|
|
"""Tests for parsing multi-plate 3MF files (Issue #93)."""
|
|
|
|
def test_parse_multiple_plates_from_slice_info(self):
|
|
"""Test parsing multiple plates from slice_info.config."""
|
|
from defusedxml import ElementTree as ET
|
|
|
|
# Multi-plate 3MF with 3 plates
|
|
slice_info_xml = """<?xml version="1.0" encoding="UTF-8"?>
|
|
<config>
|
|
<plate>
|
|
<metadata key="index" value="1" />
|
|
<metadata key="prediction" value="3600" />
|
|
<metadata key="weight" value="50.0" />
|
|
<filament id="1" type="PLA" color="#FF0000" used_g="25.0" used_m="8.5" />
|
|
<object identify_id="1" name="Part_A" skipped="false" />
|
|
</plate>
|
|
<plate>
|
|
<metadata key="index" value="2" />
|
|
<metadata key="prediction" value="7200" />
|
|
<metadata key="weight" value="100.0" />
|
|
<filament id="2" type="PETG" color="#00FF00" used_g="50.0" used_m="17.0" />
|
|
<object identify_id="2" name="Part_B" skipped="false" />
|
|
</plate>
|
|
<plate>
|
|
<metadata key="index" value="3" />
|
|
<metadata key="prediction" value="1800" />
|
|
<metadata key="weight" value="25.0" />
|
|
<filament id="1" type="PLA" color="#FF0000" used_g="12.5" used_m="4.2" />
|
|
<filament id="3" type="TPU" color="#0000FF" used_g="12.5" used_m="4.2" />
|
|
<object identify_id="3" name="Part_C" skipped="false" />
|
|
</plate>
|
|
</config>
|
|
"""
|
|
root = ET.fromstring(slice_info_xml)
|
|
plates = root.findall(".//plate")
|
|
|
|
assert len(plates) == 3
|
|
|
|
# Parse each plate
|
|
plate_data = []
|
|
for plate_elem in plates:
|
|
plate_info = {"index": None, "filaments": []}
|
|
|
|
for meta in plate_elem.findall("metadata"):
|
|
if meta.get("key") == "index":
|
|
plate_info["index"] = int(meta.get("value"))
|
|
|
|
for filament_elem in plate_elem.findall("filament"):
|
|
used_g = float(filament_elem.get("used_g", "0"))
|
|
if used_g > 0:
|
|
plate_info["filaments"].append(
|
|
{
|
|
"slot_id": int(filament_elem.get("id")),
|
|
"type": filament_elem.get("type"),
|
|
"color": filament_elem.get("color"),
|
|
"used_grams": used_g,
|
|
}
|
|
)
|
|
|
|
plate_data.append(plate_info)
|
|
|
|
# Verify plate 1
|
|
assert plate_data[0]["index"] == 1
|
|
assert len(plate_data[0]["filaments"]) == 1
|
|
assert plate_data[0]["filaments"][0]["slot_id"] == 1
|
|
assert plate_data[0]["filaments"][0]["type"] == "PLA"
|
|
|
|
# Verify plate 2
|
|
assert plate_data[1]["index"] == 2
|
|
assert len(plate_data[1]["filaments"]) == 1
|
|
assert plate_data[1]["filaments"][0]["slot_id"] == 2
|
|
assert plate_data[1]["filaments"][0]["type"] == "PETG"
|
|
|
|
# Verify plate 3 (has 2 filaments)
|
|
assert plate_data[2]["index"] == 3
|
|
assert len(plate_data[2]["filaments"]) == 2
|
|
filament_types = {f["type"] for f in plate_data[2]["filaments"]}
|
|
assert filament_types == {"PLA", "TPU"}
|
|
|
|
def test_filter_filaments_by_plate_id(self):
|
|
"""Test filtering filaments for a specific plate."""
|
|
from defusedxml import ElementTree as ET
|
|
|
|
slice_info_xml = """<?xml version="1.0" encoding="UTF-8"?>
|
|
<config>
|
|
<plate>
|
|
<metadata key="index" value="1" />
|
|
<filament id="1" type="PLA" color="#FF0000" used_g="25.0" />
|
|
</plate>
|
|
<plate>
|
|
<metadata key="index" value="2" />
|
|
<filament id="2" type="PETG" color="#00FF00" used_g="50.0" />
|
|
</plate>
|
|
</config>
|
|
"""
|
|
root = ET.fromstring(slice_info_xml)
|
|
|
|
# Filter for plate 2 only
|
|
target_plate_id = 2
|
|
filaments = []
|
|
|
|
for plate_elem in root.findall(".//plate"):
|
|
plate_index = None
|
|
for meta in plate_elem.findall("metadata"):
|
|
if meta.get("key") == "index":
|
|
plate_index = int(meta.get("value", "0"))
|
|
break
|
|
|
|
if plate_index == target_plate_id:
|
|
for filament_elem in plate_elem.findall("filament"):
|
|
used_g = float(filament_elem.get("used_g", "0"))
|
|
if used_g > 0:
|
|
filaments.append(
|
|
{
|
|
"slot_id": int(filament_elem.get("id")),
|
|
"type": filament_elem.get("type"),
|
|
}
|
|
)
|
|
break
|
|
|
|
# Should only have plate 2's filament
|
|
assert len(filaments) == 1
|
|
assert filaments[0]["slot_id"] == 2
|
|
assert filaments[0]["type"] == "PETG"
|
|
|
|
def test_detect_multi_plate_from_gcode_files(self):
|
|
"""Test detecting multiple plates from gcode file presence."""
|
|
# Simulate namelist from a multi-plate 3MF
|
|
namelist = [
|
|
"Metadata/plate_1.gcode",
|
|
"Metadata/plate_2.gcode",
|
|
"Metadata/plate_3.gcode",
|
|
"Metadata/plate_1.png",
|
|
"Metadata/plate_2.png",
|
|
"Metadata/plate_3.png",
|
|
"Metadata/slice_info.config",
|
|
"3D/3dmodel.model",
|
|
]
|
|
|
|
# Extract plate indices from gcode files
|
|
gcode_files = [n for n in namelist if n.startswith("Metadata/plate_") and n.endswith(".gcode")]
|
|
plate_indices = []
|
|
for gf in gcode_files:
|
|
plate_str = gf[15:-6] # Remove "Metadata/plate_" and ".gcode"
|
|
plate_indices.append(int(plate_str))
|
|
|
|
plate_indices.sort()
|
|
|
|
assert len(plate_indices) == 3
|
|
assert plate_indices == [1, 2, 3]
|
|
|
|
# Verify it's a multi-plate file
|
|
is_multi_plate = len(plate_indices) > 1
|
|
assert is_multi_plate is True
|
|
|
|
def test_single_plate_export_not_multi_plate(self):
|
|
"""Test that single-plate exports are not detected as multi-plate."""
|
|
# Simulate namelist from a single-plate export (plate 5 only)
|
|
namelist = [
|
|
"Metadata/plate_5.gcode",
|
|
"Metadata/plate_1.png",
|
|
"Metadata/plate_2.png",
|
|
"Metadata/plate_3.png",
|
|
"Metadata/plate_4.png",
|
|
"Metadata/plate_5.png", # All thumbnails present
|
|
"Metadata/slice_info.config",
|
|
"3D/3dmodel.model",
|
|
]
|
|
|
|
# Extract plate indices from gcode files (not thumbnails!)
|
|
gcode_files = [n for n in namelist if n.startswith("Metadata/plate_") and n.endswith(".gcode")]
|
|
plate_indices = []
|
|
for gf in gcode_files:
|
|
plate_str = gf[15:-6]
|
|
plate_indices.append(int(plate_str))
|
|
|
|
# Only one gcode file = single plate export
|
|
assert len(plate_indices) == 1
|
|
assert plate_indices[0] == 5
|
|
|
|
is_multi_plate = len(plate_indices) > 1
|
|
assert is_multi_plate is False
|
|
|
|
|
|
class TestReprintCostCalculation:
|
|
"""Tests for reprint cost calculation."""
|
|
|
|
def test_cost_addition_logic(self):
|
|
"""Test that reprint costs are added correctly."""
|
|
# Simulate the cost addition logic
|
|
existing_cost = 5.25 # Original print cost
|
|
filament_grams = 100.0
|
|
cost_per_kg = 25.0 # Default cost
|
|
|
|
# Calculate additional cost for reprint
|
|
additional_cost = round((filament_grams / 1000) * cost_per_kg, 2)
|
|
assert additional_cost == 2.50
|
|
|
|
# Add to existing cost
|
|
new_total = round(existing_cost + additional_cost, 2)
|
|
assert new_total == 7.75
|
|
|
|
def test_cost_addition_with_none_existing(self):
|
|
"""Test cost addition when existing cost is None."""
|
|
existing_cost = None
|
|
filament_grams = 200.0
|
|
cost_per_kg = 15.0
|
|
|
|
additional_cost = round((filament_grams / 1000) * cost_per_kg, 2)
|
|
assert additional_cost == 3.0
|
|
|
|
# When existing is None, just use additional
|
|
new_total = additional_cost if existing_cost is None else round(existing_cost + additional_cost, 2)
|
|
assert new_total == 3.0
|
|
|
|
def test_cost_with_custom_filament_price(self):
|
|
"""Test cost calculation with custom filament price."""
|
|
filament_grams = 150.0
|
|
custom_cost_per_kg = 35.0 # More expensive filament
|
|
|
|
cost = round((filament_grams / 1000) * custom_cost_per_kg, 2)
|
|
assert cost == 5.25
|
|
|
|
def test_multiple_reprints_accumulate(self):
|
|
"""Test that multiple reprints accumulate costs correctly."""
|
|
filament_grams = 100.0
|
|
cost_per_kg = 20.0
|
|
single_print_cost = round((filament_grams / 1000) * cost_per_kg, 2)
|
|
assert single_print_cost == 2.0
|
|
|
|
# After 3 prints (1 original + 2 reprints)
|
|
total_after_3_prints = round(single_print_cost * 3, 2)
|
|
assert total_after_3_prints == 6.0
|
|
|
|
|
|
class TestGcodeHeaderFilamentUsage:
|
|
"""ThreeMFParser pulls total filament usage from the produced 3MF's G-code
|
|
header. Some slicer-sidecar builds leave the X-Filament-Used-* response
|
|
headers unset, so the slice would otherwise report "0 g" for a real
|
|
multi-hour print."""
|
|
|
|
@staticmethod
|
|
def _make_3mf(gcode_header: str) -> str:
|
|
import tempfile
|
|
import zipfile
|
|
|
|
fd, path = tempfile.mkstemp(suffix=".3mf")
|
|
import os
|
|
|
|
os.close(fd)
|
|
with zipfile.ZipFile(path, "w", zipfile.ZIP_DEFLATED) as zf:
|
|
zf.writestr("3D/3dmodel.model", "<model/>")
|
|
zf.writestr("Metadata/plate_1.gcode", gcode_header + "\nG1 X0 Y0\n")
|
|
return path
|
|
|
|
def test_extracts_filament_weight_and_length_from_header(self):
|
|
from backend.app.services.archive import ThreeMFParser
|
|
|
|
header = (
|
|
"; HEADER_BLOCK_START\n"
|
|
"; BambuStudio 02.06.00.51\n"
|
|
"; total layer number: 503\n"
|
|
"; total filament length [mm] : 41661.40\n"
|
|
"; total filament volume [cm^3] : 100207.42\n"
|
|
"; total filament weight [g] : 126.26\n"
|
|
)
|
|
meta = ThreeMFParser(self._make_3mf(header)).parse()
|
|
assert meta.get("filament_used_grams") == 126.26
|
|
assert meta.get("filament_used_mm") == 41661.40
|
|
assert meta.get("total_layers") == 503
|
|
|
|
def test_no_filament_keys_when_header_lacks_them(self):
|
|
from backend.app.services.archive import ThreeMFParser
|
|
|
|
meta = ThreeMFParser(self._make_3mf("; total layer number: 10\n")).parse()
|
|
assert "filament_used_grams" not in meta
|
|
assert "filament_used_mm" not in meta
|
|
|
|
|
|
class TestMultiPlateSliceInfoSum:
|
|
"""Multi-plate ``.gcode.3mf`` exports must produce file-level totals that
|
|
are the SUM of every plate's prediction + weight, not plate-1 only.
|
|
|
|
Pre-fix the parser used ``root.find(".//plate")`` and only read the
|
|
first plate's metadata, so the archive card and project rollup
|
|
under-reported by roughly the number of plates (#1593).
|
|
"""
|
|
|
|
@staticmethod
|
|
def _make_3mf_with_slice_info(slice_info_xml: str) -> str:
|
|
"""Write a minimal .3mf with the given slice_info.config payload.
|
|
|
|
Bambu Studio's slice_info.config is the file the parser reads for
|
|
file-level `prediction` / `weight`; the rest of the 3MF members
|
|
aren't required for this test.
|
|
"""
|
|
import os
|
|
import tempfile
|
|
import zipfile
|
|
|
|
fd, path = tempfile.mkstemp(suffix=".3mf")
|
|
os.close(fd)
|
|
with zipfile.ZipFile(path, "w", zipfile.ZIP_DEFLATED) as zf:
|
|
zf.writestr("3D/3dmodel.model", "<model/>")
|
|
zf.writestr("Metadata/slice_info.config", slice_info_xml)
|
|
return path
|
|
|
|
def test_three_plate_file_sums_prediction_and_weight(self):
|
|
"""The reporter's case: three plates with distinct prediction +
|
|
weight values must yield file-level totals that are the sum.
|
|
"""
|
|
from backend.app.services.archive import ThreeMFParser
|
|
|
|
slice_info_xml = """<?xml version="1.0" encoding="UTF-8"?>
|
|
<config>
|
|
<plate>
|
|
<metadata key="index" value="1" />
|
|
<metadata key="prediction" value="7140" />
|
|
<metadata key="weight" value="19.2" />
|
|
</plate>
|
|
<plate>
|
|
<metadata key="index" value="2" />
|
|
<metadata key="prediction" value="6000" />
|
|
<metadata key="weight" value="20.0" />
|
|
</plate>
|
|
<plate>
|
|
<metadata key="index" value="3" />
|
|
<metadata key="prediction" value="6300" />
|
|
<metadata key="weight" value="18.8" />
|
|
</plate>
|
|
</config>
|
|
"""
|
|
parser = ThreeMFParser(self._make_3mf_with_slice_info(slice_info_xml))
|
|
meta = parser.parse()
|
|
assert meta["print_time_seconds"] == 7140 + 6000 + 6300 # 19440
|
|
assert meta["filament_used_grams"] == round(19.2 + 20.0 + 18.8, 2) # 58.0
|
|
# Multi-plate file: no single plate index should be claimed at the
|
|
# file level — the archive represents all plates, not a specific one.
|
|
assert parser.plate_number is None
|
|
|
|
def test_single_plate_file_preserves_plate_index_and_objects(self):
|
|
"""The single-plate path must still set ``_plate_index`` and pick
|
|
up printable objects — these only make sense when the archive
|
|
represents exactly one plate.
|
|
"""
|
|
from backend.app.services.archive import ThreeMFParser
|
|
|
|
slice_info_xml = """<?xml version="1.0" encoding="UTF-8"?>
|
|
<config>
|
|
<plate>
|
|
<metadata key="index" value="2" />
|
|
<metadata key="prediction" value="3600" />
|
|
<metadata key="weight" value="50.5" />
|
|
<metadata key="curr_bed_type" value="textured_pei" />
|
|
<object identify_id="1" name="Part_A" skipped="false" />
|
|
<object identify_id="2" name="Part_B" skipped="true" />
|
|
</plate>
|
|
</config>
|
|
"""
|
|
parser = ThreeMFParser(self._make_3mf_with_slice_info(slice_info_xml))
|
|
meta = parser.parse()
|
|
assert meta["print_time_seconds"] == 3600
|
|
assert meta["filament_used_grams"] == 50.5
|
|
# Single-plate exports surface the plate index via ``plate_number``
|
|
# (``_plate_index`` is an internal key cleared at the end of parse).
|
|
assert parser.plate_number == 2
|
|
assert meta["bed_type"] == "textured_pei"
|
|
assert meta["printable_objects"] == {1: "Part_A"}
|
|
|
|
def test_multi_plate_ignores_per_plate_objects(self):
|
|
"""Multi-plate exports must NOT carry a single plate's objects at
|
|
the file level — the ``/plates`` endpoint surfaces them per-plate.
|
|
Conflating them would attach plate-1's parts to the whole archive.
|
|
"""
|
|
from backend.app.services.archive import ThreeMFParser
|
|
|
|
slice_info_xml = """<?xml version="1.0" encoding="UTF-8"?>
|
|
<config>
|
|
<plate>
|
|
<metadata key="index" value="1" />
|
|
<metadata key="prediction" value="1000" />
|
|
<metadata key="weight" value="10.0" />
|
|
<object identify_id="1" name="Part_A" skipped="false" />
|
|
</plate>
|
|
<plate>
|
|
<metadata key="index" value="2" />
|
|
<metadata key="prediction" value="1500" />
|
|
<metadata key="weight" value="15.0" />
|
|
<object identify_id="2" name="Part_B" skipped="false" />
|
|
</plate>
|
|
</config>
|
|
"""
|
|
parser = ThreeMFParser(self._make_3mf_with_slice_info(slice_info_xml))
|
|
meta = parser.parse()
|
|
assert meta["print_time_seconds"] == 2500
|
|
assert meta["filament_used_grams"] == 25.0
|
|
# No archive-level object list when there's more than one plate.
|
|
assert "printable_objects" not in meta
|
|
assert parser.plate_number is None
|
|
|
|
def test_missing_or_malformed_values_are_skipped(self):
|
|
"""A plate with a malformed prediction/weight string must skip
|
|
that field, not poison the sum or raise — defensive parsing was
|
|
already present per-field; the sum loop must preserve it.
|
|
"""
|
|
from backend.app.services.archive import ThreeMFParser
|
|
|
|
slice_info_xml = """<?xml version="1.0" encoding="UTF-8"?>
|
|
<config>
|
|
<plate>
|
|
<metadata key="prediction" value="100" />
|
|
<metadata key="weight" value="not-a-number" />
|
|
</plate>
|
|
<plate>
|
|
<metadata key="prediction" value="200" />
|
|
<metadata key="weight" value="5.0" />
|
|
</plate>
|
|
</config>
|
|
"""
|
|
meta = ThreeMFParser(self._make_3mf_with_slice_info(slice_info_xml)).parse()
|
|
assert meta["print_time_seconds"] == 300
|
|
# Only the second plate's weight contributed.
|
|
assert meta["filament_used_grams"] == 5.0
|
|
|
|
|
|
class TestThreeMFParserSupportMaterial:
|
|
"""#1881: `_extract_filament_info` used to filter out support materials
|
|
(any slot where `filament_is_support == "1"`). That hid PVA / BVOH from
|
|
the archive card of unsliced source 3MFs — a PLA-model + PVA-support
|
|
project looked single-material until the print completed. This class
|
|
covers the follow-up: support materials must be included in
|
|
`filament_type` / `filament_color`.
|
|
"""
|
|
|
|
@staticmethod
|
|
def _make_3mf_with_project_settings(project_settings: dict) -> str:
|
|
import json
|
|
import os
|
|
import tempfile
|
|
import zipfile
|
|
|
|
fd, path = tempfile.mkstemp(suffix=".3mf")
|
|
os.close(fd)
|
|
with zipfile.ZipFile(path, "w", zipfile.ZIP_DEFLATED) as zf:
|
|
zf.writestr("3D/3dmodel.model", "<model/>")
|
|
zf.writestr("Metadata/project_settings.config", json.dumps(project_settings))
|
|
return path
|
|
|
|
def test_support_filament_included_on_source_3mf(self):
|
|
# Reporter's exact config: PLA model + PVA support. Both must show
|
|
# on the archive card badge, in slot order.
|
|
from backend.app.services.archive import ThreeMFParser
|
|
|
|
path = self._make_3mf_with_project_settings(
|
|
{
|
|
"filament_type": ["PLA", "PVA"],
|
|
"filament_colour": ["#FFFFFF", "#00AA00"],
|
|
"filament_is_support": ["0", "1"],
|
|
}
|
|
)
|
|
meta = ThreeMFParser(path).parse()
|
|
assert meta["filament_type"] == "PLA, PVA"
|
|
assert meta["filament_color"] == "#FFFFFF,#00AA00"
|
|
|
|
def test_single_support_material_still_populated(self):
|
|
# Degenerate case: only material configured happens to be marked
|
|
# support. Old fallback picked the first entry; new logic keeps
|
|
# the same shape.
|
|
from backend.app.services.archive import ThreeMFParser
|
|
|
|
path = self._make_3mf_with_project_settings(
|
|
{
|
|
"filament_type": ["PVA"],
|
|
"filament_colour": ["#FFFFFF"],
|
|
"filament_is_support": ["1"],
|
|
}
|
|
)
|
|
meta = ThreeMFParser(path).parse()
|
|
assert meta["filament_type"] == "PVA"
|
|
assert meta["filament_color"] == "#FFFFFF"
|
|
|
|
def test_duplicate_material_types_deduped(self):
|
|
# Two AMS slots both PLA of different colours: type list dedupes
|
|
# but colour list keeps both (multi-colour print).
|
|
from backend.app.services.archive import ThreeMFParser
|
|
|
|
path = self._make_3mf_with_project_settings(
|
|
{
|
|
"filament_type": ["PLA", "PLA"],
|
|
"filament_colour": ["#FFFFFF", "#000000"],
|
|
"filament_is_support": ["0", "0"],
|
|
}
|
|
)
|
|
meta = ThreeMFParser(path).parse()
|
|
assert meta["filament_type"] == "PLA"
|
|
assert meta["filament_color"] == "#FFFFFF,#000000"
|
|
|
|
|
|
class TestThreeMFLayerHeightFromPlateGcode:
|
|
"""The plate's G-code outranks project_settings.config on layer height.
|
|
|
|
Reported against a print sliced at 0.08 that archived as 0.2: the card
|
|
reads ``project_settings.config``, which is the *project's* record and can
|
|
still describe another plate or an earlier process, while the plate G-code
|
|
is the file the printer actually runs. The value sits in the CONFIG_BLOCK
|
|
14-25KB into the G-code, past the 4KB header window this parser used to
|
|
read, so the disagreement had no way to surface.
|
|
"""
|
|
|
|
@staticmethod
|
|
def _plate_gcode(layer_height: str, total_layers: int) -> str:
|
|
# Mirrors a real Bambu plate: header block first, then the alphabetical
|
|
# config block, which is what pushes layer_height past 4KB.
|
|
filler = "".join(f"; config_filler_{i} = {i}\n" for i in range(1200))
|
|
return (
|
|
"; HEADER_BLOCK_START\n"
|
|
f"; total layer number: {total_layers}\n"
|
|
"; HEADER_BLOCK_END\n"
|
|
"; CONFIG_BLOCK_START\n"
|
|
f"{filler}"
|
|
"; independent_support_layer_height = 0\n"
|
|
f"; layer_height = {layer_height}\n"
|
|
"; CONFIG_BLOCK_END\n"
|
|
"G1 X0 Y0\n"
|
|
)
|
|
|
|
def _write(self, path, *, config_layer_height, plates):
|
|
import json
|
|
import zipfile
|
|
|
|
with zipfile.ZipFile(path, "w") as zf:
|
|
zf.writestr("3D/3dmodel.model", "<model/>")
|
|
zf.writestr(
|
|
"Metadata/project_settings.config",
|
|
json.dumps({"layer_height": config_layer_height}),
|
|
)
|
|
for index, (layer_height, total_layers) in plates.items():
|
|
zf.writestr(
|
|
f"Metadata/plate_{index}.gcode",
|
|
self._plate_gcode(layer_height, total_layers),
|
|
)
|
|
return path
|
|
|
|
def test_gcode_wins_over_project_settings(self, tmp_path):
|
|
from backend.app.services.archive import ThreeMFParser
|
|
|
|
path = self._write(tmp_path / "sliced.3mf", config_layer_height="0.2", plates={1: ("0.08", 59)})
|
|
|
|
parsed = ThreeMFParser(path).parse()
|
|
|
|
assert parsed["layer_height"] == 0.08
|
|
assert parsed["total_layers"] == 59
|
|
|
|
def test_reads_the_plate_that_was_printed(self, tmp_path):
|
|
from backend.app.services.archive import ThreeMFParser
|
|
|
|
path = self._write(
|
|
tmp_path / "multi.3mf",
|
|
config_layer_height="0.2",
|
|
plates={1: ("0.2", 30), 2: ("0.08", 148)},
|
|
)
|
|
|
|
parsed = ThreeMFParser(path, plate_number=2).parse()
|
|
|
|
assert parsed["layer_height"] == 0.08
|
|
assert parsed["total_layers"] == 148
|
|
|
|
def test_falls_back_to_the_lowest_plate_when_none_was_named(self, tmp_path):
|
|
from backend.app.services.archive import ThreeMFParser
|
|
|
|
path = self._write(
|
|
tmp_path / "unnamed.3mf",
|
|
config_layer_height="0.2",
|
|
plates={2: ("0.08", 148), 1: ("0.2", 30)},
|
|
)
|
|
|
|
parsed = ThreeMFParser(path).parse()
|
|
|
|
assert parsed["layer_height"] == 0.2
|
|
assert parsed["total_layers"] == 30
|
|
|
|
def test_source_3mf_without_gcode_keeps_the_project_value(self, tmp_path):
|
|
import json
|
|
import zipfile
|
|
|
|
from backend.app.services.archive import ThreeMFParser
|
|
|
|
path = tmp_path / "source.3mf"
|
|
with zipfile.ZipFile(path, "w") as zf:
|
|
zf.writestr("3D/3dmodel.model", "<model/>")
|
|
zf.writestr("Metadata/project_settings.config", json.dumps({"layer_height": "0.28"}))
|
|
|
|
assert ThreeMFParser(path).parse()["layer_height"] == 0.28
|