fix(cv): gate AI proposals on grounding and source freshness
This commit is contained in:
@@ -755,15 +755,21 @@ test('[P5-11 PRES-C06 C10 E08] returning from preview focuses the existing edito
|
||||
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));
|
||||
? { original: JSON.stringify([{ id: 'headline', items: ['Canonical headline'] }]), result, proposalToken: 'bound-proposal' }
|
||||
: url === '/cv/variants/3/ai/apply'
|
||||
? { ...variant, version: 5, settings: { ...variant.settings, headline: 'Reviewed headline' } }
|
||||
: { 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'] }]),
|
||||
action: 'document-translate', variantId: 3, expectedVersion: 4,
|
||||
})));
|
||||
const request = mockedApi.post.mock.calls.find(([url]) => url === '/cv/ai/assist')![1] as any;
|
||||
expect(request).not.toHaveProperty('text');
|
||||
expect(request).not.toHaveProperty('targetId');
|
||||
}
|
||||
|
||||
test.each(['Apply', 'Reject'])('[PRES-C11 PRES-C07] whole-document AI %s changes only an explicitly accepted CV override', async action => {
|
||||
@@ -773,15 +779,13 @@ test.each(['Apply', 'Reject'])('[PRES-C11 PRES-C07] whole-document AI %s changes
|
||||
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();
|
||||
await waitFor(() => 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);
|
||||
expect(mockedApi.post).toHaveBeenCalledWith('/cv/variants/3/ai/apply', { proposalToken: 'bound-proposal' });
|
||||
expect(screen.getByText('Saved')).toBeInTheDocument();
|
||||
expect(mockedApi.put).not.toHaveBeenCalled();
|
||||
} else {
|
||||
expect(screen.getByText('Saved')).toBeInTheDocument();
|
||||
expect(mockedApi.put).not.toHaveBeenCalled();
|
||||
@@ -800,3 +804,124 @@ test.each([
|
||||
expect(await screen.findByLabelText('Headline override')).toHaveValue('');
|
||||
expect(mockedApi.put).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('bound document generation saves the pending draft, reviews server Original, and accepted AI remains undoable', async () => {
|
||||
routeGet(() => Promise.resolve({ data: variant }), { headline: 'Outline headline', sections: [] });
|
||||
mockedApi.put.mockImplementation((_url, body) => Promise.resolve({ data: { ...variant, ...(body as object), version: 5 } } as never));
|
||||
mockedApi.post.mockImplementation((url: string) => Promise.resolve({ data: url === '/cv/ai/assist'
|
||||
? { original: JSON.stringify([{ id: 'headline', items: ['Server source headline'] }]), result: JSON.stringify([{ id: 'headline', items: ['Accepted headline'] }]), proposalToken: 'saved-source-token' }
|
||||
: url === '/cv/variants/3/ai/apply'
|
||||
? { ...variant, version: 6, settings: { ...variant.settings, headline: 'Accepted headline' } }
|
||||
: { html: '<p>Preview</p>' } } as never));
|
||||
renderAt(3);
|
||||
await openContentTab();
|
||||
fireEvent.change(screen.getByLabelText('Headline override'), { target: { value: 'Draft before generation' } });
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'AI Tools' }));
|
||||
fireEvent.click(screen.getAllByRole('button', { name: 'Review suggestion' })[0]);
|
||||
expect(await screen.findByText('Server source headline')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Outline headline')).not.toBeInTheDocument();
|
||||
expect(mockedApi.put).toHaveBeenCalledTimes(1);
|
||||
expect(mockedApi.post).toHaveBeenCalledWith('/cv/ai/assist', expect.objectContaining({ variantId: 3, expectedVersion: 5 }));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Apply' }));
|
||||
await waitFor(() => expect(screen.queryByRole('button', { name: 'Apply' })).not.toBeInTheDocument());
|
||||
expect(mockedApi.put).toHaveBeenCalledTimes(1);
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'Content' }));
|
||||
expect(screen.getByLabelText('Headline override')).toHaveValue('Accepted headline');
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Undo' }));
|
||||
expect(screen.getByLabelText('Headline override')).toHaveValue('Draft before generation');
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save now' }));
|
||||
await waitFor(() => expect(mockedApi.put).toHaveBeenLastCalledWith('/cv/variants/3', expect.objectContaining({ expectedVersion: 6, settings: expect.objectContaining({ headline: 'Draft before generation' }) })));
|
||||
await screen.findByText('Saved');
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Undo' }));
|
||||
expect(screen.getByLabelText('Headline override')).toHaveValue('');
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Redo' }));
|
||||
expect(screen.getByLabelText('Headline override')).toHaveValue('Draft before generation');
|
||||
});
|
||||
|
||||
test('a newer draft is flushed before stale proposal acceptance fails without rebasing or changing content', async () => {
|
||||
await reviewDocumentSuggestion(JSON.stringify([{ id: 'headline', items: ['Reviewed headline'] }]));
|
||||
mockedApi.post.mockImplementation((url: string) => url === '/cv/variants/3/ai/apply'
|
||||
? Promise.reject({ response: { status: 409, data: { code: 'cv_ai_source_changed' } } })
|
||||
: Promise.resolve({ data: { html: '<p>Preview</p>' } } as never));
|
||||
mockedApi.put.mockImplementation((_url, body) => Promise.resolve({ data: { ...variant, ...(body as object), version: 5 } } as never));
|
||||
fireEvent.change(screen.getByDisplayValue('Backend CV'), { target: { value: 'Newer retained name' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Apply' }));
|
||||
expect(await screen.findByText('AI suggestion failed.')).toBeInTheDocument();
|
||||
expect(mockedApi.put).toHaveBeenCalledWith('/cv/variants/3', expect.objectContaining({ name: 'Newer retained name', expectedVersion: 4 }));
|
||||
expect(mockedApi.post).toHaveBeenCalledWith('/cv/variants/3/ai/apply', { proposalToken: 'bound-proposal' });
|
||||
expect(mockedApi.post.mock.calls.filter(([url]) => url === '/cv/ai/assist')).toHaveLength(1);
|
||||
expect(screen.getByDisplayValue('Newer retained name')).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'Content' }));
|
||||
expect(screen.getByLabelText('Headline override')).toHaveValue('');
|
||||
expect(screen.getByRole('button', { name: 'Undo' })).toBeDisabled();
|
||||
});
|
||||
|
||||
test('late document proposals cannot return after tab unmount or a source undo', async () => {
|
||||
let resolveProposal: (response: any) => void = () => {};
|
||||
routeGet(() => Promise.resolve({ data: variant }), { headline: 'Canonical headline', sections: [] });
|
||||
mockedApi.post.mockImplementation((url: string) => url === '/cv/ai/assist'
|
||||
? new Promise(resolve => { resolveProposal = resolve; })
|
||||
: Promise.resolve({ data: { html: '<p>Preview</p>' } } as never));
|
||||
renderAt(3);
|
||||
await openContentTab();
|
||||
fireEvent.change(screen.getByLabelText('Headline override'), { target: { value: 'Generation source' } });
|
||||
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.any(Object)));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Undo' }));
|
||||
await act(async () => resolveProposal({ data: { original: JSON.stringify([{ id: 'headline', items: ['Generation source'] }]), result: JSON.stringify([{ id: 'headline', items: ['Late proposal'] }]), proposalToken: 'late-token' } }));
|
||||
expect(screen.queryByText('Late proposal')).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: 'Apply' })).not.toBeInTheDocument();
|
||||
fireEvent.click(screen.getAllByRole('button', { name: 'Review suggestion' })[0]);
|
||||
await waitFor(() => expect(mockedApi.post.mock.calls.filter(([url]) => url === '/cv/ai/assist')).toHaveLength(2));
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'Content' }));
|
||||
await act(async () => resolveProposal({ data: { original: JSON.stringify([{ id: 'headline', items: ['Canonical headline'] }]), result: JSON.stringify([{ id: 'headline', items: ['Unmounted proposal'] }]), proposalToken: 'unmounted-token' } }));
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'AI Tools' }));
|
||||
expect(screen.queryByText('Unmounted proposal')).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: 'Apply' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test.each([
|
||||
{ label: 'Skills', targetId: 'section:skills', outline: { sections: [{ key: 'skills', title: 'Skills', kind: 'tags', tags: ['Original item'], bullets: [], entries: [] }] }, customSections: [] },
|
||||
{ label: 'Custom evidence', targetId: 'custom:sample', outline: { sections: [] }, customSections: [{ key: 'sample', title: 'Custom evidence', contentType: 'paragraphs', items: ['Original item'] }] },
|
||||
{ label: 'Experience', targetId: 'entry:experience:job-1', outline: { sections: [{ key: 'experience', title: 'Experience', kind: 'entries', tags: [], bullets: [], entries: [{ key: 'job-1', title: 'Original role', subtitle: 'Original employer', bullets: ['Original item'], tags: [] }] }] }, customSections: [] },
|
||||
])('bound $targetId assistant sends explicit scope and edited wording to server acceptance', async fixture => {
|
||||
const originalSettings = { ...variant.settings, customSections: fixture.customSections };
|
||||
routeGet(() => Promise.resolve({ data: { ...variant, settings: originalSettings } }), fixture.outline);
|
||||
mockedApi.post.mockImplementation((url: string) => Promise.resolve({ data: url === '/cv/ai/assist'
|
||||
? { original: 'Original item', result: 'Suggested item', proposalToken: `token:${fixture.targetId}` }
|
||||
: url === '/cv/variants/3/ai/apply'
|
||||
? { ...variant, version: 5, settings: { ...originalSettings, headline: 'Server accepted readback' } }
|
||||
: { html: '<p>Preview</p>' } } as never));
|
||||
renderAt(3);
|
||||
await openContentTab();
|
||||
fireEvent.click(screen.getByRole('button', { name: `Expand ${fixture.label}` }));
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'AI suggestions' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Suggest' }));
|
||||
const suggestion = await screen.findByLabelText('Suggested — edit before applying');
|
||||
expect(mockedApi.post).toHaveBeenCalledWith('/cv/ai/assist', expect.objectContaining({ variantId: 3, expectedVersion: 4, targetId: fixture.targetId, text: 'Original item' }));
|
||||
fireEvent.change(suggestion, { target: { value: 'Edited approved item' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Apply' }));
|
||||
await waitFor(() => expect(screen.getByLabelText('Headline override')).toHaveValue('Server accepted readback'));
|
||||
expect(mockedApi.post).toHaveBeenCalledWith('/cv/variants/3/ai/apply', { proposalToken: `token:${fixture.targetId}`, result: 'Edited approved item' });
|
||||
expect(mockedApi.put).not.toHaveBeenCalled();
|
||||
expect(screen.getByRole('button', { name: 'Undo' })).toBeEnabled();
|
||||
});
|
||||
|
||||
test('user edits made during server acceptance are retained instead of overwritten by its late saved response', async () => {
|
||||
await reviewDocumentSuggestion(JSON.stringify([{ id: 'headline', items: ['Reviewed headline'] }]));
|
||||
let resolveApply: (response: any) => void = () => {};
|
||||
mockedApi.post.mockImplementation((url: string) => url === '/cv/variants/3/ai/apply'
|
||||
? new Promise(resolve => { resolveApply = resolve; })
|
||||
: Promise.resolve({ data: { html: '<p>Preview</p>' } } as never));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Apply' }));
|
||||
await waitFor(() => expect(mockedApi.post).toHaveBeenCalledWith('/cv/variants/3/ai/apply', expect.any(Object)));
|
||||
expect(screen.getByRole('button', { name: 'Apply' })).toBeDisabled();
|
||||
fireEvent.change(screen.getByDisplayValue('Backend CV'), { target: { value: 'Retained during acceptance' } });
|
||||
await act(async () => resolveApply({ data: { ...variant, version: 5, settings: { ...variant.settings, headline: 'Late accepted headline' } } }));
|
||||
expect(await screen.findByText('CV changed elsewhere')).toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue('Retained during acceptance')).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'Content' }));
|
||||
expect(screen.getByLabelText('Headline override')).toHaveValue('');
|
||||
expect(mockedApi.put).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user