mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-09-30 11:12:35 +02:00
Reporter saw a 544 g spool jump to 1000 g after pressing the eraser.
"Spools and remaining weights are not changed" - the dialog promised
this; the implementation did the opposite. Root cause was an
architectural conflation: `weight_used` did double duty as the
resettable "consumed since tracking started" counter AND as the basis
for the displayed remaining (`label_weight - weight_used`), so zeroing
it correctly cleared the stat but unavoidably reset remaining to full.
Spoolman has separate `used_weight` and `remaining_weight` fields, so
the API call there was correct - but Bambuddy's frontend was also
computing remaining as `label_weight - weight_used` for Spoolman
spools (ignoring Spoolman's real `remaining_weight` field), so the
same visual bug bit there too. Inventory-mode parity required fixing
both halves in one drop.
Internal mode
- New `weight_used_baseline` column (Float DEFAULT 0) on `spool`.
- Reset stamps `baseline = weight_used` and leaves `weight_used` alone.
- Displayed consumed = `weight_used - baseline`; remaining =
`label_weight - weight_used` (unchanged).
- Subsequent prints continue to grow `weight_used`, so the resettable
counter naturally tracks post-reset delta and remaining keeps
decrementing across the reset.
Spoolman mode
- `_map_spoolman_spool` now reads Spoolman's `remaining_weight` field
and returns a synthetic `weight_used = label - remaining` so the
frontend's remaining calc matches Spoolman's real stored value;
`weight_used_baseline = synthetic - real_used_weight` so the consumed
counter (`weight_used - baseline`) matches Spoolman's `used_weight`.
- Fallback path (no `remaining_weight` set) preserves the old behavior.
- Related fix: `update_spool` (Spoolman PATCH) was deriving the default
`weight_used` from `used_weight`, so editing unrelated fields AFTER
a reset would patch Spoolman with `remaining_weight = label - 0 =
label`, trampling the real value. Now derives from
`remaining_weight` so non-weight edits preserve physical state.
Frontend
- `InventoryPage` `totalConsumed` aggregate switched to
`Math.max(0, weight_used - (weight_used_baseline ?? 0))`.
- `ForecastPanel` `computeDeltaRate`, `totalUsedG`, and the per-spool
"consumed" table cell got the same treatment so forecast and
inventory aggregates stay coherent across a reset.
- `?? 0` keeps pre-migration installs rendering correctly until
`init_db()` runs the idempotent ALTER TABLE.
Migration
- `ALTER TABLE spool ADD COLUMN weight_used_baseline REAL DEFAULT 0`
via `_safe_execute` - SQLite and Postgres both accept it; verified
end-to-end on Postgres 16.
210 lines
8.7 KiB
Python
210 lines
8.7 KiB
Python
"""Reset-usage endpoint regressions (#1390 follow-up).
|
|
|
|
The per-spool and bulk reset endpoints stamp `weight_used_baseline =
|
|
weight_used` instead of zeroing `weight_used` directly. This decouples
|
|
the resettable "Total Consumed" display (computed as
|
|
`weight_used - weight_used_baseline`) from remaining
|
|
(`label_weight - weight_used`), so resetting the counter does NOT
|
|
inflate remaining back to label_weight (which is what the previous
|
|
implementation did — see the report at the end of #1390).
|
|
|
|
`weight_locked` is left alone in both modes; the spool keeps receiving
|
|
AMS auto-sync updates from the next print onward.
|
|
"""
|
|
|
|
import pytest
|
|
from httpx import AsyncClient
|
|
from sqlalchemy import select
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from backend.app.models.spool import Spool
|
|
|
|
|
|
@pytest.fixture
|
|
async def spool_factory(db_session: AsyncSession):
|
|
"""Create a Spool with sensible defaults."""
|
|
|
|
async def _create(**kwargs):
|
|
defaults = {
|
|
"material": "PLA",
|
|
"subtype": "Basic",
|
|
"brand": "Bambu",
|
|
"color_name": "Red",
|
|
"rgba": "FF0000FF",
|
|
"label_weight": 1000,
|
|
"weight_used": 0,
|
|
"weight_used_baseline": 0,
|
|
"weight_locked": False,
|
|
}
|
|
defaults.update(kwargs)
|
|
spool = Spool(**defaults)
|
|
db_session.add(spool)
|
|
await db_session.commit()
|
|
await db_session.refresh(spool)
|
|
return spool
|
|
|
|
return _create
|
|
|
|
|
|
class TestResetSpoolUsage:
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.integration
|
|
async def test_reset_stamps_baseline_without_touching_weight_used(
|
|
self, async_client: AsyncClient, spool_factory, db_session
|
|
):
|
|
"""Reset stamps baseline = weight_used; remaining stays the same.
|
|
|
|
Pre-bug behaviour zeroed weight_used and made
|
|
`label_weight - weight_used` (the displayed remaining) jump back
|
|
to label_weight — a 456 g spool would suddenly read 1000 g.
|
|
"""
|
|
spool = await spool_factory(label_weight=1000, weight_used=456.0)
|
|
|
|
response = await async_client.post(f"/api/v1/inventory/spools/{spool.id}/reset-usage")
|
|
|
|
assert response.status_code == 200
|
|
body = response.json()
|
|
assert body["weight_used"] == 456.0, "weight_used must NOT be zeroed (drives remaining)"
|
|
assert body["weight_used_baseline"] == 456.0, "baseline must equal pre-reset weight_used"
|
|
# Displayed consumed = weight_used - baseline = 0
|
|
assert body["weight_used"] - body["weight_used_baseline"] == 0
|
|
# Displayed remaining = label_weight - weight_used = 544 (unchanged)
|
|
assert body["label_weight"] - body["weight_used"] == 544
|
|
|
|
await db_session.refresh(spool)
|
|
assert spool.weight_used == 456.0
|
|
assert spool.weight_used_baseline == 456.0
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.integration
|
|
async def test_reset_does_not_lock_spool(self, async_client: AsyncClient, spool_factory, db_session):
|
|
"""Reset must leave weight_locked alone.
|
|
|
|
PATCH /spools/{id} auto-locks when weight_used is set explicitly;
|
|
the dedicated reset endpoint must NOT, because the user's intent
|
|
is "track fresh from zero", not "freeze at zero forever".
|
|
"""
|
|
spool = await spool_factory(weight_used=100.0, weight_locked=False)
|
|
|
|
response = await async_client.post(f"/api/v1/inventory/spools/{spool.id}/reset-usage")
|
|
|
|
assert response.status_code == 200
|
|
await db_session.refresh(spool)
|
|
assert spool.weight_used == 100.0
|
|
assert spool.weight_used_baseline == 100.0
|
|
assert spool.weight_locked is False, "Reset must not auto-lock the spool"
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.integration
|
|
async def test_reset_preserves_existing_lock(self, async_client: AsyncClient, spool_factory, db_session):
|
|
"""If the user previously locked the spool, the lock is preserved."""
|
|
spool = await spool_factory(weight_used=500.0, weight_locked=True)
|
|
|
|
response = await async_client.post(f"/api/v1/inventory/spools/{spool.id}/reset-usage")
|
|
|
|
assert response.status_code == 200
|
|
await db_session.refresh(spool)
|
|
assert spool.weight_used == 500.0
|
|
assert spool.weight_used_baseline == 500.0
|
|
assert spool.weight_locked is True, "Pre-existing lock must be preserved"
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.integration
|
|
async def test_reset_then_print_advances_only_the_counter(
|
|
self, async_client: AsyncClient, spool_factory, db_session
|
|
):
|
|
"""After reset, a subsequent print delta shows up in the consumed
|
|
counter while remaining keeps decrementing normally.
|
|
"""
|
|
spool = await spool_factory(label_weight=1000, weight_used=456.0)
|
|
await async_client.post(f"/api/v1/inventory/spools/{spool.id}/reset-usage")
|
|
|
|
# Simulate a 50g print (usage_tracker increments weight_used).
|
|
await db_session.refresh(spool)
|
|
spool.weight_used = (spool.weight_used or 0) + 50.0
|
|
await db_session.commit()
|
|
|
|
await db_session.refresh(spool)
|
|
consumed = spool.weight_used - spool.weight_used_baseline
|
|
remaining = spool.label_weight - spool.weight_used
|
|
assert consumed == 50.0, "Consumed counter reflects only post-reset usage"
|
|
assert remaining == 494, "Remaining tracks physical depletion across reset"
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.integration
|
|
async def test_reset_404_for_missing_spool(self, async_client: AsyncClient):
|
|
response = await async_client.post("/api/v1/inventory/spools/99999/reset-usage")
|
|
assert response.status_code == 404
|
|
|
|
|
|
class TestBulkResetSpoolUsage:
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.integration
|
|
async def test_bulk_reset_stamps_baseline_only_for_listed_spools(
|
|
self, async_client: AsyncClient, spool_factory, db_session
|
|
):
|
|
"""Only spools in the request are reset; others are untouched."""
|
|
target1 = await spool_factory(weight_used=100.0)
|
|
target2 = await spool_factory(weight_used=200.0)
|
|
untouched = await spool_factory(weight_used=300.0)
|
|
|
|
response = await async_client.post(
|
|
"/api/v1/inventory/spools/reset-usage-bulk",
|
|
json={"spool_ids": [target1.id, target2.id]},
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
assert response.json() == {"reset": 2}
|
|
|
|
# The endpoint commits via its own session — expire our session so the
|
|
# next read pulls fresh values rather than the cached pre-reset state.
|
|
db_session.expire_all()
|
|
spools = (await db_session.execute(select(Spool))).scalars().all()
|
|
by_id = {s.id: s for s in spools}
|
|
assert by_id[target1.id].weight_used == 100.0
|
|
assert by_id[target1.id].weight_used_baseline == 100.0
|
|
assert by_id[target2.id].weight_used == 200.0
|
|
assert by_id[target2.id].weight_used_baseline == 200.0
|
|
assert by_id[untouched.id].weight_used == 300.0, "Spool not in request must keep its usage"
|
|
assert by_id[untouched.id].weight_used_baseline == 0, "Untouched baseline must stay at 0"
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.integration
|
|
async def test_bulk_reset_rejects_empty_list(self, async_client: AsyncClient):
|
|
"""Empty list must be rejected — guards against accidental wildcard wipes."""
|
|
response = await async_client.post(
|
|
"/api/v1/inventory/spools/reset-usage-bulk",
|
|
json={"spool_ids": []},
|
|
)
|
|
assert response.status_code == 400
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.integration
|
|
async def test_bulk_reset_rejects_missing_field(self, async_client: AsyncClient):
|
|
"""Missing spool_ids field must be rejected."""
|
|
response = await async_client.post(
|
|
"/api/v1/inventory/spools/reset-usage-bulk",
|
|
json={},
|
|
)
|
|
assert response.status_code == 400
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.integration
|
|
async def test_bulk_reset_does_not_lock_spools(self, async_client: AsyncClient, spool_factory, db_session):
|
|
"""Bulk reset preserves weight_locked across all targets."""
|
|
unlocked = await spool_factory(weight_used=100.0, weight_locked=False)
|
|
locked = await spool_factory(weight_used=200.0, weight_locked=True)
|
|
|
|
response = await async_client.post(
|
|
"/api/v1/inventory/spools/reset-usage-bulk",
|
|
json={"spool_ids": [unlocked.id, locked.id]},
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
await db_session.refresh(unlocked)
|
|
await db_session.refresh(locked)
|
|
assert (
|
|
unlocked.weight_used == 100.0 and unlocked.weight_used_baseline == 100.0 and unlocked.weight_locked is False
|
|
)
|
|
assert locked.weight_used == 200.0 and locked.weight_used_baseline == 200.0 and locked.weight_locked is True
|