| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393 |
- /**
- * 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: [] }),
- getPrinters: vi.fn().mockResolvedValue([]),
- getSpoolUsageHistory: vi.fn().mockResolvedValue([]),
- createSpool: vi.fn().mockResolvedValue({ id: 99 }),
- updateSpool: vi.fn().mockResolvedValue({ id: 1 }),
- saveSpoolKProfiles: vi.fn().mockResolvedValue([]),
- },
- }));
- // 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',
- 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}
- 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}
- 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}
- 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}
- 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}
- 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('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}
- 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();
- });
- });
- });
|