Parcourir la source

Draw a spool the way the AMS described it, and correct the tare it was added with

    Two faults in the same auto-add path, both found while tracing why an H2C
    slot named a wood roll as plain PLA.

    A spool's swatch is composed from effect_type and extra_colors, and the
    RFID auto-add set neither. It reads the colour catalogue to name the
    colour and took the name alone, even though the row it had in hand also
    carries those two columns -- the spool form's own colour picker hands both
    to a spool a user adds by hand, so the same roll rendered one way when you
    typed it in and another when the printer identified it for you.

    Both columns now travel with the name. That alone changes nothing on a
    stock install, because the shipped catalogue carries an effect on none of
    its 600-odd rows, so the subtype is read where the catalogue has none: it
    is already derived from what the printer reports, and the two vocabularies
    line up -- Wood, Silk, Sparkle, Marble, Glow, Galaxy, Metal, Rainbow,
    Translucent, Matte, and the Gradient, Dual Color and Tri Color that the
    M*/T* colour codes upgrade a subtype to. "Silk+" reads as Silk, since the
    plus is on the product name rather than the finish. A subtype that names
    no effect -- Basic, Tough, CF -- leaves the column empty rather than
    inventing an overlay, and a value already set is never overwritten, so the
    column stays what it is documented to be: a rendering hint the user can
    override without touching Bambu's categorical label.

    ------

    Correct the spool tare an RFID roll was added with (#2909)

    The lookup that gave an auto-added spool its core_weight asked for the
    first catalogue row whose name starts "Bambu Lab" and took whatever came
    back. There are three, and which is first is the database's business:
    SQLite returns insertion order in practice, Postgres promises nothing once
    a table has seen an update. The same roll was therefore recorded with the
    216 g High Temp tare on one install and correctly with the 250 g Low Temp
    one on another. @ojimpo's forward fix picks the row by name; this repairs
    the rows already written, which the forward fix cannot reach -- 22 of 26
    RFID-added spools on the instance this was traced on.

    The tare is not cosmetic. A spool weighed on SpoolBuddy has its remaining
    filament worked out as the scale reading minus the tare, so a 34 g low
    tare credits the roll with 34 g that is not there and writes a used weight
    34 g short. That error is a constant -- every later print adds to the used
    weight on top of it -- so adding the difference back is exact however much
    has been printed since. It is applied only to spools that have been on the
    scale; one that never was has a used weight derived from the AMS remaining
    percentage, which the tare never entered into.

    Rows are identified by the signature of the broken lookup: added by RFID,
    carrying the weight of one of the other Bambu catalogue rows, with the
    weights read out of the catalogue rather than hardcoded so an install
    whose rows have been re-measured is repaired to its own numbers. Keying on
    whether a catalogue row had been recorded would not have worked -- the
    weight picker auto-selects the only row matching the weight and writes its
    id on the next save, so that column says only whether the form was ever
    opened. The one case that cannot be told apart is stated rather than
    hidden: someone who moved an RFID roll onto a genuine High Temp spool and
    set 216 g by hand is normalised with the rest. Runs exactly once, so a
    tare set afterwards is kept.
maziggy il y a 2 semaines
Parent
commit
b38022ec5c

Fichier diff supprimé car celui-ci est trop grand
+ 1 - 0
CHANGELOG.md


+ 122 - 0
backend/app/core/database.py

@@ -4427,6 +4427,128 @@ async def run_migrations(conn):
         conn, "ALTER TABLE notification_providers ADD COLUMN on_ams_drying_suspended BOOLEAN DEFAULT TRUE"
     )
 
