Match a Bambu roll to its own product line, not just its colour (issue #2907) (#2944)

This commit is contained in:
Kouki Ojima
2026-09-30 10:15:42 +02:00
committed by GitHub
parent c905dac750
commit cb924e5c4a
10 changed files with 953 additions and 103 deletions
+3
View File
@@ -363,6 +363,7 @@ async def sync_printer_ams(
sync_result = await client.sync_ams_tray(
tray,
printer.name,
db,
# Per-print tracking owns weight updates (#1119); manual sync
# only refreshes spool metadata + slot assignments here.
disable_weight_sync=True,
@@ -580,6 +581,7 @@ async def sync_all_printers(
sync_result = await client.sync_ams_tray(
tray,
printer.name,
db,
# Per-print tracking owns weight updates (#1119); manual
# sync-all only refreshes spool metadata + slot assignments.
disable_weight_sync=True,
@@ -1293,6 +1295,7 @@ async def create_spool_from_slot(
sync_result = await client.sync_ams_tray(
tray,
printer.name,
db,
disable_weight_sync=True,
auto_add_unknown_rfid=True,
)
+1
View File
@@ -3013,6 +3013,7 @@ async def on_ams_change(printer_id: int, ams_data: list):
result = await client.sync_ams_tray(
tray,
printer_name,
db,
# Per-print tracking is the only weight writer (#1119).
# AMS auto-sync still maintains spool metadata / slot
# assignments but no longer touches remaining_weight.
@@ -0,0 +1,158 @@
"""Resolve a Bambu Lab roll's colour from the colour catalogue, and recognise its product line.
Extracted so the two inventory modes stop answering the same question in two
places. ``spool_tag_matcher.create_spool_from_tray`` had this logic inline since
#857, and three separate fixes landed there without crossing to the Spoolman
side -- the sub-brand filter (#1227), the alpha guard (#1545) and the translucent
handling. #2907 is the fourth. Both callers now go through here.
"""
from dataclasses import dataclass
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from backend.app.models.color_catalog import ColorCatalogEntry
BAMBU_MANUFACTURER = "BAMBU LAB"
@dataclass(frozen=True)
class CatalogColor:
"""What the catalogue row says about a roll's colour.
The name alone is not enough for the built-in path: a row can carry gradient
stops (``extra_colors``) and a rendering hint (``effect_type``), which is how
a roll the AMS identified draws the same as one picked by hand from that row.
"""
name: str
extra_colors: str | None = None
effect_type: str | None = None
async def resolve_bambu_color(db: AsyncSession, rgba: str | None, sub_brand: str | None) -> CatalogColor | None:
"""The catalogue's colour for this roll, or None when it has no row for it.
Hex alone is not an identity: ``#FFFFFF`` is "Jade White" in PLA Basic,
"Ivory White" in PLA Matte and "White" in PLA Silk. The printer reports which
product line the roll belongs to as ``tray_sub_brands``, and the catalogue
stores the same string in its ``material`` column -- so the two compare
directly, with no need to rebuild one from a type plus a subtype.
None is a real answer and not a failure. The catalogue is seeded from Bambu's
published hex list and lags new colours, so a roll it has never heard of has
no name to give and the caller has to cope rather than pick something.
Alpha 00 short-circuits to "Clear" (#1545) before the catalogue is consulted.
The catalogue stores RGB only, so a clear roll's ``00000000`` would look up
``#000000`` and come back "Black" -- the exact bug #1545 was filed for.
That short-circuit is also a limit worth stating. "Clear" is the answer for
every product line, so a PLA Matte clear roll and a PLA Basic clear roll get
the same name, and nothing downstream that keys on the name can tell them
apart. The Spoolman path's product-line criterion is inert for translucent
rolls for exactly this reason. Naming a genuinely translucent roll from the
catalogue would very likely beat "Clear", but that changes both modes and
what the built-in path has returned since #1545, so it is not something an
extraction should decide on its way past.
"""
if not rgba:
return None
# #1545, and the same test the built-in path has always applied.
if len(rgba) == 8 and rgba[6:8].lower() == "00":
return CatalogColor("Clear")
if len(rgba) < 6:
return None
hex_prefix = f"#{rgba[:6].upper()}"
query = (
select(ColorCatalogEntry)
.where(func.upper(ColorCatalogEntry.hex_color) == hex_prefix)
.where(func.upper(ColorCatalogEntry.manufacturer) == BAMBU_MANUFACTURER)
)
if sub_brand:
query = query.where(func.upper(ColorCatalogEntry.material) == sub_brand.upper())
# Deterministic tiebreak for the case the sub-brand filter cannot settle --
# a roll reporting no sub-brand at all.
query = query.order_by(ColorCatalogEntry.id).limit(1)
entry = (await db.execute(query)).scalar_one_or_none()
if entry is None:
return None
return CatalogColor(entry.color_name, entry.extra_colors, entry.effect_type)
def _normalise(name: str | None) -> str:
# "Tough+ Black" and "Silk+ Gold" are the library's spelling of lines the
# printer reports as "PLA Tough" and "PLA Silk". The plus is part of the
# product name, not the line, so it is dropped before comparing.
return " ".join((name or "").replace("+", " ").lower().split())
def _line_word(sub_brand: str | None, material: str | None) -> str:
"""The product line with the base material taken off: "PLA Matte" -> "matte"."""
line = _normalise(sub_brand)
base = _normalise(material)
if base and line.startswith(base + " "):
return line[len(base) + 1 :]
return ""
def product_line_rank(
filament_name: str | None,
color_name: str | None,
sub_brand: str | None,
material: str | None,
) -> int | None:
"""How specifically a filament name identifies this roll's product line, or None if it does not.
Material and colour alone cannot answer this: PLA Basic Black and PLA Matte
Charcoal are both PLA at ``#000000``, which is why a Matte roll was linked to
the Basic filament (#2907). The name is the only field on a Spoolman filament
that carries the line.
Three spellings count, ranked by how much of the line they carry:
0. the sub-brand itself ("PLA Matte") -- what Bambuddy names the filaments
it creates, and has since before this fix;
1. the line folded into the colour ("Matte Charcoal", "Tough+ Black",
"Brown Galaxy") -- how SpoolmanDB names every Bambu Lab entry outside PLA
Basic, and so what a filament taken from the library used to be called;
2. the bare colour name ("Black") -- how SpoolmanDB names PLA Basic, and the
lines that have a base material of their own (ABS, ASA, PLA-CF ...).
Rank 2 is the weak one and callers must prefer anything better. "Black" is
the bare name for PLA Basic, PLA Tough, PETG HF and TPU 90A alike, so on its
own it cannot tell two lines of the same material apart -- it is accepted
because an existing instance is full of filaments called that and refusing
it would duplicate every one of them.
"""
name = _normalise(filament_name)
if not name:
return None
if sub_brand and name == _normalise(sub_brand):
return 0
color = _normalise(color_name)
if not color:
return None
line = _line_word(sub_brand, material)
if line and name in (f"{line} {color}", f"{color} {line}"):
return 1
if name == color:
return 2
return None
def best_product_line_match(
filaments: list[dict],
color_name: str | None,
sub_brand: str | None,
material: str | None,
) -> dict | None:
"""The filament whose name identifies this roll's line most specifically; first wins a tie."""
best: dict | None = None
best_rank: int | None = None
for filament in filaments:
rank = product_line_rank(filament.get("name"), color_name, sub_brand, material)
if rank is not None and (best_rank is None or rank < best_rank):
best, best_rank = filament, rank
return best
+11 -36
View File
@@ -10,6 +10,7 @@ from backend.app.models.spool import Spool
from backend.app.models.spool_assignment import SpoolAssignment
from backend.app.schemas.spool import normalize_effect_type
from backend.app.services import slot_unlink_grace
from backend.app.services.color_catalog_lookup import resolve_bambu_color
from backend.app.services.slot_nozzle import resolve_slot_nozzle
from backend.app.services.spool_filament_preset import printer_safe_filament_id, resolve_spool_preset
from backend.app.utils.tag_normalization import (
@@ -54,7 +55,6 @@ async def create_spool_from_tray(db: AsyncSession, tray_data: dict) -> Spool:
Extracts material, subtype, color, temps, and tag info from the tray dict.
Looks up core_weight from the spool catalog if a Bambu Lab entry matches.
"""
from backend.app.models.color_catalog import ColorCatalogEntry
from backend.app.models.spool_catalog import SpoolCatalogEntry
tray_type = tray_data.get("tray_type", "") # "PLA"
@@ -108,42 +108,17 @@ async def create_spool_from_tray(db: AsyncSession, tray_data: dict) -> Spool:
# the printer-reported material variant (`tray_sub_brands`, e.g. "PLA Matte")
# so a new Ivory White roll doesn't get auto-named Jade White just because
# PLA Basic happens to come first in catalog insertion order. See #1227.
#
# Clear rolls (#1545) and the catalogue's gradient stops and effect hint
# come back from the same call: the lookup is shared with the Spoolman path
# so the two modes stop answering this question differently (#2907). The
# stops and hint are the same row the spool form's colour picker reads, so
# a roll the AMS identified renders like one picked by hand from that row.
rgba = tray_color if tray_color else None
color_name = None
extra_colors = None
effect_type = None
# Transparent filament (#1545): the AMS reports alpha=00 for clear spools.
# Skip the catalog lookup — the catalog only stores RGB so 000000 would
# resolve to "Black" (or whatever else lives at that RGB), which is exactly
# the bug the cream rewrite in parse_ams_tray used to paper over. Store
# "Clear" directly and let the frontend's resolveSpoolColorName +
# hexToColorName render the swatch as a checkerboard.
if rgba and len(rgba) == 8 and rgba[6:8].lower() == "00":
color_name = "Clear"
elif rgba and len(rgba) >= 6:
hex_prefix = f"#{rgba[:6].upper()}"
cat_query = (
select(ColorCatalogEntry)
.where(func.upper(ColorCatalogEntry.hex_color) == hex_prefix)
.where(func.upper(ColorCatalogEntry.manufacturer) == "BAMBU LAB")
)
if tray_sub_brands:
cat_query = cat_query.where(func.upper(ColorCatalogEntry.material) == tray_sub_brands.upper())
# Deterministic tiebreak when the material filter can't disambiguate
# (e.g. third-party spools with empty tray_sub_brands).
cat_query = cat_query.order_by(ColorCatalogEntry.id).limit(1)
cat_result = await db.execute(cat_query)
entry = cat_result.scalar_one_or_none()
if entry:
color_name = entry.color_name
# The same row the spool form's colour picker reads. It hands
# `extra_colors` and `effect_type` to the new spool when a user
# picks a colour by hand (ColorSection.selectColor), and this path
# was taking the name alone -- so a roll added by hand rendered
# its gradient and a roll the AMS identified for you did not.
extra_colors = entry.extra_colors
effect_type = entry.effect_type
catalog_color = await resolve_bambu_color(db, rgba, tray_sub_brands)
color_name = catalog_color.name if catalog_color else None
extra_colors = catalog_color.extra_colors if catalog_color else None
effect_type = catalog_color.effect_type if catalog_color else None
# If tray_id_name is a human-readable name (no "-" code), fall back to it.
if not color_name and tray_id_name and "-" not in tray_id_name:
+64 -19
View File
@@ -10,7 +10,9 @@ from datetime import datetime, timezone
from typing import Literal
import httpx
from sqlalchemy.ext.asyncio import AsyncSession
from backend.app.services.color_catalog_lookup import CatalogColor, best_product_line_match, resolve_bambu_color
from backend.app.utils.color_utils import color_match_key, spoolman_color_hex
logger = logging.getLogger(__name__)
@@ -1245,6 +1247,7 @@ class SpoolmanClient:
self,
tray: AMSTray,
printer_name: str,
db: AsyncSession,
disable_weight_sync: bool = False,
cached_spools: list[dict] | None = None,
inventory_remaining: float | None = None,
@@ -1309,8 +1312,10 @@ class SpoolmanClient:
return None
logger.info("Creating new spool in Spoolman for %s (tag: %s...)", tray.tray_sub_brands, spool_tag[:16])
catalog_color = None
if self.is_bambu_lab_spool(tray.tray_uuid, tray.tag_uid, tray.tray_info_idx):
filament = await self._find_or_create_filament(tray)
catalog_color = await resolve_bambu_color(db, tray.tray_color, (tray.tray_sub_brands or "").strip())
filament = await self._find_or_create_filament(tray, catalog_color)
filament_id = filament["id"] if filament else None
else:
# Non-BL spool with custom RFID: use generic vendor lookup
@@ -1331,11 +1336,20 @@ class SpoolmanClient:
logger.error("Failed to find or create filament for %s", tray.tray_sub_brands)
return None
extra = {"tag": json.dumps(spool_tag)}
# The colour name goes where #1357 put it, not into the filament. A
# Spoolman Filament has no color_name, so the route stores the user's
# choice under spool.extra.bambu_color_name and _map_spoolman_spool
# reads that first. This path never wrote it, which is why the colour
# ended up in filament.name and the product line fell out of subtype.
# _ensure_extra_fields registers the key from the payload (#2903).
if catalog_color is not None:
extra["bambu_color_name"] = json.dumps(catalog_color.name)
return await self.create_spool(
filament_id=filament_id,
remaining_weight=remaining,
comment="Created by Bambuddy",
extra={"tag": json.dumps(spool_tag)},
extra=extra,
)
# No-RFID fallback: use the spool ID resolved from the local slot-assignment table.
@@ -1365,8 +1379,21 @@ class SpoolmanClient:
)
return None
async def _find_or_create_filament(self, tray: AMSTray) -> dict | None:
"""Return a Bambu Lab filament matching the tray's material/color, creating it if absent."""
async def _find_or_create_filament(self, tray: AMSTray, catalog_color: CatalogColor | None) -> dict | None:
"""Return the Bambu Lab filament for this tray's product line, creating it if absent.
Material plus colour is not an identity. PLA Basic Black and PLA Matte
Charcoal are both PLA at ``#000000``, so a Matte roll was linked to the
Basic filament and inherited its name (#2907). The product line lives in
``tray_sub_brands``, so the filament's name carries it -- the same place
internal mode keeps it, as the subtype _map_spoolman_spool derives from
the name. The colour name belongs to the spool (``bambu_color_name``),
which the caller writes from ``catalog_color``.
``catalog_color`` is what the colour catalogue says about this roll, or
None when it has no row. It is resolved by the caller rather than here so
this client stays pure HTTP and the caller can store the name too.
"""
bambu_vendor_id = await self.ensure_bambu_vendor()
material_upper = tray.tray_type.upper()
# Same single value as the user-driven path: the match key is the stored
@@ -1374,23 +1401,27 @@ class SpoolmanClient:
# filaments every existing instance is full of, while a clear tray keys
# to eight and gets its own record (#2912).
color = color_match_key(tray.tray_color)
sub_brand = (tray.tray_sub_brands or "").strip()
color_name = catalog_color.name if catalog_color else None
# Search internal filaments - only match Bambu Lab vendor
filaments = await self.get_filaments()
for filament in filaments:
same_colour = []
for filament in await self.get_filaments():
fil_vendor_id = filament.get("vendor_id") or filament.get("vendor", {}).get("id")
if fil_vendor_id != bambu_vendor_id:
continue
fil_material = filament.get("material") or ""
if fil_material.upper() == material_upper and color_match_key(filament.get("color_hex")) == color:
return filament
same_colour.append(filament)
existing = best_product_line_match(same_colour, color_name, sub_brand, tray.tray_type)
if existing is not None:
return existing
# Search external filaments (SpoolmanDB) — restrict to Bambu Lab only.
# The /api/v1/external/filament endpoint returns the full multi-vendor catalog
# with no server-side filter, so without a manufacturer check the first PLA/black
# hit is typically 3DJAKE or 3DXTECH, not Bambu Lab.
external = await self.get_external_filaments()
sub_brand = (tray.tray_sub_brands or "").strip().lower()
bambu_candidates = []
for filament in external:
manufacturer = (filament.get("manufacturer") or "").strip().lower()
@@ -1401,17 +1432,23 @@ class SpoolmanClient:
if fil_material.upper() == material_upper and color_match_key(filament.get("color_hex")) == color:
bambu_candidates.append(filament)
if bambu_candidates:
# Prefer the entry whose `name` matches the AMS `tray_sub_brands`
# (e.g. "PLA Basic", "Support for PLA/PETG Black") so the more specific
# variant wins over a generic "Black" entry when both are present.
chosen = next(
(f for f in bambu_candidates if (f.get("name") or "").strip().lower() == sub_brand),
bambu_candidates[0],
)
# SpoolmanDB folds the product line into the name ("Matte Charcoal",
# "Tough+ Black", "Brown Galaxy") everywhere except PLA Basic and the
# lines with a base material of their own, which get the bare colour
# ("Black"). The catalogue keeps the line in its material column and a
# bare colour in color_name. So neither side's name equals the other's
# for most lines, and the selector has to rebuild the library's spelling
# from the line and the colour -- preferring it over the bare colour,
# which several lines share at the same hex.
chosen = best_product_line_match(bambu_candidates, color_name, sub_brand, tray.tray_type)
if chosen is not None:
return await self._create_filament_from_external(chosen, tray)
# Not found in either source - create a new Bambu Lab filament from scratch.
# Either the catalogue has no row for this colour -- it is seeded from
# Bambu's published list and lags new releases -- or it has one and no
# external entry carries that name. Both mean the library cannot say what
# this roll is, so build it from what the printer reported rather than
# attaching to whichever candidate happened to come first.
return await self.create_filament(
name=tray.tray_sub_brands or tray.tray_type,
vendor_id=bambu_vendor_id,
@@ -1421,10 +1458,18 @@ class SpoolmanClient:
)
async def _create_filament_from_external(self, external: dict, tray: AMSTray) -> dict | None:
"""Create an internal Spoolman filament from an external library entry."""
"""Create an internal Spoolman filament from an external library entry.
The entry supplies what the printer cannot: density and label weight. The
name comes from the tray, not the entry, so a filament taken from the
library is named the same way as one built from scratch and the two modes
agree on the subtype. The library's own name ("Matte Charcoal") is a
colour name with the line folded in, and the colour name is stored on the
spool instead.
"""
vendor_id = await self.ensure_bambu_vendor()
return await self.create_filament(
name=external.get("name", tray.tray_sub_brands),
name=tray.tray_sub_brands or external.get("name") or tray.tray_type,
vendor_id=vendor_id,
material=external.get("material", tray.tray_type),
# `or`, not a two-argument get: an entry that carries the key with an
@@ -833,7 +833,7 @@ class TestSpoolmanAPI:
captured_hints: list = []
async def capturing_sync(tray, printer_name, **kwargs):
async def capturing_sync(tray, printer_name, db, **kwargs):
captured_hints.append(kwargs.get("spoolman_spool_id_hint"))
return None
@@ -0,0 +1,246 @@
"""Direct tests for the shared colour-catalogue lookup (#2907).
The module was extracted so the two inventory modes stop answering "what colour
is this roll" in two places -- three fixes had landed on the built-in side
without crossing to the Spoolman side. Tests that reach it only through
``_find_or_create_filament`` cannot pin what it does, because the fake session
those use ignores the query and answers with a fixed row: the manufacturer
filter, the sub-brand filter, the id tie-break and the six-character prefix are
all invisible to them. These drive a real session against seeded rows instead.
"""
from __future__ import annotations
import pytest
from backend.app.models.color_catalog import ColorCatalogEntry
from backend.app.services.color_catalog_lookup import (
CatalogColor,
best_product_line_match,
product_line_rank,
resolve_bambu_color,
)
async def resolve_bambu_color_name(db, rgba, sub_brand):
"""The name alone, for the tests that are only about which row is chosen."""
color = await resolve_bambu_color(db, rgba, sub_brand)
return color.name if color else None
async def _seed(db, rows: list[dict]) -> None:
for row in rows:
db.add(ColorCatalogEntry(**row))
await db.commit()
BAMBU_BLACK = {"manufacturer": "Bambu Lab", "color_name": "Black", "hex_color": "#000000", "material": "PLA Basic"}
# As catalog_defaults.py seeds it: the line in `material`, a bare colour in
# `color_name`. An earlier version of this fixture said "Matte Charcoal" here,
# which is SpoolmanDB's spelling, not the catalogue's.
BAMBU_CHARCOAL = {
"manufacturer": "Bambu Lab",
"color_name": "Charcoal",
"hex_color": "#000000",
"material": "PLA Matte",
}
@pytest.mark.asyncio
async def test_the_sub_brand_picks_between_two_rows_at_the_same_hex(db_session):
"""The reported defect. #000000 is two different colours depending on the line."""
await _seed(db_session, [BAMBU_BLACK, BAMBU_CHARCOAL])
assert await resolve_bambu_color_name(db_session, "000000FF", "PLA Basic") == "Black"
assert await resolve_bambu_color_name(db_session, "000000FF", "PLA Matte") == "Charcoal"
@pytest.mark.asyncio
async def test_another_manufacturer_at_the_same_hex_is_not_an_answer(db_session):
"""Only Bambu rows may name a Bambu roll, whatever else sits at that hex.
Seeded first, so it wins the id tie-break: with the manufacturer filter gone
this returns "Panchroma Black". The sub-brand is left empty on purpose --
with one supplied the material filter drops the Polymaker row anyway and the
test would pass whether or not the manufacturer is checked, which is no test
at all.
"""
await _seed(
db_session,
[
{"manufacturer": "Polymaker", "color_name": "Panchroma Black", "hex_color": "#000000", "material": "PLA"},
BAMBU_BLACK,
],
)
assert await resolve_bambu_color_name(db_session, "000000FF", None) == "Black"
assert await resolve_bambu_color_name(db_session, "000000FF", "PLA Basic") == "Black"
@pytest.mark.asyncio
async def test_a_sub_brand_the_catalogue_does_not_carry_has_no_answer(db_session):
"""A line with no row is None, not the nearest row at that hex."""
await _seed(db_session, [BAMBU_BLACK])
assert await resolve_bambu_color_name(db_session, "000000FF", "PLA Nonesuch") is None
@pytest.mark.asyncio
async def test_no_sub_brand_falls_back_to_the_lowest_id(db_session):
"""With no sub-brand the filter cannot settle it, so the lowest id wins.
Not a third-party roll, which was the reasoning here before and was wrong:
such a roll cannot reach this module from its only caller. ``is_bambu_lab_spool``
gates non-Bambu rolls out, and ``parse_ams_tray`` substitutes ``tray_type``
when ``tray_sub_brands`` is empty (spoolman.py:1116-1118), so ``sub_brand``
is never empty on that path. The built-in side does reach it, since
``create_spool_from_tray`` passes ``tray_sub_brands`` through as it arrives.
The ordering itself is not pinned here, and no better test exists to write.
Deleting the ``order_by`` leaves this passing; only reversing it to
``.desc()`` fails. On SQLite the id is the rowid, so an unordered scan comes
back in id order anyway -- assigning ids against insertion order does not
separate them either -- and the suite is SQLite-only with no Postgres job,
so nothing in CI can notice if that ORDER BY goes. The determinism it buys
is real and untested; saying so is more honest than a docstring that claims
the two modes cannot disagree about which row wins.
"""
await _seed(db_session, [BAMBU_BLACK, BAMBU_CHARCOAL])
assert await resolve_bambu_color_name(db_session, "000000FF", None) == "Black"
assert await resolve_bambu_color_name(db_session, "000000FF", "") == "Black"
@pytest.mark.asyncio
async def test_only_the_first_six_characters_are_matched(db_session):
"""The catalogue stores RGB; the tray reports RGBA. The alpha is not part of
the key, so an opaque roll matches on its RGB regardless of what follows."""
await _seed(db_session, [BAMBU_BLACK])
assert await resolve_bambu_color_name(db_session, "000000FF", "PLA Basic") == "Black"
assert await resolve_bambu_color_name(db_session, "000000", "PLA Basic") == "Black"
assert await resolve_bambu_color_name(db_session, "0000", "PLA Basic") is None
assert await resolve_bambu_color_name(db_session, None, "PLA Basic") is None
@pytest.mark.asyncio
async def test_the_match_is_case_insensitive_on_both_sides(db_session):
await _seed(
db_session,
[{"manufacturer": "bambu lab", "color_name": "Black", "hex_color": "#000000", "material": "pla basic"}],
)
assert await resolve_bambu_color_name(db_session, "000000ff", "PLA BASIC") == "Black"
@pytest.mark.asyncio
async def test_an_alpha_00_roll_is_clear_and_never_reaches_the_catalogue(db_session):
"""#1545, carried over deliberately -- see the docstring.
The catalogue stores RGB only, so a clear roll's ``00000000`` would look up
``#000000`` and come back "Black". The seeded row here is exactly that trap:
without the guard this returns "Black" and Spoolman mode names a clear roll
black while the built-in path calls it "Clear" on the same printer.
"""
await _seed(db_session, [BAMBU_BLACK])
assert await resolve_bambu_color_name(db_session, "00000000", "PLA Basic") == "Clear"
# Any hex with alpha 00, not just the black one.
assert await resolve_bambu_color_name(db_session, "FFFFFF00", "PLA Basic") == "Clear"
# Alpha FF is opaque and must still be looked up.
assert await resolve_bambu_color_name(db_session, "000000FF", "PLA Basic") == "Black"
@pytest.mark.asyncio
async def test_the_row_hands_over_its_gradient_and_effect_not_just_its_name(db_session):
"""The built-in path takes all three off the row, so the shared lookup has to
return all three or that caller could not use it. The shipped seed carries no
stops on any row; a user-edited row does, and that is the case this is for."""
await _seed(
db_session,
[
{
"manufacturer": "Bambu Lab",
"color_name": "Arctic Whisper",
"hex_color": "#A2C8E6",
"material": "PLA Basic Gradient",
"extra_colors": "A2C8E6,FFFFFF",
"effect_type": "gradient",
}
],
)
assert await resolve_bambu_color(db_session, "A2C8E6FF", "PLA Basic Gradient") == CatalogColor(
"Arctic Whisper", "A2C8E6,FFFFFF", "gradient"
)
@pytest.mark.asyncio
async def test_an_unknown_colour_has_no_name_rather_than_a_wrong_one(db_session):
"""The catalogue lags new releases; None is a real answer."""
await _seed(db_session, [BAMBU_BLACK])
assert await resolve_bambu_color_name(db_session, "ABCDEFFF", "PLA Basic") is None
class TestProductLineRank:
"""The three spellings a Bambu filament's name can take, ranked, and nothing else."""
def test_the_sub_brand_is_the_strongest(self):
"""What Bambuddy names the filaments it creates."""
assert product_line_rank("PLA Matte", "Charcoal", "PLA Matte", "PLA") == 0
assert product_line_rank("pla matte", None, "PLA Matte", "PLA") == 0
def test_the_library_spelling_folds_the_line_into_the_colour(self):
"""SpoolmanDB's names for these rolls, taken from a live 0.26.1 instance."""
assert product_line_rank("Matte Charcoal", "Charcoal", "PLA Matte", "PLA") == 1
assert product_line_rank("Translucent Teal", "Teal", "PLA Translucent", "PLA") == 1
assert product_line_rank("HF Yellow", "Yellow", "PETG HF", "PETG") == 1
# The plus is part of the product name, not the line the printer reports.
assert product_line_rank("Tough+ Black", "Black", "PLA Tough", "PLA") == 1
assert product_line_rank("Silk+ Gold", "Gold", "PLA Silk", "PLA") == 1
# Galaxy puts the line after the colour.
assert product_line_rank("Brown Galaxy", "Brown", "PLA Galaxy", "PLA") == 1
def test_the_bare_colour_is_the_weakest(self):
"""PLA Basic's spelling in the library, and the lines with a base material
of their own -- and shared across lines, which is why it ranks last."""
assert product_line_rank("Black", "Black", "PLA Basic", "PLA") == 2
assert product_line_rank("Black", "Black", "PLA Tough", "PLA") == 2
def test_a_different_product_line_does_not_match(self):
"""The reported defect: a Matte roll must not attach to PLA Basic Black."""
assert product_line_rank("Black", "Charcoal", "PLA Matte", "PLA") is None
assert product_line_rank("PLA Basic", "Charcoal", "PLA Matte", "PLA") is None
assert product_line_rank("Tough+ Black", "Black", "PLA Basic", "PLA") is None
def test_an_unnamed_filament_never_matches(self):
assert product_line_rank(None, "Black", "PLA Basic", "PLA") is None
assert product_line_rank("", "Black", "PLA Basic", "PLA") is None
assert product_line_rank(" ", "Black", "PLA Basic", "PLA") is None
def test_with_neither_criterion_available_nothing_matches(self):
"""No catalogue row and no sub-brand: there is nothing to compare on, and
guessing is what #2907 is about."""
assert product_line_rank("Black", None, None, "PLA") is None
assert product_line_rank("Black", None, "", "PLA") is None
class TestBestProductLineMatch:
def test_the_line_spelling_beats_the_bare_colour_whatever_the_order(self):
"""The PLA Tough case from review: "Black" is PLA Basic's entry and comes
first in the library, "Tough+ Black" is the roll's own."""
basic = {"name": "Black"}
tough = {"name": "Tough+ Black"}
assert best_product_line_match([basic, tough], "Black", "PLA Tough", "PLA") is tough
assert best_product_line_match([tough, basic], "Black", "PLA Tough", "PLA") is tough
def test_the_bare_colour_is_taken_when_nothing_better_is_there(self):
basic = {"name": "Black"}
assert best_product_line_match([basic], "Black", "PLA Basic", "PLA") is basic
def test_nothing_is_better_than_the_wrong_line(self):
assert best_product_line_match([{"name": "Black"}], "Charcoal", "PLA Matte", "PLA") is None
assert best_product_line_match([], "Charcoal", "PLA Matte", "PLA") is None
@@ -22,6 +22,24 @@ import pytest
from backend.app.services.spoolman import AMSTray, SpoolmanClient
class _NoCatalog:
"""A DB session whose colour catalogue has no row for anything.
``_find_or_create_filament`` resolves the expected colour name from the
catalogue now (#2907), so it needs a session. These tests predate the
catalogue and were written against the state where it cannot name the
colour, which is this. Tests that care about the name pass a real session.
"""
async def execute(self, *_args, **_kwargs):
class _Result:
@staticmethod
def scalar_one_or_none():
return None
return _Result()
class FakeSpoolman:
"""A Spoolman that rejects unregistered extra keys, the way the real one does."""
@@ -141,7 +159,7 @@ class TestTheReportedCase:
fake = FakeSpoolman() # GET /field/spool returns nothing: no custom fields at all
client = _client(fake)
result = await client.sync_ams_tray(_tray("D144798DEF394926ACAE9D69ABA910CC"), "OJIMPO-X2D-01")
result = await client.sync_ams_tray(_tray("D144798DEF394926ACAE9D69ABA910CC"), "OJIMPO-X2D-01", _NoCatalog())
assert result is not None, "spool creation was rejected -- this is the reported 400"
assert result["extra"]["tag"] == json.dumps("D144798DEF394926ACAE9D69ABA910CC")
@@ -158,7 +176,7 @@ class TestTheReportedCase:
"1D1F3C49046246DBBADBC3631B7F1B61",
]
synced = [await client.sync_ams_tray(_tray(tag), "OJIMPO-X2D-01") for tag in tags]
synced = [await client.sync_ams_tray(_tray(tag), "OJIMPO-X2D-01", _NoCatalog()) for tag in tags]
assert all(s is not None for s in synced)
assert [s["extra"]["tag"] for s in synced] == [json.dumps(t) for t in tags]
@@ -0,0 +1,155 @@
"""The same Bambu roll must come out the same in both inventory modes (#2907).
The earlier tests for this fix staged a catalogue value the seed does not
contain ("Matte Charcoal" for PLA Matte at #000000, where ``catalog_defaults.py``
has "Charcoal") and so ran a branch the shipped catalogue never reaches. These
seed the real ``DEFAULT_COLOR_CATALOG`` and offer the real SpoolmanDB entries
for Bambu Lab PLA at #000000, as a live 0.26.1 instance returns them. The three
densities differ, which is what shows which library entry was taken.
Each roll then goes through both modes: ``create_spool_from_tray`` for the
built-in inventory, and ``sync_ams_tray`` plus ``_map_spoolman_spool`` for
Spoolman. Material, subtype and colour name have to agree.
"""
from __future__ import annotations
from unittest.mock import AsyncMock, patch
import pytest
from backend.app.api.routes._spoolman_helpers import _map_spoolman_spool
from backend.app.core.catalog_defaults import DEFAULT_COLOR_CATALOG
from backend.app.models.color_catalog import ColorCatalogEntry
from backend.app.services.spool_tag_matcher import create_spool_from_tray
from backend.app.services.spoolman import AMSTray, SpoolmanClient
# From GET /api/v1/external/filament on Spoolman 0.26.1, trimmed to the fields
# the selector and create_filament read.
LIBRARY = [
{
"id": "bambulab_pla_black_1000_175_n",
"manufacturer": "Bambu Lab",
"name": "Black",
"material": "PLA",
"color_hex": "000000",
"density": 1.24,
"weight": 1000.0,
},
{
"id": "bambulab_pla_mattecharcoal_1000_175_n",
"manufacturer": "Bambu Lab",
"name": "Matte Charcoal",
"material": "PLA",
"color_hex": "000000",
"density": 1.31,
"weight": 1000.0,
},
{
"id": "bambulab_pla_tough+black_1000_175_n",
"manufacturer": "Bambu Lab",
"name": "Tough+ Black",
"material": "PLA",
"color_hex": "000000",
"density": 1.21,
"weight": 1000.0,
},
]
ROLLS = [
# sub-brand, the library entry that is this roll's, subtype, colour name
("PLA Basic", 1.24, "Basic", "Black"),
("PLA Matte", 1.31, "Matte", "Charcoal"),
("PLA Tough", 1.21, "Tough", "Black"),
]
async def _seed_the_shipped_catalogue(db) -> None:
for manufacturer, color_name, hex_color, material in DEFAULT_COLOR_CATALOG:
db.add(
ColorCatalogEntry(manufacturer=manufacturer, color_name=color_name, hex_color=hex_color, material=material)
)
await db.commit()
def _tray(sub_brand: str) -> AMSTray:
return AMSTray(
ams_id=0,
tray_id=0,
tray_type="PLA",
tray_sub_brands=sub_brand,
tray_color="000000FF",
remain=100,
tag_uid="",
tray_uuid="A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4",
tray_info_idx="GFA00",
tray_weight=1000,
)
async def _through_spoolman(db, sub_brand: str) -> tuple[dict, dict]:
"""What Spoolman mode creates for this roll, and how the inventory then reads it."""
client = SpoolmanClient("http://localhost:7912")
with (
patch.object(client, "find_spool_by_tag", AsyncMock(return_value=None)),
patch.object(client, "ensure_bambu_vendor", AsyncMock(return_value=2)),
patch.object(client, "get_filaments", AsyncMock(return_value=[])),
patch.object(client, "get_external_filaments", AsyncMock(return_value=LIBRARY)),
patch.object(client, "create_filament", AsyncMock(return_value={"id": 9})) as create_filament,
patch.object(client, "create_spool", AsyncMock(return_value={"id": 99})) as create_spool,
):
await client.sync_ams_tray(_tray(sub_brand), "TestPrinter", db)
filament_kwargs = create_filament.call_args.kwargs
stored = {
"id": 99,
"filament": {
"id": 9,
"name": filament_kwargs["name"],
"material": filament_kwargs["material"],
"color_hex": filament_kwargs["color_hex"],
"vendor": {"id": 2, "name": "Bambu Lab"},
},
"initial_weight": 1000.0,
"used_weight": 0.0,
"remaining_weight": 1000.0,
"extra": create_spool.call_args.kwargs["extra"],
}
return filament_kwargs, _map_spoolman_spool(stored)
@pytest.mark.asyncio
@pytest.mark.parametrize(("sub_brand", "density", "subtype", "color_name"), ROLLS)
async def test_the_roll_takes_its_own_library_entry(db_session, sub_brand, density, subtype, color_name):
"""Before round three: Basic took "Black", Matte reached no entry at all and
lost the library's density, and Tough took PLA Basic's "Black"."""
await _seed_the_shipped_catalogue(db_session)
filament_kwargs, _ = await _through_spoolman(db_session, sub_brand)
assert filament_kwargs.get("density") == density
@pytest.mark.asyncio
@pytest.mark.parametrize(("sub_brand", "density", "subtype", "color_name"), ROLLS)
async def test_both_modes_store_the_roll_the_same_way(db_session, sub_brand, density, subtype, color_name):
"""The review's table, as an assertion. Round two had Spoolman mode put the
colour in the filament name, so its subtype read "Charcoal" where the
built-in inventory has "Matte"."""
await _seed_the_shipped_catalogue(db_session)
internal = await create_spool_from_tray(
db_session,
{
"tray_type": "PLA",
"tray_sub_brands": sub_brand,
"tray_color": "000000FF",
"tray_uuid": "A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4",
"tray_info_idx": "GFA00",
"tray_weight": 1000,
},
)
_, spoolman = await _through_spoolman(db_session, sub_brand)
assert (internal.material, internal.subtype, internal.color_name) == ("PLA", subtype, color_name)
assert (spoolman["material"], spoolman["subtype"], spoolman["color_name"]) == ("PLA", subtype, color_name)
@@ -5,13 +5,50 @@ functionality that controls whether remaining_weight is updated.
Also includes tests for is_bambu_lab_spool RFID detection.
"""
import json
from unittest.mock import AsyncMock, Mock, patch
import pytest
from backend.app.services.color_catalog_lookup import CatalogColor
from backend.app.services.spoolman import AMSTray, SpoolmanClient, init_spoolman_client
class _NoCatalog:
"""A DB session whose colour catalogue has no row for anything.
``sync_ams_tray`` resolves the roll's colour from the catalogue now (#2907),
so it needs a session. These tests predate the catalogue and were written
against the state where it cannot name the colour, which is this.
"""
async def execute(self, *_args, **_kwargs):
class _Result:
@staticmethod
def scalar_one_or_none():
return None
return _Result()
class _BlackCatalog:
"""A DB session whose colour catalogue answers "Black" for anything.
The fake ignores the query, so it stands for the trap rather than the lookup:
a catalogue that would name a clear roll's ``000000`` if it were asked.
"""
async def execute(self, *_args, **_kwargs):
entry = type("_Entry", (), {"color_name": "Black", "extra_colors": None, "effect_type": None})()
class _Result:
@staticmethod
def scalar_one_or_none():
return entry
return _Result()
class TestIsBambuLabSpool:
"""Tests for is_bambu_lab_spool — detects BL spools via RFID hardware identifiers only."""
@@ -127,7 +164,7 @@ class TestSpoolmanClient:
patch.object(client, "find_spool_by_tag", AsyncMock(return_value=existing_spool)),
patch.object(client, "update_spool", AsyncMock(return_value={"id": 42})) as mock_update,
):
await client.sync_ams_tray(sample_tray, "TestPrinter")
await client.sync_ams_tray(sample_tray, "TestPrinter", _NoCatalog())
mock_update.assert_called_once()
call_kwargs = mock_update.call_args.kwargs
@@ -142,7 +179,7 @@ class TestSpoolmanClient:
patch.object(client, "find_spool_by_tag", AsyncMock(return_value=existing_spool)),
patch.object(client, "update_spool", AsyncMock(return_value={"id": 42})) as mock_update,
):
await client.sync_ams_tray(sample_tray, "TestPrinter", disable_weight_sync=True)
await client.sync_ams_tray(sample_tray, "TestPrinter", _NoCatalog(), disable_weight_sync=True)
mock_update.assert_called_once()
call_kwargs = mock_update.call_args.kwargs
@@ -159,7 +196,7 @@ class TestSpoolmanClient:
patch.object(client, "_find_or_create_filament", AsyncMock(return_value=mock_filament)),
patch.object(client, "create_spool", AsyncMock(return_value={"id": 99})) as mock_create,
):
await client.sync_ams_tray(sample_tray, "TestPrinter", disable_weight_sync=True)
await client.sync_ams_tray(sample_tray, "TestPrinter", _NoCatalog(), disable_weight_sync=True)
mock_create.assert_called_once()
call_kwargs = mock_create.call_args.kwargs
@@ -167,6 +204,41 @@ class TestSpoolmanClient:
assert "remaining_weight" in call_kwargs
assert call_kwargs["remaining_weight"] == 500.0 # 50% of 1000g
@pytest.mark.asyncio
async def test_a_new_spool_carries_the_catalogue_colour_name(self, client, sample_tray, mock_filament):
"""Where #1357 put the colour name on this side: Spoolman has no color_name
on Filament, so it lives in spool.extra.bambu_color_name and
_map_spoolman_spool reads that first. The AMS path never wrote it, which
is why the colour ended up in the filament name and the product line fell
out of the subtype (#2907, round three)."""
with (
patch.object(client, "find_spool_by_tag", AsyncMock(return_value=None)),
patch.object(client, "_find_or_create_filament", AsyncMock(return_value=mock_filament)) as mock_find,
patch.object(client, "create_spool", AsyncMock(return_value={"id": 99})) as mock_create,
):
await client.sync_ams_tray(sample_tray, "TestPrinter", _BlackCatalog())
# The same answer goes to the filament lookup and onto the spool.
assert mock_find.call_args.args[1] == CatalogColor("Black")
extra = mock_create.call_args.kwargs["extra"]
assert extra["bambu_color_name"] == json.dumps("Black")
assert "tag" in extra
@pytest.mark.asyncio
async def test_no_catalogue_row_means_no_colour_name_rather_than_an_empty_one(
self, client, sample_tray, mock_filament
):
"""An empty string would read back as a name. Leaving the key out lets
_map_spoolman_spool fall through to the subtype, as it did before."""
with (
patch.object(client, "find_spool_by_tag", AsyncMock(return_value=None)),
patch.object(client, "_find_or_create_filament", AsyncMock(return_value=mock_filament)),
patch.object(client, "create_spool", AsyncMock(return_value={"id": 99})) as mock_create,
):
await client.sync_ams_tray(sample_tray, "TestPrinter", _NoCatalog())
assert "bambu_color_name" not in mock_create.call_args.kwargs["extra"]
@pytest.mark.asyncio
async def test_sync_ams_tray_does_not_write_location(self, client, sample_tray, existing_spool):
"""Verify sync_ams_tray never writes location= to Spoolman (user-managed field)."""
@@ -174,7 +246,7 @@ class TestSpoolmanClient:
patch.object(client, "find_spool_by_tag", AsyncMock(return_value=existing_spool)),
patch.object(client, "update_spool", AsyncMock(return_value={"id": 42})) as mock_update,
):
await client.sync_ams_tray(sample_tray, "My Printer", disable_weight_sync=True)
await client.sync_ams_tray(sample_tray, "My Printer", _NoCatalog(), disable_weight_sync=True)
call_kwargs = mock_update.call_args.kwargs
# Bambuddy must never auto-set spool.location — it is user-managed in Spoolman
@@ -216,7 +288,7 @@ class TestSpoolmanClient:
AsyncMock(side_effect=SpoolmanUnavailableError("timeout")),
),
):
result = await client.sync_ams_tray(tray, "TestPrinter")
result = await client.sync_ams_tray(tray, "TestPrinter", _NoCatalog())
assert result is None
@@ -250,6 +322,7 @@ class TestSpoolmanClient:
result = await client.sync_ams_tray(
tray,
"TestPrinter",
_NoCatalog(),
cached_spools=cached_spools,
spoolman_spool_id_hint=99,
)
@@ -285,6 +358,7 @@ class TestSpoolmanClient:
result = await client.sync_ams_tray(
tray,
"TestPrinter",
_NoCatalog(),
spoolman_spool_id_hint=99,
)
@@ -309,7 +383,7 @@ class TestSpoolmanClient:
tray_weight=1000,
)
result = await client.sync_ams_tray(tray, "TestPrinter")
result = await client.sync_ams_tray(tray, "TestPrinter", _NoCatalog())
assert result is None
@pytest.mark.asyncio
@@ -332,7 +406,7 @@ class TestSpoolmanClient:
with patch.object(client, "update_spool", new_callable=AsyncMock) as mock_update:
mock_update.return_value = {"id": 99}
result = await client.sync_ams_tray(
tray, "TestPrinter", cached_spools=cached_spools, spoolman_spool_id_hint=99
tray, "TestPrinter", _NoCatalog(), cached_spools=cached_spools, spoolman_spool_id_hint=99
)
assert result is not None
@@ -370,7 +444,7 @@ class TestSpoolmanClient:
patch.object(client, "find_spool_by_tag", AsyncMock(return_value=existing_spool)),
patch.object(client, "update_spool", AsyncMock(return_value={"id": 42})) as mock_update,
):
await client.sync_ams_tray(tray, "TestPrinter", disable_weight_sync=False)
await client.sync_ams_tray(tray, "TestPrinter", _NoCatalog(), disable_weight_sync=False)
call_kwargs = mock_update.call_args.kwargs
assert call_kwargs["remaining_weight"] == expected, (
@@ -429,7 +503,7 @@ class TestSpoolmanClient:
patch.object(client, "get_spools", AsyncMock()) as mock_get,
patch.object(client, "update_spool", AsyncMock(return_value={"id": 42})),
):
await client.sync_ams_tray(sample_tray, "TestPrinter", cached_spools=cached)
await client.sync_ams_tray(sample_tray, "TestPrinter", _NoCatalog(), cached_spools=cached)
mock_get.assert_not_called() # Should NOT call get_spools
@pytest.mark.asyncio
@@ -701,34 +775,84 @@ class TestFindOrCreateFilament:
tray_weight=1000,
)
@pytest.mark.asyncio
async def test_returns_existing_internal_bambu_lab_filament(self, client, tray_pla_black):
"""When a Bambu Lab filament matching material+color already exists internally,
return it as-is — never touch the external library or create a new entry.
@pytest.fixture
def tray_matte(self):
"""The reported roll: PLA Matte Charcoal, same material and hex as PLA Basic Black."""
return AMSTray(
ams_id=0,
tray_id=0,
tray_type="PLA",
tray_sub_brands="PLA Matte",
tray_color="000000FF",
remain=100,
tag_uid="",
tray_uuid="A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4",
tray_info_idx="GFA01",
tray_weight=1000,
)
This is the short-circuit that makes the workaround on #1309 necessary: once
a wrong name is on disk, subsequent AMS reads keep reusing it and the user has
to delete the mis-named entry manually for the corrected name to take effect.
async def _run(self, client, tray, catalog_color, *, filaments=None, external=None):
with (
patch.object(client, "ensure_bambu_vendor", AsyncMock(return_value=2)),
patch.object(client, "get_filaments", AsyncMock(return_value=filaments or [])),
patch.object(client, "get_external_filaments", AsyncMock(return_value=external or [])) as mock_external,
patch.object(client, "create_filament", AsyncMock(return_value={"id": 99})) as mock_create,
):
result = await client._find_or_create_filament(tray, catalog_color)
return result, mock_external, mock_create
@pytest.mark.asyncio
async def test_reuses_an_existing_filament_of_the_same_product_line(self, client, tray_pla_black):
"""The short-circuit still short-circuits — for a filament that is actually
this roll's.
This test used to pin the version that matched on material and colour alone,
with a filament named "Black" standing in for a PLA Basic roll. That is the
defect in #2907: PLA Basic Black and PLA Matte Charcoal are both PLA at
#000000, so a Matte roll was linked to the Basic filament. The name is the
only field on a Spoolman filament that carries the product line, so it is
now part of the match.
Named for the sub-brand here because that is what Bambuddy has been calling
its own creations (``name=tray.tray_sub_brands``) — which is what stops this
change minting a duplicate filament for every spool on an existing instance.
"""
existing = {
"id": 6,
"name": "Black",
"name": "PLA Basic",
"material": "PLA",
"color_hex": "000000", # alpha stripped by create_filament at insert time
"vendor_id": 2,
}
with (
patch.object(client, "ensure_bambu_vendor", AsyncMock(return_value=2)),
patch.object(client, "get_filaments", AsyncMock(return_value=[existing])),
patch.object(client, "get_external_filaments", AsyncMock()) as mock_external,
patch.object(client, "create_filament", AsyncMock()) as mock_create,
):
result = await client._find_or_create_filament(tray_pla_black)
result, mock_external, mock_create = await self._run(client, tray_pla_black, None, filaments=[existing])
assert result is existing
mock_external.assert_not_called()
mock_create.assert_not_called()
@pytest.mark.asyncio
async def test_reuses_a_filament_named_for_the_catalogue_colour(self, client, tray_pla_black):
"""The bare-colour spelling: what an entry taken from the external library
used to be called, and what PLA Basic is called there. An existing instance
is full of these, so refusing it would duplicate every one."""
existing = {"id": 6, "name": "Black", "material": "PLA", "color_hex": "000000", "vendor_id": 2}
result, _, mock_create = await self._run(client, tray_pla_black, CatalogColor("Black"), filaments=[existing])
assert result is existing
mock_create.assert_not_called()
@pytest.mark.asyncio
async def test_does_not_reuse_a_filament_from_a_different_product_line(self, client, tray_matte):
"""The reported case. A PLA Matte Charcoal roll must not attach to the
PLA Basic Black filament that happens to share its material and hex."""
pla_basic_black = {"id": 6, "name": "Black", "material": "PLA", "color_hex": "000000", "vendor_id": 2}
_, _, mock_create = await self._run(client, tray_matte, CatalogColor("Charcoal"), filaments=[pla_basic_black])
mock_create.assert_called_once()
assert mock_create.call_args.kwargs["name"] == "PLA Matte"
@pytest.mark.asyncio
async def test_skips_non_bambu_lab_external_entries(self, client, tray_pla_black):
"""Regression for #1309: the external-library loop must filter out non-Bambu-Lab
@@ -768,20 +892,39 @@ class TestFindOrCreateFilament:
patch.object(client, "get_external_filaments", AsyncMock(return_value=external)),
patch.object(client, "create_filament", AsyncMock(return_value={"id": 99})) as mock_create,
):
await client._find_or_create_filament(tray_pla_black)
await client._find_or_create_filament(tray_pla_black, CatalogColor("Black"))
mock_create.assert_called_once()
kwargs = mock_create.call_args.kwargs
# The Bambu Lab entry must win — not 3DJAKE / 3DXTECH which sort earlier.
assert kwargs["name"] == "Black"
# The name is the tray's either way, so the density says which entry it was.
assert kwargs["name"] == "PLA Basic"
assert kwargs["density"] == 1.26
@pytest.mark.asyncio
async def test_prefers_external_entry_matching_tray_sub_brands(self, client, tray_pla_black):
"""When SpoolmanDB has multiple Bambu Lab entries for the same material+color
(e.g. a "PLA Basic" variant alongside a generic "Black"), prefer the entry
whose `name` equals the AMS `tray_sub_brands` so the more specific variant wins.
Per maintainer's request on #1309.
async def test_prefers_the_external_entry_that_carries_the_line(self, client, tray_matte):
"""The tie-break selects on the line and the catalogue's colour together.
The fixture is the real pair. A current SpoolmanDB carries, for Bambu Lab
PLA at #000000, ``bambulab_pla_black_1000_175_n`` named "Black" and
``bambulab_pla_mattecharcoal_1000_175_n`` named "Matte Charcoal" -- both
material "PLA", because the line lives in the id and the name, never in
the material column. The previous version of this test staged an entry
named "PLA Basic" instead, which is a row SpoolmanDB does not contain:
none of its 269 Bambu Lab entries is named for a sub-brand.
That matters for what the old code did. Against this fixture the old
equality (``name == tray_sub_brands``, i.e. "pla matte") matches nothing,
so it falls through to candidates[0] -- "Black" -- and a Matte roll is
created as PLA Basic Black. That is #2907. Against the invented fixture
it matched, which is why the old test passed.
The catalogue value is the real one too. ``catalog_defaults.py`` seeds
#000000 under PLA Matte as "Charcoal", with the line in its material
column; the round-two version of this test staged "Matte Charcoal", which
is the library's spelling, and so exercised a branch the seed never
reaches. Neither side's name equals the other's here, which is why the
selector rebuilds the library's spelling from the line and the colour.
"""
external = [
{
@@ -793,9 +936,9 @@ class TestFindOrCreateFilament:
"density": 1.24,
},
{
"id": "bambulab_plabasic_black_1000_175_n",
"id": "bambulab_pla_mattecharcoal_1000_175_n",
"manufacturer": "Bambu Lab",
"name": "PLA Basic",
"name": "Matte Charcoal",
"material": "PLA",
"color_hex": "000000",
"density": 1.26,
@@ -807,13 +950,16 @@ class TestFindOrCreateFilament:
patch.object(client, "get_external_filaments", AsyncMock(return_value=external)),
patch.object(client, "create_filament", AsyncMock(return_value={"id": 99})) as mock_create,
):
await client._find_or_create_filament(tray_pla_black)
await client._find_or_create_filament(tray_matte, CatalogColor("Charcoal"))
mock_create.assert_called_once()
kwargs = mock_create.call_args.kwargs
# "PLA Basic" wins over generic "Black" because it matches tray_sub_brands.
assert kwargs["name"] == "PLA Basic"
# "Matte Charcoal" is the entry, and the density that comes with it says
# so. candidates[0] is "Black", which the old tie-break would have taken.
# The filament is named for the line either way, as internal mode's
# subtype is.
assert kwargs["density"] == 1.26
assert kwargs["name"] == "PLA Matte"
@pytest.mark.asyncio
async def test_falls_back_to_create_when_no_bambu_match_anywhere(self, client, tray_pla_black):
@@ -837,7 +983,7 @@ class TestFindOrCreateFilament:
patch.object(client, "get_external_filaments", AsyncMock(return_value=external)),
patch.object(client, "create_filament", AsyncMock(return_value={"id": 99})) as mock_create,
):
await client._find_or_create_filament(tray_pla_black)
await client._find_or_create_filament(tray_pla_black, None)
mock_create.assert_called_once()
kwargs = mock_create.call_args.kwargs
@@ -847,6 +993,80 @@ class TestFindOrCreateFilament:
assert kwargs["color_hex"] == "000000" # alpha channel stripped from tray_color
assert kwargs["vendor_id"] == 2
@pytest.mark.asyncio
async def test_a_colour_the_catalogue_lags_is_built_from_the_tray_not_a_bambu_entry(self, client, tray_pla_black):
"""A real Bambu candidate is present and deliberately not used.
This is the first half of the decision the fall-through makes, and the
half ``test_falls_back_to_create_when_no_bambu_match_anywhere`` does not
reach: its only external entry is 3DJAKE, which the manufacturer filter
drops before ``bambu_candidates`` is built, so that test arrives at this
branch with an empty candidate list. An empty list exercises nothing
about the choice.
Here the library does carry this roll's material and colour under Bambu
Lab, and the catalogue -- seeded from Bambu's published list, so it lags
new releases -- has no row to name it. Without a name there is nothing to
select on, and attaching to whichever candidate came first is the
misattribution #2907 is about, so the roll is built from what the printer
reported instead.
"""
external = [
{
"id": "bambulab_pla_black_1000_175_n",
"manufacturer": "Bambu Lab",
"name": "Black",
"material": "PLA",
"color_hex": "000000",
"density": 1.31,
},
]
_, _, mock_create = await self._run(client, tray_pla_black, None, external=external)
mock_create.assert_called_once()
kwargs = mock_create.call_args.kwargs
# The tray's own sub-brand, not the candidate's colour name.
assert kwargs["name"] == "PLA Basic"
assert kwargs["material"] == "PLA"
assert kwargs["color_hex"] == "000000"
assert kwargs["weight"] == 1000
# `density` reaches create_filament only via _create_filament_from_external,
# so its absence is what separates the two paths -- the name alone would
# not, since the candidate here is called "Black" for other reasons too.
assert "density" not in kwargs
@pytest.mark.asyncio
async def test_a_name_the_library_does_not_carry_is_built_from_the_tray_too(self, client, tray_matte):
"""The other half: the catalogue answers and no candidate carries that name.
The catalogue names this roll "Charcoal" under PLA Matte; the library
offers only "Black" for Bambu Lab PLA at #000000. That is the state an instance sits
in between a colour shipping and SpoolmanDB catching up. The name is the
only field separating the two product lines at this hex, so a candidate
that does not carry it is not this roll -- and "Black" is exactly the
entry the old tie-break took.
"""
external = [
{
"id": "bambulab_pla_black_1000_175_n",
"manufacturer": "Bambu Lab",
"name": "Black",
"material": "PLA",
"color_hex": "000000",
"density": 1.24,
},
]
_, _, mock_create = await self._run(client, tray_matte, CatalogColor("Charcoal"), external=external)
mock_create.assert_called_once()
kwargs = mock_create.call_args.kwargs
# Named for the line, as every filament this path creates is; what it
# must not take is the "Black" entry's identity or the density with it.
assert kwargs["name"] == "PLA Matte"
assert kwargs["material"] == "PLA"
assert kwargs["color_hex"] == "000000"
assert "density" not in kwargs
@pytest.mark.asyncio
async def test_accepts_external_entry_via_id_prefix_when_manufacturer_missing(self, client, tray_pla_black):
"""Defensive fallback: if `manufacturer` is absent or empty but the entry's `id`
@@ -868,10 +1088,12 @@ class TestFindOrCreateFilament:
patch.object(client, "get_external_filaments", AsyncMock(return_value=external)),
patch.object(client, "create_filament", AsyncMock(return_value={"id": 99})) as mock_create,
):
await client._find_or_create_filament(tray_pla_black)
await client._find_or_create_filament(tray_pla_black, CatalogColor("Black"))
mock_create.assert_called_once()
assert mock_create.call_args.kwargs["name"] == "Black"
# Only the external path forwards a density, so this is what shows the
# entry was accepted rather than the roll being built from scratch.
assert mock_create.call_args.kwargs["density"] == 1.24
@pytest.mark.asyncio
async def test_external_density_propagates_to_create_filament(self, client, tray_pla_black):
@@ -896,7 +1118,7 @@ class TestFindOrCreateFilament:
patch.object(client, "get_external_filaments", AsyncMock(return_value=external)),
patch.object(client, "create_filament", AsyncMock(return_value={"id": 99})) as mock_create,
):
await client._find_or_create_filament(tray_pla_black)
await client._find_or_create_filament(tray_pla_black, CatalogColor("Black"))
mock_create.assert_called_once()
assert mock_create.call_args.kwargs["density"] == 1.31
@@ -965,10 +1187,31 @@ class TestColorHexAlphaHandling:
patch.object(client, "get_external_filaments", AsyncMock(return_value=[])),
patch.object(client, "create_filament", AsyncMock(return_value={"id": 99})) as mock_create,
):
await client._find_or_create_filament(self._tray("00000000"))
await client._find_or_create_filament(self._tray("00000000"), None)
assert mock_create.call_args.kwargs["color_hex"] == "00000000"
@pytest.mark.asyncio
async def test_a_clear_tray_is_not_named_from_the_catalogue_even_when_it_can_answer(self, client):
"""#1545 at this level: the catalogue must not get to name a clear roll.
The catalogue here does have a row -- the trap is that a clear roll
reports ``00000000`` and the catalogue stores RGB, so the lookup would hit
#000000 and come back "Black". Without the guard the spool is stored as
"Black" for a clear roll, while the built-in path calls the same roll
"Clear" on the same printer. The colour is resolved in ``sync_ams_tray``
and handed down, so that is where this has to be driven from.
"""
with (
patch.object(client, "find_spool_by_tag", AsyncMock(return_value=None)),
patch.object(client, "_find_or_create_filament", AsyncMock(return_value={"id": 7})) as mock_find,
patch.object(client, "create_spool", AsyncMock(return_value={"id": 99})) as mock_create,
):
await client.sync_ams_tray(self._tray("00000000"), "TestPrinter", _BlackCatalog())
assert mock_find.call_args.args[1] == CatalogColor("Clear")
assert mock_create.call_args.kwargs["extra"]["bambu_color_name"] == json.dumps("Clear")
@pytest.mark.asyncio
async def test_opaque_tray_still_matches_an_existing_six_char_filament(self, client):
"""The upgrade hazard neither the report nor the original patch mentioned.
@@ -985,7 +1228,10 @@ class TestColorHexAlphaHandling:
patch.object(client, "get_external_filaments", AsyncMock()) as mock_external,
patch.object(client, "create_filament", AsyncMock()) as mock_create,
):
result = await client._find_or_create_filament(self._tray("000000FF"))
# Named after the catalogue's colour name, so the product-line
# criterion (#2907) is satisfied and the six/eight-character key is
# the only thing this test can fail on.
result = await client._find_or_create_filament(self._tray("000000FF"), CatalogColor("Black"))
assert result is existing
mock_external.assert_not_called()
@@ -1002,7 +1248,7 @@ class TestColorHexAlphaHandling:
patch.object(client, "get_external_filaments", AsyncMock(return_value=[])),
patch.object(client, "create_filament", AsyncMock(return_value={"id": 99})) as mock_create,
):
await client._find_or_create_filament(self._tray("00000000"))
await client._find_or_create_filament(self._tray("00000000"), CatalogColor("Black"))
assert mock_create.call_args.kwargs["color_hex"] == "00000000"
@@ -1021,7 +1267,7 @@ class TestColorHexAlphaHandling:
patch.object(client, "get_external_filaments", AsyncMock(return_value=[])),
patch.object(client, "create_filament", AsyncMock(return_value={"id": 99})) as mock_create,
):
await client._find_or_create_filament(self._tray("000000FF"))
await client._find_or_create_filament(self._tray("000000FF"), CatalogColor("Clear"))
assert mock_create.call_args.kwargs["color_hex"] == "000000"
@@ -1047,7 +1293,10 @@ class TestColorHexAlphaHandling:
patch.object(client, "get_external_filaments", AsyncMock(return_value=external)),
patch.object(client, "create_filament", AsyncMock(return_value={"id": 99})) as mock_create,
):
await client._find_or_create_filament(self._tray("00000000"))
# The catalogue is made to name this colour exactly what the external
# entry is called, so the selector (#2907) would take it. Only the
# colour key keeps it out of the candidate list at all.
await client._find_or_create_filament(self._tray("00000000"), CatalogColor("PLA Basic Black"))
assert mock_create.call_args.kwargs["color_hex"] == "00000000"