diff --git a/CHANGELOG.md b/CHANGELOG.md index 9b5d9378a..d41b6d980 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,6 +34,8 @@ All notable changes to Bambuddy will be documented in this file. - **Push notification for "Printer offline" now actually fires (#1752, reported by @saint-hh)** — The notification provider's `on_printer_offline` toggle has shipped since the notifications feature landed: schema field, DB column, `notification_template.py` entry, and the dispatcher `NotificationService.on_printer_offline(printer_id, printer_name, db)` are all in place. What was missing was the caller — nothing in the codebase actually invoked the dispatcher when a printer went offline. The reporter (P2S, smart-plug-cuts-power scenario) confirmed turning the toggle on did nothing; only the print-failure notification fired when power was restored, via the firmware's `gcode_state=FAILED` report on MQTT reconnect. **Why the toggle was orphan:** every other provider event (`on_print_start`, `on_print_complete`, `on_print_progress`, `on_printer_error`, etc.) has a clear call site under `main.py::on_printer_status_change` or alongside the print-lifecycle hooks. The offline event was the only edge-triggered toggle without one — the dispatcher and template predated the wiring step and were silently shipped. Both upstream offline-trigger paths (`smart_plug_manager` → `printer_manager.mark_printer_offline()` and `bambu_mqtt.py::check_staleness` after the 30s STALE_RECONNECT_COOLDOWN) route through `_on_status_change` already and reach `on_printer_status_change`; the handler just didn't act on the disconnect edge. **Fix:** edge detection in `on_printer_status_change` watches `state.connected` against the previous observation per printer (`_printer_last_connected: dict[int, bool]`). On the True → False transition it schedules `_maybe_notify_printer_offline(printer_id)` as a background asyncio task; on the next True observation it cancels any pending task. The helper sleeps `_PRINTER_OFFLINE_NOTIFY_DEBOUNCE_SECONDS = 60.0` then re-checks `printer_manager.is_connected(printer_id)` — only fires the notification if the printer is still offline. **Why 60s debounce:** sized against `bambu_mqtt.py::STALE_RECONNECT_COOLDOWN = 30s` — a single stale-trigger + reconnect cycle isn't enough to fire, only a real outage that survives one full cooldown notifies. Transient MQTT blips (WiFi roam, broker reload, brief packet loss) recover within the window and the cancellation path kicks in. **Edge-case handling:** initial observation with no prior connected state doesn't fire (covers Bambuddy startup with an already-offline printer); a False → False repeat doesn't reschedule (the in-flight task stays in place rather than resetting the clock on every status callback, which would otherwise mean the notification never fires); the task entry pops from `_printer_offline_notify_tasks` in the finally block whether the notification fired, the printer reconnected, or the task was cancelled mid-await. **No symmetric `on_printer_online` event:** the reporter explicitly noted the "printer lost power and interrupted the print" notification already fires when power is restored — that's the print-failure notification, triggered by the firmware reporting `gcode_state=FAILED` for the interrupted print on MQTT reconnect. That covers the "printer is back" channel without a new toggle. If the user then resumes the print, no print_start notification fires (Bambuddy's `bambu_mqtt.py:3039` explicitly suppresses `is_new_print` for PAUSE → RUNNING to prevent duplicates when resuming from pause), but that's a separate scope from offline-detection. **Tests:** 9 new cases in `test_printer_offline_notification.py` split across two classes. `TestMaybeNotifyPrinterOffline` pins the debounced helper: fires notification when still offline at end of window, doesn't fire when printer reconnected during debounce, doesn't fire when the printer disappeared from the DB (uninstall mid-window), clears `_printer_offline_notify_tasks[printer_id]` after run. `TestOfflineEdgeDetection` pins the edge logic inside `on_printer_status_change`: first observation (connected) doesn't schedule, first observation (disconnected) doesn't schedule (the no-prior-True case — important for startup), True → False schedules a task, reconnect cancels the pending task, repeated False observations don't replace the in-flight task. Full backend suite still green; ruff clean. +- **Completion notification reported the whole project's duration and material usage when only one plate of a multi-plate 3MF was printed (#1785)** — Reporter (H2D) noticed the Discord on-print-complete message stated the full multi-plate project's totals (e.g. "6h 12m" / "370 g") even though only a single plate had been started, while every Bambuddy surface (print queue card, archive card, statistics) correctly showed the per-plate values. Root cause traced to the 3MF parser at `services/archive.py:200-264`, which sums per-plate `prediction` (slicer time estimate) and `weight` across every `` of a multi-plate file and stores those file-level totals on the `PrintArchive.print_time_seconds` / `PrintArchive.filament_used_grams` columns. That summing was added by #1593 to fix the archive card under-reporting on multi-plate files, and is correct for the archive-level "whole project" headline. The queue UI already re-reads the 3MF per-plate at `print_queue.py:272-285` (using `extract_filament_usage_from_3mf` / `_extract_print_time_from_3mf`) and substitutes the plate's actual values — which is why everything inside Bambuddy displays per-plate correctly. The notification path at `main.py::_background_notifications` read the archive's columns and `extra_data.filament_slots` directly with **no plate-aware override**, so the dispatched template variables (`{{duration}}`, `{{filament_grams}}`, `{{filament_details}}`) consistently rendered the project-wide sum. For material grams this was unconditionally wrong on multi-plate single-plate prints; for duration it depended on whether the real elapsed `actual_time_seconds` was populated (the `actual_time_seconds or print_time_seconds` fallback chain only landed on the summed estimate when the timestamps weren't usable). **Fix:** new `_scope_notification_archive_data_to_plate(archive_data, file_path, plate_id, status, progress, base_dir)` helper in `main.py` mirrors what the queue UI does — when `plate_id` is set on the just-completed print, re-read the 3MF, sum the plate's `` entries for the actual grams, read the plate's `` for the estimate, and replace `archive_data["actual_filament_grams"]` + `archive_data["print_time_seconds"]` + `archive_data["filament_slots"]` accordingly. `notify_plate_id` is captured from the existing `_print_plate_ids` register at the same point that already pops it (around `main.py:4183`), so no extra bookkeeping is added to the print-start path — the queue dispatcher and direct-Print path both already register plate_id there. **Partial-print scaling preserved:** the helper applies the same `progress / 100` scale factor to the per-plate grams + per-slot weights as the pre-existing summed-totals branch did, so a 50%-cancelled plate-2 print still reports "half the plate's grams," not the whole plate. **Fail-open on every error path:** missing `plate_id` (single-plate file / archive-only flow), missing `archive.file_path`, the 3MF file having been deleted between print completion and notification firing, a corrupt zip, or a plate index outside the file's range — all return `archive_data` unchanged so the notification still ships with the project-level numbers it would have shown before this fix. Same defensiveness shape as the helper-loaders the queue route relies on. **Hoisted `extract_print_time_from_3mf` into `utils/threemf_tools.py`** so the notification path can reuse the queue UI's logic without importing from a routes module (the route's `_extract_print_time_from_3mf` becomes a one-line alias). Identical signature + return shape, so the queue's existing call sites keep working without changes. **Tests:** 10 new cases. `test_threemf_tools.py::TestExtractPrintTimeFrom3mf` (7 cases): plate-N prediction returned for plate_id=N, first plate when no plate_id passed, None for plate_id outside range, None for unparseable prediction, None for missing slice_info / invalid zip / missing file. `test_notification_plate_scope.py::TestScopeNotificationArchiveDataToPlate` (10 cases): plate-2 of 3 collapses summed 370g/3h into plate's 120g/60min; plate-1 and plate-3 scope correctly; partial-print at progress=50 halves grams + per-slot weights but keeps full slicer estimate; no plate_id / no file_path / missing file / corrupt zip / out-of-range plate_id all return the input unchanged so the notification still sends; single-plate file with plate_id=1 is a clean no-op (the parser's sum already collapses to plate-1's values, no double-scaling). Full backend `pytest -n 30` 4086/4086 in 50s; ruff clean. **Scope clarification:** the archive card / project rollup / statistics surfaces stay on the summed totals (the original #1593 contract) — only the completion notification path now plate-scopes, mirroring the queue card precedent. Print Logs entries continue to use the per-run filament helper (#1378 / #1390) which already reads from `usage_results` + scales by progress, so this fix doesn't touch them. + ## [0.2.4.7] - 2026-06-14 ### Added diff --git a/backend/app/api/routes/print_queue.py b/backend/app/api/routes/print_queue.py index f294976db..b58e79488 100644 --- a/backend/app/api/routes/print_queue.py +++ b/backend/app/api/routes/print_queue.py @@ -38,7 +38,11 @@ from backend.app.schemas.print_queue import ( from backend.app.services.filament_deficit import compute_deficit_for_queue_item from backend.app.services.notification_service import notification_service from backend.app.utils.printer_models import normalize_printer_model, normalize_printer_model_id -from backend.app.utils.threemf_tools import extract_bed_type_from_3mf, extract_filament_usage_from_3mf +from backend.app.utils.threemf_tools import ( + extract_bed_type_from_3mf, + extract_filament_usage_from_3mf, + extract_print_time_from_3mf, +) logger = logging.getLogger(__name__) @@ -106,56 +110,10 @@ def _extract_filament_types_from_3mf(file_path: Path, plate_id: int | None = Non return sorted(types) -def _extract_print_time_from_3mf(file_path: Path, plate_id: int | None = None) -> int | None: - """Extract print time (prediction) from a 3MF file. - - Args: - file_path: Path to the 3MF file - plate_id: Optional plate index to filter for (for multi-plate files) - - Returns: - Print time in seconds, or None if not found - """ - try: - with zipfile.ZipFile(file_path, "r") as zf: - if "Metadata/slice_info.config" not in zf.namelist(): - return None - - content = zf.read("Metadata/slice_info.config").decode() - root = ET.fromstring(content) - - if plate_id is not None: - for plate_elem in root.findall(".//plate"): - plate_index = None - for meta in plate_elem.findall("metadata"): - if meta.get("key") == "index": - try: - plate_index = int(meta.get("value", "0")) - except ValueError: - pass # Skip plate with unparseable index - break - - if plate_index == plate_id: - for meta in plate_elem.findall("metadata"): - if meta.get("key") == "prediction": - try: - return int(meta.get("value", "0")) - except ValueError: - return None - break - else: - plate_elem = root.find(".//plate") - if plate_elem is not None: - for meta in plate_elem.findall("metadata"): - if meta.get("key") == "prediction": - try: - return int(meta.get("value", "0")) - except ValueError: - return None - except Exception as e: - logger.warning("Failed to extract print time from %s: %s", file_path, e) - - return None +# Local alias kept so existing call sites stay compact; the implementation lives +# in utils/threemf_tools.py so the notification path (main.py) can reuse it +# without importing from a routes module (#1785). +_extract_print_time_from_3mf = extract_print_time_from_3mf def _enrich_response(item: PrintQueueItem) -> PrintQueueItemResponse: diff --git a/backend/app/main.py b/backend/app/main.py index 11b3d76a2..ccfa5ace0 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -691,6 +691,82 @@ def _get_start_plate_id(archive_id: int | None) -> int | None: return _print_plate_ids.get(archive_id) +def _partial_progress_scale(progress: int | float | None) -> float: + """Clamp ``progress / 100`` into [0.0, 1.0] for partial-print scaling. + + Used by every site that multiplies a "would-have-used" slicer estimate + down to "actually-used" for failed / cancelled / stopped prints. Centralised + so the three sites in ``_background_notifications`` (and the per-plate + override helper) can't drift apart on the coercion shape. + """ + return max(0.0, min((progress or 0) / 100.0, 1.0)) + + +def _scope_notification_archive_data_to_plate( + archive_data: dict, + archive_file_path: str | None, + plate_id: int | None, + print_status: str, + progress: int | float | None, + base_dir: Path, +) -> dict: + """Override summed-across-plates totals in ``archive_data`` with the values + for ``plate_id`` so the completion notification reports what was actually + printed, not the whole project (#1785). + + The 3MF parser at services/archive.py:200-264 sums ``prediction`` and + ``weight`` across every plate of a multi-plate file (#1593) — correct for + the archive card's "whole project" headline, wrong for the completion + notification of a single-plate print. The queue UI already re-reads the + 3MF per-plate at print_queue.py:272-285; this helper mirrors that for the + notification payload (filament grams, time estimate, per-slot breakdown). + + No-ops when ``plate_id`` is None, the file is missing, or the 3MF carries + no per-plate values — in every fail case the original ``archive_data`` is + returned unchanged so the notification still sends. + """ + if plate_id is None or not archive_file_path: + return archive_data + + from backend.app.utils.threemf_tools import ( + extract_filament_usage_from_3mf, + extract_print_time_from_3mf, + ) + + archive_path = base_dir / archive_file_path + if not archive_path.exists(): + return archive_data + + plate_slots = extract_filament_usage_from_3mf(archive_path, plate_id) + plate_grams = sum(f.get("used_g", 0) for f in plate_slots) + plate_time = extract_print_time_from_3mf(archive_path, plate_id) + + scale = 1.0 if print_status == "completed" else _partial_progress_scale(progress) + + if plate_time: + archive_data["print_time_seconds"] = plate_time + + # Gate both the grams headline AND the per-slot breakdown on the same + # `plate_grams > 0` signal: if the 3MF carries per-plate filament rows but + # they all sum to zero (slicer bug / re-slice without estimate), drop back + # to the project-level grams the archive columns already provide rather + # than ship a project-level headline next to an all-zero per-plate + # breakdown. + if plate_grams > 0: + archive_data["actual_filament_grams"] = round(plate_grams * scale, 1) + archive_data["filament_slots"] = [ + { + "slot_id": s.get("slot_id"), + "used_g": round((s.get("used_g") or 0) * scale, 1), + "type": s.get("type", ""), + "color": s.get("color", ""), + } + for s in plate_slots + ] + + return archive_data + + def _extract_filament_data_from_mqtt(data: dict, ams_mapping: list[int] | None = None) -> dict[str, str]: """Best-effort filament metadata from the MQTT print-start snapshot. @@ -4179,8 +4255,12 @@ async def on_print_complete(printer_id: int, data: dict): # Always drain the plate_id register on completion — the session already # consumed it at print-start injection; leaving it would leak into the next # print on the same archive_id (rare but possible with reprints) (#1697). + # Capture the popped value so the completion notification can scope the + # archive-level (summed-across-plates per #1593) filament + time totals + # down to the single plate that was actually printed (#1785). + notify_plate_id: int | None = None if archive_id: - _print_plate_ids.pop(archive_id, None) + notify_plate_id = _print_plate_ids.pop(archive_id, None) # Internal inventory: track AMS remain% deltas (skip if Spoolman handles usage) try: @@ -4733,7 +4813,7 @@ async def on_print_complete(printer_id: int, data: dict): # Scale filament usage for partial prints if print_status != "completed" and archive.filament_used_grams: progress = data.get("progress") or 0 - scale = max(0.0, min(progress / 100.0, 1.0)) + scale = _partial_progress_scale(progress) archive_data["actual_filament_grams"] = round(archive.filament_used_grams * scale, 1) archive_data["progress"] = progress @@ -4741,10 +4821,22 @@ async def on_print_complete(printer_id: int, data: dict): if archive.extra_data and archive.extra_data.get("filament_slots"): slots = archive.extra_data["filament_slots"] if print_status != "completed": - scale = max(0.0, min((data.get("progress") or 0) / 100.0, 1.0)) + scale = _partial_progress_scale(data.get("progress")) slots = [{**s, "used_g": round(s["used_g"] * scale, 1)} for s in slots] archive_data["filament_slots"] = slots + # Scope project-summed totals down to the plate that was + # actually printed — see _scope_notification_archive_data_to_plate + # for the why (#1785). + archive_data = _scope_notification_archive_data_to_plate( + archive_data, + archive.file_path, + notify_plate_id, + print_status, + data.get("progress"), + app_settings.base_dir, + ) + # Enrich filament_grams from usage_results when archive has no 3MF data if not archive_data.get("actual_filament_grams") and usage_results: total_from_usage = sum(r.get("weight_used", 0) for r in usage_results) diff --git a/backend/app/utils/threemf_tools.py b/backend/app/utils/threemf_tools.py index dcf57d141..2fae2b97a 100644 --- a/backend/app/utils/threemf_tools.py +++ b/backend/app/utils/threemf_tools.py @@ -485,6 +485,64 @@ def extract_filament_usage_from_3mf(file_path: Path, plate_id: int | None = None return filament_usage +def extract_print_time_from_3mf(file_path: Path, plate_id: int | None = None) -> int | None: + """Extract the slicer's predicted print time from a 3MF's slice_info.config. + + Multi-plate 3MFs carry one ```` + per plate. The archive-level `print_time_seconds` is the sum across all plates + (see services/archive.py:200-264, #1593). For per-plate UI / notifications, + callers re-read the 3MF and request the specific plate's value via this helper. + + Args: + file_path: Path to the 3MF file + plate_id: Plate index to filter for; if None, returns the first plate's + ``prediction`` (matches the legacy single-plate read). + + Returns: + Predicted print time in seconds, or None if not found / unparseable. + """ + try: + with zipfile.ZipFile(file_path, "r") as zf: + if "Metadata/slice_info.config" not in zf.namelist(): + return None + + content = zf.read("Metadata/slice_info.config").decode() + root = ET.fromstring(content) + + if plate_id is not None: + for plate_elem in root.findall(".//plate"): + plate_index = None + for meta in plate_elem.findall("metadata"): + if meta.get("key") == "index": + try: + plate_index = int(meta.get("value", "0")) + except ValueError: + pass # Skip plate with unparseable index + break + + if plate_index == plate_id: + for meta in plate_elem.findall("metadata"): + if meta.get("key") == "prediction": + try: + return int(meta.get("value", "0")) + except ValueError: + return None + break + else: + plate_elem = root.find(".//plate") + if plate_elem is not None: + for meta in plate_elem.findall("metadata"): + if meta.get("key") == "prediction": + try: + return int(meta.get("value", "0")) + except ValueError: + return None + except Exception as e: + logger.warning("Failed to extract print time from %s: %s", file_path, e) + + return None + + def extract_bed_type_from_3mf(file_path: Path, plate_id: int | None = None) -> str | None: """Extract the build plate type (`curr_bed_type`) for a specific plate (#1281). diff --git a/backend/tests/unit/test_notification_plate_scope.py b/backend/tests/unit/test_notification_plate_scope.py new file mode 100644 index 000000000..c8a010372 --- /dev/null +++ b/backend/tests/unit/test_notification_plate_scope.py @@ -0,0 +1,326 @@ +"""Unit tests for `_scope_notification_archive_data_to_plate` (#1785). + +The 3MF parser at services/archive.py:200-264 sums per-plate `prediction` and +`weight` into archive-level totals (#1593) — correct for the archive card's +"whole project" headline, wrong for the completion notification of a single +plate. The helper under test mirrors what the queue UI does at +print_queue.py:272-285: re-read the 3MF and substitute the plate's actual +values for filament grams, time estimate, and per-slot breakdown. +""" + +import io +import zipfile + +from backend.app.main import _scope_notification_archive_data_to_plate + + +def _write_multi_plate_3mf(tmp_path, name="multi.3mf") -> "tuple": + """Create a 3-plate 3MF with distinct prediction + weight per plate. + + Plate 1: 30 min, 50g PLA + Plate 2: 60 min, 120g PETG + Plate 3: 90 min, 200g PLA + """ + xml_content = """ + + + + + + + + + + + + + + + + + + + + + + """ + buffer = io.BytesIO() + with zipfile.ZipFile(buffer, "w") as zf: + zf.writestr("Metadata/slice_info.config", xml_content) + buffer.seek(0) + + file_path = tmp_path / name + file_path.write_bytes(buffer.read()) + return file_path, "multi.3mf" + + +def _project_totals_archive_data() -> dict: + """Pre-fix archive_data as `_background_notifications` constructs it: the + summed-across-plates totals from PrintArchive's columns and extra_data.""" + return { + # Summed: 30 + 60 + 90 min = 180 min = 10800s + "print_time_seconds": 10800, + "actual_time_seconds": None, + # Summed: 50 + 120 + 200 = 370g + "actual_filament_grams": 370.0, + # Summed across all 3 plates' filament rows + "filament_slots": [ + {"slot_id": 1, "used_g": 330.0, "type": "PLA", "color": "#FF0000"}, + {"slot_id": 2, "used_g": 40.0, "type": "PETG", "color": "#0000FF"}, + ], + } + + +class TestScopeNotificationArchiveDataToPlate: + def test_completed_plate_replaces_summed_totals(self, tmp_path): + # The bug: notification shows project totals (370g, 3h) when only + # plate 2 was printed. Expected after fix: plate 2's 120g and 60 min. + file_path, rel = _write_multi_plate_3mf(tmp_path) + archive_data = _project_totals_archive_data() + + result = _scope_notification_archive_data_to_plate( + archive_data, + rel, + plate_id=2, + print_status="completed", + progress=100, + base_dir=tmp_path, + ) + + assert result["actual_filament_grams"] == 120.0 + assert result["print_time_seconds"] == 3600 + assert result["filament_slots"] == [ + {"slot_id": 1, "used_g": 80.0, "type": "PETG", "color": "#00FF00"}, + {"slot_id": 2, "used_g": 40.0, "type": "PETG", "color": "#0000FF"}, + ] + + def test_plate_1_scoping_works(self, tmp_path): + file_path, rel = _write_multi_plate_3mf(tmp_path) + archive_data = _project_totals_archive_data() + + result = _scope_notification_archive_data_to_plate( + archive_data, + rel, + plate_id=1, + print_status="completed", + progress=100, + base_dir=tmp_path, + ) + + assert result["actual_filament_grams"] == 50.0 + assert result["print_time_seconds"] == 1800 + + def test_plate_3_scoping_works(self, tmp_path): + file_path, rel = _write_multi_plate_3mf(tmp_path) + archive_data = _project_totals_archive_data() + + result = _scope_notification_archive_data_to_plate( + archive_data, + rel, + plate_id=3, + print_status="completed", + progress=100, + base_dir=tmp_path, + ) + + assert result["actual_filament_grams"] == 200.0 + assert result["print_time_seconds"] == 5400 + + def test_partial_print_scales_plate_values(self, tmp_path): + # Plate 2 cancelled at 50%: expect half the plate's grams + per-slot + # values scaled, but full slicer estimate kept (callers display this + # alongside the partial actual_filament_grams). + file_path, rel = _write_multi_plate_3mf(tmp_path) + archive_data = _project_totals_archive_data() + + result = _scope_notification_archive_data_to_plate( + archive_data, + rel, + plate_id=2, + print_status="cancelled", + progress=50, + base_dir=tmp_path, + ) + + assert result["actual_filament_grams"] == 60.0 + assert result["print_time_seconds"] == 3600 + assert result["filament_slots"] == [ + {"slot_id": 1, "used_g": 40.0, "type": "PETG", "color": "#00FF00"}, + {"slot_id": 2, "used_g": 20.0, "type": "PETG", "color": "#0000FF"}, + ] + + def test_no_plate_id_returns_unchanged(self, tmp_path): + # Single-plate prints or non-plate-scoped completions take the + # project-level archive values as-is. + file_path, rel = _write_multi_plate_3mf(tmp_path) + archive_data = _project_totals_archive_data() + before = {**archive_data, "filament_slots": list(archive_data["filament_slots"])} + + result = _scope_notification_archive_data_to_plate( + archive_data, + rel, + plate_id=None, + print_status="completed", + progress=100, + base_dir=tmp_path, + ) + + assert result["actual_filament_grams"] == before["actual_filament_grams"] + assert result["print_time_seconds"] == before["print_time_seconds"] + assert result["filament_slots"] == before["filament_slots"] + + def test_no_file_path_returns_unchanged(self, tmp_path): + archive_data = _project_totals_archive_data() + before = {**archive_data} + + result = _scope_notification_archive_data_to_plate( + archive_data, + None, + plate_id=2, + print_status="completed", + progress=100, + base_dir=tmp_path, + ) + + assert result["actual_filament_grams"] == before["actual_filament_grams"] + assert result["print_time_seconds"] == before["print_time_seconds"] + + def test_missing_3mf_returns_unchanged(self, tmp_path): + # Archive's file may have been deleted (manual cleanup) between print + # completion and the notification firing — must not blow up the + # notification, just send the project-level numbers we already have. + archive_data = _project_totals_archive_data() + before = {**archive_data} + + result = _scope_notification_archive_data_to_plate( + archive_data, + "missing.3mf", + plate_id=2, + print_status="completed", + progress=100, + base_dir=tmp_path, + ) + + assert result["actual_filament_grams"] == before["actual_filament_grams"] + assert result["print_time_seconds"] == before["print_time_seconds"] + + def test_corrupt_3mf_returns_unchanged(self, tmp_path): + # Invalid file at the right path: helper falls back gracefully. + bad_path = tmp_path / "bad.3mf" + bad_path.write_text("not a zip file") + + archive_data = _project_totals_archive_data() + before = {**archive_data} + + result = _scope_notification_archive_data_to_plate( + archive_data, + "bad.3mf", + plate_id=2, + print_status="completed", + progress=100, + base_dir=tmp_path, + ) + + assert result["actual_filament_grams"] == before["actual_filament_grams"] + assert result["print_time_seconds"] == before["print_time_seconds"] + + def test_plate_id_outside_range_returns_unchanged(self, tmp_path): + # Defensive: if plate_id doesn't match any plate in the 3MF, leave the + # project-level numbers alone rather than emitting zeros. + file_path, rel = _write_multi_plate_3mf(tmp_path) + archive_data = _project_totals_archive_data() + before = {**archive_data} + + result = _scope_notification_archive_data_to_plate( + archive_data, + rel, + plate_id=99, + print_status="completed", + progress=100, + base_dir=tmp_path, + ) + + assert result["actual_filament_grams"] == before["actual_filament_grams"] + assert result["print_time_seconds"] == before["print_time_seconds"] + + def test_zero_grams_plate_keeps_project_level_breakdown(self, tmp_path): + # Defensive: a 3MF that emits per-plate filament rows summing to zero + # (slicer bug / re-slice without estimate) must NOT clobber the + # project-level grams + per-slot breakdown the archive columns already + # provide — otherwise the notification would headline "370 g" next to + # an all-zero per-slot breakdown. + xml_content = """ + + + + + + + + + """ + buffer = io.BytesIO() + with zipfile.ZipFile(buffer, "w") as zf: + zf.writestr("Metadata/slice_info.config", xml_content) + buffer.seek(0) + file_path = tmp_path / "zero.3mf" + file_path.write_bytes(buffer.read()) + + archive_data = _project_totals_archive_data() + before_slots = list(archive_data["filament_slots"]) + before_grams = archive_data["actual_filament_grams"] + + result = _scope_notification_archive_data_to_plate( + archive_data, + "zero.3mf", + plate_id=1, + print_status="completed", + progress=100, + base_dir=tmp_path, + ) + + # Time still scopes (prediction parsed cleanly). + assert result["print_time_seconds"] == 1800 + # Grams + per-slot breakdown stay on project-level so the notification + # doesn't ship an inconsistent headline. + assert result["actual_filament_grams"] == before_grams + assert result["filament_slots"] == before_slots + + def test_single_plate_file_with_plate_id_1(self, tmp_path): + # Single-plate 3MF where queue still has plate_id=1 set: the parser's + # "sum across plates" already collapses to plate 1's values, so the + # helper just confirms (no double-scaling, no field clobber). + xml_content = """ + + + + + + + + + """ + buffer = io.BytesIO() + with zipfile.ZipFile(buffer, "w") as zf: + zf.writestr("Metadata/slice_info.config", xml_content) + buffer.seek(0) + file_path = tmp_path / "single.3mf" + file_path.write_bytes(buffer.read()) + + archive_data = { + "print_time_seconds": 2400, + "actual_time_seconds": None, + "actual_filament_grams": 75.0, + } + + result = _scope_notification_archive_data_to_plate( + archive_data, + "single.3mf", + plate_id=1, + print_status="completed", + progress=100, + base_dir=tmp_path, + ) + + assert result["actual_filament_grams"] == 75.0 + assert result["print_time_seconds"] == 2400 diff --git a/backend/tests/unit/test_threemf_tools.py b/backend/tests/unit/test_threemf_tools.py index c7b072fef..43cc210d6 100644 --- a/backend/tests/unit/test_threemf_tools.py +++ b/backend/tests/unit/test_threemf_tools.py @@ -14,6 +14,7 @@ from backend.app.utils.threemf_tools import ( extract_embedded_presets_from_3mf, extract_filament_usage_from_3mf, extract_plate_extruder_set_from_3mf, + extract_print_time_from_3mf, extract_project_filaments_from_3mf, get_cumulative_usage_at_layer, mm_to_grams, @@ -841,3 +842,102 @@ class TestExtractBedTypeFrom3mf: file_path.write_bytes(create_mock_3mf(xml_content).read()) assert extract_bed_type_from_3mf(file_path) == "Textured PEI Plate" + + +class TestExtractPrintTimeFrom3mf: + """Tests for extract_print_time_from_3mf — the per-plate `prediction` reader + used by the completion notification path to scope the archive-level (summed) + total down to the actually-printed plate (#1785).""" + + def test_returns_plate_prediction_when_plate_id_matches(self, tmp_path): + xml_content = """ + + + + + + + + + + + + + + + """ + file_path = tmp_path / "test.3mf" + file_path.write_bytes(create_mock_3mf(xml_content).read()) + + assert extract_print_time_from_3mf(file_path, plate_id=2) == 7200 + + def test_returns_first_plate_when_no_plate_id(self, tmp_path): + xml_content = """ + + + + + + + + + + + """ + file_path = tmp_path / "test.3mf" + file_path.write_bytes(create_mock_3mf(xml_content).read()) + + assert extract_print_time_from_3mf(file_path) == 900 + + def test_returns_none_when_plate_id_missing(self, tmp_path): + xml_content = """ + + + + + + + """ + file_path = tmp_path / "test.3mf" + file_path.write_bytes(create_mock_3mf(xml_content).read()) + + assert extract_print_time_from_3mf(file_path, plate_id=5) is None + + def test_returns_none_when_prediction_unparseable(self, tmp_path): + xml_content = """ + + + + + + + """ + file_path = tmp_path / "test.3mf" + file_path.write_bytes(create_mock_3mf(xml_content).read()) + + assert extract_print_time_from_3mf(file_path, plate_id=1) is None + + def test_returns_none_when_slice_info_missing(self, tmp_path): + 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()) + + assert extract_print_time_from_3mf(file_path) is None + assert extract_print_time_from_3mf(file_path, plate_id=1) is None + + def test_returns_none_when_file_invalid(self, tmp_path): + file_path = tmp_path / "invalid.3mf" + file_path.write_text("not a zip file") + + assert extract_print_time_from_3mf(file_path) is None + assert extract_print_time_from_3mf(file_path, plate_id=1) is None + + def test_returns_none_when_file_missing(self, tmp_path): + file_path = tmp_path / "nonexistent.3mf" + + assert extract_print_time_from_3mf(file_path) is None + assert extract_print_time_from_3mf(file_path, plate_id=2) is None