Files
bambuddy/backend/tests/unit/test_inventory_remain_endpoint.py
T
maziggy d5c7047765 Name an AMS slot after the spool assigned to it
The print dialog described every slot from the printer's own telemetry, and
a printer cannot describe a spool it did not sell: a tray record carries no
brand field, tray_sub_brands is left empty for anything that is not a Bambu
spool, and the colour arrives as a bare hex the client resolves against
Bambu's own colour catalogue. A Devil Design PLA Basic Orange assigned in
Bambuddy therefore read as "PLA (Sunflower Yellow)" -- Bambu sell a
Sunflower Yellow at the same FEC600 -- while the printer card, which reads
the assignment, named it correctly. Two views of one slot, disagreeing.

GET /printers/{id}/inventory-remain now carries each bound slot's brand,
material, subtype, colour name and hex alongside the pooling key it already
sent, and the dialog prefers that over telemetry. The fallback is per field,
not all or nothing, so a spool with no stored colour name still gets the
catalogue lookup it had before while its brand and subtype come from the
binding. Resolved server-side because the identity rule differs per
inventory mode -- brand is a column in internal mode and a nested vendor in
Spoolman's, where the subtype is the filament name with its material prefix
stripped and the colour name has a three-step read order Spoolman has no
field for. Spoolman's synthesised colour name, which falls back to the
subtype, is withheld rather than rendered as "PLA Basic (Basic)".

Matching is deliberately untouched and still runs on the printer's
telemetry. The auto-assignment, the colour-mismatch test and the mapping
that actually gets dispatched all read type, colour hex and tray_info_idx,
so renaming a slot cannot make the panel and the dispatcher draw different
conclusions from it. The payload is re-read on every open of the dialog: it
names the slots now, and a spool assigned moments earlier would otherwise
keep its old name for the rest of the thirty-second stale window. Done at
the two readers rather than by invalidating the key from each of the
eighteen places a binding or a spool can change, half of which are internal
paths and half Spoolman ones -- covering some would make freshness depend on
which mode you run.

Two hardening fixes fall out of putting a mapper on this path.
build_slot_materials runs before every queue start through
compute_deficit_for_queue_item, and _map_spoolman_spool walks a dozen nested
fields off the wire, any of which arriving as the wrong type raises
AttributeError rather than ValueError. Naming a slot must never cost a
dispatch, so that call fails soft to no name. The same inputs also reached
_material_identity_spoolman and _normalize_color_for_id, which have always
been on this path and would fail a queue start on a Spoolman record whose
filament is not a dict or whose color_hex is a number; both now read those
as "nothing to pool with". Behaviour for well-formed input is unchanged --
the guards only intercept types that previously raised -- so no pooling key
moves and AMS Filament Backup is untouched.
2026-08-27 16:04:41 +02:00

210 lines
8.2 KiB
Python

