| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209 |
- """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
|