mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-09-30 11:12:35 +02:00
Reporter has a P1S with an inventory spool cloned to slot 1 and the
original (much further used) in slot 4, the preference enabled, and
the dispatch picked slot 1 every time. The sort's been blind to
Bambuddy inventory weights — it reads MQTT `tray.remain`, the
printer firmware's RFID-decremented value, which has two limitations:
- Bambu RFID only. Non-RFID spools report -1 and get clamped to a
sentinel; multiple non-RFID trays then tie in the sort and Python's
stable sort collapses to AMS-slot insertion order, so slot 1 wins.
- Even when set, it's the printer's counter, not Bambuddy's
`label_weight - weight_used` (internal) or Spoolman's
`remaining_weight`. The two diverge whenever the user re-spools,
swaps cardboard, or runs a print outside Bambuddy.
The reporter is on internal-inventory mode with non-RFID spools — both
failure modes apply, hence slot 1 every time.
Fix: when a slot is bound to an inventory spool the inventory record's
remaining weight becomes the sort signal. New async helper
`_build_inventory_remain_overrides(db, printer_id, loaded)` returns
`{global_tray_id: remaining_grams}` for bound slots — internal mode
joins SpoolAssignment → Spool once per dispatch; Spoolman mode joins
SpoolmanSlotAssignment then reuses `_spoolman_remaining_grams` from
filament_deficit.py for parity.
New `_prefer_lowest_sort_key` does a two-tier comparison: inventory-
tracked spools sort BEFORE MQTT-only spools, then ascending by
remaining within each tier, then ascending by ams_id*4+tray_id as the
deterministic slot tie-breaker. The tier flag dominates so grams
(inventory) and percent (MQTT) never get cross-compared — no unit
conversion needed.
MQTT-only behaviour is preserved exactly: remain=-1 still maps to the
101 sentinel and slot order still decides on ties. Users who haven't
bound any inventory spool see no change. The DB lookup runs only when
prefer_lowest_filament is enabled.
External / VT slots are skipped (tracked separately from AMS bindings).
195 lines
8.7 KiB
Python
195 lines
8.7 KiB
Python
"""Tests for the inventory-remain override builder in print_scheduler (#1508).
|
|
|
|
The MQTT ``remain`` field on an AMS tray is the printer firmware's
|
|
RFID-tracked value, which is ``-1`` for non-Bambu spools (and even when
|
|
set diverges from Bambuddy's inventory). When the user has bound an
|
|
inventory spool to an AMS slot, that inventory record's
|
|
``label_weight - weight_used`` (or Spoolman's ``remaining_weight``) is
|
|
the authoritative remaining-weight signal. These tests verify
|
|
``_build_inventory_remain_overrides`` surfaces those values keyed by
|
|
``global_tray_id`` so the "Prefer Lowest Remaining Filament" sort can
|
|
consume them.
|
|
"""
|
|
|
|
from types import SimpleNamespace
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
from backend.app.services.print_scheduler import PrintScheduler
|
|
|
|
|
|
@pytest.fixture
|
|
def scheduler():
|
|
return PrintScheduler()
|
|
|
|
|
|
def _make_async_session_returning(rows: list):
|
|
"""Build a stub AsyncSession whose .execute() returns an object whose
|
|
.all() (and .scalars().all()) yield ``rows``."""
|
|
result = MagicMock()
|
|
result.all.return_value = rows
|
|
scalars = MagicMock()
|
|
scalars.all.return_value = rows
|
|
result.scalars.return_value = scalars
|
|
db = MagicMock()
|
|
db.execute = AsyncMock(return_value=result)
|
|
return db
|
|
|
|
|
|
class TestInternalInventoryOverrides:
|
|
@pytest.mark.asyncio
|
|
async def test_returns_remaining_grams_for_bound_slots(self, scheduler):
|
|
"""Two slots bound; both come back keyed by global_tray_id with the
|
|
correct ``label_weight - weight_used`` in grams. This is the
|
|
reporter scenario in #1508: slot 1 has a 950 g clone, slot 4 has
|
|
a 50 g original — the sort can now actually pick the 50 g spool.
|
|
|
|
The override builder uses ``select(SpoolAssignment).options(
|
|
selectinload(SpoolAssignment.spool))`` (matching the rest of the
|
|
codebase), so the rows it iterates expose ``.ams_id``, ``.tray_id``
|
|
and ``.spool`` directly — the test stubs the same shape.
|
|
"""
|
|
spool_a = SimpleNamespace(label_weight=1000, weight_used=50) # 950 g remaining
|
|
spool_b = SimpleNamespace(label_weight=1000, weight_used=950) # 50 g remaining
|
|
rows = [
|
|
SimpleNamespace(ams_id=0, tray_id=0, spool=spool_a),
|
|
SimpleNamespace(ams_id=0, tray_id=3, spool=spool_b),
|
|
]
|
|
loaded = [
|
|
{"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},
|
|
]
|
|
db = _make_async_session_returning(rows)
|
|
with patch.object(PrintScheduler, "_is_spoolman_mode", new=AsyncMock(return_value=False)):
|
|
out = await scheduler._build_inventory_remain_overrides(db, printer_id=1, loaded=loaded)
|
|
assert out == {0: 950.0, 3: 50.0}
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_skips_external_slots(self, scheduler):
|
|
"""VT / external slots are tracked separately from AMS inventory
|
|
bindings — the override builder must not assign them an inventory
|
|
remaining value even if (somehow) an assignment row exists.
|
|
"""
|
|
loaded = [
|
|
{"ams_id": -1, "tray_id": 0, "global_tray_id": 254, "is_external": True},
|
|
]
|
|
db = _make_async_session_returning([])
|
|
with patch.object(PrintScheduler, "_is_spoolman_mode", new=AsyncMock(return_value=False)):
|
|
out = await scheduler._build_inventory_remain_overrides(db, printer_id=1, loaded=loaded)
|
|
# DB shouldn't even be queried — nothing AMS-side to look up.
|
|
db.execute.assert_not_called()
|
|
assert out == {}
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_empty_loaded_returns_empty(self, scheduler):
|
|
"""No loaded filaments → no overrides. The scheduler short-circuits
|
|
before this is called in practice, but the function must be
|
|
defensive — it's used in any prefer_lowest dispatch path."""
|
|
db = _make_async_session_returning([])
|
|
with patch.object(PrintScheduler, "_is_spoolman_mode", new=AsyncMock(return_value=False)):
|
|
out = await scheduler._build_inventory_remain_overrides(db, printer_id=1, loaded=[])
|
|
assert out == {}
|
|
db.execute.assert_not_called()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_negative_remaining_clamped_to_zero(self, scheduler):
|
|
"""An over-consumed spool (weight_used > label_weight) shouldn't
|
|
produce a negative grams value — clamped to 0 so the sort treats
|
|
it as fully empty rather than "more empty than zero."
|
|
"""
|
|
spool = SimpleNamespace(label_weight=1000, weight_used=1100)
|
|
rows = [
|
|
SimpleNamespace(ams_id=0, tray_id=0, spool=spool),
|
|
]
|
|
loaded = [{"ams_id": 0, "tray_id": 0, "global_tray_id": 0, "is_external": False}]
|
|
db = _make_async_session_returning(rows)
|
|
with patch.object(PrintScheduler, "_is_spoolman_mode", new=AsyncMock(return_value=False)):
|
|
out = await scheduler._build_inventory_remain_overrides(db, printer_id=1, loaded=loaded)
|
|
assert out == {0: 0.0}
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_slot_without_binding_absent_from_overrides(self, scheduler):
|
|
"""A slot that has loaded filament but no inventory binding must
|
|
not appear in the override map — the sort then falls back to MQTT
|
|
``remain`` for that one slot, preserving pre-#1508 behaviour.
|
|
"""
|
|
rows = [
|
|
SimpleNamespace(
|
|
ams_id=0,
|
|
tray_id=0,
|
|
spool=SimpleNamespace(label_weight=1000, weight_used=100),
|
|
),
|
|
]
|
|
loaded = [
|
|
{"ams_id": 0, "tray_id": 0, "global_tray_id": 0, "is_external": False},
|
|
{"ams_id": 0, "tray_id": 1, "global_tray_id": 1, "is_external": False},
|
|
]
|
|
db = _make_async_session_returning(rows)
|
|
with patch.object(PrintScheduler, "_is_spoolman_mode", new=AsyncMock(return_value=False)):
|
|
out = await scheduler._build_inventory_remain_overrides(db, printer_id=1, loaded=loaded)
|
|
assert out == {0: 900.0}
|
|
assert 1 not in out
|
|
|
|
|
|
class TestSpoolmanModeOverrides:
|
|
@pytest.mark.asyncio
|
|
async def test_spoolman_remaining_grams_used_when_available(self, scheduler):
|
|
"""Spoolman mode: each bound slot's spoolman_spool_id is fetched
|
|
through ``_spoolman_remaining_grams``; the result is the same
|
|
global-tray-id-keyed grams map. Parity rule with internal mode
|
|
(feedback_inventory_modes_parity).
|
|
"""
|
|
rows = [
|
|
SimpleNamespace(printer_id=1, ams_id=0, tray_id=0, spoolman_spool_id=42),
|
|
SimpleNamespace(printer_id=1, ams_id=0, tray_id=2, spoolman_spool_id=99),
|
|
]
|
|
loaded = [
|
|
{"ams_id": 0, "tray_id": 0, "global_tray_id": 0, "is_external": False},
|
|
{"ams_id": 0, "tray_id": 2, "global_tray_id": 2, "is_external": False},
|
|
]
|
|
db = _make_async_session_returning(rows)
|
|
|
|
async def _fake_grams(spool_id: int):
|
|
return {42: 720.0, 99: 80.0}[spool_id]
|
|
|
|
with (
|
|
patch.object(PrintScheduler, "_is_spoolman_mode", new=AsyncMock(return_value=True)),
|
|
patch(
|
|
"backend.app.services.filament_deficit._spoolman_remaining_grams",
|
|
new=AsyncMock(side_effect=_fake_grams),
|
|
),
|
|
):
|
|
out = await scheduler._build_inventory_remain_overrides(db, printer_id=1, loaded=loaded)
|
|
assert out == {0: 720.0, 2: 80.0}
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_spoolman_unreachable_skips_silently(self, scheduler):
|
|
"""If Spoolman is unreachable for one spool, ``_spoolman_remaining_grams``
|
|
returns None and that slot is omitted from the override map —
|
|
sorting then falls back to MQTT remain for that slot only.
|
|
"""
|
|
rows = [
|
|
SimpleNamespace(printer_id=1, ams_id=0, tray_id=0, spoolman_spool_id=42),
|
|
SimpleNamespace(printer_id=1, ams_id=0, tray_id=1, spoolman_spool_id=99),
|
|
]
|
|
loaded = [
|
|
{"ams_id": 0, "tray_id": 0, "global_tray_id": 0, "is_external": False},
|
|
{"ams_id": 0, "tray_id": 1, "global_tray_id": 1, "is_external": False},
|
|
]
|
|
db = _make_async_session_returning(rows)
|
|
|
|
async def _fake_grams(spool_id: int):
|
|
return 500.0 if spool_id == 42 else None
|
|
|
|
with (
|
|
patch.object(PrintScheduler, "_is_spoolman_mode", new=AsyncMock(return_value=True)),
|
|
patch(
|
|
"backend.app.services.filament_deficit._spoolman_remaining_grams",
|
|
new=AsyncMock(side_effect=_fake_grams),
|
|
),
|
|
):
|
|
out = await scheduler._build_inventory_remain_overrides(db, printer_id=1, loaded=loaded)
|
|
assert out == {0: 500.0}
|
|
assert 1 not in out
|