| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119 |
- /**
- * 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);
- });
- });
|