| 1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183 |
- /**
- * 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([]),
- 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 storageLocationTouched', () => {
- /**
- * Regression tests for the round-trip bug: saving the edit modal without
- * touching the Storage Location field must NOT include storage_location in
- * the PATCH payload, so Spoolman's location field is never overwritten with
- * a stale cached value.
- */
- beforeEach(() => {
- vi.clearAllMocks();
- });
- const spoolWithStorageLocation: InventorySpool = {
- ...existingSpool,
- storage_location: 'IKEAREGAL',
- };
- 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');
- // Other fields should still be present
- expect(payload).toHaveProperty('material', 'PLA');
- });
- it('includes storage_location 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();
- });
- // Find the storage location input and change it
- const locationInput = screen.getByPlaceholderText('e.g. Shelf A, Drawer 1');
- fireEvent.change(locationInput, { target: { value: 'Shelf B' } });
- 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 be present since the user changed it
- expect(payload).toHaveProperty('storage_location', 'Shelf B');
- });
- it('includes storage_location 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];
- // storage_location MUST be included for new spools (default empty string → null)
- expect(payload).toHaveProperty('storage_location', null);
- });
- });
- 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();
- });
- });
|