feat(cv): redesign document editing with revision-safe letters and writing tools

This commit is contained in:
cesnimda
2026-10-25 22:44:14 +01:00
parent 01e10996c5
commit 2bfaf6c14b
57 changed files with 2859 additions and 118 deletions
+207 -1
View File
@@ -62,6 +62,12 @@ const coverLetter = {
aiSuggestionCount: 1,
};
const letterSettings = {
schemaVersion: 1, senderName: "Recorded sender", senderTitle: "Engineer", senderEmail: "sender@example.test", senderPhone: "", senderLocation: "Oslo",
recipient: "Hiring team", subject: "Original subject", date: "2026-10-25", themeId: "modern", accentColor: "#2563eb", bodyFont: "Arial, Helvetica, sans-serif",
baseFontSizePt: 10, lineHeight: 1.42, pageMarginMm: 16, pageSize: "a4",
};
function routeGet(overrides: Record<string, any> = {}) {
mockedApi.get.mockImplementation((url: string) => {
if (url.endsWith("/tailoring")) return Promise.resolve({ data: overrides.tailoring ?? tailoring } as any);
@@ -283,6 +289,43 @@ test("the empty cover-letter template and rich-text controls follow Bokmål UI l
expect(screen.getByRole("button", { name: "Lenke" })).toBeInTheDocument();
});
test("AI letter results are discarded when the source changes during generation", async () => {
routeGet();
let complete!: (value: unknown) => void;
mockedApi.post.mockImplementation(() => new Promise(resolve => { complete = resolve; }) as never);
render(<ApplicationCoverLetterSection jobId={7} />);
fireEvent.click(await screen.findByRole("button", { name: "Generate" }));
fireEvent.change(screen.getByRole("textbox", { name: "Cover letter" }), { target: { value: "Newer local facts" } });
await act(async () => complete({ data: { result: { text: "Outdated proposal" } } }));
expect(screen.queryByRole("button", { name: /Apply to editor/i })).not.toBeInTheDocument();
expect(screen.getByRole("textbox", { name: "Cover letter" })).toHaveValue("Newer local facts");
});
test.each([['Improve writing', 'improve'], ['Suggest content', 'generate'], ['Grammar check', 'grammar'], ['Shorter', 'shorten']])(
'letter %s uses the existing reviewed action', async (label, action) => {
routeGet();
mockedApi.post.mockResolvedValue({ data: { result: { text: 'Reviewed candidate wording' } } } as never);
render(<ApplicationCoverLetterSection jobId={7} />);
fireEvent.click(await screen.findByRole('button', { name: label }));
await waitFor(() => expect(mockedApi.post).toHaveBeenCalledWith('/jobapplications/7/ai/generate', expect.objectContaining({ action })));
expect(await screen.findByDisplayValue('Reviewed candidate wording')).toBeInTheDocument();
expect(screen.getByRole('textbox', { name: 'Cover letter' })).toHaveValue('Dear team');
expect(mockedApi.put).not.toHaveBeenCalled();
},
);
test('cancelling letter generation ignores its late response', async () => {
routeGet();
const pending = deferred<any>();
mockedApi.post.mockImplementation(() => pending.promise);
render(<ApplicationCoverLetterSection jobId={7} />);
fireEvent.click(await screen.findByRole('button', { name: 'Suggest content' }));
fireEvent.click(await screen.findByRole('button', { name: 'Cancel' }));
await act(async () => pending.resolve({ data: { result: { text: 'Cancelled response' } } }));
expect(screen.queryByDisplayValue('Cancelled response')).not.toBeInTheDocument();
expect(screen.getByRole('textbox', { name: 'Cover letter' })).toHaveValue('Dear team');
});
test("AI cover letter suggestions use the linked CV and require explicit apply", async () => {
routeGet();
mockedApi.post.mockResolvedValue({
@@ -535,7 +578,7 @@ test("held letter Save submits one snapshot and prevents an AI Apply from replac
act(() => { fireEvent.click(save); fireEvent.click(save); });
expect(mockedApi.put).toHaveBeenCalledTimes(1);
expect(mockedApi.put).toHaveBeenCalledWith("/jobapplications/7/cover-letter", { text: "Explicit manual save snapshot", source: "manual", aiAction: undefined, expectedVersion: 7 });
expect(apply).toBeDisabled();
expect(apply).not.toBeInTheDocument();
fireEvent.click(apply);
expect(screen.getByRole("textbox", { name: "Cover letter" })).toHaveValue("Explicit manual save snapshot");
await act(async () => { write.resolve({ data: { ...coverLetter, text: "Explicit manual save snapshot" } }); });
@@ -779,3 +822,166 @@ test.each([409, 0])("cover-letter rejection (%s) retains draft and source revisi
expect(mockedApi.get).toHaveBeenCalledTimes(reads);
if (status === 409) expect(section("workspaceCoverLetter").getByRole("alert")).toHaveTextContent(copy("revisionConflict"));
});
test("letter text preview shows unsaved formatting, hides the inactive narrow pane and never saves on switching", async () => {
routeGet();
render(<ApplicationCoverLetterSection jobId={7} />);
const editor = await screen.findByRole("textbox", { name: copy("jobDetailsCoverLetter") });
fireEvent.change(editor, { target: { value: "Dear **team**, *hello*\n- First achievement\n[Portfolio](https://example.com)" } });
fireEvent.click(screen.getByRole("tab", { name: copy("assetsLetterPreview") }));
const preview = screen.getByRole("region", { name: copy("assetsLetterTextPreview") });
expect(within(preview).getByText("team").tagName).toBe("STRONG");
expect(within(preview).getByText("hello").tagName).toBe("EM");
expect(within(preview).getByRole("listitem")).toHaveTextContent("First achievement");
expect(within(preview).getByRole("link", { name: "Portfolio" })).toHaveAttribute("rel", "noopener noreferrer");
expect(screen.queryByRole("textbox", { name: copy("jobDetailsCoverLetter") })).not.toBeInTheDocument();
expect(editor).not.toBeVisible();
expect(screen.getByText(copy("assetsLetterTextPreviewHelp"))).toBeVisible();
fireEvent.click(screen.getByRole("tab", { name: copy("assetsLetterWrite") }));
expect(screen.getByRole("textbox", { name: copy("jobDetailsCoverLetter") })).toBe(editor);
expect(screen.queryByRole("region", { name: copy("assetsLetterTextPreview") })).not.toBeInTheDocument();
expect(mockedApi.put).not.toHaveBeenCalled();
expect(mockedApi.post).not.toHaveBeenCalled();
});
test("letter text preview renders unsafe Markdown as inert text", async () => {
routeGet();
render(<ApplicationCoverLetterSection jobId={7} />);
fireEvent.change(await screen.findByRole("textbox", { name: copy("jobDetailsCoverLetter") }), { target: { value: '<script>alert(1)</script>\n<img src=x onerror=alert(1)>\n[Run](javascript:alert(1))\n[Data](data:text/html,bad)' } });
fireEvent.click(screen.getByRole("tab", { name: copy("assetsLetterPreview") }));
const preview = screen.getByRole("region", { name: copy("assetsLetterTextPreview") });
expect(preview.querySelector("script, img, iframe, a")).toBeNull();
expect(preview).toHaveTextContent("<script>alert(1)</script>");
expect(preview).toHaveTextContent("[Run](javascript:alert(1))");
});
test("letter preview retains the draft after cancelled restore and failed save", async () => {
routeGet();
mockedApi.put.mockRejectedValue({ response: { status: 409 } });
render(<ApplicationCoverLetterSection jobId={7} />);
fireEvent.change(await screen.findByRole("textbox", { name: copy("jobDetailsCoverLetter") }), { target: { value: "Retained **draft** body" } });
fireEvent.click(screen.getByRole("tab", { name: copy("assetsLetterPreview") }));
fireEvent.click(screen.getByRole("button", { name: /Restore version 1/i }));
fireEvent.click(within(await screen.findByRole("dialog")).getByRole("button", { name: copy("cancel") }));
await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument());
expect(screen.getByRole("region", { name: copy("assetsLetterTextPreview") })).toHaveTextContent("Retained draft body");
expect(mockedApi.post).not.toHaveBeenCalled();
fireEvent.click(section("workspaceCoverLetter").getByRole("button", { name: copy("save") }));
await section("workspaceCoverLetter").findByRole("alert");
expect(screen.getByRole("region", { name: copy("assetsLetterTextPreview") })).toHaveTextContent("Retained draft body");
fireEvent.click(screen.getByRole("tab", { name: copy("assetsLetterWrite") }));
expect(screen.getByRole("textbox", { name: copy("jobDetailsCoverLetter") })).toHaveValue("Retained **draft** body");
expect(mockedApi.put).toHaveBeenCalledTimes(1);
});
test("keyed letter job changes discard the old preview and ignore its late save", async () => {
routeGet();
const saveA = deferred<any>();
mockedApi.put.mockReturnValueOnce(saveA.promise);
const mounted = render(<ApplicationCoverLetterSection key={7} jobId={7} />);
fireEvent.change(await screen.findByRole("textbox", { name: copy("jobDetailsCoverLetter") }), { target: { value: "Private job A draft" } });
fireEvent.click(screen.getByRole("tab", { name: copy("assetsLetterPreview") }));
fireEvent.click(section("workspaceCoverLetter").getByRole("button", { name: copy("save") }));
routeGet({ coverLetter: { ...coverLetter, text: "Job B saved letter" } });
mounted.rerender(withProviders(<ApplicationCoverLetterSection key={8} jobId={8} />));
fireEvent.change(await screen.findByRole("textbox", { name: copy("jobDetailsCoverLetter") }), { target: { value: "Private job B draft" } });
fireEvent.click(screen.getByRole("tab", { name: copy("assetsLetterPreview") }));
await act(async () => { saveA.resolve({ data: { ...coverLetter, text: "Private job A draft" } }); await saveA.promise; });
const preview = screen.getByRole("region", { name: copy("assetsLetterTextPreview") });
expect(preview).toHaveTextContent("Private job B draft");
expect(preview).not.toHaveTextContent("Private job A draft");
expect(mockedApi.put).toHaveBeenCalledTimes(1);
});
test("wide letter workspace keeps one editor beside its live preview", async () => {
const previous = Object.getOwnPropertyDescriptor(window, "matchMedia");
Object.defineProperty(window, "matchMedia", { configurable: true, value: (query: string) => ({
matches: query.includes("min-width:900px"), media: query, onchange: null,
addListener: jest.fn(), removeListener: jest.fn(), addEventListener: jest.fn(), removeEventListener: jest.fn(), dispatchEvent: jest.fn(),
}) });
try {
routeGet();
render(<ApplicationCoverLetterSection jobId={7} />);
const editor = await screen.findByRole("textbox", { name: copy("jobDetailsCoverLetter") });
fireEvent.change(editor, { target: { value: "Live desktop **draft**" } });
expect(screen.getByRole("region", { name: copy("assetsLetterTextPreview") })).toHaveTextContent("Live desktop draft");
expect(editor).toBeVisible();
expect(screen.getAllByRole("textbox", { name: copy("jobDetailsCoverLetter") })).toHaveLength(1);
expect(screen.queryByRole("tablist", { name: copy("assetsLetterWorkspace") })).not.toBeInTheDocument();
expect(mockedApi.put).not.toHaveBeenCalled();
} finally {
if (previous) Object.defineProperty(window, "matchMedia", previous); else Reflect.deleteProperty(window, "matchMedia");
}
});
test("letter preview controls follow Bokmål without translating or saving the local draft", async () => {
routeGet();
render(<ApplicationCoverLetterSection jobId={7} />);
fireEvent.change(await screen.findByRole("textbox", { name: copy("jobDetailsCoverLetter") }), { target: { value: "Keep this **original** prose" } });
changeLanguage("nb");
fireEvent.click(screen.getByRole("tab", { name: copy("assetsLetterPreview") }));
expect(screen.getByRole("region", { name: copy("assetsLetterTextPreview") })).toHaveTextContent("Keep this original prose");
expect(screen.getByText(copy("assetsLetterTextPreviewHelp"))).toBeVisible();
fireEvent.click(screen.getByRole("tab", { name: copy("assetsLetterWrite") }));
expect(screen.getByRole("textbox", { name: copy("jobDetailsCoverLetter") })).toHaveValue("Keep this **original** prose");
expect(mockedApi.put).not.toHaveBeenCalled();
expect(mockedApi.post).not.toHaveBeenCalled();
});
test("document settings are dirty local drafts until explicit Save, and Discard restores saved details", async () => {
routeGet({ coverLetter: { ...coverLetter, documentSettings: letterSettings } });
mockedApi.put.mockResolvedValue({ data: { ...coverLetter, revision: 8, documentSettings: { ...letterSettings, subject: "New subject" } } } as any);
render(<ApplicationCoverLetterSection jobId={7} />);
fireEvent.change(await screen.findByRole("textbox", { name: copy("letterSubject") }), { target: { value: "New subject" } });
expect(screen.getByText(copy("assetsUnsavedChanges"))).toBeInTheDocument();
expect(mockedApi.put).not.toHaveBeenCalled();
fireEvent.click(section("workspaceCoverLetter").getByRole("button", { name: copy("save") }));
await waitFor(() => expect(mockedApi.put).toHaveBeenCalledWith("/jobapplications/7/cover-letter", expect.objectContaining({ text: "Dear team", expectedVersion: 7, documentSettings: { ...letterSettings, subject: "New subject" } })));
await waitFor(() => expect(screen.queryByText(copy("assetsUnsavedChanges"))).not.toBeInTheDocument());
fireEvent.change(screen.getByRole("textbox", { name: copy("letterSubject") }), { target: { value: "Discard me" } });
fireEvent.click(screen.getByRole("button", { name: copy("assetsDiscardChanges") }));
expect(screen.getByRole("textbox", { name: copy("letterSubject") })).toHaveValue("New subject");
});
test("letter PDF awaits durable settings and blocks duplicate exports; failed save never exports", async () => {
routeGet({ coverLetter: { ...coverLetter, documentSettings: letterSettings } });
const write = deferred<any>();
mockedApi.put.mockReturnValueOnce(write.promise);
render(<ApplicationCoverLetterSection jobId={7} />);
fireEvent.change(await screen.findByRole("textbox", { name: copy("letterSubject") }), { target: { value: "Keep draft" } });
const exportButton = screen.getByRole("button", { name: copy("letterExportPdf") });
act(() => { fireEvent.click(exportButton); fireEvent.click(exportButton); });
expect(mockedApi.put).toHaveBeenCalledTimes(1);
expect(mockedApi.post).not.toHaveBeenCalled();
await act(async () => { write.reject({ response: { status: 409 } }); });
expect(await section("workspaceCoverLetter").findByRole("alert")).toHaveTextContent(copy("revisionConflict"));
expect(screen.getByRole("textbox", { name: copy("letterSubject") })).toHaveValue("Keep draft");
expect(mockedApi.post).not.toHaveBeenCalled();
});
test("letter document preview ignores stale responses, keeps last good HTML on failure and retries without saving", async () => {
routeGet({ coverLetter: { ...coverLetter, documentSettings: letterSettings } });
const oldPreview = deferred<any>();
mockedApi.post.mockReturnValueOnce(oldPreview.promise)
.mockResolvedValueOnce({ data: { html: "<html><body>Current document</body></html>", themeId: "modern", suggestedFileName: "letter.pdf" } } as any)
.mockRejectedValueOnce({ response: { status: 503 } })
.mockResolvedValueOnce({ data: { html: "<html><body>Retried document</body></html>", themeId: "modern", suggestedFileName: "letter.pdf" } } as any);
render(<ApplicationCoverLetterSection jobId={7} />);
await screen.findByRole("textbox", { name: copy("letterSubject") });
fireEvent.click(screen.getByRole("tab", { name: copy("assetsLetterPreview") }));
await waitFor(() => expect(mockedApi.post).toHaveBeenCalledTimes(1));
fireEvent.click(screen.getByRole("tab", { name: copy("assetsLetterWrite") }));
fireEvent.change(screen.getByRole("textbox", { name: copy("letterSubject") }), { target: { value: "Current subject" } });
fireEvent.click(screen.getByRole("tab", { name: copy("assetsLetterPreview") }));
await waitFor(() => expect(screen.getByTitle(copy("letterDocumentPreview"))).toHaveAttribute("srcdoc", expect.stringContaining("Current document")));
await act(async () => { oldPreview.resolve({ data: { html: "<html><body>Stale document</body></html>" } }); await oldPreview.promise; });
expect(screen.getByTitle(copy("letterDocumentPreview"))).not.toHaveAttribute("srcdoc", expect.stringContaining("Stale document"));
fireEvent.click(screen.getByRole("tab", { name: copy("assetsLetterWrite") }));
fireEvent.change(screen.getByRole("textbox", { name: copy("letterSubject") }), { target: { value: "Next subject" } });
fireEvent.click(screen.getByRole("tab", { name: copy("assetsLetterPreview") }));
await screen.findByText(copy("letterPreviewFailed"));
expect(screen.getByTitle(copy("letterDocumentPreview"))).toHaveAttribute("srcdoc", expect.stringContaining("Current document"));
fireEvent.click(screen.getByRole("button", { name: copy("retry") }));
await waitFor(() => expect(screen.getByTitle(copy("letterDocumentPreview"))).toHaveAttribute("srcdoc", expect.stringContaining("Retried document")));
expect(mockedApi.put).not.toHaveBeenCalled();
});