fix: preserve blank CVs and reliable editor undo

This commit is contained in:
cesnimda
2026-10-27 07:37:03 +01:00
parent 5d4b8f23ce
commit 663023a088
22 changed files with 950 additions and 101 deletions
@@ -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();
});