Files
bambuddy/backend/tests/integration/test_spoolman_cost_preserved_2591.py
maziggy 39835437a3 Price a print from the spool that fed it, not the default rate (issue #2591)
Spoolman holds per-spool pricing, and #261 gave that as the reason for
integrating with it. Nothing ever read it. A print's cost is set once, at
archive time, from the built-in Filament catalogue matched on the primary type
and falling back to a global default rate -- and in Spoolman mode nothing
revisited that figure afterwards. The per-spool recompute that would have fixed
it, in usage_tracker.on_print_complete, runs only over rows the built-in
inventory writes, and Spoolman mode hands the usage tracker spoolman_owns_usage
at print start so it writes none. The reporter's catalogue was empty, which is
the ordinary state of one in Spoolman mode, so every print came out at the
default no matter what the linked spool cost.

Multi-material was wrong twice over there: the primary type's rate applied to
the whole print's weight, so a slot of expensive PA was billed at the price of
the PLA beside it.

Each slot is now priced from the spool it was actually charged to, at the
moment of the charge, and the per-slot costs are summed -- which is what fixes
the multi-material case, rather than a separate change. All three charge paths
feed it: per-slot, tray-split, and the remain%-delta fallback. The rate is the
spool's own price when set, else the filament's, over filament.weight. That is
net grams excluding the core, and the same field the remain-delta path already
divides by to turn a percentage into weight, so a spool that can be charged by
percentage can always be priced. The price comes out of the get_spool call the
colour and material rewrites already pay for, so the tagged path costs no extra
round trip.

Grams no spool could price are covered at the global default in one subtraction
against the archive's own total. A spool with no price, a tray with no Spoolman
row, and filament the sliced file never attributed are the same case from here,
and without the top-up a print with one priced slot out of four would report a
quarter of its cost -- #1344 in the other inventory mode. Only the first run
writes the archive, matching the built-in writer (#1378); reprint actuals live
in PrintLogEntry. If no slot could be priced at all, whatever archive.py
recorded is left alone, so an install with prices in neither place stays where
it was.

Applied even when the slot-to-tray mapping was a positional guess, unlike the
colour and material rewrites beside it. Those overwrite what the slicer
recorded, which is why a guess must not touch them. The cost has no such
original -- archive.py's figure is itself derived from a default rate -- and the
grams have already been deducted from these spools, so the archive should say
what that deduction was worth.

Both cost recalculations would have undone it on the next run. /rescan and
/recalculate-costs rebuild an archive's cost from SpoolUsageHistory and fall
back to the catalogue or the default when there are no rows, which in Spoolman
mode is always, so the fallback was not a recalculation but a downgrade. The
spool-to-slot resolution a price is derived from exists only while a print is
completing and cannot be rebuilt from the archive row, so both now leave a cost
alone rather than replacing it with a worse one, and the bulk endpoint reports
how many it kept. An archive with no cost yet is still priced, and with
Spoolman off both behave exactly as before.

The rate parser refuses more than it looks like it needs to, because everything
it refuses was reachable. A non-dict filament raised through a call that sits
after a successful use_spool, which would have abandoned the remaining slots of
a multi-material print with the charges already made. NaN compares False
against every bound, including the applier's own total <= 0, so a NaN price
would have been written to the archive with nothing downstream able to clear
it; two finite operands can produce it by overflow, so the quotient is checked
as well as the inputs. A bool is an int in Python, and float(True) is 1.0 -- a
weight of 1 g prices a spool per-gram at its whole cost. And a spool-level price
of 0 now falls through to the catalogue rather than reading as free: Spoolman
leaves the override null when unset, but importers write 0 often enough that
treating it literally would price a whole print at the default with a good
catalogue price one level down.
2026-08-25 16:45:50 +02:00

95 lines
4.0 KiB
Python

"""A Spoolman-priced cost survives the two recalculations (#2591).
``spoolman_tracking`` prices an archive from the linked spools at completion.
Both cost recalculations rebuild a print's cost from ``SpoolUsageHistory``, and
Spoolman mode never writes rows there -- the built-in usage tracker is handed
``spoolman_owns_usage`` at print start. Their catalogue-or-default fallback
would therefore overwrite the Spoolman figure with a default-rate one on the
next rescan or bulk recalculate, silently undoing the fix, and the per-slot
spool resolution it came from is transient and cannot be rebuilt from the
archive row.
"""
import pytest
from httpx import AsyncClient
from backend.app.models.settings import Settings
@pytest.fixture
async def spoolman_mode(db_session):
"""Spoolman owns pricing; the built-in catalogue is empty, as the reporter's was."""
db_session.add(Settings(key="spoolman_enabled", value="true"))
db_session.add(Settings(key="default_filament_cost", value="25"))
await db_session.commit()
yield
await db_session.rollback()
class TestRecalculateCostsPreservesSpoolmanPricing:
@pytest.mark.asyncio
@pytest.mark.integration
async def test_bulk_recalculate_keeps_the_spoolman_figure(
self, async_client: AsyncClient, spoolman_mode, archive_factory, printer_factory, db_session
):
"""100 g at the 25/kg default would be 2.50. The archive says 4.00
because the linked spool cost 40.00 a kilo, and a recalculate with no
usage history to read must not drag it back down."""
printer = await printer_factory()
archive = await archive_factory(printer.id, print_name="SpoolmanPriced", status="completed", cost=4.0)
archive.filament_used_grams = 100.0
archive.filament_type = "PLA"
await db_session.commit()
response = await async_client.post("/api/v1/archives/recalculate-costs")
assert response.status_code == 200
assert response.json()["preserved"] >= 1
after = await async_client.get(f"/api/v1/archives/{archive.id}")
assert after.status_code == 200
assert after.json()["cost"] == 4.0
@pytest.mark.asyncio
@pytest.mark.integration
async def test_an_archive_with_no_cost_is_still_priced(
self, async_client: AsyncClient, spoolman_mode, archive_factory, printer_factory, db_session
):
"""The guard preserves a figure; it does not stop one being produced.
An archive that never got a cost still falls to the default rate."""
printer = await printer_factory()
archive = await archive_factory(printer.id, print_name="NeverPriced", status="completed", cost=None)
archive.filament_used_grams = 100.0
archive.filament_type = "PLA"
await db_session.commit()
response = await async_client.post("/api/v1/archives/recalculate-costs")
assert response.status_code == 200
after = await async_client.get(f"/api/v1/archives/{archive.id}")
assert after.json()["cost"] == 2.5
class TestRecalculateCostsWithoutSpoolman:
@pytest.mark.asyncio
@pytest.mark.integration
async def test_internal_mode_recalculates_as_before(
self, async_client: AsyncClient, archive_factory, printer_factory, db_session
):
"""With Spoolman off, the catalogue/default path is the only source of
truth and must still overwrite a stale cost."""
db_session.add(Settings(key="default_filament_cost", value="25"))
await db_session.commit()
printer = await printer_factory()
archive = await archive_factory(printer.id, print_name="InternalMode", status="completed", cost=999.0)
archive.filament_used_grams = 100.0
archive.filament_type = "PLA"
await db_session.commit()
response = await async_client.post("/api/v1/archives/recalculate-costs")
assert response.status_code == 200
after = await async_client.get(f"/api/v1/archives/{archive.id}")
assert after.json()["cost"] == 2.5
await db_session.rollback()