+    # Migration: repair the tare of spools the RFID auto-add gave the wrong
+    # Bambu spool row (#2909). Runs last so the spool catalogue it reads is
+    # whatever this database actually holds.
+    await _migrate_repair_rfid_core_weight(conn)
+
+
+async def _migrate_repair_rfid_core_weight(conn) -> None:
+    """Correct the tare of RFID-added spools that took the wrong catalogue row (#2909).
+
+    A Bambu roll arrives on the 250 g Low Temp spool, but the lookup that gave
+    an auto-added spool its ``core_weight`` asked for the first row whose name
+    starts "Bambu Lab" and took whatever came back. There are three, and which
+    one is first is up to the database: SQLite returns insertion order in
+    practice, Postgres promises nothing once the table has seen an update. So
+    the same roll could be recorded with a 216 g High Temp tare on one install
+    and correctly on another, and the reporting instance here got 216.
+
+    The tare is not cosmetic. A spool weighed on SpoolBuddy has its remaining
+    filament worked out as ``scale reading - core_weight``, so a 34 g low tare
+    credits the roll with 34 g of filament that is not there and writes a
+    ``weight_used`` 34 g short. That error is a constant: every later print
+    adds to ``weight_used`` on top of it, so adding the difference back is an
+    exact repair however much has been printed since. Only rows that have
+    actually been weighed carry it -- a spool that was never on the scale has
+    a ``weight_used`` derived from the AMS remaining percentage, which the tare
+    never touched.
+
+    Which rows: ``data_origin = 'rfid_auto'`` narrows it to spools this code
+    path created, and a ``core_weight`` matching one of the *other* Bambu
+    catalogue rows is the signature of the broken lookup. Reading the weights
+    out of the catalogue rather than hardcoding 216 and 253 keeps it correct on
+    an install whose catalogue has been edited.
+
+    One case cannot be told apart and is stated rather than hidden: a user who
+    moved an RFID roll onto a genuine High Temp spool and set its tare to 216
+    by hand looks identical to a row the lookup got wrong, and is normalised
+    with them. Keying on ``core_weight_catalog_id IS NULL`` instead would not
+    have rescued them -- the spool form's weight picker auto-selects the only
+    catalogue row matching the weight and writes its id on the next save, so
+    that column says only whether the form was ever opened.
+
+    Gated to run exactly once via a settings flag, so a user who deliberately
+    sets one of these tares afterwards keeps it.
+    """
+    from sqlalchemy import bindparam, text
+
+    flag = "_backfill_2909_rfid_core_weight_done"
+
+    async with conn.begin_nested():
+        already = (
+            await conn.execute(text('SELECT value FROM settings WHERE "key" = :k'), {"k": flag})
+        ).scalar_one_or_none()
+        if already:
+            return
+
+        # The row that names the spool an RFID roll actually arrives on. Its
+        # absence is not an error -- a catalogue the user has pruned still gets
+        # the documented default.
+        correct = (
+            await conn.execute(
+                text("SELECT id, weight FROM spool_catalog WHERE UPPER(name) = :name ORDER BY id LIMIT 1"),
+                {"name": "BAMBU LAB - PLASTIC LOW TEMP"},
+            )
+        ).fetchone()
+        correct_id = correct[0] if correct else None
+        correct_weight = correct[1] if correct else 250
+
+        bambu_weights = {
+            row[0]
+            for row in (
+                await conn.execute(
+                    text("SELECT weight FROM spool_catalog WHERE UPPER(name) LIKE :prefix"),
+                    {"prefix": "BAMBU LAB%"},
+                )
+            ).fetchall()
+        }
+        wrong_weights = sorted(bambu_weights - {correct_weight})
+
+        repaired = 0
+        reweighed = 0
+        if wrong_weights:
+            rows = (
+                await conn.execute(
+                    text(
+                        "SELECT id, core_weight, label_weight, weight_used, last_weighed_at FROM spool "
+                        "WHERE data_origin = 'rfid_auto' AND core_weight IN :wrong"
+                    ).bindparams(bindparam("wrong", expanding=True)),
+                    {"wrong": wrong_weights},
+                )
+            ).fetchall()
+
+            for row in rows:
+                delta = correct_weight - row.core_weight
+                weight_used = row.weight_used or 0.0
+                if row.last_weighed_at is not None:
+                    weight_used = min(max(0.0, weight_used + delta), float(row.label_weight or 0))
+                    reweighed += 1
+                await conn.execute(
+                    text(
+                        "UPDATE spool SET core_weight = :cw, core_weight_catalog_id = :cid, "
+                        "weight_used = :wu WHERE id = :id"
+                    ),
+                    {"cw": correct_weight, "cid": correct_id, "wu": weight_used, "id": row.id},
+                )
+                repaired += 1
+
+        if repaired:
+            logger.info(
+                "[#2909] Corrected the spool tare on %d RFID-added spool(s) to %d g; "
+                "%d of them had been weighed and had their used weight adjusted with it",
+                repaired,
+                correct_weight,
+                reweighed,
+            )
+
+        # Marked done even when nothing matched, so the one-shot never reopens
+        # a tare the user has since set for themselves.
+        await conn.execute(
+            text('INSERT INTO settings ("key", value) VALUES (:k, :v)'),
+            {"k": flag, "v": "true"},
+        )
+
 
 async def _migrate_backfill_variant_groups(conn) -> None:
     """Build variant groups from the slice provenance already on disk (#671 / #2570).

+ 34 - 0
backend/app/services/spool_tag_matcher.py

@@ -8,6 +8,7 @@ from sqlalchemy.orm import selectinload
 
 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.utils.tag_normalization import (
     normalize_tag_uid as _normalize_tag_uid,
     normalize_tray_uuid as _normalize_tray_uuid,
@@ -99,6 +100,8 @@ async def create_spool_from_tray(db: AsyncSession, tray_data: dict) -> Spool:
     # PLA Basic happens to come first in catalog insertion order. See #1227.
     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
@@ -124,6 +127,13 @@ async def create_spool_from_tray(db: AsyncSession, tray_data: dict) -> Spool:
         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
 
     # 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:
@@ -136,6 +146,28 @@ async def create_spool_from_tray(db: AsyncSession, tray_data: dict) -> Spool:
         color_name,
     )
 
+    # Fall back to the subtype for the swatch's rendering hint. `effect_type`
+    # is a visual variant kept independent of `subtype` so a user can override
+    # how a roll is drawn without touching Bambu's categorical label -- but
+    # nothing had ever set it here, and the shipped colour catalogue carries no
+    # effect on any of its 600-odd rows, so in practice it was always NULL and
+    # every wood, silk, sparkle and gradient roll drew as a flat disc. The
+    # subtype is the answer where the catalogue has none: it is derived above
+    # from what the printer reports, and the two vocabularies already line up
+    # ("Wood", "Silk", "Dual Color" are values of both). A subtype that names
+    # no effect -- Basic, Tough, CF -- leaves it NULL, which is the honest
+    # answer rather than a guessed overlay.
+    # "Silk+" is the same finish as "Silk" with a plus on the product name, so
+    # the trailing sign is dropped on a second attempt rather than costing the
+    # roll its overlay.
+    if effect_type is None and subtype:
+        for candidate in (subtype, subtype.rstrip("+")):
+            try:
+                effect_type = normalize_effect_type(candidate)
+            except ValueError:
+                continue
+            break
+
     # Look up core weight from spool catalog
     core_weight = 250  # Default for Bambu Lab plastic spools
     cat_result = await db.execute(select(SpoolCatalogEntry).where(SpoolCatalogEntry.name.ilike("Bambu Lab%")).limit(10))
@@ -173,6 +205,8 @@ async def create_spool_from_tray(db: AsyncSession, tray_data: dict) -> Spool:
         subtype=subtype,
         color_name=color_name,
         rgba=rgba,
+        extra_colors=extra_colors,
+        effect_type=effect_type,
         brand="Bambu Lab",
         label_weight=label_weight,
         core_weight=core_weight,

+ 103 - 0
backend/tests/unit/services/test_spool_tag_matcher.py

@@ -111,6 +111,109 @@ async def test_create_spool_from_tray_relationships_loaded(db_session):
     assert spool.assignments == []
 
 
+# -- create_spool_from_tray: swatch rendering ------------------------------
+#
+# `effect_type` and `extra_colors` drive how a roll is drawn in the inventory
+# and on the slot card. Nothing set either here, and the shipped colour
+# catalogue carries an effect on none of its rows, so every auto-created wood,
+# silk, sparkle and gradient roll came out as a flat disc.
+
+
+@pytest.mark.asyncio
+async def test_create_spool_from_tray_takes_effect_from_subtype(db_session):
+    """A wood-filled roll is drawn as wood, not as a flat disc.
+
+    The catalogue row that names the colour has no effect of its own -- none
+    of the shipped rows do -- so the subtype the printer reported is what the
+    swatch has to be read from.
+    """
+    db_session.add(
+        ColorCatalogEntry(
+            manufacturer="Bambu Lab",
+            color_name="Classic Birch",
+            hex_color="#918669",
+            material="PLA Wood",
+            is_default=True,
+        )
+    )
+    await db_session.flush()
+
+    spool = await create_spool_from_tray(
+        db_session,
+        {**SAMPLE_TRAY, "tray_sub_brands": "PLA Wood", "tray_color": "918669FF", "tray_id_name": "A16-G0"},
+    )
+
+    assert spool.subtype == "Wood"
+    assert spool.color_name == "Classic Birch"
+    assert spool.effect_type == "wood"
+
+
+@pytest.mark.asyncio
+async def test_create_spool_from_tray_prefers_the_catalogue_row_over_the_subtype(db_session):
+    """A catalogue row that does carry rendering columns is what gets used.
+
+    This is the same row the spool form's colour picker reads, and it hands
+    both columns to a spool a user adds by hand; taking only the name here is
+    what made an RFID roll render differently from a hand-added one.
+    """
+    db_session.add(
+        ColorCatalogEntry(
+            manufacturer="Bambu Lab",
+            color_name="Dawn Radiance",
+            hex_color="#FF0000",
+            material="PLA Basic",
+            extra_colors="00ff00,0000ff",
+            effect_type="marble",
+            is_default=True,
+        )
+    )
+    await db_session.flush()
+
+    spool = await create_spool_from_tray(
+        db_session,
+        {**SAMPLE_TRAY, "tray_color": "FF0000FF"},
+    )
+
+    # Subtype "Basic" names no effect, so the catalogue is the only source.
+    assert spool.subtype == "Basic"
+    assert spool.effect_type == "marble"
+    assert spool.extra_colors == "00ff00,0000ff"
+
+
+@pytest.mark.asyncio
+async def test_create_spool_from_tray_leaves_a_plain_subtype_alone(db_session):
+    """Basic, Tough, CF and the rest name no effect and must not invent one."""
+    spool = await create_spool_from_tray(db_session, SAMPLE_TRAY)
+
+    assert spool.subtype == "Basic"
+    assert spool.effect_type is None
+    assert spool.extra_colors is None
+
+
+@pytest.mark.asyncio
+async def test_create_spool_from_tray_reads_silk_plus_as_silk(db_session):
+    """Silk+ is the Silk finish with a plus on the product name."""
+    spool = await create_spool_from_tray(
+        db_session,
+        {**SAMPLE_TRAY, "tray_sub_brands": "PLA Silk+"},
+    )
+
+    assert spool.subtype == "Silk+"
+    assert spool.effect_type == "silk"
+
+
+@pytest.mark.asyncio
+async def test_create_spool_from_tray_reads_a_gradient_roll_as_a_gradient(db_session):
+    """The M*/T* upgrade already rewrites the subtype; the effect follows it."""
+    spool = await create_spool_from_tray(
+        db_session,
+        {**SAMPLE_TRAY, "tray_id_name": "A00-M0"},
+    )
+
+    assert spool.subtype == "Gradient"
+    assert spool.effect_type == "gradient"
+
+
 # -- get_spool_by_tag -------------------------------------------------------
 
 

+ 254 - 0
backend/tests/unit/test_rfid_core_weight_repair_2909.py

@@ -0,0 +1,254 @@
+"""Repair for RFID-added spools that took the wrong Bambu spool tare (#2909).
+
+A Bambu roll arrives on the 250 g Low Temp spool, but the lookup that gave an
+auto-added spool its ``core_weight`` took the first row named "Bambu Lab" in
+whatever order the database returned, of which there are three. The forward fix
+picks the row by name; ``_migrate_repair_rfid_core_weight`` corrects the rows
+already written, and the ``weight_used`` a wrong tare put out with them.
+"""
+
+from datetime import datetime
+
+import pytest
+from sqlalchemy import text
+from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
+
+import backend.app.models  # noqa: F401 - populate Base.metadata
+from backend.app.core.database import Base, _migrate_repair_rfid_core_weight
+from backend.app.models.spool import Spool
+from backend.app.models.spool_catalog import SpoolCatalogEntry
+
+FLAG = "_backfill_2909_rfid_core_weight_done"
+
+# The three rows the broken lookup could return, at their seeded weights.
+HIGH_TEMP = 216
+LOW_TEMP = 250
+WHITE = 253
+
+
+@pytest.fixture
+async def engine(tmp_path):
+    eng = create_async_engine(f"sqlite+aiosqlite:///{tmp_path}/t.db")
+    async with eng.begin() as conn:
+        await conn.run_sync(Base.metadata.create_all)
+    try:
+        yield eng
+    finally:
+        await eng.dispose()
+
+
+async def _seed_catalog(db):
+    db.add_all(
+        [
+            SpoolCatalogEntry(name="Bambu Lab - Plastic High Temp", weight=HIGH_TEMP),
+            SpoolCatalogEntry(name="Bambu Lab - Plastic Low Temp", weight=LOW_TEMP),
+            SpoolCatalogEntry(name="Bambu Lab - Plastic White", weight=WHITE),
+            SpoolCatalogEntry(name="eSUN - Plastic", weight=240),
+        ]
+    )
+    await db.flush()
+
+
+def _spool(**kw):
+    base = {
+        "material": "PLA",
+        "brand": "Bambu Lab",
+        "label_weight": 1000,
+        "core_weight": HIGH_TEMP,
+        "weight_used": 0.0,
+        "data_origin": "rfid_auto",
+    }
+    base.update(kw)
+    return Spool(**base)
+
+
+async def _run(engine):
+    async with engine.begin() as conn:
+        await _migrate_repair_rfid_core_weight(conn)
+
+
+@pytest.mark.asyncio
+async def test_corrects_the_tare_of_a_wrongly_added_spool(engine):
+    sm = async_sessionmaker(engine, expire_on_commit=False)
+    async with sm() as db:
+        await _seed_catalog(db)
+        s = _spool()
+        db.add(s)
+        await db.commit()
+        spool_id = s.id
+        catalog_id = (
+            await db.execute(text("SELECT id FROM spool_catalog WHERE weight = :w"), {"w": LOW_TEMP})
+        ).scalar_one()
+
+    await _run(engine)
+
+    async with sm() as db:
+        fixed = await db.get(Spool, spool_id)
+        assert fixed.core_weight == LOW_TEMP
+        # The picker's own row, so the spool form shows the tare it now has.
+        assert fixed.core_weight_catalog_id == catalog_id
+
+
+@pytest.mark.asyncio
+async def test_adds_back_the_filament_a_wrong_tare_took_off_a_weighed_spool(engine):
+    """A 34 g low tare wrote a ``weight_used`` 34 g short. That error is a
+    constant, so adding the difference back is exact however much has been
+    printed since the weighing."""
+    sm = async_sessionmaker(engine, expire_on_commit=False)
+    async with sm() as db:
+        await _seed_catalog(db)
+        # Weighed at 800 g with a 216 g tare: 584 g of filament read, so
+        # weight_used = 1000 - 584 = 416. The truth is 800 - 250 = 550 left,
+        # i.e. 450 used.
+        weighed = _spool(weight_used=416.0, last_scale_weight=800.0, last_weighed_at=datetime(2026, 8, 23, 11, 0))
+        db.add(weighed)
+        await db.commit()
+        weighed_id = weighed.id
+
+    await _run(engine)
+
+    async with sm() as db:
+        fixed = await db.get(Spool, weighed_id)
+        assert fixed.core_weight == LOW_TEMP
+        assert fixed.weight_used == 450.0
+
+
+@pytest.mark.asyncio
+async def test_leaves_the_used_weight_of_a_never_weighed_spool_alone(engine):
+    """Its ``weight_used`` came from the AMS remaining percentage, which the
+    tare never entered into."""
+    sm = async_sessionmaker(engine, expire_on_commit=False)
+    async with sm() as db:
+        await _seed_catalog(db)
+        s = _spool(weight_used=200.0)
+        db.add(s)
+        await db.commit()
+        spool_id = s.id
+
+    await _run(engine)
+
+    async with sm() as db:
+        fixed = await db.get(Spool, spool_id)
+        assert fixed.core_weight == LOW_TEMP
+        assert fixed.weight_used == 200.0
+
+
+@pytest.mark.asyncio
+async def test_leaves_spools_the_user_added_alone(engine):
+    """Only ``rfid_auto`` rows came through the lookup that got this wrong."""
+    sm = async_sessionmaker(engine, expire_on_commit=False)
+    async with sm() as db:
+        await _seed_catalog(db)
+        manual = _spool(data_origin="manual", weight_used=100.0)
+        db.add(manual)
+        await db.commit()
+        manual_id = manual.id
+
+    await _run(engine)
+
+    async with sm() as db:
+        untouched = await db.get(Spool, manual_id)
+        assert untouched.core_weight == HIGH_TEMP
+        assert untouched.weight_used == 100.0
+
+
+@pytest.mark.asyncio
+async def test_leaves_a_tare_that_is_not_a_bambu_row_alone(engine):
+    """A third-party or hand-typed tare is not the broken lookup's signature."""
+    sm = async_sessionmaker(engine, expire_on_commit=False)
+    async with sm() as db:
+        await _seed_catalog(db)
+        third_party = _spool(core_weight=240)  # the eSUN row
+        odd = _spool(core_weight=137)  # typed by hand
+        db.add_all([third_party, odd])
+        await db.commit()
+        ids = (third_party.id, odd.id)
+
+    await _run(engine)
+
+    async with sm() as db:
+        assert (await db.get(Spool, ids[0])).core_weight == 240
+        assert (await db.get(Spool, ids[1])).core_weight == 137
+
+
+@pytest.mark.asyncio
+async def test_reads_the_right_weight_from_an_edited_catalogue(engine):
+    """The weights are read out of the catalogue, not hardcoded, so an install
+    whose rows have been re-measured is repaired to its own numbers."""
+    sm = async_sessionmaker(engine, expire_on_commit=False)
+    async with sm() as db:
+        db.add_all(
+            [
+                SpoolCatalogEntry(name="Bambu Lab - Plastic High Temp", weight=210),
+                SpoolCatalogEntry(name="Bambu Lab - Plastic Low Temp", weight=247),
+            ]
+        )
+        await db.flush()
+        s = _spool(core_weight=210)
+        db.add(s)
+        await db.commit()
+        spool_id = s.id
+
+    await _run(engine)
+
+    async with sm() as db:
+        assert (await db.get(Spool, spool_id)).core_weight == 247
+
+
+@pytest.mark.asyncio
+async def test_runs_exactly_once(engine):
+    """A tare the user sets after the repair has run is theirs to keep."""
+    sm = async_sessionmaker(engine, expire_on_commit=False)
+    async with sm() as db:
+        await _seed_catalog(db)
+        s = _spool()
+        db.add(s)
+        await db.commit()
+        spool_id = s.id
+
+    await _run(engine)
+
+    async with sm() as db:
+        deliberate = await db.get(Spool, spool_id)
+        deliberate.core_weight = HIGH_TEMP
+        await db.commit()
+
+    await _run(engine)
+
+    async with sm() as db:
+        assert (await db.get(Spool, spool_id)).core_weight == HIGH_TEMP
+
+
+@pytest.mark.asyncio
+async def test_marks_itself_done_on_an_install_with_nothing_to_repair(engine):
+    """Otherwise the scan would repeat on every boot for the rest of time."""
+    sm = async_sessionmaker(engine, expire_on_commit=False)
+    async with sm() as db:
+        await _seed_catalog(db)
+        await db.commit()
+
+    await _run(engine)
+
+    async with sm() as db:
+        done = (await db.execute(text('SELECT value FROM settings WHERE "key" = :k'), {"k": FLAG})).scalar_one_or_none()
+        assert done == "true"
+
+
+@pytest.mark.asyncio
+async def test_survives_a_catalogue_with_no_bambu_rows(engine):
+    """A pruned catalogue gets the documented 250 g default rather than a crash."""
+    sm = async_sessionmaker(engine, expire_on_commit=False)
+    async with sm() as db:
+        db.add(SpoolCatalogEntry(name="eSUN - Plastic", weight=240))
+        await db.flush()
+        s = _spool()
+        db.add(s)
+        await db.commit()
+        spool_id = s.id
+
+    await _run(engine)
+
+    async with sm() as db:
+        # No Bambu rows means no signature to match, so the spool is left as it
+        # stands -- guessing at it would be worse than the tare it has.
+        assert (await db.get(Spool, spool_id)).core_weight == HIGH_TEMP

Certains fichiers n'ont pas été affichés car il y a eu trop de fichiers modifiés dans ce diff