test_spoolman_reset_baseline_2906.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351
  1. """The consumed-counter reset must not throw away the measured remaining weight (#2906).
  2. "Reset usage to 0" on a Spoolman-backed spool PATCHed ``used_weight = 0``.
  3. Spoolman derives ``remaining_weight`` as initial minus used, so zeroing the
  4. used weight put the spool back to full — while the confirmation dialog promised,
  5. in all thirteen locales, that "the spool itself, its remaining weight
  6. calculation, and your settings are not changed."
  7. The fake below is the point of these tests. It recomputes remaining the way a
  8. real Spoolman does instead of accepting whatever it is told, so a reset that
  9. writes ``used_weight`` fails here for the same reason it failed on the reporting
  10. instance. Mocking that rule away is how the original implementation shipped
  11. green: ``test_spoolman_inventory_api.py`` staged ``used_weight: 0`` next to
  12. ``remaining_weight: 750.0``, a pair real Spoolman cannot return.
  13. """
  14. import json
  15. import httpx
  16. import pytest
  17. from backend.app.api.routes._spoolman_helpers import _map_spoolman_spool
  18. from backend.app.services.spoolman import SpoolmanClient
  19. LABEL_WEIGHT = 1000.0
  20. class FakeSpoolman:
  21. """A Spoolman that applies the three rules a real one applies.
  22. 1. ``remaining_weight`` is derived, not stored: initial minus used.
  23. 2. An extra key must be registered before it can be written, and an
  24. unregistered one is a 404 on GET so the caller creates it. A field's
  25. type cannot be changed afterwards.
  26. 3. Extra values are validated against the registered type. The default
  27. type is ``text``, which requires the stored JSON to decode to a str.
  28. Rule 1 is why the reset stopped PATCHing ``used_weight``. Rules 2 and 3
  29. are why the first version of that fix did not land either: it wrote the
  30. baseline as a JSON number, which a ``text`` field rejects, and the fake
  31. answered every ``/field/spool/`` GET with 200 so neither rule ever ran.
  32. A fake that models the rule that bit last time and not the one biting now
  33. is the same failure as the fixture these tests were written to correct.
  34. """
  35. def __init__(self, *, initial: float = LABEL_WEIGHT, used: float = 263.0):
  36. self.spool: dict = {
  37. "id": 42,
  38. "filament": {"id": 7, "name": "PLA Basic", "material": "PLA", "weight": LABEL_WEIGHT},
  39. "initial_weight": initial,
  40. "used_weight": used,
  41. "remaining_weight": initial - used,
  42. "extra": {},
  43. }
  44. # What a real instance looks like before this feature runs: the keys
  45. # Bambuddy already writes are registered, all of them text, and
  46. # bambu_weight_used_baseline is not registered at all.
  47. self.fields: dict[str, str] = {
  48. "tag": "text",
  49. "bambu_color_name": "text",
  50. "bambu_slicer_filament_id": "text",
  51. "bambu_slicer_setting_id": "text",
  52. }
  53. self.log: list[str] = []
  54. def _validate_extra(self, extra: dict) -> str | None:
  55. """Return Spoolman's error message, or None if the dict is acceptable."""
  56. for name, raw in extra.items():
  57. field_type = self.fields.get(name)
  58. if field_type is None:
  59. return f"Unknown extra field {name}."
  60. try:
  61. decoded = json.loads(raw)
  62. except (TypeError, ValueError):
  63. return f"Value for {name} is not valid JSON."
  64. if field_type == "text" and not isinstance(decoded, str):
  65. return "Value is not a string."
  66. return None
  67. def _recompute(self) -> None:
  68. # With no initial weight there is nothing to derive remaining from, and
  69. # real Spoolman leaves it null rather than inventing one.
  70. initial = self.spool.get("initial_weight")
  71. if initial is None:
  72. self.spool["remaining_weight"] = None
  73. return
  74. self.spool["remaining_weight"] = initial - self.spool["used_weight"]
  75. async def handler(self, request: httpx.Request) -> httpx.Response:
  76. path = request.url.path.removeprefix("/api/v1")
  77. self.log.append(f"{request.method} {path}")
  78. if path == "/spool/42":
  79. if request.method == "GET":
  80. return httpx.Response(200, json=self.spool)
  81. body = json.loads(request.content) if request.content else {}
  82. if "extra" in body:
  83. error = self._validate_extra(body["extra"])
  84. if error is not None:
  85. return httpx.Response(400, json={"message": error})
  86. self.spool.update(body)
  87. # The rule the old fixture mocked away.
  88. self._recompute()
  89. return httpx.Response(200, json=self.spool)
  90. if path.startswith("/field/spool/"):
  91. name = path.rsplit("/", 1)[-1]
  92. if request.method == "GET":
  93. if name not in self.fields:
  94. return httpx.Response(404, json={"message": f"No field {name}."})
  95. return httpx.Response(200, json={"name": name, "field_type": self.fields[name]})
  96. if request.method == "POST":
  97. field_type = (json.loads(request.content) if request.content else {}).get("field_type", "text")
  98. existing = self.fields.get(name)
  99. if existing is not None and existing != field_type:
  100. # The reason the storage type has to be right the first
  101. # time: no later release can repair an install that
  102. # registered the key with the wrong type.
  103. return httpx.Response(400, json={"message": "Field type cannot be changed."})
  104. self.fields[name] = field_type
  105. return httpx.Response(201, json={"name": name, "field_type": field_type})
  106. raise AssertionError(f"unexpected request {request.method} {path}")
  107. @pytest.fixture
  108. def fake():
  109. return FakeSpoolman()
  110. @pytest.fixture
  111. def client(fake):
  112. c = SpoolmanClient("http://localhost:7912")
  113. c._client = httpx.AsyncClient(
  114. transport=httpx.MockTransport(fake.handler),
  115. base_url="http://localhost:7912",
  116. )
  117. return c
  118. @pytest.mark.asyncio
  119. async def test_reset_leaves_the_measured_remaining_weight_alone(client, fake):
  120. """The reported symptom: a spool with 737 g left jumped back to 1000 g."""
  121. before = fake.spool["remaining_weight"]
  122. await client.reset_spool_consumed_counter(42)
  123. assert fake.spool["remaining_weight"] == before == 737.0
  124. @pytest.mark.asyncio
  125. async def test_reset_does_not_touch_any_native_spoolman_field(client, fake):
  126. """initial, remaining and used all survive — the point of using extra.
  127. The rejected alternative rewrote initial_weight to the current remaining,
  128. which repairs Bambuddy's display by overwriting a field the user owns:
  129. it ratchets down on every reset and Spoolman's own views then show the
  130. spool as full.
  131. """
  132. await client.reset_spool_consumed_counter(42)
  133. assert fake.spool["initial_weight"] == 1000.0
  134. assert fake.spool["used_weight"] == 263.0
  135. assert fake.spool["remaining_weight"] == 737.0
  136. @pytest.mark.asyncio
  137. async def test_reset_records_the_baseline_in_extra(client, fake):
  138. await client.reset_spool_consumed_counter(42)
  139. assert fake.spool["extra"]["bambu_weight_used_baseline"] == json.dumps("263.0")
  140. @pytest.mark.asyncio
  141. async def test_reset_preserves_other_extra_keys(client, fake):
  142. """The tag lives in the same dict; a reset must not drop it."""
  143. fake.spool["extra"] = {"tag": '"AABBCCDDEEFF0011AABBCCDDEEFF0011"'}
  144. await client.reset_spool_consumed_counter(42)
  145. assert fake.spool["extra"]["tag"] == '"AABBCCDDEEFF0011AABBCCDDEEFF0011"'
  146. assert "bambu_weight_used_baseline" in fake.spool["extra"]
  147. @pytest.mark.asyncio
  148. async def test_displayed_consumed_reads_zero_while_remaining_holds(client, fake):
  149. """End to end through the read mapping, which is what the Inventory page uses."""
  150. await client.reset_spool_consumed_counter(42)
  151. mapped = _map_spoolman_spool(fake.spool)
  152. assert mapped["weight_used"] - mapped["weight_used_baseline"] == 0.0, "consumed reads 0"
  153. assert LABEL_WEIGHT - mapped["weight_used"] == 737.0, "remaining still 737 g"
  154. @pytest.mark.asyncio
  155. async def test_consumption_after_a_reset_counts_from_the_baseline(client, fake):
  156. """A reset is a baseline, not an erasure: the next print's grams show up."""
  157. await client.reset_spool_consumed_counter(42)
  158. # 50 g printed afterwards, recorded the way Spoolman's /use endpoint does.
  159. fake.spool["used_weight"] = 313.0
  160. fake.spool["remaining_weight"] = 687.0
  161. mapped = _map_spoolman_spool(fake.spool)
  162. assert mapped["weight_used"] - mapped["weight_used_baseline"] == 50.0
  163. assert LABEL_WEIGHT - mapped["weight_used"] == 687.0
  164. @pytest.mark.asyncio
  165. async def test_reset_survives_a_spool_with_no_remaining_weight(client, fake):
  166. """Legacy spools, and spools with a filament linked but never primed, carry
  167. remaining_weight = None. The mapping documents that case; the reset used to
  168. be handed one and PATCH straight through it.
  169. """
  170. fake.spool["remaining_weight"] = None
  171. fake.spool["initial_weight"] = None
  172. await client.reset_spool_consumed_counter(42)
  173. mapped = _map_spoolman_spool({**fake.spool, "remaining_weight": None})
  174. assert fake.spool["extra"]["bambu_weight_used_baseline"] == json.dumps("263.0")
  175. assert mapped["weight_used"] - mapped["weight_used_baseline"] == 0.0
  176. @pytest.mark.asyncio
  177. async def test_second_reset_moves_the_baseline_forward(client, fake):
  178. """Idempotent in the sense that matters: resetting twice does not compound."""
  179. await client.reset_spool_consumed_counter(42)
  180. fake.spool["used_weight"] = 313.0
  181. fake.spool["remaining_weight"] = 687.0
  182. await client.reset_spool_consumed_counter(42)
  183. mapped = _map_spoolman_spool(fake.spool)
  184. assert fake.spool["extra"]["bambu_weight_used_baseline"] == json.dumps("313.0")
  185. assert mapped["weight_used"] - mapped["weight_used_baseline"] == 0.0
  186. assert LABEL_WEIGHT - mapped["weight_used"] == 687.0, "still not full"
  187. @pytest.mark.asyncio
  188. async def test_the_baseline_is_stored_in_the_form_a_text_field_accepts(client, fake):
  189. """The blocking defect in the first version of this fix.
  190. Spoolman registers an unseen extra key as ``text`` and then requires the
  191. value to decode to a str, so ``json.dumps(263.0)`` -- the JSON number
  192. ``263.0`` -- is rejected with "Value is not a string." and the PATCH 400s.
  193. Pinning the string form is what makes the write land at all.
  194. """
  195. await client.reset_spool_consumed_counter(42)
  196. stored = fake.spool["extra"]["bambu_weight_used_baseline"]
  197. assert json.loads(stored) == "263.0", "stored as a JSON string, not a JSON number"
  198. assert fake._validate_extra({"bambu_weight_used_baseline": stored}) is None
  199. @pytest.mark.asyncio
  200. async def test_the_baseline_key_is_registered_before_it_is_written(client, fake):
  201. """It is not registered on an existing install, so the reset has to create it.
  202. The claim is the ordering, not the shape of the probe. Spoolman answers 400
  203. "Unknown extra field <name>." for a key it was never told about, so the
  204. registration has to land before the PATCH that carries the value -- which is
  205. what ``_ensure_extra_fields`` buys (#2903). How it decides the key is
  206. missing is its own business: it probed the key directly when this test was
  207. first written and lists the fields now, and pinning either spelling here
  208. would fail on a change that does not touch the behaviour under test.
  209. """
  210. assert "bambu_weight_used_baseline" not in fake.fields
  211. await client.reset_spool_consumed_counter(42)
  212. assert fake.fields["bambu_weight_used_baseline"] == "text"
  213. registered = fake.log.index("POST /field/spool/bambu_weight_used_baseline")
  214. written = fake.log.index("PATCH /spool/42")
  215. assert registered < written, f"registration must precede the write: {fake.log}"
  216. @pytest.mark.asyncio
  217. async def test_a_baseline_written_as_text_reads_back_as_a_number(client, fake):
  218. """The other half: the read side has to decode what the write side stores.
  219. ``_extract_extra_float`` used to require the decoded value to be a number,
  220. so the string form it now has to write would have read back as None and the
  221. baseline would have been silently ignored.
  222. """
  223. await client.reset_spool_consumed_counter(42)
  224. mapped = _map_spoolman_spool(fake.spool)
  225. assert mapped["weight_used_baseline"] == 263.0
  226. assert mapped["weight_used"] - mapped["weight_used_baseline"] == 0.0
  227. def test_a_negative_stored_baseline_cannot_inflate_the_counter():
  228. """The write side clamps to >= 0 and the read side has to agree.
  229. It only shows on a spool whose remaining_weight and used_weight disagree --
  230. a hand-edited remaining, or a re-weigh -- because that is when the baseline
  231. is a non-zero correction rather than a cancelling pair. Here Spoolman says
  232. 263 g used while remaining says 300 g has gone; the baseline carries the
  233. 37 g difference. A stored -100 drags the sum under zero, the outer max()
  234. floors it at 0, and the 37 g correction is lost: consumed jumps to 300.
  235. Clamping ``stored`` itself keeps the correction intact.
  236. """
  237. spool = {
  238. "id": 42,
  239. "filament": {"id": 7, "name": "PLA Basic", "material": "PLA", "weight": LABEL_WEIGHT},
  240. "initial_weight": LABEL_WEIGHT,
  241. "used_weight": 263.0,
  242. "remaining_weight": 700.0,
  243. "extra": {"bambu_weight_used_baseline": json.dumps("-100.0")},
  244. }
  245. mapped = _map_spoolman_spool(spool)
  246. assert mapped["weight_used"] == 300.0
  247. assert mapped["weight_used_baseline"] == 37.0, "the negative is discarded, not subtracted"
  248. assert mapped["weight_used"] - mapped["weight_used_baseline"] == 263.0, "not inflated to 300"
  249. def test_a_missing_baseline_and_a_zero_one_are_not_the_same_thing():
  250. """``or 0.0`` collapsed them, which is the distinction _extract_extra_float
  251. exists to preserve. Both map to a zero baseline, but by different routes and
  252. the helper has to keep answering None for the absent one."""
  253. from backend.app.api.routes._spoolman_helpers import (
  254. BAMBU_WEIGHT_USED_BASELINE_KEY,
  255. _extract_extra_float,
  256. )
  257. assert _extract_extra_float({}, BAMBU_WEIGHT_USED_BASELINE_KEY) is None
  258. assert (
  259. _extract_extra_float({BAMBU_WEIGHT_USED_BASELINE_KEY: json.dumps("0.0")}, BAMBU_WEIGHT_USED_BASELINE_KEY) == 0.0
  260. )
  261. # Both spellings have to read alike, so an install that already stored the
  262. # number form before this fix keeps working.
  263. assert (
  264. _extract_extra_float({BAMBU_WEIGHT_USED_BASELINE_KEY: json.dumps(263.0)}, BAMBU_WEIGHT_USED_BASELINE_KEY)
  265. == 263.0
  266. )
  267. def test_the_writer_and_the_reader_name_the_same_key():
  268. """The key is declared twice on purpose (services/spoolman.py keeps the
  269. client free of the routes package), so nothing but this assert holds the
  270. two together. A divergence would not fail loudly anywhere else: the reset
  271. returns 200, the value lands under the writer's key, and the reader never
  272. finds it, so the counter simply never zeroes. And because Spoolman fixes an
  273. extra field's type on its first write, a stray key cannot be tidied up
  274. afterwards either."""
  275. from backend.app.api.routes import _spoolman_helpers
  276. from backend.app.services import spoolman
  277. assert spoolman.BAMBU_WEIGHT_USED_BASELINE_KEY == _spoolman_helpers.BAMBU_WEIGHT_USED_BASELINE_KEY