mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-07 23:01:06 +02:00
Slicing an STL via the integrated slicer always defaulted to whatever
curr_bed_type lived in the chosen process preset (typically "Cool
Plate"), which the slicer CLI rejected for high-temp filaments with
"Plate 1: Cool Plate does not support filament 1". The user had no
way to switch plates without cloning the preset in BambuStudio.
The Slice modal now exposes a Build plate dropdown with the six
canonical BambuStudio / OrcaSlicer plates (Cool Plate, Cool Plate
SuperTack, Engineering Plate, High Temp Plate, Textured PEI Plate,
Smooth PEI Plate) plus an "Auto (use process preset)" option that
preserves the previous behavior. Positioned between Process profile
and Filament rows so a long filament list never pushes it off the
modal's scrolled viewport, and always enabled regardless of whether
the user picked a Printer Preset Bundle.
A new bed_type field on SliceRequest flows through both dispatch
paths:
- Resolved-preset path: _patch_process_bed_type overwrites
curr_bed_type on the process JSON before forwarding to the sidecar.
Works end-to-end today, no sidecar change needed.
- Bundle dispatch path: slice_with_bundle adds a bedType form field
to the sidecar multipart. The sidecar (maziggy/orca-slicer-api
fork) needs a matching change to honor it as --curr_bed_type on
the CLI invocation; until then the field is silently ignored and
the slice runs with the bundle's default plate.
1201 lines
43 KiB
TypeScript
1201 lines
43 KiB
TypeScript
/**
|
|
* Tests for SliceModal.
|
|
*
|
|
* The modal handles preset selection across three tiers (cloud / local /
|
|
* standard) + enqueueing a slice job. After enqueue success it hands the
|
|
* job_id off to SliceJobTrackerProvider (which lives at app level) and
|
|
* calls onClose. Polling, toasts, and query invalidation all happen in
|
|
* the tracker — not here.
|
|
*/
|
|
|
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
import { screen, waitFor, within } from '@testing-library/react';
|
|
import userEvent from '@testing-library/user-event';
|
|
import { render } from '../utils';
|
|
import { SliceModal } from '../../components/SliceModal';
|
|
import { SliceJobTrackerProvider } from '../../contexts/SliceJobTrackerContext';
|
|
import { api, type UnifiedPresetsResponse } from '../../api/client';
|
|
|
|
vi.mock('../../api/client', () => ({
|
|
api: {
|
|
getSlicerPresets: vi.fn(),
|
|
sliceLibraryFile: vi.fn(),
|
|
sliceArchive: vi.fn(),
|
|
getSliceJob: vi.fn(),
|
|
getLibraryFilePlates: vi.fn(),
|
|
getArchivePlates: vi.fn(),
|
|
getLibraryFileFilamentRequirements: vi.fn(),
|
|
getArchiveFilamentRequirements: vi.fn(),
|
|
listSlicerBundles: vi.fn(),
|
|
getSettings: vi.fn().mockResolvedValue({}),
|
|
updateSettings: vi.fn().mockResolvedValue({}),
|
|
},
|
|
}));
|
|
|
|
const mockApi = api as unknown as {
|
|
getSlicerPresets: ReturnType<typeof vi.fn>;
|
|
sliceLibraryFile: ReturnType<typeof vi.fn>;
|
|
sliceArchive: ReturnType<typeof vi.fn>;
|
|
getSliceJob: ReturnType<typeof vi.fn>;
|
|
getLibraryFilePlates: ReturnType<typeof vi.fn>;
|
|
getArchivePlates: ReturnType<typeof vi.fn>;
|
|
getLibraryFileFilamentRequirements: ReturnType<typeof vi.fn>;
|
|
getArchiveFilamentRequirements: ReturnType<typeof vi.fn>;
|
|
listSlicerBundles: ReturnType<typeof vi.fn>;
|
|
};
|
|
|
|
function makeUnified(overrides: Partial<UnifiedPresetsResponse> = {}): UnifiedPresetsResponse {
|
|
return {
|
|
cloud: { printer: [], process: [], filament: [] },
|
|
local: { printer: [], process: [], filament: [] },
|
|
standard: { printer: [], process: [], filament: [] },
|
|
cloud_status: 'ok',
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
const fullThreeTier: UnifiedPresetsResponse = makeUnified({
|
|
cloud: {
|
|
printer: [{ id: 'PFUcloud-printer', name: 'My Custom X1C', source: 'cloud' }],
|
|
process: [{ id: 'PFUcloud-process', name: 'My 0.16mm Tweaked', source: 'cloud' }],
|
|
filament: [{ id: 'PFUcloud-filament', name: 'My PLA Black', source: 'cloud' }],
|
|
},
|
|
local: {
|
|
printer: [{ id: '1', name: 'Imported X1C 0.4', source: 'local' }],
|
|
process: [{ id: '2', name: 'Imported 0.20mm', source: 'local' }],
|
|
filament: [{ id: '3', name: 'Imported PLA Basic', source: 'local' }],
|
|
},
|
|
standard: {
|
|
printer: [{ id: 'Bambu Lab X1 Carbon 0.4 nozzle', name: 'Bambu Lab X1 Carbon 0.4 nozzle', source: 'standard' }],
|
|
process: [{ id: '0.20mm Standard', name: '0.20mm Standard', source: 'standard' }],
|
|
filament: [{ id: 'Bambu PLA Basic', name: 'Bambu PLA Basic', source: 'standard' }],
|
|
},
|
|
});
|
|
|
|
function renderWithTracker(props: Parameters<typeof SliceModal>[0]) {
|
|
return render(
|
|
<SliceJobTrackerProvider>
|
|
<SliceModal {...props} />
|
|
</SliceJobTrackerProvider>,
|
|
);
|
|
}
|
|
|
|
describe('SliceModal', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
mockApi.getSlicerPresets.mockResolvedValue(fullThreeTier);
|
|
mockApi.getSliceJob.mockResolvedValue({
|
|
job_id: 42,
|
|
status: 'running',
|
|
kind: 'library_file',
|
|
source_id: 100,
|
|
source_name: 'Cube.stl',
|
|
created_at: new Date().toISOString(),
|
|
started_at: null,
|
|
completed_at: null,
|
|
});
|
|
// Default: single-plate (or non-3MF). Multi-plate tests override this.
|
|
mockApi.getLibraryFilePlates.mockResolvedValue({
|
|
file_id: 100,
|
|
filename: 'Cube.stl',
|
|
plates: [],
|
|
is_multi_plate: false,
|
|
});
|
|
mockApi.getArchivePlates.mockResolvedValue({
|
|
archive_id: 100,
|
|
filename: 'Cube.3mf',
|
|
plates: [],
|
|
is_multi_plate: false,
|
|
});
|
|
// Default: no per-plate filament metadata available (mirrors STL or
|
|
// unsliced source). Multi-color tests override this.
|
|
mockApi.getLibraryFileFilamentRequirements.mockResolvedValue({
|
|
file_id: 100,
|
|
filename: 'Cube.stl',
|
|
plate_id: 1,
|
|
filaments: [],
|
|
});
|
|
mockApi.getArchiveFilamentRequirements.mockResolvedValue({
|
|
archive_id: 100,
|
|
filename: 'Cube.3mf',
|
|
plate_id: 1,
|
|
filaments: [],
|
|
});
|
|
// Default: no bundles imported. Bundle-tier tests override this with a
|
|
// populated array; everything else inherits the empty default so the
|
|
// modal renders the original (preset-only) layout.
|
|
mockApi.listSlicerBundles.mockResolvedValue([]);
|
|
});
|
|
|
|
it('auto-selects the highest-priority tier per slot on first load', async () => {
|
|
renderWithTracker({
|
|
source: { kind: 'libraryFile', id: 100, filename: 'Cube.stl' },
|
|
onClose: vi.fn(),
|
|
});
|
|
|
|
// SliceModal-specific tier priority: imported (local) wins over cloud
|
|
// and standard so the user's curated picks come first.
|
|
await waitFor(() => {
|
|
expect(screen.getByText('My Custom X1C')).toBeDefined();
|
|
});
|
|
// 4 selects: printer, process, bed-type (#1337), filament. bed-type sits
|
|
// between process and filament — it overrides curr_bed_type on the
|
|
// process preset so the related controls cluster — and defaults to "".
|
|
const selects = screen.getAllByRole('combobox') as HTMLSelectElement[];
|
|
expect(selects).toHaveLength(4);
|
|
expect(selects[0].value).toBe('local:1');
|
|
expect(selects[1].value).toBe('local:2');
|
|
expect(selects[2].value).toBe('');
|
|
expect(selects[3].value).toBe('local:3');
|
|
|
|
// Slice button is enabled because all three slots auto-defaulted and
|
|
// the preview-slice query has resolved (mock returns immediately).
|
|
const sliceBtn = screen.getByRole('button', { name: /^Slice$/ });
|
|
expect((sliceBtn as HTMLButtonElement).disabled).toBe(false);
|
|
});
|
|
|
|
it('renders Imported / Cloud / Standard sections via <optgroup>', async () => {
|
|
renderWithTracker({
|
|
source: { kind: 'libraryFile', id: 100, filename: 'Cube.stl' },
|
|
onClose: vi.fn(),
|
|
});
|
|
|
|
await waitFor(() => expect(screen.getByText('Imported X1C 0.4')).toBeDefined());
|
|
|
|
const printerSelect = screen.getAllByRole('combobox')[0];
|
|
const groups = printerSelect.querySelectorAll('optgroup');
|
|
expect(Array.from(groups).map((g) => g.label)).toEqual([
|
|
'Imported',
|
|
'Cloud',
|
|
'Standard',
|
|
]);
|
|
|
|
// Each entry sits inside its own tier's group — pin the assignment so
|
|
// a future render-shape change can't quietly mix them. Order matches
|
|
// SLICE_MODAL_TIER_ORDER (local → cloud → standard).
|
|
const localGroup = groups[0];
|
|
expect(within(localGroup as HTMLElement).getByText('Imported X1C 0.4')).toBeDefined();
|
|
const cloudGroup = groups[1];
|
|
expect(within(cloudGroup as HTMLElement).getByText('My Custom X1C')).toBeDefined();
|
|
const standardGroup = groups[2];
|
|
expect(within(standardGroup as HTMLElement).getByText('Bambu Lab X1 Carbon 0.4 nozzle')).toBeDefined();
|
|
});
|
|
|
|
it('falls back to local when cloud is empty (auto-pick respects priority)', async () => {
|
|
mockApi.getSlicerPresets.mockResolvedValue(
|
|
makeUnified({
|
|
local: fullThreeTier.local,
|
|
standard: fullThreeTier.standard,
|
|
}),
|
|
);
|
|
renderWithTracker({
|
|
source: { kind: 'libraryFile', id: 100, filename: 'Cube.stl' },
|
|
onClose: vi.fn(),
|
|
});
|
|
|
|
await waitFor(() => expect(screen.getByText('Imported X1C 0.4')).toBeDefined());
|
|
const selects = screen.getAllByRole('combobox') as HTMLSelectElement[];
|
|
expect(selects[0].value).toBe('local:1');
|
|
});
|
|
|
|
it('falls back to standard when both cloud and local are empty', async () => {
|
|
mockApi.getSlicerPresets.mockResolvedValue(
|
|
makeUnified({ standard: fullThreeTier.standard }),
|
|
);
|
|
renderWithTracker({
|
|
source: { kind: 'libraryFile', id: 100, filename: 'Cube.stl' },
|
|
onClose: vi.fn(),
|
|
});
|
|
|
|
await waitFor(() => expect(screen.getByText('Bambu Lab X1 Carbon 0.4 nozzle')).toBeDefined());
|
|
const selects = screen.getAllByRole('combobox') as HTMLSelectElement[];
|
|
expect(selects[0].value).toBe('standard:Bambu Lab X1 Carbon 0.4 nozzle');
|
|
});
|
|
|
|
it('sends source-aware refs (not legacy bare ints) on submit', async () => {
|
|
const onClose = vi.fn();
|
|
mockApi.sliceLibraryFile.mockResolvedValue({
|
|
job_id: 42,
|
|
status: 'pending',
|
|
status_url: '/api/v1/slice-jobs/42',
|
|
});
|
|
|
|
renderWithTracker({
|
|
source: { kind: 'libraryFile', id: 100, filename: 'Cube.stl' },
|
|
onClose,
|
|
});
|
|
|
|
await waitFor(() => expect(screen.getByText('My Custom X1C')).toBeDefined());
|
|
|
|
const user = userEvent.setup();
|
|
await user.click(screen.getByRole('button', { name: /^Slice$/ }));
|
|
|
|
await waitFor(() => {
|
|
// SliceModal-specific tier priority puts imported (local) above cloud,
|
|
// so the auto-pick lands on the local entries even when a cloud entry
|
|
// with the same slot is also available in the listing.
|
|
expect(mockApi.sliceLibraryFile).toHaveBeenCalledWith(100, {
|
|
printer_preset: { source: 'local', id: '1' },
|
|
process_preset: { source: 'local', id: '2' },
|
|
filament_preset: { source: 'local', id: '3' },
|
|
filament_presets: [{ source: 'local', id: '3' }],
|
|
});
|
|
});
|
|
await waitFor(() => expect(onClose).toHaveBeenCalled());
|
|
});
|
|
|
|
it('includes bed_type in the request when the user picks a non-auto plate (#1337)', async () => {
|
|
const onClose = vi.fn();
|
|
mockApi.sliceLibraryFile.mockResolvedValue({
|
|
job_id: 42,
|
|
status: 'pending',
|
|
status_url: '/api/v1/slice-jobs/42',
|
|
});
|
|
|
|
renderWithTracker({
|
|
source: { kind: 'libraryFile', id: 100, filename: 'Cube.stl' },
|
|
onClose,
|
|
});
|
|
|
|
await waitFor(() => expect(screen.getByText('My Custom X1C')).toBeDefined());
|
|
|
|
const user = userEvent.setup();
|
|
// Order with the dropdown now sits between Process and Filament:
|
|
// printer (0), process (1), bed-type (2), filament (3+). Find the
|
|
// bed-type select by name rather than positional index so this stays
|
|
// green if the layout adds another control around it.
|
|
const bedSelect = screen.getAllByRole('combobox').find((el) =>
|
|
(el as HTMLSelectElement).options[0]?.textContent?.toLowerCase().includes('auto'),
|
|
) as HTMLSelectElement;
|
|
expect(bedSelect).toBeDefined();
|
|
await user.selectOptions(bedSelect, 'Textured PEI Plate');
|
|
await user.click(screen.getByRole('button', { name: /^Slice$/ }));
|
|
|
|
await waitFor(() => {
|
|
expect(mockApi.sliceLibraryFile).toHaveBeenCalledWith(
|
|
100,
|
|
expect.objectContaining({ bed_type: 'Textured PEI Plate' }),
|
|
);
|
|
});
|
|
});
|
|
|
|
it('omits bed_type when the user leaves it on Auto (no override)', async () => {
|
|
const onClose = vi.fn();
|
|
mockApi.sliceLibraryFile.mockResolvedValue({
|
|
job_id: 42,
|
|
status: 'pending',
|
|
status_url: '/api/v1/slice-jobs/42',
|
|
});
|
|
|
|
renderWithTracker({
|
|
source: { kind: 'libraryFile', id: 100, filename: 'Cube.stl' },
|
|
onClose,
|
|
});
|
|
|
|
await waitFor(() => expect(screen.getByText('My Custom X1C')).toBeDefined());
|
|
|
|
const user = userEvent.setup();
|
|
await user.click(screen.getByRole('button', { name: /^Slice$/ }));
|
|
|
|
await waitFor(() => {
|
|
const [, body] = vi.mocked(mockApi.sliceLibraryFile).mock.calls[0];
|
|
expect(body).not.toHaveProperty('bed_type');
|
|
});
|
|
});
|
|
|
|
it('lets the user override the default and pick a Standard preset', async () => {
|
|
const onClose = vi.fn();
|
|
mockApi.sliceLibraryFile.mockResolvedValue({
|
|
job_id: 42,
|
|
status: 'pending',
|
|
status_url: '/api/v1/slice-jobs/42',
|
|
});
|
|
|
|
renderWithTracker({
|
|
source: { kind: 'libraryFile', id: 100, filename: 'Cube.stl' },
|
|
onClose,
|
|
});
|
|
|
|
await waitFor(() => expect(screen.getByText('My Custom X1C')).toBeDefined());
|
|
|
|
const user = userEvent.setup();
|
|
const selects = screen.getAllByRole('combobox');
|
|
await user.selectOptions(selects[0], 'standard:Bambu Lab X1 Carbon 0.4 nozzle');
|
|
await user.click(screen.getByRole('button', { name: /^Slice$/ }));
|
|
|
|
await waitFor(() => {
|
|
expect(mockApi.sliceLibraryFile).toHaveBeenCalledWith(
|
|
100,
|
|
expect.objectContaining({
|
|
printer_preset: { source: 'standard', id: 'Bambu Lab X1 Carbon 0.4 nozzle' },
|
|
}),
|
|
);
|
|
});
|
|
});
|
|
|
|
it('routes archive sources to sliceArchive instead of sliceLibraryFile', async () => {
|
|
const onClose = vi.fn();
|
|
mockApi.sliceArchive.mockResolvedValue({
|
|
job_id: 7,
|
|
status: 'pending',
|
|
status_url: '/api/v1/slice-jobs/7',
|
|
});
|
|
|
|
renderWithTracker({
|
|
source: { kind: 'archive', id: 86, filename: 'orca.3mf' },
|
|
onClose,
|
|
});
|
|
|
|
await waitFor(() => expect(screen.getByText('My Custom X1C')).toBeDefined());
|
|
|
|
const user = userEvent.setup();
|
|
await user.click(screen.getByRole('button', { name: /^Slice$/ }));
|
|
|
|
await waitFor(() => {
|
|
expect(mockApi.sliceArchive).toHaveBeenCalledWith(86, expect.any(Object));
|
|
expect(mockApi.sliceLibraryFile).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
it('surfaces enqueue errors inline and keeps the modal open', async () => {
|
|
const onClose = vi.fn();
|
|
mockApi.sliceLibraryFile.mockRejectedValue(new Error('Server says no'));
|
|
|
|
renderWithTracker({
|
|
source: { kind: 'libraryFile', id: 100, filename: 'Cube.stl' },
|
|
onClose,
|
|
});
|
|
|
|
await waitFor(() => expect(screen.getByText('My Custom X1C')).toBeDefined());
|
|
|
|
const user = userEvent.setup();
|
|
await user.click(screen.getByRole('button', { name: /^Slice$/ }));
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByRole('alert')).toHaveTextContent('Server says no');
|
|
});
|
|
expect(onClose).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('shows a friendly notice when getSlicerPresets fails', async () => {
|
|
mockApi.getSlicerPresets.mockRejectedValue(new Error('500'));
|
|
|
|
renderWithTracker({
|
|
source: { kind: 'libraryFile', id: 100, filename: 'Cube.stl' },
|
|
onClose: vi.fn(),
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByRole('alert')).toHaveTextContent(/Failed to load presets/i);
|
|
});
|
|
});
|
|
|
|
it('renders a "sign in" banner when cloud_status is not_authenticated', async () => {
|
|
mockApi.getSlicerPresets.mockResolvedValue(
|
|
makeUnified({
|
|
cloud_status: 'not_authenticated',
|
|
local: fullThreeTier.local,
|
|
standard: fullThreeTier.standard,
|
|
}),
|
|
);
|
|
renderWithTracker({
|
|
source: { kind: 'libraryFile', id: 100, filename: 'Cube.stl' },
|
|
onClose: vi.fn(),
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByRole('status')).toHaveTextContent(/Sign in to Bambu Cloud/i);
|
|
});
|
|
});
|
|
|
|
it('renders an "expired" banner when cloud_status is expired', async () => {
|
|
mockApi.getSlicerPresets.mockResolvedValue(
|
|
makeUnified({
|
|
cloud_status: 'expired',
|
|
local: fullThreeTier.local,
|
|
}),
|
|
);
|
|
renderWithTracker({
|
|
source: { kind: 'libraryFile', id: 100, filename: 'Cube.stl' },
|
|
onClose: vi.fn(),
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByRole('status')).toHaveTextContent(/expired/i);
|
|
});
|
|
});
|
|
|
|
it('omits the banner entirely when cloud_status is ok', async () => {
|
|
renderWithTracker({
|
|
source: { kind: 'libraryFile', id: 100, filename: 'Cube.stl' },
|
|
onClose: vi.fn(),
|
|
});
|
|
await waitFor(() => expect(screen.getByText('My Custom X1C')).toBeDefined());
|
|
// No status-role banner should be rendered on the happy path.
|
|
expect(screen.queryByRole('status')).toBeNull();
|
|
});
|
|
|
|
// ----- Multi-plate flow -----------------------------------------------
|
|
|
|
function makeMultiPlateLibraryResponse() {
|
|
return {
|
|
file_id: 100,
|
|
filename: 'Multi.3mf',
|
|
is_multi_plate: true,
|
|
plates: [
|
|
{
|
|
index: 1,
|
|
name: 'Plate 1',
|
|
objects: ['Cube'],
|
|
object_count: 1,
|
|
has_thumbnail: false,
|
|
thumbnail_url: null,
|
|
print_time_seconds: 600,
|
|
filament_used_grams: 10,
|
|
filaments: [],
|
|
},
|
|
{
|
|
index: 2,
|
|
name: 'Plate 2',
|
|
objects: ['Pyramid'],
|
|
object_count: 1,
|
|
has_thumbnail: false,
|
|
thumbnail_url: null,
|
|
print_time_seconds: 800,
|
|
filament_used_grams: 12,
|
|
filaments: [],
|
|
},
|
|
],
|
|
};
|
|
}
|
|
|
|
it('shows the plate picker first for multi-plate library files', async () => {
|
|
mockApi.getLibraryFilePlates.mockResolvedValue(makeMultiPlateLibraryResponse());
|
|
renderWithTracker({
|
|
source: { kind: 'libraryFile', id: 100, filename: 'Multi.3mf' },
|
|
onClose: vi.fn(),
|
|
});
|
|
|
|
// Plate picker renders one button per plate — the accessible name
|
|
// joins the heading ("Plate N — name") with the object summary line.
|
|
await screen.findByRole('button', { name: /Plate 1.*Cube/ });
|
|
expect(screen.getByRole('button', { name: /Plate 2.*Pyramid/ })).toBeDefined();
|
|
// Profile dropdowns must NOT be visible yet — the user has to pick a
|
|
// plate first.
|
|
expect(screen.queryByRole('combobox')).toBeNull();
|
|
});
|
|
|
|
it('skips the plate picker for single-plate sources', async () => {
|
|
mockApi.getLibraryFilePlates.mockResolvedValue({
|
|
file_id: 100,
|
|
filename: 'Single.3mf',
|
|
is_multi_plate: false,
|
|
plates: [
|
|
{
|
|
index: 1,
|
|
name: 'Plate 1',
|
|
objects: [],
|
|
has_thumbnail: false,
|
|
thumbnail_url: null,
|
|
print_time_seconds: null,
|
|
filament_used_grams: null,
|
|
filaments: [],
|
|
},
|
|
],
|
|
});
|
|
renderWithTracker({
|
|
source: { kind: 'libraryFile', id: 100, filename: 'Single.3mf' },
|
|
onClose: vi.fn(),
|
|
});
|
|
|
|
// Should jump straight to the profile dropdowns.
|
|
await waitFor(() => expect(screen.getByText('My Custom X1C')).toBeDefined());
|
|
});
|
|
|
|
it('passes the picked plate to the slice request', async () => {
|
|
mockApi.getLibraryFilePlates.mockResolvedValue(makeMultiPlateLibraryResponse());
|
|
mockApi.sliceLibraryFile.mockResolvedValue({
|
|
job_id: 42,
|
|
status: 'pending',
|
|
status_url: '/api/v1/slice-jobs/42',
|
|
});
|
|
|
|
renderWithTracker({
|
|
source: { kind: 'libraryFile', id: 100, filename: 'Multi.3mf' },
|
|
onClose: vi.fn(),
|
|
});
|
|
|
|
const user = userEvent.setup();
|
|
// Step 1: pick Plate 2.
|
|
const plate2Button = await screen.findByRole('button', { name: /Plate 2.*Pyramid/ });
|
|
await user.click(plate2Button);
|
|
|
|
// Step 2: profile dropdowns are now visible.
|
|
await waitFor(() => expect(screen.getByText('My Custom X1C')).toBeDefined());
|
|
|
|
// Step 3: submit and verify the plate index made it into the body.
|
|
await user.click(screen.getByRole('button', { name: /^Slice$/ }));
|
|
await waitFor(() => {
|
|
expect(mockApi.sliceLibraryFile).toHaveBeenCalledWith(
|
|
100,
|
|
expect.objectContaining({ plate: 2 }),
|
|
);
|
|
});
|
|
});
|
|
|
|
it('routes the plate fetch through getArchivePlates for archive sources', async () => {
|
|
mockApi.getArchivePlates.mockResolvedValue({
|
|
...makeMultiPlateLibraryResponse(),
|
|
archive_id: 100,
|
|
filename: 'Multi.3mf',
|
|
});
|
|
renderWithTracker({
|
|
source: { kind: 'archive', id: 100, filename: 'Multi.3mf' },
|
|
onClose: vi.fn(),
|
|
});
|
|
|
|
await screen.findByRole('button', { name: /Plate 1.*Cube/ });
|
|
expect(mockApi.getArchivePlates).toHaveBeenCalledWith(100);
|
|
expect(mockApi.getLibraryFilePlates).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('cancelling the plate picker closes the entire slice flow', async () => {
|
|
const onClose = vi.fn();
|
|
mockApi.getLibraryFilePlates.mockResolvedValue(makeMultiPlateLibraryResponse());
|
|
renderWithTracker({
|
|
source: { kind: 'libraryFile', id: 100, filename: 'Multi.3mf' },
|
|
onClose,
|
|
});
|
|
|
|
await screen.findByRole('button', { name: /Plate 1.*Cube/ });
|
|
|
|
const user = userEvent.setup();
|
|
await user.click(screen.getByRole('button', { name: /^Close$/i }));
|
|
|
|
expect(onClose).toHaveBeenCalled();
|
|
});
|
|
|
|
it('omits the plate field when the source is single-plate', async () => {
|
|
mockApi.sliceLibraryFile.mockResolvedValue({
|
|
job_id: 42,
|
|
status: 'pending',
|
|
status_url: '/api/v1/slice-jobs/42',
|
|
});
|
|
|
|
renderWithTracker({
|
|
source: { kind: 'libraryFile', id: 100, filename: 'Cube.stl' },
|
|
onClose: vi.fn(),
|
|
});
|
|
|
|
await waitFor(() => expect(screen.getByText('My Custom X1C')).toBeDefined());
|
|
|
|
const user = userEvent.setup();
|
|
await user.click(screen.getByRole('button', { name: /^Slice$/ }));
|
|
|
|
await waitFor(() => {
|
|
const [, body] = mockApi.sliceLibraryFile.mock.calls[0];
|
|
expect(body).not.toHaveProperty('plate');
|
|
});
|
|
});
|
|
|
|
// ----- Multi-color flow ------------------------------------------------
|
|
|
|
function makeMultiColorPlateResponse() {
|
|
// Single-plate 3MF that uses two filament slots — mirrors the realistic
|
|
// "I have a multi-color file with one plate" case. Multi-plate is a
|
|
// separate axis that's already covered above.
|
|
return {
|
|
file_id: 100,
|
|
filename: 'TwoColor.3mf',
|
|
is_multi_plate: false,
|
|
plates: [
|
|
{
|
|
index: 1,
|
|
name: 'Plate 1',
|
|
objects: ['Logo'],
|
|
object_count: 1,
|
|
has_thumbnail: false,
|
|
thumbnail_url: null,
|
|
print_time_seconds: 600,
|
|
filament_used_grams: 20,
|
|
filaments: [],
|
|
},
|
|
],
|
|
};
|
|
}
|
|
|
|
function makeMultiColorRequirementsResponse() {
|
|
return {
|
|
file_id: 100,
|
|
filename: 'TwoColor.3mf',
|
|
plate_id: 1,
|
|
filaments: [
|
|
{ slot_id: 1, type: 'PLA', color: '#000000', used_grams: 10, used_meters: 3 },
|
|
{ slot_id: 2, type: 'PLA', color: '#FFFFFF', used_grams: 10, used_meters: 3 },
|
|
],
|
|
};
|
|
}
|
|
|
|
function makeColorAwarePresets(): UnifiedPresetsResponse {
|
|
// Two filament presets in cloud: one black PLA, one white PLA. Pre-pick
|
|
// should match each plate slot to the same-colour preset so the user
|
|
// doesn't have to manually align them.
|
|
return {
|
|
cloud: {
|
|
printer: [{ id: 'P1', name: 'X1C', source: 'cloud' }],
|
|
process: [{ id: 'PR1', name: '0.20mm', source: 'cloud' }],
|
|
filament: [
|
|
{ id: 'F-BLACK', name: 'Cloud PLA Black', source: 'cloud', filament_type: 'PLA', filament_colour: '#000000' },
|
|
{ id: 'F-WHITE', name: 'Cloud PLA White', source: 'cloud', filament_type: 'PLA', filament_colour: '#FFFFFF' },
|
|
],
|
|
},
|
|
local: { printer: [], process: [], filament: [] },
|
|
standard: { printer: [], process: [], filament: [] },
|
|
cloud_status: 'ok',
|
|
};
|
|
}
|
|
|
|
it('renders one filament dropdown per plate slot when the source is multi-color', async () => {
|
|
mockApi.getLibraryFilePlates.mockResolvedValue(makeMultiColorPlateResponse());
|
|
mockApi.getLibraryFileFilamentRequirements.mockResolvedValue(makeMultiColorRequirementsResponse());
|
|
mockApi.getSlicerPresets.mockResolvedValue(makeColorAwarePresets());
|
|
|
|
renderWithTracker({
|
|
source: { kind: 'libraryFile', id: 100, filename: 'TwoColor.3mf' },
|
|
onClose: vi.fn(),
|
|
});
|
|
|
|
await waitFor(() => expect(screen.getByText('X1C')).toBeDefined());
|
|
// 1 printer + 1 process + 2 filament + 1 bed-type (#1337) = 5 dropdowns.
|
|
expect(screen.getAllByRole('combobox')).toHaveLength(5);
|
|
});
|
|
|
|
it('pre-picks each filament slot by matching colour metadata', async () => {
|
|
mockApi.getLibraryFilePlates.mockResolvedValue(makeMultiColorPlateResponse());
|
|
mockApi.getLibraryFileFilamentRequirements.mockResolvedValue(makeMultiColorRequirementsResponse());
|
|
mockApi.getSlicerPresets.mockResolvedValue(makeColorAwarePresets());
|
|
mockApi.sliceLibraryFile.mockResolvedValue({
|
|
job_id: 42,
|
|
status: 'pending',
|
|
status_url: '/api/v1/slice-jobs/42',
|
|
});
|
|
|
|
renderWithTracker({
|
|
source: { kind: 'libraryFile', id: 100, filename: 'TwoColor.3mf' },
|
|
onClose: vi.fn(),
|
|
});
|
|
|
|
await waitFor(() => expect(screen.getByText('X1C')).toBeDefined());
|
|
|
|
const user = userEvent.setup();
|
|
await user.click(screen.getByRole('button', { name: /^Slice$/ }));
|
|
|
|
await waitFor(() => {
|
|
const [, body] = mockApi.sliceLibraryFile.mock.calls[0];
|
|
// Slot 1 was black plate → cloud black preset; slot 2 was white →
|
|
// cloud white preset. Pre-pick aligns them by metadata so the user
|
|
// doesn't have to swap them manually.
|
|
expect(body.filament_presets).toEqual([
|
|
{ source: 'cloud', id: 'F-BLACK' },
|
|
{ source: 'cloud', id: 'F-WHITE' },
|
|
]);
|
|
});
|
|
});
|
|
|
|
it('still sends the legacy filament_preset for single-color flows', async () => {
|
|
// Backwards-compat with backends / proxies that read the singular field.
|
|
mockApi.sliceLibraryFile.mockResolvedValue({
|
|
job_id: 42,
|
|
status: 'pending',
|
|
status_url: '/api/v1/slice-jobs/42',
|
|
});
|
|
|
|
renderWithTracker({
|
|
source: { kind: 'libraryFile', id: 100, filename: 'Cube.stl' },
|
|
onClose: vi.fn(),
|
|
});
|
|
|
|
await waitFor(() => expect(screen.getByText('My Custom X1C')).toBeDefined());
|
|
|
|
const user = userEvent.setup();
|
|
await user.click(screen.getByRole('button', { name: /^Slice$/ }));
|
|
|
|
await waitFor(() => {
|
|
const [, body] = mockApi.sliceLibraryFile.mock.calls[0];
|
|
// Single-color path mirrors the array's first entry into the legacy
|
|
// singular so older backend clients that only know about
|
|
// `filament_preset` still work.
|
|
expect(body.filament_preset).toEqual(body.filament_presets[0]);
|
|
expect(body.filament_presets).toHaveLength(1);
|
|
});
|
|
});
|
|
|
|
it('lets the user override a pre-picked filament slot', async () => {
|
|
mockApi.getLibraryFilePlates.mockResolvedValue(makeMultiColorPlateResponse());
|
|
mockApi.getLibraryFileFilamentRequirements.mockResolvedValue(makeMultiColorRequirementsResponse());
|
|
mockApi.getSlicerPresets.mockResolvedValue(makeColorAwarePresets());
|
|
mockApi.sliceLibraryFile.mockResolvedValue({
|
|
job_id: 42,
|
|
status: 'pending',
|
|
status_url: '/api/v1/slice-jobs/42',
|
|
});
|
|
|
|
renderWithTracker({
|
|
source: { kind: 'libraryFile', id: 100, filename: 'TwoColor.3mf' },
|
|
onClose: vi.fn(),
|
|
});
|
|
|
|
await waitFor(() => expect(screen.getByText('X1C')).toBeDefined());
|
|
|
|
const user = userEvent.setup();
|
|
const selects = screen.getAllByRole('combobox') as HTMLSelectElement[];
|
|
// Order: 0 printer, 1 process, 2 bed-type, 3 filament-1, 4 filament-2
|
|
// (#1337). Auto-picks land on printer/process/filaments; bed-type
|
|
// defaults to "". Swap filament-1 (index 3) from the auto-picked black
|
|
// to white.
|
|
await user.selectOptions(selects[3], 'cloud:F-WHITE');
|
|
await user.click(screen.getByRole('button', { name: /^Slice$/ }));
|
|
|
|
await waitFor(() => {
|
|
const [, body] = mockApi.sliceLibraryFile.mock.calls[0];
|
|
expect(body.filament_presets[0]).toEqual({ source: 'cloud', id: 'F-WHITE' });
|
|
// Slot 1 stayed at the auto-picked white.
|
|
expect(body.filament_presets[1]).toEqual({ source: 'cloud', id: 'F-WHITE' });
|
|
});
|
|
});
|
|
|
|
// Pre-slice printer-mismatch warning. The slicer CLI cannot re-slice a
|
|
// 3MF for a different printer model — clicking Slice in that state
|
|
// would silently fall back to the embedded settings and produce a
|
|
// wrong-printer file. The modal surfaces a warning and disables Slice
|
|
// when the source's source_printer_model doesn't match the picked
|
|
// printer profile.
|
|
it('shows a printer-mismatch warning and disables Slice when models differ', async () => {
|
|
mockApi.getLibraryFilePlates.mockResolvedValue({
|
|
file_id: 100,
|
|
filename: 'A1Original.3mf',
|
|
is_multi_plate: false,
|
|
source_printer_model: 'A1',
|
|
plates: [
|
|
{
|
|
index: 1,
|
|
name: 'Plate 1',
|
|
objects: [],
|
|
has_thumbnail: false,
|
|
thumbnail_url: null,
|
|
print_time_seconds: null,
|
|
filament_used_grams: null,
|
|
filaments: [],
|
|
},
|
|
],
|
|
});
|
|
// Standard tier offers an X1C profile — the user picks (auto-picks) it.
|
|
mockApi.getSlicerPresets.mockResolvedValue(makeUnified({
|
|
standard: {
|
|
printer: [{ id: 'Bambu Lab X1 Carbon 0.4 nozzle', name: 'Bambu Lab X1 Carbon 0.4 nozzle', source: 'standard' }],
|
|
process: [{ id: '0.20mm Standard', name: '0.20mm Standard', source: 'standard' }],
|
|
filament: [{ id: 'Bambu PLA Basic', name: 'Bambu PLA Basic', source: 'standard' }],
|
|
},
|
|
}));
|
|
|
|
renderWithTracker({
|
|
source: { kind: 'libraryFile', id: 100, filename: 'A1Original.3mf' },
|
|
onClose: vi.fn(),
|
|
});
|
|
|
|
await waitFor(() =>
|
|
expect(screen.getByText('Bambu Lab X1 Carbon 0.4 nozzle')).toBeDefined(),
|
|
);
|
|
|
|
// Warning banner is visible (role=alert) and references both models.
|
|
const alert = await screen.findByRole('alert');
|
|
expect(alert.textContent).toMatch(/A1/);
|
|
expect(alert.textContent).toMatch(/X1 Carbon/);
|
|
|
|
// Slice button is disabled while the warning is up.
|
|
const sliceButton = screen.getByRole('button', { name: /^Slice$/ }) as HTMLButtonElement;
|
|
expect(sliceButton.disabled).toBe(true);
|
|
});
|
|
|
|
it('keeps Slice enabled when the picked profile matches the source printer model', async () => {
|
|
mockApi.getLibraryFilePlates.mockResolvedValue({
|
|
file_id: 100,
|
|
filename: 'X1COriginal.3mf',
|
|
is_multi_plate: false,
|
|
source_printer_model: 'X1 Carbon',
|
|
plates: [
|
|
{
|
|
index: 1,
|
|
name: 'Plate 1',
|
|
objects: [],
|
|
has_thumbnail: false,
|
|
thumbnail_url: null,
|
|
print_time_seconds: null,
|
|
filament_used_grams: null,
|
|
filaments: [],
|
|
},
|
|
],
|
|
});
|
|
mockApi.getSlicerPresets.mockResolvedValue(makeUnified({
|
|
standard: {
|
|
printer: [{ id: 'Bambu Lab X1 Carbon 0.4 nozzle', name: 'Bambu Lab X1 Carbon 0.4 nozzle', source: 'standard' }],
|
|
process: [{ id: '0.20mm Standard', name: '0.20mm Standard', source: 'standard' }],
|
|
filament: [{ id: 'Bambu PLA Basic', name: 'Bambu PLA Basic', source: 'standard' }],
|
|
},
|
|
}));
|
|
|
|
renderWithTracker({
|
|
source: { kind: 'libraryFile', id: 100, filename: 'X1COriginal.3mf' },
|
|
onClose: vi.fn(),
|
|
});
|
|
|
|
await waitFor(() =>
|
|
expect(screen.getByText('Bambu Lab X1 Carbon 0.4 nozzle')).toBeDefined(),
|
|
);
|
|
|
|
// No mismatch warning.
|
|
expect(screen.queryByRole('alert')).toBeNull();
|
|
const sliceButton = screen.getByRole('button', { name: /^Slice$/ }) as HTMLButtonElement;
|
|
expect(sliceButton.disabled).toBe(false);
|
|
});
|
|
|
|
it('keeps Slice enabled when source_printer_model is unknown (legacy archives)', async () => {
|
|
// Older 3MFs without project_settings.printer_model fall through to
|
|
// no-warning — we don't have enough info to gate the user.
|
|
mockApi.getLibraryFilePlates.mockResolvedValue({
|
|
file_id: 100,
|
|
filename: 'Legacy.3mf',
|
|
is_multi_plate: false,
|
|
source_printer_model: null,
|
|
plates: [
|
|
{
|
|
index: 1,
|
|
name: 'Plate 1',
|
|
objects: [],
|
|
has_thumbnail: false,
|
|
thumbnail_url: null,
|
|
print_time_seconds: null,
|
|
filament_used_grams: null,
|
|
filaments: [],
|
|
},
|
|
],
|
|
});
|
|
|
|
renderWithTracker({
|
|
source: { kind: 'libraryFile', id: 100, filename: 'Legacy.3mf' },
|
|
onClose: vi.fn(),
|
|
});
|
|
|
|
await waitFor(() => expect(screen.getByText('My Custom X1C')).toBeDefined());
|
|
expect(screen.queryByRole('alert')).toBeNull();
|
|
const sliceButton = screen.getByRole('button', { name: /^Slice$/ }) as HTMLButtonElement;
|
|
expect(sliceButton.disabled).toBe(false);
|
|
});
|
|
|
|
// The `used_in_plate` flag tells the modal which AMS slots are
|
|
// actually consumed by the picked plate. Slots flagged as unused
|
|
// are still rendered (the slicer CLI needs a profile per project
|
|
// slot, otherwise it silently fills the gap from embedded defaults
|
|
// and unwanted colours leak into the output) but disabled in the UI
|
|
// so the user only interacts with the dropdowns that matter.
|
|
it('disables filament dropdowns for slots not used by the picked plate', async () => {
|
|
mockApi.getLibraryFilePlates.mockResolvedValue({
|
|
file_id: 100,
|
|
filename: 'Helmet.3mf',
|
|
is_multi_plate: false,
|
|
plates: [
|
|
{
|
|
index: 1,
|
|
name: 'Plate 1',
|
|
objects: ['Helmet'],
|
|
has_thumbnail: false,
|
|
thumbnail_url: null,
|
|
print_time_seconds: 1200,
|
|
filament_used_grams: 80,
|
|
filaments: [],
|
|
},
|
|
],
|
|
});
|
|
// Project has 2 AMS slots configured (white + grey support), but
|
|
// plate 1 only paints with white (slot 1). The backend now returns
|
|
// BOTH slots with used_in_plate flagging the difference.
|
|
mockApi.getLibraryFileFilamentRequirements.mockResolvedValue({
|
|
file_id: 100,
|
|
filename: 'Helmet.3mf',
|
|
plate_id: 1,
|
|
filaments: [
|
|
{ slot_id: 1, type: 'PLA', color: '#FFFFFF', used_grams: 80, used_meters: 27, used_in_plate: true },
|
|
{ slot_id: 2, type: 'PLA', color: '#808080', used_grams: 0, used_meters: 0, used_in_plate: false },
|
|
],
|
|
});
|
|
mockApi.getSlicerPresets.mockResolvedValue({
|
|
cloud: {
|
|
printer: [{ id: 'P1', name: 'X1C', source: 'cloud' }],
|
|
process: [{ id: 'PR1', name: '0.20mm', source: 'cloud' }],
|
|
filament: [
|
|
{ id: 'F-WHITE', name: 'Cloud PLA White', source: 'cloud', filament_type: 'PLA', filament_colour: '#FFFFFF' },
|
|
{ id: 'F-GREY', name: 'Cloud PLA Grey', source: 'cloud', filament_type: 'PLA', filament_colour: '#808080' },
|
|
],
|
|
},
|
|
local: { printer: [], process: [], filament: [] },
|
|
standard: { printer: [], process: [], filament: [] },
|
|
cloud_status: 'ok',
|
|
});
|
|
|
|
renderWithTracker({
|
|
source: { kind: 'libraryFile', id: 100, filename: 'Helmet.3mf' },
|
|
onClose: vi.fn(),
|
|
});
|
|
|
|
await waitFor(() => expect(screen.getByText('X1C')).toBeDefined());
|
|
|
|
// Both filament rows render — 1 printer + 1 process + 1 bed-type +
|
|
// 2 filament (#1337) = 5. bed-type sits at index 2, filament slots
|
|
// follow at 3 and 4.
|
|
const selects = screen.getAllByRole('combobox') as HTMLSelectElement[];
|
|
expect(selects).toHaveLength(5);
|
|
// Slot 1 (used) is editable, slot 2 (not used) is disabled.
|
|
expect(selects[3].disabled).toBe(false);
|
|
expect(selects[4].disabled).toBe(true);
|
|
// The disabled row's label calls out why it's disabled.
|
|
expect(screen.getByText(/not used by this plate/i)).toBeDefined();
|
|
});
|
|
|
|
it('still sends both filaments to the backend even when one slot is disabled', async () => {
|
|
// The auto-pick scoring fills the disabled slot from project
|
|
// metadata — the slicer CLI requires a profile for every project
|
|
// slot, otherwise it silently fills the gap. The disabled UI is
|
|
// purely cosmetic; the wire format must include the full list.
|
|
mockApi.getLibraryFilePlates.mockResolvedValue({
|
|
file_id: 100,
|
|
filename: 'Helmet.3mf',
|
|
is_multi_plate: false,
|
|
plates: [
|
|
{
|
|
index: 1,
|
|
name: 'Plate 1',
|
|
objects: ['Helmet'],
|
|
has_thumbnail: false,
|
|
thumbnail_url: null,
|
|
print_time_seconds: 1200,
|
|
filament_used_grams: 80,
|
|
filaments: [],
|
|
},
|
|
],
|
|
});
|
|
mockApi.getLibraryFileFilamentRequirements.mockResolvedValue({
|
|
file_id: 100,
|
|
filename: 'Helmet.3mf',
|
|
plate_id: 1,
|
|
filaments: [
|
|
{ slot_id: 1, type: 'PLA', color: '#FFFFFF', used_grams: 80, used_meters: 27, used_in_plate: true },
|
|
{ slot_id: 2, type: 'PLA', color: '#808080', used_grams: 0, used_meters: 0, used_in_plate: false },
|
|
],
|
|
});
|
|
mockApi.getSlicerPresets.mockResolvedValue({
|
|
cloud: {
|
|
printer: [{ id: 'P1', name: 'X1C', source: 'cloud' }],
|
|
process: [{ id: 'PR1', name: '0.20mm', source: 'cloud' }],
|
|
filament: [
|
|
{ id: 'F-WHITE', name: 'Cloud PLA White', source: 'cloud', filament_type: 'PLA', filament_colour: '#FFFFFF' },
|
|
{ id: 'F-GREY', name: 'Cloud PLA Grey', source: 'cloud', filament_type: 'PLA', filament_colour: '#808080' },
|
|
],
|
|
},
|
|
local: { printer: [], process: [], filament: [] },
|
|
standard: { printer: [], process: [], filament: [] },
|
|
cloud_status: 'ok',
|
|
});
|
|
mockApi.sliceLibraryFile.mockResolvedValue({
|
|
job_id: 50,
|
|
status: 'pending',
|
|
status_url: '/api/v1/slice-jobs/50',
|
|
});
|
|
|
|
renderWithTracker({
|
|
source: { kind: 'libraryFile', id: 100, filename: 'Helmet.3mf' },
|
|
onClose: vi.fn(),
|
|
});
|
|
|
|
await waitFor(() => expect(screen.getByText('X1C')).toBeDefined());
|
|
|
|
const user = userEvent.setup();
|
|
await user.click(screen.getByRole('button', { name: /^Slice$/ }));
|
|
|
|
await waitFor(() => {
|
|
const [, body] = mockApi.sliceLibraryFile.mock.calls[0];
|
|
// Both slots populated: slot 1 with the user's white pick, slot
|
|
// 2 auto-picked with grey from the colour-match scoring.
|
|
expect(body.filament_presets).toHaveLength(2);
|
|
expect(body.filament_presets[0]).toEqual({ source: 'cloud', id: 'F-WHITE' });
|
|
expect(body.filament_presets[1]).toEqual({ source: 'cloud', id: 'F-GREY' });
|
|
});
|
|
});
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Bundle tier — picking an imported .bbscfg replaces the cloud/local/standard
|
|
// dropdown set with bundle-scoped pickers and routes the slice through the
|
|
// backend's bundle dispatch shape (no PresetRefs in the body).
|
|
// -------------------------------------------------------------------------
|
|
|
|
describe('Bundle tier', () => {
|
|
const sampleBundle = {
|
|
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',
|
|
],
|
|
version: '02.06.00.50',
|
|
};
|
|
|
|
it('hides the bundle picker when no bundles are imported', async () => {
|
|
// Default beforeEach already returns []; assert the picker isn't
|
|
// rendered so users without bundles see the original layout.
|
|
renderWithTracker({
|
|
source: { kind: 'libraryFile', id: 100, filename: 'Cube.stl' },
|
|
onClose: vi.fn(),
|
|
});
|
|
await waitFor(() => expect(screen.getByText('My Custom X1C')).toBeDefined());
|
|
expect(screen.queryByText(/slicer bundle/i)).toBeNull();
|
|
});
|
|
|
|
it('renders the bundle picker when at least one bundle is imported', async () => {
|
|
mockApi.listSlicerBundles.mockResolvedValue([sampleBundle]);
|
|
renderWithTracker({
|
|
source: { kind: 'libraryFile', id: 100, filename: 'Cube.stl' },
|
|
onClose: vi.fn(),
|
|
});
|
|
await waitFor(() =>
|
|
expect(screen.getByText(/slicer bundle/i)).toBeDefined(),
|
|
);
|
|
// The bundle option is in the dropdown.
|
|
const bundleSelect = screen.getAllByRole('combobox')[0] as HTMLSelectElement;
|
|
expect(
|
|
Array.from(bundleSelect.options).map((o) => o.textContent),
|
|
).toContain('# Bambu Lab H2D 0.4 nozzle');
|
|
});
|
|
|
|
it('replaces preset dropdowns with bundle-scoped pickers when a bundle is selected', async () => {
|
|
mockApi.listSlicerBundles.mockResolvedValue([sampleBundle]);
|
|
renderWithTracker({
|
|
source: { kind: 'libraryFile', id: 100, filename: 'Cube.stl' },
|
|
onClose: vi.fn(),
|
|
});
|
|
await waitFor(() => expect(screen.getByText('My Custom X1C')).toBeDefined());
|
|
|
|
const user = userEvent.setup();
|
|
const selects = screen.getAllByRole('combobox') as HTMLSelectElement[];
|
|
// First select is the bundle picker (new top-of-modal dropdown).
|
|
await user.selectOptions(selects[0], sampleBundle.id);
|
|
|
|
// Wait for the bundle-mode UI to take over: process options should
|
|
// now reflect the bundle's process names.
|
|
await waitFor(() => {
|
|
expect(
|
|
screen.getByText('# 0.20mm Standard @BBL H2D'),
|
|
).toBeDefined();
|
|
});
|
|
|
|
// The static printer label shows the bundle's printer. Both the
|
|
// <option> in the bundle picker and the read-only <div> below
|
|
// contain this text, so use getAllByText.
|
|
const printerNameMatches = screen.getAllByText('# Bambu Lab H2D 0.4 nozzle');
|
|
expect(printerNameMatches.length).toBeGreaterThanOrEqual(2);
|
|
|
|
// Cloud/local/standard preset names from the original tier no longer
|
|
// appear in the visible dropdowns (the bundle replaced them).
|
|
const visibleSelects = screen.getAllByRole('combobox') as HTMLSelectElement[];
|
|
const allOptionTexts = visibleSelects.flatMap((sel) =>
|
|
Array.from(sel.options).map((o) => o.textContent ?? ''),
|
|
);
|
|
// Cloud printer name shouldn't be in any visible dropdown anymore.
|
|
expect(allOptionTexts).not.toContain('My Custom X1C');
|
|
});
|
|
|
|
it('submits bundle dispatch shape (no PresetRefs) when a bundle is selected', async () => {
|
|
mockApi.listSlicerBundles.mockResolvedValue([sampleBundle]);
|
|
mockApi.sliceLibraryFile.mockResolvedValue({
|
|
job_id: 99,
|
|
status: 'pending',
|
|
status_url: '/api/v1/slice-jobs/99',
|
|
});
|
|
|
|
renderWithTracker({
|
|
source: { kind: 'libraryFile', id: 100, filename: 'Cube.stl' },
|
|
onClose: vi.fn(),
|
|
});
|
|
await waitFor(() => expect(screen.getByText('My Custom X1C')).toBeDefined());
|
|
|
|
const user = userEvent.setup();
|
|
const selects = screen.getAllByRole('combobox') as HTMLSelectElement[];
|
|
await user.selectOptions(selects[0], sampleBundle.id);
|
|
|
|
// Wait for bundle-mode dropdowns to render.
|
|
await waitFor(() =>
|
|
expect(screen.getByText('# 0.20mm Standard @BBL H2D')).toBeDefined(),
|
|
);
|
|
await user.click(screen.getByRole('button', { name: /^Slice$/ }));
|
|
|
|
await waitFor(() => {
|
|
const [fileId, body] = mockApi.sliceLibraryFile.mock.calls[0];
|
|
expect(fileId).toBe(100);
|
|
expect(body.bundle).toEqual({
|
|
bundle_id: sampleBundle.id,
|
|
printer_name: '# Bambu Lab H2D 0.4 nozzle',
|
|
process_name: '# 0.20mm Standard @BBL H2D',
|
|
filament_names: ['# Bambu PLA Basic @BBL H2D'],
|
|
});
|
|
// The preset triplet must NOT be in the body — bundle dispatch
|
|
// skips PresetRef resolution entirely on the backend.
|
|
expect(body.printer_preset).toBeUndefined();
|
|
expect(body.process_preset).toBeUndefined();
|
|
expect(body.filament_presets).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
it('switching back to "None" restores the preset triplet path', async () => {
|
|
mockApi.listSlicerBundles.mockResolvedValue([sampleBundle]);
|
|
mockApi.sliceLibraryFile.mockResolvedValue({
|
|
job_id: 100,
|
|
status: 'pending',
|
|
status_url: '/api/v1/slice-jobs/100',
|
|
});
|
|
|
|
renderWithTracker({
|
|
source: { kind: 'libraryFile', id: 100, filename: 'Cube.stl' },
|
|
onClose: vi.fn(),
|
|
});
|
|
await waitFor(() => expect(screen.getByText('My Custom X1C')).toBeDefined());
|
|
|
|
const user = userEvent.setup();
|
|
const bundleSelect = screen.getAllByRole('combobox')[0] as HTMLSelectElement;
|
|
await user.selectOptions(bundleSelect, sampleBundle.id);
|
|
await waitFor(() =>
|
|
expect(screen.getByText('# 0.20mm Standard @BBL H2D')).toBeDefined(),
|
|
);
|
|
|
|
// Flip back to None.
|
|
await user.selectOptions(bundleSelect, '');
|
|
await waitFor(() => {
|
|
const selects = screen.getAllByRole('combobox') as HTMLSelectElement[];
|
|
// After de-selecting bundle, the printer dropdown's first option
|
|
// should be one of the original cloud/local/standard names.
|
|
const printerOptions = Array.from(selects[1].options).map((o) => o.textContent);
|
|
expect(printerOptions).toContain('My Custom X1C');
|
|
});
|
|
|
|
await user.click(screen.getByRole('button', { name: /^Slice$/ }));
|
|
await waitFor(() => {
|
|
const [, body] = mockApi.sliceLibraryFile.mock.calls[0];
|
|
expect(body.bundle).toBeUndefined();
|
|
expect(body.printer_preset).toBeDefined();
|
|
});
|
|
});
|
|
});
|
|
});
|