"""Spoolman mode reads a spool's size from its own initial_weight (#3194). Spoolman keeps the net weight of a full spool on the spool (``initial_weight``) and falls back to the filament's catalogue ``weight``, so one filament can have spools of different sizes. Bambuddy read only the filament, so a 250 g spool of a 1000 g filament showed as 1000 g everywhere, and editing its label weight PATCHed the filament instead of the spool. """ from unittest.mock import AsyncMock, MagicMock, patch import pytest from httpx import AsyncClient # A 250 g spool of a 1000 g filament with 200 g left. QUARTER_SPOOL = { "id": 42, "initial_weight": 250.0, "spool_weight": 100.0, "remaining_weight": 200.0, "used_weight": 50.0, "price": 6.25, "filament": { "id": 7, "name": "PLA Basic", "material": "PLA", "color_hex": "FF0000", "weight": 1000.0, "vendor": {"id": 3, "name": "Bambu Lab"}, }, "location": None, "comment": None, "archived": False, "registered": "2024-01-01T00:00:00+00:00", "extra": {"tag": '"AABBCCDD"'}, } @pytest.fixture async def spoolman_settings(db_session): from backend.app.models.settings import Settings db_session.add(Settings(key="spoolman_enabled", value="true")) db_session.add(Settings(key="spoolman_url", value="http://localhost:7912")) await db_session.commit() @pytest.fixture def client(): mock = MagicMock() mock.base_url = "http://localhost:7912" mock.health_check = AsyncMock(return_value=True) mock.get_all_spools = AsyncMock(return_value=[QUARTER_SPOOL]) mock.get_spools = AsyncMock(return_value=[QUARTER_SPOOL]) mock.get_spool = AsyncMock(return_value=QUARTER_SPOOL) mock.create_spool = AsyncMock(return_value=QUARTER_SPOOL) mock.update_spool = AsyncMock(return_value=None) mock.update_spool_full = AsyncMock(return_value=QUARTER_SPOOL) mock.merge_spool_extra = AsyncMock(return_value=QUARTER_SPOOL) mock.find_or_create_filament = AsyncMock(return_value=7) mock.find_or_create_vendor = AsyncMock(return_value=3) mock.patch_filament = AsyncMock(return_value={"id": 7}) mock.is_filament_shared = AsyncMock(return_value=True) mock.ensure_extra_field = AsyncMock(return_value=True) mock.get_distinct_locations = AsyncMock(return_value=[]) with ( patch("backend.app.api.routes.spoolman_inventory.get_spoolman_client", AsyncMock(return_value=mock)), patch("backend.app.api.routes.spoolman_inventory.init_spoolman_client", AsyncMock(return_value=mock)), patch("backend.app.api.routes.spoolman.get_spoolman_client", AsyncMock(return_value=mock)), patch("backend.app.api.routes.spoolbuddy._get_spoolman_client_or_none", AsyncMock(return_value=mock)), ): yield mock class TestReadsUseTheSpoolsWeight: @pytest.mark.asyncio @pytest.mark.integration async def test_inventory_shows_the_spools_own_weight(self, async_client: AsyncClient, spoolman_settings, client): response = await async_client.get("/api/v1/spoolman/inventory/spools/42") assert response.status_code == 200 body = response.json() assert body["label_weight"] == 250 assert body["label_weight"] - body["weight_used"] == pytest.approx(200.0) # 6.25 for 250 g is 25 per kilo assert body["cost_per_kg"] == pytest.approx(25.0) @pytest.mark.asyncio @pytest.mark.integration async def test_weigh_reports_weight_used_against_the_spool( self, async_client: AsyncClient, spoolman_settings, client ): client.update_spool_full = AsyncMock(return_value={**QUARTER_SPOOL, "remaining_weight": 150.0}) response = await async_client.patch("/api/v1/spoolman/inventory/spools/42/weight", json={"weight_grams": 250.0}) assert response.status_code == 200 assert client.update_spool_full.call_args.kwargs["remaining_weight"] == pytest.approx(150.0) assert response.json()["weight_used"] == pytest.approx(100.0) @pytest.mark.asyncio @pytest.mark.integration async def test_spoolbuddy_scale_reports_weight_used_against_the_spool( self, async_client: AsyncClient, spoolman_settings, client ): response = await async_client.post( "/api/v1/spoolbuddy/scale/update-spool-weight", json={"spool_id": 42, "weight_grams": 250.0} ) assert response.status_code == 200 client.update_spool.assert_called_once_with(spool_id=42, remaining_weight=pytest.approx(150.0)) assert response.json()["weight_used"] == pytest.approx(100.0) @pytest.mark.asyncio @pytest.mark.integration async def test_linked_spools_fill_reference_is_the_spools_weight( self, async_client: AsyncClient, spoolman_settings, client ): response = await async_client.get("/api/v1/spoolman/spools/linked") assert response.status_code == 200 assert response.json()["linked"]["AABBCCDD"]["filament_weight"] == pytest.approx(250.0) class TestCreateWritesTheSpoolsWeight: @pytest.mark.asyncio @pytest.mark.integration async def test_create_sends_initial_weight_and_the_price_of_the_spool( self, async_client: AsyncClient, spoolman_settings, client ): """A 250 g spool picked from a 1000 g catalogue filament is a 250 g spool.""" response = await async_client.post( "/api/v1/spoolman/inventory/spools", json={"spoolman_filament_id": 7, "label_weight": 250, "cost_per_kg": 25.0}, ) assert response.status_code == 200 assert client.create_spool.call_args.kwargs["initial_weight"] == 250.0 assert client.create_spool.call_args.kwargs["remaining_weight"] == 250.0 assert client.update_spool_full.call_args.kwargs["price"] == pytest.approx(6.25) @pytest.mark.asyncio @pytest.mark.integration async def test_bulk_create_sends_initial_weight(self, async_client: AsyncClient, spoolman_settings, client): response = await async_client.post( "/api/v1/spoolman/inventory/spools/bulk", json={"spool": {"spoolman_filament_id": 7, "label_weight": 250}, "quantity": 2}, ) assert response.status_code == 200 assert client.create_spool.call_count == 2 for call in client.create_spool.call_args_list: assert call.kwargs["initial_weight"] == 250.0 @pytest.mark.asyncio @pytest.mark.integration async def test_create_without_label_weight_leaves_the_size_to_spoolman( self, async_client: AsyncClient, spoolman_settings, client ): """An API caller that gives no label weight gets the filament's, as before: Spoolman fills initial_weight from the filament itself.""" response = await async_client.post( "/api/v1/spoolman/inventory/spools", json={"spoolman_filament_id": 7, "cost_per_kg": 25.0} ) assert response.status_code == 200 assert client.create_spool.call_args.kwargs["initial_weight"] is None # priced at the size Spoolman gave the spool (QUARTER_SPOOL: 250 g) assert client.update_spool_full.call_args.kwargs["price"] == pytest.approx(6.25) class TestEditWritesTheSpoolNotTheFilament: @pytest.mark.asyncio @pytest.mark.integration @pytest.mark.parametrize("shared", [True, False]) async def test_label_weight_edit_goes_to_initial_weight( self, async_client: AsyncClient, spoolman_settings, client, shared ): """Neither a shared nor a singleton filament is touched, and no duplicate filament is created for a spool of another size.""" client.is_filament_shared = AsyncMock(return_value=shared) response = await async_client.patch( "/api/v1/spoolman/inventory/spools/42", json={"label_weight": 500, "weight_used": 100.0}, ) assert response.status_code == 200 client.patch_filament.assert_not_called() client.find_or_create_filament.assert_not_called() kwargs = client.update_spool_full.call_args.kwargs assert kwargs["filament_id"] == 7 assert kwargs["initial_weight"] == 500.0 assert kwargs["remaining_weight"] == pytest.approx(400.0) @pytest.mark.asyncio @pytest.mark.integration async def test_untouched_edit_writes_neither_size_nor_price( self, async_client: AsyncClient, spoolman_settings, client ): """The form sends back the label weight and cost per kg it loaded; a save that changes neither must not write them.""" response = await async_client.patch( "/api/v1/spoolman/inventory/spools/42", json={"label_weight": 250, "cost_per_kg": 25.0, "note": "moved"}, ) assert response.status_code == 200 client.patch_filament.assert_not_called() kwargs = client.update_spool_full.call_args.kwargs assert kwargs["initial_weight"] is None assert kwargs["price"] is None assert kwargs["remaining_weight"] == pytest.approx(200.0) @pytest.mark.asyncio @pytest.mark.integration async def test_fractional_size_survives_an_untouched_edit( self, async_client: AsyncClient, spoolman_settings, client ): """The form shows 250.7 g as 250; saving it must not cut the spool to 250 g or re-price it.""" spool = {**QUARTER_SPOOL, "initial_weight": 250.7, "price": 6.99} client.get_spool = AsyncMock(return_value=spool) loaded = (await async_client.get("/api/v1/spoolman/inventory/spools/42")).json() assert loaded["label_weight"] == 250 response = await async_client.patch( "/api/v1/spoolman/inventory/spools/42", json={"label_weight": loaded["label_weight"], "cost_per_kg": loaded["cost_per_kg"]}, ) assert response.status_code == 200 kwargs = client.update_spool_full.call_args.kwargs assert kwargs["initial_weight"] is None assert kwargs["price"] is None @pytest.mark.asyncio @pytest.mark.integration async def test_price_is_converted_at_the_spools_weight(self, async_client: AsyncClient, spoolman_settings, client): response = await async_client.patch( "/api/v1/spoolman/inventory/spools/42", json={"label_weight": 250, "cost_per_kg": 30.0}, ) assert response.status_code == 200 assert client.update_spool_full.call_args.kwargs["price"] == pytest.approx(7.5) @pytest.mark.asyncio @pytest.mark.integration @pytest.mark.parametrize("payload", [{"label_weight": 500}, {"label_weight": 500, "cost_per_kg": 25.0}]) async def test_resize_keeps_the_rate_per_kg(self, async_client: AsyncClient, spoolman_settings, client, payload): """25 per kg stays 25 per kg when the spool is resized, as in internal mode, whether or not the caller (e.g. Bulk Edit) sends the rate.""" response = await async_client.patch("/api/v1/spoolman/inventory/spools/42", json=payload) assert response.status_code == 200 kwargs = client.update_spool_full.call_args.kwargs assert kwargs["initial_weight"] == 500.0 assert kwargs["price"] == pytest.approx(12.5) @pytest.mark.asyncio @pytest.mark.integration async def test_colour_change_on_shared_filament_relinks_without_resizing( self, async_client: AsyncClient, spoolman_settings, client ): """A relink to a filament of another weight keeps the spool's size: Spoolman would otherwise stamp the new filament's weight on a spool without an initial_weight of its own.""" client.find_or_create_filament = AsyncMock(return_value=8) spool = {**QUARTER_SPOOL, "initial_weight": None, "remaining_weight": 800.0, "used_weight": 200.0} client.get_spool = AsyncMock(return_value=spool) response = await async_client.patch( "/api/v1/spoolman/inventory/spools/42", json={"rgba": "00FF00FF"}, ) assert response.status_code == 200 kwargs = client.update_spool_full.call_args.kwargs assert kwargs["filament_id"] == 8 assert kwargs["initial_weight"] == 1000.0 assert kwargs["remaining_weight"] == pytest.approx(800.0) assert kwargs["price"] is None class TestSpoolWithoutAnySize: """Spoolman refuses a remaining_weight (HTTP 400, the whole PATCH) while a spool has neither an initial_weight nor a filament weight.""" BARE = {**QUARTER_SPOOL, "initial_weight": None, "remaining_weight": None, "used_weight": 0.0, "price": None} @pytest.mark.asyncio @pytest.mark.integration async def test_edit_without_label_weight_sends_no_remaining( self, async_client: AsyncClient, spoolman_settings, client ): client.get_spool = AsyncMock( return_value={**self.BARE, "filament": {**QUARTER_SPOOL["filament"], "weight": None}} ) response = await async_client.patch("/api/v1/spoolman/inventory/spools/42", json={"note": "hi"}) assert response.status_code == 200 kwargs = client.update_spool_full.call_args.kwargs assert kwargs["remaining_weight"] is None assert kwargs["initial_weight"] is None @pytest.mark.asyncio @pytest.mark.integration async def test_form_label_weight_becomes_the_size(self, async_client: AsyncClient, spoolman_settings, client): """The form shows such a spool as 1000 g and sends that back.""" client.get_spool = AsyncMock( return_value={**self.BARE, "filament": {**QUARTER_SPOOL["filament"], "weight": None}} ) response = await async_client.patch( "/api/v1/spoolman/inventory/spools/42", json={"label_weight": 1000, "weight_used": 100.0} ) assert response.status_code == 200 kwargs = client.update_spool_full.call_args.kwargs assert kwargs["initial_weight"] == 1000.0 assert kwargs["remaining_weight"] == pytest.approx(900.0) class TestAmsSyncUsesTheSpoolsWeight: @pytest.mark.asyncio @pytest.mark.integration async def test_ams_percentage_is_of_the_spool( self, async_client: AsyncClient, spoolman_settings, client, db_session ): from backend.app.models.printer import Printer from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment printer = Printer(name="P", serial_number="SN3194", ip_address="192.168.1.9", access_code="12345678") db_session.add(printer) await db_session.commit() await db_session.refresh(printer) db_session.add(SpoolmanSlotAssignment(printer_id=printer.id, ams_id=0, tray_id=0, spoolman_spool_id=42)) await db_session.commit() state = MagicMock() state.raw_data = {"ams": [{"id": 0, "tray": [{"id": 0, "remain": 80}]}]} with ( patch("backend.app.api.routes.spoolman_inventory._get_client", AsyncMock(return_value=client)), patch("backend.app.api.routes.spoolman_inventory.printer_manager") as pm, ): pm.get_status = MagicMock(return_value=state) response = await async_client.post("/api/v1/spoolman/inventory/sync-ams-weights") assert response.status_code == 200 assert response.json()["synced"] == 1 # 80 % of 250 g, not of the filament's 1000 g client.update_spool_full.assert_called_once_with(42, remaining_weight=200.0)