Let a spool keep its own empty weight (issue #2908) (#3011)

This commit is contained in:
maziggy
2026-09-30 16:19:21 +02:00
31 changed files with 559 additions and 82 deletions
@@ -32,6 +32,7 @@ class MappedSpoolFields(TypedDict):
effect_type: None
label_weight: int | None
core_weight: int | None
core_weight_is_inherited: bool
core_weight_catalog_id: None
weight_used: float | None
weight_used_baseline: float | None
@@ -378,6 +379,11 @@ def _map_spoolman_spool(spool: dict) -> MappedSpoolFields:
"core_weight": _safe_int(
spool.get("spool_weight") if spool.get("spool_weight") is not None else filament.get("spool_weight"), 250
),
# True when the spool has no spool_weight of its own and core_weight is
# the filament type's (or the 250 g fallback). The spool form needs it
# to copy a spool without dropping an own tare or stamping an
# inherited one (#2908).
"core_weight_is_inherited": spool.get("spool_weight") is None,
"core_weight_catalog_id": None,
"weight_used": used_weight,
"weight_used_baseline": weight_used_baseline,
+18 -6
View File
@@ -311,9 +311,12 @@ class SpoolmanInventoryCreate(BaseModel):
color_name: str | None = Field(None, max_length=64)
rgba: str | None = Field(None, max_length=8, description="6-digit hex (RRGGBB) or 8-digit (RRGGBBAA)")
label_weight: int = Field(1000, ge=1, le=100_000)
core_weight: int = Field(
250, ge=0, le=10_000
) # Accepted for schema parity but not persisted to Spoolman (stored on filament type, not spool)
# Persisted to the Spoolman spool's own `spool_weight` (tare), which takes
# priority over the filament-level value both in _map_spoolman_spool and in
# the weigh endpoint. Only written when the request actually sets it: the
# 250 default is the display fallback, and writing it on every create would
# stamp an explicit tare on spools that should keep inheriting one (#2908).
core_weight: int = Field(250, ge=0, le=10_000)
weight_used: float = Field(0.0, ge=0.0, le=100_000.0)
note: str | None = Field(None, max_length=1000)
cost_per_kg: float | None = Field(None, ge=0.0, le=1_000_000.0)
@@ -352,9 +355,11 @@ class SpoolmanInventoryUpdate(BaseModel):
color_name: str | None = Field(None, max_length=64)
rgba: str | None = Field(None, max_length=8, description="6-digit hex (RRGGBB) or 8-digit (RRGGBBAA)")
label_weight: int | None = Field(None, ge=1, le=100_000)
core_weight: int | None = Field(
None, ge=0, le=10_000
) # Accepted for schema parity but not persisted to Spoolman (stored on filament type, not spool)
# Persisted to the spool's own `spool_weight` (see the Create schema).
# Omitted / null leaves the current value alone, as with every other field
# here. There is no per-spool "go back to inheriting" through this route;
# the filament-level route already owns that decision (#2908).
core_weight: int | None = Field(None, ge=0, le=10_000)
weight_used: float | None = Field(None, ge=0.0, le=100_000.0)
note: str | None = Field(None, max_length=1000)
cost_per_kg: float | None = Field(None, ge=0.0, le=1_000_000.0)
@@ -552,6 +557,7 @@ async def create_spool(
remaining_weight=remaining,
comment=data.note or None,
location=storage_location or None,
spool_weight=(data.core_weight if "core_weight" in data.model_fields_set else None),
)
except HTTPException as exc:
if exc.status_code == 404 and data.spoolman_filament_id is not None:
@@ -641,6 +647,7 @@ async def bulk_create_spools(
remaining_weight=remaining,
comment=data.note or None,
location=storage_location or None,
spool_weight=(data.core_weight if "core_weight" in data.model_fields_set else None),
)
except (SpoolmanUnavailableError, SpoolmanClientError, SpoolmanNotFoundError) as exc:
logger.warning("Bulk spool creation: one spool failed: %s", exc)
@@ -854,6 +861,11 @@ async def update_spool(
extra=extra,
location=storage_location or None,
clear_location=storage_location_changed and not storage_location,
# No model_fields_set guard here, unlike create: this schema
# already defaults core_weight to None, and None is what
# update_spool_full reads as "leave the tare alone". A guard
# would be a second spelling of the same condition.
spool_weight=data.core_weight,
)
# Persist BambuStudio slicer preset AND color_name under spool.extra.
+6
View File
@@ -413,6 +413,7 @@ class SpoolmanClient:
lot_nr: str | None = None,
comment: str | None = None,
extra: dict | None = None,
spool_weight: float | None = None,
) -> dict:
"""Create a new spool in Spoolman."""
data: dict = {"filament_id": filament_id}
@@ -424,6 +425,11 @@ class SpoolmanClient:
data["lot_nr"] = lot_nr
if comment:
data["comment"] = comment
# `is not None`, not truthiness: 0 g is a legitimate tare (a spool-less
# coil), and it is not the same answer as "inherit from the filament",
# which is what leaving the field off means to Spoolman.
if spool_weight is not None:
data["spool_weight"] = spool_weight
if extra:
data["extra"] = extra
await self._ensure_extra_fields(extra)
@@ -2217,14 +2217,14 @@ class TestLinkTagDuplicate:
class TestSpoolmanInventoryUpdateCoreWeight:
"""core_weight is accepted for schema parity but not persisted — any value should be accepted."""
"""core_weight is forwarded to Spoolman when sent — any value should be accepted."""
@pytest.mark.asyncio
@pytest.mark.integration
async def test_patch_core_weight_other_than_250_accepted(
self, async_client: AsyncClient, spoolman_settings, mock_spoolman_client
):
"""PATCH with core_weight != 250 is accepted (field is ignored server-side, not rejected)."""
"""PATCH with core_weight != 250 is accepted and carried through, not rejected."""
resp = await async_client.patch(
"/api/v1/spoolman/inventory/spools/42",
json={"core_weight": 100},
@@ -2759,7 +2759,7 @@ class TestCreateSpoolWeightValidation:
async def test_create_spool_with_non_default_core_weight_accepted(
self, async_client: AsyncClient, spoolman_settings, mock_spoolman_client
):
"""A3: core_weight != 250 must no longer be rejected → 201."""
"""A3: core_weight != 250 is accepted and reaches Spoolman → 201."""
resp = await async_client.post(
"/api/v1/spoolman/inventory/spools",
json={"material": "PLA", "label_weight": 1000, "weight_used": 0, "core_weight": 196},
@@ -2945,3 +2945,129 @@ class TestGetAllSlotAssignmentsEnriched:
assert data[0]["printer_id"] == 1
assert data[0]["printer_name"] == "P1"
assert data[0]["spoolman_spool_id"] == 201
class TestPerSpoolCoreWeight:
"""The per-spool tare reaches Spoolman now (#2908).
`core_weight` was declared on both write schemas and dropped after
validation, with a comment saying so. The read path never showed it: it
derives the value from ``spool.spool_weight ?? filament.spool_weight ?? 250``
(_spoolman_helpers.py), so an edit that went nowhere came back as the
inherited value and looked like it had simply not changed.
It is not cosmetic, because the same resolution is the tare the weigh
endpoint subtracts. A spool whose real empty weight differs from its
filament's produced a wrong remaining weight on every weigh-in -- 70 g for
the reporter's third-party spools against Bambu's 250 g reusable ones.
Spoolman already has the field and already gives it priority. Only the
write was missing.
"""
@pytest.mark.asyncio
@pytest.mark.integration
async def test_an_edited_tare_is_written_to_the_spools_own_field(
self, async_client: AsyncClient, spoolman_settings, mock_spoolman_client
):
response = await async_client.patch("/api/v1/spoolman/inventory/spools/42", json={"core_weight": 180})
assert response.status_code == 200
assert mock_spoolman_client.update_spool_full.call_args.kwargs["spool_weight"] == 180
@pytest.mark.asyncio
@pytest.mark.integration
async def test_an_edit_that_does_not_mention_the_tare_leaves_it_inheriting(
self, async_client: AsyncClient, spoolman_settings, mock_spoolman_client
):
"""The reason this keys off model_fields_set rather than the value.
`core_weight` carries a default, so a PATCH that never mentions it still
arrives at the handler holding one. Writing that would stamp an explicit
tare on every spool the user edits for any reason, silently detaching it
from its filament -- a worse bug than the one being fixed, and an
invisible one, since the number displayed would not change.
"""
response = await async_client.patch("/api/v1/spoolman/inventory/spools/42", json={"note": "just a note"})
assert response.status_code == 200
assert mock_spoolman_client.update_spool_full.call_args.kwargs["spool_weight"] is None
@pytest.mark.asyncio
@pytest.mark.integration
async def test_a_tare_given_at_creation_is_written(
self, async_client: AsyncClient, spoolman_settings, mock_spoolman_client
):
response = await async_client.post(
"/api/v1/spoolman/inventory/spools",
json={"material": "PLA", "label_weight": 1000, "core_weight": 180},
)
assert response.status_code == 200
assert mock_spoolman_client.create_spool.call_args.kwargs["spool_weight"] == 180
@pytest.mark.asyncio
@pytest.mark.integration
async def test_a_creation_that_omits_the_tare_leaves_the_spool_inheriting(
self, async_client: AsyncClient, spoolman_settings, mock_spoolman_client
):
"""Same defaulting hazard as the update, and the commoner path: the
form posts without a tare far more often than with one."""
response = await async_client.post(
"/api/v1/spoolman/inventory/spools",
json={"material": "PLA", "label_weight": 1000},
)
assert response.status_code == 200
assert mock_spoolman_client.create_spool.call_args.kwargs["spool_weight"] is None
@pytest.mark.asyncio
@pytest.mark.integration
async def test_bulk_creation_persists_the_tare_on_every_spool(
self, async_client: AsyncClient, spoolman_settings, mock_spoolman_client
):
"""Bulk create takes the same schema, so it dropped the field the same way."""
response = await async_client.post(
"/api/v1/spoolman/inventory/spools/bulk",
json={"spool": {"material": "PLA", "label_weight": 1000, "core_weight": 180}, "quantity": 3},
)
assert response.status_code in (200, 201)
assert mock_spoolman_client.create_spool.await_count == 3
assert all(c.kwargs["spool_weight"] == 180 for c in mock_spoolman_client.create_spool.await_args_list)
@pytest.mark.asyncio
@pytest.mark.integration
async def test_a_zero_tare_is_a_value_not_an_absence(
self, async_client: AsyncClient, spoolman_settings, mock_spoolman_client
):
"""0 g is a real answer -- a coil with no spool -- and the schema allows
it (``ge=0``). Guarding the write on truthiness rather than ``is not
None`` would silently turn it into "inherit", which resolves to 250."""
response = await async_client.patch("/api/v1/spoolman/inventory/spools/42", json={"core_weight": 0})
assert response.status_code == 200
assert mock_spoolman_client.update_spool_full.call_args.kwargs["spool_weight"] == 0
@pytest.mark.asyncio
@pytest.mark.integration
async def test_the_written_tare_is_the_one_the_weigh_endpoint_subtracts(
self, async_client: AsyncClient, spoolman_settings, mock_spoolman_client
):
"""What the fix is actually for.
The weigh endpoint resolves the tare exactly as the read path does, so
once the per-spool value is stored it is the number a measured gross
weight is reduced by. With a 180 g spool inheriting the filament's 250 g
this same weigh-in would have recorded 550 g remaining instead of 620 --
the 70 g error from the report, on every weigh-in.
"""
mock_spoolman_client.get_spool.return_value = {
**SAMPLE_SPOOLMAN_SPOOL,
"spool_weight": 180.0,
}
response = await async_client.patch("/api/v1/spoolman/inventory/spools/42/weight", json={"weight_grams": 800.0})
assert response.status_code == 200
assert mock_spoolman_client.update_spool_full.call_args.kwargs["remaining_weight"] == 620.0
@@ -430,6 +430,18 @@ class TestMapSpoolmanSpool:
spool = {**MINIMAL_SPOOL, "spool_weight": None, "filament": {**MINIMAL_SPOOL["filament"], "spool_weight": 196}}
assert _map_spoolman_spool(spool)["core_weight"] == 196
@pytest.mark.parametrize(
("spool_level", "inherited"),
[(180, False), (0, False), (None, True), ("absent", True)],
)
def test_core_weight_is_inherited_says_whose_tare_it_is(self, spool_level, inherited):
"""The spool form copies an own tare onto a copy and leaves an
inherited one alone (#2908). 0 is an own tare, not a missing one."""
spool = {**MINIMAL_SPOOL, "filament": {**MINIMAL_SPOOL["filament"], "spool_weight": 196}}
if spool_level != "absent":
spool["spool_weight"] = spool_level
assert _map_spoolman_spool(spool)["core_weight_is_inherited"] is inherited
def test_spool_level_absent_falls_back_to_filament(self):
spool = {**MINIMAL_SPOOL, "filament": {**MINIMAL_SPOOL["filament"], "spool_weight": 196}}
assert _map_spoolman_spool(spool)["core_weight"] == 196
@@ -665,3 +665,43 @@ class TestRenameLocationBulkAndFallback:
pytest.raises(httpx.HTTPStatusError),
):
await client.rename_location("Drybox 1", "Drybox 2")
class TestCreateSpoolTare:
"""create_spool carries the per-spool tare, and tells 0 apart from absent (#2908).
Leaving `spool_weight` off the payload is meaningful to Spoolman -- it means
the spool inherits its filament's value -- so the two cases have to stay
distinguishable all the way down to the request body.
"""
@pytest.mark.asyncio
async def test_a_tare_is_sent(self, client):
mock_http = AsyncMock()
mock_http.post = AsyncMock(return_value=_make_response(SAMPLE_SPOOL))
with patch.object(client, "_get_client", AsyncMock(return_value=mock_http)):
await client.create_spool(filament_id=7, spool_weight=180)
assert mock_http.post.call_args.kwargs["json"]["spool_weight"] == 180
@pytest.mark.asyncio
async def test_a_zero_tare_is_sent_rather_than_dropped(self, client):
"""A guard on truthiness would silently turn 0 g into "inherit", which
resolves to 250 g -- a 250 g error on every weigh-in for a bare coil."""
mock_http = AsyncMock()
mock_http.post = AsyncMock(return_value=_make_response(SAMPLE_SPOOL))
with patch.object(client, "_get_client", AsyncMock(return_value=mock_http)):
await client.create_spool(filament_id=7, spool_weight=0)
assert mock_http.post.call_args.kwargs["json"]["spool_weight"] == 0
@pytest.mark.asyncio
async def test_no_tare_leaves_the_key_off_entirely(self, client):
"""Sending an explicit null would pin the spool to "no inheritance",
which is not the same as not having been told."""
mock_http = AsyncMock()
mock_http.post = AsyncMock(return_value=_make_response(SAMPLE_SPOOL))
with patch.object(client, "_get_client", AsyncMock(return_value=mock_http)):
await client.create_spool(filament_id=7)
assert "spool_weight" not in mock_http.post.call_args.kwargs["json"]
@@ -21,25 +21,9 @@ const baseProps = {
};
describe('AdditionalSection', () => {
it('renders SpoolWeightPicker when spoolmanMode is false', () => {
render(<AdditionalSection {...baseProps} spoolmanMode={false} />);
// SpoolWeightPicker renders the 'inventory.coreWeight' label
expect(screen.getByText('inventory.coreWeight')).toBeTruthy();
// Info notice must NOT be present
expect(screen.queryByText('inventory.spoolWeightManagedBySpoolman')).toBeNull();
});
it('hides SpoolWeightPicker and shows info notice when spoolmanMode is true', () => {
render(<AdditionalSection {...baseProps} spoolmanMode={true} />);
// Info notice must appear
expect(screen.getByText('inventory.spoolWeightManagedBySpoolman')).toBeTruthy();
// SpoolWeightPicker must NOT be rendered
expect(screen.queryByText('inventory.coreWeight')).toBeNull();
});
it('defaults to spoolmanMode=false when prop is omitted', () => {
it('renders SpoolWeightPicker', () => {
render(<AdditionalSection {...baseProps} />);
// SpoolWeightPicker present by default
// SpoolWeightPicker renders the 'inventory.coreWeight' label
expect(screen.getByText('inventory.coreWeight')).toBeTruthy();
});
});
@@ -1432,3 +1432,174 @@ describe('SpoolFormModal header spool ID (#1385)', () => {
expect(screen.queryByText(/^#\d+$/)).not.toBeInTheDocument();
});
});
describe('SpoolFormModal — per-spool tare in Spoolman mode (#2908)', () => {
// The mapped Spoolman spool carries the tare it resolves to: its own
// spool_weight if set, else the filament type's. The form opens on that value.
const spoolmanSpool = {
...existingSpool,
id: 42,
core_weight: 250,
core_weight_catalog_id: null,
data_origin: 'spoolman',
tag_type: 'spoolman',
} as InventorySpool;
const catalog = [
{ id: 7, name: 'Bambu Lab 250g', weight: 250 },
{ id: 3, name: 'Standard 300g', weight: 300 },
];
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(api.getSpoolCatalog).mockResolvedValue(catalog);
});
function weightPicker() {
const picker = screen
.getAllByPlaceholderText(/search/i)
.find((input) => input.getAttribute('placeholder')?.toLowerCase().includes('spool'));
expect(picker).toBeTruthy();
return picker!;
}
async function openEdit() {
render(
<SpoolFormModal
isOpen={true}
onClose={vi.fn()}
spool={spoolmanSpool}
mode="edit"
currencySymbol="$"
spoolmanMode={true}
/>
);
await waitFor(() => {
expect(screen.getByText('Edit Spool')).toBeInTheDocument();
});
openColorAndCostTab();
await waitFor(() => {
expect(api.getSpoolCatalog).toHaveBeenCalled();
});
}
async function savedPayload() {
fireEvent.click(screen.getByRole('button', { name: /save/i }));
await waitFor(() => {
expect(api.updateSpoolmanInventorySpool).toHaveBeenCalledTimes(1);
});
return vi.mocked(api.updateSpoolmanInventorySpool).mock.calls[0][1] as Record<string, unknown>;
}
it('shows the empty spool weight picker, which it used to replace with a notice', async () => {
await openEdit();
expect(weightPicker()).toBeInTheDocument();
});
it('does not send the tare when the user left it alone', async () => {
// An untouched edit must not copy the inherited value onto the spool:
// that would stop it following its filament type.
await openEdit();
const payload = await savedPayload();
expect(payload).not.toHaveProperty('core_weight');
expect(payload).not.toHaveProperty('core_weight_catalog_id');
});
it('does not count the picker selecting a catalogue entry by itself as a touch', async () => {
// One catalogue row matches the opening weight, so the picker selects it
// on mount. That changes core_weight_catalog_id without the user doing
// anything, which is why the touched flag keys on core_weight instead.
await openEdit();
await screen.findByDisplayValue('Bambu Lab 250g');
const payload = await savedPayload();
expect(payload).not.toHaveProperty('core_weight');
});
it('sends the tare the user picked, without the catalogue id', async () => {
await openEdit();
fireEvent.focus(weightPicker());
fireEvent.click(await screen.findByText('Standard 300g'));
const payload = await savedPayload();
expect(payload).toHaveProperty('core_weight', 300);
// No field for it on SpoolmanInventoryCreate / Update; it would be dropped.
expect(payload).not.toHaveProperty('core_weight_catalog_id');
});
it('does not send the form default on create either', async () => {
render(<SpoolFormModal isOpen={true} onClose={vi.fn()} currencySymbol="$" spoolmanMode={true} />);
await waitFor(() => {
expect(screen.getByRole('heading', { name: 'Add Spool' })).toBeInTheDocument();
});
const addButtons = screen.getAllByRole('button', { name: /add spool/i });
const submitButton = addButtons.find((btn) => btn.tagName === 'BUTTON' && btn.querySelector('svg.lucide-save'));
fireEvent.click(submitButton!);
await waitFor(() => {
expect(api.createSpoolmanInventorySpool).toHaveBeenCalledTimes(1);
});
const payload = vi.mocked(api.createSpoolmanInventorySpool).mock.calls[0][0] as Record<string, unknown>;
expect(payload).not.toHaveProperty('core_weight');
});
it('loads a 0 g tare as 0, not the 250 fallback', async () => {
// A spool-less coil. `|| 250` read the real 0 as missing, so the form
// showed 250 and the measured-weight field subtracted it.
render(
<SpoolFormModal
isOpen={true}
onClose={vi.fn()}
spool={{ ...spoolmanSpool, core_weight: 0, core_weight_is_inherited: false }}
mode="edit"
currencySymbol="$"
spoolmanMode={true}
/>
);
await waitFor(() => {
expect(screen.getByText('Edit Spool')).toBeInTheDocument();
});
openColorAndCostTab();
const tareInput = screen.getAllByRole('spinbutton').find((el) => el.getAttribute('max') === '2000');
expect(tareInput).toHaveValue(0);
});
async function copiedPayload(source: InventorySpool) {
render(
<SpoolFormModal isOpen={true} onClose={vi.fn()} spool={source} mode="copy" currencySymbol="$" spoolmanMode={true} />
);
await waitFor(() => {
expect(screen.getByRole('heading', { name: 'Copy Spool' })).toBeInTheDocument();
});
const submit = screen
.getAllByRole('button', { name: /copy spool/i })
.find((btn) => btn.tagName === 'BUTTON' && btn.querySelector('svg.lucide-save'));
fireEvent.click(submit!);
await waitFor(() => {
expect(api.createSpoolmanInventorySpool).toHaveBeenCalledTimes(1);
});
return vi.mocked(api.createSpoolmanInventorySpool).mock.calls[0][0] as Record<string, unknown>;
}
it('carries a spool\'s own tare onto its copy', async () => {
// The copy's form shows 180; left untouched it used to send nothing, and
// the copy came out inheriting the filament's 250.
const payload = await copiedPayload({ ...spoolmanSpool, core_weight: 180, core_weight_is_inherited: false });
expect(payload).toHaveProperty('core_weight', 180);
expect(payload).not.toHaveProperty('core_weight_catalog_id');
});
it('lets a copy of an inheriting spool keep inheriting', async () => {
const payload = await copiedPayload({ ...spoolmanSpool, core_weight: 250, core_weight_is_inherited: true });
expect(payload).not.toHaveProperty('core_weight');
});
});
@@ -385,6 +385,33 @@ describe('SpoolBuddyDashboard', () => {
});
});
it('quick-adds a Spoolman spool without a tare, so it inherits the filament type\'s (#2908)', async () => {
// The quick-create has no tare input. The 250 it used to send was a
// placeholder, and since #2908 a sent value lands on the spool's own
// spool_weight -- every kiosk-created spool would stop inheriting.
const { api } = await import('../../api/client');
(api.getSpoolmanSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
spoolman_enabled: 'true',
spoolman_url: 'http://localhost:7912',
spoolman_sync_mode: 'off',
spoolman_disable_weight_sync: 'false',
spoolman_report_partial_usage: 'false',
});
renderPage({ unknownTagUid: 'AABB1122334455FF' });
fireEvent.click(await waitFor(() => screen.getAllByText('Add to Inventory')[0]));
fireEvent.click(await waitFor(() => screen.getByText('Add Anyway')));
await waitFor(() => {
expect(api.createSpoolmanInventorySpool).toHaveBeenCalledTimes(1);
});
const payload = (api.createSpoolmanInventorySpool as ReturnType<typeof vi.fn>).mock.calls[0][0];
expect(payload).not.toHaveProperty('core_weight');
expect(payload).not.toHaveProperty('core_weight_catalog_id');
expect(api.createSpool).not.toHaveBeenCalled();
});
it('switches to SpoolInfoCard and hides UnknownTagCard after successful Spoolman link', async () => {
const { api } = await import('../../api/client');
(api.getSpoolmanSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
@@ -30,6 +30,14 @@ vi.mock('../../api/client', () => ({
}),
getSpoolmanInventorySpools: vi.fn().mockResolvedValue([]),
createSpoolmanInventorySpool: vi.fn().mockResolvedValue({ id: 1, material: 'PLA' }),
// Read by the new-spool form's own data load; empty unless a test says otherwise.
getSpoolCatalog: vi.fn().mockResolvedValue([]),
getColorCatalog: vi.fn().mockResolvedValue([]),
getLocalPresets: vi.fn().mockResolvedValue({ filament: [] }),
getBuiltinFilaments: vi.fn().mockResolvedValue([]),
getPrinters: vi.fn().mockResolvedValue([]),
getCloudStatus: vi.fn().mockResolvedValue({ is_authenticated: false }),
orcaCloudStatus: vi.fn().mockResolvedValue({ connected: false }),
},
spoolbuddyApi: {
getDevices: vi.fn().mockResolvedValue([]),
@@ -41,7 +49,9 @@ vi.mock('../../api/client', () => ({
// Mock i18n
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string, fallback: string) => fallback,
// The second argument is an options object on some calls (the full
// form's sections); only a string is a fallback.
t: (key: string, fallback?: unknown) => (typeof fallback === 'string' ? fallback : key),
i18n: { language: 'en', changeLanguage: vi.fn() },
}),
}));
@@ -280,4 +290,71 @@ describe('SpoolBuddyWriteTagPage', () => {
mockOutletContext.sbState.deviceOnline = false;
});
describe('per-spool tare in Spoolman mode (#2908)', () => {
beforeEach(() => {
vi.mocked(mockedApi.getSpoolmanSettings).mockResolvedValue({
spoolman_enabled: 'true',
spoolman_url: 'http://spoolman.test',
spoolman_sync_mode: '',
spoolman_disable_weight_sync: '',
spoolman_report_partial_usage: '',
});
vi.mocked(mockedApi.getSpoolCatalog).mockResolvedValue([
{ id: 3, name: 'Standard 300g', weight: 300 },
] as never);
});
async function openNewSpoolForm() {
renderPage();
await waitFor(() => {
expect(vi.mocked(mockedApi.getSpoolmanInventorySpools)).toHaveBeenCalled();
});
fireEvent.click(screen.getByText('New Spool'));
await waitFor(() => screen.getByText('Create Spool'));
// The one field the simple view requires.
const material = screen.getAllByRole('combobox').find((el) => el.querySelector('option[value="PLA"]'));
fireEvent.change(material!, { target: { value: 'PLA' } });
}
async function createdPayload() {
fireEvent.click(screen.getAllByText('Create Spool').at(-1)!);
await waitFor(() => {
expect(vi.mocked(mockedApi.createSpoolmanInventorySpool)).toHaveBeenCalledTimes(1);
});
return vi.mocked(mockedApi.createSpoolmanInventorySpool).mock.calls[0][0] as unknown as Record<string, unknown>;
}
it('does not send the form default when the tare was left alone', async () => {
await openNewSpoolForm();
const payload = await createdPayload();
expect(payload).not.toHaveProperty('core_weight');
expect(payload).not.toHaveProperty('core_weight_catalog_id');
});
it('sends the tare the user picked, without the catalogue id', async () => {
await openNewSpoolForm();
fireEvent.click(screen.getByText('Full'));
const picker = await waitFor(() => {
const input = screen
.getAllByPlaceholderText(/search/i)
.find((el) => el.getAttribute('placeholder')?.toLowerCase().includes('spool'));
expect(input).toBeTruthy();
return input!;
});
fireEvent.focus(picker);
fireEvent.click(await screen.findByText('Standard 300g'));
// Back to the simple view to submit: it validates on material alone, and
// the form state, touched flag included, is shared by both views.
fireEvent.click(screen.getByText('Simple'));
const payload = await createdPayload();
expect(payload).toHaveProperty('core_weight', 300);
// No field for it on the Spoolman create schema; it would be dropped.
expect(payload).not.toHaveProperty('core_weight_catalog_id');
});
});
});
+3
View File
@@ -3626,6 +3626,9 @@ export interface InventorySpool {
brand: string | null;
label_weight: number;
core_weight: number;
// Spoolman-backed inventory only: true when the spool has no tare of its
// own and core_weight is the filament type's. Absent for local spools (#2908).
core_weight_is_inherited?: boolean;
core_weight_catalog_id: number | null;
weight_used: number;
// Anchor for the resettable "Total Consumed" display (#1390). The
+30 -13
View File
@@ -75,6 +75,11 @@ export function SpoolFormModal({
const [errors, setErrors] = useState<Partial<Record<keyof SpoolFormData, string>>>({});
const [activeTab, setActiveTab] = useState<TabId>('filament');
const [weightTouched, setWeightTouched] = useState(false);
// Keyed on core_weight, not core_weight_catalog_id: SpoolWeightPicker selects
// a catalogue entry by itself on mount when one matches the current weight,
// so the id changes on forms nobody has touched. Both real user actions go
// through core_weight.
const [coreWeightTouched, setCoreWeightTouched] = useState(false);
const [locationIdTouched, setLocationIdTouched] = useState(false);
const [quickAdd, setQuickAdd] = useState(false);
const [quantity, setQuantity] = useState(1);
@@ -206,9 +211,9 @@ export function SpoolFormModal({
}
};
fetchData();
if (!spoolmanMode) {
api.getSpoolCatalog().then(setSpoolCatalog).catch(console.error);
}
// Fetched in Spoolman mode too: the empty spool weight picker is shown
// there now, and its catalogue is Bambuddy's own either way (#2908).
api.getSpoolCatalog().then(setSpoolCatalog).catch(console.error);
api.getColorCatalog().then(setColorCatalog).catch(console.error);
api.getLocalPresets().then(r => setLocalPresets(r.filament)).catch(console.error);
api.getBuiltinFilaments().then(setBuiltinFilaments).catch(console.error);
@@ -255,16 +260,14 @@ export function SpoolFormModal({
})();
}
}
// The effect intentionally depends only on `isOpen` (and the prop-side
// calibration count) — re-running on every spoolmanMode toggle would
// race the in-flight async fetches with unmount/teardown and emit
// "test environment was torn down" errors in vitest. spoolmanMode only
// gates a single fetch (getSpoolCatalog) which is cheap enough to skip
// when the modal opens in Spoolman mode.
// Depends only on `isOpen` (and the prop-side calibration count). It used
// to read spoolmanMode for the catalogue fetch and left it out of the deps
// on purpose -- re-running on every toggle raced the in-flight fetches with
// unmount and emitted "test environment was torn down" errors in vitest.
// It no longer reads it, so the deps are complete as written.
return () => {
cancelled = true;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isOpen, printersWithCalibrations.length]);
// Build filament options: cloud → local → fallback
@@ -402,7 +405,8 @@ export function SpoolFormModal({
extra_colors: spool.extra_colors || '',
effect_type: spool.effect_type || '',
label_weight: spool.label_weight || 1000,
core_weight: spool.core_weight || 250,
// ?? not ||: 0 g is a real tare (a spool-less coil) and must load as 0.
core_weight: spool.core_weight ?? 250,
core_weight_catalog_id: spool.core_weight_catalog_id ?? null,
weight_used: isCopying ? 0 : spool.weight_used || 0,
slicer_filament: spool.slicer_filament || '',
@@ -462,6 +466,10 @@ export function SpoolFormModal({
// save) A's per-model overrides on B. Refilled by the fetch below.
setModelPresets(new Map());
setWeightTouched(false);
// A copy of a Spoolman spool with its own tare carries that tare, as it
// would any other field shown in the form; one that inherits keeps
// inheriting. Only Spoolman spools report the flag (#2908).
setCoreWeightTouched(isCopying && spool?.core_weight_is_inherited === false);
setLocationIdTouched(false);
}
}, [isOpen, spool, mode, isCopying]);
@@ -527,6 +535,7 @@ export function SpoolFormModal({
: {}),
}));
if (key === 'weight_used') setWeightTouched(true);
if (key === 'core_weight') setCoreWeightTouched(true);
if (key === 'location_id') setLocationIdTouched(true);
if (errors[key]) {
setErrors(prev => ({ ...prev, [key]: undefined }));
@@ -864,7 +873,16 @@ export function SpoolFormModal({
extra_colors: formData.extra_colors || null,
effect_type: formData.effect_type || null,
label_weight: formData.label_weight,
...(spoolmanMode ? {} : { core_weight: formData.core_weight, core_weight_catalog_id: formData.core_weight_catalog_id }),
// In Spoolman mode the picker opens on the tare the spool resolves to,
// which is the filament type's unless the spool has its own. Sending it
// untouched would copy that inherited value onto the spool and stop it
// following the filament, so only a value the user set goes out (#2908).
// The catalogue id has no field on the Spoolman side.
...(spoolmanMode
? coreWeightTouched
? { core_weight: formData.core_weight }
: {}
: { core_weight: formData.core_weight, core_weight_catalog_id: formData.core_weight_catalog_id }),
slicer_filament: formData.slicer_filament || null,
slicer_filament_name: presetName,
nozzle_temp_min: null,
@@ -1096,7 +1114,6 @@ export function SpoolFormModal({
}
}}
globalLowStockThreshold={globalLowStockThreshold}
spoolmanMode={spoolmanMode}
/>
</div>
@@ -177,7 +177,6 @@ export function AdditionalSection({
availableLocations = [],
onCreateLocation,
globalLowStockThreshold,
spoolmanMode = false,
}: AdditionalSectionProps) {
const { t } = useTranslation();
const { showToast } = useToast();
@@ -210,19 +209,17 @@ export function AdditionalSection({
// catalogue picker carries a long product name beside its own number
// input, and the note is a textarea.
<div className="grid grid-cols-1 sm:grid-cols-2 gap-x-4 gap-y-4">
{/* Empty Spool Weight — hidden in Spoolman mode (managed per filament type in Spoolman) */}
{/* Empty Spool Weight. Shown in Spoolman mode too: Spoolman keeps a
per-spool spool_weight that overrides the filament type's, and it is
the tare every weigh-in subtracts (#2908). */}
<div className="sm:col-span-2">
{spoolmanMode ? (
<p className="text-xs text-bambu-gray px-1">{t('inventory.spoolWeightManagedBySpoolman')}</p>
) : (
<SpoolWeightPicker
catalog={spoolCatalog}
value={formData.core_weight}
onChange={(weight) => updateField('core_weight', weight)}
catalogId={formData.core_weight_catalog_id}
onCatalogIdChange={(id) => updateField('core_weight_catalog_id', id)}
/>
)}
<SpoolWeightPicker
catalog={spoolCatalog}
value={formData.core_weight}
onChange={(weight) => updateField('core_weight', weight)}
catalogId={formData.core_weight_catalog_id}
onCatalogIdChange={(id) => updateField('core_weight_catalog_id', id)}
/>
</div>
{/* Current Weight (remaining filament) */}
@@ -212,9 +212,6 @@ export interface AdditionalSectionProps extends SectionProps {
globalLowStockThreshold: number;
availableLocations?: { id: number; name: string }[];
onCreateLocation?: (name: string) => Promise<{ id: number; name: string } | null>;
// When true the empty-spool weight is managed by Spoolman on the filament
// object, so SpoolWeightPicker is hidden and an info notice is shown instead.
spoolmanMode?: boolean;
}
// PA Profile section props
-1
View File
@@ -5027,7 +5027,6 @@ export default {
spoolmanFilamentUnlinked: 'Verknüpfung mit Filamentkatalog aufgehoben',
noSpoolmanFilaments: 'Keine Filamente im Spoolman-Katalog gefunden',
spoolmanFilamentColorSwatch: 'Filamentfarbe',
spoolWeightManagedBySpoolman: 'Das Leerspulengewicht wird pro Filamenttyp in Spoolman verwaltet',
spoolmanCatalogLoadFailed: 'Spoolman-Filamentkatalog konnte nicht geladen werden',
},
-1
View File
@@ -5072,7 +5072,6 @@ export default {
spoolmanFilamentUnlinked: 'Filament catalog link cleared',
noSpoolmanFilaments: 'No filaments found in Spoolman catalog',
spoolmanFilamentColorSwatch: 'Filament color',
spoolWeightManagedBySpoolman: 'Empty spool weight is managed per filament type in Spoolman',
spoolmanCatalogLoadFailed: 'Failed to load Spoolman filament catalog',
},
-1
View File
@@ -5034,7 +5034,6 @@ export default {
spoolmanFilamentUnlinked: 'Vínculo del catálogo de filamentos borrado',
noSpoolmanFilaments: 'No se encontraron filamentos en el catálogo de Spoolman',
spoolmanFilamentColorSwatch: 'Color del filamento',
spoolWeightManagedBySpoolman: 'El peso de la bobina vacía se gestiona por tipo de filamento en Spoolman',
spoolmanCatalogLoadFailed: 'Error al cargar el catálogo de filamentos de Spoolman',
},
-1
View File
@@ -5016,7 +5016,6 @@ export default {
spoolmanFilamentUnlinked: 'Lien avec le catalogue de filaments supprimé',
noSpoolmanFilaments: 'Aucun filament trouvé dans le catalogue Spoolman',
spoolmanFilamentColorSwatch: 'Couleur du filament',
spoolWeightManagedBySpoolman: 'Le poids de la bobine vide est géré par type de filament dans Spoolman',
spoolmanCatalogLoadFailed: 'Impossible de charger le catalogue Spoolman',
},
-1
View File
@@ -5015,7 +5015,6 @@ export default {
spoolmanFilamentUnlinked: 'Collegamento al catalogo filamenti rimosso',
noSpoolmanFilaments: 'Nessun filamento trovato nel catalogo Spoolman',
spoolmanFilamentColorSwatch: 'Colore del filamento',
spoolWeightManagedBySpoolman: 'Il peso della bobina vuota è gestito per tipo di filamento in Spoolman',
spoolmanCatalogLoadFailed: 'Impossibile caricare il catalogo Spoolman',
},
-1
View File
@@ -5027,7 +5027,6 @@ export default {
spoolmanFilamentUnlinked: 'フィラメントカタログのリンクを解除しました',
noSpoolmanFilaments: 'Spoolmanカタログにフィラメントが見つかりません',
spoolmanFilamentColorSwatch: 'フィラメントの色',
spoolWeightManagedBySpoolman: '空スプールの重量はSpoolmanでフィラメントタイプごとに管理されています',
spoolmanCatalogLoadFailed: 'Spoolmanのフィラメントカタログを読み込めませんでした',
},
-1
View File
@@ -4792,7 +4792,6 @@ export default {
spoolmanFilamentUnlinked: '필라멘트 카탈로그 연결 해제됨',
noSpoolmanFilaments: 'Spoolman 카탈로그에서 필라멘트를 찾을 수 없음',
spoolmanFilamentColorSwatch: '필라멘트 색상',
spoolWeightManagedBySpoolman: '빈 스풀 무게는 Spoolman에서 필라멘트 유형별로 관리됩니다',
spoolmanCatalogLoadFailed: 'Spoolman 필라멘트 카탈로그 불러오기 실패',
subtitle: '스풀 관리',
storageLocationNone: '위치 미설정',
-1
View File
@@ -5072,7 +5072,6 @@ export default {
spoolmanFilamentUnlinked: 'Koppeling met filamentcatalogus gewist',
noSpoolmanFilaments: 'Geen filamenten gevonden in Spoolman-catalogus',
spoolmanFilamentColorSwatch: 'Filamentkleur',
spoolWeightManagedBySpoolman: 'Leeg spoelgewicht wordt per filamenttype beheerd in Spoolman',
spoolmanCatalogLoadFailed: 'Spoolman-filamentcatalogus laden mislukt',
},
-1
View File
@@ -5015,7 +5015,6 @@ export default {
spoolmanFilamentUnlinked: 'Vínculo com o catálogo de filamentos removido',
noSpoolmanFilaments: 'Nenhum filamento encontrado no catálogo Spoolman',
spoolmanFilamentColorSwatch: 'Cor do filamento',
spoolWeightManagedBySpoolman: 'O peso do carretel vazio é gerenciado por tipo de filamento no Spoolman',
spoolmanCatalogLoadFailed: 'Falha ao carregar catálogo de filamentos do Spoolman',
},
-1
View File
@@ -4792,7 +4792,6 @@ export default {
spoolmanFilamentUnlinked: "Связь с каталогом филаментов удалена",
noSpoolmanFilaments: "В каталоге Spoolman филаменты не найдены",
spoolmanFilamentColorSwatch: "Цвет филамента",
spoolWeightManagedBySpoolman: "Масса пустой катушки задаётся в Spoolman для каждого типа филамента",
spoolmanCatalogLoadFailed: "Не удалось загрузить каталог филаментов Spoolman",
},
timelapse: {
-1
View File
@@ -5072,7 +5072,6 @@ errors: {
spoolmanFilamentUnlinked: 'Filamentkataloglänk rensad',
noSpoolmanFilaments: 'Inga filament hittade i Spoolman-katalog',
spoolmanFilamentColorSwatch: 'Filamentfärg',
spoolWeightManagedBySpoolman: 'Tom rullvikt hanteras per filamenttyp i Spoolman',
spoolmanCatalogLoadFailed: 'Kunde inte ladda Spoolman filamentkatalog',
},
-1
View File
@@ -5004,7 +5004,6 @@ export default {
spoolmanFilamentUnlinked: 'Filament katalog bağlantısı temizlendi',
noSpoolmanFilaments: 'Spoolman kataloğunda filament bulunamadı',
spoolmanFilamentColorSwatch: 'Filament rengi',
spoolWeightManagedBySpoolman: 'Boş makara ağırlığı Spoolman\'de filament türü başına yönetilir',
spoolmanCatalogLoadFailed: 'Spoolman filament kataloğu yüklenemedi',
},
-1
View File
@@ -5069,7 +5069,6 @@ export default {
spoolmanFilamentUnlinked: "Посилання на каталог філаментів видалено",
noSpoolmanFilaments: "У каталозі Spoolman не знайдено філаментів",
spoolmanFilamentColorSwatch: "Колір філаменту",
spoolWeightManagedBySpoolman: "Вага порожньої котушки керується залежно від типу філаменту в Spoolman",
spoolmanCatalogLoadFailed: "Не вдалося завантажити каталог філаментів Spoolman",
},
-1
View File
@@ -5015,7 +5015,6 @@ export default {
spoolmanFilamentUnlinked: '已解除耗材目录关联',
noSpoolmanFilaments: 'Spoolman 目录中未找到耗材',
spoolmanFilamentColorSwatch: '耗材颜色',
spoolWeightManagedBySpoolman: '空线轴重量在 Spoolman 中按耗材类型管理',
spoolmanCatalogLoadFailed: '无法加载 Spoolman 耗材目录',
},
-1
View File
@@ -5015,7 +5015,6 @@ export default {
spoolmanFilamentUnlinked: '已解除耗材目錄關聯',
noSpoolmanFilaments: 'Spoolman 目錄中未找到耗材',
spoolmanFilamentColorSwatch: '耗材顏色',
spoolWeightManagedBySpoolman: '空線軸重量在 Spoolman 中按耗材類型管理',
spoolmanCatalogLoadFailed: '無法載入 Spoolman 耗材目錄',
},
@@ -413,6 +413,11 @@ export function SpoolBuddyDashboard() {
try {
const weight = liveWeight ?? displayedWeight;
if (spoolmanMode) {
// No tare is sent. The quick-create has no input for one, so the 250
// that used to go here was a placeholder, not a user's choice -- and
// since #2908 a sent value is written to the spool's own spool_weight,
// which would stamp every spool the kiosk creates and stop it
// inheriting the filament type's.
const created = await api.createSpoolmanInventorySpool({
material: 'PLA',
subtype: null,
@@ -422,8 +427,6 @@ export function SpoolBuddyDashboard() {
effect_type: null,
brand: null,
label_weight: 1000,
core_weight: 250,
core_weight_catalog_id: null,
weight_used: 0,
slicer_filament: null,
slicer_filament_name: null,
@@ -442,7 +445,7 @@ export function SpoolBuddyDashboard() {
last_weighed_at: weight !== null ? new Date().toISOString() : null,
category: null,
low_stock_threshold_pct: null,
});
} as Parameters<typeof api.createSpoolmanInventorySpool>[0]);
await api.linkTagToSpoolmanSpool(created.id, {
tag_uid: sbState.unknownTagUid || undefined,
tray_uuid: (!sbState.unknownTagUid && sbState.unknownTrayUuid) ? sbState.unknownTrayUuid : undefined,
@@ -470,6 +470,10 @@ function NewSpoolTouchForm({ currencySymbol, onCreated, selectedSpool, spoolmanM
const [viewMode, setViewMode] = useState<NewSpoolViewMode>('simple');
const [activeSubTab, setActiveSubTab] = useState<NewSpoolSubTab>('filament');
const [formData, setFormData] = useState<SpoolFormData>(defaultFormData);
// The empty spool weight picker is on screen in Spoolman mode too. Track
// whether the user reached for it, so an untouched form does not send its
// default (issue #2908).
const [coreWeightTouched, setCoreWeightTouched] = useState(false);
const [errors, setErrors] = useState<Partial<Record<keyof SpoolFormData, string>>>({});
const [quickAdd, setQuickAdd] = useState(false);
const [quantity, setQuantity] = useState(1);
@@ -671,6 +675,9 @@ function NewSpoolTouchForm({ currencySymbol, onCreated, selectedSpool, spoolmanM
const updateField = <K extends keyof SpoolFormData>(key: K, value: SpoolFormData[K]) => {
setFormData(prev => ({ ...prev, [key]: value }));
if (key === 'core_weight') {
setCoreWeightTouched(true);
}
if (errors[key]) {
setErrors(prev => ({ ...prev, [key]: undefined }));
}
@@ -736,7 +743,7 @@ function NewSpoolTouchForm({ currencySymbol, onCreated, selectedSpool, spoolmanM
}
const presetName = selectedPresetOption?.displayName || presetInputValue || null;
const payload = {
const payload: Record<string, unknown> = {
material: formData.material,
subtype: formData.subtype || null,
brand: formData.brand || null,
@@ -745,8 +752,15 @@ function NewSpoolTouchForm({ currencySymbol, onCreated, selectedSpool, spoolmanM
extra_colors: formData.extra_colors || null,
effect_type: formData.effect_type || null,
label_weight: formData.label_weight,
core_weight: formData.core_weight,
core_weight_catalog_id: formData.core_weight_catalog_id,
// Only send a per-spool tare in Spoolman mode when the user actually set
// one here; otherwise let it keep inheriting from the filament type.
// The catalogue id has no field on the Spoolman side, so a catalogue
// selection does not round-trip there; only the weight does.
...(spoolmanMode
? coreWeightTouched
? { core_weight: formData.core_weight }
: {}
: { core_weight: formData.core_weight, core_weight_catalog_id: formData.core_weight_catalog_id }),
weight_used: formData.weight_used,
slicer_filament: formData.slicer_filament || null,
slicer_filament_name: presetName,
@@ -774,8 +788,8 @@ function NewSpoolTouchForm({ currencySymbol, onCreated, selectedSpool, spoolmanM
// internal bulk returns InventorySpool[]. Mirrors SpoolFormModal's
// duck-typed handling so partial failures surface as a warning toast.
const raw = spoolmanMode
? await api.bulkCreateSpoolmanInventorySpools(payload, quantity)
: await api.bulkCreateSpools(payload, quantity);
? await api.bulkCreateSpoolmanInventorySpools(payload as Parameters<typeof api.bulkCreateSpoolmanInventorySpools>[0], quantity)
: await api.bulkCreateSpools(payload as Parameters<typeof api.bulkCreateSpools>[0], quantity);
const created: InventorySpool[] =
spoolmanMode && raw && typeof raw === 'object' && 'created' in raw
? (raw as { created: InventorySpool[] }).created
@@ -786,8 +800,8 @@ function NewSpoolTouchForm({ currencySymbol, onCreated, selectedSpool, spoolmanM
if (created.length > 0) onCreated(created[0]);
} else {
const created = spoolmanMode
? await api.createSpoolmanInventorySpool(payload)
: await api.createSpool(payload);
? await api.createSpoolmanInventorySpool(payload as Parameters<typeof api.createSpoolmanInventorySpool>[0])
: await api.createSpool(payload as Parameters<typeof api.createSpool>[0]);
await saveKProfiles(created.id);
onCreated(created);
}