diff --git a/backend/tests/unit/test_threemf_tools.py b/backend/tests/unit/test_threemf_tools.py
index b07bd749d..bacbed307 100644
--- a/backend/tests/unit/test_threemf_tools.py
+++ b/backend/tests/unit/test_threemf_tools.py
@@ -4,15 +4,27 @@ Tests G-code parsing, filament length-to-weight conversion,
and cumulative layer usage lookup.
"""
+import io
import math
+import zipfile
from backend.app.utils.threemf_tools import (
+ extract_filament_usage_from_3mf,
get_cumulative_usage_at_layer,
mm_to_grams,
parse_gcode_layer_filament_usage,
)
+def create_mock_3mf(slice_info_content: str) -> io.BytesIO:
+ """Create a mock 3MF file (ZIP) with slice_info.config content."""
+ buffer = io.BytesIO()
+ with zipfile.ZipFile(buffer, "w") as zf:
+ zf.writestr("Metadata/slice_info.config", slice_info_content)
+ buffer.seek(0)
+ return buffer
+
+
class TestParseGcodeLayerFilamentUsage:
"""Tests for parse_gcode_layer_filament_usage()."""
@@ -247,3 +259,152 @@ class TestGetCumulativeUsageAtLayer:
"""Target layer 0."""
data = {0: {0: 10.0}, 1: {0: 20.0}}
assert get_cumulative_usage_at_layer(data, 0) == {0: 10.0}
+
+
+class TestExtractFilamentUsageFrom3mf:
+ """Tests for extract_filament_usage_from_3mf function."""
+
+ def test_extract_single_filament(self, tmp_path):
+ """Test extracting a single filament."""
+ xml_content = """
+
+
+
+ """
+ mock_3mf = create_mock_3mf(xml_content)
+ file_path = tmp_path / "test.3mf"
+ file_path.write_bytes(mock_3mf.read())
+
+ result = extract_filament_usage_from_3mf(file_path)
+
+ assert len(result) == 1
+ assert result[0]["slot_id"] == 1
+ assert result[0]["used_g"] == 50.5
+ assert result[0]["type"] == "PLA"
+ assert result[0]["color"] == "#FF0000"
+
+ def test_extract_multiple_filaments(self, tmp_path):
+ """Test extracting multiple filaments."""
+ xml_content = """
+
+
+
+
+
+ """
+ mock_3mf = create_mock_3mf(xml_content)
+ file_path = tmp_path / "test.3mf"
+ file_path.write_bytes(mock_3mf.read())
+
+ result = extract_filament_usage_from_3mf(file_path)
+
+ assert len(result) == 3
+ assert result[0]["slot_id"] == 1
+ assert result[1]["slot_id"] == 2
+ assert result[2]["slot_id"] == 3
+
+ def test_extract_filament_with_plate_id(self, tmp_path):
+ """Test extracting filament for a specific plate."""
+ xml_content = """
+
+
+
+
+
+
+
+
+
+
+ """
+ mock_3mf = create_mock_3mf(xml_content)
+ file_path = tmp_path / "test.3mf"
+ file_path.write_bytes(mock_3mf.read())
+
+ result = extract_filament_usage_from_3mf(file_path, plate_id=2)
+
+ assert len(result) == 1
+ assert result[0]["used_g"] == 75.0
+ assert result[0]["type"] == "PETG"
+
+ def test_missing_slice_info_returns_empty(self, tmp_path):
+ """Test that missing slice_info.config returns empty list."""
+ buffer = io.BytesIO()
+ with zipfile.ZipFile(buffer, "w") as zf:
+ zf.writestr("other_file.txt", "content")
+ buffer.seek(0)
+
+ file_path = tmp_path / "test.3mf"
+ file_path.write_bytes(buffer.read())
+
+ result = extract_filament_usage_from_3mf(file_path)
+
+ assert result == []
+
+ def test_invalid_file_returns_empty(self, tmp_path):
+ """Test that invalid file returns empty list."""
+ file_path = tmp_path / "invalid.3mf"
+ file_path.write_text("not a zip file")
+
+ result = extract_filament_usage_from_3mf(file_path)
+
+ assert result == []
+
+ def test_nonexistent_file_returns_empty(self, tmp_path):
+ """Test that nonexistent file returns empty list."""
+ file_path = tmp_path / "nonexistent.3mf"
+
+ result = extract_filament_usage_from_3mf(file_path)
+
+ assert result == []
+
+ def test_filament_without_id_is_skipped(self, tmp_path):
+ """Test that filament without id is skipped."""
+ xml_content = """
+
+
+
+
+ """
+ mock_3mf = create_mock_3mf(xml_content)
+ file_path = tmp_path / "test.3mf"
+ file_path.write_bytes(mock_3mf.read())
+
+ result = extract_filament_usage_from_3mf(file_path)
+
+ assert len(result) == 1
+ assert result[0]["slot_id"] == 2
+
+ def test_invalid_used_g_is_skipped(self, tmp_path):
+ """Test that filament with invalid used_g is skipped."""
+ xml_content = """
+
+
+
+
+ """
+ mock_3mf = create_mock_3mf(xml_content)
+ file_path = tmp_path / "test.3mf"
+ file_path.write_bytes(mock_3mf.read())
+
+ result = extract_filament_usage_from_3mf(file_path)
+
+ assert len(result) == 1
+ assert result[0]["slot_id"] == 2
+
+ def test_missing_optional_fields(self, tmp_path):
+ """Test that missing type and color default to empty string."""
+ xml_content = """
+
+
+
+ """
+ mock_3mf = create_mock_3mf(xml_content)
+ file_path = tmp_path / "test.3mf"
+ file_path.write_bytes(mock_3mf.read())
+
+ result = extract_filament_usage_from_3mf(file_path)
+
+ assert len(result) == 1
+ assert result[0]["type"] == ""
+ assert result[0]["color"] == ""