/**
* Tests for the EditArchiveModal component.
*/
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';
import { EditArchiveModal } from '../../components/EditArchiveModal';
import { http, HttpResponse } from 'msw';
import { server } from '../mocks/server';
const mockArchive = {
id: 1,
filename: 'benchy.gcode.3mf',
print_name: 'Benchy',
printer_id: 1,
printer_name: 'X1 Carbon',
notes: 'Test notes',
rating: 4,
project_id: null,
tags: 'test,calibration',
};
const mockProjects = [
{ id: 1, name: 'Functional Parts', color: '#00ae42' },
{ id: 2, name: 'Art', color: '#ff5500' },
];
describe('EditArchiveModal', () => {
const mockOnClose = vi.fn();
const mockOnSave = vi.fn();
beforeEach(() => {
vi.clearAllMocks();
server.use(
http.get('/api/v1/projects/', () => {
return HttpResponse.json(mockProjects);
}),
http.get('/api/v1/archives/tags', () => {
return HttpResponse.json([
{ name: 'test', count: 2 },
{ name: 'calibration', count: 1 },
{ name: 'functional', count: 3 },
]);
}),
http.patch('/api/v1/archives/:id', async ({ request }) => {
const body = await request.json();
return HttpResponse.json({ ...mockArchive, ...body });
})
);
});
describe('rendering', () => {
it('renders the modal title', () => {
render(
);
expect(screen.getByText(/edit/i)).toBeInTheDocument();
});
it('shows print name field', async () => {
render(
);
await waitFor(() => {
// Name field should be present
const nameInput = screen.getByDisplayValue('Benchy');
expect(nameInput).toBeInTheDocument();
});
});
it('shows notes field', async () => {
render(
);
await waitFor(() => {
const notesField = screen.getByDisplayValue('Test notes');
expect(notesField).toBeInTheDocument();
});
});
it('shows rating selector', async () => {
render(
);
await waitFor(() => {
// Rating may be shown as stars or dropdown
expect(screen.getByText(/edit/i)).toBeInTheDocument();
});
});
it('shows project selector', async () => {
render(
);
await waitFor(() => {
// Project section should be present
expect(screen.getByText(/edit/i)).toBeInTheDocument();
});
});
it('shows tags input', () => {
render(
);
expect(screen.getByText(/tags/i)).toBeInTheDocument();
});
});
describe('existing values', () => {
it('shows existing tags', () => {
render(
);
expect(screen.getByText('test')).toBeInTheDocument();
expect(screen.getByText('calibration')).toBeInTheDocument();
});
});
describe('actions', () => {
it('has save button', () => {
render(
);
expect(screen.getByRole('button', { name: /save/i })).toBeInTheDocument();
});
it('has cancel button', () => {
render(
);
expect(screen.getByRole('button', { name: /cancel/i })).toBeInTheDocument();
});
it('calls onClose when cancel is clicked', async () => {
const user = userEvent.setup();
render(
);
await user.click(screen.getByRole('button', { name: /cancel/i }));
expect(mockOnClose).toHaveBeenCalled();
});
it('can edit print name', async () => {
const user = userEvent.setup();
render(
);
const nameInput = screen.getByDisplayValue('Benchy');
await user.clear(nameInput);
await user.type(nameInput, 'New Name');
expect(nameInput).toHaveValue('New Name');
});
});
describe('failure_reason vocabulary (#1687 follow-up)', () => {
// The Stats page's Failure Analysis widget groups by the raw column value.
// Before this fix this modal saved the translated label, so a language
// switch fragmented historical buckets and any round-trip through the
// new PATCH /print-log endpoint (which validates against camelCase keys)
// would reject the value. The dropdown now saves the key.
const failedArchive = { ...mockArchive, status: 'failed', failure_reason: 'filamentRunout' };
const legacyArchive = { ...mockArchive, status: 'failed', failure_reason: 'Filament runout' };
it('preselects the option when the stored value is already a camelCase key', () => {
render();
const select = screen.getByLabelText(/failure reason/i) as HTMLSelectElement;
expect(select.value).toBe('filamentRunout');
});
it('reverse-looks-up a legacy translated value back to its key', () => {
render();
const select = screen.getByLabelText(/failure reason/i) as HTMLSelectElement;
expect(select.value).toBe('filamentRunout');
});
it('sends the camelCase key on save, not the translated label', async () => {
const user = userEvent.setup();
let patched: { failure_reason?: string } | undefined;
server.use(
http.patch('/api/v1/archives/:id', async ({ request }) => {
patched = (await request.json()) as { failure_reason?: string };
return HttpResponse.json({ ...failedArchive, ...patched });
}),
);
render();
const select = screen.getByLabelText(/failure reason/i);
await user.selectOptions(select, 'cloggedNozzle');
await user.click(screen.getByRole('button', { name: /save/i }));
await waitFor(() => {
expect(patched?.failure_reason).toBe('cloggedNozzle');
});
});
// A value outside the vocabulary used to initialise the dropdown to '',
// and saving from that state wrote the empty selection over the stored
// text -- opening the editor and pressing Save destroyed the
// classification. The startup migration folds every known spelling onto a
// key, so what reaches here is genuinely unrecognisable text; it has to
// survive rather than be silently discarded (issue #2974).
const freeTextArchive = {
...mockArchive,
status: 'failed',
failure_reason: 'Custom legacy reason',
};
it('keeps a stored value it cannot map, as its own option', () => {
render();
const select = screen.getByLabelText(/failure reason/i) as HTMLSelectElement;
expect(select.value).toBe('Custom legacy reason');
expect(
screen.getByRole('option', { name: 'Custom legacy reason' }),
).toBeInTheDocument();
});
it('does not clear an unmappable reason on an untouched save', async () => {
const user = userEvent.setup();
let patched: { failure_reason?: string } | undefined;
server.use(
http.patch('/api/v1/archives/:id', async ({ request }) => {
patched = (await request.json()) as { failure_reason?: string };
return HttpResponse.json({ ...freeTextArchive, ...patched });
}),
);
render();
await user.click(screen.getByRole('button', { name: /save/i }));
await waitFor(() => {
expect(patched?.failure_reason).toBe('Custom legacy reason');
});
});
it('offers the stale-path reason the backend now writes', () => {
// Both stale writers in main.py store `noStatusUpdate`. If it were
// missing from the dropdown the editor would treat it as unmappable and
// show the raw key to the user instead of a translated label.
const staleArchive = {
...mockArchive,
status: 'failed',
failure_reason: 'noStatusUpdate',
};
render();
const select = screen.getByLabelText(/failure reason/i) as HTMLSelectElement;
expect(select.value).toBe('noStatusUpdate');
expect(
screen.getByRole('option', { name: 'No status update received' }),
).toBeInTheDocument();
});
});
describe('filament grams (#1820)', () => {
// A print archived without its 3MF carries no weight at all, and no rescan
// can supply one — there is no file to read. Typing it here is the only
// route, so the field has to reach the API, and an untouched save must not
// overwrite a figure that came from a real slice.
function patchSpy() {
const seen: { body?: Record } = {};
server.use(
http.patch('/api/v1/archives/:id', async ({ request }) => {
seen.body = (await request.json()) as Record;
return HttpResponse.json({ ...mockArchive, ...seen.body });
}),
);
return seen;
}
it('sends a figure typed for an archive that has none', async () => {
const user = userEvent.setup();
const seen = patchSpy();
render();
await user.type(screen.getByLabelText(/filament used/i), '46.16');
await user.click(screen.getByRole('button', { name: /save/i }));
await waitFor(() => {
expect(seen.body?.filament_used_grams).toBe(46.16);
});
});
it('leaves the field out of a save that did not touch it', async () => {
const user = userEvent.setup();
const seen = patchSpy();
const weighed = { ...mockArchive, filament_used_grams: 50 };
render();
await user.click(screen.getByRole('button', { name: /save/i }));
await waitFor(() => {
expect(seen.body).toBeDefined();
});
expect(seen.body).not.toHaveProperty('filament_used_grams');
});
it('accepts a decimal comma, which a number input would have swallowed', async () => {
const user = userEvent.setup();
const seen = patchSpy();
render();
await user.type(screen.getByLabelText(/filament used/i), '46,16');
await user.click(screen.getByRole('button', { name: /save/i }));
await waitFor(() => {
expect(seen.body?.filament_used_grams).toBe(46.16);
});
});
it('refuses characters that could never reach the API as a number', async () => {
const user = userEvent.setup();
render();
const field = screen.getByLabelText(/filament used/i) as HTMLInputElement;
await user.type(field, '4a6-1..2');
expect(field.value).toBe('461.2');
});
it('clamps to the bound the API enforces, so a save cannot be refused', async () => {
const user = userEvent.setup();
const seen = patchSpy();
render();
await user.type(screen.getByLabelText(/filament used/i), '999999');
await user.click(screen.getByRole('button', { name: /save/i }));
await waitFor(() => {
expect(seen.body?.filament_used_grams).toBe(100000);
});
});
it('does not read a half-typed value as a clear', async () => {
// Enter submits without the field ever losing focus, so the blur-time
// tidy-up has not run and the state still holds what was typed.
const user = userEvent.setup();
const seen = patchSpy();
const weighed = { ...mockArchive, filament_used_grams: 50 };
render();
const field = screen.getByLabelText(/filament used/i);
await user.clear(field);
await user.type(field, '.{Enter}');
await waitFor(() => {
expect(seen.body).toBeDefined();
});
expect(seen.body).not.toHaveProperty('filament_used_grams');
});
it('refreshes the print log, which the mirrored figure lands in', async () => {
const user = userEvent.setup();
let runFetches = 0;
server.use(
http.get('/api/v1/archives/:id/runs', () => {
runFetches += 1;
return HttpResponse.json({ items: [], total: 0 });
}),
);
render();
await waitFor(() => expect(runFetches).toBe(1));
await user.type(screen.getByLabelText(/filament used/i), '46.16');
await user.click(screen.getByRole('button', { name: /save/i }));
// Without the invalidation the table keeps serving its cached rows, so
// the run the edit just corrected still shows the old figure.
await waitFor(() => expect(runFetches).toBe(2));
});
it('clears the figure when the field is emptied', async () => {
const user = userEvent.setup();
const seen = patchSpy();
const weighed = { ...mockArchive, filament_used_grams: 50 };
render();
await user.clear(screen.getByLabelText(/filament used/i));
await user.click(screen.getByRole('button', { name: /save/i }));
await waitFor(() => {
expect(seen.body?.filament_used_grams).toBeNull();
});
});
});
describe('project picker (#2888)', () => {
// Statuses matter here, so this describe brings its own list rather than
// the bare one the rest of the file shares.
const withStatuses = (rows: Array>) =>
server.use(http.get('/api/v1/projects/', () => HttpResponse.json(rows)));
function savedBody() {
const seen: { body?: Record } = {};
server.use(
http.patch('/api/v1/archives/:id', async ({ request }) => {
seen.body = (await request.json()) as Record;
return HttpResponse.json({ ...mockArchive, ...seen.body });
}),
);
return seen;
}
it('leaves archived projects out of the list', async () => {
withStatuses([
{ id: 1, name: 'Live Work', color: '#00ae42', status: 'active' },
{ id: 2, name: 'Last Year', color: '#888888', status: 'archived' },
]);
render();
await screen.findByRole('option', { name: 'Live Work' });
expect(screen.queryByRole('option', { name: 'Last Year' })).not.toBeInTheDocument();
});
it('keeps completed projects, which are still worth filing a reprint under', async () => {
withStatuses([
{ id: 1, name: 'Live Work', color: '#00ae42', status: 'active' },
{ id: 3, name: 'Shipped', color: '#888888', status: 'completed' },
]);
render();
expect(await screen.findByRole('option', { name: 'Shipped' })).toBeInTheDocument();
});
it('still offers the archived project this archive is already in', async () => {
// Filtered out, the select holds a value no option matches, and the
// browser resets it to the first option -- "No project". The archive
// would say it is filed nowhere while sitting in a project.
withStatuses([
{ id: 1, name: 'Live Work', color: '#00ae42', status: 'active' },
{ id: 2, name: 'Last Year', color: '#888888', status: 'archived' },
]);
const filed = { ...mockArchive, project_id: 2 };
render();
const option = await screen.findByRole('option', { name: 'Last Year' });
expect((option as HTMLOptionElement).selected).toBe(true);
});
it('saves the project it was already in when nothing else is touched', async () => {
const user = userEvent.setup();
const seen = savedBody();
withStatuses([{ id: 2, name: 'Last Year', color: '#888888', status: 'archived' }]);
render(
,
);
await screen.findByRole('option', { name: 'Last Year' });
await user.click(screen.getByRole('button', { name: /save/i }));
// The stored id survives the round trip untouched: showing the archived
// project is what makes the field honest, and it must not also change
// what an untouched save writes.
await waitFor(() => expect(seen.body?.project_id).toBe(2));
});
});
describe('items printed (#3051)', () => {
// A plate that jammed and came off ruined produced nothing, even when the
// printer called the job a success. The project's completed-items count
// sums this column, so 0 has to be typeable.
function patchSpy() {
const seen: { body?: Record } = {};
server.use(
http.patch('/api/v1/archives/:id', async ({ request }) => {
seen.body = (await request.json()) as Record;
return HttpResponse.json({ ...mockArchive, ...seen.body });
}),
);
return seen;
}
it('sends 0 for a plate that produced nothing', async () => {
const user = userEvent.setup();
const seen = patchSpy();
render(
,
);
const field = screen.getByLabelText(/items printed/i) as HTMLInputElement;
await user.clear(field);
await user.type(field, '0');
await user.click(screen.getByRole('button', { name: /save/i }));
await waitFor(() => expect(seen.body?.quantity).toBe(0));
});
it('does not floor a cleared field back to 1', async () => {
const user = userEvent.setup();
render(
,
);
const field = screen.getByLabelText(/items printed/i) as HTMLInputElement;
await user.clear(field);
// Empty while editing (#3182), and 0 rather than 1 once the field is left.
expect(field.value).toBe('');
await user.tab();
expect(field.value).toBe('0');
});
it('still refuses a negative count', async () => {
const user = userEvent.setup();
const seen = patchSpy();
render(
,
);
const field = screen.getByLabelText(/items printed/i) as HTMLInputElement;
await user.clear(field);
await user.type(field, '-3');
await user.click(screen.getByRole('button', { name: /save/i }));
expect(field.value).toBe('0');
await waitFor(() => expect(seen.body?.quantity).toBe(0));
});
});
// Post-print outcome confirmation (#1898)
describe('outcome verdict source', () => {
const completed = {
...mockArchive,
status: 'completed',
user_verdict: 'good',
confirm_requested: true,
};
it('explains a verdict the plate-clear default recorded', () => {
render(
,
);
expect(screen.getByTestId('verdict-source-hint')).toHaveTextContent(
'Recorded when the plate was cleared.',
);
});
it('shows no hint for a verdict with no recorded source', () => {
render(
,
);
expect(screen.queryByTestId('verdict-source-hint')).not.toBeInTheDocument();
});
it('stamps the dialog as the source when the verdict is changed here', async () => {
const user = userEvent.setup();
let seen: Record | null = null;
server.use(
http.patch('/api/v1/archives/:id', async ({ request }) => {
seen = (await request.json()) as Record;
return HttpResponse.json({ ...completed, ...seen });
}),
);
render(
,
);
await user.selectOptions(screen.getByLabelText(/outcome verdict/i), 'reject');
await user.click(screen.getByRole('button', { name: /save/i }));
await waitFor(() => expect(seen).not.toBeNull());
expect(seen!.user_verdict).toBe('reject');
expect(seen!.user_verdict_source).toBe('dialog');
});
});
describe('failure_reason on a machine status the dropdown cannot show', () => {
// The reject reason (#1898) reuses failure_reason, so the clearing branch
// grew "|| archive.failure_reason" to drop it when the verdict goes away.
// That clause also catches a 'cancelled' archive: print_queue writes
// status 'cancelled' with "Stopped by user (printer was offline)" when a
// stop is issued to an offline printer, the status dropdown has no
// 'cancelled' option, and so opening the editor and pressing Save with
// nothing touched wiped a reason the user could not put back.
const cancelled = {
...mockArchive,
status: 'cancelled',
failure_reason: 'Stopped by user (printer was offline)',
user_verdict: null,
};
it('keeps the machine-written reason on an untouched save', async () => {
const user = userEvent.setup();
let seen: Record | null = null;
server.use(
http.patch('/api/v1/archives/:id', async ({ request }) => {
seen = (await request.json()) as Record;
return HttpResponse.json({ ...cancelled, ...seen });
}),
);
render();
await user.click(screen.getByRole('button', { name: /save/i }));
await waitFor(() => expect(seen).not.toBeNull());
expect(seen!).not.toHaveProperty('failure_reason');
});
it('still drops the reason when a reject verdict is taken back', async () => {
// The behaviour the clause was written for, on the archive it was
// written for: a completed print whose reject reason no longer applies.
const user = userEvent.setup();
let seen: Record | null = null;
const rejected = {
...mockArchive,
status: 'completed',
user_verdict: 'reject',
failure_reason: 'warping',
};
server.use(
http.patch('/api/v1/archives/:id', async ({ request }) => {
seen = (await request.json()) as Record;
return HttpResponse.json({ ...rejected, ...seen });
}),
);
render();
await user.selectOptions(screen.getByLabelText(/outcome verdict/i), 'good');
await user.click(screen.getByRole('button', { name: /save/i }));
await waitFor(() => expect(seen).not.toBeNull());
expect(seen!.failure_reason).toBeNull();
});
});
});