Files
bambuddy/backend/tests/unit/services/test_archive_service.py
T
maziggy 96ce403554 fix(archive): HTML-unescape 3MF Title metadata; correct VP name tooltip (#1658)
ThreeMFParser._parse_3dmodel left XML-escaped values raw, so a Title of
  "PCB Vise & Solder Station" landed in the DB as the literal "&" and
  React re-escaped it on render to "&". Apply the same
  loop-until-stable html.unescape() the sibling ProjectPageParser already
  uses, uniformly across all <metadata> values.

  Same drop: rewrite the VP archive-name-source tooltip in all 11 locales.
  BambuStudio 2.7.x (PrintJob.cpp:314-325) overwrites the user-typed
  Send-dialog name with the slugified 3MF Title field whenever one is
  present, so the previous "handy if you renamed the job in the send dialog"
  copy was false. New text spells out the actual behavior; both Filename
  and Metadata modes often produce the same string for that reason.
2026-06-07 11:43:19 +02:00

941 lines
35 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