mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-05 21:51:23 +02:00
1184 lines
36 KiB
TypeScript
1184 lines
36 KiB
TypeScript
/**
|
|
* 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();
|
|
});
|
|
});
|