test_spool_reset_usage.py 9.1 KB

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