Use the vendor's empty spool weight as tare in Spoolman mode (#3195)

Spoolman resolves a spool's tare from the spool, then the filament, then
the vendor's empty_spool_weight. Bambuddy skipped the vendor and fell
back to 250 g, so weighing a spool whose tare was set only on its vendor
gave the wrong remaining weight. The inventory weigh action, the
SpoolBuddy scale and the displayed core weight now share one lookup, and
"keep old weight" on a filament change stamps a vendor-inherited tare.
This commit is contained in:
maziggy
2026-09-30 08:43:21 +02:00
parent d2a7a2d20f
commit c145b3ebc1
6 changed files with 215 additions and 20 deletions
@@ -525,3 +525,113 @@ class TestUpdateSpoolWeightPriority:
# 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")
# ---------------------------------------------------------------------------
# Vendor-level tare (#3195): spool -> filament -> vendor.empty_spool_weight -> 250 g
# ---------------------------------------------------------------------------
# The reporter's numbers: Sunlu's empty_spool_weight is 211.7 g, the scale reads
# 1177 g, and Spoolman's own /measure gives 965.3 g remaining.
SUNLU_VENDOR = {"id": 5, "name": "Sunlu", "empty_spool_weight": 211.7}
SPOOL_WITH_VENDOR_TARE_ONLY = {
**SAMPLE_SPOOL_WITH_FILAMENT_7,
"spool_weight": None,
"filament": {**SAMPLE_SPOOL_WITH_FILAMENT_7["filament"], "spool_weight": None, "vendor": SUNLU_VENDOR},
}
class TestVendorTare:
@pytest.mark.asyncio
@pytest.mark.integration
async def test_sync_weight_uses_vendor_empty_spool_weight(self, async_client: AsyncClient, spoolman_settings):
mock_client = make_mock_client()
mock_client.get_spool = AsyncMock(return_value=SPOOL_WITH_VENDOR_TARE_ONLY)
mock_client.update_spool_full = AsyncMock(return_value=SPOOL_WITH_VENDOR_TARE_ONLY)
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": 1177.0},
)
assert response.status_code == 200
assert mock_client.update_spool_full.call_args.kwargs["remaining_weight"] == pytest.approx(965.3)
@pytest.mark.asyncio
@pytest.mark.integration
async def test_sync_weight_filament_tare_still_beats_vendor(self, async_client: AsyncClient, spoolman_settings):
spool_data = {
**SPOOL_WITH_VENDOR_TARE_ONLY,
"filament": {**SPOOL_WITH_VENDOR_TARE_ONLY["filament"], "spool_weight": 180.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": 1177.0},
)
assert response.status_code == 200
assert mock_client.update_spool_full.call_args.kwargs["remaining_weight"] == pytest.approx(997.0)
@pytest.mark.asyncio
@pytest.mark.integration
async def test_spoolbuddy_scale_uses_vendor_tare_without_warning(
self, async_client: AsyncClient, spoolman_settings
):
mock_client = MagicMock()
mock_client.get_spool = AsyncMock(return_value=SPOOL_WITH_VENDOR_TARE_ONLY)
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": 1177.0},
)
assert response.status_code == 200
mock_client.update_spool.assert_called_once_with(spool_id=42, remaining_weight=pytest.approx(965.3))
# The vendor tare is a real value, so the 250 g fallback warning must not fire.
assert not response.json().get("warnings")
@pytest.mark.asyncio
@pytest.mark.integration
async def test_option_a_stamps_vendor_tare_when_filament_had_none(
self, async_client: AsyncClient, spoolman_settings
):
"""Inheriting spools weighed against the vendor's tare before the filament
got its own, so keeping them unchanged means stamping the vendor's value."""
filament = {**SAMPLE_FILAMENT, "spool_weight": None, "vendor": SUNLU_VENDOR}
mock_client = make_mock_client(filament=filament, all_spools=[SPOOL_WITH_VENDOR_TARE_ONLY])
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_called_once_with(spool_id=42, spool_weight=pytest.approx(211.7))
@pytest.mark.asyncio
@pytest.mark.integration
async def test_option_a_filament_tare_beats_vendor_tare(self, async_client: AsyncClient, spoolman_settings):
filament = {**SAMPLE_FILAMENT, "spool_weight": 250.0, "vendor": SUNLU_VENDOR}
mock_client = make_mock_client(filament=filament, all_spools=[SPOOL_WITH_VENDOR_TARE_ONLY])
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_called_once_with(spool_id=42, spool_weight=pytest.approx(250.0))