mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-09-30 11:12:35 +02:00
feat(spoolman-inventory): squashed feature work for rebase onto dev Squashed all commits from feature/spoolman-inventory-ui onto a single commit to enable a clean rebase onto dev. Original per-commit history preserved at backup tag backup/spoolman-inventory-ui-prerebase-20260507-105721.
528 lines
24 KiB
Python
528 lines
24 KiB
Python
"""Integration tests for PATCH /spoolman/inventory/filaments/{filament_id}.
|
|
|
|
Covers:
|
|
- Option A (keep_existing_spools=True): stamps old filament weight onto spools that currently inherit
|
|
- Option B (keep_existing_spools=False): clears per-spool overrides in Spoolman so all inherit new value
|
|
- Name-only patch: no get_all_spools call
|
|
- Edge cases: disabled Spoolman, not found, invalid inputs
|
|
- Spool-level tare priority in sync_spool_weight (spoolman_inventory) and update_spool_weight (spoolbuddy)
|
|
"""
|
|
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
import pytest
|
|
from httpx import AsyncClient
|
|
|
|
SAMPLE_FILAMENT = {
|
|
"id": 7,
|
|
"name": "PLA Basic",
|
|
"material": "PLA",
|
|
"color_hex": "FF0000",
|
|
"color_name": "Red",
|
|
"weight": 1000,
|
|
"spool_weight": 250.0,
|
|
"vendor": {"id": 3, "name": "Bambu Lab"},
|
|
}
|
|
|
|
SAMPLE_SPOOL_WITH_FILAMENT_7 = {
|
|
"id": 42,
|
|
"spool_weight": None, # inheriting from filament
|
|
"filament": {"id": 7, "name": "PLA Basic", "material": "PLA", "spool_weight": 250.0, "weight": 1000},
|
|
"remaining_weight": 750.0,
|
|
"used_weight": 250.0,
|
|
"location": None,
|
|
"comment": None,
|
|
"archived": False,
|
|
"extra": {},
|
|
}
|
|
|
|
SAMPLE_SPOOL_WITH_FILAMENT_99 = {
|
|
"id": 55,
|
|
"spool_weight": 196.0, # has its own spool-level override
|
|
"filament": {"id": 99, "name": "PETG HF", "material": "PETG", "spool_weight": 196.0, "weight": 1000},
|
|
"remaining_weight": 500.0,
|
|
"used_weight": 500.0,
|
|
"location": None,
|
|
"comment": None,
|
|
"archived": False,
|
|
"extra": {},
|
|
}
|
|
|
|
SPOOL_WITH_NULL_FILAMENT = {
|
|
"id": 77,
|
|
"spool_weight": None,
|
|
"filament": None,
|
|
"remaining_weight": 100.0,
|
|
"used_weight": 900.0,
|
|
"location": None,
|
|
"comment": None,
|
|
"archived": False,
|
|
"extra": {},
|
|
}
|
|
|
|
SAMPLE_SPOOL_7_WITH_OVERRIDE = {
|
|
"id": 43,
|
|
"spool_weight": 300.0, # has its own spool-level override
|
|
"filament": {"id": 7, "name": "PLA Basic", "material": "PLA", "spool_weight": 250.0, "weight": 1000},
|
|
"remaining_weight": 700.0,
|
|
"used_weight": 300.0,
|
|
"location": None,
|
|
"comment": None,
|
|
"archived": False,
|
|
"extra": {},
|
|
}
|
|
|
|
|
|
@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()
|
|
|
|
|
|
def make_mock_client(filament=None, all_spools=None, patched_filament=None):
|
|
mock_client = MagicMock()
|
|
mock_client.base_url = "http://localhost:7912"
|
|
mock_client.get_filament = AsyncMock(return_value=filament or SAMPLE_FILAMENT)
|
|
mock_client.patch_filament = AsyncMock(return_value=patched_filament or SAMPLE_FILAMENT)
|
|
mock_client.get_all_spools = AsyncMock(
|
|
return_value=all_spools if all_spools is not None else [SAMPLE_SPOOL_WITH_FILAMENT_7]
|
|
)
|
|
mock_client.update_spool_full = AsyncMock(return_value={})
|
|
return mock_client
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# PATCH /filaments/{id} — core scenarios
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestPatchFilamentOptionB:
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.integration
|
|
async def test_option_b_stamps_new_weight_on_all_affected_spools(
|
|
self, async_client: AsyncClient, spoolman_settings
|
|
):
|
|
"""Option B: ALL affected spools (inheriting and overridden alike) get the new weight stamped."""
|
|
mock_client = make_mock_client(all_spools=[SAMPLE_SPOOL_WITH_FILAMENT_7, SAMPLE_SPOOL_7_WITH_OVERRIDE])
|
|
with patch("backend.app.api.routes.spoolman_inventory._get_client", AsyncMock(return_value=mock_client)):
|
|
response = await async_client.patch(
|
|
"/api/v1/spoolman/inventory/filaments/7",
|
|
json={"spool_weight": 196.0, "keep_existing_spools": False},
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
mock_client.patch_filament.assert_called_once_with(7, {"spool_weight": 196.0})
|
|
assert mock_client.update_spool_full.call_count == 2
|
|
calls = {c.kwargs["spool_id"]: c.kwargs["spool_weight"] for c in mock_client.update_spool_full.call_args_list}
|
|
assert calls[42] == pytest.approx(196.0)
|
|
assert calls[43] == pytest.approx(196.0)
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.integration
|
|
async def test_option_b_stamps_inheriting_spool_with_new_weight(self, async_client: AsyncClient, spoolman_settings):
|
|
"""Option B: a spool inheriting (spool_weight=None) gets the new weight explicitly stamped."""
|
|
mock_client = make_mock_client(all_spools=[SAMPLE_SPOOL_WITH_FILAMENT_7])
|
|
with patch("backend.app.api.routes.spoolman_inventory._get_client", AsyncMock(return_value=mock_client)):
|
|
response = await async_client.patch(
|
|
"/api/v1/spoolman/inventory/filaments/7",
|
|
json={"spool_weight": 196.0, "keep_existing_spools": False},
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
mock_client.update_spool_full.assert_called_once_with(spool_id=42, spool_weight=pytest.approx(196.0))
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.integration
|
|
async def test_option_b_only_stamps_affected_filament_spools(self, async_client: AsyncClient, spoolman_settings):
|
|
"""Option B for filament 7 must not touch spools belonging to other filament types."""
|
|
mock_client = make_mock_client(all_spools=[SAMPLE_SPOOL_WITH_FILAMENT_7, SAMPLE_SPOOL_WITH_FILAMENT_99])
|
|
with patch("backend.app.api.routes.spoolman_inventory._get_client", AsyncMock(return_value=mock_client)):
|
|
response = await async_client.patch(
|
|
"/api/v1/spoolman/inventory/filaments/7",
|
|
json={"spool_weight": 196.0, "keep_existing_spools": False},
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
# Only spool 42 (filament 7) should be stamped; spool 55 (filament 99) must not be touched
|
|
mock_client.update_spool_full.assert_called_once_with(spool_id=42, spool_weight=pytest.approx(196.0))
|
|
|
|
|
|
class TestPatchFilamentOptionA:
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.integration
|
|
async def test_option_a_stamps_old_weight_on_inheriting_spools(self, async_client: AsyncClient, spoolman_settings):
|
|
"""Option A: spools inheriting from filament (spool_weight=None) get old weight stamped on them."""
|
|
mock_client = make_mock_client(all_spools=[SAMPLE_SPOOL_WITH_FILAMENT_7])
|
|
with patch("backend.app.api.routes.spoolman_inventory._get_client", AsyncMock(return_value=mock_client)):
|
|
response = await async_client.patch(
|
|
"/api/v1/spoolman/inventory/filaments/7",
|
|
json={"spool_weight": 196.0, "keep_existing_spools": True},
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
# old_weight = SAMPLE_FILAMENT["spool_weight"] = 250.0
|
|
mock_client.update_spool_full.assert_called_once_with(spool_id=42, spool_weight=pytest.approx(250.0))
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.integration
|
|
async def test_option_a_does_not_patch_spools_with_existing_override(
|
|
self, async_client: AsyncClient, spoolman_settings
|
|
):
|
|
"""Option A: spools already having their own spool_weight are left unchanged."""
|
|
mock_client = make_mock_client(all_spools=[SAMPLE_SPOOL_7_WITH_OVERRIDE])
|
|
with patch("backend.app.api.routes.spoolman_inventory._get_client", AsyncMock(return_value=mock_client)):
|
|
response = await async_client.patch(
|
|
"/api/v1/spoolman/inventory/filaments/7",
|
|
json={"spool_weight": 196.0, "keep_existing_spools": True},
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
mock_client.update_spool_full.assert_not_called()
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.integration
|
|
async def test_option_a_mixed_spools_stamps_only_inheriting(self, async_client: AsyncClient, spoolman_settings):
|
|
"""Option A: only inheriting spools (spool_weight=None) get old weight; overridden spools are skipped."""
|
|
mock_client = make_mock_client(all_spools=[SAMPLE_SPOOL_WITH_FILAMENT_7, SAMPLE_SPOOL_7_WITH_OVERRIDE])
|
|
with patch("backend.app.api.routes.spoolman_inventory._get_client", AsyncMock(return_value=mock_client)):
|
|
response = await async_client.patch(
|
|
"/api/v1/spoolman/inventory/filaments/7",
|
|
json={"spool_weight": 196.0, "keep_existing_spools": True},
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
# Only spool 42 (inheriting) should be stamped; spool 43 (has override) must not be touched
|
|
mock_client.update_spool_full.assert_called_once_with(spool_id=42, spool_weight=pytest.approx(250.0))
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.integration
|
|
async def test_option_a_zero_spools_no_error(self, async_client: AsyncClient, spoolman_settings):
|
|
"""Option A with zero spools for this filament: no error, no Spoolman calls."""
|
|
mock_client = make_mock_client(all_spools=[])
|
|
with patch("backend.app.api.routes.spoolman_inventory._get_client", AsyncMock(return_value=mock_client)):
|
|
response = await async_client.patch(
|
|
"/api/v1/spoolman/inventory/filaments/7",
|
|
json={"spool_weight": 196.0, "keep_existing_spools": True},
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
mock_client.update_spool_full.assert_not_called()
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.integration
|
|
async def test_option_a_filament_no_old_weight_skips_stamping(self, async_client: AsyncClient, spoolman_settings):
|
|
"""Option A: if the filament has no old spool_weight, no stamping occurs."""
|
|
mock_client = make_mock_client(filament={**SAMPLE_FILAMENT, "spool_weight": None})
|
|
with patch("backend.app.api.routes.spoolman_inventory._get_client", AsyncMock(return_value=mock_client)):
|
|
response = await async_client.patch(
|
|
"/api/v1/spoolman/inventory/filaments/7",
|
|
json={"spool_weight": 196.0, "keep_existing_spools": True},
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
mock_client.update_spool_full.assert_not_called()
|
|
|
|
|
|
class TestPatchFilamentNameOnly:
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.integration
|
|
async def test_name_only_patch_no_get_all_spools(self, async_client: AsyncClient, db_session, spoolman_settings):
|
|
"""Patching name only must not call get_all_spools."""
|
|
mock_client = make_mock_client()
|
|
with patch("backend.app.api.routes.spoolman_inventory._get_client", AsyncMock(return_value=mock_client)):
|
|
response = await async_client.patch(
|
|
"/api/v1/spoolman/inventory/filaments/7",
|
|
json={"name": "PLA Basic Renamed"},
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
mock_client.patch_filament.assert_called_once_with(7, {"name": "PLA Basic Renamed"})
|
|
mock_client.get_all_spools.assert_not_called()
|
|
|
|
|
|
class TestPatchFilamentEdgeCases:
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.integration
|
|
async def test_null_filament_on_spool_skipped(self, async_client: AsyncClient, db_session, spoolman_settings):
|
|
"""Spools with filament=null are skipped without error."""
|
|
mock_client = make_mock_client(all_spools=[SPOOL_WITH_NULL_FILAMENT, SAMPLE_SPOOL_WITH_FILAMENT_7])
|
|
with patch("backend.app.api.routes.spoolman_inventory._get_client", AsyncMock(return_value=mock_client)):
|
|
response = await async_client.patch(
|
|
"/api/v1/spoolman/inventory/filaments/7",
|
|
json={"spool_weight": 196.0},
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.integration
|
|
async def test_spool_weight_zero_is_valid(self, async_client: AsyncClient, db_session, spoolman_settings):
|
|
"""spool_weight=0 is valid (0g tare weight is legitimate)."""
|
|
mock_client = make_mock_client()
|
|
with patch("backend.app.api.routes.spoolman_inventory._get_client", AsyncMock(return_value=mock_client)):
|
|
response = await async_client.patch(
|
|
"/api/v1/spoolman/inventory/filaments/7",
|
|
json={"spool_weight": 0},
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
mock_client.patch_filament.assert_called_once_with(7, {"spool_weight": 0})
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.integration
|
|
async def test_spool_weight_null_removes_weight(self, async_client: AsyncClient, db_session, spoolman_settings):
|
|
"""spool_weight=null is forwarded to Spoolman as None."""
|
|
mock_client = make_mock_client()
|
|
with patch("backend.app.api.routes.spoolman_inventory._get_client", AsyncMock(return_value=mock_client)):
|
|
response = await async_client.patch(
|
|
"/api/v1/spoolman/inventory/filaments/7",
|
|
json={"spool_weight": None},
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
mock_client.patch_filament.assert_called_once_with(7, {"spool_weight": None})
|
|
|
|
|
|
class TestPatchFilamentErrors:
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.integration
|
|
async def test_disabled_returns_400(self, async_client: AsyncClient, db_session):
|
|
"""When Spoolman is disabled, PATCH /filaments/{id} returns 400."""
|
|
response = await async_client.patch(
|
|
"/api/v1/spoolman/inventory/filaments/7",
|
|
json={"spool_weight": 196.0},
|
|
)
|
|
assert response.status_code == 400
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.integration
|
|
async def test_not_found_returns_404(self, async_client: AsyncClient, db_session, spoolman_settings):
|
|
"""When get_filament raises SpoolmanNotFoundError, endpoint returns 404."""
|
|
from backend.app.services.spoolman import SpoolmanNotFoundError
|
|
|
|
mock_client = make_mock_client()
|
|
mock_client.get_filament = AsyncMock(side_effect=SpoolmanNotFoundError("not found"))
|
|
with patch("backend.app.api.routes.spoolman_inventory._get_client", AsyncMock(return_value=mock_client)):
|
|
response = await async_client.patch(
|
|
"/api/v1/spoolman/inventory/filaments/7",
|
|
json={"spool_weight": 196.0},
|
|
)
|
|
|
|
assert response.status_code == 404
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.integration
|
|
async def test_invalid_id_returns_422(self, async_client: AsyncClient, db_session, spoolman_settings):
|
|
"""filament_id=0 fails Path validation (gt=0) with 422."""
|
|
response = await async_client.patch(
|
|
"/api/v1/spoolman/inventory/filaments/0",
|
|
json={"spool_weight": 196.0},
|
|
)
|
|
assert response.status_code == 422
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.integration
|
|
async def test_negative_spool_weight_returns_422(self, async_client: AsyncClient, db_session, spoolman_settings):
|
|
"""spool_weight=-1 fails Pydantic validation (ge=0.0) with 422."""
|
|
mock_client = make_mock_client()
|
|
with patch("backend.app.api.routes.spoolman_inventory._get_client", AsyncMock(return_value=mock_client)):
|
|
response = await async_client.patch(
|
|
"/api/v1/spoolman/inventory/filaments/7",
|
|
json={"spool_weight": -1},
|
|
)
|
|
assert response.status_code == 422
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Spool-level tare priority in sync_spool_weight (spoolman_inventory)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestSyncSpoolWeightPriority:
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.integration
|
|
async def test_spool_level_spool_weight_takes_priority(self, async_client: AsyncClient, spoolman_settings):
|
|
"""sync_spool_weight uses spool.spool_weight over filament.spool_weight for tare."""
|
|
spool_data = {**SAMPLE_SPOOL_WITH_FILAMENT_7, "spool_weight": 100.0}
|
|
mock_client = make_mock_client()
|
|
mock_client.get_spool = AsyncMock(return_value=spool_data)
|
|
mock_client.update_spool_full = AsyncMock(return_value=spool_data)
|
|
|
|
with patch("backend.app.api.routes.spoolman_inventory._get_client", AsyncMock(return_value=mock_client)):
|
|
response = await async_client.patch(
|
|
"/api/v1/spoolman/inventory/spools/42/weight",
|
|
json={"weight_grams": 600.0},
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
# remaining = 600 - 100 (spool-level tare) = 500
|
|
update_call = mock_client.update_spool_full.call_args
|
|
assert update_call.kwargs["remaining_weight"] == pytest.approx(500.0)
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.integration
|
|
async def test_filament_spool_weight_used_as_fallback(self, async_client: AsyncClient, spoolman_settings):
|
|
"""sync_spool_weight falls back to filament.spool_weight when spool.spool_weight is None."""
|
|
spool_data = {**SAMPLE_SPOOL_WITH_FILAMENT_7} # spool_weight=None → filament fallback 250.0
|
|
mock_client = make_mock_client()
|
|
mock_client.get_spool = AsyncMock(return_value=spool_data)
|
|
mock_client.update_spool_full = AsyncMock(return_value=spool_data)
|
|
|
|
with patch("backend.app.api.routes.spoolman_inventory._get_client", AsyncMock(return_value=mock_client)):
|
|
response = await async_client.patch(
|
|
"/api/v1/spoolman/inventory/spools/42/weight",
|
|
json={"weight_grams": 600.0},
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
# remaining = 600 - 250 (filament.spool_weight) = 350
|
|
update_call = mock_client.update_spool_full.call_args
|
|
assert update_call.kwargs["remaining_weight"] == pytest.approx(350.0)
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.integration
|
|
async def test_spool_level_zero_not_treated_as_missing(self, async_client: AsyncClient, spoolman_settings):
|
|
"""spool.spool_weight=0 is a valid 0g tare, not treated as missing."""
|
|
spool_data = {**SAMPLE_SPOOL_WITH_FILAMENT_7, "spool_weight": 0}
|
|
mock_client = make_mock_client()
|
|
mock_client.get_spool = AsyncMock(return_value=spool_data)
|
|
mock_client.update_spool_full = AsyncMock(return_value=spool_data)
|
|
|
|
with patch("backend.app.api.routes.spoolman_inventory._get_client", AsyncMock(return_value=mock_client)):
|
|
response = await async_client.patch(
|
|
"/api/v1/spoolman/inventory/spools/42/weight",
|
|
json={"weight_grams": 600.0},
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
# remaining = 600 - 0 = 600 (not 600 - 250 fallback)
|
|
update_call = mock_client.update_spool_full.call_args
|
|
assert update_call.kwargs["remaining_weight"] == pytest.approx(600.0)
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.integration
|
|
async def test_both_levels_none_uses_250g_fallback(self, async_client: AsyncClient, spoolman_settings):
|
|
"""When both spool.spool_weight and filament.spool_weight are None, 250g fallback is used."""
|
|
spool_data = {
|
|
**SAMPLE_SPOOL_WITH_FILAMENT_7,
|
|
"spool_weight": None,
|
|
"filament": {**SAMPLE_SPOOL_WITH_FILAMENT_7["filament"], "spool_weight": None},
|
|
}
|
|
mock_client = make_mock_client()
|
|
mock_client.get_spool = AsyncMock(return_value=spool_data)
|
|
mock_client.update_spool_full = AsyncMock(return_value=spool_data)
|
|
|
|
with patch("backend.app.api.routes.spoolman_inventory._get_client", AsyncMock(return_value=mock_client)):
|
|
response = await async_client.patch(
|
|
"/api/v1/spoolman/inventory/spools/42/weight",
|
|
json={"weight_grams": 600.0},
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
# remaining = 600 - 250 (fallback) = 350
|
|
update_call = mock_client.update_spool_full.call_args
|
|
assert update_call.kwargs["remaining_weight"] == pytest.approx(350.0)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Spool-level tare priority in update_spool_weight (spoolbuddy.py scale endpoint)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestUpdateSpoolWeightPriority:
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.integration
|
|
async def test_spool_level_spool_weight_takes_priority(self, async_client: AsyncClient, spoolman_settings):
|
|
"""update_spool_weight uses spool.spool_weight over filament.spool_weight for tare."""
|
|
spool_data = {**SAMPLE_SPOOL_WITH_FILAMENT_7, "spool_weight": 100.0}
|
|
mock_client = MagicMock()
|
|
mock_client.get_spool = AsyncMock(return_value=spool_data)
|
|
mock_client.update_spool = AsyncMock(return_value=None)
|
|
|
|
with patch(
|
|
"backend.app.api.routes.spoolbuddy._get_spoolman_client_or_none",
|
|
AsyncMock(return_value=mock_client),
|
|
):
|
|
response = await async_client.post(
|
|
"/api/v1/spoolbuddy/scale/update-spool-weight",
|
|
json={"spool_id": 42, "weight_grams": 600.0},
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
# remaining = 600 - 100 (spool-level tare) = 500
|
|
mock_client.update_spool.assert_called_once_with(spool_id=42, remaining_weight=pytest.approx(500.0))
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.integration
|
|
async def test_filament_spool_weight_used_as_fallback(self, async_client: AsyncClient, spoolman_settings):
|
|
"""update_spool_weight falls back to filament.spool_weight when spool.spool_weight is None."""
|
|
spool_data = {**SAMPLE_SPOOL_WITH_FILAMENT_7} # spool_weight=None → filament fallback 250.0
|
|
mock_client = MagicMock()
|
|
mock_client.get_spool = AsyncMock(return_value=spool_data)
|
|
mock_client.update_spool = AsyncMock(return_value=None)
|
|
|
|
with patch(
|
|
"backend.app.api.routes.spoolbuddy._get_spoolman_client_or_none",
|
|
AsyncMock(return_value=mock_client),
|
|
):
|
|
response = await async_client.post(
|
|
"/api/v1/spoolbuddy/scale/update-spool-weight",
|
|
json={"spool_id": 42, "weight_grams": 600.0},
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
# remaining = 600 - 250 (filament.spool_weight) = 350
|
|
mock_client.update_spool.assert_called_once_with(spool_id=42, remaining_weight=pytest.approx(350.0))
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.integration
|
|
async def test_spool_level_zero_not_treated_as_missing(self, async_client: AsyncClient, spoolman_settings):
|
|
"""spool.spool_weight=0 is a valid 0g tare, not treated as missing."""
|
|
spool_data = {**SAMPLE_SPOOL_WITH_FILAMENT_7, "spool_weight": 0}
|
|
mock_client = MagicMock()
|
|
mock_client.get_spool = AsyncMock(return_value=spool_data)
|
|
mock_client.update_spool = AsyncMock(return_value=None)
|
|
|
|
with patch(
|
|
"backend.app.api.routes.spoolbuddy._get_spoolman_client_or_none",
|
|
AsyncMock(return_value=mock_client),
|
|
):
|
|
response = await async_client.post(
|
|
"/api/v1/spoolbuddy/scale/update-spool-weight",
|
|
json={"spool_id": 42, "weight_grams": 600.0},
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
# remaining = 600 - 0 = 600 (not 600 - 250 fallback)
|
|
mock_client.update_spool.assert_called_once_with(spool_id=42, remaining_weight=pytest.approx(600.0))
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.integration
|
|
async def test_both_levels_none_uses_250g_fallback_and_warns(self, async_client: AsyncClient, spoolman_settings):
|
|
"""When both spool.spool_weight and filament.spool_weight are None, 250g fallback is used with a warning."""
|
|
spool_data = {
|
|
**SAMPLE_SPOOL_WITH_FILAMENT_7,
|
|
"spool_weight": None,
|
|
"filament": {**SAMPLE_SPOOL_WITH_FILAMENT_7["filament"], "spool_weight": None},
|
|
}
|
|
mock_client = MagicMock()
|
|
mock_client.get_spool = AsyncMock(return_value=spool_data)
|
|
mock_client.update_spool = AsyncMock(return_value=None)
|
|
|
|
with patch(
|
|
"backend.app.api.routes.spoolbuddy._get_spoolman_client_or_none",
|
|
AsyncMock(return_value=mock_client),
|
|
):
|
|
response = await async_client.post(
|
|
"/api/v1/spoolbuddy/scale/update-spool-weight",
|
|
json={"spool_id": 42, "weight_grams": 600.0},
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
# remaining = 600 - 250 (fallback) = 350
|
|
mock_client.update_spool.assert_called_once_with(spool_id=42, remaining_weight=pytest.approx(350.0))
|
|
assert response.json().get("warnings")
|