diff --git a/frontend/src/__tests__/components/SpoolFormModal.test.tsx b/frontend/src/__tests__/components/SpoolFormModal.test.tsx index 9537ae693..86dea10d3 100644 --- a/frontend/src/__tests__/components/SpoolFormModal.test.tsx +++ b/frontend/src/__tests__/components/SpoolFormModal.test.tsx @@ -1063,6 +1063,118 @@ describe('SpoolFormModal โ€” Unassign button (#1336)', () => { }); }); +describe('SpoolFormModal โ€” Clear RFID Tag for a tray-UUID-only spool (#3109)', () => { + const trayUuidOnly = (overrides: Partial): InventorySpool => + ({ + ...existingSpool, + id: 42, + tag_uid: null, + tray_uuid: 'A1B2C3D4E5F60718293A4B5C6D7E8F90', + ...overrides, + }) as InventorySpool; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('clears the tag on a Spoolman spool linked only by its tray UUID', async () => { + // _map_spoolman_spool splits extra.tag by length: a 32-char value becomes + // tray_uuid and tag_uid stays None. That is every Bambu Lab spool synced + // from the AMS, and not one of them could have its tag cleared here. + render( + + ); + + const clearBtn = await screen.findByRole('button', { name: /clear rfid tag/i }); + expect(clearBtn).not.toBeDisabled(); + + fireEvent.click(clearBtn); + + await waitFor(() => { + expect(api.updateSpoolmanInventorySpool).toHaveBeenCalledWith( + 42, + expect.objectContaining({ tag_uid: null, tray_uuid: null }) + ); + }); + expect(api.updateSpool).not.toHaveBeenCalled(); + }); + + it('clears the tag on a built-in spool linked only by its tray UUID', async () => { + // PATCH /inventory/spools/{id}/link-tag takes tray_uuid on its own, so the + // built-in inventory reaches the same state without Spoolman involved. + render( + + ); + + const clearBtn = await screen.findByRole('button', { name: /clear rfid tag/i }); + expect(clearBtn).not.toBeDisabled(); + + fireEvent.click(clearBtn); + + await waitFor(() => { + expect(api.updateSpool).toHaveBeenCalledWith( + 42, + expect.objectContaining({ tag_uid: null, tray_uuid: null }) + ); + }); + expect(api.updateSpoolmanInventorySpool).not.toHaveBeenCalled(); + }); + + it('stays disabled for a spool carrying neither identifier', async () => { + // The button still has something to gate on -- it is not simply always on. + render( + + ); + + const clearBtn = await screen.findByRole('button', { name: /clear rfid tag/i }); + expect(clearBtn).toBeDisabled(); + }); + + it('still clears the tag on a spool carrying a tag_uid', async () => { + const clearBtnSpool = trayUuidOnly({ tag_uid: 'DEADBEEF', tray_uuid: null }); + render( + + ); + + const clearBtn = await screen.findByRole('button', { name: /clear rfid tag/i }); + expect(clearBtn).not.toBeDisabled(); + + fireEvent.click(clearBtn); + + await waitFor(() => { + expect(api.updateSpool).toHaveBeenCalledWith( + 42, + expect.objectContaining({ tag_uid: null, tray_uuid: null }) + ); + }); + }); +}); + describe('SpoolFormModal locationIdTouched', () => { /** * Regression tests for the round-trip bug: saving the edit modal without diff --git a/frontend/src/components/SpoolFormModal.tsx b/frontend/src/components/SpoolFormModal.tsx index 170a749f6..d1edd6eef 100644 --- a/frontend/src/components/SpoolFormModal.tsx +++ b/frontend/src/components/SpoolFormModal.tsx @@ -1128,10 +1128,17 @@ export function SpoolFormModal({
{isEditing && (
+ {/* Either identifier counts as "tagged". A Bambu Lab spool is + linked by its 32-char tray UUID and carries no tag_uid at all + -- in Spoolman mode that is every Bambu spool, because + _map_spoolman_spool splits extra.tag by length -- so gating on + tag_uid alone left this permanently greyed out for them, while + the Tag ID column beside it showed the UUID and the payload + below already cleared both fields (#3109). */}