mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-17 19:36:28 +02:00
fix(ams): keep slot card preset name in sync after spool swaps
The slot card on PrintersPage shows slot_preset_mappings.preset_name first in its display fallback chain. Three write paths swap which spool occupies a given slot: - internal manual assign (inventory.apply_spool_to_slot_via_mqtt) - internal RFID auto-assign (spool_tag_matcher.auto_assign_spool) - Spoolman RFID sync (main.auto_sync_spoolman_ams_trays) Only the first one was reconciling the row. After an RFID-driven spool change, the card kept surfacing the previous spool's preset name until the user opened Configure Slot manually. Reporter saw H2D-1 / AMS-B3 displaying "Bambu PLA Silk+" for a freshly-inserted Bambu PLA-CF spool. The matching row in slot_preset_mappings was last written in March when a PLA Silk+ spool had been in that slot - confirmed live in the database. New backend/app/services/slot_preset_writer.py exposes a primitive upsert_slot_preset plus two derivation wrappers: one for the internal Spool ORM object, one for the Spoolman API dict shape. All three call sites now go through the helper, so the row stays in lockstep with the assigned spool regardless of inventory mode. Bug shape exists in both inventory modes and the patch fixes both per feedback_inventory_modes_parity. The Spoolman path was latent for users who'd never manually picked a slot preset; the same "stale row overrides correct catalog name" symptom appeared for those who had. Existing stale rows self-heal on the next RFID-driven swap.
This commit is contained in:
@@ -20,6 +20,8 @@ All notable changes to Bambuddy will be documented in this file.
|
||||
- **Print Log page: per-row delete (#1687 part 1, reported by @IndividualGhost1905)** — Reporter noted that the existing "Also remove this print from Quick Stats" toggle on archive delete is one-shot: if you tick "keep stats" at delete time, there was no later way to drop the row from /stats; and rows that aren't tied to an archive (errors, aborts, manual entries) had no delete affordance at all. **Fix:** every row in the Archives → Print Log table now has a trash icon next to the filament cell, gated on `archives:delete_own` (own rows) or `archives:delete_all` (any row), matching the archive-delete permission shape. Click → confirm modal → row is gone, and because /archives/stats aggregates over `PrintLogEntry` the filament / time / cost contribution drops out of Quick Stats in the same response cycle. The matching archive (if any) is untouched — the log row is a sibling, not a child. **Backend:** new `DELETE /print-log/{entry_id}` mirrors `delete_archive`'s ownership flow via `require_ownership_permission(ARCHIVES_DELETE_ALL, ARCHIVES_DELETE_OWN)`; owners can drop their own rows, admins can drop any row, missing IDs return 404 rather than 200-silently. **Frontend:** new `deletePrintLogEntry` API helper, per-row mutation that invalidates both `print-log` and `archives-stats` query keys so the totals re-render without a manual refresh. **i18n:** 4 new keys (`deleteEntryTitle`, `deleteEntryConfirm`, `entryDeleted`, `entryDeleteFailed`) translated across all 11 locales (de / en / es / fr / it / ja / ko / pt-BR / tr / zh-CN / zh-TW). **Tests:** 3 backend integration cases — delete drops the row from /stats while keeping the linked archive listed, missing ID returns 404, delete-one does not touch siblings (regression guard against an accidental `delete(PrintLogEntry)` without a `where`). Frontend ArchivesPage / PrintLogModal vitests stay green (31 / 31). i18n parity green (5099 leaves × 11 locales). Issue #1687 also asks for per-row tagging (already covered by `EditArchiveModal`'s tags field) and per-row filament-usage-history edits (deferred — see the issue thread for the reasoning).
|
||||
|
||||
### Fixed
|
||||
- **AMS slot card surfaced the previous spool's preset name after RFID auto-assigned a new spool (reported with H2D-1 / AMS-B3 / PLA-CF showing as "Bambu PLA Silk+")** — Reporter inserted a fresh Bambu PLA-CF spool into AMS-B3, RFID identified it correctly, but the slot card kept showing "Bambu PLA Silk+" (the name from a PLA Silk+ spool that had occupied the slot back in March). Confirmed in the live data: `slot_preset_mappings` row for `(printer_id=1, ams_id=1, tray_id=2)` was `preset_id=GFSA06_09, preset_name='Bambu PLA Silk+', updated_at=2026-03-15` — three months stale. **Root cause:** `slot_preset_mappings.preset_name` is first in the PrintersPage display chain (`PrintersPage.tsx:3624`) and overrides the spool's own `slicer_filament_name` plus the cloud catalog `cloudInfo.name`. The internal-mode manual-assign path (`inventory.apply_spool_to_slot_via_mqtt`) kept this row in sync, but the internal-mode RFID auto-assign path (`spool_tag_matcher.auto_assign_spool`) skipped it entirely. The Spoolman-mode sync path (`main.auto_sync_spoolman_ams_trays`) also skipped it — same bug shape, latent for Spoolman users who'd never manually configured a slot preset, active for those who had. **Fix — three writers in lockstep via one shared helper.** New `backend/app/services/slot_preset_writer.py` exposes a primitive `upsert_slot_preset` plus two convenience wrappers: `upsert_slot_preset_for_spool` for internal `Spool` ORM objects (local-preset numeric ids → `local_{n}`, cloud ids run through `filament_id_to_setting_id`) and `upsert_slot_preset_for_spoolman_spool` for Spoolman dicts (filament.name → preset_name, tray_info_idx → preset_id). All three call sites — the manual-assign block in `inventory.py:396-438`, the RFID auto-assign tail in `spool_tag_matcher.py:auto_assign_spool`, and the per-tray-sync branch in `main.py:auto_sync_spoolman_ams_trays` — now go through the helper. **Self-heal:** existing stale rows from past spool swaps get rewritten the next time a fresh spool is detected on the same slot. No migration script needed. **What this also covers per `feedback_inventory_modes_parity`:** the bug shape exists in both internal and Spoolman modes, so the patch ships fixes for both inventory paths in the same drop — a Spoolman user with a manually-configured slot preset would have seen the same stale-name behavior after every RFID swap until the row was overwritten through Configure Slot. **Tests:** new `test_slot_preset_writer.py` (6 cases) pins the helper contracts — no-op on empty preset_id, upsert idempotency, Spoolman filament.name → preset_name, fallback to material → tray_sub_brands → tray_type, stale-row overwrite from the Spoolman path, skip when tray_info_idx is unknown. New `test_spool_tag_matcher.py` cases (3) pin the internal RFID-auto-assign path — stale-row overwrite (the exact reporter shape: PLA Silk+ → PLA-CF), fresh insert when no row exists, `local_{n}` formatting for numeric local-preset ids. Total touched-area suite 69/69 green; broader related suite (inventory + spoolman + spool_tag + auto_sync) 767/767 green; ruff clean.
|
||||
|
||||
- **Stats page Failure Analysis widget rendered raw camelCase keys instead of translated reasons (#1687 follow-up, reported by @IndividualGhost1905)** — After #1687 part 4 shipped the per-row Print Log editor, the reporter classified a couple of failed runs and saw "filamentRunout" / "cloggedNozzle" (the literal camelCase keys) appear under Statistics → Failure Analysis → Top Failure Reasons, while the same rows rendered correctly as "Filament runout" / "Clogged nozzle" on the Print Log table. Surfaced an inconsistency I introduced when shipping the new editor: the new Print Log row editor saves the camelCase key (`filamentRunout`) which is what the new backend PATCH validates against, but the older `EditArchiveModal` was still saving the localised label (`"Filament runout"`) as the value — two formats landing in the same `PrintLogEntry.failure_reason` column from two different UI surfaces. The Failure Analysis widget at `frontend/src/pages/StatsPage.tsx:817` and the per-archive run history sub-table at `frontend/src/components/PrintLogTable.tsx:81` both rendered the raw column value without running it through i18n, so the new key-form values surfaced as literal keys. **Fix — three sites in one drop:** (1) `StatsPage.tsx` and (2) `PrintLogTable.tsx` now wrap the value in `t('editArchive.failureReasons.${reason}', { defaultValue: reason })` — same pattern already used at `ArchivesPage.tsx:3874` for the Print Log table. The `defaultValue` fallback keeps legacy translated-text rows rendering as-is, no regression. (3) `EditArchiveModal.tsx` now saves the camelCase key (`<option value={reasonKey}>`) instead of the localised label, matching the new editor's wire format. On modal open, a reverse-lookup against the current locale resolves any legacy translated-text value back to its key so the dropdown pre-selects the right option — every save thereafter converts that row forward to the key format, so the data set self-heals over time without a migration script. Added `htmlFor`/`id` linkage to the failure-reason `<label>`/`<select>` pair as a side benefit (lets `getByLabelText` in tests reach the control, plus a small a11y improvement). **What this also fixes invisibly:** German / Japanese / Turkish users who classified rows under one UI language and then switched languages would have seen their historical buckets fragment in the Failure Analysis widget (each translation = its own group). With keys as the storage format, language switch no longer reclassifies anything. **Tests:** 5 new vitest cases — StatsPage `translates camelCase failure-reason keys` and `renders legacy translated-text failure reasons unchanged`; EditArchiveModal `preselects the option when the stored value is already a camelCase key`, `reverse-looks-up a legacy translated value back to its key`, and `sends the camelCase key on save, not the translated label`; PrintLogModal `translates camelCase failure_reason keys`. The existing `shows failure_reason under failed runs` case (which checks legacy text path) keeps passing under the defaultValue fallback. Full vitest 58 / 58 across touched files. ESLint clean; frontend build clean (vite 9.61s); i18n parity 5118 leaves × 11 locales green (no new keys — reuses `editArchive.failureReasons.*`).
|
||||
|
||||
- **System page boot time was rendered with a doubled timezone offset (#1690 follow-up, reported by @IndividualGhost1905)** — After the original #1690 fix landed in 0.2.4.6, the reporter on UTC+3 (Turkey) confirmed uptime was correct but boot time displayed +3 hours ahead of reality. **Root cause:** `backend/app/api/routes/system.py` built `boot_time` as a NAIVE LOCAL datetime via `datetime.fromtimestamp(psutil.Process(1).create_time())` and serialised it with `.isoformat()`, which emits no timezone marker (e.g. `"2026-06-09T11:22:05"`). The frontend's `parseUTCDate()` helper at `frontend/src/utils/date.ts:206` is documented to append `'Z'` when no tz marker is present, treating the string as UTC, then `toLocaleString` converts UTC → local — applying the local offset on top of an already-local timestamp. Uptime was unaffected because it's computed entirely backend-side as `datetime.now() - boot_time`, two naive-local values whose delta is correct regardless of the missing tz info. **Fix:** make both boot_time and the uptime anchor tz-aware UTC — `datetime.fromtimestamp(ts, tz=timezone.utc)` on the main path and the `psutil.boot_time()` fallback, and `datetime.now(timezone.utc)` in the uptime subtraction. `isoformat()` then emits `"+00:00"` and the frontend's parseUTCDate uses the marker as-is. Same naive-datetime pattern surfaced in two adjacent `generated_at` fields — the storage-usage cache snapshot in `system.py` and the support bundle root in `support.py`. Neither is rendered as a wall-clock timestamp in the frontend today, but both now emit tz-aware UTC for consistency so any future surface that does render them won't recreate this bug. **Tests:** new `test_boot_time_isoformat_carries_utc_marker` regression case asserts the boot_time string ends in `+00:00` (or `Z`) — without that marker the frontend double-converts, which is exactly the reporter's symptom. Existing `test_boot_time_uses_pid1_create_time` and `test_boot_time_falls_back_to_psutil_boot_time_on_pid1_failure` still pass under the tz-aware values because `1700345600` is `2023-11-18T20:53:20+00:00` UTC, so the date-prefix assertion is unaffected. Full system API suite 21/21 green; support API 72/72 green; ruff clean.
|
||||
|
||||
@@ -394,48 +394,23 @@ async def apply_spool_to_slot_via_mqtt(
|
||||
)
|
||||
|
||||
# Persist slot preset mapping for UI display (preset_name on hover card).
|
||||
try:
|
||||
from backend.app.models.slot_preset import SlotPresetMapping
|
||||
# Shared with the RFID auto-assign path — both must keep this row in sync
|
||||
# with the currently-assigned spool, otherwise the slot card surfaces the
|
||||
# previous spool's preset name (the PrintersPage display chain consults
|
||||
# slot_preset_mappings.preset_name first).
|
||||
from backend.app.services.slot_preset_writer import upsert_slot_preset_for_spool
|
||||
|
||||
preset_name = spool.slicer_filament_name or tray_sub_brands or tray_type
|
||||
preset_source = "cloud"
|
||||
if sf:
|
||||
base_sf_mapping = sf.split("_")[0] if "_" in sf else sf
|
||||
try:
|
||||
int(base_sf_mapping)
|
||||
preset_id_to_save = f"local_{base_sf_mapping}"
|
||||
preset_source = "local"
|
||||
except (ValueError, TypeError):
|
||||
preset_id_to_save = filament_id_to_setting_id(tray_info_idx) if tray_info_idx else setting_id
|
||||
else:
|
||||
preset_id_to_save = filament_id_to_setting_id(tray_info_idx) if tray_info_idx else ""
|
||||
|
||||
if preset_id_to_save:
|
||||
existing_mapping = await db.execute(
|
||||
select(SlotPresetMapping).where(
|
||||
SlotPresetMapping.printer_id == printer_id,
|
||||
SlotPresetMapping.ams_id == ams_id,
|
||||
SlotPresetMapping.tray_id == tray_id,
|
||||
)
|
||||
)
|
||||
mapping = existing_mapping.scalar_one_or_none()
|
||||
if mapping:
|
||||
mapping.preset_id = preset_id_to_save
|
||||
mapping.preset_name = preset_name
|
||||
mapping.preset_source = preset_source
|
||||
else:
|
||||
mapping = SlotPresetMapping(
|
||||
printer_id=printer_id,
|
||||
ams_id=ams_id,
|
||||
tray_id=tray_id,
|
||||
preset_id=preset_id_to_save,
|
||||
preset_name=preset_name,
|
||||
preset_source=preset_source,
|
||||
)
|
||||
db.add(mapping)
|
||||
await db.commit()
|
||||
except Exception as e:
|
||||
logger.warning("Failed to save slot preset mapping for spool %d: %s", spool.id, e)
|
||||
await upsert_slot_preset_for_spool(
|
||||
db=db,
|
||||
spool=spool,
|
||||
printer_id=printer_id,
|
||||
ams_id=ams_id,
|
||||
tray_id=tray_id,
|
||||
tray_info_idx=tray_info_idx,
|
||||
tray_sub_brands=tray_sub_brands,
|
||||
tray_type=tray_type,
|
||||
setting_id=setting_id,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"Auto-configured AMS slot ams=%d tray=%d for spool %d on printer %d",
|
||||
|
||||
@@ -1695,6 +1695,24 @@ async def on_ams_change(printer_id: int, ams_data: list):
|
||||
printer_id,
|
||||
result["id"],
|
||||
)
|
||||
# Reconcile slot_preset_mappings (the same row internal
|
||||
# mode keeps in sync via inventory + spool_tag_matcher).
|
||||
# Without this the slot card surfaces the previous spool's
|
||||
# preset name — same bug shape, different inventory mode.
|
||||
from backend.app.services.slot_preset_writer import (
|
||||
upsert_slot_preset_for_spoolman_spool,
|
||||
)
|
||||
|
||||
await upsert_slot_preset_for_spoolman_spool(
|
||||
db=db,
|
||||
spoolman_spool=result,
|
||||
tray_info_idx=tray.tray_info_idx or "",
|
||||
tray_sub_brands=tray.tray_sub_brands or "",
|
||||
tray_type=tray.tray_type or "",
|
||||
printer_id=printer_id,
|
||||
ams_id=ams_id,
|
||||
tray_id=tray.tray_id,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error("Error syncing AMS %s tray %s: %s", ams_id, tray.tray_id, e)
|
||||
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
"""Shared upsert for the slot_preset_mappings row that drives the AMS slot
|
||||
card's displayed preset name.
|
||||
|
||||
Three call sites must keep this row in sync with the currently-assigned spool:
|
||||
|
||||
- ``api.routes.inventory.apply_spool_to_slot_via_mqtt`` (internal manual assign)
|
||||
- ``services.spool_tag_matcher.auto_assign_spool`` (internal RFID auto-assign)
|
||||
- ``main.auto_sync_spoolman_ams_trays`` (Spoolman RFID-driven sync)
|
||||
|
||||
If any of them skips this row, the slot card surfaces the previous spool's
|
||||
preset name because the PrintersPage display chain consults
|
||||
slot_preset_mappings.preset_name first — it overrides cloudInfo.name and the
|
||||
spool's own slicer_filament_name.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from backend.app.models.slot_preset import SlotPresetMapping
|
||||
from backend.app.models.spool import Spool
|
||||
from backend.app.utils.filament_ids import filament_id_to_setting_id
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def upsert_slot_preset(
|
||||
*,
|
||||
db: AsyncSession,
|
||||
printer_id: int,
|
||||
ams_id: int,
|
||||
tray_id: int,
|
||||
preset_id: str,
|
||||
preset_name: str,
|
||||
preset_source: str = "cloud",
|
||||
) -> None:
|
||||
"""Primitive upsert. No-op when ``preset_id`` is empty (the column is
|
||||
NOT NULL on the model, and an empty string isn't a useful key to
|
||||
overwrite by). Soft-fails on DB errors so a broken upsert never
|
||||
cascades into the surrounding spool-assign flow.
|
||||
"""
|
||||
if not preset_id:
|
||||
return
|
||||
try:
|
||||
existing = await db.execute(
|
||||
select(SlotPresetMapping).where(
|
||||
SlotPresetMapping.printer_id == printer_id,
|
||||
SlotPresetMapping.ams_id == ams_id,
|
||||
SlotPresetMapping.tray_id == tray_id,
|
||||
)
|
||||
)
|
||||
mapping = existing.scalar_one_or_none()
|
||||
if mapping:
|
||||
mapping.preset_id = preset_id
|
||||
mapping.preset_name = preset_name
|
||||
mapping.preset_source = preset_source
|
||||
else:
|
||||
mapping = SlotPresetMapping(
|
||||
printer_id=printer_id,
|
||||
ams_id=ams_id,
|
||||
tray_id=tray_id,
|
||||
preset_id=preset_id,
|
||||
preset_name=preset_name,
|
||||
preset_source=preset_source,
|
||||
)
|
||||
db.add(mapping)
|
||||
await db.commit()
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"Failed to save slot preset mapping for printer=%d ams=%d tray=%d: %s",
|
||||
printer_id,
|
||||
ams_id,
|
||||
tray_id,
|
||||
e,
|
||||
)
|
||||
|
||||
|
||||
async def upsert_slot_preset_for_spool(
|
||||
*,
|
||||
db: AsyncSession,
|
||||
spool: Spool,
|
||||
printer_id: int,
|
||||
ams_id: int,
|
||||
tray_id: int,
|
||||
tray_info_idx: str = "",
|
||||
tray_sub_brands: str = "",
|
||||
tray_type: str = "",
|
||||
setting_id: str = "",
|
||||
) -> None:
|
||||
"""Convenience wrapper for internal-mode call sites — derives the
|
||||
(preset_id, preset_name, preset_source) triple from a ``Spool`` ORM object,
|
||||
then defers to ``upsert_slot_preset``.
|
||||
|
||||
Local numeric ``spool.slicer_filament`` (e.g. ``"50"``) → ``local_50``;
|
||||
cloud-form ids (GFS… / GFA… via ``filament_id_to_setting_id`` on the
|
||||
tray's ``tray_info_idx``) → standard setting_id form.
|
||||
"""
|
||||
preset_name = spool.slicer_filament_name or tray_sub_brands or tray_type
|
||||
preset_source = "cloud"
|
||||
sf = spool.slicer_filament or ""
|
||||
if sf:
|
||||
base_sf_mapping = sf.split("_")[0] if "_" in sf else sf
|
||||
try:
|
||||
int(base_sf_mapping)
|
||||
preset_id = f"local_{base_sf_mapping}"
|
||||
preset_source = "local"
|
||||
except (ValueError, TypeError):
|
||||
preset_id = filament_id_to_setting_id(tray_info_idx) if tray_info_idx else setting_id
|
||||
else:
|
||||
preset_id = filament_id_to_setting_id(tray_info_idx) if tray_info_idx else ""
|
||||
|
||||
await upsert_slot_preset(
|
||||
db=db,
|
||||
printer_id=printer_id,
|
||||
ams_id=ams_id,
|
||||
tray_id=tray_id,
|
||||
preset_id=preset_id,
|
||||
preset_name=preset_name,
|
||||
preset_source=preset_source,
|
||||
)
|
||||
|
||||
|
||||
async def upsert_slot_preset_for_spoolman_spool(
|
||||
*,
|
||||
db: AsyncSession,
|
||||
spoolman_spool: dict,
|
||||
tray_info_idx: str,
|
||||
tray_sub_brands: str,
|
||||
tray_type: str,
|
||||
printer_id: int,
|
||||
ams_id: int,
|
||||
tray_id: int,
|
||||
) -> None:
|
||||
"""Spoolman-mode counterpart. Derives ``preset_name`` from the Spoolman
|
||||
filament's display name (falling back to material if absent) and
|
||||
``preset_id`` from the AMS-reported tray_info_idx (the cloud filament
|
||||
id the printer is currently using). ``preset_source`` is always
|
||||
``"cloud"`` since Spoolman doesn't carry a local-preset concept.
|
||||
|
||||
The ``spoolman_spool`` dict is the shape returned by
|
||||
``SpoolmanClient.sync_ams_tray`` — ``spool["filament"]["name"]`` etc.
|
||||
"""
|
||||
filament = spoolman_spool.get("filament") or {}
|
||||
preset_name = filament.get("name") or filament.get("material") or tray_sub_brands or tray_type
|
||||
preset_id = filament_id_to_setting_id(tray_info_idx) if tray_info_idx else ""
|
||||
|
||||
await upsert_slot_preset(
|
||||
db=db,
|
||||
printer_id=printer_id,
|
||||
ams_id=ams_id,
|
||||
tray_id=tray_id,
|
||||
preset_id=preset_id,
|
||||
preset_name=preset_name or "",
|
||||
preset_source="cloud",
|
||||
)
|
||||
@@ -578,4 +578,21 @@ async def auto_assign_spool(
|
||||
except Exception as e:
|
||||
logger.warning("K-profile apply failed for spool %d (RFID match): %s", spool.id, e)
|
||||
|
||||
# Reconcile slot_preset_mappings so the AMS slot card stops surfacing the
|
||||
# previous spool's preset name. Shared with the manual-assign path
|
||||
# (inventory.apply_spool_to_slot_via_mqtt). Outside the try above so a
|
||||
# transient MQTT failure doesn't leave the display row stale.
|
||||
from backend.app.services.slot_preset_writer import upsert_slot_preset_for_spool
|
||||
|
||||
await upsert_slot_preset_for_spool(
|
||||
db=db,
|
||||
spool=spool,
|
||||
printer_id=printer_id,
|
||||
ams_id=ams_id,
|
||||
tray_id=tray_id,
|
||||
tray_info_idx=tray_info_idx,
|
||||
tray_sub_brands=tray.get("tray_sub_brands", "") if tray else "",
|
||||
tray_type=tray.get("tray_type", "") if tray else "",
|
||||
)
|
||||
|
||||
return assignment
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
"""Tests for the shared slot_preset_mappings writer.
|
||||
|
||||
The writer keeps PrintersPage's slot-card preset_name in sync with the
|
||||
currently-assigned spool from three different inventory paths:
|
||||
|
||||
* internal manual assign (inventory.apply_spool_to_slot_via_mqtt)
|
||||
* internal RFID auto-assign (spool_tag_matcher.auto_assign_spool)
|
||||
* Spoolman RFID sync (main.auto_sync_spoolman_ams_trays)
|
||||
|
||||
Internal-mode regression coverage lives in test_spool_tag_matcher.py
|
||||
(which exercises the call site end-to-end). This file focuses on the
|
||||
helper-level contracts — local-preset id formatting, soft-fail on
|
||||
no preset_id, and the Spoolman derivation path.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import select
|
||||
|
||||
from backend.app.models.slot_preset import SlotPresetMapping
|
||||
from backend.app.services.slot_preset_writer import (
|
||||
upsert_slot_preset,
|
||||
upsert_slot_preset_for_spoolman_spool,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_upsert_no_op_when_preset_id_empty(db_session, printer_factory):
|
||||
"""An empty preset_id is not a useful key — the model's column is NOT
|
||||
NULL and an empty string would overwrite the user's last good preset
|
||||
with garbage. Skip without raising."""
|
||||
printer = await printer_factory()
|
||||
await upsert_slot_preset(
|
||||
db=db_session,
|
||||
printer_id=printer.id,
|
||||
ams_id=0,
|
||||
tray_id=0,
|
||||
preset_id="",
|
||||
preset_name="ignored",
|
||||
)
|
||||
result = await db_session.execute(select(SlotPresetMapping).where(SlotPresetMapping.printer_id == printer.id))
|
||||
assert result.scalar_one_or_none() is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_upsert_inserts_then_updates(db_session, printer_factory):
|
||||
"""First call inserts, second call on same (printer, ams, tray) updates
|
||||
in place rather than violating the unique constraint."""
|
||||
printer = await printer_factory()
|
||||
await upsert_slot_preset(
|
||||
db=db_session,
|
||||
printer_id=printer.id,
|
||||
ams_id=0,
|
||||
tray_id=1,
|
||||
preset_id="GFSA50",
|
||||
preset_name="Bambu PLA-CF",
|
||||
preset_source="cloud",
|
||||
)
|
||||
await upsert_slot_preset(
|
||||
db=db_session,
|
||||
printer_id=printer.id,
|
||||
ams_id=0,
|
||||
tray_id=1,
|
||||
preset_id="GFSA00",
|
||||
preset_name="Bambu PLA Basic",
|
||||
preset_source="cloud",
|
||||
)
|
||||
|
||||
rows = (
|
||||
(await db_session.execute(select(SlotPresetMapping).where(SlotPresetMapping.printer_id == printer.id)))
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
assert len(rows) == 1
|
||||
assert rows[0].preset_id == "GFSA00"
|
||||
assert rows[0].preset_name == "Bambu PLA Basic"
|
||||
|
||||
|
||||
# -- Spoolman derivation ----------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_spoolman_helper_uses_filament_name_and_tray_info_idx(db_session, printer_factory):
|
||||
"""A Spoolman spool with a typical filament shape — name + material —
|
||||
should land in the row with the AMS-reported tray_info_idx as
|
||||
preset_id (normalised to setting_id form) and the filament name as
|
||||
preset_name."""
|
||||
printer = await printer_factory()
|
||||
spoolman_spool = {
|
||||
"id": 42,
|
||||
"filament": {
|
||||
"id": 7,
|
||||
"name": "Bambu PLA-CF Burgundy Red",
|
||||
"material": "PLA-CF",
|
||||
},
|
||||
}
|
||||
await upsert_slot_preset_for_spoolman_spool(
|
||||
db=db_session,
|
||||
spoolman_spool=spoolman_spool,
|
||||
tray_info_idx="GFA50",
|
||||
tray_sub_brands="PLA-CF",
|
||||
tray_type="PLA",
|
||||
printer_id=printer.id,
|
||||
ams_id=1,
|
||||
tray_id=2,
|
||||
)
|
||||
mapping = (
|
||||
await db_session.execute(
|
||||
select(SlotPresetMapping).where(
|
||||
SlotPresetMapping.printer_id == printer.id,
|
||||
SlotPresetMapping.ams_id == 1,
|
||||
SlotPresetMapping.tray_id == 2,
|
||||
)
|
||||
)
|
||||
).scalar_one()
|
||||
assert mapping.preset_id == "GFSA50"
|
||||
assert mapping.preset_name == "Bambu PLA-CF Burgundy Red"
|
||||
assert mapping.preset_source == "cloud"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_spoolman_helper_falls_back_to_material_when_name_missing(db_session, printer_factory):
|
||||
"""Some Spoolman setups have unnamed filaments — fall back through
|
||||
material → tray_sub_brands → tray_type so we never write an empty
|
||||
preset_name."""
|
||||
printer = await printer_factory()
|
||||
spoolman_spool = {"id": 99, "filament": {"material": "PETG"}}
|
||||
await upsert_slot_preset_for_spoolman_spool(
|
||||
db=db_session,
|
||||
spoolman_spool=spoolman_spool,
|
||||
tray_info_idx="GFG00",
|
||||
tray_sub_brands="PETG Basic",
|
||||
tray_type="PETG",
|
||||
printer_id=printer.id,
|
||||
ams_id=0,
|
||||
tray_id=0,
|
||||
)
|
||||
mapping = (
|
||||
await db_session.execute(select(SlotPresetMapping).where(SlotPresetMapping.printer_id == printer.id))
|
||||
).scalar_one()
|
||||
assert mapping.preset_name == "PETG"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_spoolman_helper_overwrites_stale_internal_row(db_session, printer_factory):
|
||||
"""Mirror of the internal-mode regression: pre-seed a stale row with a
|
||||
previous spool's name, run the Spoolman helper, verify the row now
|
||||
reflects the freshly-synced Spoolman spool. This is the bug shape
|
||||
that would surface on a Spoolman user with a manually-set preset."""
|
||||
printer = await printer_factory()
|
||||
db_session.add(
|
||||
SlotPresetMapping(
|
||||
printer_id=printer.id,
|
||||
ams_id=1,
|
||||
tray_id=2,
|
||||
preset_id="GFSA06_09",
|
||||
preset_name="Bambu PLA Silk+",
|
||||
preset_source="cloud",
|
||||
)
|
||||
)
|
||||
await db_session.commit()
|
||||
|
||||
await upsert_slot_preset_for_spoolman_spool(
|
||||
db=db_session,
|
||||
spoolman_spool={"id": 42, "filament": {"name": "Bambu PLA-CF"}},
|
||||
tray_info_idx="GFA50",
|
||||
tray_sub_brands="PLA-CF",
|
||||
tray_type="PLA",
|
||||
printer_id=printer.id,
|
||||
ams_id=1,
|
||||
tray_id=2,
|
||||
)
|
||||
mapping = (
|
||||
await db_session.execute(
|
||||
select(SlotPresetMapping).where(
|
||||
SlotPresetMapping.printer_id == printer.id,
|
||||
SlotPresetMapping.ams_id == 1,
|
||||
SlotPresetMapping.tray_id == 2,
|
||||
)
|
||||
)
|
||||
).scalar_one()
|
||||
assert mapping.preset_name == "Bambu PLA-CF"
|
||||
assert mapping.preset_id == "GFSA50"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_spoolman_helper_skips_when_tray_info_idx_unknown(db_session, printer_factory):
|
||||
"""No tray_info_idx → no preset_id → upsert skips. Caller must not
|
||||
write an empty-string preset_id (would clobber any existing row)."""
|
||||
printer = await printer_factory()
|
||||
await upsert_slot_preset_for_spoolman_spool(
|
||||
db=db_session,
|
||||
spoolman_spool={"id": 1, "filament": {"name": "Random"}},
|
||||
tray_info_idx="",
|
||||
tray_sub_brands="",
|
||||
tray_type="PLA",
|
||||
printer_id=printer.id,
|
||||
ams_id=0,
|
||||
tray_id=0,
|
||||
)
|
||||
result = await db_session.execute(select(SlotPresetMapping).where(SlotPresetMapping.printer_id == printer.id))
|
||||
assert result.scalar_one_or_none() is None
|
||||
@@ -1365,3 +1365,185 @@ async def test_auto_assign_kprofile_takes_priority_over_live_cali_idx(db_session
|
||||
mqtt_mock.extrusion_cali_sel.assert_called_once()
|
||||
call_kwargs = mqtt_mock.extrusion_cali_sel.call_args[1]
|
||||
assert call_kwargs["cali_idx"] == 7 # stored profile, not 99
|
||||
|
||||
|
||||
# -- slot_preset_mappings reconciliation on RFID auto-assign ----------------
|
||||
#
|
||||
# The slot card on PrintersPage shows slot_preset_mappings.preset_name first
|
||||
# in its fallback chain (it's the user-configured override for a slot). When a
|
||||
# new spool gets auto-assigned via RFID the manual-assign path used to be the
|
||||
# only one that kept this row in sync, so the slot card kept showing the
|
||||
# previous spool's preset name until the user opened Configure Slot manually.
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_auto_assign_overwrites_stale_slot_preset_mapping(db_session, printer_factory):
|
||||
"""Pre-seed a slot_preset_mappings row from a previous spool, run RFID
|
||||
auto-assign with a different filament, and verify the row reflects the
|
||||
NEW spool's preset (not the stale one). The bug being pinned: the user's
|
||||
AMS-B3 (PLA-CF) kept showing 'Bambu PLA Silk+' because the row was last
|
||||
written when the PLA Silk+ spool was loaded back in March.
|
||||
"""
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from sqlalchemy import select as sa_select
|
||||
|
||||
from backend.app.models.slot_preset import SlotPresetMapping
|
||||
|
||||
printer = await printer_factory()
|
||||
db_session.add(
|
||||
SlotPresetMapping(
|
||||
printer_id=printer.id,
|
||||
ams_id=1,
|
||||
tray_id=2,
|
||||
preset_id="GFSA06_09",
|
||||
preset_name="Bambu PLA Silk+",
|
||||
preset_source="cloud",
|
||||
)
|
||||
)
|
||||
await db_session.commit()
|
||||
|
||||
spool = Spool(
|
||||
material="PLA-CF",
|
||||
subtype="CF",
|
||||
brand="Bambu Lab",
|
||||
label_weight=1000,
|
||||
core_weight=250,
|
||||
slicer_filament="GFA50",
|
||||
slicer_filament_name="Bambu PLA-CF",
|
||||
rgba="951E23FF",
|
||||
)
|
||||
spool.k_profiles = []
|
||||
spool.assignments = []
|
||||
db_session.add(spool)
|
||||
await db_session.flush()
|
||||
|
||||
mock_pm = MagicMock()
|
||||
mock_pm.get_status.return_value = None
|
||||
mock_pm.get_client.return_value = None
|
||||
|
||||
await auto_assign_spool(
|
||||
printer_id=printer.id,
|
||||
ams_id=1,
|
||||
tray_id=2,
|
||||
spool=spool,
|
||||
printer_manager=mock_pm,
|
||||
db=db_session,
|
||||
tray_info_idx="GFA50",
|
||||
)
|
||||
await db_session.commit()
|
||||
|
||||
result = await db_session.execute(
|
||||
sa_select(SlotPresetMapping).where(
|
||||
SlotPresetMapping.printer_id == printer.id,
|
||||
SlotPresetMapping.ams_id == 1,
|
||||
SlotPresetMapping.tray_id == 2,
|
||||
)
|
||||
)
|
||||
mapping = result.scalar_one()
|
||||
assert mapping.preset_name == "Bambu PLA-CF"
|
||||
assert mapping.preset_id == "GFSA50"
|
||||
assert mapping.preset_source == "cloud"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_auto_assign_inserts_slot_preset_when_absent(db_session, printer_factory):
|
||||
"""No pre-existing row → auto-assign inserts one. Pairs with the upsert
|
||||
case to keep both branches of the helper covered from this path."""
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from sqlalchemy import select as sa_select
|
||||
|
||||
from backend.app.models.slot_preset import SlotPresetMapping
|
||||
|
||||
printer = await printer_factory()
|
||||
spool = Spool(
|
||||
material="PLA-CF",
|
||||
brand="Bambu Lab",
|
||||
label_weight=1000,
|
||||
core_weight=250,
|
||||
slicer_filament="GFA50",
|
||||
slicer_filament_name="Bambu PLA-CF",
|
||||
)
|
||||
spool.k_profiles = []
|
||||
spool.assignments = []
|
||||
db_session.add(spool)
|
||||
await db_session.flush()
|
||||
|
||||
mock_pm = MagicMock()
|
||||
mock_pm.get_status.return_value = None
|
||||
mock_pm.get_client.return_value = None
|
||||
|
||||
await auto_assign_spool(
|
||||
printer_id=printer.id,
|
||||
ams_id=0,
|
||||
tray_id=3,
|
||||
spool=spool,
|
||||
printer_manager=mock_pm,
|
||||
db=db_session,
|
||||
tray_info_idx="GFA50",
|
||||
)
|
||||
await db_session.commit()
|
||||
|
||||
result = await db_session.execute(
|
||||
sa_select(SlotPresetMapping).where(
|
||||
SlotPresetMapping.printer_id == printer.id,
|
||||
SlotPresetMapping.ams_id == 0,
|
||||
SlotPresetMapping.tray_id == 3,
|
||||
)
|
||||
)
|
||||
mapping = result.scalar_one()
|
||||
assert mapping.preset_id == "GFSA50"
|
||||
assert mapping.preset_name == "Bambu PLA-CF"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_auto_assign_local_preset_uses_local_prefix(db_session, printer_factory):
|
||||
"""Spools whose slicer_filament is a numeric local-preset id get saved
|
||||
with a `local_{n}` preset_id (matches the manual-assign path's shape).
|
||||
"""
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from sqlalchemy import select as sa_select
|
||||
|
||||
from backend.app.models.slot_preset import SlotPresetMapping
|
||||
|
||||
printer = await printer_factory()
|
||||
spool = Spool(
|
||||
material="PLA",
|
||||
brand="Bambu Lab",
|
||||
label_weight=1000,
|
||||
core_weight=250,
|
||||
slicer_filament="50", # numeric → local preset
|
||||
slicer_filament_name="My Custom PLA",
|
||||
)
|
||||
spool.k_profiles = []
|
||||
spool.assignments = []
|
||||
db_session.add(spool)
|
||||
await db_session.flush()
|
||||
|
||||
mock_pm = MagicMock()
|
||||
mock_pm.get_status.return_value = None
|
||||
mock_pm.get_client.return_value = None
|
||||
|
||||
await auto_assign_spool(
|
||||
printer_id=printer.id,
|
||||
ams_id=0,
|
||||
tray_id=0,
|
||||
spool=spool,
|
||||
printer_manager=mock_pm,
|
||||
db=db_session,
|
||||
)
|
||||
await db_session.commit()
|
||||
|
||||
result = await db_session.execute(
|
||||
sa_select(SlotPresetMapping).where(
|
||||
SlotPresetMapping.printer_id == printer.id,
|
||||
SlotPresetMapping.ams_id == 0,
|
||||
SlotPresetMapping.tray_id == 0,
|
||||
)
|
||||
)
|
||||
mapping = result.scalar_one()
|
||||
assert mapping.preset_id == "local_50"
|
||||
assert mapping.preset_source == "local"
|
||||
assert mapping.preset_name == "My Custom PLA"
|
||||
|
||||
Reference in New Issue
Block a user