"""Tests for GET /printers/{id}/inventory-remain (#1766).
The endpoint exposes the same `_build_inventory_remain_overrides` map the
dispatcher uses so PrintModal's client-side "Prefer Lowest Remaining Filament"
sort agrees with what gets dispatched — closes the gap where Spoolman-mode
users couldn't see inventory grams from the frontend.
It also carries `slot_materials`: every inventory binding with the backend's
material identity and extruder side, which is what lets the modal's pre-flight
filament check pool spools under AMS Filament Backup the way the dispatcher
does instead of resolving spools itself.
"""
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from backend.app.api.routes.printers import get_inventory_remain
from backend.app.services.filament_deficit import SlotMaterial, SlotSpoolIdentity
@pytest.fixture
def db():
return MagicMock()
async def _call_endpoint(db, printer_id=1):
return await get_inventory_remain(printer_id=printer_id, _=None, db=db)
class TestGetInventoryRemain:
@pytest.mark.asyncio
async def test_returns_empty_when_printer_has_no_status(self, db):
# Printer disconnected / unknown — endpoint must not error, return {}.
with patch(
"backend.app.services.printer_manager.printer_manager.get_status",
return_value=None,
):
result = await _call_endpoint(db)
assert result == {"inventory_remain_g": {}, "slot_materials": []}
@pytest.mark.asyncio
async def test_serialises_globaltrayid_keys_as_strings(self, db):
# JSON requires string keys; client converts back to Number on receive.
# Asserts the key-shape contract the frontend depends on.
state = SimpleNamespace(raw_data={})
with (
patch(
"backend.app.services.printer_manager.printer_manager.get_status",
return_value=state,
),
patch(
"backend.app.services.print_scheduler.PrintScheduler._build_loaded_filaments",
return_value=[
{"ams_id": 0, "tray_id": 0, "global_tray_id": 0, "is_external": False},
{"ams_id": 0, "tray_id": 3, "global_tray_id": 3, "is_external": False},
],
),
patch(
"backend.app.services.print_scheduler.PrintScheduler._build_inventory_remain_overrides",
new=AsyncMock(return_value={0: 950.0, 3: 50.0}),
),
patch(
"backend.app.services.filament_deficit.build_slot_materials",
new=AsyncMock(return_value=[]),
),
):
result = await _call_endpoint(db)
assert result["inventory_remain_g"] == {"0": 950.0, "3": 50.0}
@pytest.mark.asyncio
async def test_returns_empty_dict_when_no_bound_slots(self, db):
# Loaded filaments exist but none are bound to an inventory spool.
# Backend returns {}; route serialises it unchanged.
state = SimpleNamespace(raw_data={})
with (
patch(
"backend.app.services.printer_manager.printer_manager.get_status",
return_value=state,
),
patch(
"backend.app.services.print_scheduler.PrintScheduler._build_loaded_filaments",
return_value=[
{"ams_id": 0, "tray_id": 0, "global_tray_id": 0, "is_external": False},
],
),
patch(
"backend.app.services.print_scheduler.PrintScheduler._build_inventory_remain_overrides",
new=AsyncMock(return_value={}),
),
patch(
"backend.app.services.filament_deficit.build_slot_materials",
new=AsyncMock(return_value=[]),
),
):
result = await _call_endpoint(db)
assert result["inventory_remain_g"] == {}
@pytest.mark.asyncio
async def test_slot_materials_carry_identity_and_extruder(self, db):
# The modal groups on (material_key, extruder) to decide what AMS
# Filament Backup can pool, so both fields have to survive the wire.
# Unlike inventory_remain_g this covers every binding, not just the
# slots currently loaded — the dispatcher pools all of them.
state = SimpleNamespace(raw_data={})
with (
patch(
"backend.app.services.printer_manager.printer_manager.get_status",
return_value=state,
),
patch(
"backend.app.services.print_scheduler.PrintScheduler._build_loaded_filaments",
return_value=[],
),
patch(
"backend.app.services.print_scheduler.PrintScheduler._build_inventory_remain_overrides",
new=AsyncMock(return_value={}),
),
patch(
"backend.app.services.filament_deficit.build_slot_materials",
new=AsyncMock(
return_value=[
SlotMaterial(
ams_id=0,
tray_id=2,
global_tray_id=2,
material_key="preset:PFUS6488|color:616777",
remaining_grams=1000.0,
extruder=0,
),
]
),
),
):
result = await _call_endpoint(db)
assert result["slot_materials"] == [
{
"ams_id": 0,
"tray_id": 2,
"global_tray_id": 2,
"material_key": "preset:PFUS6488|color:616777",
"remaining_g": 1000.0,
"extruder": 0,
# Present even when there is nothing to say, so the client can
# branch on the field rather than on its absence.
"spool": None,
}
]
@pytest.mark.asyncio
async def test_slot_materials_carry_the_bound_spool_s_display_identity(self, db):
"""What the printer cannot say about a slot has to reach the client here.
A tray record has no brand field and reports no sub-brand for anything
that isn't a Bambu spool, so the print dialog named the reporter's
Devil Design PLA Basic Orange after whichever catalogue colour shares
its hex — "PLA (Sunflower Yellow)" — while the printer card, which
reads the assignment, had it right.
"""
state = SimpleNamespace(raw_data={})
with (
patch(
"backend.app.services.printer_manager.printer_manager.get_status",
return_value=state,
),
patch(
"backend.app.services.print_scheduler.PrintScheduler._build_loaded_filaments",
return_value=[],
),
patch(
"backend.app.services.print_scheduler.PrintScheduler._build_inventory_remain_overrides",
new=AsyncMock(return_value={}),
),
patch(
"backend.app.services.filament_deficit.build_slot_materials",
new=AsyncMock(
return_value=[
SlotMaterial(
ams_id=2,
tray_id=0,
global_tray_id=8,
material_key="unmatched:85",
remaining_grams=640.0,
extruder=1,
spool=SlotSpoolIdentity(
brand="Devil Design",
material="PLA",
subtype="Basic",
color_name="Orange",
rgba="FEC600FF",
),
),
]
),
),
):
result = await _call_endpoint(db)
assert result["slot_materials"][0]["spool"] == {
"brand": "Devil Design",
"material": "PLA",
"subtype": "Basic",
"color_name": "Orange",
"rgba": "FEC600FF",
}