|
|
@@ -28,6 +28,9 @@ vi.mock('../../api/client', () => ({
|
|
|
getArchiveFilamentRequirements: vi.fn(),
|
|
|
getSettings: vi.fn().mockResolvedValue({}),
|
|
|
updateSettings: vi.fn().mockResolvedValue({}),
|
|
|
+ // Slicer Pipelines (#1425)
|
|
|
+ listSlicerPipelines: vi.fn(),
|
|
|
+ createSlicerPipeline: vi.fn(),
|
|
|
},
|
|
|
}));
|
|
|
|
|
|
@@ -40,6 +43,8 @@ const mockApi = api as unknown as {
|
|
|
getArchivePlates: ReturnType<typeof vi.fn>;
|
|
|
getLibraryFileFilamentRequirements: ReturnType<typeof vi.fn>;
|
|
|
getArchiveFilamentRequirements: ReturnType<typeof vi.fn>;
|
|
|
+ listSlicerPipelines: ReturnType<typeof vi.fn>;
|
|
|
+ createSlicerPipeline: ReturnType<typeof vi.fn>;
|
|
|
};
|
|
|
|
|
|
function makeUnified(overrides: Partial<UnifiedPresetsResponse> = {}): UnifiedPresetsResponse {
|
|
|
@@ -80,6 +85,16 @@ function renderWithTracker(props: Parameters<typeof SliceModal>[0]) {
|
|
|
);
|
|
|
}
|
|
|
|
|
|
+// SliceModal renders one extra combobox for the Slicer Pipelines (#1425)
|
|
|
+// "Apply pipeline" dropdown above the preset slots. Tests written before
|
|
|
+// pipelines landed assume selects[0] = printer; this helper drops the
|
|
|
+// pipeline combobox so those indices stay stable.
|
|
|
+function presetSelects(): HTMLSelectElement[] {
|
|
|
+ return (screen.getAllByRole('combobox') as HTMLSelectElement[]).filter(
|
|
|
+ (el) => el.getAttribute('aria-label') !== 'Apply pipeline',
|
|
|
+ );
|
|
|
+}
|
|
|
+
|
|
|
describe('SliceModal', () => {
|
|
|
beforeEach(() => {
|
|
|
vi.clearAllMocks();
|
|
|
@@ -121,6 +136,8 @@ describe('SliceModal', () => {
|
|
|
plate_id: 1,
|
|
|
filaments: [],
|
|
|
});
|
|
|
+ // Default: no saved pipelines. Tests opt in by overriding this.
|
|
|
+ mockApi.listSlicerPipelines.mockResolvedValue({ pipelines: [] });
|
|
|
});
|
|
|
|
|
|
it('auto-selects the highest-priority tier per slot on first load', async () => {
|
|
|
@@ -137,7 +154,7 @@ describe('SliceModal', () => {
|
|
|
// 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[];
|
|
|
+ const selects = presetSelects();
|
|
|
expect(selects).toHaveLength(4);
|
|
|
expect(selects[0].value).toBe('local:1');
|
|
|
expect(selects[1].value).toBe('local:2');
|
|
|
@@ -158,7 +175,7 @@ describe('SliceModal', () => {
|
|
|
|
|
|
await waitFor(() => expect(screen.getByText('Imported X1C 0.4')).toBeDefined());
|
|
|
|
|
|
- const printerSelect = screen.getAllByRole('combobox')[0];
|
|
|
+ const printerSelect = presetSelects()[0];
|
|
|
const groups = printerSelect.querySelectorAll('optgroup');
|
|
|
expect(Array.from(groups).map((g) => g.label)).toEqual([
|
|
|
'Imported',
|
|
|
@@ -190,7 +207,7 @@ describe('SliceModal', () => {
|
|
|
});
|
|
|
|
|
|
await waitFor(() => expect(screen.getByText('Imported X1C 0.4')).toBeDefined());
|
|
|
- const selects = screen.getAllByRole('combobox') as HTMLSelectElement[];
|
|
|
+ const selects = presetSelects();
|
|
|
expect(selects[0].value).toBe('local:1');
|
|
|
});
|
|
|
|
|
|
@@ -204,7 +221,7 @@ describe('SliceModal', () => {
|
|
|
});
|
|
|
|
|
|
await waitFor(() => expect(screen.getByText('Bambu Lab X1 Carbon 0.4 nozzle')).toBeDefined());
|
|
|
- const selects = screen.getAllByRole('combobox') as HTMLSelectElement[];
|
|
|
+ const selects = presetSelects();
|
|
|
expect(selects[0].value).toBe('standard:Bambu Lab X1 Carbon 0.4 nozzle');
|
|
|
});
|
|
|
|
|
|
@@ -260,7 +277,7 @@ describe('SliceModal', () => {
|
|
|
// 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) =>
|
|
|
+ const bedSelect = presetSelects().find((el) =>
|
|
|
(el as HTMLSelectElement).options[0]?.textContent?.toLowerCase().includes('auto'),
|
|
|
) as HTMLSelectElement;
|
|
|
expect(bedSelect).toBeDefined();
|
|
|
@@ -315,7 +332,7 @@ describe('SliceModal', () => {
|
|
|
await waitFor(() => expect(screen.getByText('My Custom X1C')).toBeDefined());
|
|
|
|
|
|
const user = userEvent.setup();
|
|
|
- const selects = screen.getAllByRole('combobox');
|
|
|
+ const selects = presetSelects();
|
|
|
await user.selectOptions(selects[0], 'standard:Bambu Lab X1 Carbon 0.4 nozzle');
|
|
|
await user.click(screen.getByRole('button', { name: /^Slice$/ }));
|
|
|
|
|
|
@@ -731,7 +748,7 @@ describe('SliceModal', () => {
|
|
|
|
|
|
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);
|
|
|
+ expect(presetSelects()).toHaveLength(5);
|
|
|
});
|
|
|
|
|
|
it('pre-picks each filament slot by matching colour metadata', async () => {
|
|
|
@@ -812,7 +829,7 @@ describe('SliceModal', () => {
|
|
|
await waitFor(() => expect(screen.getByText('X1C')).toBeDefined());
|
|
|
|
|
|
const user = userEvent.setup();
|
|
|
- const selects = screen.getAllByRole('combobox') as HTMLSelectElement[];
|
|
|
+ const selects = presetSelects();
|
|
|
// 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
|
|
|
@@ -937,7 +954,7 @@ describe('SliceModal', () => {
|
|
|
// 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[];
|
|
|
+ const selects = presetSelects();
|
|
|
expect(selects).toHaveLength(5);
|
|
|
// Slot 1 (used) is editable, slot 2 (not used) is disabled.
|
|
|
expect(selects[3].disabled).toBe(false);
|
|
|
@@ -1018,4 +1035,128 @@ describe('SliceModal', () => {
|
|
|
});
|
|
|
});
|
|
|
|
|
|
+ // ------------------------------------------------------------------
|
|
|
+ // Slicer Pipelines (#1425) — Apply / Save integration in SliceModal
|
|
|
+ // ------------------------------------------------------------------
|
|
|
+
|
|
|
+ it('Apply pipeline dropdown is disabled and shows empty hint when no pipelines exist', async () => {
|
|
|
+ mockApi.listSlicerPipelines.mockResolvedValue({ pipelines: [] });
|
|
|
+ renderWithTracker({
|
|
|
+ source: { kind: 'libraryFile', id: 100, filename: 'Cube.stl' },
|
|
|
+ onClose: vi.fn(),
|
|
|
+ });
|
|
|
+ await waitFor(() => {
|
|
|
+ const select = screen.getByLabelText(/Apply pipeline/i) as HTMLSelectElement;
|
|
|
+ expect(select.disabled).toBe(true);
|
|
|
+ expect(select.querySelector('option')?.textContent).toMatch(/No saved pipelines/i);
|
|
|
+ });
|
|
|
+ });
|
|
|
+
|
|
|
+ it('applies a saved pipeline to printer, process, and bed_type slots on selection', async () => {
|
|
|
+ mockApi.listSlicerPipelines.mockResolvedValue({
|
|
|
+ pipelines: [
|
|
|
+ {
|
|
|
+ id: 7,
|
|
|
+ name: 'Production Batch',
|
|
|
+ description: null,
|
|
|
+ printer_preset: { source: 'local', id: '1' },
|
|
|
+ process_preset: { source: 'local', id: '2' },
|
|
|
+ filament_presets: [{ source: 'local', id: '3' }],
|
|
|
+ bed_type: 'Textured PEI Plate',
|
|
|
+ target_kind: 'printer_class',
|
|
|
+ target_printer_id: null,
|
|
|
+ target_model_class: null,
|
|
|
+ fanout_strategy: 'max_parallel',
|
|
|
+ created_by: null,
|
|
|
+ created_at: '2026-06-27T00:00:00Z',
|
|
|
+ updated_at: '2026-06-27T00:00:00Z',
|
|
|
+ },
|
|
|
+ ],
|
|
|
+ });
|
|
|
+
|
|
|
+ renderWithTracker({
|
|
|
+ source: { kind: 'libraryFile', id: 100, filename: 'Cube.stl' },
|
|
|
+ onClose: vi.fn(),
|
|
|
+ });
|
|
|
+
|
|
|
+ // Wait for presets + pipelines listing to populate the modal.
|
|
|
+ await waitFor(() => {
|
|
|
+ const select = screen.getByLabelText(/Apply pipeline/i) as HTMLSelectElement;
|
|
|
+ expect(select.disabled).toBe(false);
|
|
|
+ expect(within(select).getByText('Production Batch')).toBeDefined();
|
|
|
+ });
|
|
|
+
|
|
|
+ const user = userEvent.setup();
|
|
|
+ await user.selectOptions(screen.getByLabelText(/Apply pipeline/i), '7');
|
|
|
+
|
|
|
+ // After applying, submitting the slice request should carry the
|
|
|
+ // pipeline's preset refs end-to-end.
|
|
|
+ mockApi.sliceLibraryFile.mockResolvedValue({
|
|
|
+ job_id: 42,
|
|
|
+ status: 'queued',
|
|
|
+ status_url: '/api/v1/slice-jobs/42',
|
|
|
+ });
|
|
|
+
|
|
|
+ await user.click(screen.getByRole('button', { name: /^Slice$/ }));
|
|
|
+
|
|
|
+ await waitFor(() => {
|
|
|
+ expect(mockApi.sliceLibraryFile).toHaveBeenCalled();
|
|
|
+ const [, body] = mockApi.sliceLibraryFile.mock.calls[0];
|
|
|
+ expect(body.printer_preset).toEqual({ source: 'local', id: '1' });
|
|
|
+ expect(body.process_preset).toEqual({ source: 'local', id: '2' });
|
|
|
+ expect(body.filament_presets[0]).toEqual({ source: 'local', id: '3' });
|
|
|
+ expect(body.bed_type).toBe('Textured PEI Plate');
|
|
|
+ });
|
|
|
+ });
|
|
|
+
|
|
|
+ it('saves the current four-slot selection as a new pipeline when the user clicks Save as pipeline', async () => {
|
|
|
+ mockApi.listSlicerPipelines.mockResolvedValue({ pipelines: [] });
|
|
|
+ mockApi.createSlicerPipeline.mockResolvedValue({
|
|
|
+ id: 99,
|
|
|
+ name: 'My Default',
|
|
|
+ description: null,
|
|
|
+ printer_preset: { source: 'local', id: '1' },
|
|
|
+ process_preset: { source: 'local', id: '2' },
|
|
|
+ filament_presets: [{ source: 'local', id: '3' }],
|
|
|
+ bed_type: null,
|
|
|
+ target_kind: 'printer_class',
|
|
|
+ target_printer_id: null,
|
|
|
+ target_model_class: null,
|
|
|
+ fanout_strategy: 'max_parallel',
|
|
|
+ created_by: null,
|
|
|
+ created_at: '2026-06-27T00:00:00Z',
|
|
|
+ updated_at: '2026-06-27T00:00:00Z',
|
|
|
+ });
|
|
|
+
|
|
|
+ renderWithTracker({
|
|
|
+ source: { kind: 'libraryFile', id: 100, filename: 'Cube.stl' },
|
|
|
+ onClose: vi.fn(),
|
|
|
+ });
|
|
|
+
|
|
|
+ // Wait for auto-pick to populate all four slots from the fullThreeTier
|
|
|
+ // listing — then Save as pipeline becomes enabled.
|
|
|
+ const user = userEvent.setup();
|
|
|
+ let saveBtn: HTMLButtonElement;
|
|
|
+ await waitFor(() => {
|
|
|
+ saveBtn = screen.getByRole('button', { name: /^Save as pipeline$/ }) as HTMLButtonElement;
|
|
|
+ expect(saveBtn.disabled).toBe(false);
|
|
|
+ });
|
|
|
+ await user.click(saveBtn!);
|
|
|
+
|
|
|
+ const nameInput = screen.getByLabelText(/New pipeline name/i);
|
|
|
+ await user.type(nameInput, 'My Default');
|
|
|
+ await user.click(screen.getByRole('button', { name: /^Save$/ }));
|
|
|
+
|
|
|
+ await waitFor(() => {
|
|
|
+ expect(mockApi.createSlicerPipeline).toHaveBeenCalledTimes(1);
|
|
|
+ const body = mockApi.createSlicerPipeline.mock.calls[0][0];
|
|
|
+ expect(body.name).toBe('My Default');
|
|
|
+ // The four slots come from the auto-picked unified-presets listing —
|
|
|
+ // local tier wins per SLICE_MODAL_TIER_ORDER.
|
|
|
+ expect(body.printer_preset.source).toBe('local');
|
|
|
+ expect(body.process_preset.source).toBe('local');
|
|
|
+ expect(body.filament_presets[0].source).toBe('local');
|
|
|
+ });
|
|
|
+ });
|
|
|
+
|
|
|
});
|