mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-07 23:01:06 +02:00
fix(inventory): archive filament colour follows the assigned spool, not the 3MF (#1494)
An archive's filament_color was parsed verbatim from the print job's 3MF (filament_colour in project_settings.config) — the slicer's filament-slot colour, which a user picks independently of the exact hex they curate on the Bambuddy inventory spool. So a print from a #000000 inventory spool showed #161616 (the slicer's near-black) in the archive card and the Color Distribution graph, even though usage tracking correctly decremented the right spool. Once usage tracking has resolved the print's filament slots to inventory spools, the spool colours are authoritative. _track_from_3mf (built-in inventory) and report_usage (Spoolman mode) now overwrite the archive's filament_color with the slot-ordered, de-duplicated colours of the matched spools. The rewrite is all-or-nothing: it only applies when every used slot resolved to a spool carrying a colour, so a partially-mapped multi-colour print keeps the 3MF colour rather than silently dropping the unmatched slots. Shipped for both inventory modes: built-in spools read Spool.rgba, Spoolman spools read the spool's filament.color_hex (fetched via get_spool for tag-less slot-assignment matches). New helpers _spool_color_to_hex / _archive_colors_from_spools in usage_tracker.py, reused by spoolman_tracking.py via _apply_spool_colors_to_archive. Tests: 12 new in test_usage_tracker.py (hex normalisation, the all-or-nothing rule across single/multi/partial/no-colour/AMS-fallback cases, end-to-end rewrite), 4 in test_spoolman_tracking.py (Spoolman rewrite + empty/partial/missing-archive no-ops). 70 tracking tests green; backend ruff clean.
This commit is contained in:
@@ -22,6 +22,7 @@ All notable changes to Bambuddy will be documented in this file.
|
||||
- **PyJWT CVE-2025-45768 (PYSEC-2025-183 / GHSA-65pc-fj4g-8rjx): permanently ignored in pip-audit** — Advisory is disputed by the PyJWT maintainers, with the advisory description literally noting *"this is disputed by the Supplier because the key length is chosen by the application that uses the library."* `fix_versions=[]` on the advisory confirms no PyJWT patch exists or will exist. Bambuddy is not affected: `backend/app/core/auth.py:184` auto-generates secrets via `secrets.token_urlsafe(64)` (~86 chars of entropy, far above any sane minimum) and the file-loaded path at `:177` rejects secrets shorter than 32 chars. Added a permanent `--ignore-vuln CVE-2025-45768` to `.github/workflows/security.yml` with an inline comment citing the file:line evidence so a future maintainer reviewing the ignore list sees why it's load-bearing. Also dropped the stale `--ignore-vuln CVE-2026-4539` for Pygments — Pygments has since shipped a patched version and the ignore is no longer load-bearing (verified: `pip-audit --ignore-vuln CVE-2025-45768` alone reports clean).
|
||||
|
||||
### Fixed
|
||||
- **Archive filament colour now reflects the assigned inventory spool, not the slicer's 3MF (#1494, reported by @IndividualGhost1905)** — A user added a `#000000` black filament to the built-in inventory, assigned it to the printer, and printed from the desktop slicer; the print, AMS and inventory all showed it as black and the correct spool's weight decremented — but the resulting archive (and the Color Distribution graph) showed `#161616`. Root cause is two independent colour sources: an archive's `filament_color` is parsed verbatim from the print job's 3MF (`archive.py` `_extract_filament_info` reads `filament_colour` from `project_settings.config`), which carries the *slicer's* filament-slot colour — a value the user picks separately from the exact hex they curate on the Bambuddy inventory spool. The two are "close but not equal" (slicer near-black `#161616` vs inventory `#000000`), which is exactly the "always a similar colour, never an unrelated one" pattern the report describes. **Fix**: once usage tracking has resolved the print's filament slots to inventory spools, the spool colours are authoritative — `_track_from_3mf` (built-in inventory) and `report_usage` (Spoolman mode) now overwrite the archive's `filament_color` with the slot-ordered, de-duplicated colours of the matched spools. The rewrite is **all-or-nothing**: it only applies when *every* used slot resolved to a spool that carries a colour, so a partially-mapped multi-colour print never silently loses the unmatched slots' colours (the 3MF value is kept). Shipped for both inventory modes in the same drop — built-in spools read `Spool.rgba`, Spoolman spools read the spool's `filament.color_hex` (fetched for tag-less slot-assignment matches). New helpers `_spool_color_to_hex` / `_archive_colors_from_spools` in `usage_tracker.py`, reused by `spoolman_tracking.py` via `_apply_spool_colors_to_archive`. **Tests**: 12 new in `test_usage_tracker.py` (hex normalisation, the all-or-nothing slot-colour rule across single/multi/partial/no-colour/AMS-fallback cases, and end-to-end that a `#000000` spool rewrites a `#161616` archive) + 4 in `test_spoolman_tracking.py` (the Spoolman-mode rewrite, empty/partial/missing-archive no-ops). 70 usage + Spoolman tracking tests green; backend ruff clean.
|
||||
- **Re-slicing across the single-nozzle / dual-nozzle boundary now fails fast with a clear message** — Re-slicing a model that was sliced for a single-nozzle printer (X1C, P1S, A1, …) onto a dual-nozzle printer (H2D / H2D Pro), or vice versa, produced a cryptic BambuStudio rejection — *"temperature difference of the filaments used is too large"* or *"G-code in unprintable area of multi-extruder printers"* — and an attempt at automatically converting the embedded single-nozzle layout segfaults the slicer CLI. Full cross-nozzle-class re-slicing isn't supported yet (it needs a proper dual-nozzle `project_settings` reconciliation — tracked separately). Until then, both slice routes (`POST /archives/{id}/slice`, `POST /library/files/{id}/slice`) now detect a nozzle-class mismatch up front and reject it synchronously with a `400` and a plain explanation, instead of letting the user wait for a confusing slicer failure. The guard is fail-open — it only fires when both the source's `sliced_for_model` and the target printer resolve to known models of *different* nozzle class; an un-sliced source (first-time slice) or an undeterminable target is never blocked. As part of this, the dual-nozzle model classification — previously an inline `("H2D", "H2D PRO", …)` tuple **duplicated across three call sites** (`start_print`, the two K-profile routes) — is centralized into `DUAL_NOZZLE_MODELS` + `is_dual_nozzle_model()` in `printer_models.py`, and all three sites plus the new guard now consume that single source of truth. **Tests**: `is_dual_nozzle_model` (H2D/Pro/internal-codes dual, X1C/P1S/A1/H2S single, None safe); `_canonical_printer_model` (strips the `"# "` clone prefix and `" 0.4 nozzle"` suffix); `guard_nozzle_class_reslice` (blocks single→dual and dual→single, allows same-class, no-ops on an un-sliced source); and an end-to-end test that an X1C archive re-sliced for an H2D preset returns `400` before any job is enqueued. 305 slicer/MQTT/K-profile tests + 31 slice-API + the printer-model suite all green; backend ruff clean.
|
||||
- **Sliced files no longer report "0 g" filament usage** — A slice result — and the re-sliced archive's card — showed `filament_used_g: 0` (and `0 mm`) even for a real multi-hour print, while the print time came through fine. Bambuddy reads filament totals from the slicer sidecar's `X-Filament-Used-G` / `X-Filament-Used-Mm` response headers, and some sidecar builds simply don't populate them. **Fix**: `ThreeMFParser._parse_gcode_header` now also reads the slicer's own totals — `; total filament weight [g] : …` and `; total filament length [mm] : …` — from the produced 3MF's G-code header (verified against a real sliced output: 126.26 g / 41661.4 mm extracted correctly). Both slice-persist paths (`slice_and_persist` for library files, `slice_and_persist_as_archive`) now fall back to those parsed totals when the sidecar header is 0, applying the corrected figure to the stored metadata, the archive's `filament_used_grams` column, and the slice response. The G-code-header read is a fallback only — `slice_info.config` still wins when it carries per-filament `used_g`. **Tests**: 2 new in `test_archive_service.py` (`_parse_gcode_header` extracts weight + length; absent header lines leave the keys unset). 36 archive-service + 23 slice-API tests green; backend ruff clean.
|
||||
- **A failed slice now opens an error modal instead of a toast that vanishes before it can be read** — Slice failures surfaced through `SliceJobTrackerContext` as a transient error toast, which `ToastContext` auto-dismisses after a flat 3 seconds. Now that a slice failure carries an actionable message — the slicer's own reason, e.g. *"Some objects are located over the boundary of the heated bed."* — 3 seconds is not enough to read it, let alone act on it. **Fix**: a new `AlertModal` component (a small acknowledge-only modal: title, optional subtitle, message, single Close button; Escape / click-outside to dismiss — modelled on `ConfirmModal` but one button). On a failed slice job, `SliceJobTrackerContext` now shows `AlertModal` with the filename as subtitle and the slicer's reason as the body, instead of the error toast — the user dismisses it themselves. Successful slices keep the existing 3 s success toast; the persistent in-progress toast is still cleared on terminal state. New `slice.failedTitle` key translated across all 9 locales. **Tests**: 4 new in `AlertModal.test.tsx` (renders title/subtitle/message, Close button and Escape both fire `onClose`, subtitle line omitted when absent). Frontend build clean; i18n parity holds.
|
||||
|
||||
@@ -385,6 +385,7 @@ async def _report_spool_usage_for_slots(
|
||||
method_label: str,
|
||||
printer_serial: str = "",
|
||||
printer_id: int | None = None,
|
||||
slot_colors_out: dict[int, str] | None = None,
|
||||
) -> int:
|
||||
"""Report usage to Spoolman for a list of (slot_id, grams) pairs.
|
||||
|
||||
@@ -395,6 +396,11 @@ async def _report_spool_usage_for_slots(
|
||||
never get their weight decremented because their extra.tag is empty
|
||||
on the Spoolman side.
|
||||
|
||||
When ``slot_colors_out`` is provided it is populated with
|
||||
``{slot_id: color_hex}`` for every resolved spool — used by
|
||||
:func:`report_usage` to stamp the archive's filament colour from the
|
||||
Spoolman spool rather than the slicer's 3MF value (#1494).
|
||||
|
||||
Returns number of spools successfully updated.
|
||||
"""
|
||||
spools_updated = 0
|
||||
@@ -420,6 +426,10 @@ async def _report_spool_usage_for_slots(
|
||||
|
||||
spool_id_to_use: int | None = None
|
||||
resolution_path = ""
|
||||
# color_hex of the resolved spool's filament, for the #1494 archive
|
||||
# colour rewrite. The tag path already has the full spool object;
|
||||
# the slot-assignment path only yields an id and is fetched below.
|
||||
spool_color_hex: str | None = None
|
||||
|
||||
spool_tag = _resolve_spool_tag(tray_info, printer_serial, global_tray_id)
|
||||
if spool_tag:
|
||||
@@ -427,6 +437,7 @@ async def _report_spool_usage_for_slots(
|
||||
if spool:
|
||||
spool_id_to_use = spool["id"]
|
||||
resolution_path = "tag"
|
||||
spool_color_hex = (spool.get("filament") or {}).get("color_hex")
|
||||
|
||||
if spool_id_to_use is None and printer_id is not None:
|
||||
ams_id, tray_id = _global_tray_id_to_ams_slot(global_tray_id)
|
||||
@@ -442,6 +453,20 @@ async def _report_spool_usage_for_slots(
|
||||
)
|
||||
continue
|
||||
|
||||
# Record the spool's filament colour for the archive rewrite (#1494).
|
||||
# The slot-assignment path resolved only an id, so fetch the spool.
|
||||
# Strictly best-effort: a colour-fetch failure must never abort the
|
||||
# weight reporting for the remaining slots, so the catch is broad.
|
||||
if slot_colors_out is not None:
|
||||
if spool_color_hex is None:
|
||||
try:
|
||||
full_spool = await client.get_spool(spool_id_to_use)
|
||||
spool_color_hex = (full_spool.get("filament") or {}).get("color_hex")
|
||||
except Exception as exc: # noqa: BLE001 — colour is non-critical
|
||||
logger.debug("[SPOOLMAN] Slot %s: could not fetch spool colour: %s", slot_id, exc)
|
||||
if spool_color_hex:
|
||||
slot_colors_out[slot_id] = spool_color_hex
|
||||
|
||||
try:
|
||||
await client.use_spool(spool_id_to_use, grams_used)
|
||||
logger.info(
|
||||
@@ -675,6 +700,7 @@ async def report_usage(printer_id: int, archive_id: int):
|
||||
logger.info("[SPOOLMAN] Reporting per-filament usage for archive %s", archive_id)
|
||||
|
||||
usage_items = [(u.get("slot_id", 0), u.get("used_g", 0)) for u in filament_usage]
|
||||
slot_colors: dict[int, str] = {}
|
||||
spools_updated = await _report_spool_usage_for_slots(
|
||||
client,
|
||||
usage_items,
|
||||
@@ -683,9 +709,58 @@ async def report_usage(printer_id: int, archive_id: int):
|
||||
f"Archive {archive_id}",
|
||||
printer_serial,
|
||||
printer_id=printer_id,
|
||||
slot_colors_out=slot_colors,
|
||||
)
|
||||
|
||||
if spools_updated == 0:
|
||||
logger.info("[SPOOLMAN] Archive %s: no spools updated", archive_id)
|
||||
else:
|
||||
logger.info("[SPOOLMAN] Archive %s: updated %s spool(s)", archive_id, spools_updated)
|
||||
|
||||
# Stamp the archive's filament colour from the matched Spoolman spools
|
||||
# so it reflects the curated inventory colour, not the slicer's 3MF
|
||||
# value (#1494) — mirrors the built-in inventory path in usage_tracker.
|
||||
await _apply_spool_colors_to_archive(db, archive_id, filament_usage, slot_colors)
|
||||
|
||||
|
||||
async def _apply_spool_colors_to_archive(
|
||||
db,
|
||||
archive_id: int,
|
||||
filament_usage: list[dict],
|
||||
slot_colors: dict[int, str],
|
||||
) -> None:
|
||||
"""Overwrite an archive's ``filament_color`` with the colours of the
|
||||
Spoolman spools that fed the print (#1494).
|
||||
|
||||
All-or-nothing, exactly like the built-in inventory path: the colour is
|
||||
only rewritten when every used slot resolved to a spool that carries a
|
||||
colour, so a partial match never drops slots from the archive.
|
||||
"""
|
||||
if not slot_colors:
|
||||
return
|
||||
|
||||
from backend.app.models.archive import PrintArchive
|
||||
from backend.app.services.usage_tracker import (
|
||||
_archive_colors_from_spools,
|
||||
_spool_color_to_hex,
|
||||
)
|
||||
|
||||
results = [{"slot_id": sid, "color": _spool_color_to_hex(hex_)} for sid, hex_ in slot_colors.items()]
|
||||
colors = _archive_colors_from_spools(filament_usage, results)
|
||||
if not colors:
|
||||
return
|
||||
|
||||
archive = (await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))).scalar_one_or_none()
|
||||
if archive is None:
|
||||
return
|
||||
|
||||
joined = ",".join(colors)
|
||||
if joined != archive.filament_color:
|
||||
logger.info(
|
||||
"[SPOOLMAN] Archive %s filament_color %r -> %r (from Spoolman spools)",
|
||||
archive_id,
|
||||
archive.filament_color,
|
||||
joined,
|
||||
)
|
||||
archive.filament_color = joined
|
||||
await db.commit()
|
||||
|
||||
@@ -63,6 +63,60 @@ def _decode_mqtt_mapping(mapping_raw: list | None) -> list[int] | None:
|
||||
return result
|
||||
|
||||
|
||||
def _spool_color_to_hex(rgba: str | None) -> str | None:
|
||||
"""Normalise a ``Spool.rgba`` value (``RRGGBBAA`` hex, no ``#``) to the
|
||||
``#RRGGBB`` form archives store in ``filament_color``.
|
||||
|
||||
Alpha is dropped — the archive colour list and the Color Distribution
|
||||
graph treat filament colour as opaque. Returns ``None`` for a missing or
|
||||
too-short value so the caller can fall back to the 3MF colour.
|
||||
"""
|
||||
if not rgba:
|
||||
return None
|
||||
h = rgba.strip().lstrip("#")
|
||||
if len(h) < 6:
|
||||
return None
|
||||
return "#" + h[:6].upper()
|
||||
|
||||
|
||||
def _archive_colors_from_spools(filament_usage: list[dict], results: list[dict]) -> list[str] | None:
|
||||
"""Slot-ordered, de-duplicated hex colours for an archive's ``filament_color``,
|
||||
taken from the inventory spools that actually fed the print (#1494).
|
||||
|
||||
The slicer's 3MF carries its own ``filament_colour`` per slot — a value
|
||||
picked independently of the colour the user curates on the matched
|
||||
inventory spool. So an archive printed from a ``#000000`` inventory spool
|
||||
would otherwise show the slicer's near-black ``#161616``. Once usage
|
||||
tracking has resolved the used slots to spools, the spool colours are the
|
||||
authoritative source and replace the 3MF values.
|
||||
|
||||
Returns ``None`` — leave the 3MF colour untouched — unless *every* slot
|
||||
with non-zero usage was matched to a spool that carries a colour. A
|
||||
partial rewrite would silently drop the unmatched slots' colours from the
|
||||
archive (and the Color Distribution graph), so it is all-or-nothing.
|
||||
"""
|
||||
used_slots = {u["slot_id"] for u in filament_usage if u.get("used_g", 0) > 0 and u.get("slot_id") is not None}
|
||||
if not used_slots:
|
||||
return None
|
||||
|
||||
slot_color: dict[int, str] = {}
|
||||
for r in results:
|
||||
slot_id = r.get("slot_id")
|
||||
color = r.get("color")
|
||||
if slot_id is not None and color:
|
||||
slot_color.setdefault(slot_id, color)
|
||||
|
||||
if not used_slots.issubset(slot_color):
|
||||
return None
|
||||
|
||||
ordered: list[str] = []
|
||||
for slot_id in sorted(used_slots):
|
||||
color = slot_color[slot_id]
|
||||
if color not in ordered:
|
||||
ordered.append(color)
|
||||
return ordered
|
||||
|
||||
|
||||
def _match_slots_by_color(
|
||||
filament_usage: list[dict],
|
||||
ams_raw: dict | list | None,
|
||||
@@ -589,6 +643,10 @@ async def on_print_complete(
|
||||
"tray_id": assign_tray_id,
|
||||
"material": spool.material,
|
||||
"cost": cost,
|
||||
# AMS remain%-delta fallback has no 3MF slot — slot_id
|
||||
# stays None so it is excluded from the colour rewrite.
|
||||
"slot_id": None,
|
||||
"color": _spool_color_to_hex(spool.rgba),
|
||||
}
|
||||
)
|
||||
|
||||
@@ -823,6 +881,7 @@ async def _track_from_3mf(
|
||||
from backend.app.utils.threemf_tools import extract_filament_usage_from_3mf
|
||||
|
||||
file_path: Path | None = threemf_path
|
||||
archive: PrintArchive | None = None
|
||||
|
||||
if file_path is None and archive_id:
|
||||
result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))
|
||||
@@ -1134,6 +1193,8 @@ async def _track_from_3mf(
|
||||
"tray_id": seg_tray_id,
|
||||
"material": spool.material,
|
||||
"cost": cost,
|
||||
"slot_id": slot_id,
|
||||
"color": _spool_color_to_hex(spool.rgba),
|
||||
}
|
||||
)
|
||||
|
||||
@@ -1263,6 +1324,8 @@ async def _track_from_3mf(
|
||||
"tray_id": tray_id,
|
||||
"material": spool.material,
|
||||
"cost": cost,
|
||||
"slot_id": slot_id,
|
||||
"color": _spool_color_to_hex(spool.rgba),
|
||||
}
|
||||
)
|
||||
|
||||
@@ -1285,4 +1348,22 @@ async def _track_from_3mf(
|
||||
status,
|
||||
)
|
||||
|
||||
# --- Adopt the matched inventory spools' colours for the archive (#1494) ---
|
||||
# The archive's filament_color was set from the slicer's 3MF at creation
|
||||
# time; now that every used slot has been resolved to an inventory spool,
|
||||
# the curated spool colour is authoritative. Committed by the caller's
|
||||
# `if results: await db.commit()`.
|
||||
if archive is not None:
|
||||
spool_colors = _archive_colors_from_spools(filament_usage, results)
|
||||
if spool_colors:
|
||||
joined = ",".join(spool_colors)
|
||||
if joined != archive.filament_color:
|
||||
logger.info(
|
||||
"[UsageTracker] 3MF: archive %s filament_color %r -> %r (from inventory spools)",
|
||||
archive_id,
|
||||
archive.filament_color,
|
||||
joined,
|
||||
)
|
||||
archive.filament_color = joined
|
||||
|
||||
return results
|
||||
|
||||
@@ -6,6 +6,7 @@ from unittest.mock import AsyncMock, MagicMock, patch
|
||||
import pytest
|
||||
|
||||
from backend.app.services.spoolman_tracking import (
|
||||
_apply_spool_colors_to_archive,
|
||||
_get_fallback_spool_tag,
|
||||
_global_tray_id_to_ams_slot,
|
||||
_hash_serial_to_hex32,
|
||||
@@ -300,3 +301,69 @@ class TestStorePrintData:
|
||||
|
||||
# Tracking row was inserted — the fix is working.
|
||||
db.add.assert_called_once()
|
||||
|
||||
|
||||
class TestApplySpoolColorsToArchive:
|
||||
"""`_apply_spool_colors_to_archive` stamps the archive's filament_color
|
||||
from the matched Spoolman spools (#1494) — the Spoolman-mode mirror of
|
||||
the built-in inventory rewrite in usage_tracker."""
|
||||
|
||||
def _make_db(self, archive):
|
||||
db = AsyncMock()
|
||||
db.execute = AsyncMock(return_value=MagicMock(scalar_one_or_none=MagicMock(return_value=archive)))
|
||||
return db
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rewrites_color_from_spoolman_spool(self):
|
||||
"""The #1494 case: 3MF said #161616, the Spoolman spool is 000000."""
|
||||
archive = MagicMock()
|
||||
archive.filament_color = "#161616"
|
||||
db = self._make_db(archive)
|
||||
|
||||
await _apply_spool_colors_to_archive(
|
||||
db,
|
||||
archive_id=10,
|
||||
filament_usage=[{"slot_id": 1, "used_g": 15.9}],
|
||||
slot_colors={1: "000000"},
|
||||
)
|
||||
|
||||
assert archive.filament_color == "#000000"
|
||||
db.commit.assert_awaited()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_empty_slot_colors_is_noop(self):
|
||||
"""No resolved spool colours — never touches the DB."""
|
||||
db = self._make_db(MagicMock())
|
||||
await _apply_spool_colors_to_archive(
|
||||
db, archive_id=10, filament_usage=[{"slot_id": 1, "used_g": 15.9}], slot_colors={}
|
||||
)
|
||||
db.execute.assert_not_awaited()
|
||||
db.commit.assert_not_awaited()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_partial_match_leaves_archive_untouched(self):
|
||||
"""Slot 2 used but unresolved — keep the 3MF colour, don't load the archive."""
|
||||
db = self._make_db(MagicMock())
|
||||
await _apply_spool_colors_to_archive(
|
||||
db,
|
||||
archive_id=10,
|
||||
filament_usage=[
|
||||
{"slot_id": 1, "used_g": 10.0},
|
||||
{"slot_id": 2, "used_g": 20.0},
|
||||
],
|
||||
slot_colors={1: "000000"},
|
||||
)
|
||||
db.execute.assert_not_awaited()
|
||||
db.commit.assert_not_awaited()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_missing_archive_does_not_crash(self):
|
||||
"""Archive row gone (deleted between completion and reporting)."""
|
||||
db = self._make_db(None)
|
||||
await _apply_spool_colors_to_archive(
|
||||
db,
|
||||
archive_id=10,
|
||||
filament_usage=[{"slot_id": 1, "used_g": 15.9}],
|
||||
slot_colors={1: "000000"},
|
||||
)
|
||||
db.commit.assert_not_awaited()
|
||||
|
||||
@@ -12,13 +12,15 @@ import pytest
|
||||
from backend.app.services.usage_tracker import (
|
||||
PrintSession,
|
||||
_active_sessions,
|
||||
_archive_colors_from_spools,
|
||||
_spool_color_to_hex,
|
||||
_track_from_3mf,
|
||||
on_print_complete,
|
||||
on_print_start,
|
||||
)
|
||||
|
||||
|
||||
def _make_spool(*, id=1, label_weight=1000, weight_used=0, tag_uid=None, tray_uuid=None):
|
||||
def _make_spool(*, id=1, label_weight=1000, weight_used=0, tag_uid=None, tray_uuid=None, rgba=None):
|
||||
"""Create a mock Spool object."""
|
||||
spool = MagicMock()
|
||||
spool.id = id
|
||||
@@ -29,6 +31,7 @@ def _make_spool(*, id=1, label_weight=1000, weight_used=0, tag_uid=None, tray_uu
|
||||
spool.last_used = None
|
||||
spool.cost_per_kg = None
|
||||
spool.material = "PLA"
|
||||
spool.rgba = rgba
|
||||
return spool
|
||||
|
||||
|
||||
@@ -766,3 +769,194 @@ class TestSpoolAssignmentSnapshot:
|
||||
assert results[0]["weight_used"] == 14.2
|
||||
# Spool weight should be updated: 50 + 14.2 = 64.2
|
||||
assert spool.weight_used == 64.2
|
||||
|
||||
|
||||
class TestSpoolColorToHex:
|
||||
"""`_spool_color_to_hex` normalises Spool.rgba (RRGGBBAA, no #) to #RRGGBB."""
|
||||
|
||||
def test_strips_alpha_and_adds_hash(self):
|
||||
assert _spool_color_to_hex("000000FF") == "#000000"
|
||||
assert _spool_color_to_hex("EC984CFF") == "#EC984C"
|
||||
|
||||
def test_uppercases(self):
|
||||
assert _spool_color_to_hex("ec984cff") == "#EC984C"
|
||||
|
||||
def test_accepts_six_char_value(self):
|
||||
"""A value with no alpha is still valid."""
|
||||
assert _spool_color_to_hex("161616") == "#161616"
|
||||
|
||||
def test_tolerates_leading_hash(self):
|
||||
assert _spool_color_to_hex("#000000FF") == "#000000"
|
||||
|
||||
def test_none_and_too_short_return_none(self):
|
||||
"""Missing / malformed colour falls back to the 3MF value."""
|
||||
assert _spool_color_to_hex(None) is None
|
||||
assert _spool_color_to_hex("") is None
|
||||
assert _spool_color_to_hex("FFF") is None
|
||||
|
||||
|
||||
class TestArchiveColorsFromSpools:
|
||||
"""`_archive_colors_from_spools` rebuilds an archive's filament_color from
|
||||
the inventory spools that fed the print (#1494). All-or-nothing: a partial
|
||||
match returns None so the 3MF colour is left intact."""
|
||||
|
||||
def test_single_slot_matched(self):
|
||||
"""The #1494 case: one used slot, matched to a #000000 spool."""
|
||||
usage = [{"slot_id": 1, "used_g": 15.9, "color": "#161616"}]
|
||||
results = [{"slot_id": 1, "color": "#000000"}]
|
||||
assert _archive_colors_from_spools(usage, results) == ["#000000"]
|
||||
|
||||
def test_multi_slot_all_matched_keeps_slot_order(self):
|
||||
usage = [
|
||||
{"slot_id": 1, "used_g": 10.0, "color": "#111111"},
|
||||
{"slot_id": 2, "used_g": 20.0, "color": "#222222"},
|
||||
]
|
||||
# results deliberately out of slot order — output must be slot-ordered
|
||||
results = [
|
||||
{"slot_id": 2, "color": "#00FF00"},
|
||||
{"slot_id": 1, "color": "#FF0000"},
|
||||
]
|
||||
assert _archive_colors_from_spools(usage, results) == ["#FF0000", "#00FF00"]
|
||||
|
||||
def test_duplicate_colors_deduplicated(self):
|
||||
"""Two slots of the same spool colour collapse to one entry, as the
|
||||
3MF-derived path also de-duplicates."""
|
||||
usage = [
|
||||
{"slot_id": 1, "used_g": 10.0, "color": "#111111"},
|
||||
{"slot_id": 2, "used_g": 20.0, "color": "#222222"},
|
||||
]
|
||||
results = [
|
||||
{"slot_id": 1, "color": "#000000"},
|
||||
{"slot_id": 2, "color": "#000000"},
|
||||
]
|
||||
assert _archive_colors_from_spools(usage, results) == ["#000000"]
|
||||
|
||||
def test_partial_match_returns_none(self):
|
||||
"""Slot 2 was used but never matched to a spool — leave the 3MF colour
|
||||
untouched rather than dropping slot 2 from the archive."""
|
||||
usage = [
|
||||
{"slot_id": 1, "used_g": 10.0, "color": "#111111"},
|
||||
{"slot_id": 2, "used_g": 20.0, "color": "#222222"},
|
||||
]
|
||||
results = [{"slot_id": 1, "color": "#000000"}]
|
||||
assert _archive_colors_from_spools(usage, results) is None
|
||||
|
||||
def test_matched_spool_without_color_returns_none(self):
|
||||
"""A spool with no rgba (color None) does not count as matched."""
|
||||
usage = [{"slot_id": 1, "used_g": 15.0, "color": "#161616"}]
|
||||
results = [{"slot_id": 1, "color": None}]
|
||||
assert _archive_colors_from_spools(usage, results) is None
|
||||
|
||||
def test_unused_slot_not_required(self):
|
||||
"""A slot with zero usage need not be matched."""
|
||||
usage = [
|
||||
{"slot_id": 1, "used_g": 15.0, "color": "#161616"},
|
||||
{"slot_id": 2, "used_g": 0.0, "color": "#888888"},
|
||||
]
|
||||
results = [{"slot_id": 1, "color": "#000000"}]
|
||||
assert _archive_colors_from_spools(usage, results) == ["#000000"]
|
||||
|
||||
def test_no_used_slots_returns_none(self):
|
||||
assert _archive_colors_from_spools([], []) is None
|
||||
|
||||
def test_ams_fallback_results_excluded(self):
|
||||
"""AMS remain%-delta fallback results carry slot_id=None and must not
|
||||
satisfy the match for a real 3MF slot."""
|
||||
usage = [{"slot_id": 1, "used_g": 15.0, "color": "#161616"}]
|
||||
results = [{"slot_id": None, "color": "#000000"}]
|
||||
assert _archive_colors_from_spools(usage, results) is None
|
||||
|
||||
|
||||
class TestArchiveFilamentColorRewrite:
|
||||
"""`_track_from_3mf` overwrites the archive's filament_color with the
|
||||
matched inventory spool colour at print completion (#1494)."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_archive_color_adopts_spool_color(self):
|
||||
"""A print from a #000000 inventory spool whose 3MF says #161616 ends
|
||||
up with the archive showing the spool's #000000."""
|
||||
spool = _make_spool(id=5, label_weight=1000, weight_used=100, rgba="000000FF")
|
||||
assignment = _make_assignment(spool_id=5)
|
||||
archive = MagicMock()
|
||||
archive.file_path = "archives/test.3mf"
|
||||
archive.filament_color = "#161616" # what archive.py set from the 3MF
|
||||
|
||||
db = AsyncMock()
|
||||
db.execute = AsyncMock(
|
||||
side_effect=[
|
||||
MagicMock(scalar_one_or_none=MagicMock(return_value=archive)),
|
||||
MagicMock(scalar_one_or_none=MagicMock(return_value=None)),
|
||||
MagicMock(scalar_one_or_none=MagicMock(return_value=assignment)),
|
||||
MagicMock(scalar_one_or_none=MagicMock(return_value=spool)),
|
||||
]
|
||||
)
|
||||
|
||||
pm = _make_printer_manager(_make_printer_state([], tray_now=0))
|
||||
filament_usage = [{"slot_id": 1, "used_g": 25.5, "type": "PETG", "color": "#161616"}]
|
||||
|
||||
with (
|
||||
patch("backend.app.core.config.settings") as mock_settings,
|
||||
patch("backend.app.utils.threemf_tools.extract_filament_usage_from_3mf", return_value=filament_usage),
|
||||
):
|
||||
mock_path = MagicMock()
|
||||
mock_path.exists.return_value = True
|
||||
mock_settings.base_dir.__truediv__ = MagicMock(return_value=mock_path)
|
||||
|
||||
results = await _track_from_3mf(
|
||||
printer_id=1,
|
||||
archive_id=10,
|
||||
status="completed",
|
||||
print_name="test_print",
|
||||
handled_trays=set(),
|
||||
printer_manager=pm,
|
||||
db=db,
|
||||
)
|
||||
|
||||
assert len(results) == 1
|
||||
assert results[0]["color"] == "#000000"
|
||||
assert results[0]["slot_id"] == 1
|
||||
# The archive colour was rewritten from the slicer's #161616 to the
|
||||
# inventory spool's #000000.
|
||||
assert archive.filament_color == "#000000"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_archive_color_untouched_when_spool_has_no_color(self):
|
||||
"""A spool with no rgba leaves the 3MF colour in place."""
|
||||
spool = _make_spool(id=5, label_weight=1000, weight_used=100, rgba=None)
|
||||
assignment = _make_assignment(spool_id=5)
|
||||
archive = MagicMock()
|
||||
archive.file_path = "archives/test.3mf"
|
||||
archive.filament_color = "#161616"
|
||||
|
||||
db = AsyncMock()
|
||||
db.execute = AsyncMock(
|
||||
side_effect=[
|
||||
MagicMock(scalar_one_or_none=MagicMock(return_value=archive)),
|
||||
MagicMock(scalar_one_or_none=MagicMock(return_value=None)),
|
||||
MagicMock(scalar_one_or_none=MagicMock(return_value=assignment)),
|
||||
MagicMock(scalar_one_or_none=MagicMock(return_value=spool)),
|
||||
]
|
||||
)
|
||||
|
||||
pm = _make_printer_manager(_make_printer_state([], tray_now=0))
|
||||
filament_usage = [{"slot_id": 1, "used_g": 25.5, "type": "PETG", "color": "#161616"}]
|
||||
|
||||
with (
|
||||
patch("backend.app.core.config.settings") as mock_settings,
|
||||
patch("backend.app.utils.threemf_tools.extract_filament_usage_from_3mf", return_value=filament_usage),
|
||||
):
|
||||
mock_path = MagicMock()
|
||||
mock_path.exists.return_value = True
|
||||
mock_settings.base_dir.__truediv__ = MagicMock(return_value=mock_path)
|
||||
|
||||
await _track_from_3mf(
|
||||
printer_id=1,
|
||||
archive_id=10,
|
||||
status="completed",
|
||||
print_name="test_print",
|
||||
handled_trays=set(),
|
||||
printer_manager=pm,
|
||||
db=db,
|
||||
)
|
||||
|
||||
assert archive.filament_color == "#161616"
|
||||
|
||||
Reference in New Issue
Block a user