799 lines
45 KiB
TypeScript
799 lines
45 KiB
TypeScript
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();
|
||
});
|