test_spoolman_initial_weight_3194.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334
  1. """Spoolman mode reads a spool's size from its own initial_weight (#3194).
  2. Spoolman keeps the net weight of a full spool on the spool (``initial_weight``)
  3. and falls back to the filament's catalogue ``weight``, so one filament can have
  4. spools of different sizes. Bambuddy read only the filament, so a 250 g spool of
  5. a 1000 g filament showed as 1000 g everywhere, and editing its label weight
  6. PATCHed the filament instead of the spool.
  7. """
  8. from unittest.mock import AsyncMock, MagicMock, patch
  9. import pytest
  10. from httpx import AsyncClient
  11. # A 250 g spool of a 1000 g filament with 200 g left.
  12. QUARTER_SPOOL = {
  13. "id": 42,
  14. "initial_weight": 250.0,
  15. "spool_weight": 100.0,
  16. "remaining_weight": 200.0,
  17. "used_weight": 50.0,
  18. "price": 6.25,
  19. "filament": {
  20. "id": 7,
  21. "name": "PLA Basic",
  22. "material": "PLA",
  23. "color_hex": "FF0000",
  24. "weight": 1000.0,
  25. "vendor": {"id": 3, "name": "Bambu Lab"},
  26. },
  27. "location": None,
  28. "comment": None,
  29. "archived": False,
  30. "registered": "2024-01-01T00:00:00+00:00",
  31. "extra": {"tag": '"AABBCCDD"'},
  32. }
  33. @pytest.fixture
  34. async def spoolman_settings(db_session):
  35. from backend.app.models.settings import Settings
  36. db_session.add(Settings(key="spoolman_enabled", value="true"))
  37. db_session.add(Settings(key="spoolman_url", value="http://localhost:7912"))
  38. await db_session.commit()
  39. @pytest.fixture
  40. def client():
  41. mock = MagicMock()
  42. mock.base_url = "http://localhost:7912"
  43. mock.health_check = AsyncMock(return_value=True)
  44. mock.get_all_spools = AsyncMock(return_value=[QUARTER_SPOOL])
  45. mock.get_spools = AsyncMock(return_value=[QUARTER_SPOOL])
  46. mock.get_spool = AsyncMock(return_value=QUARTER_SPOOL)
  47. mock.create_spool = AsyncMock(return_value=QUARTER_SPOOL)
  48. mock.update_spool = AsyncMock(return_value=None)
  49. mock.update_spool_full = AsyncMock(return_value=QUARTER_SPOOL)
  50. mock.merge_spool_extra = AsyncMock(return_value=QUARTER_SPOOL)
  51. mock.find_or_create_filament = AsyncMock(return_value=7)
  52. mock.find_or_create_vendor = AsyncMock(return_value=3)
  53. mock.patch_filament = AsyncMock(return_value={"id": 7})
  54. mock.is_filament_shared = AsyncMock(return_value=True)
  55. mock.ensure_extra_field = AsyncMock(return_value=True)
  56. mock.get_distinct_locations = AsyncMock(return_value=[])
  57. with (
  58. patch("backend.app.api.routes.spoolman_inventory.get_spoolman_client", AsyncMock(return_value=mock)),
  59. patch("backend.app.api.routes.spoolman_inventory.init_spoolman_client", AsyncMock(return_value=mock)),
  60. patch("backend.app.api.routes.spoolman.get_spoolman_client", AsyncMock(return_value=mock)),
  61. patch("backend.app.api.routes.spoolbuddy._get_spoolman_client_or_none", AsyncMock(return_value=mock)),
  62. ):
  63. yield mock
  64. class TestReadsUseTheSpoolsWeight:
  65. @pytest.mark.asyncio
  66. @pytest.mark.integration
  67. async def test_inventory_shows_the_spools_own_weight(self, async_client: AsyncClient, spoolman_settings, client):
  68. response = await async_client.get("/api/v1/spoolman/inventory/spools/42")
  69. assert response.status_code == 200
  70. body = response.json()
  71. assert body["label_weight"] == 250
  72. assert body["label_weight"] - body["weight_used"] == pytest.approx(200.0)
  73. # 6.25 for 250 g is 25 per kilo
  74. assert body["cost_per_kg"] == pytest.approx(25.0)
  75. @pytest.mark.asyncio
  76. @pytest.mark.integration
  77. async def test_weigh_reports_weight_used_against_the_spool(
  78. self, async_client: AsyncClient, spoolman_settings, client
  79. ):
  80. client.update_spool_full = AsyncMock(return_value={**QUARTER_SPOOL, "remaining_weight": 150.0})
  81. response = await async_client.patch("/api/v1/spoolman/inventory/spools/42/weight", json={"weight_grams": 250.0})
  82. assert response.status_code == 200
  83. assert client.update_spool_full.call_args.kwargs["remaining_weight"] == pytest.approx(150.0)
  84. assert response.json()["weight_used"] == pytest.approx(100.0)
  85. @pytest.mark.asyncio
  86. @pytest.mark.integration
  87. async def test_spoolbuddy_scale_reports_weight_used_against_the_spool(
  88. self, async_client: AsyncClient, spoolman_settings, client
  89. ):
  90. response = await async_client.post(
  91. "/api/v1/spoolbuddy/scale/update-spool-weight", json={"spool_id": 42, "weight_grams": 250.0}
  92. )
  93. assert response.status_code == 200
  94. client.update_spool.assert_called_once_with(spool_id=42, remaining_weight=pytest.approx(150.0))
  95. assert response.json()["weight_used"] == pytest.approx(100.0)
  96. @pytest.mark.asyncio
  97. @pytest.mark.integration
  98. async def test_linked_spools_fill_reference_is_the_spools_weight(
  99. self, async_client: AsyncClient, spoolman_settings, client
  100. ):
  101. response = await async_client.get("/api/v1/spoolman/spools/linked")
  102. assert response.status_code == 200
  103. assert response.json()["linked"]["AABBCCDD"]["filament_weight"] == pytest.approx(250.0)
  104. class TestCreateWritesTheSpoolsWeight:
  105. @pytest.mark.asyncio
  106. @pytest.mark.integration
  107. async def test_create_sends_initial_weight_and_the_price_of_the_spool(
  108. self, async_client: AsyncClient, spoolman_settings, client
  109. ):
  110. """A 250 g spool picked from a 1000 g catalogue filament is a 250 g spool."""
  111. response = await async_client.post(
  112. "/api/v1/spoolman/inventory/spools",
  113. json={"spoolman_filament_id": 7, "label_weight": 250, "cost_per_kg": 25.0},
  114. )
  115. assert response.status_code == 200
  116. assert client.create_spool.call_args.kwargs["initial_weight"] == 250.0
  117. assert client.create_spool.call_args.kwargs["remaining_weight"] == 250.0
  118. assert client.update_spool_full.call_args.kwargs["price"] == pytest.approx(6.25)
  119. @pytest.mark.asyncio
  120. @pytest.mark.integration
  121. async def test_bulk_create_sends_initial_weight(self, async_client: AsyncClient, spoolman_settings, client):
  122. response = await async_client.post(
  123. "/api/v1/spoolman/inventory/spools/bulk",
  124. json={"spool": {"spoolman_filament_id": 7, "label_weight": 250}, "quantity": 2},
  125. )
  126. assert response.status_code == 200
  127. assert client.create_spool.call_count == 2
  128. for call in client.create_spool.call_args_list:
  129. assert call.kwargs["initial_weight"] == 250.0
  130. @pytest.mark.asyncio
  131. @pytest.mark.integration
  132. async def test_create_without_label_weight_leaves_the_size_to_spoolman(
  133. self, async_client: AsyncClient, spoolman_settings, client
  134. ):
  135. """An API caller that gives no label weight gets the filament's, as
  136. before: Spoolman fills initial_weight from the filament itself."""
  137. response = await async_client.post(
  138. "/api/v1/spoolman/inventory/spools", json={"spoolman_filament_id": 7, "cost_per_kg": 25.0}
  139. )
  140. assert response.status_code == 200
  141. assert client.create_spool.call_args.kwargs["initial_weight"] is None
  142. # priced at the size Spoolman gave the spool (QUARTER_SPOOL: 250 g)
  143. assert client.update_spool_full.call_args.kwargs["price"] == pytest.approx(6.25)
  144. class TestEditWritesTheSpoolNotTheFilament:
  145. @pytest.mark.asyncio
  146. @pytest.mark.integration
  147. @pytest.mark.parametrize("shared", [True, False])
  148. async def test_label_weight_edit_goes_to_initial_weight(
  149. self, async_client: AsyncClient, spoolman_settings, client, shared
  150. ):
  151. """Neither a shared nor a singleton filament is touched, and no
  152. duplicate filament is created for a spool of another size."""
  153. client.is_filament_shared = AsyncMock(return_value=shared)
  154. response = await async_client.patch(
  155. "/api/v1/spoolman/inventory/spools/42",
  156. json={"label_weight": 500, "weight_used": 100.0},
  157. )
  158. assert response.status_code == 200
  159. client.patch_filament.assert_not_called()
  160. client.find_or_create_filament.assert_not_called()
  161. kwargs = client.update_spool_full.call_args.kwargs
  162. assert kwargs["filament_id"] == 7
  163. assert kwargs["initial_weight"] == 500.0
  164. assert kwargs["remaining_weight"] == pytest.approx(400.0)
  165. @pytest.mark.asyncio
  166. @pytest.mark.integration
  167. async def test_untouched_edit_writes_neither_size_nor_price(
  168. self, async_client: AsyncClient, spoolman_settings, client
  169. ):
  170. """The form sends back the label weight and cost per kg it loaded; a
  171. save that changes neither must not write them."""
  172. response = await async_client.patch(
  173. "/api/v1/spoolman/inventory/spools/42",
  174. json={"label_weight": 250, "cost_per_kg": 25.0, "note": "moved"},
  175. )
  176. assert response.status_code == 200
  177. client.patch_filament.assert_not_called()
  178. kwargs = client.update_spool_full.call_args.kwargs
  179. assert kwargs["initial_weight"] is None
  180. assert kwargs["price"] is None
  181. assert kwargs["remaining_weight"] == pytest.approx(200.0)
  182. @pytest.mark.asyncio
  183. @pytest.mark.integration
  184. async def test_fractional_size_survives_an_untouched_edit(
  185. self, async_client: AsyncClient, spoolman_settings, client
  186. ):
  187. """The form shows 250.7 g as 250; saving it must not cut the spool to 250 g
  188. or re-price it."""
  189. spool = {**QUARTER_SPOOL, "initial_weight": 250.7, "price": 6.99}
  190. client.get_spool = AsyncMock(return_value=spool)
  191. loaded = (await async_client.get("/api/v1/spoolman/inventory/spools/42")).json()
  192. assert loaded["label_weight"] == 250
  193. response = await async_client.patch(
  194. "/api/v1/spoolman/inventory/spools/42",
  195. json={"label_weight": loaded["label_weight"], "cost_per_kg": loaded["cost_per_kg"]},
  196. )
  197. assert response.status_code == 200
  198. kwargs = client.update_spool_full.call_args.kwargs
  199. assert kwargs["initial_weight"] is None
  200. assert kwargs["price"] is None
  201. @pytest.mark.asyncio
  202. @pytest.mark.integration
  203. async def test_price_is_converted_at_the_spools_weight(self, async_client: AsyncClient, spoolman_settings, client):
  204. response = await async_client.patch(
  205. "/api/v1/spoolman/inventory/spools/42",
  206. json={"label_weight": 250, "cost_per_kg": 30.0},
  207. )
  208. assert response.status_code == 200
  209. assert client.update_spool_full.call_args.kwargs["price"] == pytest.approx(7.5)
  210. @pytest.mark.asyncio
  211. @pytest.mark.integration
  212. @pytest.mark.parametrize("payload", [{"label_weight": 500}, {"label_weight": 500, "cost_per_kg": 25.0}])
  213. async def test_resize_keeps_the_rate_per_kg(self, async_client: AsyncClient, spoolman_settings, client, payload):
  214. """25 per kg stays 25 per kg when the spool is resized, as in internal
  215. mode, whether or not the caller (e.g. Bulk Edit) sends the rate."""
  216. response = await async_client.patch("/api/v1/spoolman/inventory/spools/42", json=payload)
  217. assert response.status_code == 200
  218. kwargs = client.update_spool_full.call_args.kwargs
  219. assert kwargs["initial_weight"] == 500.0
  220. assert kwargs["price"] == pytest.approx(12.5)
  221. @pytest.mark.asyncio
  222. @pytest.mark.integration
  223. async def test_colour_change_on_shared_filament_relinks_without_resizing(
  224. self, async_client: AsyncClient, spoolman_settings, client
  225. ):
  226. """A relink to a filament of another weight keeps the spool's size:
  227. Spoolman would otherwise stamp the new filament's weight on a spool
  228. without an initial_weight of its own."""
  229. client.find_or_create_filament = AsyncMock(return_value=8)
  230. spool = {**QUARTER_SPOOL, "initial_weight": None, "remaining_weight": 800.0, "used_weight": 200.0}
  231. client.get_spool = AsyncMock(return_value=spool)
  232. response = await async_client.patch(
  233. "/api/v1/spoolman/inventory/spools/42",
  234. json={"rgba": "00FF00FF"},
  235. )
  236. assert response.status_code == 200
  237. kwargs = client.update_spool_full.call_args.kwargs
  238. assert kwargs["filament_id"] == 8
  239. assert kwargs["initial_weight"] == 1000.0
  240. assert kwargs["remaining_weight"] == pytest.approx(800.0)
  241. assert kwargs["price"] is None
  242. class TestSpoolWithoutAnySize:
  243. """Spoolman refuses a remaining_weight (HTTP 400, the whole PATCH) while a
  244. spool has neither an initial_weight nor a filament weight."""
  245. BARE = {**QUARTER_SPOOL, "initial_weight": None, "remaining_weight": None, "used_weight": 0.0, "price": None}
  246. @pytest.mark.asyncio
  247. @pytest.mark.integration
  248. async def test_edit_without_label_weight_sends_no_remaining(
  249. self, async_client: AsyncClient, spoolman_settings, client
  250. ):
  251. client.get_spool = AsyncMock(
  252. return_value={**self.BARE, "filament": {**QUARTER_SPOOL["filament"], "weight": None}}
  253. )
  254. response = await async_client.patch("/api/v1/spoolman/inventory/spools/42", json={"note": "hi"})
  255. assert response.status_code == 200
  256. kwargs = client.update_spool_full.call_args.kwargs
  257. assert kwargs["remaining_weight"] is None
  258. assert kwargs["initial_weight"] is None
  259. @pytest.mark.asyncio
  260. @pytest.mark.integration
  261. async def test_form_label_weight_becomes_the_size(self, async_client: AsyncClient, spoolman_settings, client):
  262. """The form shows such a spool as 1000 g and sends that back."""
  263. client.get_spool = AsyncMock(
  264. return_value={**self.BARE, "filament": {**QUARTER_SPOOL["filament"], "weight": None}}
  265. )
  266. response = await async_client.patch(
  267. "/api/v1/spoolman/inventory/spools/42", json={"label_weight": 1000, "weight_used": 100.0}
  268. )
  269. assert response.status_code == 200
  270. kwargs = client.update_spool_full.call_args.kwargs
  271. assert kwargs["initial_weight"] == 1000.0
  272. assert kwargs["remaining_weight"] == pytest.approx(900.0)
  273. class TestAmsSyncUsesTheSpoolsWeight:
  274. @pytest.mark.asyncio
  275. @pytest.mark.integration
  276. async def test_ams_percentage_is_of_the_spool(
  277. self, async_client: AsyncClient, spoolman_settings, client, db_session
  278. ):
  279. from backend.app.models.printer import Printer
  280. from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
  281. printer = Printer(name="P", serial_number="SN3194", ip_address="192.168.1.9", access_code="12345678")
  282. db_session.add(printer)
  283. await db_session.commit()
  284. await db_session.refresh(printer)
  285. db_session.add(SpoolmanSlotAssignment(printer_id=printer.id, ams_id=0, tray_id=0, spoolman_spool_id=42))
  286. await db_session.commit()
  287. state = MagicMock()
  288. state.raw_data = {"ams": [{"id": 0, "tray": [{"id": 0, "remain": 80}]}]}
  289. with (
  290. patch("backend.app.api.routes.spoolman_inventory._get_client", AsyncMock(return_value=client)),
  291. patch("backend.app.api.routes.spoolman_inventory.printer_manager") as pm,
  292. ):
  293. pm.get_status = MagicMock(return_value=state)
  294. response = await async_client.post("/api/v1/spoolman/inventory/sync-ams-weights")
  295. assert response.status_code == 200
  296. assert response.json()["synced"] == 1
  297. # 80 % of 250 g, not of the filament's 1000 g
  298. client.update_spool_full.assert_called_once_with(42, remaining_weight=200.0)