| 12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190 |
- /**
- * 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([]),
- 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({}),
- },
- 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';
- 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();
- });
- // 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();
- });
- // 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();
- });
- // 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();
- });
- // 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 PA Profile 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();
- });
- // PA Profile tab should be visible in Spoolman mode
- expect(screen.getByText('PA Profile')).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();
- });
- });
- // ---------------------------------------------------------------------------
- // 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>
- </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('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');
- });
- // Manually edit the color_name field (a linked field)
- const colorNameInput = screen.getByPlaceholderText('Jade White, Fire Red...');
- fireEvent.change(colorNameInput, { target: { value: 'Custom Blue' } });
- // 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 — Unassign button (#1336)', () => {
- 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('keeps Unassign disabled in Spoolman mode when no slot assignment exists', async () => {
- vi.mocked(api.getSpoolmanSlotAssignments).mockResolvedValueOnce([]);
- render(
- <SpoolFormModal
- isOpen={true}
- onClose={vi.fn()}
- spool={spoolmanSpool}
- mode="edit"
- currencySymbol="$"
- spoolmanMode={true}
- />
- );
- const unassignBtn = await screen.findByRole('button', { name: /unassign/i });
- // Wait one tick for the (empty) query result to settle so the disabled state is final.
- await waitFor(() => {
- expect(api.getSpoolmanSlotAssignments).toHaveBeenCalled();
- });
- expect(unassignBtn).toBeDisabled();
- });
- });
- 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();
- });
- // 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 "#<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();
- });
- });
|