Feature/spoolman inventory UI (#1241)

feat(spoolman-inventory): squashed feature work for rebase onto dev

Squashed all commits from feature/spoolman-inventory-ui onto a single commit
to enable a clean rebase onto dev. Original per-commit history preserved at
backup tag backup/spoolman-inventory-ui-prerebase-20260507-105721.
This commit is contained in:
MartinNYHC
2026-05-08 11:52:42 +02:00
committed by GitHub
parent ceffcfaef6
commit b30a283184
112 changed files with 24572 additions and 2230 deletions
@@ -0,0 +1,45 @@
import React from 'react';
import { describe, it, expect, vi } from 'vitest';
import { screen } from '@testing-library/react';
import { render } from '../utils';
import { AdditionalSection } from '../../components/spool-form/AdditionalSection';
import { defaultFormData } from '../../components/spool-form/types';
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}));
const baseProps = {
formData: defaultFormData,
updateField: vi.fn(),
spoolCatalog: [],
currencySymbol: '$',
availableCategories: [],
globalLowStockThreshold: 20,
};
describe('AdditionalSection', () => {
it('renders SpoolWeightPicker when spoolmanMode is false', () => {
render(<AdditionalSection {...baseProps} spoolmanMode={false} />);
// SpoolWeightPicker renders the 'inventory.coreWeight' label
expect(screen.getByText('inventory.coreWeight')).toBeTruthy();
// Info notice must NOT be present
expect(screen.queryByText('inventory.spoolWeightManagedBySpoolman')).toBeNull();
});
it('hides SpoolWeightPicker and shows info notice when spoolmanMode is true', () => {
render(<AdditionalSection {...baseProps} spoolmanMode={true} />);
// Info notice must appear
expect(screen.getByText('inventory.spoolWeightManagedBySpoolman')).toBeTruthy();
// SpoolWeightPicker must NOT be rendered
expect(screen.queryByText('inventory.coreWeight')).toBeNull();
});
it('defaults to spoolmanMode=false when prop is omitted', () => {
render(<AdditionalSection {...baseProps} />);
// SpoolWeightPicker present by default
expect(screen.getByText('inventory.coreWeight')).toBeTruthy();
});
});
@@ -9,6 +9,7 @@ vi.mock('../../api/client', () => ({
getSpools: vi.fn(),
getAssignments: vi.fn(),
assignSpool: vi.fn(),
getSpoolmanInventorySpools: vi.fn(),
getSettings: vi.fn().mockResolvedValue({}),
getAuthStatus: vi.fn().mockResolvedValue({ auth_enabled: false }),
},
@@ -285,3 +286,73 @@ describe('AssignSpoolModal', () => {
});
});
});
describe('AssignSpoolModal — Spoolman enabled (T-Gap 7)', () => {
const spoolmanSpool = {
id: 200,
material: 'PETG',
subtype: 'HF',
brand: 'Bambu Lab',
color_name: 'Blue',
rgba: '0000FFFF',
label_weight: 1000,
weight_used: 0,
tag_uid: null,
tray_uuid: null,
archived_at: null,
};
beforeEach(() => {
vi.clearAllMocks();
(api.getSpools as ReturnType<typeof vi.fn>).mockResolvedValue([]);
(api.getAssignments as ReturnType<typeof vi.fn>).mockResolvedValue([]);
(api.getSpoolmanInventorySpools as ReturnType<typeof vi.fn>).mockResolvedValue([spoolmanSpool]);
});
it('shows Spoolman spool section when spoolmanEnabled=true', async () => {
render(<AssignSpoolModal {...defaultProps} spoolmanEnabled />);
await waitFor(() => {
// Spoolman spool brand should appear in the modal
expect(screen.getByText(/Bambu Lab/)).toBeInTheDocument();
});
expect(api.getSpoolmanInventorySpools).toHaveBeenCalledWith(false);
});
it('does not fetch Spoolman spools when spoolmanEnabled=false', async () => {
render(<AssignSpoolModal {...defaultProps} spoolmanEnabled={false} />);
// Give the component time to settle
await waitFor(() => {
expect(api.getSpools).toHaveBeenCalled();
});
expect(api.getSpoolmanInventorySpools).not.toHaveBeenCalled();
});
it('hides local spool list when spoolmanEnabled=true (Bug #5)', async () => {
// Even when local spools exist, they must not appear in Spoolman mode.
(api.getSpools as ReturnType<typeof vi.fn>).mockResolvedValue([manualSpool]);
render(<AssignSpoolModal {...defaultProps} spoolmanEnabled />);
await waitFor(() => {
// Spoolman spool is shown
expect(screen.getByText(/Bambu Lab/)).toBeInTheDocument();
});
// Local spool (Polymaker) must NOT appear in Spoolman mode
expect(screen.queryByText(/Polymaker/)).not.toBeInTheDocument();
});
it('hides archived Spoolman spools', async () => {
const archivedSpool = { ...spoolmanSpool, id: 201, brand: 'Prusa', archived_at: '2025-01-01T00:00:00Z' };
(api.getSpoolmanInventorySpools as ReturnType<typeof vi.fn>).mockResolvedValue([spoolmanSpool, archivedSpool]);
render(<AssignSpoolModal {...defaultProps} spoolmanEnabled />);
await waitFor(() => {
expect(screen.getByText(/Bambu Lab/)).toBeInTheDocument();
});
// Archived spool brand must NOT appear
expect(screen.queryByText(/Prusa/)).not.toBeInTheDocument();
});
});
@@ -0,0 +1,251 @@
/**
* Tests for AssignToAmsModal — verifies that spoolmanMode prop
* routes to assignSpoolmanSlot (not assignSpool) when assigning.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { render } from '../utils';
vi.mock('../../api/client', () => ({
api: {
getPrinterStatus: vi.fn(),
getPrinter: vi.fn(),
getSettings: vi.fn().mockResolvedValue({}),
assignSpool: vi.fn(),
assignSpoolmanSlot: vi.fn(),
getAuthStatus: vi.fn().mockResolvedValue({ auth_enabled: false }),
getAssignments: vi.fn().mockResolvedValue([]),
getSpoolmanSlotAssignments: vi.fn().mockResolvedValue([]),
},
}));
import { AssignToAmsModal } from '../../components/spoolbuddy/AssignToAmsModal';
import { api } from '../../api/client';
const SPOOL = {
id: 42,
material: 'PLA',
subtype: 'Basic',
brand: 'BrandX',
color_name: 'Red',
rgba: 'FF0000FF',
label_weight: 1000,
weight_used: 0,
tag_uid: null,
tray_uuid: null,
slicer_filament_name: 'PLA',
data_origin: 'spoolman',
k_profiles: [],
};
const BLANK_TRAY = {
tray_color: null,
tray_type: null,
tray_sub_brands: null,
tray_id_name: null,
tray_info_idx: null,
remain: 100,
k: null,
cali_idx: null,
tag_uid: null,
tray_uuid: null,
nozzle_temp_min: null,
nozzle_temp_max: null,
drying_temp: null,
drying_time: null,
state: null,
};
const PRINTER_STATUS_ONLINE = {
connected: true,
state: 'idle',
ams: [
{
id: 0,
humidity: null,
temp: null,
is_ams_ht: false,
serial_number: '',
sw_ver: '',
dry_time: 0,
dry_status: 0,
dry_sub_status: 0,
tray: [
{ id: 0, ...BLANK_TRAY },
{ id: 1, ...BLANK_TRAY },
{ id: 2, ...BLANK_TRAY },
{ id: 3, ...BLANK_TRAY },
],
},
],
nozzles: [{ nozzle_diameter: '0.4', nozzle_type: 'stainless' }],
ams_extruder_map: { '0': 0 },
dual_nozzle: false,
};
describe('AssignToAmsModal', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(api.getPrinterStatus).mockResolvedValue(PRINTER_STATUS_ONLINE as never);
vi.mocked(api.getPrinter).mockResolvedValue({ id: 1, name: 'Test Printer' } as never);
vi.mocked(api.assignSpool).mockResolvedValue({} as never);
vi.mocked(api.assignSpoolmanSlot).mockResolvedValue({} as never);
});
it('renders modal when open', async () => {
render(
<AssignToAmsModal
isOpen={true}
onClose={vi.fn()}
spool={SPOOL as never}
printerId={1}
spoolmanMode={false}
/>
);
await waitFor(() => {
expect(screen.getByText(/Assign.*to AMS/i)).toBeInTheDocument();
});
});
it('renders nothing when closed', () => {
render(
<AssignToAmsModal
isOpen={false}
onClose={vi.fn()}
spool={SPOOL as never}
printerId={1}
spoolmanMode={false}
/>
);
expect(screen.queryByText(/Assign.*to AMS/i)).not.toBeInTheDocument();
});
describe('API routing based on spoolmanMode', () => {
it('calls assignSpool when spoolmanMode is false', async () => {
const user = userEvent.setup();
render(
<AssignToAmsModal
isOpen={true}
onClose={vi.fn()}
spool={SPOOL as never}
printerId={1}
spoolmanMode={false}
/>
);
await waitFor(() => {
expect(screen.queryAllByTitle(/AMS Slot/i).length).toBeGreaterThan(0);
});
// Click first available slot button
const slotButtons = screen.queryAllByTitle(/AMS Slot/i);
await user.click(slotButtons[0]);
await waitFor(() => {
expect(api.assignSpool).toHaveBeenCalledWith(
expect.objectContaining({ spool_id: 42, printer_id: 1 })
);
});
expect(api.assignSpoolmanSlot).not.toHaveBeenCalled();
});
it('calls assignSpoolmanSlot when spoolmanMode is true', async () => {
const user = userEvent.setup();
render(
<AssignToAmsModal
isOpen={true}
onClose={vi.fn()}
spool={SPOOL as never}
printerId={1}
spoolmanMode={true}
/>
);
await waitFor(() => {
expect(screen.queryAllByTitle(/AMS Slot/i).length).toBeGreaterThan(0);
});
const slotButtons = screen.queryAllByTitle(/AMS Slot/i);
await user.click(slotButtons[0]);
await waitFor(() => {
expect(api.assignSpoolmanSlot).toHaveBeenCalledWith(
expect.objectContaining({ spoolman_spool_id: 42, printer_id: 1 })
);
});
expect(api.assignSpool).not.toHaveBeenCalled();
});
});
describe('query invalidation after successful assign', () => {
it('calls assignSpoolmanSlot successfully — invalidation fires on success', async () => {
const user = userEvent.setup();
vi.mocked(api.assignSpoolmanSlot).mockResolvedValue({} as never);
render(
<AssignToAmsModal
isOpen={true}
onClose={vi.fn()}
spool={SPOOL as never}
printerId={1}
spoolmanMode={true}
/>
);
await waitFor(() => {
expect(screen.queryAllByTitle(/AMS Slot/i).length).toBeGreaterThan(0);
});
const slotButtons = screen.queryAllByTitle(/AMS Slot/i);
await user.click(slotButtons[0]);
await waitFor(() => {
expect(api.assignSpoolmanSlot).toHaveBeenCalledWith(
expect.objectContaining({ spoolman_spool_id: 42, printer_id: 1 })
);
});
});
});
describe('slot highlighting', () => {
it('shows no ring-bambu-green when spool is not assigned', async () => {
vi.mocked(api.getSpoolmanSlotAssignments).mockResolvedValue([]);
const { container } = render(
<AssignToAmsModal
isOpen={true}
onClose={vi.fn()}
spool={SPOOL as never}
printerId={1}
spoolmanMode={true}
/>
);
await waitFor(() => {
expect(screen.queryAllByRole('button').length).toBeGreaterThan(0);
});
expect(container.querySelector('.ring-bambu-green')).toBeNull();
});
it('highlights assigned slot with ring-bambu-green when spool is assigned to tray 2', async () => {
vi.mocked(api.getSpoolmanSlotAssignments).mockResolvedValue([
{ printer_id: 1, ams_id: 0, tray_id: 2, spoolman_spool_id: 42 },
] as never);
const { container } = render(
<AssignToAmsModal
isOpen={true}
onClose={vi.fn()}
spool={SPOOL as never}
printerId={1}
spoolmanMode={true}
/>
);
await waitFor(() => {
expect(container.querySelector('.ring-bambu-green')).toBeInTheDocument();
});
});
});
});
@@ -74,63 +74,44 @@ describe('FilamentHoverCard', () => {
});
});
describe('Spoolman source indicator', () => {
it('shows Spoolman label when fillSource is spoolman', async () => {
describe('fill source badge transparency (#11)', () => {
it('never shows a Spoolman-source badge — UI stays mode-agnostic', async () => {
renderWithHover(
<FilamentHoverCard data={{ ...baseFilamentData, fillLevel: 80, fillSource: 'spoolman' }}>
<div>trigger</div>
</FilamentHoverCard>
);
vi.advanceTimersByTime(100);
await waitFor(() => {
expect(screen.getByText('(Spoolman)')).toBeInTheDocument();
});
});
it('does not show Spoolman label when fillSource is ams', async () => {
renderWithHover(
<FilamentHoverCard data={{ ...baseFilamentData, fillLevel: 80, fillSource: 'ams' }}>
<div>trigger</div>
</FilamentHoverCard>
);
vi.advanceTimersByTime(100);
await waitFor(() => {
expect(screen.getByText('80%')).toBeInTheDocument();
expect(screen.queryByText('(Spoolman)')).not.toBeInTheDocument();
});
});
it('does not show Spoolman label when fillLevel is null', async () => {
it('never shows an inventory-source badge — UI stays mode-agnostic', async () => {
renderWithHover(
<FilamentHoverCard data={{ ...baseFilamentData, fillLevel: 80, fillSource: 'inventory' }}>
<div>trigger</div>
</FilamentHoverCard>
);
vi.advanceTimersByTime(100);
await waitFor(() => {
expect(screen.getByText('80%')).toBeInTheDocument();
expect(screen.queryByText('(Inv)')).not.toBeInTheDocument();
});
});
it('does not render an empty source-label span when fillLevel is null', async () => {
renderWithHover(
<FilamentHoverCard data={{ ...baseFilamentData, fillLevel: null, fillSource: 'spoolman' }}>
<div>trigger</div>
</FilamentHoverCard>
);
vi.advanceTimersByTime(100);
await waitFor(() => {
expect(screen.getByText('—')).toBeInTheDocument();
expect(screen.queryByText('(Spoolman)')).not.toBeInTheDocument();
});
});
it('does not show Spoolman label when fillSource is undefined', async () => {
renderWithHover(
<FilamentHoverCard data={{ ...baseFilamentData, fillLevel: 50 }}>
<div>trigger</div>
</FilamentHoverCard>
);
vi.advanceTimersByTime(100);
await waitFor(() => {
expect(screen.getByText('50%')).toBeInTheDocument();
expect(screen.queryByText('(Spoolman)')).not.toBeInTheDocument();
expect(screen.queryByText('(Inv)')).not.toBeInTheDocument();
});
});
});
@@ -231,6 +212,104 @@ describe('FilamentHoverCard', () => {
expect(screen.getByText(/assign/i)).toBeInTheDocument();
});
});
it('shows the assign-spool button as disabled when isAssigned=true', async () => {
const onAssign = vi.fn();
renderWithHover(
<FilamentHoverCard
data={{ ...baseFilamentData, vendor: 'Bambu Lab' }}
inventory={{ assignedSpool: null, onAssignSpool: onAssign, isAssigned: true }}
>
<div>trigger</div>
</FilamentHoverCard>
);
vi.advanceTimersByTime(100);
await waitFor(() => {
expect(screen.getByText(/assign/i)).toBeInTheDocument();
expect(screen.getByText(/assign/i).closest('button')).toBeDisabled();
});
});
it('does not call onAssignSpool when the button is disabled via isAssigned', async () => {
const onAssign = vi.fn();
renderWithHover(
<FilamentHoverCard
data={{ ...baseFilamentData, vendor: 'Bambu Lab' }}
inventory={{ assignedSpool: null, onAssignSpool: onAssign, isAssigned: true }}
>
<div>trigger</div>
</FilamentHoverCard>
);
vi.advanceTimersByTime(100);
await waitFor(() => expect(screen.getByText(/assign/i)).toBeInTheDocument());
const btn = screen.getByText(/assign/i).closest('button')!;
btn.click();
expect(onAssign).not.toHaveBeenCalled();
});
});
// For RFID-synced BL spools, both spoolman.linkedSpoolId and
// inventory.assignedSpool.id point to the same Spoolman spool. Rendering
// both branches gave two identical "Open in Inventory" buttons. The
// inventory-side button is suppressed when it would duplicate the
// spoolman-side link.
describe('"Open in Inventory" deduplication', () => {
const inventorySpool = {
id: 42,
material: 'PLA',
brand: 'eSun',
color_name: 'Black',
};
it('shows only one Open in Inventory button when spoolman linkedSpoolId matches assignedSpool id', async () => {
renderWithHover(
<FilamentHoverCard
data={baseFilamentData}
spoolman={{ enabled: true, linkedSpoolId: 42 }}
inventory={{ assignedSpool: inventorySpool }}
>
<div>trigger</div>
</FilamentHoverCard>
);
vi.advanceTimersByTime(100);
await waitFor(() => {
expect(screen.getByText(/assigned/i)).toBeInTheDocument();
});
expect(screen.queryAllByTitle('Open in Inventory')).toHaveLength(1);
});
it('shows two Open in Inventory buttons when spoolman and inventory point to different spools', async () => {
renderWithHover(
<FilamentHoverCard
data={baseFilamentData}
spoolman={{ enabled: true, linkedSpoolId: 99 }}
inventory={{ assignedSpool: inventorySpool }}
>
<div>trigger</div>
</FilamentHoverCard>
);
vi.advanceTimersByTime(100);
await waitFor(() => {
expect(screen.getByText(/assigned/i)).toBeInTheDocument();
});
expect(screen.queryAllByTitle('Open in Inventory')).toHaveLength(2);
});
it('shows one Open in Inventory button when spoolman is absent but inventory spool is assigned', async () => {
renderWithHover(
<FilamentHoverCard
data={baseFilamentData}
inventory={{ assignedSpool: inventorySpool }}
>
<div>trigger</div>
</FilamentHoverCard>
);
vi.advanceTimersByTime(100);
await waitFor(() => {
expect(screen.getByText(/assigned/i)).toBeInTheDocument();
});
expect(screen.queryAllByTitle('Open in Inventory')).toHaveLength(1);
});
});
});
@@ -246,7 +325,7 @@ describe('EmptySlotHoverCard (#1133)', () => {
vi.useFakeTimers({ shouldAdvanceTime: true });
});
it('does not render an assign-spool affordance', async () => {
it('does not render an assign-spool button when onAssignSpool is not provided', async () => {
const result = render(
<EmptySlotHoverCard configureSlot={{ enabled: true, onConfigure: vi.fn() }}>
<div>trigger</div>
@@ -259,7 +338,7 @@ describe('EmptySlotHoverCard (#1133)', () => {
// a card that simply never opened.
expect(screen.getByText(/empty/i)).toBeInTheDocument();
});
expect(screen.queryByText(/assign/i)).not.toBeInTheDocument();
expect(screen.queryByText(/assign spool/i)).not.toBeInTheDocument();
});
it('still shows the configure button on an empty slot', async () => {
@@ -275,4 +354,32 @@ describe('EmptySlotHoverCard (#1133)', () => {
expect(screen.getByText(/configure/i)).toBeInTheDocument();
});
});
it('shows Assign Spool button when onAssignSpool is provided', async () => {
const onAssign = vi.fn();
const result = render(
<EmptySlotHoverCard onAssignSpool={onAssign}>
<div>trigger</div>
</EmptySlotHoverCard>
);
fireEvent.mouseEnter(result.container.firstElementChild as HTMLElement);
vi.advanceTimersByTime(100);
await waitFor(() => {
expect(screen.getByText(/assign spool/i)).toBeInTheDocument();
});
});
it('calls onAssignSpool when Assign Spool button is clicked', async () => {
const onAssign = vi.fn();
const result = render(
<EmptySlotHoverCard onAssignSpool={onAssign}>
<div>trigger</div>
</EmptySlotHoverCard>
);
fireEvent.mouseEnter(result.container.firstElementChild as HTMLElement);
vi.advanceTimersByTime(100);
await waitFor(() => expect(screen.getByText(/assign spool/i)).toBeInTheDocument());
fireEvent.click(screen.getByText(/assign spool/i));
expect(onAssign).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,122 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { screen, fireEvent } from '@testing-library/react';
import { render } from '../utils';
import { InventorySpoolInfoCard } from '../../components/spoolbuddy/InventorySpoolInfoCard';
import type { InventorySpool } from '../../api/client';
vi.mock('../../api/client', () => ({
api: {
getSettings: vi.fn().mockResolvedValue({}),
getAuthStatus: vi.fn().mockResolvedValue({ auth_enabled: false }),
getSpoolKProfiles: vi.fn().mockResolvedValue([]),
},
spoolbuddyApi: {
updateSpoolWeight: vi.fn().mockResolvedValue({ status: 'ok', weight_used: 300 }),
},
}));
const mockSpool: InventorySpool = {
id: 42,
material: 'PLA',
subtype: 'Matte',
color_name: 'Jade White',
rgba: 'E8F5E9FF',
extra_colors: null,
effect_type: null,
brand: 'Bambu',
label_weight: 1000,
core_weight: 250,
core_weight_catalog_id: null,
weight_used: 200,
slicer_filament: null,
slicer_filament_name: null,
nozzle_temp_min: null,
nozzle_temp_max: null,
note: null,
added_full: null,
last_used: null,
encode_time: null,
tag_uid: 'AABBCCDD11223344',
tray_uuid: null,
data_origin: 'local',
tag_type: null,
archived_at: null,
created_at: '2024-01-01T00:00:00Z',
updated_at: '2024-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: [],
};
describe('InventorySpoolInfoCard', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('disables "Assign to AMS" button when isAssigned=true', () => {
render(
<InventorySpoolInfoCard
spool={mockSpool}
liveScaleWeight={null}
onAssignToAms={vi.fn()}
isAssigned
/>
);
expect(screen.getByText('Assign to AMS')).toBeDisabled();
});
it('enables "Assign to AMS" button when isAssigned=false', () => {
render(
<InventorySpoolInfoCard
spool={mockSpool}
liveScaleWeight={null}
onAssignToAms={vi.fn()}
isAssigned={false}
/>
);
expect(screen.getByText('Assign to AMS')).not.toBeDisabled();
});
it('shows Unassign button when isAssigned=true and onUnassignFromAms is provided', () => {
render(
<InventorySpoolInfoCard
spool={mockSpool}
liveScaleWeight={null}
onAssignToAms={vi.fn()}
isAssigned
onUnassignFromAms={vi.fn()}
/>
);
expect(screen.getByText(/unassign/i)).toBeInTheDocument();
});
it('does not show Unassign button when onUnassignFromAms is not provided', () => {
render(
<InventorySpoolInfoCard
spool={mockSpool}
liveScaleWeight={null}
onAssignToAms={vi.fn()}
isAssigned
/>
);
expect(screen.queryByText(/unassign/i)).not.toBeInTheDocument();
});
it('calls onUnassignFromAms when Unassign button is clicked', () => {
const onUnassign = vi.fn();
render(
<InventorySpoolInfoCard
spool={mockSpool}
liveScaleWeight={null}
onAssignToAms={vi.fn()}
isAssigned
onUnassignFromAms={onUnassign}
/>
);
fireEvent.click(screen.getByText(/unassign/i));
expect(onUnassign).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,473 @@
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 { SpoolCatalogSettings } from '../../components/SpoolCatalogSettings';
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string, fallback?: string) => fallback ?? key,
}),
}));
const mockShowToast = vi.fn();
vi.mock('../../contexts/ToastContext', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../contexts/ToastContext')>();
return { ...actual, useToast: () => ({ showToast: mockShowToast }) };
});
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;
constructor(message: string, status: number) {
super(message);
this.status = status;
}
},
}));
import { api, ApiError } 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', () => {
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)
);
render(<SpoolCatalogSettings />);
await waitFor(() => {
expect(screen.getByText('common.add')).toBeTruthy();
});
expect(screen.getByText('common.export')).toBeTruthy();
expect(screen.getByText('common.import')).toBeTruthy();
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]);
render(<SpoolCatalogSettings />);
await waitFor(() => {
expect(screen.getByText(/Bambu Lab — PLA Basic/)).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('settings.catalog.weight')).toBeTruthy();
expect(screen.getByText('settings.catalog.spoolWeight')).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);
});
});
@@ -26,8 +26,24 @@ vi.mock('../../api/client', () => ({
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([]),
},
ApiError: class ApiError extends Error {
status: number;
constructor(message: string, status: number) {
super(message);
this.status = status;
}
},
}));
@@ -417,6 +433,108 @@ describe('SpoolFormModal weightTouched', () => {
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()}
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()}
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,
@@ -460,3 +578,345 @@ describe('SpoolFormModal weightTouched', () => {
});
});
});
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}
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}
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 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}
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}
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);
});
});
@@ -92,6 +92,55 @@ describe('SpoolInfoCard', () => {
fireEvent.click(screen.getByText('Close'));
expect(onClose).toHaveBeenCalledTimes(1);
});
it('disables "Assign to AMS" button when isAssigned=true', () => {
render(
<SpoolInfoCard spool={mockSpool} scaleWeight={800} onAssignToAms={vi.fn()} isAssigned />
);
expect(screen.getByText('Assign to AMS')).toBeDisabled();
});
it('enables "Assign to AMS" button when isAssigned=false', () => {
render(
<SpoolInfoCard spool={mockSpool} scaleWeight={800} onAssignToAms={vi.fn()} isAssigned={false} />
);
expect(screen.getByText('Assign to AMS')).not.toBeDisabled();
});
it('shows Unassign button when isAssigned=true and onUnassignFromAms is provided', () => {
render(
<SpoolInfoCard
spool={mockSpool}
scaleWeight={800}
onAssignToAms={vi.fn()}
isAssigned
onUnassignFromAms={vi.fn()}
/>
);
expect(screen.getByText(/unassign/i)).toBeInTheDocument();
});
it('does not show Unassign button when onUnassignFromAms is not provided', () => {
render(
<SpoolInfoCard spool={mockSpool} scaleWeight={800} onAssignToAms={vi.fn()} isAssigned />
);
expect(screen.queryByText(/unassign/i)).not.toBeInTheDocument();
});
it('calls onUnassignFromAms when Unassign button is clicked', () => {
const onUnassign = vi.fn();
render(
<SpoolInfoCard
spool={mockSpool}
scaleWeight={800}
onAssignToAms={vi.fn()}
isAssigned
onUnassignFromAms={onUnassign}
/>
);
fireEvent.click(screen.getByText(/unassign/i));
expect(onUnassign).toHaveBeenCalledTimes(1);
});
});
describe('UnknownTagCard', () => {
@@ -0,0 +1,97 @@
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();
});
});
@@ -0,0 +1,188 @@
import React from 'react';
import { describe, it, expect, vi } from 'vitest';
import { screen, fireEvent } from '@testing-library/react';
import { render } from '../utils';
import { SpoolmanFilamentPicker } from '../../components/spool-form/SpoolmanFilamentPicker';
import type { SpoolmanFilamentEntry } from '../../api/client';
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string, fallback?: string) => fallback ?? key,
}),
}));
const FILAMENTS: SpoolmanFilamentEntry[] = [
{
id: 1,
name: 'PLA Basic',
material: 'PLA',
color_hex: 'FF0000',
color_name: 'Red',
weight: 1000,
spool_weight: 196,
vendor: { id: 1, name: 'Bambu Lab' },
},
{
id: 2,
name: 'PETG',
material: 'PETG',
color_hex: '00FF00',
color_name: 'Green',
weight: 1000,
spool_weight: null,
vendor: { id: 2, name: 'Bambu Lab' },
},
{
id: 3,
name: 'ABS Basic',
material: 'ABS',
color_hex: null,
color_name: null,
weight: 1000,
spool_weight: 250,
vendor: null,
},
];
function openDropdown() {
const trigger = screen.getByRole('button', { name: /spoolman/i });
fireEvent.click(trigger);
}
describe('SpoolmanFilamentPicker', () => {
it('renders the trigger button with catalog label when nothing selected', () => {
render(
<SpoolmanFilamentPicker
filaments={FILAMENTS}
isLoading={false}
selectedId={null}
onSelect={vi.fn()}
/>
);
expect(screen.getByText('inventory.spoolmanFilamentCatalog')).toBeTruthy();
});
it('shows all filaments when dropdown is opened', () => {
render(
<SpoolmanFilamentPicker
filaments={FILAMENTS}
isLoading={false}
selectedId={null}
onSelect={vi.fn()}
/>
);
openDropdown();
expect(screen.getByText(/Bambu Lab — PLA Basic/)).toBeTruthy();
expect(screen.getByText(/Bambu Lab — PETG/)).toBeTruthy();
expect(screen.getByText(/ABS Basic/)).toBeTruthy();
});
it('filters by search term (material)', () => {
render(
<SpoolmanFilamentPicker
filaments={FILAMENTS}
isLoading={false}
selectedId={null}
onSelect={vi.fn()}
/>
);
openDropdown();
const searchInput = screen.getByPlaceholderText('inventory.pickFromSpoolmanCatalog');
fireEvent.change(searchInput, { target: { value: 'PETG' } });
expect(screen.getAllByText(/PETG/).length).toBeGreaterThan(0);
expect(screen.queryByText(/PLA Basic/)).toBeNull();
expect(screen.queryByText(/ABS Basic/)).toBeNull();
});
it('filters by vendor name', () => {
render(
<SpoolmanFilamentPicker
filaments={FILAMENTS}
isLoading={false}
selectedId={null}
onSelect={vi.fn()}
/>
);
openDropdown();
const searchInput = screen.getByPlaceholderText('inventory.pickFromSpoolmanCatalog');
fireEvent.change(searchInput, { target: { value: 'Bambu' } });
// ABS Basic has no vendor — should be filtered out
expect(screen.queryByText(/ABS Basic/)).toBeNull();
});
it('calls onSelect with the correct filament when an item is clicked', () => {
const onSelect = vi.fn();
render(
<SpoolmanFilamentPicker
filaments={FILAMENTS}
isLoading={false}
selectedId={null}
onSelect={onSelect}
/>
);
openDropdown();
fireEvent.click(screen.getByText(/Bambu Lab — PLA Basic/));
expect(onSelect).toHaveBeenCalledOnce();
expect(onSelect).toHaveBeenCalledWith(FILAMENTS[0]);
});
it('shows empty state when no filaments match search', () => {
render(
<SpoolmanFilamentPicker
filaments={FILAMENTS}
isLoading={false}
selectedId={null}
onSelect={vi.fn()}
/>
);
openDropdown();
const searchInput = screen.getByPlaceholderText('inventory.pickFromSpoolmanCatalog');
fireEvent.change(searchInput, { target: { value: 'xyzzy-not-found' } });
expect(screen.getByText('inventory.noSpoolmanFilaments')).toBeTruthy();
});
it('shows loading spinner when isLoading is true', () => {
render(
<SpoolmanFilamentPicker
filaments={[]}
isLoading={true}
selectedId={null}
onSelect={vi.fn()}
/>
);
// Trigger button shows spinner (Loader2 icon with animate-spin)
const spinners = document.querySelectorAll('.animate-spin');
expect(spinners.length).toBeGreaterThan(0);
});
it('shows selected filament name in trigger button', () => {
render(
<SpoolmanFilamentPicker
filaments={FILAMENTS}
isLoading={false}
selectedId={1}
onSelect={vi.fn()}
/>
);
// Should display the selected filament in the trigger
expect(screen.getByText(/Bambu Lab — PLA Basic/)).toBeTruthy();
});
it('filters by color_name', () => {
render(
<SpoolmanFilamentPicker
filaments={FILAMENTS}
isLoading={false}
selectedId={null}
onSelect={vi.fn()}
/>
);
openDropdown();
const searchInput = screen.getByPlaceholderText('inventory.pickFromSpoolmanCatalog');
fireEvent.change(searchInput, { target: { value: 'Green' } });
// PETG has color_name 'Green' — should match
expect(screen.getAllByText(/PETG/).length).toBeGreaterThan(0);
// PLA Basic has color_name 'Red' — should be filtered out
expect(screen.queryByText(/PLA Basic/)).toBeNull();
});
});
@@ -26,6 +26,7 @@ vi.mock('../../api/client', () => ({
disconnectSpoolman: vi.fn(),
syncAllPrintersAms: vi.fn(),
syncPrinterAms: vi.fn(),
syncSpoolmanAmsWeights: vi.fn(),
getPrinters: vi.fn(),
getAuthStatus: vi.fn().mockResolvedValue({ auth_enabled: false }),
},
@@ -319,4 +320,51 @@ describe('SpoolmanSettings', () => {
});
});
});
describe('Spoolman AMS weight sync button', () => {
beforeEach(() => {
vi.mocked(api.getSpoolmanSettings).mockResolvedValue({
spoolman_enabled: 'true',
spoolman_url: 'http://localhost:7912',
spoolman_sync_mode: 'auto',
spoolman_disable_weight_sync: 'false',
spoolman_report_partial_usage: 'true',
});
vi.mocked(api.getSpoolmanStatus).mockResolvedValue({
enabled: true,
connected: true,
url: 'http://localhost:7912',
});
vi.mocked(api.syncSpoolmanAmsWeights).mockResolvedValue({ synced: 2, skipped: 1 });
});
it('shows Spoolman AMS sync button when connected', async () => {
render(<SpoolmanSettings />);
// Text appears in both the label <p> and the button — use getAllByText
await waitFor(() => {
const elements = screen.getAllByText('Sync Spoolman Weights from AMS');
expect(elements.length).toBeGreaterThanOrEqual(1);
});
});
it('opens confirm modal when Spoolman AMS sync button clicked', async () => {
const user = userEvent.setup();
render(<SpoolmanSettings />);
// Wait for the button section to appear
await waitFor(() => {
const elements = screen.getAllByText('Sync Spoolman Weights from AMS');
expect(elements.length).toBeGreaterThanOrEqual(1);
});
// Find the button by role (not the label <p>) and click it
const syncButtons = screen.getAllByRole('button', { name: /Sync Spoolman Weights from AMS/i });
await user.click(syncButtons[0]);
await waitFor(() => {
expect(screen.getByText('Sync Spoolman Spool Weights from AMS')).toBeInTheDocument();
});
});
});
});