Files
bambuddy/backend/tests/unit/test_inventory_remain_endpoint.py
maziggy 1a84dfea5b feat(print-modal): show each printer slot's colour in the filament mapping (issue #3159)
The Print / Schedule dialog's filament mapping is where the colour a slice
asked for is compared against the colour actually loaded, and only the
left-hand side of that comparison had a swatch. The slot, and every slot in
its dropdown, was text -- and the text cannot be trusted: a slot's colour name
is resolved from the Color Catalog or, failing that, from hue, so a
third-party beige is announced as "Orange". A "Color mismatch" warning then
gives no way to tell a real mismatch from two names for the same hex without
opening the printer card in another tab, which on a farm swapping twenty or
thirty non-Bambu colours between machines is a check made many times a day.

Each slot now carries its colour and its hex, and the slot whose colour is
exactly the one the slice asked for is ticked. This works for a slot bound to
an inventory spool and for one configured through Configure Slot or on the
printer itself: the second kind has no inventory row behind it, and the
printer's own tray colour is then what draws. A bound spool contributes what a
tray record cannot -- SlotSpoolIdentity gains extra_colors and effect_type, so
a two-tone or glittery spool draws as itself rather than as its base colour.

The same treatment goes to the filament-override picker used for model-based
assignment. It is the same choice on the other dispatch path, and leaving it
text-only would have made one decision read two ways.

Both controls stop being <select>s to do it, because an <option> renders text
and nothing else. SlotPicker keeps what the select gave for free -- arrow,
Home/End, Enter and Escape keys, listbox semantics, and the border colouring
that encodes match, same-type-different-colour and not-loaded -- and is
portaled with position:fixed so it is not clipped by the dialog's own scroll
container, flipping above the row when there is no room below.
2026-09-25 11:38:13 +02:00

214 lines
8.4 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",
# The rest of the colour, for the slot picker's swatch (#3159).
# None here: this spool is a single flat colour.
"extra_colors": None,
"effect_type": None,
}