feat(cv-import): add source-grounded review and explicit corrections
This commit is contained in:
@@ -0,0 +1,139 @@
|
||||
import React from 'react';
|
||||
import '@testing-library/jest-dom';
|
||||
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react';
|
||||
import { api } from './api';
|
||||
import { emptyStructuredCv } from './profileCv';
|
||||
import { ToastProvider } from './toast';
|
||||
import { I18nProvider } from './i18n/I18nProvider';
|
||||
import CareerProfilePage from './views/CareerProfilePage';
|
||||
|
||||
jest.mock('./api', () => ({
|
||||
getApiErrorMessage: jest.requireActual('./api').getApiErrorMessage,
|
||||
api: { get: jest.fn(), post: jest.fn(), put: jest.fn() },
|
||||
}));
|
||||
jest.mock('./components/CropImageDialog', () => () => null);
|
||||
|
||||
const mockedApi = api as jest.Mocked<typeof api>;
|
||||
const run = { id: 23, trigger: 'upload', status: 'pending_review', artifactFileName: 'career.pdf',
|
||||
startedAtUtc: '2026-10-26T10:00:00Z', parserVersion: 'docling-v1', normalizerVersion: 'document-v1', llmPromptVersion: 'none' };
|
||||
const review = { runId: 23, status: 'pending_review', revision: 7, fileName: 'career.pdf',
|
||||
sourceUrl: '/api/profile-cv/runs/23/source', sourceText: 'Source name\nDocker', warnings: ['Check ambiguous dates.'],
|
||||
groups: [{ id: 'Contact', category: 'Contact', label: 'Contact', matchDescription: null, fields: [
|
||||
{ id: 'Contact.fullName', label: 'Full name', currentValue: 'Saved name', extractedValue: 'Source name', sourceSnippet: 'Source name', sourcePage: 1, method: 'docling', reviewState: 'suggested' },
|
||||
{ id: 'Contact.headline', label: 'Headline', currentValue: 'Saved headline', extractedValue: 'Engineer', sourceSnippet: null, sourcePage: null, method: 'deterministic', reviewState: 'suggested' },
|
||||
] }] };
|
||||
let currentReview = review;
|
||||
let runs = [run];
|
||||
|
||||
function renderImport() {
|
||||
window.history.replaceState({}, '', '/career?section=import');
|
||||
return render(<ToastProvider><I18nProvider><CareerProfilePage /></I18nProvider></ToastProvider>);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear();
|
||||
currentReview = review;
|
||||
runs = [run];
|
||||
URL.createObjectURL = jest.fn(() => 'blob:owned-source');
|
||||
URL.revokeObjectURL = jest.fn();
|
||||
mockedApi.get.mockImplementation((url: string) => {
|
||||
if (url === '/career/profile') return Promise.resolve({ data: { profile: emptyStructuredCv(), revision: 7, completeness: { percent: 0, missing: [], sections: [] } } } as any);
|
||||
if (url === '/auth/me') return Promise.resolve({ data: { provider: 'local', userName: 'Importer' } } as any);
|
||||
if (url === '/career/profile/versions') return Promise.resolve({ data: [] } as any);
|
||||
if (url === '/profile-cv/runs') return Promise.resolve({ data: runs } as any);
|
||||
if (url === '/profile-cv/runs/23/review') return Promise.resolve({ data: currentReview } as any);
|
||||
if (url === '/profile-cv/runs/23/diff') return Promise.resolve({ data: { revision: 7, diff: { categories: [], hasChanges: false, totalAdded: 0, totalUpdated: 0, totalLowConfidence: 0 } } } as any);
|
||||
if (url === '/profile-cv/runs/23/source') return Promise.resolve({ data: new Blob(['%PDF-1.4'], { type: 'application/pdf' }) } as any);
|
||||
return Promise.resolve({ data: {} } as any);
|
||||
});
|
||||
mockedApi.post.mockResolvedValue({ data: { runId: 23, status: 'applied', revision: 8, structuredCv: emptyStructuredCv() } } as any);
|
||||
});
|
||||
|
||||
afterEach(() => jest.clearAllMocks());
|
||||
|
||||
test('Docling review requires explicit decisions and sends only reviewed corrections before showing durable completion', async () => {
|
||||
renderImport();
|
||||
const choice = await screen.findByRole('combobox', { name: 'Decision: Full name' });
|
||||
expect(choice).toHaveValue('');
|
||||
expect(screen.getByRole('button', { name: 'Apply reviewed changes' })).toBeDisabled();
|
||||
expect(screen.getByText('Current: Saved name')).toBeInTheDocument();
|
||||
expect(screen.getByText('Imported: Source name')).toBeInTheDocument();
|
||||
expect(screen.getByText('Page 1')).toBeInTheDocument();
|
||||
expect(screen.getByText('Check ambiguous dates.')).toBeInTheDocument();
|
||||
fireEvent.change(choice, { target: { value: 'correct' } });
|
||||
fireEvent.change(screen.getByLabelText('Correction: Full name'), { target: { value: 'Reviewed name' } });
|
||||
fireEvent.change(screen.getByRole('combobox', { name: 'Decision: Professional headline' }), { target: { value: 'keep' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Apply reviewed changes' }));
|
||||
await waitFor(() => expect(mockedApi.post).toHaveBeenCalledWith('/profile-cv/runs/23/apply-reviewed', {
|
||||
expectedVersion: 7, decisions: [{ id: 'Contact.fullName', action: 'correct', value: 'Reviewed name' }, { id: 'Contact.headline', action: 'keep' }],
|
||||
}));
|
||||
expect(await screen.findByText('Reviewed changes saved to your Career Profile.')).toBeInTheDocument();
|
||||
expect(screen.getByRole('link', { name: 'Open Career Profile' })).toHaveAttribute('href', '/career?section=profile');
|
||||
expect(screen.getByRole('link', { name: 'Open CV editor' })).toHaveAttribute('href', '/career/builder');
|
||||
});
|
||||
|
||||
test('stale apply retains corrections and refreshes current values but requires every choice to be confirmed again', async () => {
|
||||
mockedApi.post.mockRejectedValueOnce({ response: { status: 409 } });
|
||||
renderImport();
|
||||
fireEvent.change(await screen.findByRole('combobox', { name: 'Decision: Full name' }), { target: { value: 'correct' } });
|
||||
fireEvent.change(screen.getByLabelText('Correction: Full name'), { target: { value: 'Keep this correction' } });
|
||||
fireEvent.change(screen.getByRole('combobox', { name: 'Decision: Professional headline' }), { target: { value: 'use' } });
|
||||
currentReview = { ...review, revision: 9, groups: [{ ...review.groups[0], fields: [{ ...review.groups[0].fields[0], currentValue: 'Changed elsewhere' }, review.groups[0].fields[1]] }] };
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Apply reviewed changes' }));
|
||||
expect(await screen.findByText('Current: Changed elsewhere')).toBeInTheDocument();
|
||||
expect(screen.getByLabelText('Correction: Full name')).toHaveValue('Keep this correction');
|
||||
expect(screen.getByRole('button', { name: 'Apply reviewed changes' })).toBeDisabled();
|
||||
expect(screen.getByRole('combobox', { name: 'Decision: Full name' })).toHaveValue('');
|
||||
fireEvent.change(screen.getByRole('combobox', { name: 'Decision: Full name' }), { target: { value: 'correct' } });
|
||||
expect(screen.getByRole('button', { name: 'Apply reviewed changes' })).toBeDisabled();
|
||||
fireEvent.change(screen.getByRole('combobox', { name: 'Decision: Professional headline' }), { target: { value: 'keep' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Apply reviewed changes' }));
|
||||
await waitFor(() => expect(mockedApi.post).toHaveBeenLastCalledWith('/profile-cv/runs/23/apply-reviewed', {
|
||||
expectedVersion: 9, decisions: [{ id: 'Contact.fullName', action: 'correct', value: 'Keep this correction' }, { id: 'Contact.headline', action: 'keep' }],
|
||||
}));
|
||||
});
|
||||
|
||||
test('original PDF uses the authenticated API blob and revokes its URL when leaving the page', async () => {
|
||||
const { unmount } = renderImport();
|
||||
const preview = await screen.findByTitle('Original CV document');
|
||||
expect(preview).toHaveAttribute('src', 'blob:owned-source');
|
||||
expect(mockedApi.get).toHaveBeenCalledWith('/profile-cv/runs/23/source', expect.objectContaining({ responseType: 'blob' }));
|
||||
expect(screen.getByRole('link', { name: 'Download original' })).toHaveAttribute('download', 'career.pdf');
|
||||
unmount();
|
||||
expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:owned-source');
|
||||
});
|
||||
|
||||
test.each([['career.pdf', 'application/pdf', '/profile-cv/import'], ['career.docx', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', '/profile-cv/import'], ['career.txt', 'text/plain', '/profile-cv/upload']])('upload %s uses its compatible pipeline', async (fileName, type, route) => {
|
||||
runs = [];
|
||||
const { container } = renderImport();
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: 'Upload CV' })).toBeEnabled());
|
||||
fireEvent.change(container.querySelector('input[type="file"][accept*=".pdf"]')!, { target: { files: [new File(['content'], fileName, { type })] } });
|
||||
await waitFor(() => expect(mockedApi.post).toHaveBeenCalledWith(route, expect.any(FormData), { headers: { 'Content-Type': 'multipart/form-data' } }));
|
||||
});
|
||||
|
||||
test('oversized document is explained before upload and a later valid file remains selectable', async () => {
|
||||
runs = [];
|
||||
const { container } = renderImport();
|
||||
await screen.findByRole('button', { name: 'Upload CV' });
|
||||
const input = container.querySelector('input[type="file"][accept*=".pdf"]')!;
|
||||
const file = new File(['small'], 'career.pdf', { type: 'application/pdf' });
|
||||
Object.defineProperty(file, 'size', { value: 5 * 1024 * 1024 + 1 });
|
||||
fireEvent.change(input, { target: { files: [file] } });
|
||||
expect(await screen.findByText('Choose a file no larger than 5 MiB.')).toBeInTheDocument();
|
||||
expect(mockedApi.post).not.toHaveBeenCalled();
|
||||
fireEvent.change(input, { target: { files: [new File(['pdf'], 'career.pdf', { type: 'application/pdf' })] } });
|
||||
await waitFor(() => expect(mockedApi.post).toHaveBeenCalledTimes(1));
|
||||
});
|
||||
|
||||
test('group approval is deliberate and a dirty profile disables application without losing review corrections', async () => {
|
||||
renderImport();
|
||||
const group = await screen.findByRole('group', { name: 'Contact information' });
|
||||
fireEvent.click(within(group).getByRole('button', { name: 'Use all extracted values in this group' }));
|
||||
expect(screen.getByRole('button', { name: 'Apply reviewed changes' })).toBeEnabled();
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'Career profile' }));
|
||||
fireEvent.change(await screen.findByLabelText('Full name'), { target: { value: 'Unsaved draft' } });
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'Import review' }));
|
||||
expect(screen.getByRole('button', { name: 'Apply reviewed changes' })).toBeDisabled();
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'Career profile' }));
|
||||
expect(screen.getByLabelText('Full name')).toHaveValue('Unsaved draft');
|
||||
});
|
||||
Reference in New Issue
Block a user