refactor(settings): Spool Catalog now identical in internal and Spoolman modes

Drop the Spoolman-mode hijack that replaced the local spool tare catalog
  with an inline filament editor — two unrelated concepts that should never
  have shared a card. Spool Catalog now renders the same way in both modes;
  Spoolman users edit filament name and spool_weight in Spoolman's own UI.

  Also eliminates the GET /spoolman/inventory/filaments 400 probe that fired
  on the Filament settings page whenever Spoolman was disabled.

  - frontend/src/components/SpoolCatalogSettings.tsx rewritten (752 -> 444 lines)
  - frontend/src/components/SpoolWeightUpdateModal.tsx deleted (orphan)
  - SpoolCatalogSettings test file rewritten to match the simplified component
  - PATCH /spoolman/inventory/filaments/{id} backend route left in place
This commit is contained in:
maziggy
2026-05-17 13:48:05 +02:00
parent bff240e90a
commit 74dcaf5142
9 changed files with 300 additions and 1149 deletions
@@ -1,6 +1,5 @@
import React from 'react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { screen, waitFor, fireEvent } from '@testing-library/react';
import { screen, waitFor } from '@testing-library/react';
import { render } from '../utils';
import { SpoolCatalogSettings } from '../../components/SpoolCatalogSettings';
@@ -20,17 +19,6 @@ vi.mock('../../api/client', () => ({
api: {
getSettings: vi.fn().mockResolvedValue({}),
getSpoolCatalog: vi.fn().mockResolvedValue([]),
getSpoolmanInventoryFilaments: vi.fn().mockResolvedValue([]),
patchSpoolmanFilament: vi.fn().mockResolvedValue({
id: 1,
name: 'PLA Basic',
material: 'PLA',
color_hex: 'FF0000',
color_name: 'Red',
weight: 1000,
spool_weight: 196,
vendor: { id: 1, name: 'Bambu Lab' },
}),
},
ApiError: class ApiError extends Error {
status: number;
@@ -41,87 +29,15 @@ vi.mock('../../api/client', () => ({
},
}));
import { api, ApiError } from '../../api/client';
import { api } from '../../api/client';
const sampleFilament = {
id: 1,
name: 'PLA Basic',
material: 'PLA',
color_hex: 'FF0000',
color_name: 'Red',
weight: 1000,
spool_weight: 196,
vendor: { id: 1, name: 'Bambu Lab' },
};
describe('SpoolCatalogSettings — mode switching', () => {
describe('SpoolCatalogSettings — local catalog UI', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(api.getSpoolCatalog).mockResolvedValue([]);
});
it('hides Spoolman table and shows local CRUD buttons when Spoolman is disabled (400)', async () => {
vi.mocked(api.getSpoolmanInventoryFilaments).mockRejectedValue(
new ApiError('disabled', 400)
);
render(<SpoolCatalogSettings />);
await waitFor(() => {
// Local mode: Add button visible
expect(screen.getByText('common.add')).toBeTruthy();
});
// Spoolman table columns must NOT appear
expect(screen.queryByText('settings.catalog.material')).toBeNull();
expect(screen.queryByText('settings.catalog.spoolWeight')).toBeNull();
// Spoolman catalog title must NOT appear
expect(screen.queryByText('settings.spoolmanFilamentCatalogTitle')).toBeNull();
});
it('shows Spoolman error row when Spoolman is unreachable (503)', async () => {
vi.mocked(api.getSpoolmanInventoryFilaments).mockRejectedValue(
new ApiError('unreachable', 503)
);
render(<SpoolCatalogSettings />);
await waitFor(() => {
expect(screen.getByText('inventory.spoolmanCatalogLoadFailed')).toBeTruthy();
});
// Local CRUD buttons must NOT appear in Spoolman mode
expect(screen.queryByText('common.add')).toBeNull();
});
it('shows empty state when Spoolman returns an empty list', async () => {
vi.mocked(api.getSpoolmanInventoryFilaments).mockResolvedValue([]);
render(<SpoolCatalogSettings />);
await waitFor(() => {
expect(screen.getByText('inventory.noSpoolmanFilaments')).toBeTruthy();
});
// Local CRUD buttons must NOT appear
expect(screen.queryByText('common.add')).toBeNull();
});
it('renders Spoolman filament rows with vendor and name combined', async () => {
vi.mocked(api.getSpoolmanInventoryFilaments).mockResolvedValue([sampleFilament]);
render(<SpoolCatalogSettings />);
await waitFor(() => {
expect(screen.getByText(/Bambu Lab — PLA Basic/)).toBeTruthy();
});
});
it('(local mode) shows Export, Import, Reset, Add buttons when Spoolman disabled', async () => {
vi.mocked(api.getSpoolmanInventoryFilaments).mockRejectedValue(
new ApiError('disabled', 400)
);
it('shows local CRUD buttons regardless of Spoolman state', async () => {
render(<SpoolCatalogSettings />);
await waitFor(() => {
@@ -133,341 +49,20 @@ describe('SpoolCatalogSettings — mode switching', () => {
expect(screen.getByText('common.reset')).toBeTruthy();
});
it('(spoolman mode) hides Export, Import, Reset, Add buttons when Spoolman is enabled', async () => {
vi.mocked(api.getSpoolmanInventoryFilaments).mockResolvedValue([sampleFilament]);
it('renders the local Spool Catalog header and column layout', async () => {
render(<SpoolCatalogSettings />);
await waitFor(() => {
expect(screen.getByText(/Bambu Lab — PLA Basic/)).toBeTruthy();
expect(screen.getByText('settings.catalog.spoolCatalog')).toBeTruthy();
});
expect(screen.queryByText('common.add')).toBeNull();
expect(screen.queryByText('common.export')).toBeNull();
expect(screen.queryByText('common.import')).toBeNull();
expect(screen.queryByText('common.reset')).toBeNull();
});
it('(spoolman mode) renders correct column headers — Name, Material, Weight, Spool Weight', async () => {
vi.mocked(api.getSpoolmanInventoryFilaments).mockResolvedValue([sampleFilament]);
render(<SpoolCatalogSettings />);
await waitFor(() => {
expect(screen.getByText('common.name')).toBeTruthy();
});
expect(screen.getByText('settings.catalog.material')).toBeTruthy();
expect(screen.getByText('common.name')).toBeTruthy();
expect(screen.getByText('settings.catalog.weight')).toBeTruthy();
expect(screen.getByText('settings.catalog.spoolWeight')).toBeTruthy();
});
expect(screen.getByText('settings.catalog.type')).toBeTruthy();
it('(spoolman mode) renders all data fields for a filament row', async () => {
vi.mocked(api.getSpoolmanInventoryFilaments).mockResolvedValue([sampleFilament]);
render(<SpoolCatalogSettings />);
await waitFor(() => {
expect(screen.getByText(/Bambu Lab — PLA Basic/)).toBeTruthy();
});
// Material column
expect(screen.getByText('PLA')).toBeTruthy();
// Filament weight
expect(screen.getByText('1000g')).toBeTruthy();
// Spool (empty) weight
expect(screen.getByText('196g')).toBeTruthy();
});
it('(spoolman mode) renders color swatch with correct background color', async () => {
vi.mocked(api.getSpoolmanInventoryFilaments).mockResolvedValue([
{ ...sampleFilament, color_hex: 'FF5500' },
]);
render(<SpoolCatalogSettings />);
await waitFor(() => {
expect(screen.getByText(/Bambu Lab — PLA Basic/)).toBeTruthy();
});
const swatch = screen.getByLabelText('inventory.spoolmanFilamentColorSwatch');
const bg = (swatch as HTMLElement).style.backgroundColor;
// Accepts both hex-like and rgb() representations
expect(bg).toBeTruthy();
expect(bg).not.toBe('');
});
it('(spoolman mode) renders fallback color when color_hex is null', async () => {
vi.mocked(api.getSpoolmanInventoryFilaments).mockResolvedValue([
{ ...sampleFilament, color_hex: null },
]);
render(<SpoolCatalogSettings />);
await waitFor(() => {
expect(screen.getByText(/Bambu Lab — PLA Basic/)).toBeTruthy();
});
const swatch = screen.getByLabelText('inventory.spoolmanFilamentColorSwatch');
expect((swatch as HTMLElement).style.backgroundColor).toContain('128');
});
it('(spoolman mode) renders dash for null material, weight, and spool_weight', async () => {
vi.mocked(api.getSpoolmanInventoryFilaments).mockResolvedValue([
{ ...sampleFilament, material: null, weight: null, spool_weight: null },
]);
render(<SpoolCatalogSettings />);
await waitFor(() => {
expect(screen.getByText(/Bambu Lab — PLA Basic/)).toBeTruthy();
});
// All three nullable fields must show '—', not 'nullg' or empty string
const dashes = screen.getAllByText('—');
expect(dashes.length).toBeGreaterThanOrEqual(3);
});
it('(spoolman mode) shows Spoolman catalog title, not local catalog title', async () => {
vi.mocked(api.getSpoolmanInventoryFilaments).mockResolvedValue([sampleFilament]);
render(<SpoolCatalogSettings />);
await waitFor(() => {
expect(screen.getByText('settings.spoolmanFilamentCatalogTitle')).toBeTruthy();
});
expect(screen.queryByText('settings.catalog.spoolCatalog')).toBeNull();
});
it('(spoolman mode) shows pencil edit button in each filament row', async () => {
vi.mocked(api.getSpoolmanInventoryFilaments).mockResolvedValue([sampleFilament]);
render(<SpoolCatalogSettings />);
await waitFor(() => {
expect(screen.getByText(/Bambu Lab — PLA Basic/)).toBeTruthy();
});
const editButtons = screen.getAllByLabelText('common.edit');
expect(editButtons.length).toBeGreaterThanOrEqual(1);
});
it('(spoolman mode) clicking pencil shows name and weight inputs', async () => {
vi.mocked(api.getSpoolmanInventoryFilaments).mockResolvedValue([sampleFilament]);
render(<SpoolCatalogSettings />);
await waitFor(() => {
expect(screen.getByLabelText('common.edit')).toBeTruthy();
});
fireEvent.click(screen.getByLabelText('common.edit'));
await waitFor(() => {
expect(screen.getByLabelText('common.name')).toBeTruthy();
expect(screen.getByLabelText('settings.catalog.spoolWeight')).toBeTruthy();
});
});
it('(spoolman mode) name input is pre-filled with current name', async () => {
vi.mocked(api.getSpoolmanInventoryFilaments).mockResolvedValue([sampleFilament]);
render(<SpoolCatalogSettings />);
await waitFor(() => {
expect(screen.getByLabelText('common.edit')).toBeTruthy();
});
fireEvent.click(screen.getByLabelText('common.edit'));
await waitFor(() => {
const nameInput = screen.getByLabelText('common.name') as HTMLInputElement;
expect(nameInput.value).toBe('PLA Basic');
});
});
it('(spoolman mode) weight input is pre-filled with current spool_weight', async () => {
vi.mocked(api.getSpoolmanInventoryFilaments).mockResolvedValue([sampleFilament]);
render(<SpoolCatalogSettings />);
await waitFor(() => {
expect(screen.getByLabelText('common.edit')).toBeTruthy();
});
fireEvent.click(screen.getByLabelText('common.edit'));
await waitFor(() => {
const weightInput = screen.getByLabelText('settings.catalog.spoolWeight') as HTMLInputElement;
expect(weightInput.value).toBe('196');
});
});
it('(spoolman mode) cancel edit restores read-only display', async () => {
vi.mocked(api.getSpoolmanInventoryFilaments).mockResolvedValue([sampleFilament]);
render(<SpoolCatalogSettings />);
await waitFor(() => {
expect(screen.getByLabelText('common.edit')).toBeTruthy();
});
fireEvent.click(screen.getByLabelText('common.edit'));
await waitFor(() => {
expect(screen.getByLabelText('common.cancel')).toBeTruthy();
});
fireEvent.click(screen.getByLabelText('common.cancel'));
await waitFor(() => {
expect(screen.queryByLabelText('common.name')).toBeNull();
expect(screen.getByLabelText('common.edit')).toBeTruthy();
});
});
it('(spoolman mode) empty name input disables save button', async () => {
vi.mocked(api.getSpoolmanInventoryFilaments).mockResolvedValue([sampleFilament]);
render(<SpoolCatalogSettings />);
await waitFor(() => {
expect(screen.getByLabelText('common.edit')).toBeTruthy();
});
fireEvent.click(screen.getByLabelText('common.edit'));
await waitFor(() => {
expect(screen.getByLabelText('common.name')).toBeTruthy();
});
const nameInput = screen.getByLabelText('common.name');
fireEvent.change(nameInput, { target: { value: '' } });
const saveBtn = screen.getByLabelText('common.save') as HTMLButtonElement;
expect(saveBtn.disabled).toBe(true);
});
it('(spoolman mode) saving name-only calls patchSpoolmanFilament without modal', async () => {
vi.mocked(api.getSpoolmanInventoryFilaments).mockResolvedValue([sampleFilament]);
render(<SpoolCatalogSettings />);
await waitFor(() => {
expect(screen.getByLabelText('common.edit')).toBeTruthy();
});
fireEvent.click(screen.getByLabelText('common.edit'));
await waitFor(() => {
expect(screen.getByLabelText('common.name')).toBeTruthy();
});
const nameInput = screen.getByLabelText('common.name');
fireEvent.change(nameInput, { target: { value: 'PLA Basic Renamed' } });
fireEvent.click(screen.getByLabelText('common.save'));
await waitFor(() => {
expect(vi.mocked(api.patchSpoolmanFilament)).toHaveBeenCalledWith(1, { name: 'PLA Basic Renamed' });
});
// Modal must NOT appear
expect(screen.queryByText('settings.catalog.updateSpoolWeight')).toBeNull();
});
it('(spoolman mode) saving changed spool_weight opens SpoolWeightUpdateModal', async () => {
vi.mocked(api.getSpoolmanInventoryFilaments).mockResolvedValue([sampleFilament]);
render(<SpoolCatalogSettings />);
await waitFor(() => {
expect(screen.getByLabelText('common.edit')).toBeTruthy();
});
fireEvent.click(screen.getByLabelText('common.edit'));
await waitFor(() => {
expect(screen.getByLabelText('settings.catalog.spoolWeight')).toBeTruthy();
});
const weightInput = screen.getByLabelText('settings.catalog.spoolWeight');
fireEvent.change(weightInput, { target: { value: '100' } });
fireEvent.click(screen.getByLabelText('common.save'));
await waitFor(() => {
expect(screen.getByText('settings.catalog.updateSpoolWeight')).toBeTruthy();
});
});
it('(spoolman mode) confirming option B calls patchSpoolmanFilament with keep_existing_spools=false', async () => {
vi.mocked(api.getSpoolmanInventoryFilaments).mockResolvedValue([sampleFilament]);
render(<SpoolCatalogSettings />);
await waitFor(() => { expect(screen.getByLabelText('common.edit')).toBeTruthy(); });
fireEvent.click(screen.getByLabelText('common.edit'));
await waitFor(() => { expect(screen.getByLabelText('settings.catalog.spoolWeight')).toBeTruthy(); });
fireEvent.change(screen.getByLabelText('settings.catalog.spoolWeight'), { target: { value: '100' } });
fireEvent.click(screen.getByLabelText('common.save'));
await waitFor(() => { expect(screen.getByText('settings.catalog.updateSpoolWeight')).toBeTruthy(); });
// Confirm with option B selected by default
fireEvent.click(screen.getByText('common.confirm'));
await waitFor(() => {
expect(vi.mocked(api.patchSpoolmanFilament)).toHaveBeenCalledWith(
1,
expect.objectContaining({ spool_weight: 100, keep_existing_spools: false }),
);
});
});
it('(spoolman mode) confirming option A calls patchSpoolmanFilament with keep_existing_spools=true', async () => {
vi.mocked(api.getSpoolmanInventoryFilaments).mockResolvedValue([sampleFilament]);
render(<SpoolCatalogSettings />);
await waitFor(() => { expect(screen.getByLabelText('common.edit')).toBeTruthy(); });
fireEvent.click(screen.getByLabelText('common.edit'));
await waitFor(() => { expect(screen.getByLabelText('settings.catalog.spoolWeight')).toBeTruthy(); });
fireEvent.change(screen.getByLabelText('settings.catalog.spoolWeight'), { target: { value: '100' } });
fireEvent.click(screen.getByLabelText('common.save'));
await waitFor(() => { expect(screen.getByText('settings.catalog.updateSpoolWeight')).toBeTruthy(); });
// Select option A (keep existing)
const radios = screen.getAllByRole('radio');
fireEvent.click(radios[1]); // Option A = second radio = keepExisting=true
fireEvent.click(screen.getByText('common.confirm'));
await waitFor(() => {
expect(vi.mocked(api.patchSpoolmanFilament)).toHaveBeenCalledWith(
1,
expect.objectContaining({ spool_weight: 100, keep_existing_spools: true }),
);
});
});
it('(spoolman mode) negative weight input disables save button', async () => {
vi.mocked(api.getSpoolmanInventoryFilaments).mockResolvedValue([sampleFilament]);
render(<SpoolCatalogSettings />);
await waitFor(() => { expect(screen.getByLabelText('common.edit')).toBeTruthy(); });
fireEvent.click(screen.getByLabelText('common.edit'));
await waitFor(() => { expect(screen.getByLabelText('settings.catalog.spoolWeight')).toBeTruthy(); });
fireEvent.change(screen.getByLabelText('settings.catalog.spoolWeight'), { target: { value: '-5' } });
const saveBtn = screen.getByLabelText('common.save') as HTMLButtonElement;
expect(saveBtn.disabled).toBe(true);
// No Spoolman-only columns leak in
expect(screen.queryByText('settings.catalog.material')).toBeNull();
expect(screen.queryByText('settings.catalog.spoolWeight')).toBeNull();
expect(screen.queryByText('settings.spoolmanFilamentCatalogTitle')).toBeNull();
});
});
@@ -1,97 +0,0 @@
import React from 'react';
import { describe, it, expect, vi } from 'vitest';
import { screen, fireEvent } from '@testing-library/react';
import { render } from '../utils';
import { SpoolWeightUpdateModal } from '../../components/SpoolWeightUpdateModal';
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}));
const defaultProps = {
isOpen: true,
filamentName: 'PLA Basic',
oldWeight: 250,
newWeight: 196,
onConfirm: vi.fn(),
onClose: vi.fn(),
};
describe('SpoolWeightUpdateModal', () => {
it('renders filament name, old weight, and new weight', () => {
render(<SpoolWeightUpdateModal {...defaultProps} />);
expect(screen.getByText('settings.catalog.updateSpoolWeight')).toBeTruthy();
expect(screen.getByText(/PLA Basic/)).toBeTruthy();
expect(screen.getByText(/250g → 196g/)).toBeTruthy();
});
it('renders option labels', () => {
render(<SpoolWeightUpdateModal {...defaultProps} />);
expect(screen.getByText('settings.catalog.applyToAllSpools')).toBeTruthy();
expect(screen.getByText('settings.catalog.keepExistingSpoolWeight')).toBeTruthy();
});
it('option B (apply to all) is selected by default', () => {
render(<SpoolWeightUpdateModal {...defaultProps} />);
const radios = screen.getAllByRole('radio') as HTMLInputElement[];
// First radio = apply-to-all (Option B, keepExisting=false)
expect(radios[0].checked).toBe(true);
expect(radios[1].checked).toBe(false);
});
it('calls onConfirm(false) when option B is selected and Confirm clicked', () => {
const onConfirm = vi.fn();
render(<SpoolWeightUpdateModal {...defaultProps} onConfirm={onConfirm} />);
fireEvent.click(screen.getByText('common.confirm'));
expect(onConfirm).toHaveBeenCalledWith(false);
});
it('calls onConfirm(true) when option A is selected and Confirm clicked', () => {
const onConfirm = vi.fn();
render(<SpoolWeightUpdateModal {...defaultProps} onConfirm={onConfirm} />);
const radios = screen.getAllByRole('radio');
fireEvent.click(radios[1]); // Option A: keep existing
fireEvent.click(screen.getByText('common.confirm'));
expect(onConfirm).toHaveBeenCalledWith(true);
});
it('calls onClose on Cancel click', () => {
const onClose = vi.fn();
render(<SpoolWeightUpdateModal {...defaultProps} onClose={onClose} />);
fireEvent.click(screen.getByText('common.cancel'));
expect(onClose).toHaveBeenCalled();
});
it('does not call onConfirm on Cancel click', () => {
const onConfirm = vi.fn();
render(<SpoolWeightUpdateModal {...defaultProps} onConfirm={onConfirm} />);
fireEvent.click(screen.getByText('common.cancel'));
expect(onConfirm).not.toHaveBeenCalled();
});
it('renders dash when oldWeight is null', () => {
render(<SpoolWeightUpdateModal {...defaultProps} oldWeight={null} />);
expect(screen.getByText(/— → 196g/)).toBeTruthy();
});
it('returns null when isOpen is false', () => {
render(<SpoolWeightUpdateModal {...defaultProps} isOpen={false} />);
expect(screen.queryByText('settings.catalog.updateSpoolWeight')).toBeNull();
});
});