/**
* Tests for the SlicerBundlesPanel — Settings panel for managing
* BambuStudio Printer Preset Bundles (.bbscfg) on the slicer sidecar.
*
* Coverage:
* - Empty state when the sidecar has no bundles imported yet.
* - List rendering with summary line (process / filament counts).
* - Upload happy path → success toast + list invalidation.
* - Upload error → error toast.
* - Delete with confirmation → success toast + list invalidation.
* - Delete error → error toast.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { screen, fireEvent, waitFor } from '@testing-library/react';
import { render } from '../utils';
import { api } from '../../api/client';
import { SlicerBundlesPanel } from '../../components/SlicerBundlesPanel';
vi.mock('../../api/client', async () => {
const actual: typeof import('../../api/client') = await vi.importActual(
'../../api/client',
);
return {
...actual,
api: {
...actual.api,
listSlicerBundles: vi.fn(),
importSlicerBundle: vi.fn(),
deleteSlicerBundle: vi.fn(),
},
getAuthToken: vi.fn(() => null),
};
});
const SAMPLE_BUNDLE = {
id: 'abc123def456abcd',
printer_preset_name: '# Bambu Lab H2D 0.4 nozzle',
printer: ['# Bambu Lab H2D 0.4 nozzle'],
process: [
'# 0.20mm Standard @BBL H2D',
'# 0.16mm Standard @BBL H2D',
],
filament: [
'# Bambu PLA Basic @BBL H2D',
'# Bambu PETG HF @BBL H2D 0.4 nozzle',
'# Bambu ABS @BBL H2D',
],
version: '02.06.00.50',
};
beforeEach(() => {
vi.clearAllMocks();
});
describe('SlicerBundlesPanel — empty state', () => {
it('renders the empty-state message when no bundles exist', async () => {
vi.mocked(api.listSlicerBundles).mockResolvedValueOnce([]);
render();
await waitFor(() =>
expect(api.listSlicerBundles).toHaveBeenCalled(),
);
expect(
await screen.findByText(/no bundles imported yet/i),
).toBeInTheDocument();
});
});
describe('SlicerBundlesPanel — list rendering', () => {
it('renders bundle name + summary (process and filament counts)', async () => {
vi.mocked(api.listSlicerBundles).mockResolvedValueOnce([SAMPLE_BUNDLE]);
render();
expect(
await screen.findByText('# Bambu Lab H2D 0.4 nozzle'),
).toBeInTheDocument();
// Summary should reflect 2 process + 3 filament from the fixture.
expect(
await screen.findByText(/2 process · 3 filament/i),
).toBeInTheDocument();
// Version suffix appended after the summary.
expect(screen.getByText(/v02\.06\.00\.50/)).toBeInTheDocument();
});
});
describe('SlicerBundlesPanel — upload flow', () => {
it('imports a selected file and refreshes the list on success', async () => {
// First listing call returns empty so the test can detect the post-import
// re-fetch (second call) returning the new bundle.
vi.mocked(api.listSlicerBundles)
.mockResolvedValueOnce([])
.mockResolvedValueOnce([SAMPLE_BUNDLE]);
vi.mocked(api.importSlicerBundle).mockResolvedValueOnce(SAMPLE_BUNDLE);
const { container } = render();
// The file input is hidden (display: none for styling); grab it directly.
const fileInput = container.querySelector(
'input[type="file"]',
) as HTMLInputElement;
expect(fileInput).toBeTruthy();
const file = new File([new Uint8Array([0x50, 0x4b, 0x03, 0x04])], 'H2D.bbscfg', {
type: 'application/zip',
});
fireEvent.change(fileInput, { target: { files: [file] } });
await waitFor(() =>
expect(api.importSlicerBundle).toHaveBeenCalledWith(file),
);
// After the success, the list call should fire a second time (cache
// invalidation by react-query).
await waitFor(() =>
expect(api.listSlicerBundles).toHaveBeenCalledTimes(2),
);
// The newly imported bundle should now be visible in the list.
expect(
await screen.findByText('# Bambu Lab H2D 0.4 nozzle'),
).toBeInTheDocument();
});
it('shows an error and does not refresh on upload failure', async () => {
vi.mocked(api.listSlicerBundles).mockResolvedValueOnce([]);
vi.mocked(api.importSlicerBundle).mockRejectedValueOnce(
new Error('Bundle is missing bundle_structure.json'),
);
const { container } = render();
await waitFor(() => expect(api.listSlicerBundles).toHaveBeenCalled());
const fileInput = container.querySelector(
'input[type="file"]',
) as HTMLInputElement;
const file = new File([new Uint8Array([0])], 'bad.bbscfg', {
type: 'application/zip',
});
fireEvent.change(fileInput, { target: { files: [file] } });
await waitFor(() =>
expect(api.importSlicerBundle).toHaveBeenCalled(),
);
// Listing should NOT be re-called on failure — only the initial load.
expect(api.listSlicerBundles).toHaveBeenCalledTimes(1);
// Empty state still showing.
expect(
screen.getByText(/no bundles imported yet/i),
).toBeInTheDocument();
});
});
describe('SlicerBundlesPanel — delete flow', () => {
it('deletes a bundle after confirmation and refreshes the list', async () => {
vi.mocked(api.listSlicerBundles)
.mockResolvedValueOnce([SAMPLE_BUNDLE])
.mockResolvedValueOnce([]);
vi.mocked(api.deleteSlicerBundle).mockResolvedValueOnce(undefined);
render();
// Wait for the bundle to render.
await screen.findByText('# Bambu Lab H2D 0.4 nozzle');
// Click the trash button (aria-label="Delete").
fireEvent.click(screen.getByRole('button', { name: /delete/i }));
// ConfirmModal should appear with the bundle name in the message.
const confirmMessage = await screen.findByText(
/Slice requests that reference "# Bambu Lab H2D 0.4 nozzle" will fail/i,
);
expect(confirmMessage).toBeInTheDocument();
// The modal renders its own "Delete" button — there are now two buttons
// matching /delete/i. Click the one inside the dialog (last in document
// order, since the modal portal renders after the panel).
const deleteButtons = screen.getAllByRole('button', { name: /delete/i });
fireEvent.click(deleteButtons[deleteButtons.length - 1]);
await waitFor(() =>
expect(api.deleteSlicerBundle).toHaveBeenCalledWith(
'abc123def456abcd',
),
);
// Cache invalidation should re-fire the list query.
await waitFor(() =>
expect(api.listSlicerBundles).toHaveBeenCalledTimes(2),
);
});
it('keeps the bundle in the list when the user cancels the delete dialog', async () => {
vi.mocked(api.listSlicerBundles).mockResolvedValueOnce([SAMPLE_BUNDLE]);
render();
await screen.findByText('# Bambu Lab H2D 0.4 nozzle');
fireEvent.click(screen.getByRole('button', { name: /delete/i }));
// Cancel by clicking the "Cancel" button on the ConfirmModal.
const cancelButton = await screen.findByRole('button', { name: /cancel/i });
fireEvent.click(cancelButton);
// Delete API never called, list never re-fetched.
expect(api.deleteSlicerBundle).not.toHaveBeenCalled();
expect(api.listSlicerBundles).toHaveBeenCalledTimes(1);
// Bundle still rendered.
expect(
screen.getByText('# Bambu Lab H2D 0.4 nozzle'),
).toBeInTheDocument();
});
});