| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334 |
- """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)
|