Files
jobtrackingapp/job-tracker-ui/src/cv-builder-deep-link.test.tsx
T
2026-10-07 19:05:09 +02:00

799 lines
45 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React from 'react';
import '@testing-library/jest-dom';
import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
import { createMemoryRouter, RouterProvider } from 'react-router-dom';
import CvBuilderEditor from './views/CvBuilderEditor';
import { I18nProvider } from './i18n/I18nProvider';
import { ToastProvider } from './toast';
import { api } from './api';
import { emptyCvVariantSettings } from './cvBuilder';
import { ConfirmProvider } from './confirm';
import { PromptProvider } from './prompt';
// The Application Workspace CV section deep-links straight into the builder at
// /career/builder/:id. These tests cover that entry point: the variant loads, and a variant the
// server refuses (missing, or someone else's) lands on a safe error state rather than a blank editor.
jest.mock('./api', () => ({
api: {
get: jest.fn(),
post: jest.fn(),
put: jest.fn(),
delete: jest.fn(),
interceptors: { request: { use: jest.fn() }, response: { use: jest.fn() } },
},
getApiErrorMessage: (_e: any, fallback?: string) => fallback || 'Request failed.',
}));
const mockedApi = api as jest.Mocked<typeof api>;
const variant = {
id: 3,
name: 'Backend CV',
settings: emptyCvVariantSettings(),
isPublic: false,
publicSlug: 'abc123',
version: 4,
jobApplicationId: 7,
updatedAtUtc: '2026-07-19T10:00:00Z',
};
function renderAt(id: number) {
const router = createMemoryRouter([
{ path: '/career/builder/:id', element: <CvBuilderEditor /> },
{ path: '/career/builder', element: <div>CV list destination</div> },
], { initialEntries: [`/career/builder/${id}`] });
return render(
<I18nProvider>
<ToastProvider>
<ConfirmProvider>
<PromptProvider>
<RouterProvider router={router} />
</PromptProvider>
</ConfirmProvider>
</ToastProvider>
</I18nProvider>,
);
}
beforeEach(() => {
window.localStorage.removeItem('uiLanguage');
jest.clearAllMocks();
mockedApi.put.mockReset();
mockedApi.put.mockResolvedValue({ data: { ...variant, version: variant.version + 1 } } as any);
mockedApi.post.mockResolvedValue({ data: { themeId: 'nordic', html: '<p>cv</p>', suggestedFileName: 'cv.pdf' } } as any);
});
afterEach(() => window.localStorage.removeItem('uiLanguage'));
function routeGet(onVariant: () => Promise<any>, outline: any = { sections: [] }, history: any[] = []) {
mockedApi.get.mockImplementation((url: string) => {
if (url === '/cv/variants/3') return onVariant();
if (url === '/cv/themes') return Promise.resolve({ data: [] } as any);
if (url === '/cv/outline') return Promise.resolve({ data: outline } as any);
if (url === '/cv/variants/3/versions') return Promise.resolve({ data: history } as any);
return Promise.resolve({ data: [] } as any);
});
}
async function openContentTab() {
await screen.findByDisplayValue('Backend CV');
fireEvent.click(screen.getByRole('tab', { name: 'Content' }));
}
test('section navigation opens and focuses omitted/custom sections without changing their data', async () => {
const settings = { ...variant.settings,
sections: [{ key: 'skills', hidden: true }, { key: 'custom:sample', hidden: false }],
customSections: [{ key: 'sample', title: 'Volunteer work', items: ['Bokmål: frivillig arbeid'], hidden: false }],
};
routeGet(() => Promise.resolve({ data: { ...variant, settings } }));
renderAt(3);
await openContentTab();
const navigation = screen.getByRole('navigation', { name: 'CV section navigation' });
fireEvent.mouseDown(within(navigation).getByRole('combobox', { name: 'Go to section' }));
fireEvent.click(await screen.findByRole('option', { name: 'Skills · Omitted' }));
await waitFor(() => expect(screen.getByRole('button', { name: 'Collapse Skills' })).toHaveFocus());
expect(screen.getByRole('button', { name: 'Show Skills section' })).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Collapse Skills' }));
fireEvent.mouseDown(within(navigation).getByRole('combobox', { name: /Go to section/ }));
fireEvent.click(await screen.findByRole('option', { name: 'Skills · Omitted' }));
await waitFor(() => expect(screen.getByRole('button', { name: 'Collapse Skills' })).toHaveFocus());
fireEvent.mouseDown(within(navigation).getByRole('combobox', { name: 'Go to section' }));
fireEvent.click(await screen.findByRole('option', { name: 'Volunteer work · Included' }));
await waitFor(() => expect(screen.getByRole('button', { name: 'Collapse Volunteer work' })).toHaveFocus());
expect(screen.getByDisplayValue('Bokmål: frivillig arbeid')).toBeInTheDocument();
fireEvent.click(screen.getByRole('tab', { name: 'Customize' }));
fireEvent.click(screen.getByRole('tab', { name: 'Content' }));
expect(within(navigation).getByRole('combobox')).toHaveTextContent('Volunteer work');
expect(screen.getByRole('button', { name: 'Collapse Skills' })).toHaveAttribute('aria-expanded', 'true');
expect(screen.getByRole('button', { name: 'Collapse Volunteer work' })).toHaveAttribute('aria-expanded', 'true');
expect(mockedApi.put).not.toHaveBeenCalled();
});
test('keyboard move controls retain focus and persist the same section order shown by navigation', async () => {
routeGet(() => Promise.resolve({ data: { ...variant, settings: { ...variant.settings,
sections: [{ key: 'summary' }, { key: 'experience' }, { key: 'skills', hidden: true }],
} } }));
renderAt(3);
await openContentTab();
const move = screen.getByRole('button', { name: 'Move Experience section down' });
move.focus();
fireEvent.click(move);
expect(move).toHaveFocus();
fireEvent.mouseDown(screen.getByRole('combobox', { name: 'Go to section' }));
expect(screen.getAllByRole('option').slice(0, 3).map(option => option.textContent)).toEqual([
'Professional Summary · Included', 'Skills · Omitted', 'Experience · Included',
]);
fireEvent.keyDown(screen.getByRole('listbox'), { key: 'Escape' });
fireEvent.click(screen.getByRole('button', { name: 'Save now' }));
await screen.findByText('Saved');
expect(mockedApi.put).toHaveBeenLastCalledWith('/cv/variants/3', expect.objectContaining({
expectedVersion: 4, settings: expect.objectContaining({ sections: expect.arrayContaining([
{ key: 'summary' }, { key: 'skills', hidden: true }, { key: 'experience' },
]) }),
}));
expect((mockedApi.put.mock.calls[mockedApi.put.mock.calls.length - 1][1] as any).settings.sections.slice(0, 3).map((row: any) => row.key)).toEqual(['summary', 'skills', 'experience']);
});
test('secondary duplicate action saves the current draft before creating the copy', async () => {
routeGet(() => Promise.resolve({ data: variant }));
mockedApi.post.mockImplementation((url: string) => Promise.resolve({ data: url.endsWith('/duplicate')
? { ...variant, id: 8, name: 'Backend CV copy' } : { html: '<p>CV</p>' } } as any));
renderAt(3);
await openContentTab();
fireEvent.change(screen.getByLabelText('Headline override'), { target: { value: 'Draft before copy' } });
fireEvent.click(screen.getByRole('button', { name: 'More document actions' }));
fireEvent.click(screen.getByRole('menuitem', { name: 'Duplicate' }));
await waitFor(() => expect(mockedApi.post).toHaveBeenCalledWith('/cv/variants/3/duplicate', expect.anything()));
expect(mockedApi.put).toHaveBeenCalledWith('/cv/variants/3', expect.objectContaining({
expectedVersion: 4, settings: expect.objectContaining({ headline: 'Draft before copy' }),
}));
expect(mockedApi.put.mock.invocationCallOrder[0]).toBeLessThan(mockedApi.post.mock.invocationCallOrder[
mockedApi.post.mock.calls.findIndex(call => call[0] === '/cv/variants/3/duplicate')
]);
});
test('pane navigation focuses the existing preview without changing the pending draft', async () => {
routeGet(() => Promise.resolve({ data: variant }));
renderAt(3);
await openContentTab();
const headline = screen.getByLabelText('Headline override');
fireEvent.change(headline, { target: { value: 'Retained draft' } });
const preview = screen.getByRole('region', { name: 'Live preview' });
preview.scrollIntoView = jest.fn();
fireEvent.click(screen.getByRole('button', { name: 'Go to preview' }));
expect(preview).toHaveFocus();
expect(headline).toHaveValue('Retained draft');
expect(screen.getByText('Unsaved')).toBeInTheDocument();
expect(mockedApi.put).not.toHaveBeenCalled();
});
test('deep link loads the exact variant named in the route', async () => {
routeGet(() => Promise.resolve({ data: variant } as any));
renderAt(3);
expect(await screen.findByDisplayValue('Backend CV')).toBeInTheDocument();
expect(mockedApi.get).toHaveBeenCalledWith('/cv/variants/3');
});
test('a variant that does not exist shows a safe error instead of an empty editor', async () => {
routeGet(() => Promise.reject(new Error('404')));
renderAt(3);
expect(await screen.findByText(/Could not open this CV/i)).toBeInTheDocument();
expect(screen.queryByDisplayValue('Backend CV')).not.toBeInTheDocument();
});
test("another user's variant is refused by the server and never rendered", async () => {
// Ownership is enforced server-side: CvVariantService scopes every read to the owner and the
// controller returns 404, so the client only ever sees the same safe failure.
routeGet(() => Promise.reject({ response: { status: 404 } }));
renderAt(3);
expect(await screen.findByText(/Could not open this CV/i)).toBeInTheDocument();
});
test('invalid names are explained and are not silently autosaved', async () => {
routeGet(() => Promise.resolve({ data: variant } as any));
renderAt(3);
fireEvent.change(await screen.findByLabelText('CV name'), { target: { value: '' } });
expect(screen.getByText('Enter a name before saving.')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Save now' })).toBeDisabled();
expect(mockedApi.put).not.toHaveBeenCalled();
});
test('failed autosave is visible and can be retried with the latest data', async () => {
routeGet(() => Promise.resolve({ data: variant } as any));
mockedApi.put.mockRejectedValueOnce(new Error('offline')).mockResolvedValueOnce({ data: variant } as any);
renderAt(3);
await openContentTab();
fireEvent.change(await screen.findByLabelText('Headline override'), { target: { value: 'Platform Engineer' } });
expect(screen.getByText('Unsaved')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Save now' }));
expect(await screen.findByText('Save failed')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Save now' }));
expect(await screen.findByText('Saved')).toBeInTheDocument();
expect(mockedApi.put).toHaveBeenLastCalledWith('/cv/variants/3', expect.objectContaining({
name: 'Backend CV',
settings: expect.objectContaining({ headline: 'Platform Engineer' }),
}));
});
test('session undo and redo restore content edits before autosave', async () => {
routeGet(() => Promise.resolve({ data: variant } as any));
mockedApi.put.mockResolvedValue({ data: variant } as any);
renderAt(3);
await openContentTab();
const headline = await screen.findByLabelText('Headline override');
fireEvent.change(headline, { target: { value: 'Platform Engineer' } });
expect(screen.getByRole('button', { name: 'Undo' })).toBeEnabled();
fireEvent.click(screen.getByRole('button', { name: 'Undo' }));
expect(headline).toHaveValue('');
expect(screen.getByRole('button', { name: 'Redo' })).toBeEnabled();
fireEvent.click(screen.getByRole('button', { name: 'Redo' }));
expect(headline).toHaveValue('Platform Engineer');
});
test('queued saves use acknowledged server versions without replacing newer local editing state', async () => {
routeGet(() => Promise.resolve({ data: variant } as any));
let release!: (value: any) => void;
mockedApi.put.mockImplementationOnce(() => new Promise(resolve => { release = resolve; }));
mockedApi.put.mockResolvedValueOnce({ data: { ...variant, version: 6 } } as any);
renderAt(3);
await openContentTab();
const headline = screen.getByLabelText('Headline override');
fireEvent.change(headline, { target: { value: 'First edit' } });
fireEvent.click(screen.getByRole('button', { name: 'Save now' }));
await waitFor(() => expect(mockedApi.put).toHaveBeenCalledTimes(1));
expect(mockedApi.put.mock.calls[0][1]).toEqual(expect.objectContaining({ expectedVersion: 4 }));
fireEvent.change(headline, { target: { value: 'Second edit' } });
fireEvent.click(screen.getByRole('button', { name: 'Save now' }));
expect(mockedApi.put).toHaveBeenCalledTimes(1);
release({ data: { ...variant, version: 5 } });
await screen.findByText('Saved');
expect(mockedApi.put.mock.calls[1][1]).toEqual(expect.objectContaining({ expectedVersion: 5, settings: expect.objectContaining({ headline: 'Second edit' }) }));
expect(headline).toHaveValue('Second edit');
});
test('revision conflict retains the draft and blocks later autosaves until explicit reload', async () => {
let saved = variant;
routeGet(() => Promise.resolve({ data: saved } as any));
mockedApi.put.mockRejectedValueOnce({ response: { status: 409, data: { code: 'cv_revision_conflict' } } });
renderAt(3);
await openContentTab();
const headline = screen.getByLabelText('Headline override');
fireEvent.change(headline, { target: { value: 'Recoverable draft' } });
fireEvent.click(screen.getByRole('button', { name: 'Save now' }));
expect(await screen.findByText('CV changed elsewhere')).toBeInTheDocument();
expect(headline).toHaveValue('Recoverable draft');
fireEvent.change(headline, { target: { value: 'Still recoverable' } });
expect(screen.queryByRole('button', { name: 'Save now' })).not.toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Download unsaved copy' })).toBeEnabled();
fireEvent.click(screen.getByRole('button', { name: 'Reload saved CV' }));
fireEvent.click(within(await screen.findByRole('dialog')).getByRole('button', { name: 'Cancel' }));
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument());
expect(headline).toHaveValue('Still recoverable');
saved = { ...variant, version: 6, settings: { ...variant.settings, headline: 'Reviewed remote state' } };
fireEvent.click(screen.getByRole('button', { name: 'Reload saved CV' }));
fireEvent.click(within(await screen.findByRole('dialog')).getByRole('button', { name: 'Reload saved CV' }));
await waitFor(() => expect(headline).toHaveValue('Reviewed remote state'));
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument());
expect(mockedApi.put).toHaveBeenCalledTimes(1);
mockedApi.put.mockResolvedValue({ data: { ...saved, version: 7 } } as any);
fireEvent.change(headline, { target: { value: 'After review' } });
fireEvent.click(screen.getByRole('button', { name: 'Save now' }));
await screen.findByText('Saved');
expect(mockedApi.put).toHaveBeenLastCalledWith('/cv/variants/3', expect.objectContaining({ expectedVersion: 6 }));
});
test('edits made while a confirmed reload is in flight remain recoverable', async () => {
let release!: (value: any) => void;
routeGet(() => Promise.resolve({ data: variant }));
mockedApi.put.mockRejectedValue({ response: { status: 409, data: { code: 'cv_revision_conflict' } } });
renderAt(3);
await openContentTab();
const headline = screen.getByLabelText('Headline override');
fireEvent.change(headline, { target: { value: 'Conflicting draft' } });
fireEvent.click(screen.getByRole('button', { name: 'Save now' }));
await screen.findByText('CV changed elsewhere');
routeGet(() => new Promise(resolve => { release = resolve; }));
fireEvent.click(screen.getByRole('button', { name: 'Reload saved CV' }));
fireEvent.click(within(await screen.findByRole('dialog')).getByRole('button', { name: 'Reload saved CV' }));
await waitFor(() => expect(release).toBeDefined());
fireEvent.change(headline, { target: { value: 'New edit during reload' } });
release({ data: { ...variant, version: 6, settings: { ...variant.settings, headline: 'Remote state' } } });
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument());
expect(headline).toHaveValue('New edit during reload');
expect(screen.getByText('CV changed elsewhere')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Download unsaved copy' })).toBeEnabled();
});
test('restoring history uses the current version and never replaces edits made during the request', async () => {
routeGet(() => Promise.resolve({ data: variant }), { sections: [] }, [
{ version: 4, isCurrent: true, source: 'manual', createdAtUtc: variant.updatedAtUtc },
{ version: 3, isCurrent: false, source: 'manual', createdAtUtc: variant.updatedAtUtc },
]);
let release!: (value: any) => void;
mockedApi.post.mockImplementation((url: string) => url.endsWith('/restore')
? new Promise(resolve => { release = resolve; })
: Promise.resolve({ data: { html: '<p>CV</p>' } } as any));
renderAt(3);
await screen.findByDisplayValue('Backend CV');
expect(screen.getByRole('region', { name: 'CV editing controls' })).toBeInTheDocument();
expect(screen.getByRole('tablist', { name: 'CV studio sections' })).toBeInTheDocument();
fireEvent.click(screen.getByRole('tab', { name: 'History' }));
fireEvent.click(await screen.findByRole('button', { name: 'Restore version 3' }));
fireEvent.click(within(await screen.findByRole('dialog')).getByRole('button', { name: 'Restore version' }));
await waitFor(() => expect(mockedApi.post).toHaveBeenCalledWith('/cv/variants/3/versions/3/restore', { expectedVersion: 4 }));
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument());
fireEvent.click(screen.getByRole('tab', { name: 'Content' }));
const headline = screen.getByLabelText('Headline override');
fireEvent.change(headline, { target: { value: 'New edit during restore' } });
release({ data: { ...variant, version: 5, settings: { ...variant.settings, headline: 'Historical content' } } });
await screen.findByText('CV changed elsewhere');
expect(headline).toHaveValue('New edit during restore');
expect(mockedApi.put).not.toHaveBeenCalled();
});
test('Content is the primary studio panel and all advanced tabs retain their controls', async () => {
routeGet(() => Promise.resolve({ data: variant } as any));
renderAt(3);
await screen.findByDisplayValue('Backend CV');
const content = screen.getByRole('tab', { name: 'Content' });
expect(content).toHaveAttribute('aria-selected', 'true');
expect(screen.getByRole('tablist', { name: 'CV studio sections' }).firstElementChild).toBe(content);
expect(screen.getByRole('tabpanel', { name: 'Content' })).toBeInTheDocument();
expect(screen.getByText('This CV only')).toBeInTheDocument();
expect(screen.getByRole('link', { name: 'Linked application' })).toHaveAttribute('href', '/jobs/7');
fireEvent.click(screen.getByRole('tab', { name: 'Customize' }));
expect(screen.getByText('Typography')).toBeInTheDocument();
expect(screen.getByLabelText('Body size')).toBeInTheDocument();
expect(screen.getByLabelText('Skills presentation')).toBeInTheDocument();
expect(screen.getByLabelText('Page size')).toBeInTheDocument();
expect(screen.getByLabelText('Columns')).toBeInTheDocument();
expect(screen.getByLabelText('Language')).toBeInTheDocument();
fireEvent.click(screen.getByRole('tab', { name: 'AI Tools' }));
expect(screen.getByText('Translate resume')).toBeInTheDocument();
expect(screen.getByText('Improve writing')).toBeInTheDocument();
expect(screen.getByText('Check spelling and grammar')).toBeInTheDocument();
});
test('choosing a template restores its own structure instead of inheriting the previous template layout', async () => {
const customizedVariant = {
...variant,
settings: {
...emptyCvVariantSettings(),
themeId: 'minimal',
layout: 'single',
headerStyle: 'plain',
headingStyle: 'plain',
sidebarWidthMm: 72,
sidebarSections: ['skills'],
accentColor: '#123456',
pageMarginMm: 9,
},
};
mockedApi.get.mockImplementation((url: string) => {
if (url === '/cv/variants/3') return Promise.resolve({ data: customizedVariant } as any);
if (url === '/cv/themes') return Promise.resolve({ data: [
{ id: 'minimal', name: 'Minimal', category: 'ATS', description: 'Quiet single column', layout: 'single', available: true, swatches: ['#111827', '#fff', '#fff'] },
{ id: 'technical', name: 'Technical', category: 'Technical', description: 'Structured sidebar', layout: 'sidebar-left', available: true, swatches: ['#0f4c5c', '#0f4c5c', '#fff'] },
] } as any);
if (url === '/cv/outline') return Promise.resolve({ data: { sections: [] } } as any);
if (url.startsWith('/cv/themes/')) return Promise.resolve({ data: { themeId: url.includes('technical') ? 'technical' : 'minimal', html: '<p>preview</p>', suggestedFileName: 'preview.pdf' } } as any);
return Promise.resolve({ data: [] } as any);
});
mockedApi.put.mockResolvedValue({ data: customizedVariant } as any);
renderAt(3);
await screen.findByDisplayValue('Backend CV');
fireEvent.click(screen.getByRole('tab', { name: 'Template' }));
fireEvent.click(screen.getByRole('button', { name: 'All templates' }));
fireEvent.click(screen.getByRole('button', { name: /Technical/ }));
fireEvent.click(screen.getByRole('button', { name: 'Save now' }));
await waitFor(() => expect(mockedApi.put).toHaveBeenCalledWith('/cv/variants/3', expect.objectContaining({
settings: expect.objectContaining({
themeId: 'technical',
layout: null,
headerStyle: null,
headingStyle: null,
sidebarWidthMm: null,
sidebarSections: null,
accentColor: null,
pageMarginMm: null,
}),
})));
});
test('editor sections, design controls and document settings render in Bokmål', async () => {
window.localStorage.setItem('uiLanguage', 'nb');
routeGet(() => Promise.resolve({ data: variant } as any));
renderAt(3);
await screen.findByDisplayValue('Backend CV');
fireEvent.click(screen.getByRole('tab', { name: 'Innhold' }));
expect(await screen.findByLabelText('Egen overskrift')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Utvid Erfaring' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Legg til innhold' })).toBeInTheDocument();
fireEvent.click(screen.getByRole('tab', { name: 'Tilpass' }));
expect(screen.getByText('Typografi')).toBeInTheDocument();
expect(screen.getByLabelText('Visning av ferdigheter')).toBeInTheDocument();
expect(screen.getByLabelText('Sidestørrelse')).toBeInTheDocument();
expect(screen.getByLabelText('Dokumentspråk')).toBeInTheDocument();
});
test('internal navigation warns and can be cancelled before discarding a pending edit', async () => {
routeGet(() => Promise.resolve({ data: variant } as any));
renderAt(3);
await openContentTab();
fireEvent.change(await screen.findByLabelText('Headline override'), { target: { value: 'Unsaved headline' } });
fireEvent.click(screen.getByRole('button', { name: 'Back to CVs' }));
const dialog = await screen.findByRole('dialog', { name: 'Discard unsaved CV changes?' });
expect(within(dialog).getByText('This CV has unsaved changes. Leave and discard them?')).toBeInTheDocument();
fireEvent.click(within(dialog).getByRole('button', { name: 'Cancel' }));
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument(), { timeout: 5000 });
expect(screen.getByDisplayValue('Backend CV')).toBeInTheDocument();
});
test('custom entries can be added, edited, reordered and deleted with confirmation', async () => {
routeGet(() => Promise.resolve({ data: variant } as any));
mockedApi.put.mockResolvedValue({ data: variant } as any);
renderAt(3);
await openContentTab();
await screen.findByLabelText('Headline override');
fireEvent.click(screen.getByRole('button', { name: 'Add content' }));
const addContentDialog = await screen.findByRole('dialog', { name: 'Add content' });
const additionalExperienceCard = within(addContentDialog).getByText('Additional Experience').closest('.MuiPaper-root');
expect(additionalExperienceCard).not.toBeNull();
fireEvent.click(within(additionalExperienceCard as HTMLElement).getByRole('button', { name: 'Add' }));
await waitFor(() => expect(screen.queryByRole('dialog', { name: 'Add content' })).not.toBeInTheDocument());
const title = screen.getByLabelText(/Section name for custom:/);
fireEvent.change(title, { target: { value: 'Selected projects' } });
fireEvent.click(screen.getByRole('button', { name: 'Expand Selected projects' }));
expect(within(screen.getByRole('group', { name: 'Selected projects' })).getByText('CV-only content')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Add entry' }));
fireEvent.change(screen.getByLabelText('Entry 1'), { target: { value: 'First project' } });
fireEvent.click(screen.getByRole('button', { name: 'Add entry' }));
fireEvent.change(screen.getByLabelText('Entry 2'), { target: { value: 'Second project' } });
fireEvent.click(screen.getByRole('button', { name: 'Move custom entry 2 up' }));
expect(screen.getByLabelText('Entry 1')).toHaveValue('Second project');
expect(screen.getByLabelText('Entry 2')).toHaveValue('First project');
const deleteEntry = screen.getByRole('button', { name: 'Delete custom entry 1' });
deleteEntry.focus();
fireEvent.click(deleteEntry);
let dialog = await screen.findByRole('dialog', { name: 'Delete custom entry' });
fireEvent.click(within(dialog).getByRole('button', { name: 'Cancel' }));
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument());
expect(deleteEntry).toHaveFocus();
expect(screen.getAllByLabelText(/^Entry /)).toHaveLength(2);
fireEvent.click(screen.getByRole('button', { name: 'Delete custom entry 1' }));
dialog = await screen.findByRole('dialog', { name: 'Delete custom entry' });
fireEvent.click(within(dialog).getByRole('button', { name: 'Delete entry' }));
await waitFor(() => expect(screen.getAllByLabelText(/^Entry /)).toHaveLength(1));
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument());
expect(screen.getByLabelText('Entry 1')).toHaveFocus();
await waitFor(() => expect(mockedApi.put).toHaveBeenLastCalledWith('/cv/variants/3', expect.objectContaining({
settings: expect.objectContaining({
customSections: [expect.objectContaining({ title: 'Selected projects', items: ['First project'] })],
}),
})), { timeout: 5000 });
expect(await screen.findByText('Saved')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Remove Selected projects section from this CV' }));
dialog = await screen.findByRole('dialog', { name: 'Delete custom section' });
fireEvent.click(within(dialog).getByRole('button', { name: 'Cancel' }));
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument());
expect(screen.getByLabelText(/Section name for custom:/)).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Remove Selected projects section from this CV' }));
dialog = await screen.findByRole('dialog', { name: 'Delete custom section' });
fireEvent.click(within(dialog).getByRole('button', { name: 'Delete section' }));
await waitFor(() => expect(screen.queryByLabelText(/Section name for custom:/)).not.toBeInTheDocument());
});
const contentOutline = {
sections: [{
key: 'experience', title: 'Experience', kind: 'entries', bullets: [], tags: [], entries: [
{ key: 'job-1', title: 'Engineer', subtitle: 'First Co', meta: '2021-01 – Present', bullets: ['Built APIs'], tags: [] },
{ key: 'job-2', title: 'Lead', subtitle: 'Second Co', bullets: ['Led teams'], tags: [] },
],
}],
};
test('[P5-10 PRES-C01 C07] source-backed entry edits identify their scope and save only this CV', async () => {
routeGet(() => Promise.resolve({ data: variant }), contentOutline);
renderAt(3);
await openContentTab();
fireEvent.click(screen.getByRole('button', { name: 'Expand Experience' }));
const entry = screen.getByRole('group', { name: 'Engineer' });
expect(entry).not.toHaveTextContent('2021-01 – Present');
expect(within(entry).getAllByText('Using Career Profile')).toHaveLength(3);
fireEvent.change(within(entry).getByLabelText('Title'), { target: { value: ' ' } });
expect(screen.getByRole('group', { name: 'Engineer' })).toBe(entry);
expect(within(entry).getByText('Blank uses Career Profile')).toBeInTheDocument();
fireEvent.change(within(entry).getByLabelText('Title'), { target: { value: 'Platform Engineer' } });
fireEvent.change(within(entry).getByLabelText('Subtitle'), { target: { value: 'CV-specific employer label' } });
fireEvent.change(within(entry).getByLabelText('Bullet points (one per line)'), { target: { value: 'Reviewed CV bullet\nSecond CV bullet' } });
expect(within(entry).getAllByText('Customized for this CV')).toHaveLength(3);
fireEvent.click(screen.getByRole('button', { name: 'Save now' }));
await screen.findByText('Saved');
expect(mockedApi.put).toHaveBeenLastCalledWith('/cv/variants/3', expect.objectContaining({
expectedVersion: 4, settings: expect.objectContaining({ overrides: {
'job-1': { title: 'Platform Engineer', subtitle: 'CV-specific employer label', bullets: ['Reviewed CV bullet', 'Second CV bullet'] },
} }),
}));
expect(mockedApi.put.mock.calls.every(([url]) => url === '/cv/variants/3')).toBe(true);
expect(contentOutline.sections[0].entries[0]).toEqual({ key: 'job-1', title: 'Engineer', subtitle: 'First Co', meta: '2021-01 – Present', bullets: ['Built APIs'], tags: [] });
});
test('[P5-10 PRES-C07 C10] resetting one entry field preserves omission, sibling override and order and supports undo', async () => {
const settings = { ...variant.settings,
sections: [{ key: 'experience', itemOrder: ['job-2', 'job-1'] }],
overrides: {
'job-1': { title: 'CV Engineer', subtitle: 'CV employer', bullets: ['CV bullet'], hidden: true },
'job-2': { title: 'CV Lead', bullets: ['Sibling item override'] },
},
};
routeGet(() => Promise.resolve({ data: { ...variant, settings } }), contentOutline);
renderAt(3);
await openContentTab();
fireEvent.click(screen.getByRole('button', { name: 'Expand Experience' }));
const entry = screen.getByRole('group', { name: 'CV Engineer' });
fireEvent.click(within(entry).getByRole('button', { name: 'Use Career Profile: Title' }));
expect(within(entry).getByLabelText('Title')).toHaveValue('Engineer');
expect(within(entry).getByLabelText('Subtitle')).toHaveValue('CV employer');
expect(within(entry).getByLabelText('Bullet points (one per line)')).toHaveValue('CV bullet');
fireEvent.click(within(entry).getByRole('button', { name: 'Use Career Profile: Subtitle' }));
expect(within(entry).getByLabelText('Subtitle')).toHaveValue('First Co');
fireEvent.click(within(entry).getByRole('button', { name: 'Use Career Profile: Bullet points (one per line)' }));
expect(within(entry).getByLabelText('Bullet points (one per line)')).toHaveValue('Built APIs');
expect(within(entry).getAllByText('Using Career Profile')).toHaveLength(3);
expect(within(entry).getByRole('button', { name: 'Show Engineer entry' })).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Undo' }));
expect(within(entry).getByLabelText('Title')).toHaveValue('Engineer');
expect(within(entry).getByLabelText('Subtitle')).toHaveValue('First Co');
expect(within(entry).getByLabelText('Bullet points (one per line)')).toHaveValue('CV bullet');
fireEvent.click(screen.getByRole('button', { name: 'Redo' }));
fireEvent.click(screen.getByRole('button', { name: 'Save now' }));
await screen.findByText('Saved');
const saved = mockedApi.put.mock.calls[mockedApi.put.mock.calls.length - 1][1] as any;
expect(saved.settings.overrides).toEqual({ 'job-1': { hidden: true }, 'job-2': settings.overrides['job-2'] });
expect(saved.settings.sections[0].itemOrder).toEqual(['job-2', 'job-1']);
});
test('[P5-10 PRES-C07 C10 E08] omitted entries remain editable and retain their draft after a failed save', async () => {
routeGet(() => Promise.resolve({ data: { ...variant, settings: { ...variant.settings, overrides: { 'job-1': { hidden: true } } } } }), contentOutline);
mockedApi.put.mockRejectedValueOnce(new Error('offline')).mockResolvedValueOnce({ data: { ...variant, version: 5 } } as any);
renderAt(3);
await openContentTab();
fireEvent.click(screen.getByRole('button', { name: 'Expand Experience' }));
const entry = screen.getByRole('group', { name: 'Engineer' });
expect(within(entry).getByText('Omitted', { exact: true })).toBeInTheDocument();
expect(within(entry).getByLabelText('Title')).toBeEnabled();
expect(within(entry).getByLabelText('Subtitle')).toBeEnabled();
const bullets = within(entry).getByLabelText('Bullet points (one per line)');
fireEvent.change(bullets, { target: { value: 'Retained omitted-entry draft' } });
fireEvent.click(screen.getByRole('button', { name: 'Save now' }));
await screen.findByText('Save failed');
expect(bullets).toHaveValue('Retained omitted-entry draft');
expect(within(entry).getByRole('button', { name: 'Show Engineer entry' })).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Save now' }));
await screen.findByText('Saved');
expect(mockedApi.put).toHaveBeenLastCalledWith('/cv/variants/3', expect.objectContaining({
settings: expect.objectContaining({ overrides: { 'job-1': { hidden: true, bullets: ['Retained omitted-entry draft'] } } }),
}));
});
test('[P5-10 PRES-C01 C07] section content reset inherits source without changing omission or presentation', async () => {
routeGet(() => Promise.resolve({ data: { ...variant, settings: { ...variant.settings,
sections: [{ key: 'skills', items: ['CV-specific skill'], hidden: true, presentation: 'bubble' }],
} } }), { sections: [{ key: 'skills', title: 'Skills', kind: 'tags', tags: ['C#', 'Norsk'], bullets: [], entries: [] }] });
renderAt(3);
await openContentTab();
fireEvent.click(screen.getByRole('button', { name: 'Expand Skills' }));
const section = screen.getByRole('group', { name: 'Skills' });
expect(within(section).getByLabelText('Items (one per line)')).toHaveValue('CV-specific skill');
fireEvent.click(within(section).getByRole('button', { name: 'Use Career Profile content' }));
expect(within(section).getByLabelText('Items (one per line)')).toHaveValue('C#\nNorsk');
expect(within(section).getByRole('button', { name: 'Show Skills section' })).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Save now' }));
await screen.findByText('Saved');
const saved = mockedApi.put.mock.calls[mockedApi.put.mock.calls.length - 1][1] as any;
const row = saved.settings.sections.find((item: any) => item.key === 'skills');
expect(row).toEqual(expect.objectContaining({ key: 'skills', hidden: true, presentation: 'bubble' }));
expect(row.items).toBeUndefined();
expect(mockedApi.put.mock.calls.every(([url]) => url === '/cv/variants/3')).toBe(true);
});
test('profile-backed sections expand, reorder, hide and persist variant-only overrides', async () => {
routeGet(() => Promise.resolve({ data: variant } as any), {
sections: [{
key: 'experience', title: 'Experience', kind: 'entries', bullets: [], tags: [], entries: [
{ key: 'job-1', title: 'Engineer', subtitle: 'First Co', bullets: ['Built APIs'], tags: [] },
{ key: 'job-2', title: 'Lead', subtitle: 'Second Co', bullets: ['Led teams'], tags: [] },
],
}],
});
mockedApi.put.mockResolvedValue({ data: variant } as any);
renderAt(3);
await openContentTab();
fireEvent.click(await screen.findByRole('button', { name: 'Expand Experience' }));
expect(screen.getByRole('button', { name: 'Collapse Experience' })).toHaveAttribute('aria-expanded', 'true');
fireEvent.mouseDown(screen.getByLabelText('Presentation'));
fireEvent.click(await screen.findByRole('option', { name: 'Grid' }));
fireEvent.click(within(screen.getByRole('group', { name: 'Columns' })).getByRole('button', { name: '2' }));
fireEvent.click(screen.getByRole('button', { name: 'Move Engineer entry down' }));
fireEvent.click(screen.getByRole('button', { name: 'Hide Lead entry' }));
fireEvent.click(screen.getByRole('button', { name: 'Move Experience section down' }));
await waitFor(() => expect(mockedApi.put).toHaveBeenLastCalledWith('/cv/variants/3', expect.objectContaining({
settings: expect.objectContaining({
sections: expect.arrayContaining([expect.objectContaining({ key: 'experience', itemOrder: ['job-2', 'job-1'], presentation: 'grid', columns: 2 })]),
overrides: expect.objectContaining({ 'job-2': expect.objectContaining({ hidden: true }) }),
}),
})), { timeout: 5000 });
expect(await screen.findByText('Saved')).toBeInTheDocument();
});
test('preview failure is visible and retryable without leaving the editor', async () => {
routeGet(() => Promise.resolve({ data: variant } as any));
mockedApi.post.mockRejectedValueOnce(new Error('preview offline')).mockResolvedValueOnce({ data: { themeId: 'modern', html: '<p>retry</p>', suggestedFileName: 'cv.pdf' } } as any);
renderAt(3);
expect(await screen.findByText('Preview unavailable')).toBeInTheDocument();
expect(screen.getByRole('region', { name: 'Live preview' })).toHaveAttribute('aria-busy', 'false');
expect(screen.queryByText('updating…')).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Retry preview' }));
await waitFor(() => expect(screen.getByTitle('CV preview').getAttribute('srcdoc')).toContain('<p>retry</p>'));
expect(screen.getByTitle('CV preview')).toHaveAttribute('sandbox', 'allow-same-origin allow-scripts');
await waitFor(() => expect(screen.queryByText('Preview unavailable')).not.toBeInTheDocument());
});
function settlePreview(count: number) {
const frame = screen.getByTitle('CV preview') as HTMLIFrameElement;
const doc = frame.contentDocument!;
doc.body.innerHTML = '<div class="page">Measured document</div>';
delete doc.documentElement.dataset.cvPreviewReady;
fireEvent.load(frame);
act(() => {
doc.documentElement.dataset.cvPreviewPages = String(count);
doc.documentElement.dataset.cvPreviewReady = 'true';
frame.contentWindow!.dispatchEvent(new Event('cv-preview-ready'));
});
}
test('[P5-11 PRES-C06 E08] automatic fit attaches when the asynchronously loaded preview pane appears', async () => {
const width = jest.spyOn(HTMLElement.prototype, 'clientWidth', 'get').mockImplementation(function (this: HTMLElement) {
return this.getAttribute('aria-label') === 'Live preview' ? 400 : 0;
});
try {
routeGet(() => Promise.resolve({ data: variant }));
renderAt(3);
await screen.findByDisplayValue('Backend CV');
expect(await screen.findByText('Fit width · 48%')).toBeInTheDocument();
} finally { width.mockRestore(); }
});
test('[P5-11 PRES-C06 C12] page count is announced only after the renderer has settled', async () => {
routeGet(() => Promise.resolve({ data: variant }));
renderAt(3);
const region = await screen.findByRole('region', { name: 'Live preview' });
await waitFor(() => expect(screen.getByTitle('CV preview').getAttribute('srcdoc')).toContain('<p>cv</p>'));
expect(region).toHaveAttribute('aria-busy', 'true');
expect(screen.queryByText('1-page CV')).not.toBeInTheDocument();
settlePreview(3);
expect(region).toHaveAttribute('aria-busy', 'false');
expect(screen.getByText('3-page CV')).toBeInTheDocument();
expect(region.querySelectorAll('[data-cv-page]')).toHaveLength(3);
});
test('[P5-11 PRES-C06 E09] overlapping preview successes and failures cannot replace the newest document', async () => {
const pending: { resolve: (value: any) => void; reject: (reason: Error) => void }[] = [];
mockedApi.post.mockImplementation(() => new Promise((resolve, reject) => { pending.push({ resolve, reject }); }));
try {
routeGet(() => Promise.resolve({ data: variant }));
renderAt(3);
await openContentTab();
const headline = screen.getByLabelText('Headline override');
const region = screen.getByRole('region', { name: 'Live preview' });
await waitFor(() => expect(pending).toHaveLength(1));
fireEvent.change(headline, { target: { value: 'Newer draft' } });
await waitFor(() => expect(pending).toHaveLength(2));
await act(async () => { pending[1].resolve({ data: { html: '<p>Newer document</p>' } }); });
settlePreview(2);
await act(async () => { pending[0].resolve({ data: { html: '<p>Stale document</p>' } }); });
expect(screen.getByTitle('CV preview').getAttribute('srcdoc')).toContain('Newer document');
expect(region).toHaveAttribute('aria-busy', 'false');
fireEvent.change(headline, { target: { value: 'Intermediate draft' } });
await waitFor(() => expect(pending).toHaveLength(3));
fireEvent.change(headline, { target: { value: 'Newest draft' } });
await waitFor(() => expect(pending).toHaveLength(4));
await act(async () => { pending[3].resolve({ data: { html: '<p>Newest document</p>' } }); });
settlePreview(4);
await act(async () => { pending[2].reject(new Error('stale failure')); });
expect(screen.getByTitle('CV preview').getAttribute('srcdoc')).toContain('Newest document');
expect(screen.queryByText('Preview unavailable')).not.toBeInTheDocument();
expect(region).toHaveAttribute('aria-busy', 'false');
expect(screen.getByText('4-page CV')).toBeInTheDocument();
expect(headline).toHaveValue('Newest draft');
} finally { mockedApi.post.mockResolvedValue({ data: { html: '<p>cv</p>' } } as any); }
});
test('[P5-11 PRES-C06 C10 E08] returning from preview focuses the existing editor without changing the pending draft', async () => {
routeGet(() => Promise.resolve({ data: variant }));
renderAt(3);
await openContentTab();
const headline = screen.getByLabelText('Headline override');
fireEvent.change(headline, { target: { value: 'Retained preview draft' } });
const editor = screen.getByRole('region', { name: 'CV editing controls' });
editor.scrollIntoView = jest.fn();
fireEvent.click(screen.getByRole('button', { name: 'Go to editor' }));
await waitFor(() => expect(editor).toHaveFocus());
expect(headline).toHaveValue('Retained preview draft');
expect(screen.getByText('Unsaved')).toBeInTheDocument();
expect(mockedApi.put).not.toHaveBeenCalled();
});
async function reviewDocumentSuggestion(result: string) {
routeGet(() => Promise.resolve({ data: variant }), { headline: 'Canonical headline', sections: [] });
mockedApi.post.mockImplementation((url: string) => Promise.resolve({ data: url === '/cv/ai/assist'
? { result } : { html: '<p>Preview</p>' } } as never));
mockedApi.put.mockImplementation((_url, body) => Promise.resolve({ data: { ...variant, ...(body as object) } } as never));
renderAt(3);
await screen.findByDisplayValue('Backend CV');
fireEvent.click(screen.getByRole('tab', { name: 'AI Tools' }));
fireEvent.click(screen.getAllByRole('button', { name: 'Review suggestion' })[0]);
await waitFor(() => expect(mockedApi.post).toHaveBeenCalledWith('/cv/ai/assist', expect.objectContaining({
action: 'document-translate', text: JSON.stringify([{ id: 'headline', items: ['Canonical headline'] }]),
})));
}
test.each(['Apply', 'Reject'])('[PRES-C11 PRES-C07] whole-document AI %s changes only an explicitly accepted CV override', async action => {
await reviewDocumentSuggestion(JSON.stringify([{ id: 'headline', items: ['Reviewed headline'] }]));
expect(await screen.findByText('Review 1 proposed change(s)')).toBeInTheDocument();
expect(screen.getByText('Canonical headline')).toBeInTheDocument();
expect(screen.getByText('Reviewed headline')).toBeInTheDocument();
expect(mockedApi.put).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole('button', { name: action }));
expect(screen.queryByText('Review 1 proposed change(s)')).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('tab', { name: 'Content' }));
expect(await screen.findByLabelText('Headline override')).toHaveValue(action === 'Apply' ? 'Reviewed headline' : '');
if (action === 'Apply') {
fireEvent.click(screen.getByRole('button', { name: 'Save now' }));
await waitFor(() => expect(mockedApi.put).toHaveBeenCalledWith('/cv/variants/3', expect.objectContaining({
settings: expect.objectContaining({ headline: 'Reviewed headline' }),
})));
expect(mockedApi.put.mock.calls.every(([url]) => url === '/cv/variants/3')).toBe(true);
} else {
expect(screen.getByText('Saved')).toBeInTheDocument();
expect(mockedApi.put).not.toHaveBeenCalled();
}
});
test.each([
['invalid JSON', 'not JSON'],
['missing original ID', JSON.stringify([{ id: 'unknown', items: ['Wrong document'] }])],
['duplicate original ID', JSON.stringify([{ id: 'headline', items: ['First'] }, { id: 'headline', items: ['Second'] }])],
])('[PRES-C11] unsafe whole-document response (%s) cannot be applied', async (_case, result) => {
await reviewDocumentSuggestion(result);
expect(await screen.findByText('AI suggestion failed.')).toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Apply' })).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('tab', { name: 'Content' }));
expect(await screen.findByLabelText('Headline override')).toHaveValue('');
expect(mockedApi.put).not.toHaveBeenCalled();
});