Files
bambuddy/backend/tests/integration/test_ams_slot_material_2902.py
maziggy 7b181b84f0 Let a filled or foamed filament keep its own name (issue #2902)
The reduction that gave an AMS slot a material type read PLA-AERO,
PLA-GF, ASA-GF and PPS-GF as their base material, so a slot loaded with
foaming or glass-filled filament went out saying plain PLA or ASA. That
is worse than the bug it replaced. "PLA-AERO" matched nothing before,
which was useless but honest; "PLA" matches every PLA plate in the
queue, so the dispatcher would have sent one to filament that will not
print it -- and the contract the first fix claimed, that it could only
ever repair a slot, no longer held. @doncaruana caught PLA Aero on the
issue.

All four are values Bambuddy itself offers: filament_fields.json is the
material list the Profiles editor puts in a dropdown, and the reduction
table was assembled from the cloud filament names and the frontend
preset parser without ever being checked against it. It is checked now,
so the next type added to one and not the other fails a test rather than
a print. ASA-AERO joins them from the cloud catalogue (GFB02).

The table hyphenates because the slicers do, while a spool says "PLA
Aero" and every Bambu preset name says "Bambu PLA Aero". Adjacent words
are joined and taken when the join is a type exactly -- exactly, because
letting the prefix and suffix rules reach across a space would make
"Support for PLA" a type by its tail.

Also from @doncaruana, and the better half of his point: a preset is
chosen from a list the slicer defines, so it already knows its own type
and nothing has to be read out of a product name. The resolver now hands
that answer back and both assign routes prefer it. It cannot be the only
source -- material is required on a spool and slicer_filament is not, and
the spool this issue was reported for had no preset at all -- so the
reduction stays as the fallback for spools without one.

Two things had to move with it. The auto-unlink guard compared the
slot's reported type against the reduced material, so a spool whose
preset outranked its material column would have been unlinked from the
slot it had just been assigned to; it now accepts any type the assign
path could have written. And two lookups keyed by material took the
catch-all for a type they had no row for, which sent an ASA-GF spool out
at 200/240 -- too cold to extrude -- and preheated its chamber to
nothing. Both fall back to the base material last, so PLA-CF, PETG-CF
and PA-CF keep the rows they are listed with, and ASA-CF and ABS-GF pick
up ranges they had been missing all along.

What counts as a material name is decided by the base for the same
reason: saying yes throws the value away and rescues the slot from the
generic-material fallback, so the answer has to be no when that fallback
has nothing to offer. ABS-GF reduces to a generic ABS the printer can
resolve; PPS-CF reduces to nothing and is left as it stands. Adding a
type to the table therefore cannot quietly change that answer, which is
how these five slipped through in the first place.

------

Hand the bundled chamber-preheat table back the way it is read

Every lookup of the per-filament chamber map happens after the keys are
upper-cased, and the parser documents exactly that: keys uppercased,
DEFAULT always present so the resolution loop can index it
unconditionally. The three fallback paths returned the bundled constant
as declared, with the lowercase "default" row the Settings editor writes
and displays, so an install that had never opened the setting got a dict
the loop could not read its fallback out of and used a hardcoded 0 for
any filament without a row of its own.

It reported the right number only because that bundled default is 0.
Raising it would have changed nothing for everyone who had not
customised the map, with the map in Settings still showing the value
that was not being used.

The test that should have caught this asserted the fallback under either
spelling, and its docstring contradicted itself between title and
comment. It pins the contract now, over all four ways the parser can
fall back.
2026-08-23 10:36:50 +02:00

892 lines
34 KiB
Python

"""What reaches an AMS slot when a spool's material is a product line (#2902).
The reporter assigned an eSUN PLA+ spool and the slot came out unusable: any
plate sliced with a PLA profile refused it. Four routes configure a slot and
all four wrote the spool's material straight into ``tray_type``, where "PLA+"
matches nothing -- not the slicer, and not Bambuddy's own dispatch matcher,
which compares the printer's reported ``tray_type`` to the 3MF's declared type
as plain equality.
Each test below asserts the whole slot, not just the type: an unrecognised
material also missed the generic-filament-id lookup, so the slot went out with
an empty ``tray_info_idx`` -- the half-configured state #2604 documents the
printer as reverting from -- and took the 200/240 catch-all temperatures
instead of PLA's.
"""
import json
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from httpx import AsyncClient
from sqlalchemy.ext.asyncio import AsyncSession
from backend.app.models.spool import Spool
def _mqtt_mock():
client = MagicMock()
client.ams_set_filament_setting.return_value = True
client.extrusion_cali_sel.return_value = True
return client
def _status(ams_data=None):
status = MagicMock()
status.raw_data = {"ams": {"ams": ams_data if ams_data is not None else []}}
status.nozzles = [MagicMock(nozzle_diameter="0.4")]
status.ams_extruder_map = None
status.kprofiles = []
return status
def _spoolman_spool(material, spool_id=11, slicer_filament=None):
extra = {}
if slicer_filament is not None:
extra["bambu_slicer_filament"] = json.dumps(str(slicer_filament))
return {
"id": spool_id,
"filament": {
"id": 1,
"name": "Cool White",
"material": material,
"color_hex": "E1E9E9",
"weight": 1000,
"vendor": {"id": 1, "name": "eSUN"},
},
"remaining_weight": 800.0,
"used_weight": 200.0,
"archived": False,
"extra": extra,
}
def _spoolman_client(spool):
client = MagicMock()
client.base_url = "http://localhost:7912"
client.health_check = AsyncMock(return_value=True)
client.get_spool = AsyncMock(return_value=spool)
client.get_spools = AsyncMock(return_value=[spool])
client.merge_spool_extra = AsyncMock(return_value=spool)
return client
class TestInternalInventoryAssign:
async def _assign(self, async_client, db_session, material, **spool_kwargs):
from backend.app.models.printer import Printer
printer = Printer(
name="P1S",
serial_number=f"MAT2902{material[:4]}",
ip_address="192.168.1.77",
access_code="12345678",
)
db_session.add(printer)
spool = Spool(
material=material,
brand="eSUN",
color_name="Cool White",
rgba="E1E9E9FF",
label_weight=1000,
weight_used=0,
**spool_kwargs,
)
db_session.add(spool)
await db_session.commit()
await db_session.refresh(printer)
await db_session.refresh(spool)
client = _mqtt_mock()
with patch("backend.app.services.printer_manager.printer_manager") as pm:
pm.get_client.return_value = client
pm.get_status.return_value = _status()
response = await async_client.post(
"/api/v1/inventory/assignments",
json={"spool_id": spool.id, "printer_id": printer.id, "ams_id": 0, "tray_id": 1},
)
assert response.status_code == 200
client.ams_set_filament_setting.assert_called_once()
return client.ams_set_filament_setting.call_args.kwargs
@pytest.mark.asyncio
@pytest.mark.integration
async def test_a_pla_plus_spool_configures_the_slot_as_pla(
self, async_client: AsyncClient, db_session: AsyncSession
):
sent = await self._assign(async_client, db_session, "PLA+")
assert sent["tray_type"] == "PLA"
# Not just the label: the id and its setting_id are what stop the
# printer treating the slot as half configured, and the temperatures
# are PLA's rather than the catch-all.
assert sent["tray_info_idx"] == "GFL99"
assert sent["setting_id"] == "GFSL99"
assert (sent["nozzle_temp_min"], sent["nozzle_temp_max"]) == (190, 230)
@pytest.mark.asyncio
@pytest.mark.integration
async def test_the_product_name_is_not_lost_it_moves_to_the_sub_brand(
self, async_client: AsyncClient, db_session: AsyncSession
):
"""Which is where Bambu itself puts it -- their own catalogue has a
preset named "eSUN PLA+" (GFL03) whose type is PLA."""
sent = await self._assign(async_client, db_session, "PLA+")
assert "PLA+" in sent["tray_sub_brands"]
@pytest.mark.asyncio
@pytest.mark.integration
async def test_a_material_that_already_resolved_keeps_its_own_preset(
self, async_client: AsyncClient, db_session: AsyncSession
):
""" "PETG HF" has a generic preset of its own (GFG96, "Generic PETG HF").
Reducing the material before the id lookup rather than after it would
trade that away for plain PETG's GFG99 -- a quiet downgrade of slots
that work today."""
sent = await self._assign(async_client, db_session, "PETG HF")
assert sent["tray_info_idx"] == "GFG96"
assert sent["tray_type"] == "PETG"
@pytest.mark.asyncio
@pytest.mark.integration
async def test_it_can_now_reuse_the_calibrated_preset_already_in_the_slot(
self, async_client: AsyncClient, db_session: AsyncSession
):
"""A slot already holding a specific preset keeps it when the incoming
spool is the same material -- that is how a printer's calibration
context survives an assignment. The comparison is against the slot's
reported type, so a PLA+ spool could never match a PLA slot and the
reuse branch was dead for every spool this issue is about."""
from backend.app.models.printer import Printer
printer = Printer(
name="Reuse P1S",
serial_number="MAT2902RU",
ip_address="192.168.1.81",
access_code="12345678",
)
db_session.add(printer)
spool = Spool(material="PLA+", brand="eSUN", rgba="E1E9E9FF", label_weight=1000, weight_used=0)
db_session.add(spool)
await db_session.commit()
await db_session.refresh(printer)
await db_session.refresh(spool)
client = _mqtt_mock()
live_slot = [{"id": 0, "tray": [{"id": 1, "tray_info_idx": "P4d64437", "tray_type": "PLA"}]}]
with patch("backend.app.services.printer_manager.printer_manager") as pm:
pm.get_client.return_value = client
pm.get_status.return_value = _status(live_slot)
response = await async_client.post(
"/api/v1/inventory/assignments",
json={"spool_id": spool.id, "printer_id": printer.id, "ams_id": 0, "tray_id": 1},
)
assert response.status_code == 200
sent = client.ams_set_filament_setting.call_args.kwargs
assert sent["tray_info_idx"] == "P4d64437"
@pytest.mark.asyncio
@pytest.mark.integration
async def test_but_it_does_not_reuse_a_product_name_a_previous_version_left_there(
self, async_client: AsyncClient, db_session: AsyncSession
):
"""A spool whose slicer_filament was free text could send that text to
the printer as the slot's filament id, and the printer reports it
straight back -- so an upgraded install can be looking at a slot that
says type PLA, id "PLA+". Reuse has always refused a bare material name
in that field; refusing a product line too is what stops the bad id
being carried forward on every assignment instead of replaced."""
from backend.app.models.printer import Printer
printer = Printer(
name="Stale P1S",
serial_number="MAT2902ST",
ip_address="192.168.1.82",
access_code="12345678",
)
db_session.add(printer)
spool = Spool(material="PLA", brand="eSUN", rgba="E1E9E9FF", label_weight=1000, weight_used=0)
db_session.add(spool)
await db_session.commit()
await db_session.refresh(printer)
await db_session.refresh(spool)
client = _mqtt_mock()
stale_slot = [{"id": 0, "tray": [{"id": 1, "tray_info_idx": "PLA+", "tray_type": "PLA"}]}]
with patch("backend.app.services.printer_manager.printer_manager") as pm:
pm.get_client.return_value = client
pm.get_status.return_value = _status(stale_slot)
response = await async_client.post(
"/api/v1/inventory/assignments",
json={"spool_id": spool.id, "printer_id": printer.id, "ams_id": 0, "tray_id": 1},
)
assert response.status_code == 200
sent = client.ams_set_filament_setting.call_args.kwargs
assert sent["tray_info_idx"] == "GFL99"
assert sent["tray_type"] == "PLA"
@pytest.mark.asyncio
@pytest.mark.integration
async def test_a_material_nothing_can_be_made_of_is_sent_unchanged(
self, async_client: AsyncClient, db_session: AsyncSession
):
"""The catalogue ships a few names with no filament type in them at all.
Guessing at those would be worse than leaving them: this route behaved
exactly this way before #2902, and still does."""
sent = await self._assign(async_client, db_session, "CPE HG100")
assert sent["tray_type"] == "CPE HG100"
assert sent["tray_info_idx"] == ""
@pytest.mark.asyncio
@pytest.mark.integration
async def test_a_free_text_slicer_filament_naming_a_product_is_not_a_filament_id(
self, async_client: AsyncClient, db_session: AsyncSession
):
"""slicer_filament is free text on older spools, so "PLA+" can be sitting
in it. It is as unusable a tray_info_idx as the bare "PLA" the resolver
already discarded, and letting it through would put a product name in
the field the printer keys its calibration table by."""
sent = await self._assign(async_client, db_session, "PLA+", slicer_filament="PLA+")
assert sent["tray_info_idx"] == "GFL99"
class TestSpoolmanInventoryAssign:
@pytest.fixture
async def settings(self, db_session):
from backend.app.models.settings import Settings
db_session.add(Settings(key="spoolman_enabled", value="true"))
db_session.add(Settings(key="spoolman_url", value="http://localhost:7912"))
await db_session.commit()
@pytest.fixture
async def printer(self, db_session):
from backend.app.models.printer import Printer
p = Printer(
name="Spoolman P1S",
serial_number="MAT2902SM",
ip_address="192.168.1.78",
access_code="12345678",
)
db_session.add(p)
await db_session.commit()
await db_session.refresh(p)
return p
async def _assign(self, async_client, printer, material, slicer_filament=None):
mqtt = _mqtt_mock()
spool = _spoolman_spool(material, slicer_filament=slicer_filament)
with (
patch("backend.app.api.routes.spoolman_inventory.printer_manager") as pm,
patch(
"backend.app.api.routes.spoolman_inventory.get_spoolman_client",
AsyncMock(return_value=_spoolman_client(spool)),
),
):
pm.get_client.return_value = mqtt
pm.get_status.return_value = _status()
response = await async_client.post(
"/api/v1/spoolman/inventory/slot-assignments",
json={"spoolman_spool_id": 11, "printer_id": printer.id, "ams_id": 0, "tray_id": 2},
)
assert response.status_code == 200
return mqtt.ams_set_filament_setting.call_args.kwargs
@pytest.mark.asyncio
@pytest.mark.integration
async def test_spoolmans_free_text_material_is_reduced_the_same_way(
self, async_client: AsyncClient, settings, printer
):
"""Spoolman's material field is free text too, so the same product names
arrive by this route -- and it is the route the reporter used."""
sent = await self._assign(async_client, printer, "PLA+")
assert sent["tray_type"] == "PLA"
assert sent["tray_info_idx"] == "GFL99"
# Exactly, not just "contains PLA+": the filament's own name is in this
# string too, so a substring check would pass even if the material had
# been reduced before it was built.
assert sent["tray_sub_brands"] == "eSUN PLA+ Cool White"
@pytest.mark.asyncio
@pytest.mark.integration
async def test_it_keeps_this_routes_own_preset_for_a_material_that_had_one(
self, async_client: AsyncClient, settings, printer
):
sent = await self._assign(async_client, printer, "PETG HF")
assert sent["tray_info_idx"] == "GFG96"
assert sent["tray_type"] == "PETG"
@pytest.mark.asyncio
@pytest.mark.integration
async def test_the_resolver_is_handed_the_spools_own_wording_not_the_type(
self, async_client: AsyncClient, settings, printer, db_session
):
"""This route also passes the material down to the slicer-filament
resolver. Handing that the reduced type instead would look harmless and
quietly downgrade GFG96 to GFG99 whenever the spool points at a local
preset with no filament_id of its own."""
from backend.app.models.local_preset import LocalPreset
lp = LocalPreset(name="Generic PETG HF", preset_type="filament", source="orcaslicer", setting="{}")
db_session.add(lp)
await db_session.commit()
await db_session.refresh(lp)
sent = await self._assign(async_client, printer, "PETG HF", slicer_filament=lp.id)
assert sent["tray_info_idx"] == "GFG96"
class TestConfigureSlotModal:
@pytest.mark.asyncio
@pytest.mark.integration
async def test_a_product_line_typed_into_the_modal_is_reduced_too(self, async_client: AsyncClient, printer_factory):
"""The Configure Slot modal derives tray_type from a preset name or the
spool's material, so it can hand the backend a product line as readily
as the assignment routes can."""
printer = await printer_factory(model="P1S")
client = _mqtt_mock()
with patch("backend.app.api.routes.printers.printer_manager") as pm:
pm.get_client.return_value = client
pm.get_status.return_value = _status()
response = await async_client.post(
f"/api/v1/printers/{printer.id}/slots/0/1/configure",
params={
"tray_info_idx": "",
"tray_type": "PLA+",
"tray_sub_brands": "eSUN PLA+",
"tray_color": "E1E9E9FF",
"nozzle_temp_min": 190,
"nozzle_temp_max": 230,
},
)
assert response.status_code == 200
sent = client.ams_set_filament_setting.call_args.kwargs
assert sent["tray_type"] == "PLA"
# The empty tray_info_idx the modal sent for a generic material is what
# the reduced type now rescues.
assert sent["tray_info_idx"] == "GFL99"
assert sent["tray_sub_brands"] == "eSUN PLA+"
class TestSpoolmanLink:
"""The fourth route that configures a slot: linking a Spoolman spool to a
slot's tag auto-configures it too, from the same free-text material."""
@pytest.fixture
async def settings(self, db_session):
from backend.app.models.settings import Settings
db_session.add(Settings(key="spoolman_enabled", value="true"))
db_session.add(Settings(key="spoolman_url", value="http://localhost:7912"))
await db_session.commit()
@pytest.fixture
async def printer(self, db_session):
from backend.app.models.printer import Printer
p = Printer(
name="Link P1S",
serial_number="MAT2902LK",
ip_address="192.168.1.79",
access_code="12345678",
)
db_session.add(p)
await db_session.commit()
await db_session.refresh(p)
return p
async def _link(self, async_client, printer, material):
client = _spoolman_client(_spoolman_spool(material, spool_id=12))
mqtt = _mqtt_mock()
with (
patch("backend.app.api.routes.spoolman.get_spoolman_client", AsyncMock(return_value=client)),
patch("backend.app.api.routes.spoolman.init_spoolman_client", AsyncMock(return_value=client)),
patch("backend.app.api.routes.spoolman.printer_manager") as pm,
):
pm.get_client.return_value = mqtt
pm.get_status.return_value = _status()
response = await async_client.post(
"/api/v1/spoolman/spools/12/link",
json={
"tray_uuid": "A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4",
"printer_id": printer.id,
"ams_id": 0,
"tray_id": 3,
},
)
assert response.status_code == 200
return mqtt.ams_set_filament_setting.call_args.kwargs
@pytest.mark.asyncio
@pytest.mark.integration
async def test_linking_a_pla_plus_spool_configures_the_slot_as_pla(
self, async_client: AsyncClient, settings, printer
):
sent = await self._link(async_client, printer, "PLA+")
assert sent["tray_type"] == "PLA"
assert sent["tray_info_idx"] == "GFL99"
assert sent["tray_sub_brands"] == "eSUN PLA+ Cool White"
assert (sent["nozzle_temp_min"], sent["nozzle_temp_max"]) == (190, 230)
@pytest.mark.asyncio
@pytest.mark.integration
async def test_it_keeps_this_routes_own_preset_for_a_material_that_had_one(
self, async_client: AsyncClient, settings, printer
):
sent = await self._link(async_client, printer, "PETG HF")
assert sent["tray_info_idx"] == "GFG96"
assert sent["tray_type"] == "PETG"
class TestALocalPresetThatNamesNoFilamentId:
"""The one path where the material reaches the slicer-filament resolver
rather than the route's own fallback: a spool pointing at an imported local
preset whose setting JSON carries no filament_id. The resolver then has only
the material to go on, so it has to read it the same way -- and be handed
the spool's own wording, not the reduced type."""
@pytest.fixture
async def preset(self, db_session):
from backend.app.models.local_preset import LocalPreset
lp = LocalPreset(
name="eSUN PLA+ @BBL P1S",
preset_type="filament",
source="orcaslicer",
filament_type=None,
setting="{}",
)
db_session.add(lp)
await db_session.commit()
await db_session.refresh(lp)
return lp
@pytest.fixture
async def printer(self, db_session):
from backend.app.models.printer import Printer
p = Printer(
name="LP P1S",
serial_number="MAT2902LP",
ip_address="192.168.1.80",
access_code="12345678",
)
db_session.add(p)
await db_session.commit()
await db_session.refresh(p)
return p
async def _assign(self, async_client, db_session, printer, preset, material):
spool = Spool(
material=material,
brand="eSUN",
rgba="E1E9E9FF",
label_weight=1000,
weight_used=0,
slicer_filament=str(preset.id),
)
db_session.add(spool)
await db_session.commit()
await db_session.refresh(spool)
client = _mqtt_mock()
with patch("backend.app.services.printer_manager.printer_manager") as pm:
pm.get_client.return_value = client
pm.get_status.return_value = _status()
response = await async_client.post(
"/api/v1/inventory/assignments",
json={"spool_id": spool.id, "printer_id": printer.id, "ams_id": 0, "tray_id": 0},
)
assert response.status_code == 200
return client.ams_set_filament_setting.call_args.kwargs
@pytest.mark.asyncio
@pytest.mark.integration
async def test_the_resolver_places_a_product_line_too(
self, async_client: AsyncClient, db_session: AsyncSession, printer, preset
):
sent = await self._assign(async_client, db_session, printer, preset, "PLA+")
assert sent["tray_info_idx"] == "GFL99"
@pytest.mark.asyncio
@pytest.mark.integration
async def test_and_still_prefers_a_material_that_has_its_own_preset(
self, async_client: AsyncClient, db_session: AsyncSession, printer, preset
):
sent = await self._assign(async_client, db_session, printer, preset, "PETG HF")
assert sent["tray_info_idx"] == "GFG96"
class TestTheAssignmentSurvivesTheSlotItJustConfigured:
"""The other side of the same coin, and the one that bites hardest.
on_ams_change auto-unlinks an assignment whose slot no longer looks like it
did when the spool was assigned. The fingerprint is snapshotted *before* the
MQTT config goes out, so the very next AMS push after an assignment is a
mismatch by construction -- and what saves the assignment is a second check:
does the tray match the assigned spool now? That check read the spool's raw
material, which the slot no longer carries, so every spool this issue is
about would have been silently unlinked from the slot it had just been
assigned to. Correct in isolation, ruinous together.
"""
async def _push(
self,
db_session,
printer_factory,
spool_material,
reported_type,
fingerprint_type="PETG",
**spool_kwargs,
):
from unittest.mock import AsyncMock
from backend.app.main import on_ams_change
from backend.app.models.spool_assignment import SpoolAssignment
printer = await printer_factory(name="H2D")
spool = Spool(
material=spool_material,
brand="eSUN",
rgba="E1E9E9FF",
label_weight=1000,
weight_used=0,
**spool_kwargs,
)
db_session.add(spool)
await db_session.commit()
await db_session.refresh(spool)
assignment = SpoolAssignment(
spool_id=spool.id,
printer_id=printer.id,
ams_id=0,
tray_id=2,
fingerprint_color="E1E9E9FF",
fingerprint_type=fingerprint_type,
)
db_session.add(assignment)
await db_session.commit()
assignment_id = assignment.id
ams_data = [{"id": 0, "tray": [{"id": 2, "tray_type": reported_type, "tray_color": "E1E9E9FF", "state": 11}]}]
status = _status(ams_data)
status.state = "IDLE"
with (
patch("backend.app.main.printer_manager") as pm,
patch("backend.app.main.mqtt_relay") as relay,
patch("backend.app.main.ws_manager") as ws,
):
pm.get_printer.return_value = MagicMock(name="H2D", serial_number="0948BB540200427")
pm.get_status.return_value = status
pm.get_model.return_value = "H2D"
relay.on_ams_change = AsyncMock()
ws.send_printer_status = AsyncMock()
ws.broadcast = AsyncMock()
await on_ams_change(printer.id, ams_data)
# on_ams_change commits through its own session.
db_session.expunge_all()
return await db_session.get(SpoolAssignment, assignment_id)
@pytest.mark.asyncio
@pytest.mark.integration
async def test_a_pla_plus_spool_is_not_unlinked_from_the_slot_now_reporting_pla(
self, async_client: AsyncClient, db_session: AsyncSession, printer_factory
):
surviving = await self._push(db_session, printer_factory, "PLA+", reported_type="PLA")
assert surviving is not None, "the slot reports what we wrote to it -- that is a match, not a swap"
@pytest.mark.asyncio
@pytest.mark.integration
async def test_nor_is_one_in_a_slot_an_older_version_configured(
self, async_client: AsyncClient, db_session: AsyncSession, printer_factory
):
"""An install upgrading into this fix has slots still reporting "PLA+"
until something reconfigures them. Reducing only the spool's side would
break those the moment they were left alone."""
surviving = await self._push(db_session, printer_factory, "PLA+", reported_type="PLA+")
assert surviving is not None
@pytest.mark.asyncio
@pytest.mark.integration
async def test_nor_is_one_whose_slot_took_its_presets_type_rather_than_its_material(
self, async_client: AsyncClient, db_session: AsyncSession, printer_factory
):
"""The preset outranks the material column when the spool has one, so
the slot can legitimately carry a type the material never named. The
check has to recognise that as its own handiwork or it unlinks the
assignment on the very next AMS push."""
surviving = await self._push(
db_session,
printer_factory,
"PLA",
reported_type="PLA-AERO",
slicer_filament_name="Bambu PLA Aero @BBL H2D",
)
assert surviving is not None
@pytest.mark.asyncio
@pytest.mark.integration
async def test_even_when_the_preset_name_was_never_stored(
self, async_client: AsyncClient, db_session: AsyncSession, printer_factory
):
"""slicer_filament_name is optional. An imported local preset carries
its type outright, which is the value the assign path actually used."""
from backend.app.models.local_preset import LocalPreset
lp = LocalPreset(
name="Bambu PLA Aero @BBL H2D",
preset_type="filament",
source="orcaslicer",
filament_type="PLA-AERO",
setting="{}",
)
db_session.add(lp)
await db_session.commit()
await db_session.refresh(lp)
surviving = await self._push(
db_session,
printer_factory,
"PLA",
reported_type="PLA-AERO",
slicer_filament=str(lp.id),
)
assert surviving is not None
@pytest.mark.asyncio
@pytest.mark.integration
async def test_a_genuinely_different_filament_still_unlinks(
self, async_client: AsyncClient, db_session: AsyncSession, printer_factory
):
"""The check still has to do its job: someone swapping PLA for ABS in
the slot must lose the assignment, or usage gets charged to the wrong
spool."""
surviving = await self._push(db_session, printer_factory, "PLA+", reported_type="ABS")
assert surviving is None
@pytest.mark.asyncio
@pytest.mark.integration
async def test_and_a_preset_name_does_not_excuse_an_unrelated_slot(
self, async_client: AsyncClient, db_session: AsyncSession, printer_factory
):
"""Widening the check to the preset only accepts the types the assign
path could actually have written. Anything else is still a swap."""
surviving = await self._push(
db_session,
printer_factory,
"PLA",
reported_type="ABS",
slicer_filament_name="Bambu PLA Aero @BBL H2D",
)
assert surviving is None
class TestAFilledOrFoamedVariantIsATypeOfItsOwn:
"""The first cut of this fix reduced PLA-AERO, PLA-GF, ASA-GF and PPS-GF
onto their base material, because the reduction table was assembled from
the cloud filament names and the frontend preset parser and never checked
against ``filament_fields.json`` -- the list Bambuddy itself offers when a
preset is created. @doncaruana caught PLA Aero on the issue.
That is worse than the bug it replaced. "PLA-AERO" matched nothing before,
which was useless but honest; "PLA" matches every plain PLA plate in the
queue, so the dispatcher would have sent one to foaming filament.
"""
@pytest.fixture
async def printer(self, db_session):
from backend.app.models.printer import Printer
p = Printer(
name="Aero P1S",
serial_number="MAT2902AERO",
ip_address="192.168.1.81",
access_code="12345678",
)
db_session.add(p)
await db_session.commit()
await db_session.refresh(p)
return p
async def _assign(self, async_client, db_session, printer, material, tray_id, **spool_kwargs):
spool = Spool(
material=material,
brand="Bambu Lab",
rgba="E1E9E9FF",
label_weight=1000,
weight_used=0,
**spool_kwargs,
)
db_session.add(spool)
await db_session.commit()
await db_session.refresh(spool)
client = _mqtt_mock()
with patch("backend.app.services.printer_manager.printer_manager") as pm:
pm.get_client.return_value = client
pm.get_status.return_value = _status()
response = await async_client.post(
"/api/v1/inventory/assignments",
json={"spool_id": spool.id, "printer_id": printer.id, "ams_id": 0, "tray_id": tray_id},
)
assert response.status_code == 200
return client.ams_set_filament_setting.call_args.kwargs
@pytest.mark.asyncio
@pytest.mark.integration
@pytest.mark.parametrize(
("material", "tray_id"),
[("PLA-AERO", 0), ("PLA-GF", 1), ("ASA-GF", 2), ("PPS-GF", 3)],
)
async def test_it_reaches_the_slot_intact(
self, async_client: AsyncClient, db_session: AsyncSession, printer, material, tray_id
):
sent = await self._assign(async_client, db_session, printer, material, tray_id)
assert sent["tray_type"] == material
@pytest.mark.asyncio
@pytest.mark.integration
async def test_written_with_a_space_it_still_reaches_the_slot_intact(
self, async_client: AsyncClient, db_session: AsyncSession, printer
):
"""The table hyphenates because the slicers do; a spool says "PLA Aero"
and so does every Bambu preset name."""
sent = await self._assign(async_client, db_session, printer, "PLA Aero", 0)
assert sent["tray_type"] == "PLA-AERO"
class TestThePresetOutranksTheMaterialColumn:
"""#2902 again, from @doncaruana: a preset has to be picked from a list the
slicer defines, so it already knows its own type and nothing has to be read
out of a product name. When a spool points at one, that answer wins.
It cannot be the only answer. ``material`` is required on a spool and
``slicer_filament`` is not -- the spool this issue was reported for had no
preset at all -- so the reduction stays as the fallback.
"""
@pytest.fixture
async def printer(self, db_session):
from backend.app.models.printer import Printer
p = Printer(
name="Preset P1S",
serial_number="MAT2902PRE",
ip_address="192.168.1.82",
access_code="12345678",
)
db_session.add(p)
await db_session.commit()
await db_session.refresh(p)
return p
async def _preset(self, db_session, name, filament_type):
from backend.app.models.local_preset import LocalPreset
lp = LocalPreset(
name=name,
preset_type="filament",
source="orcaslicer",
filament_type=filament_type,
setting="{}",
)
db_session.add(lp)
await db_session.commit()
await db_session.refresh(lp)
return lp
async def _assign(self, async_client, db_session, printer, material, preset, tray_id):
spool = Spool(
material=material,
brand="Bambu Lab",
rgba="E1E9E9FF",
label_weight=1000,
weight_used=0,
slicer_filament=str(preset.id) if preset else None,
)
db_session.add(spool)
await db_session.commit()
await db_session.refresh(spool)
client = _mqtt_mock()
with patch("backend.app.services.printer_manager.printer_manager") as pm:
pm.get_client.return_value = client
pm.get_status.return_value = _status()
response = await async_client.post(
"/api/v1/inventory/assignments",
json={"spool_id": spool.id, "printer_id": printer.id, "ams_id": 0, "tray_id": tray_id},
)
assert response.status_code == 200
return client.ams_set_filament_setting.call_args.kwargs
@pytest.mark.asyncio
@pytest.mark.integration
async def test_the_slot_gets_the_presets_type_not_one_read_from_the_material(
self, async_client: AsyncClient, db_session: AsyncSession, printer
):
"""The material column says "PLA", which the reduction would happily
accept. The preset says the spool is foaming PLA, and it is right."""
preset = await self._preset(db_session, "Bambu PLA Aero @BBL P1S", "PLA-AERO")
sent = await self._assign(async_client, db_session, printer, "PLA", preset, 0)
assert sent["tray_type"] == "PLA-AERO"
@pytest.mark.asyncio
@pytest.mark.integration
async def test_a_preset_that_names_no_type_leaves_the_reduction_in_charge(
self, async_client: AsyncClient, db_session: AsyncSession, printer
):
preset = await self._preset(db_session, "eSUN PLA+ @BBL P1S", None)
sent = await self._assign(async_client, db_session, printer, "PLA+", preset, 1)
assert sent["tray_type"] == "PLA"
@pytest.mark.asyncio
@pytest.mark.integration
async def test_and_a_spool_with_no_preset_at_all_still_gets_one(
self, async_client: AsyncClient, db_session: AsyncSession, printer
):
sent = await self._assign(async_client, db_session, printer, "PLA+", None, 2)
assert sent["tray_type"] == "PLA"
@pytest.mark.asyncio
@pytest.mark.integration
async def test_a_hand_edited_preset_naming_a_product_line_is_still_reduced(
self, async_client: AsyncClient, db_session: AsyncSession, printer
):
"""Preferring the preset does not mean trusting it blindly. A profile
whose filament_type is a product line puts that product line in the
slot, which is the exact failure this issue is about."""
preset = await self._preset(db_session, "My PLA+ @BBL P1S", "PLA+")
sent = await self._assign(async_client, db_session, printer, "PLA", preset, 3)
assert sent["tray_type"] == "PLA"