Files
maziggy 7e77bf5833 Take the layer height from the plate that actually printed
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.
2026-08-18 16:23:32 +02:00

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 &amp; Bar`` and
React then escapes the `&` on render to ``Foo &amp;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 &amp; 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;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;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