mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-22 03:49:18 +02:00
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:
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user