mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-09-30 11:12:35 +02:00
The archive card, the library file details and the slice dialog all read the layer height from a 3MF's project_settings.config. That records the project's settings and can still describe an earlier process or another plate; the plate's own G-code - what the printer executes - was never consulted for it, because the parser read the first 4KB, enough for the layer count in the header block but not for the config block that carries layer_height 14-25KB in. A print running at 0.08 on the H2C archived as 0.2 with the layer count from the same file correct beside it. The plate G-code now wins wherever the two disagree, and the plate that was printed is the one read - the header parse used to take the first gcode entry in the zip regardless of which plate the archive was for. Source 3MFs, which carry no G-code, keep the project value as before. --- Stop carrying a file's layer height over the preset you picked Bambuddy carries a designer's process deviations across a re-slice (#2622) and pre-ticked every one that was not machine-coupled. layer_height is one MakerWorld projects routinely carry, so picking "0.08mm High Quality" for a file whose designer had moved layer height to 0.2 sliced at 0.2 while the dropdown still read 0.08 - the same 0.2 the settings panel showed, tagged "from file". Layer height and first layer height are now classified preset_defining and treated like the machine-coupled keys: offered, never pre-selected. The flag travels on DesignOverride so the modal and the backend agree, and the panel's badge names the conflict and shows the preset's own value next to the file's, so ticking one is a deliberate choice.
1111 lines
42 KiB
Python
1111 lines
42 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 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
|