test_spool_reset_usage.py 8.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209
  1. """Reset-usage endpoint regressions (#1390 follow-up).
  2. The per-spool and bulk reset endpoints stamp `weight_used_baseline =
  3. weight_used` instead of zeroing `weight_used` directly. This decouples
  4. the resettable "Total Consumed" display (computed as
  5. `weight_used - weight_used_baseline`) from remaining
  6. (`label_weight - weight_used`), so resetting the counter does NOT
  7. inflate remaining back to label_weight (which is what the previous
  8. implementation did — see the report at the end of #1390).
  9. `weight_locked` is left alone in both modes; the spool keeps receiving
  10. AMS auto-sync updates from the next print onward.
  11. """
  12. import pytest
  13. from httpx import AsyncClient
  14. from sqlalchemy import select
  15. from sqlalchemy.ext.asyncio import AsyncSession
  16. from backend.app.models.spool import Spool
  17. @pytest.fixture
  18. async def spool_factory(db_session: AsyncSession):
  19. """Create a Spool with sensible defaults."""
  20. async def _create(**kwargs):
  21. defaults = {
  22. "material": "PLA",
  23. "subtype": "Basic",
  24. "brand": "Bambu",
  25. "color_name": "Red",
  26. "rgba": "FF0000FF",
  27. "label_weight": 1000,
  28. "weight_used": 0,
  29. "weight_used_baseline": 0,
  30. "weight_locked": False,
  31. }
  32. defaults.update(kwargs)
  33. spool = Spool(**defaults)
  34. db_session.add(spool)
  35. await db_session.commit()
  36. await db_session.refresh(spool)
  37. return spool
  38. return _create
  39. class TestResetSpoolUsage:
  40. @pytest.mark.asyncio
  41. @pytest.mark.integration
  42. async def test_reset_stamps_baseline_without_touching_weight_used(
  43. self, async_client: AsyncClient, spool_factory, db_session
  44. ):
  45. """Reset stamps baseline = weight_used; remaining stays the same.
  46. Pre-bug behaviour zeroed weight_used and made
  47. `label_weight - weight_used` (the displayed remaining) jump back
  48. to label_weight — a 456 g spool would suddenly read 1000 g.
  49. """
  50. spool = await spool_factory(label_weight=1000, weight_used=456.0)
  51. response = await async_client.post(f"/api/v1/inventory/spools/{spool.id}/reset-usage")
  52. assert response.status_code == 200
  53. body = response.json()
  54. assert body["weight_used"] == 456.0, "weight_used must NOT be zeroed (drives remaining)"
  55. assert body["weight_used_baseline"] == 456.0, "baseline must equal pre-reset weight_used"
  56. # Displayed consumed = weight_used - baseline = 0
  57. assert body["weight_used"] - body["weight_used_baseline"] == 0
  58. # Displayed remaining = label_weight - weight_used = 544 (unchanged)
  59. assert body["label_weight"] - body["weight_used"] == 544
  60. await db_session.refresh(spool)
  61. assert spool.weight_used == 456.0
  62. assert spool.weight_used_baseline == 456.0
  63. @pytest.mark.asyncio
  64. @pytest.mark.integration
  65. async def test_reset_does_not_lock_spool(self, async_client: AsyncClient, spool_factory, db_session):
  66. """Reset must leave weight_locked alone.
  67. PATCH /spools/{id} auto-locks when weight_used is set explicitly;
  68. the dedicated reset endpoint must NOT, because the user's intent
  69. is "track fresh from zero", not "freeze at zero forever".
  70. """
  71. spool = await spool_factory(weight_used=100.0, weight_locked=False)
  72. response = await async_client.post(f"/api/v1/inventory/spools/{spool.id}/reset-usage")
  73. assert response.status_code == 200
  74. await db_session.refresh(spool)
  75. assert spool.weight_used == 100.0
  76. assert spool.weight_used_baseline == 100.0
  77. assert spool.weight_locked is False, "Reset must not auto-lock the spool"
  78. @pytest.mark.asyncio
  79. @pytest.mark.integration
  80. async def test_reset_preserves_existing_lock(self, async_client: AsyncClient, spool_factory, db_session):
  81. """If the user previously locked the spool, the lock is preserved."""
  82. spool = await spool_factory(weight_used=500.0, weight_locked=True)
  83. response = await async_client.post(f"/api/v1/inventory/spools/{spool.id}/reset-usage")
  84. assert response.status_code == 200
  85. await db_session.refresh(spool)
  86. assert spool.weight_used == 500.0
  87. assert spool.weight_used_baseline == 500.0
  88. assert spool.weight_locked is True, "Pre-existing lock must be preserved"
  89. @pytest.mark.asyncio
  90. @pytest.mark.integration
  91. async def test_reset_then_print_advances_only_the_counter(
  92. self, async_client: AsyncClient, spool_factory, db_session
  93. ):
  94. """After reset, a subsequent print delta shows up in the consumed
  95. counter while remaining keeps decrementing normally.
  96. """
  97. spool = await spool_factory(label_weight=1000, weight_used=456.0)
  98. await async_client.post(f"/api/v1/inventory/spools/{spool.id}/reset-usage")
  99. # Simulate a 50g print (usage_tracker increments weight_used).
  100. await db_session.refresh(spool)
  101. spool.weight_used = (spool.weight_used or 0) + 50.0
  102. await db_session.commit()
  103. await db_session.refresh(spool)
  104. consumed = spool.weight_used - spool.weight_used_baseline
  105. remaining = spool.label_weight - spool.weight_used
  106. assert consumed == 50.0, "Consumed counter reflects only post-reset usage"
  107. assert remaining == 494, "Remaining tracks physical depletion across reset"
  108. @pytest.mark.asyncio
  109. @pytest.mark.integration
  110. async def test_reset_404_for_missing_spool(self, async_client: AsyncClient):
  111. response = await async_client.post("/api/v1/inventory/spools/99999/reset-usage")
  112. assert response.status_code == 404
  113. class TestBulkResetSpoolUsage:
  114. @pytest.mark.asyncio
  115. @pytest.mark.integration
  116. async def test_bulk_reset_stamps_baseline_only_for_listed_spools(
  117. self, async_client: AsyncClient, spool_factory, db_session
  118. ):
  119. """Only spools in the request are reset; others are untouched."""
  120. target1 = await spool_factory(weight_used=100.0)
  121. target2 = await spool_factory(weight_used=200.0)
  122. untouched = await spool_factory(weight_used=300.0)
  123. response = await async_client.post(
  124. "/api/v1/inventory/spools/reset-usage-bulk",
  125. json={"spool_ids": [target1.id, target2.id]},
  126. )
  127. assert response.status_code == 200
  128. assert response.json() == {"reset": 2}
  129. # The endpoint commits via its own session — expire our session so the
  130. # next read pulls fresh values rather than the cached pre-reset state.
  131. db_session.expire_all()
  132. spools = (await db_session.execute(select(Spool))).scalars().all()
  133. by_id = {s.id: s for s in spools}
  134. assert by_id[target1.id].weight_used == 100.0
  135. assert by_id[target1.id].weight_used_baseline == 100.0
  136. assert by_id[target2.id].weight_used == 200.0
  137. assert by_id[target2.id].weight_used_baseline == 200.0
  138. assert by_id[untouched.id].weight_used == 300.0, "Spool not in request must keep its usage"
  139. assert by_id[untouched.id].weight_used_baseline == 0, "Untouched baseline must stay at 0"
  140. @pytest.mark.asyncio
  141. @pytest.mark.integration
  142. async def test_bulk_reset_rejects_empty_list(self, async_client: AsyncClient):
  143. """Empty list must be rejected — guards against accidental wildcard wipes."""
  144. response = await async_client.post(
  145. "/api/v1/inventory/spools/reset-usage-bulk",
  146. json={"spool_ids": []},
  147. )
  148. assert response.status_code == 400
  149. @pytest.mark.asyncio
  150. @pytest.mark.integration
  151. async def test_bulk_reset_rejects_missing_field(self, async_client: AsyncClient):
  152. """Missing spool_ids field must be rejected."""
  153. response = await async_client.post(
  154. "/api/v1/inventory/spools/reset-usage-bulk",
  155. json={},
  156. )
  157. assert response.status_code == 400
  158. @pytest.mark.asyncio
  159. @pytest.mark.integration
  160. async def test_bulk_reset_does_not_lock_spools(self, async_client: AsyncClient, spool_factory, db_session):
  161. """Bulk reset preserves weight_locked across all targets."""
  162. unlocked = await spool_factory(weight_used=100.0, weight_locked=False)
  163. locked = await spool_factory(weight_used=200.0, weight_locked=True)
  164. response = await async_client.post(
  165. "/api/v1/inventory/spools/reset-usage-bulk",
  166. json={"spool_ids": [unlocked.id, locked.id]},
  167. )
  168. assert response.status_code == 200
  169. await db_session.refresh(unlocked)
  170. await db_session.refresh(locked)
  171. assert (
  172. unlocked.weight_used == 100.0 and unlocked.weight_used_baseline == 100.0 and unlocked.weight_locked is False
  173. )
  174. assert locked.weight_used == 200.0 and locked.weight_used_baseline == 200.0 and locked.weight_locked is True