fix: preserve blank CVs and reliable editor undo
This commit is contained in:
@@ -26,6 +26,8 @@ jest.mock('./api', () => ({
|
||||
}));
|
||||
|
||||
const mockedApi = api as jest.Mocked<typeof api>;
|
||||
const originalRequest = global.Request;
|
||||
const originalAbortController = global.AbortController;
|
||||
|
||||
const variant = {
|
||||
id: 3,
|
||||
@@ -38,12 +40,12 @@ const variant = {
|
||||
updatedAtUtc: '2026-07-19T10:00:00Z',
|
||||
};
|
||||
|
||||
function renderAt(id: number) {
|
||||
function renderAt(id: number, strict = false) {
|
||||
const router = createMemoryRouter([
|
||||
{ path: '/career/builder/:id', element: <CvBuilderEditor /> },
|
||||
{ path: '/career/builder', element: <div>CV list destination</div> },
|
||||
], { initialEntries: [`/career/builder/${id}`] });
|
||||
return render(
|
||||
const tree = (
|
||||
<I18nProvider>
|
||||
<ToastProvider>
|
||||
<ConfirmProvider>
|
||||
@@ -52,8 +54,9 @@ function renderAt(id: number) {
|
||||
</PromptProvider>
|
||||
</ConfirmProvider>
|
||||
</ToastProvider>
|
||||
</I18nProvider>,
|
||||
</I18nProvider>
|
||||
);
|
||||
return { ...render(strict ? <React.StrictMode>{tree}</React.StrictMode> : tree), router };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -65,7 +68,11 @@ beforeEach(() => {
|
||||
mockedApi.post.mockResolvedValue({ data: { themeId: 'nordic', html: '<p>cv</p>', suggestedFileName: 'cv.pdf' } } as any);
|
||||
});
|
||||
|
||||
afterEach(() => window.localStorage.removeItem('uiLanguage'));
|
||||
afterEach(() => {
|
||||
window.localStorage.removeItem('uiLanguage');
|
||||
global.Request = originalRequest;
|
||||
global.AbortController = originalAbortController;
|
||||
});
|
||||
|
||||
function routeGet(onVariant: () => Promise<any>, outline: any = { sections: [] }, history: any[] = []) {
|
||||
mockedApi.get.mockImplementation((url: string) => {
|
||||
@@ -82,6 +89,143 @@ async function openContentTab() {
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'Content' }));
|
||||
}
|
||||
|
||||
test('Strict Mode groups a typing burst into one undo step and preserves redo through autosave', async () => {
|
||||
routeGet(() => Promise.resolve({ data: variant }));
|
||||
renderAt(3, true);
|
||||
await openContentTab();
|
||||
const headline = screen.getByLabelText('Headline override');
|
||||
act(() => headline.focus());
|
||||
fireEvent.change(headline, { target: { value: 'D' } });
|
||||
fireEvent.change(headline, { target: { value: 'Developer' } });
|
||||
act(() => headline.blur());
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save now' }));
|
||||
await screen.findByText('Saved');
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Undo' }));
|
||||
expect(headline).toHaveValue('');
|
||||
expect(screen.getByRole('button', { name: 'Undo' })).toBeDisabled();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Redo' }));
|
||||
expect(headline).toHaveValue('Developer');
|
||||
});
|
||||
|
||||
test('document shortcuts undo and redo renaming, preserve native input undo and discard branched redo', async () => {
|
||||
routeGet(() => Promise.resolve({ data: variant }));
|
||||
renderAt(3, true);
|
||||
await openContentTab();
|
||||
const name = screen.getByDisplayValue('Backend CV');
|
||||
act(() => name.focus());
|
||||
fireEvent.change(name, { target: { value: 'Frontend CV' } });
|
||||
fireEvent.keyDown(name, { key: 'z', ctrlKey: true });
|
||||
expect(name).toHaveValue('Frontend CV');
|
||||
act(() => name.blur());
|
||||
fireEvent.keyDown(document, { key: 'z', ctrlKey: true });
|
||||
expect(name).toHaveValue('Backend CV');
|
||||
fireEvent.keyDown(document, { key: 'y', ctrlKey: true });
|
||||
expect(name).toHaveValue('Frontend CV');
|
||||
fireEvent.keyDown(document, { key: 'z', ctrlKey: true });
|
||||
fireEvent.keyDown(document, { key: 'Z', ctrlKey: true, shiftKey: true });
|
||||
expect(name).toHaveValue('Frontend CV');
|
||||
fireEvent.keyDown(document, { key: 'z', ctrlKey: true });
|
||||
fireEvent.change(screen.getByLabelText('Headline override'), { target: { value: 'New branch' } });
|
||||
expect(screen.getByRole('button', { name: 'Redo' })).toBeDisabled();
|
||||
});
|
||||
|
||||
test('a revision conflict disables document history without silently writing an undo', async () => {
|
||||
mockedApi.put.mockRejectedValue({ response: { status: 409, data: { code: 'cv_revision_conflict' } } });
|
||||
routeGet(() => Promise.resolve({ data: variant }));
|
||||
renderAt(3);
|
||||
await openContentTab();
|
||||
fireEvent.change(screen.getByLabelText('Headline override'), { target: { value: 'Retained local edit' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save now' }));
|
||||
await screen.findByRole('button', { name: 'Reload saved CV' });
|
||||
expect(screen.getByRole('button', { name: 'Undo' })).toBeDisabled();
|
||||
fireEvent.keyDown(document, { key: 'z', ctrlKey: true });
|
||||
expect(screen.getByLabelText('Headline override')).toHaveValue('Retained local edit');
|
||||
expect(mockedApi.put).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('switching documents hides the previous draft while loading and starts fresh history', async () => {
|
||||
// JSDOM omits Node's fetch globals, which React Router needs for navigation.
|
||||
global.Request = jest.requireActual('node:vm').runInThisContext('Request');
|
||||
global.AbortController = jest.requireActual('node:vm').runInThisContext('AbortController');
|
||||
let loadNext!: (value: any) => void;
|
||||
routeGet(() => Promise.resolve({ data: variant }));
|
||||
const previousGet = mockedApi.get.getMockImplementation()!;
|
||||
mockedApi.get.mockImplementation((url: string, ...args: any[]) => url === '/cv/variants/4'
|
||||
? new Promise(resolve => { loadNext = resolve; }) : previousGet(url, ...args));
|
||||
const { router } = renderAt(3);
|
||||
await openContentTab();
|
||||
fireEvent.change(screen.getByLabelText('Headline override'), { target: { value: 'Only first document' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save now' }));
|
||||
await screen.findByText('Saved');
|
||||
await act(async () => { await router.navigate('/career/builder/4'); });
|
||||
expect(screen.queryByDisplayValue('Backend CV')).not.toBeInTheDocument();
|
||||
expect(screen.queryByDisplayValue('Only first document')).not.toBeInTheDocument();
|
||||
await act(async () => loadNext({ data: { ...variant, id: 4, name: 'Second CV' } }));
|
||||
await screen.findByDisplayValue('Second CV');
|
||||
expect(screen.getByRole('button', { name: 'Undo' })).toBeDisabled();
|
||||
fireEvent.keyDown(document, { key: 'z', ctrlKey: true });
|
||||
expect(screen.getByLabelText('Headline override')).toHaveValue('');
|
||||
});
|
||||
|
||||
test('explicit blank editing excludes master content and includes a profile section only after an Add action', async () => {
|
||||
routeGet(() => Promise.resolve({ data: { ...variant, settings: { ...variant.settings,
|
||||
sectionsExplicit: true, includeProfileContact: false,
|
||||
} } }), { headline: 'Private master title', sections: [
|
||||
{ key: 'summary', kind: 'bullets', title: 'Professional Summary', bullets: ['Private master summary'] },
|
||||
] });
|
||||
renderAt(3);
|
||||
await openContentTab();
|
||||
expect(screen.queryByRole('button', { name: 'Expand Professional Summary' })).not.toBeInTheDocument();
|
||||
expect(screen.getByRole('combobox', { name: 'Go to section' })).toHaveAttribute('aria-disabled', 'true');
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add content' }));
|
||||
const card = screen.getByText('Professional Summary', { selector: 'h6' }).parentElement!;
|
||||
fireEvent.click(within(card).getByRole('button', { name: 'Add' }));
|
||||
expect(await screen.findByRole('button', { name: 'Expand Professional Summary' })).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Undo' }));
|
||||
expect(screen.queryByRole('button', { name: 'Expand Professional Summary' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('a blank CV can deliberately add an additional Career Profile section', async () => {
|
||||
routeGet(() => Promise.resolve({ data: { ...variant, settings: { ...variant.settings,
|
||||
sectionsExplicit: true, includeProfileContact: false,
|
||||
} } }), { sections: [
|
||||
{ key: 'other:0', kind: 'bullets', title: 'Community service', bullets: ['Explicitly sourced volunteering'] },
|
||||
] });
|
||||
renderAt(3);
|
||||
await openContentTab();
|
||||
expect(screen.queryByRole('button', { name: 'Expand Community service' })).not.toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add content' }));
|
||||
const card = screen.getByText('Community service', { selector: 'h6' }).parentElement!;
|
||||
fireEvent.click(within(card).getByRole('button', { name: 'Add' }));
|
||||
expect(await screen.findByRole('button', { name: 'Expand Community service' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('a historical restore response from the previous document cannot change the newly opened CV', async () => {
|
||||
global.Request = jest.requireActual('node:vm').runInThisContext('Request');
|
||||
global.AbortController = jest.requireActual('node:vm').runInThisContext('AbortController');
|
||||
let finishRestore!: (value: any) => void;
|
||||
routeGet(() => Promise.resolve({ data: variant }), { sections: [] }, [
|
||||
{ version: 3, source: 'manual', createdAtUtc: variant.updatedAtUtc, isCurrent: false },
|
||||
]);
|
||||
const previousGet = mockedApi.get.getMockImplementation()!;
|
||||
mockedApi.get.mockImplementation((url: string, ...args: any[]) => url === '/cv/variants/4'
|
||||
? Promise.resolve({ data: { ...variant, id: 4, name: 'Second CV' } } as any) : previousGet(url, ...args));
|
||||
mockedApi.post.mockImplementation((url: string) => url.endsWith('/restore')
|
||||
? new Promise(resolve => { finishRestore = resolve; })
|
||||
: Promise.resolve({ data: { html: '<p>CV</p>' } } as any));
|
||||
const { router } = renderAt(3);
|
||||
await openContentTab();
|
||||
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 act(async () => { await router.navigate('/career/builder/4'); });
|
||||
await screen.findByDisplayValue('Second CV');
|
||||
await act(async () => finishRestore({ data: { ...variant, name: 'Old restored name', version: 5 } }));
|
||||
expect(screen.getByDisplayValue('Second CV')).toBeInTheDocument();
|
||||
expect(screen.queryByText('CV changed elsewhere')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('content sections follow the editing-region heading without mutating CV settings', async () => {
|
||||
routeGet(() => Promise.resolve({ data: variant }));
|
||||
renderAt(3);
|
||||
@@ -999,6 +1143,11 @@ test('a newer draft is flushed before stale proposal acceptance fails without re
|
||||
expect(screen.getByDisplayValue('Newer retained name')).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'Content' }));
|
||||
expect(screen.getByLabelText('Headline override')).toHaveValue('');
|
||||
// Renaming is now an editor transaction; the rejected proposal must add no history step.
|
||||
expect(screen.getByRole('button', { name: 'Undo' })).toBeEnabled();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Undo' }));
|
||||
expect(screen.getByDisplayValue('Backend CV')).toBeInTheDocument();
|
||||
expect(screen.getByLabelText('Headline override')).toHaveValue('');
|
||||
expect(screen.getByRole('button', { name: 'Undo' })).toBeDisabled();
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user