| 1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744 |
- /**
- * Tests for the SpoolFormModal weightTouched behavior.
- *
- * Verifies that weight_used is only included in the PATCH payload when the user
- * explicitly changes the remaining weight field. This prevents stale React Query
- * cache values from overwriting usage-tracked weight data on the backend.
- */
- import React from 'react';
- import { describe, it, expect, vi, beforeEach } from 'vitest';
- import { screen, waitFor, fireEvent } from '@testing-library/react';
- import { render } from '../utils';
- import { SpoolFormModal } from '../../components/SpoolFormModal';
- import type { InventorySpool } from '../../api/client';
- // Mock the API client
- vi.mock('../../api/client', () => ({
- api: {
- getSettings: vi.fn().mockResolvedValue({}),
- getAuthStatus: vi.fn().mockResolvedValue({ auth_enabled: false }),
- getCloudStatus: vi.fn().mockResolvedValue({ is_authenticated: false }),
- getFilamentPresets: vi.fn().mockResolvedValue([]),
- getSpoolCatalog: vi.fn().mockResolvedValue([]),
- getLocations: vi.fn().mockResolvedValue([]),
- getColorCatalog: vi.fn().mockResolvedValue([]),
- getLocalPresets: vi.fn().mockResolvedValue({ filament: [] }),
- getBuiltinFilaments: vi.fn().mockResolvedValue([]),
- getPrinters: vi.fn().mockResolvedValue([]),
- getSpoolUsageHistory: vi.fn().mockResolvedValue([]),
- createSpool: vi.fn().mockResolvedValue({ id: 99 }),
- createSpoolmanInventorySpool: vi.fn().mockResolvedValue({ id: 88 }),
- updateSpool: vi.fn().mockResolvedValue({ id: 1 }),
- saveSpoolKProfiles: vi.fn().mockResolvedValue([]),
- getSpoolFilamentPresets: vi.fn().mockResolvedValue([]),
- saveSpoolFilamentPresets: vi.fn().mockResolvedValue([]),
- getSpoolmanFilamentPresets: vi.fn().mockResolvedValue([]),
- saveSpoolmanFilamentPresets: vi.fn().mockResolvedValue([]),
- saveSpoolmanKProfiles: vi.fn().mockResolvedValue([]),
- updateSpoolmanInventorySpool: vi.fn().mockResolvedValue({ id: 42 }),
- bulkCreateSpoolmanInventorySpools: vi.fn().mockResolvedValue({
- created: [{ id: 1, material: 'PLA' }],
- requested_count: 1,
- failed_count: 0,
- }),
- getSpoolmanInventoryFilaments: vi.fn().mockResolvedValue([]),
- getAssignments: vi.fn().mockResolvedValue([]),
- getSpoolmanSlotAssignments: vi.fn().mockResolvedValue([]),
- unassignSpool: vi.fn().mockResolvedValue({}),
- unassignSpoolmanSlot: vi.fn().mockResolvedValue({}),
- // Suppliers (#2988) — the SupplierSection inside the form loads these.
- getSuppliers: vi.fn().mockResolvedValue([]),
- createSupplier: vi.fn().mockResolvedValue({ id: 1, name: 'S' }),
- setSpoolSuppliers: vi.fn().mockResolvedValue([]),
- setSpoolmanSpoolSuppliers: vi.fn().mockResolvedValue([]),
- },
- ApiError: class ApiError extends Error {
- status: number;
- constructor(message: string, status: number) {
- super(message);
- this.status = status;
- }
- },
- }));
- // Mock validateForm so we can bypass validation for the create-mode test
- // (editing tests pass validation naturally since the spool has material + slicer_filament)
- vi.mock('../../components/spool-form/types', async (importOriginal) => {
- const actual = await importOriginal<typeof import('../../components/spool-form/types')>();
- return {
- ...actual,
- validateForm: vi.fn().mockReturnValue({ isValid: true, errors: {} }),
- };
- });
- // Mock the toast context
- const mockShowToast = vi.fn();
- vi.mock('../../contexts/ToastContext', async (importOriginal) => {
- const actual = await importOriginal<typeof import('../../contexts/ToastContext')>();
- return {
- ...actual,
- useToast: () => ({ showToast: mockShowToast }),
- };
- });
- import { api } from '../../api/client';
- /**
- * Open the spool form's "Color & Cost" tab.
- *
- * The form is split across three tabs -- Filament (identity + preset), Color &
- * Cost (colour, spool weights, price, category, location) and Printers
- * (per-model preset + per-hotend K profile). Fields that used to sit in one
- * long scroll under Filament now need their tab opened first.
- */
- function openColorAndCostTab() {
- fireEvent.click(screen.getByText('Color & Cost'));
- }
- const existingSpool: InventorySpool = {
- id: 1,
- material: 'PLA',
- subtype: 'Basic',
- brand: 'Polymaker',
- color_name: 'Red',
- rgba: 'FF0000FF',
- extra_colors: null,
- effect_type: null,
- label_weight: 1000,
- core_weight: 250,
- core_weight_catalog_id: null,
- weight_used: 300,
- slicer_filament: 'GFL99',
- slicer_filament_name: 'Generic PLA',
- nozzle_temp_min: null,
- nozzle_temp_max: null,
- note: null,
- added_full: null,
- last_used: null,
- encode_time: null,
- tag_uid: null,
- tray_uuid: null,
- data_origin: null,
- tag_type: null,
- archived_at: null,
- created_at: '2025-01-01T00:00:00Z',
- updated_at: '2025-01-01T00:00:00Z',
- k_profiles: [],
- };
- describe('SpoolFormModal weightTouched', () => {
- beforeEach(() => {
- vi.clearAllMocks();
- });
- it('excludes weight_used from PATCH when editing without changing weight', async () => {
- render(
- <SpoolFormModal
- isOpen={true}
- onClose={vi.fn()}
- spool={existingSpool}
- mode="edit"
- currencySymbol="$"
- />
- );
- // Wait for the modal to render with the edit title
- await waitFor(() => {
- expect(screen.getByText('Edit Spool')).toBeInTheDocument();
- });
- // Click Save without touching the weight field
- const saveButton = screen.getByRole('button', { name: /save/i });
- fireEvent.click(saveButton);
- await waitFor(() => {
- expect(api.updateSpool).toHaveBeenCalledTimes(1);
- });
- const [spoolId, payload] = vi.mocked(api.updateSpool).mock.calls[0];
- expect(spoolId).toBe(1);
- // weight_used must NOT be present in the payload
- expect(payload).not.toHaveProperty('weight_used');
- // Other fields should still be present
- expect(payload).toHaveProperty('material', 'PLA');
- expect(payload).toHaveProperty('label_weight', 1000);
- });
- it('includes weight_used in PATCH when editing and changing remaining weight', async () => {
- render(
- <SpoolFormModal
- isOpen={true}
- onClose={vi.fn()}
- spool={existingSpool}
- mode="edit"
- currencySymbol="$"
- />
- );
- await waitFor(() => {
- expect(screen.getByText('Edit Spool')).toBeInTheDocument();
- });
- openColorAndCostTab();
- // The remaining weight is (label_weight - weight_used) = 1000 - 300 = 700.
- // The input is a number input displaying 700. Find it by its displayed value.
- const remainingInput = screen.getByDisplayValue('700');
- expect(remainingInput).toBeInTheDocument();
- // Change the remaining weight from 700 to 500 (weight_used becomes 1000 - 500 = 500)
- fireEvent.change(remainingInput, { target: { value: '500' } });
- // Blur triggers updateField('weight_used', ...) which sets weightTouched
- fireEvent.blur(remainingInput);
- // Click Save
- const saveButton = screen.getByRole('button', { name: /save/i });
- fireEvent.click(saveButton);
- await waitFor(() => {
- expect(api.updateSpool).toHaveBeenCalledTimes(1);
- });
- const [spoolId, payload] = vi.mocked(api.updateSpool).mock.calls[0];
- expect(spoolId).toBe(1);
- // weight_used MUST be present since the user changed the weight
- expect(payload).toHaveProperty('weight_used', 500);
- });
- it('includes weight_used when creating a new spool', async () => {
- render(
- <SpoolFormModal
- isOpen={true}
- onClose={vi.fn()}
- currencySymbol="$"
- />
- );
- // Wait for the modal to render with the create title
- await waitFor(() => {
- expect(screen.getByRole('heading', { name: 'Add Spool' })).toBeInTheDocument();
- });
- // Click the submit button (validation is mocked to always pass).
- // The default form data has weight_used=0, and for create mode the condition
- // if (!isEditing || weightTouched) { data.weight_used = formData.weight_used; }
- // always includes weight_used since isEditing is false.
- // The submit button also says "Add Spool" — use getAllByText and pick the button.
- const addButtons = screen.getAllByRole('button', { name: /add spool/i });
- const submitButton = addButtons.find(btn => btn.tagName === 'BUTTON' && btn.querySelector('svg.lucide-save'));
- expect(submitButton).toBeTruthy();
- fireEvent.click(submitButton!);
- await waitFor(() => {
- expect(api.createSpool).toHaveBeenCalledTimes(1);
- });
- const [payload] = vi.mocked(api.createSpool).mock.calls[0];
- // weight_used MUST be included for new spools (default value 0)
- expect(payload).toHaveProperty('weight_used', 0);
- });
- it('preserves core_weight_catalog_id when editing other fields', async () => {
- const spoolWithCatalogId: InventorySpool = {
- ...existingSpool,
- core_weight_catalog_id: 5,
- };
- render(
- <SpoolFormModal
- isOpen={true}
- onClose={vi.fn()}
- spool={spoolWithCatalogId}
- mode="edit"
- currencySymbol="$"
- />
- );
- await waitFor(() => {
- expect(screen.getByText('Edit Spool')).toBeInTheDocument();
- });
- openColorAndCostTab();
- // Change the note field (unrelated to catalog ID)
- const noteInputs = screen.getAllByPlaceholderText(/note/i);
- expect(noteInputs.length).toBeGreaterThan(0);
- fireEvent.change(noteInputs[0], { target: { value: 'Updated note' } });
- // Click Save
- const saveButton = screen.getByRole('button', { name: /save/i });
- fireEvent.click(saveButton);
- await waitFor(() => {
- expect(api.updateSpool).toHaveBeenCalledTimes(1);
- });
- const [spoolId, payload] = vi.mocked(api.updateSpool).mock.calls[0];
- expect(spoolId).toBe(1);
- // core_weight_catalog_id MUST be preserved when editing other fields
- expect(payload).toHaveProperty('core_weight_catalog_id', 5);
- // Other changes should also be present
- expect(payload).toHaveProperty('note', 'Updated note');
- });
- it('includes core_weight_catalog_id when selecting from catalog', async () => {
- const mockCatalog = [
- { id: 1, name: 'Generic 250g', weight: 250 },
- { id: 2, name: 'Bambu Lab 250g', weight: 250 },
- { id: 3, name: 'Standard 300g', weight: 300 },
- ];
- vi.mocked(api.getSpoolCatalog).mockResolvedValue(mockCatalog);
- render(
- <SpoolFormModal
- isOpen={true}
- onClose={vi.fn()}
- currencySymbol="$"
- />
- );
- await waitFor(() => {
- expect(screen.getByRole('heading', { name: 'Add Spool' })).toBeInTheDocument();
- });
- openColorAndCostTab();
- // Wait for catalog to load
- await waitFor(() => {
- expect(api.getSpoolCatalog).toHaveBeenCalled();
- });
- // Click on the empty spool weight field to open dropdown
- const weightInputs = screen.getAllByPlaceholderText(/search/i);
- const weightPicker = weightInputs.find(input =>
- input.getAttribute('placeholder')?.toLowerCase().includes('spool')
- );
- expect(weightPicker).toBeTruthy();
- fireEvent.focus(weightPicker!);
- // Click on "Bambu Lab 250g" option
- const bambuOption = await screen.findByText('Bambu Lab 250g');
- fireEvent.click(bambuOption);
- // Click the add spool button
- const addButtons = screen.getAllByRole('button', { name: /add spool/i });
- const submitButton = addButtons.find(btn => btn.tagName === 'BUTTON' && btn.querySelector('svg.lucide-save'));
- expect(submitButton).toBeTruthy();
- fireEvent.click(submitButton!);
- await waitFor(() => {
- expect(api.createSpool).toHaveBeenCalledTimes(1);
- });
- const [payload] = vi.mocked(api.createSpool).mock.calls[0];
- // Both weight AND catalog ID should be sent
- expect(payload).toHaveProperty('core_weight', 250);
- expect(payload).toHaveProperty('core_weight_catalog_id', 2); // ID of "Bambu Lab 250g"
- });
- it('preserves cost_per_kg when editing spool', async () => {
- const spoolWithCost: InventorySpool = {
- ...existingSpool,
- cost_per_kg: 25.50,
- };
- render(
- <SpoolFormModal
- isOpen={true}
- onClose={vi.fn()}
- spool={spoolWithCost}
- mode="edit"
- currencySymbol="$"
- />
- );
- await waitFor(() => {
- expect(screen.getByText('Edit Spool')).toBeInTheDocument();
- });
- // Click Save without changing cost
- const saveButton = screen.getByRole('button', { name: /save/i });
- fireEvent.click(saveButton);
- await waitFor(() => {
- expect(api.updateSpool).toHaveBeenCalledTimes(1);
- });
- const [spoolId, payload] = vi.mocked(api.updateSpool).mock.calls[0];
- expect(spoolId).toBe(1);
- // cost_per_kg should be preserved in the update payload
- expect(payload).toHaveProperty('cost_per_kg', 25.50);
- });
- it('sends null cost_per_kg when spool has no cost', async () => {
- const spoolWithoutCost: InventorySpool = {
- ...existingSpool,
- cost_per_kg: null,
- };
- render(
- <SpoolFormModal
- isOpen={true}
- onClose={vi.fn()}
- spool={spoolWithoutCost}
- mode="edit"
- currencySymbol="$"
- />
- );
- await waitFor(() => {
- expect(screen.getByText('Edit Spool')).toBeInTheDocument();
- });
- const saveButton = screen.getByRole('button', { name: /save/i });
- fireEvent.click(saveButton);
- await waitFor(() => {
- expect(api.updateSpool).toHaveBeenCalledTimes(1);
- });
- const [, payload] = vi.mocked(api.updateSpool).mock.calls[0];
- // cost_per_kg should be null when not set
- expect(payload).toHaveProperty('cost_per_kg', null);
- });
- it('normalizes a malformed legacy rgba on edit-form load so PATCH is not rejected (#1055)', async () => {
- // #1055 regression guard: a spool with a legacy 7-char rgba (e.g. 'FFFFFFF')
- // was editable in the UI but any save 422'd because SpoolUpdate now enforces
- // the 8-char pattern. The form must sanitize the loaded value to a valid
- // default so users can edit unrelated fields without being forced to fix
- // a color they may not even have noticed was broken.
- const spoolWithBadRgba: InventorySpool = {
- ...existingSpool,
- rgba: 'FFFFFFF', // 7 chars — the exact #1055 trigger pattern
- };
- render(
- <SpoolFormModal
- isOpen={true}
- onClose={vi.fn()}
- spool={spoolWithBadRgba}
- mode="edit"
- currencySymbol="$"
- />
- );
- await waitFor(() => {
- expect(screen.getByText('Edit Spool')).toBeInTheDocument();
- });
- const saveButton = screen.getByRole('button', { name: /save/i });
- fireEvent.click(saveButton);
- await waitFor(() => {
- expect(api.updateSpool).toHaveBeenCalledTimes(1);
- });
- const [, payload] = vi.mocked(api.updateSpool).mock.calls[0];
- // The PATCH payload must carry a valid 8-char rgba — never the raw 7-char
- // value loaded from the stale DB row.
- expect(payload).toHaveProperty('rgba');
- expect(typeof (payload as { rgba: unknown }).rgba).toBe('string');
- expect((payload as { rgba: string }).rgba).toMatch(/^[0-9A-Fa-f]{8}$/);
- });
- it('preserves a valid existing rgba on edit (no forced default)', async () => {
- // Sanity: the normalization only kicks in for malformed values. A valid
- // 8-char rgba must round-trip untouched so untouched edits don't quietly
- // reset a user's chosen color.
- render(
- <SpoolFormModal
- isOpen={true}
- onClose={vi.fn()}
- spool={existingSpool} // rgba = 'FF0000FF' (valid)
- mode="edit"
- currencySymbol="$"
- />
- );
- await waitFor(() => {
- expect(screen.getByText('Edit Spool')).toBeInTheDocument();
- });
- const saveButton = screen.getByRole('button', { name: /save/i });
- fireEvent.click(saveButton);
- await waitFor(() => {
- expect(api.updateSpool).toHaveBeenCalledTimes(1);
- });
- const [, payload] = vi.mocked(api.updateSpool).mock.calls[0];
- expect((payload as { rgba: string }).rgba).toBe('FF0000FF');
- });
- it('shows warning toast on partial bulk-create in Spoolman mode (T1/partial)', async () => {
- vi.mocked(api.bulkCreateSpoolmanInventorySpools).mockResolvedValueOnce({
- created: [{ id: 1, material: 'PLA' } as InventorySpool],
- requested_count: 3,
- failed_count: 2,
- });
- render(
- <SpoolFormModal
- isOpen={true}
- onClose={vi.fn()}
- mode="create"
- currencySymbol="$"
- spoolmanMode={true}
- />
- );
- await waitFor(() => {
- expect(screen.getByRole('heading', { name: 'Add Spool' })).toBeInTheDocument();
- });
- // Enable Quick Add mode so the quantity field appears
- const quickAddRow = screen.getByText('Quick Add (Stock)').closest('div[class*="justify-between"]');
- const toggleButton = quickAddRow?.querySelector('button[type="button"]');
- expect(toggleButton).toBeTruthy();
- fireEvent.click(toggleButton!);
- // Set quantity to 3 (triggers bulkCreateMutation instead of createMutation)
- const quantityContainer = screen.getByText('Quantity').closest('div');
- const quantityInput = quantityContainer?.querySelector('input[type="number"]');
- expect(quantityInput).toBeTruthy();
- fireEvent.change(quantityInput!, { target: { value: '3' } });
- // Click the submit button
- const addButtons = screen.getAllByRole('button', { name: /add spool/i });
- const submitButton = addButtons.find(btn => btn.tagName === 'BUTTON' && btn.querySelector('svg.lucide-save'));
- expect(submitButton).toBeTruthy();
- fireEvent.click(submitButton!);
- await waitFor(() => {
- expect(api.bulkCreateSpoolmanInventorySpools).toHaveBeenCalledTimes(1);
- });
- // Should show a warning toast for partial failure (1 created, 2 failed, 3 requested)
- expect(mockShowToast).toHaveBeenCalledWith(
- expect.stringContaining('1 of 3'),
- 'warning',
- );
- });
- it('shows success toast on full bulk-create success in Spoolman mode (T1/success)', async () => {
- vi.mocked(api.bulkCreateSpoolmanInventorySpools).mockResolvedValueOnce({
- created: [
- { id: 1, material: 'PLA' } as InventorySpool,
- { id: 2, material: 'PLA' } as InventorySpool,
- { id: 3, material: 'PLA' } as InventorySpool,
- ],
- requested_count: 3,
- failed_count: 0,
- });
- render(
- <SpoolFormModal
- isOpen={true}
- onClose={vi.fn()}
- mode="create"
- currencySymbol="$"
- spoolmanMode={true}
- />
- );
- await waitFor(() => {
- expect(screen.getByRole('heading', { name: 'Add Spool' })).toBeInTheDocument();
- });
- // Enable Quick Add mode so the quantity field appears
- const quickAddRow = screen.getByText('Quick Add (Stock)').closest('div[class*="justify-between"]');
- const toggleButton = quickAddRow?.querySelector('button[type="button"]');
- expect(toggleButton).toBeTruthy();
- fireEvent.click(toggleButton!);
- // Set quantity to 3
- const quantityContainer = screen.getByText('Quantity').closest('div');
- const quantityInput = quantityContainer?.querySelector('input[type="number"]');
- expect(quantityInput).toBeTruthy();
- fireEvent.change(quantityInput!, { target: { value: '3' } });
- // Click the submit button
- const addButtons = screen.getAllByRole('button', { name: /add spool/i });
- const submitButton = addButtons.find(btn => btn.tagName === 'BUTTON' && btn.querySelector('svg.lucide-save'));
- expect(submitButton).toBeTruthy();
- fireEvent.click(submitButton!);
- await waitFor(() => {
- expect(api.bulkCreateSpoolmanInventorySpools).toHaveBeenCalledTimes(1);
- });
- // Should show a success toast listing the count of created spools
- expect(mockShowToast).toHaveBeenCalledWith(
- expect.stringContaining('3'),
- 'success',
- );
- });
- it('displays correct catalog name when duplicates exist', async () => {
- const spoolWithCatalogId: InventorySpool = {
- ...existingSpool,
- core_weight: 250,
- core_weight_catalog_id: 2, // "Bambu Lab 250g", not the first match
- };
- const mockCatalog = [
- { id: 1, name: 'Generic 250g', weight: 250 },
- { id: 2, name: 'Bambu Lab 250g', weight: 250 },
- { id: 3, name: 'Standard 300g', weight: 300 },
- ];
- vi.mocked(api.getSpoolCatalog).mockResolvedValue(mockCatalog);
- render(
- <SpoolFormModal
- isOpen={true}
- onClose={vi.fn()}
- spool={spoolWithCatalogId}
- mode="edit"
- currencySymbol="$"
- />
- );
- await waitFor(() => {
- expect(screen.getByText('Edit Spool')).toBeInTheDocument();
- });
- openColorAndCostTab();
- // Wait for catalog to load
- await waitFor(() => {
- expect(api.getSpoolCatalog).toHaveBeenCalled();
- });
- // Should display "Bambu Lab 250g" (by ID), not "Generic 250g" (first match by weight)
- await waitFor(() => {
- const weightInputs = screen.getAllByDisplayValue(/250|Bambu/i);
- const bambuFound = weightInputs.some(input =>
- input.value === 'Bambu Lab 250g' || input.getAttribute('value') === 'Bambu Lab 250g'
- );
- expect(bambuFound).toBeTruthy();
- });
- });
- });
- describe('SpoolFormModal Spoolman K-profile support', () => {
- const spoolmanSpool: InventorySpool = {
- ...{
- id: 42,
- material: 'PLA',
- subtype: 'Basic',
- brand: 'BrandX',
- color_name: 'Black',
- rgba: '000000FF',
- label_weight: 1000,
- core_weight: 250,
- core_weight_catalog_id: null,
- weight_used: 200,
- slicer_filament: '',
- slicer_filament_name: '',
- nozzle_temp_min: null,
- nozzle_temp_max: null,
- note: null,
- added_full: null,
- last_used: null,
- encode_time: null,
- tag_uid: null,
- tray_uuid: null,
- data_origin: 'spoolman',
- tag_type: 'spoolman',
- archived_at: null,
- created_at: '2025-01-01T00:00:00Z',
- updated_at: '2025-01-01T00:00:00Z',
- k_profiles: [],
- },
- } as InventorySpool;
- beforeEach(() => {
- vi.clearAllMocks();
- });
- it('shows the Printers tab for Spoolman spools in non-quickAdd mode', async () => {
- render(
- <SpoolFormModal
- isOpen={true}
- onClose={vi.fn()}
- spool={spoolmanSpool}
- mode="edit"
- currencySymbol="$"
- spoolmanMode={true}
- />
- );
- await waitFor(() => {
- expect(screen.getByText('Edit Spool')).toBeInTheDocument();
- });
- // Printers tab should be visible in Spoolman mode
- expect(screen.getByText('Printers')).toBeInTheDocument();
- });
- it('calls saveSpoolmanKProfiles (not saveSpoolKProfiles) on update in Spoolman mode', async () => {
- render(
- <SpoolFormModal
- isOpen={true}
- onClose={vi.fn()}
- spool={spoolmanSpool}
- mode="edit"
- currencySymbol="$"
- spoolmanMode={true}
- />
- );
- await waitFor(() => {
- expect(screen.getByText('Edit Spool')).toBeInTheDocument();
- });
- const saveButton = screen.getByRole('button', { name: /save/i });
- fireEvent.click(saveButton);
- await waitFor(() => {
- expect(api.updateSpoolmanInventorySpool).toHaveBeenCalledTimes(1);
- });
- // saveSpoolmanKProfiles is always called on update (even with empty list)
- await waitFor(() => {
- expect(api.saveSpoolmanKProfiles).toHaveBeenCalledWith(42, []);
- });
- expect(api.saveSpoolKProfiles).not.toHaveBeenCalled();
- });
- it('saves the per-model preset overrides alongside the K profiles', async () => {
- // Both are full replacements and both are written on every save: that is
- // how the user clears the last profile or the last override. The Spoolman
- // pair must be the one called in Spoolman mode -- the two inventory modes
- // have drifted apart on this path before (#1713).
- render(
- <SpoolFormModal
- isOpen={true}
- onClose={vi.fn()}
- spool={spoolmanSpool}
- mode="edit"
- currencySymbol="$"
- spoolmanMode={true}
- />
- );
- await waitFor(() => {
- expect(screen.getByText('Edit Spool')).toBeInTheDocument();
- });
- fireEvent.click(screen.getByRole('button', { name: /save/i }));
- await waitFor(() => {
- expect(api.saveSpoolmanFilamentPresets).toHaveBeenCalledWith(42, []);
- });
- expect(api.saveSpoolFilamentPresets).not.toHaveBeenCalled();
- });
- });
- // ---------------------------------------------------------------------------
- // T2: SpoolmanFilamentPicker integration with SpoolFormModal
- // ---------------------------------------------------------------------------
- vi.mock('../../components/spool-form/SpoolmanFilamentPicker', () => ({
- SpoolmanFilamentPicker: ({ onSelect, selectedId }: { onSelect: (f: unknown) => void; selectedId: number | null; isLoading: boolean; filaments: unknown[] }) => {
- return (
- <div>
- <span data-testid="picker-selected-id">{selectedId ?? 'none'}</span>
- <button data-testid="picker-select-btn" onClick={() => onSelect({
- id: 7,
- name: 'PLA Basic',
- material: 'PLA',
- color_hex: 'FF0000',
- color_name: 'Red',
- weight: 1000,
- spool_weight: 196,
- vendor: { id: 1, name: 'Bambu Lab' },
- })}>
- Select Filament
- </button>
- <button data-testid="picker-select-clear-btn" onClick={() => onSelect({
- id: 8,
- name: 'PLA Basic Clear',
- material: 'PLA',
- color_hex: '00000000',
- color_name: 'Clear',
- weight: 1000,
- spool_weight: 196,
- vendor: { id: 1, name: 'Bambu Lab' },
- })}>
- Select Clear Filament
- </button>
- </div>
- );
- },
- }));
- describe('SpoolFormModal — SpoolmanFilamentPicker integration (T2)', () => {
- beforeEach(() => {
- vi.clearAllMocks();
- });
- it('renders SpoolmanFilamentPicker in Spoolman create mode', async () => {
- render(
- <SpoolFormModal
- isOpen={true}
- onClose={vi.fn()}
- currencySymbol="$"
- spoolmanMode={true}
- />
- );
- await waitFor(() => {
- expect(screen.getByTestId('picker-select-btn')).toBeInTheDocument();
- });
- });
- it('does NOT render SpoolmanFilamentPicker in local inventory mode', async () => {
- render(
- <SpoolFormModal
- isOpen={true}
- onClose={vi.fn()}
- currencySymbol="$"
- spoolmanMode={false}
- />
- );
- await waitFor(() => {
- expect(screen.queryByTestId('picker-select-btn')).not.toBeInTheDocument();
- });
- });
- it('prefills form fields when a filament is selected from the picker', async () => {
- render(
- <SpoolFormModal
- isOpen={true}
- onClose={vi.fn()}
- currencySymbol="$"
- spoolmanMode={true}
- />
- );
- await waitFor(() => {
- expect(screen.getByTestId('picker-select-btn')).toBeInTheDocument();
- });
- fireEvent.click(screen.getByTestId('picker-select-btn'));
- // After selection, the picker should reflect the selected ID
- await waitFor(() => {
- expect(screen.getByTestId('picker-selected-id').textContent).toBe('7');
- });
- });
- it('prefills a translucent filament with its own alpha, not 808080FF (#2912)', async () => {
- // The guard here required exactly 6 hex chars and then appended FF. That was
- // unreachable while Bambuddy never wrote 8 characters; once a clear filament
- // is storable, picking it out of the Spoolman catalogue prefilled the form
- // with neutral grey — the frontend twin of the read-side regex.
- render(
- <SpoolFormModal
- isOpen={true}
- onClose={vi.fn()}
- currencySymbol="$"
- spoolmanMode={true}
- spoolsQueryKey={['spoolman-spools']}
- />
- );
- await waitFor(() => {
- expect(screen.getByTestId('picker-select-clear-btn')).toBeInTheDocument();
- });
- fireEvent.click(screen.getByTestId('picker-select-clear-btn'));
- const saveButton = screen.getByRole('button', { name: /save|add spool/i });
- fireEvent.click(saveButton);
- await waitFor(() => {
- expect(api.createSpoolmanInventorySpool).toHaveBeenCalledTimes(1);
- });
- const payload = vi.mocked(api.createSpoolmanInventorySpool).mock.calls[0][0] as Record<string, unknown>;
- expect(payload.rgba).toBe('00000000');
- });
- it('still appends the opaque alpha to a 6-char catalogue colour', async () => {
- render(
- <SpoolFormModal
- isOpen={true}
- onClose={vi.fn()}
- currencySymbol="$"
- spoolmanMode={true}
- spoolsQueryKey={['spoolman-spools']}
- />
- );
- await waitFor(() => {
- expect(screen.getByTestId('picker-select-btn')).toBeInTheDocument();
- });
- fireEvent.click(screen.getByTestId('picker-select-btn'));
- const saveButton = screen.getByRole('button', { name: /save|add spool/i });
- fireEvent.click(saveButton);
- await waitFor(() => {
- expect(api.createSpoolmanInventorySpool).toHaveBeenCalledTimes(1);
- });
- const payload = vi.mocked(api.createSpoolmanInventorySpool).mock.calls[0][0] as Record<string, unknown>;
- expect(payload.rgba).toBe('FF0000FF');
- });
- it('includes spoolman_filament_id in the submit payload when a filament is pre-selected', async () => {
- render(
- <SpoolFormModal
- isOpen={true}
- onClose={vi.fn()}
- currencySymbol="$"
- spoolmanMode={true}
- spoolsQueryKey={['spoolman-spools']}
- />
- );
- await waitFor(() => {
- expect(screen.getByTestId('picker-select-btn')).toBeInTheDocument();
- });
- // Select a filament
- fireEvent.click(screen.getByTestId('picker-select-btn'));
- // Submit the form
- const saveButton = screen.getByRole('button', { name: /save|add spool/i });
- fireEvent.click(saveButton);
- await waitFor(() => {
- expect(api.createSpoolmanInventorySpool).toHaveBeenCalledTimes(1);
- });
- const callArg = vi.mocked(api.createSpoolmanInventorySpool).mock.calls[0][0] as Record<string, unknown>;
- expect(callArg.spoolman_filament_id).toBe(7);
- });
- it('clears spoolman_filament_id and shows unlink toast when user edits a linked field', async () => {
- render(
- <SpoolFormModal
- isOpen={true}
- onClose={vi.fn()}
- currencySymbol="$"
- spoolmanMode={true}
- />
- );
- await waitFor(() => {
- expect(screen.getByTestId('picker-select-btn')).toBeInTheDocument();
- });
- // Select a filament from the catalog picker
- fireEvent.click(screen.getByTestId('picker-select-btn'));
- await waitFor(() => {
- expect(screen.getByTestId('picker-selected-id').textContent).toBe('7');
- });
- openColorAndCostTab();
- // Manually edit the color_name field (a linked field)
- const colorNameInput = screen.getByPlaceholderText('Jade White, Fire Red...');
- fireEvent.change(colorNameInput, { target: { value: 'Custom Blue' } });
- // Back to the Filament tab: the catalog picker only renders there, so the
- // link state has to be read where it lives.
- fireEvent.click(screen.getByText('Filament Info'));
- // spoolman_filament_id must be cleared (picker shows 'none')
- await waitFor(() => {
- expect(screen.getByTestId('picker-selected-id').textContent).toBe('none');
- });
- // Unlink toast must have been shown
- expect(mockShowToast).toHaveBeenCalledWith(
- expect.stringContaining('catalog link'),
- 'info',
- );
- });
- });
- describe('SpoolFormModal — assignment actions (#1336, #2978)', () => {
- const spoolmanSpool: InventorySpool = {
- id: 42,
- material: 'PLA',
- subtype: 'Basic',
- brand: 'BrandX',
- color_name: 'Black',
- rgba: '000000FF',
- extra_colors: null,
- effect_type: null,
- label_weight: 1000,
- core_weight: 250,
- core_weight_catalog_id: null,
- weight_used: 200,
- slicer_filament: '',
- slicer_filament_name: '',
- nozzle_temp_min: null,
- nozzle_temp_max: null,
- note: null,
- added_full: null,
- last_used: null,
- encode_time: null,
- tag_uid: null,
- tray_uuid: null,
- data_origin: 'spoolman',
- tag_type: 'spoolman',
- archived_at: null,
- created_at: '2025-01-01T00:00:00Z',
- updated_at: '2025-01-01T00:00:00Z',
- cost_per_kg: null,
- last_scale_weight: null,
- last_weighed_at: null,
- category: null,
- low_stock_threshold_pct: null,
- k_profiles: [],
- } as InventorySpool;
- beforeEach(() => {
- vi.clearAllMocks();
- });
- it('enables Unassign in Spoolman mode when a spoolman_slot_assignment exists for the spool', async () => {
- vi.mocked(api.getSpoolmanSlotAssignments).mockResolvedValueOnce([
- {
- printer_id: 1,
- printer_name: 'Test Printer',
- ams_id: 0,
- tray_id: 2,
- spoolman_spool_id: 42,
- ams_label: 'AMS 1',
- },
- ]);
- render(
- <SpoolFormModal
- isOpen={true}
- onClose={vi.fn()}
- spool={spoolmanSpool}
- mode="edit"
- currencySymbol="$"
- spoolmanMode={true}
- />
- );
- const unassignBtn = await screen.findByRole('button', { name: /unassign/i });
- await waitFor(() => {
- expect(unassignBtn).not.toBeDisabled();
- });
- fireEvent.click(unassignBtn);
- await waitFor(() => {
- expect(api.unassignSpoolmanSlot).toHaveBeenCalledWith(42);
- });
- expect(api.unassignSpool).not.toHaveBeenCalled();
- });
- it('offers direct assignment when no Spoolman slot assignment exists (#2978)', async () => {
- vi.mocked(api.getSpoolmanSlotAssignments).mockResolvedValueOnce([]);
- render(
- <SpoolFormModal
- isOpen={true}
- onClose={vi.fn()}
- spool={spoolmanSpool}
- mode="edit"
- currencySymbol="$"
- spoolmanMode={true}
- />
- );
- await waitFor(() => {
- expect(api.getSpoolmanSlotAssignments).toHaveBeenCalled();
- });
- expect(screen.queryByRole('button', { name: /unassign/i })).not.toBeInTheDocument();
- const assignBtn = screen.getByRole('button', { name: /assign spool/i });
- expect(assignBtn).not.toBeDisabled();
- fireEvent.click(assignBtn);
- expect(await screen.findByRole('heading', { name: /assign to AMS/i })).toBeInTheDocument();
- expect(screen.queryByLabelText(/color name/i)).not.toBeInTheDocument();
- });
- it('closes only the nested assignment dialog on the first Escape press', async () => {
- const onClose = vi.fn();
- vi.mocked(api.getSpoolmanSlotAssignments).mockResolvedValueOnce([]);
- render(
- <SpoolFormModal
- isOpen={true}
- onClose={onClose}
- spool={spoolmanSpool}
- mode="edit"
- currencySymbol="$"
- spoolmanMode={true}
- />
- );
- await waitFor(() => expect(api.getSpoolmanSlotAssignments).toHaveBeenCalled());
- fireEvent.click(screen.getByRole('button', { name: /assign spool/i }));
- expect(await screen.findByRole('heading', { name: /assign to AMS/i })).toBeInTheDocument();
- fireEvent.keyDown(document, { key: 'Escape' });
- await waitFor(() => {
- expect(screen.queryByRole('heading', { name: /assign to AMS/i })).not.toBeInTheDocument();
- });
- expect(onClose).not.toHaveBeenCalled();
- expect(screen.getByText('Edit Spool')).toBeInTheDocument();
- fireEvent.keyDown(document, { key: 'Escape' });
- expect(onClose).toHaveBeenCalledTimes(1);
- });
- });
- describe('SpoolFormModal — Clear RFID Tag for a tray-UUID-only spool (#3109)', () => {
- const trayUuidOnly = (overrides: Partial<InventorySpool>): 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(
- <SpoolFormModal
- isOpen={true}
- onClose={vi.fn()}
- spool={trayUuidOnly({ data_origin: 'spoolman', tag_type: 'spoolman' })}
- mode="edit"
- currencySymbol="$"
- spoolmanMode={true}
- />
- );
- 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(
- <SpoolFormModal
- isOpen={true}
- onClose={vi.fn()}
- spool={trayUuidOnly({})}
- mode="edit"
- currencySymbol="$"
- />
- );
- 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(
- <SpoolFormModal
- isOpen={true}
- onClose={vi.fn()}
- spool={trayUuidOnly({ tray_uuid: null })}
- mode="edit"
- currencySymbol="$"
- />
- );
- 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(
- <SpoolFormModal
- isOpen={true}
- onClose={vi.fn()}
- spool={clearBtnSpool}
- mode="edit"
- currencySymbol="$"
- />
- );
- 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
- * touching the Storage Location field must NOT include location_id in
- * the PATCH payload, so Spoolman's location field is never overwritten with
- * a stale cached value.
- */
- beforeEach(() => {
- vi.clearAllMocks();
- vi.mocked(api.getLocations).mockResolvedValue([
- { id: 1, name: 'IKEAREGAL', identifier: null, spool_count: 1, created_at: '', updated_at: '' },
- { id: 2, name: 'Shelf B', identifier: null, spool_count: 0, created_at: '', updated_at: '' },
- ]);
- });
- const spoolWithStorageLocation: InventorySpool = {
- ...existingSpool,
- storage_location: 'IKEAREGAL',
- location_id: 1,
- };
- it('excludes storage_location from PATCH when editing without changing it', async () => {
- render(
- <SpoolFormModal
- isOpen={true}
- onClose={vi.fn()}
- spool={spoolWithStorageLocation}
- mode="edit"
- currencySymbol="$"
- />
- );
- await waitFor(() => {
- expect(screen.getByText('Edit Spool')).toBeInTheDocument();
- });
- // Save without touching the storage location field
- const saveButton = screen.getByRole('button', { name: /save/i });
- fireEvent.click(saveButton);
- await waitFor(() => {
- expect(api.updateSpool).toHaveBeenCalledTimes(1);
- });
- const [spoolId, payload] = vi.mocked(api.updateSpool).mock.calls[0];
- expect(spoolId).toBe(1);
- // storage_location must NOT be in the payload — prevents Spoolman location overwrite
- expect(payload).not.toHaveProperty('storage_location');
- expect(payload).not.toHaveProperty('location_id');
- // Other fields should still be present
- expect(payload).toHaveProperty('material', 'PLA');
- });
- it('includes location_id in PATCH when editing and changing it', async () => {
- render(
- <SpoolFormModal
- isOpen={true}
- onClose={vi.fn()}
- spool={spoolWithStorageLocation}
- mode="edit"
- currencySymbol="$"
- />
- );
- await waitFor(() => {
- expect(screen.getByText('Edit Spool')).toBeInTheDocument();
- });
- openColorAndCostTab();
- // Change storage location via the catalog dropdown
- const locationSelect = screen.getByLabelText(/storage location/i);
- fireEvent.change(locationSelect, { target: { value: '2' } });
- const saveButton = screen.getByRole('button', { name: /save/i });
- fireEvent.click(saveButton);
- await waitFor(() => {
- expect(api.updateSpool).toHaveBeenCalledTimes(1);
- });
- const [spoolId, payload] = vi.mocked(api.updateSpool).mock.calls[0];
- expect(spoolId).toBe(1);
- expect(payload).toHaveProperty('location_id', 2);
- expect(payload).not.toHaveProperty('storage_location');
- });
- it('includes location_id when creating a new spool', async () => {
- render(
- <SpoolFormModal
- isOpen={true}
- onClose={vi.fn()}
- currencySymbol="$"
- />
- );
- await waitFor(() => {
- expect(screen.getByRole('heading', { name: 'Add Spool' })).toBeInTheDocument();
- });
- // Submit without setting storage_location (validation is mocked to pass)
- const addButtons = screen.getAllByRole('button', { name: /add spool/i });
- const submitButton = addButtons.find(btn => btn.tagName === 'BUTTON' && btn.querySelector('svg.lucide-save'));
- expect(submitButton).toBeTruthy();
- fireEvent.click(submitButton!);
- await waitFor(() => {
- expect(api.createSpool).toHaveBeenCalledTimes(1);
- });
- const [payload] = vi.mocked(api.createSpool).mock.calls[0];
- expect(payload).toHaveProperty('location_id', null);
- expect(payload).not.toHaveProperty('storage_location');
- });
- });
- describe('SpoolFormModal copy mode', () => {
- beforeEach(() => {
- vi.clearAllMocks();
- });
- it('shows "Copy Spool" as the modal title when spool and mode="copy" are passed', async () => {
- render(
- <SpoolFormModal
- isOpen={true}
- onClose={vi.fn()}
- spool={existingSpool}
- mode="copy"
- currencySymbol="$"
- />
- );
- await waitFor(() => {
- expect(screen.getByRole('heading', { name: 'Copy Spool' })).toBeInTheDocument();
- });
- });
- it('calls api.createSpool (not api.updateSpool) when saving in copy mode', async () => {
- render(
- <SpoolFormModal
- isOpen={true}
- onClose={vi.fn()}
- spool={existingSpool}
- mode="copy"
- currencySymbol="$"
- />
- );
- await waitFor(() => {
- expect(screen.getByRole('heading', { name: 'Copy Spool' })).toBeInTheDocument();
- });
- // The save button label is "Copy Spool" in copy mode
- const saveBtn = screen.getAllByRole('button', { name: /copy spool/i })
- .find(btn => btn.tagName === 'BUTTON' && btn.querySelector('svg'));
- expect(saveBtn).toBeTruthy();
- fireEvent.click(saveBtn!);
- await waitFor(() => {
- expect(api.createSpool).toHaveBeenCalledTimes(1);
- });
- expect(api.updateSpool).not.toHaveBeenCalled();
- });
- it('resets weight_used to 0 in the create payload when copying a spool with non-zero usage', async () => {
- // existingSpool has weight_used: 300 — must become 0 on copy
- render(
- <SpoolFormModal
- isOpen={true}
- onClose={vi.fn()}
- spool={existingSpool}
- mode="copy"
- currencySymbol="$"
- />
- );
- await waitFor(() => {
- expect(screen.getByRole('heading', { name: 'Copy Spool' })).toBeInTheDocument();
- });
- const saveBtn = screen.getAllByRole('button', { name: /copy spool/i })
- .find(btn => btn.tagName === 'BUTTON' && btn.querySelector('svg'));
- expect(saveBtn).toBeTruthy();
- fireEvent.click(saveBtn!);
- await waitFor(() => {
- expect(api.createSpool).toHaveBeenCalledTimes(1);
- });
- const [payload] = vi.mocked(api.createSpool).mock.calls[0];
- expect((payload as Record<string, unknown>).weight_used).toBe(0);
- });
- // The dialog seeds the supplier chips from the spool being copied and shows
- // them, so the copy has to actually get them (#2988). The backend's
- // inheritance is not a stand-in: it keys on the (material, subtype, brand,
- // color_name) tuple, so it resolves to the NEWEST spool of that product
- // rather than the one on screen, and in Spoolman mode it never runs at all.
- const spoolWithSuppliers: InventorySpool = {
- ...existingSpool,
- suppliers: [
- {
- id: 11,
- supplier_id: 4,
- supplier_name: 'Extrudr',
- supplier_article_number: 'EX-42',
- quoted_price_per_kg: 21.5,
- is_purchase_source: true,
- },
- ],
- };
- async function clickCopy() {
- await waitFor(() => {
- expect(screen.getByRole('heading', { name: 'Copy Spool' })).toBeInTheDocument();
- });
- const saveBtn = screen.getAllByRole('button', { name: /copy spool/i })
- .find(btn => btn.tagName === 'BUTTON' && btn.querySelector('svg'));
- expect(saveBtn).toBeTruthy();
- fireEvent.click(saveBtn!);
- }
- it('saves the supplier assignments it displays when copying', async () => {
- render(
- <SpoolFormModal
- isOpen={true}
- onClose={vi.fn()}
- spool={spoolWithSuppliers}
- mode="copy"
- currencySymbol="$"
- />
- );
- await clickCopy();
- await waitFor(() => {
- expect(api.setSpoolSuppliers).toHaveBeenCalledTimes(1);
- });
- // createSpool is mocked to answer id 99.
- expect(vi.mocked(api.setSpoolSuppliers).mock.calls[0]).toEqual([
- 99,
- [
- {
- supplier_id: 4,
- supplier_article_number: 'EX-42',
- quoted_price_per_kg: 21.5,
- // Where a copy was bought is unknown — only the source list carries over.
- is_purchase_source: false,
- },
- ],
- ]);
- });
- it('saves them through the Spoolman endpoint in Spoolman mode', async () => {
- render(
- <SpoolFormModal
- isOpen={true}
- onClose={vi.fn()}
- spool={spoolWithSuppliers}
- mode="copy"
- currencySymbol="$"
- spoolmanMode={true}
- />
- );
- await clickCopy();
- await waitFor(() => {
- expect(api.setSpoolmanSpoolSuppliers).toHaveBeenCalledTimes(1);
- });
- // createSpoolmanInventorySpool is mocked to answer id 88.
- expect(vi.mocked(api.setSpoolmanSpoolSuppliers).mock.calls[0][0]).toBe(88);
- expect(api.setSpoolSuppliers).not.toHaveBeenCalled();
- });
- it('leaves an untouched create to the backend inheritance', async () => {
- render(<SpoolFormModal isOpen={true} onClose={vi.fn()} currencySymbol="$" />);
- 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.createSpool).toHaveBeenCalledTimes(1);
- });
- // An empty replace-all here would wipe what the backend just inherited.
- expect(api.setSpoolSuppliers).not.toHaveBeenCalled();
- });
- });
- // The "#<id>" affordance in the modal header (#1385) is only meaningful when
- // editing an existing spool — there's no ID yet on create, and the copy path
- // is producing a new spool too. Guard all three cases so a future refactor
- // can't quietly start leaking the source spool's ID into the Copy modal.
- describe('SpoolFormModal header spool ID (#1385)', () => {
- beforeEach(() => {
- vi.clearAllMocks();
- });
- it('shows #<id> next to the title when editing an existing spool', async () => {
- render(
- <SpoolFormModal
- isOpen={true}
- onClose={vi.fn()}
- spool={existingSpool}
- mode="edit"
- currencySymbol="$"
- />
- );
- await waitFor(() => {
- expect(screen.getByText('Edit Spool')).toBeInTheDocument();
- });
- // existingSpool.id is 1; render as "#1" in the modal header.
- expect(screen.getByText('#1')).toBeInTheDocument();
- });
- it('does not show an ID when creating a new spool', async () => {
- render(
- <SpoolFormModal
- isOpen={true}
- onClose={vi.fn()}
- currencySymbol="$"
- />
- );
- await waitFor(() => {
- expect(screen.getByRole('heading', { name: 'Add Spool' })).toBeInTheDocument();
- });
- // No spool exists yet → header carries no "#..." token.
- expect(screen.queryByText(/^#\d+$/)).not.toBeInTheDocument();
- });
- it('does not leak the source spool ID when copying', async () => {
- // Copying produces a fresh spool — surfacing the source ID in the
- // "Copy Spool" header would mislead the user into thinking the new
- // spool inherits it.
- render(
- <SpoolFormModal
- isOpen={true}
- onClose={vi.fn()}
- spool={existingSpool}
- mode="copy"
- currencySymbol="$"
- />
- );
- await waitFor(() => {
- expect(screen.getByRole('heading', { name: 'Copy Spool' })).toBeInTheDocument();
- });
- 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');
- });
- });
|