fix(cv): bind letter proposals to current sources and preserve document choices

This commit is contained in:
cesnimda
2026-10-26 03:44:37 +01:00
parent 8948c988e2
commit 8979244576
21 changed files with 816 additions and 32 deletions
@@ -1,4 +1,5 @@
import { expect, test, type APIRequestContext } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';
import fs from 'node:fs/promises';
import path from 'node:path';
import { createHash } from 'node:crypto';
@@ -35,6 +36,7 @@ test('cover letter native preview/PDF fidelity records four independent physical
const email = `letter-fidelity-${Date.now()}@example.test`;
try {
expect((await admin.post(`${apiUrl}/users`, { headers: adminHeaders, data: { email, password, roles: [] } })).status()).toBe(200);
expect((await admin.post(`${apiUrl}/jobapplications/2147483647/ai/history/2147483647/validate-source`, { headers: adminHeaders })).status()).toBe(404);
} finally { await admin.dispose(); }
const headers = await login(page.request, email);
const sourceBefore = await (await page.request.get(`${apiUrl}/career/profile`)).json();
@@ -51,7 +53,7 @@ test('cover letter native preview/PDF fidelity records four independent physical
: 'Documented reliable workflows and accessible navigation using reviewed source evidence. ';
const text = Array.from({ length: 24 }, (_, index) => `B${String(index + 1).padStart(2, '0')} ${sentence.repeat(4)}`)
.concat(['- S01 **Reviewed achievement** — æøå.', '- S02 *Verified collaboration* — ÆØÅ.', 'R99 Final reviewed letter content — Åse Østby.']).join('\n\n');
const documentSettings = { schemaVersion: 1, senderName: 'R01 Åse Østby', senderTitle: 'P01 Platform engineer',
const documentSettings = { schemaVersion: 1, language: language === 'nb' ? 'nb-NO' : 'en', senderName: 'R01 Åse Østby', senderTitle: 'P01 Platform engineer',
senderEmail: 'ase@example.test', senderPhone: '+47 12345678', senderLocation: 'Oslo, Norge',
recipient: 'R03 Hiring team\nNordlys Synthetic AS\nOslo, Norge', subject: language === 'nb' ? 'R02 Søknad om plattformutvikler' : 'R02 Application for platform engineer',
date: '2026-10-25', themeId: 'minimal', accentColor: '#126b55', bodyFont: 'Arial, Helvetica, sans-serif',
@@ -71,6 +73,7 @@ test('cover letter native preview/PDF fidelity records four independent physical
const preview = await page.request.post(`${letterUrl}/preview`, { headers, data: { text: saved.text, documentSettings: saved.documentSettings } });
expect(preview.status()).toBe(200);
const rendered = await preview.json();
expect(rendered.html).toContain(`<html lang="${documentSettings.language}">`);
expect(rendered.html).toContain('class="page letter-paper"');
const pdf = await page.request.post(`${letterUrl}/export-pdf?expectedVersion=${saved.revision}`, { headers });
expect(pdf.status()).toBe(200);
@@ -89,6 +92,7 @@ test('cover letter native preview/PDF fidelity records four independent physical
const previewWorkspace = frame.locator('..').locator('..').locator('..');
await expect(frame).toBeVisible();
await expect.poll(() => frame.evaluate(element => (element as HTMLIFrameElement).contentDocument?.documentElement.dataset.cvPreviewReady)).toBe('true');
expect(await frame.evaluate(element => (element as HTMLIFrameElement).contentDocument?.documentElement.lang)).toBe(documentSettings.language);
const measured = await frame.evaluate(async element => {
const doc = (element as HTMLIFrameElement).contentDocument!;
const win = (element as HTMLIFrameElement).contentWindow!;
@@ -171,6 +175,7 @@ test('cover letter workspace preserves keyboard drafts and localised controls ac
const created = await context.request.post(`${apiUrl}/jobapplications`, { headers, data: { companyId: company.id, jobTitle: 'Responsive reviewed letter', status: 'Saved' } });
expect(created.status()).toBe(201);
const job = await created.json();
expect((await context.request.post(`${apiUrl}/jobapplications/${job.id}/ai/history/2147483647/validate-source`, { headers })).status()).toBe(403);
const letterUrl = `${apiUrl}/jobapplications/${job.id}/cover-letter`;
const original = await (await context.request.get(letterUrl)).json();
const savedResponse = await context.request.put(letterUrl, { headers, data: { text: 'Original reviewed letter — æøå.', source: 'manual', expectedVersion: original.revision } });
@@ -199,6 +204,10 @@ test('cover letter workspace preserves keyboard drafts and localised controls ac
const workspace = page.getByRole('group', { name: t.workspaceCoverLetter, exact: true });
const body = workspace.getByRole('textbox', { name: t.jobDetailsCoverLetter, exact: true });
await expect(body).toHaveValue(saved.text);
const documentLanguage = workspace.getByRole('combobox', { name: t.letterDocumentLanguage });
await expect(documentLanguage).toBeVisible();
await documentLanguage.click();
await page.getByRole('option', { name: language === 'nb' ? t.languageNorwegianBokmal : t.languageEnglish, exact: true }).click();
const draft = `${width} ${language === 'nb' ? 'Ulagret søknadsbrev med gjennomgåtte fakta' : 'Unsaved letter with reviewed facts'} — æøå.`;
await body.focus(); await body.press('ControlOrMeta+A'); await body.pressSequentially(draft);
await expect(body).toHaveValue(draft);
@@ -220,6 +229,15 @@ test('cover letter workspace preserves keyboard drafts and localised controls ac
expect(geometry.root).toBeLessThanOrEqual(1); expect(geometry.app).toBeLessThanOrEqual(1);
const writePane = workspace.locator('[id$="-write-panel"]');
const previewPane = workspace.locator('[id$="-preview-panel"]');
const audit = async (state: string) => {
await workspace.evaluate(node => node.setAttribute('data-cv-ux-axe', 'letter'));
const result = await new AxeBuilder({ page }).include('[data-cv-ux-axe="letter"]').withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa']).analyze();
const reportPath = info.outputPath(`letter-${width}-${state}-axe.json`);
await fs.writeFile(reportPath, JSON.stringify({ violations: result.violations, incomplete: result.incomplete }, null, 2));
await info.attach(`letter-${width}-${state}-axe`, { path: reportPath, contentType: 'application/json' });
expect(result.violations).toEqual([]);
};
if (width === 320 || width === 390 || width === 1440) await audit('write');
if (width < 1024) {
const tabs = workspace.getByRole('tablist', { name: t.assetsLetterWorkspace, exact: true });
const write = tabs.getByRole('tab', { name: t.assetsLetterWrite, exact: true });
@@ -228,6 +246,7 @@ test('cover letter workspace preserves keyboard drafts and localised controls ac
await write.focus(); await page.keyboard.press('ArrowRight'); await expect(preview).toBeFocused(); await page.keyboard.press('Enter');
await expect(preview).toHaveAttribute('aria-selected', 'true');
await expect(writePane).not.toBeVisible(); await expect(previewPane).toBeVisible();
if (width === 320 || width === 390) await audit('preview');
await expect(workspace.getByRole('textbox', { name: t.jobDetailsCoverLetter, exact: true })).toHaveCount(0);
for (let step = 0; step < 8; step++) {
await page.keyboard.press('Tab');
+1
View File
@@ -32,5 +32,6 @@ export const aiWorkspaceApi = {
api.post<AiInteraction>(`/jobapplications/${jobId}/ai/generate`, body).then((r) => r.data),
history: (jobId: number, module?: string) =>
api.get<AiInteraction[]>(`/jobapplications/${jobId}/ai/history`, { params: { module } }).then((r) => r.data),
validateSource: (jobId: number, id: number) => api.post(`/jobapplications/${jobId}/ai/history/${id}/validate-source`).then(() => undefined),
remove: (jobId: number, id: number) => api.delete(`/jobapplications/${jobId}/ai/history/${id}`),
};
+75 -3
View File
@@ -359,7 +359,7 @@ test("AI cover letter suggestions use the linked CV and require explicit apply",
));
expect(screen.getByRole("textbox", { name: "Cover letter" })).toHaveValue("Dear team");
fireEvent.click(await screen.findByRole("button", { name: /Apply to editor/i }));
expect(screen.getByRole("textbox", { name: "Cover letter" })).toHaveValue("A tailored, truthful suggestion.");
await waitFor(() => expect(screen.getByRole("textbox", { name: "Cover letter" })).toHaveValue("A tailored, truthful suggestion."));
mockedApi.put.mockResolvedValue({ data: { ...coverLetter, text: "A tailored, truthful suggestion." } } as any);
fireEvent.click(screen.getByRole("button", { name: "Save" }));
@@ -369,6 +369,77 @@ test("AI cover letter suggestions use the linked CV and require explicit apply",
));
});
test("letter AI revalidates its recorded source before applying and refuses a cross-tab source conflict", async () => {
routeGet();
mockedApi.post.mockImplementation(((url: string) => url.endsWith('/generate')
? Promise.resolve({ data: { id: 91, result: { text: 'Old source proposal' } } })
: Promise.reject({ response: { status: 409, data: { code: 'cover_letter_source_changed' } } })) as any);
render(<ApplicationCoverLetterSection jobId={7} />);
fireEvent.click(await screen.findByRole('button', { name: 'Generate' }));
fireEvent.click(await screen.findByRole('button', { name: /Apply to editor/i }));
await waitFor(() => expect(mockedApi.post).toHaveBeenCalledWith('/jobapplications/7/ai/history/91/validate-source'));
expect(await screen.findByText(copy('coverAiSourceChanged'))).toBeInTheDocument();
expect(screen.getByRole('textbox', { name: 'Cover letter' })).toHaveValue('Dear team');
expect(screen.queryByRole('button', { name: /Apply to editor/i })).not.toBeInTheDocument();
expect(mockedApi.put).not.toHaveBeenCalled();
});
test("letter AI language follows the saved document and updates the same local language draft", async () => {
routeGet({ coverLetter: { ...coverLetter, documentSettings: { ...letterSettings, language: 'nb-NO' } } });
render(<ApplicationCoverLetterSection jobId={7} />);
expect(await screen.findByRole('combobox', { name: 'Letter language' })).toHaveTextContent('Norsk bokmål');
await waitFor(() => expect(screen.getByRole('combobox', { name: 'Document language' })).toHaveTextContent('Norsk bokmål'));
fireEvent.mouseDown(screen.getByRole('combobox', { name: 'Document language' }));
fireEvent.click(await screen.findByRole('option', { name: 'English' }));
expect(screen.getByRole('combobox', { name: 'Letter language' })).toHaveTextContent('English');
expect(screen.getByRole('textbox', { name: 'Cover letter' })).toHaveValue('Dear team');
expect(mockedApi.put).not.toHaveBeenCalled();
mockedApi.put.mockResolvedValue({ data: { ...coverLetter, revision: 8, documentSettings: { ...letterSettings, language: 'en' } } } as any);
fireEvent.click(section('workspaceCoverLetter').getByRole('button', { name: 'Save' }));
await waitFor(() => expect(mockedApi.put).toHaveBeenCalledWith('/jobapplications/7/cover-letter', expect.objectContaining({ documentSettings: { ...letterSettings, language: 'en' }, expectedVersion: 7 })));
});
test.each(['edit', 'cancel', 'job change'])("letter AI ignores a late source validation after local %s", async action => {
routeGet();
const validation = deferred<any>();
mockedApi.post.mockImplementation(((url: string) => url.endsWith('/generate')
? Promise.resolve({ data: { id: 92, result: { text: 'Previously reviewed proposal' } } })
: validation.promise) as any);
const view = render(<ApplicationCoverLetterSection jobId={7} />);
fireEvent.click(await screen.findByRole('button', { name: 'Generate' }));
fireEvent.click(await screen.findByRole('button', { name: /Apply to editor/i }));
await waitFor(() => expect(mockedApi.post).toHaveBeenCalledWith('/jobapplications/7/ai/history/92/validate-source'));
if (action === 'edit') fireEvent.change(screen.getByRole('textbox', { name: 'Cover letter' }), { target: { value: 'New local draft' } });
else if (action === 'cancel') fireEvent.click(screen.getByRole('button', { name: 'Cancel' }));
else {
routeGet({ coverLetter: { ...coverLetter, text: 'Different job draft' } });
view.rerender(withProviders(<ApplicationCoverLetterSection jobId={8} />));
await screen.findByDisplayValue('Different job draft');
}
await act(async () => { validation.resolve({ status: 204 }); await validation.promise; });
expect(screen.getByRole('textbox', { name: 'Cover letter' })).toHaveValue(action === 'edit' ? 'New local draft' : action === 'cancel' ? 'Dear team' : 'Different job draft');
expect(mockedApi.put).not.toHaveBeenCalled();
});
test("a held letter Save prevents an in-flight AI validation from replacing the submitted snapshot", async () => {
routeGet();
const validation = deferred<any>(); const save = deferred<any>();
mockedApi.post.mockImplementation(((url: string) => url.endsWith('/generate')
? Promise.resolve({ data: { id: 94, result: { text: 'Validated proposal' } } }) : validation.promise) as any);
mockedApi.put.mockReturnValue(save.promise);
render(<ApplicationCoverLetterSection jobId={7} />);
fireEvent.change(await screen.findByRole('textbox', { name: 'Cover letter' }), { target: { value: 'Snapshot being saved' } });
fireEvent.click(await screen.findByRole('button', { name: 'Generate' }));
fireEvent.click(await screen.findByRole('button', { name: /Apply to editor/i }));
await waitFor(() => expect(mockedApi.post).toHaveBeenCalledWith('/jobapplications/7/ai/history/94/validate-source'));
fireEvent.click(section('workspaceCoverLetter').getByRole('button', { name: 'Save' }));
expect(mockedApi.put).toHaveBeenCalledWith('/jobapplications/7/cover-letter', expect.objectContaining({ text: 'Snapshot being saved', expectedVersion: 7 }));
await act(async () => { validation.resolve({ status: 204 }); await validation.promise; });
expect(screen.getByRole('textbox', { name: 'Cover letter' })).toHaveValue('Snapshot being saved');
await act(async () => { save.resolve({ data: { ...coverLetter, text: 'Snapshot being saved', revision: 8 } }); await save.promise; });
expect(screen.getByRole('textbox', { name: 'Cover letter' })).toHaveValue('Snapshot being saved');
});
test("[PRES-W06] rejecting an AI cover letter preserves the saved draft without a write", async () => {
routeGet();
mockedApi.post.mockResolvedValue({ data: { result: { text: "Unaccepted letter" } } } as never);
@@ -601,14 +672,15 @@ test("held AI generation locks its controls and preserves captured action attrib
expect(mockedApi.post).toHaveBeenCalledWith("/jobapplications/7/ai/generate", expect.objectContaining({ module: "cover-letter", action: "generate", mode: "professional", targetLanguage: "nb-NO", currentText: "Dear team", extraContext: "Use only recorded context" }));
for (const name of ["Action", "Tone", "Document language"]) expect(screen.getByRole("combobox", { name })).toHaveAttribute("aria-disabled", "true");
expect(screen.getByRole("textbox", { name: "Additional instructions" })).toBeDisabled();
await act(async () => { generation.resolve({ data: { result: { text: "Captured generated suggestion" } } }); });
await act(async () => { generation.resolve({ data: { id: 93, result: { text: "Captured generated suggestion" } } }); });
fireEvent.mouseDown(screen.getByRole("combobox", { name: "Action" }));
fireEvent.click(await screen.findByRole("option", { name: "Improve" }));
fireEvent.click(screen.getByRole("button", { name: /Apply to editor/i }));
await waitFor(() => expect(screen.getByRole("textbox", { name: "Cover letter" })).toHaveValue("Captured generated suggestion"));
mockedApi.put.mockResolvedValue({ data: { ...coverLetter, text: "Captured generated suggestion" } } as any);
fireEvent.click(section("workspaceCoverLetter").getByRole("button", { name: "Save" }));
await waitFor(() => expect(mockedApi.put).toHaveBeenCalledWith("/jobapplications/7/cover-letter", { text: "Captured generated suggestion", source: "ai", aiAction: "generate", expectedVersion: 7 }));
expect(mockedApi.post).toHaveBeenCalledTimes(1);
expect(mockedApi.post).toHaveBeenCalledTimes(2);
});
test("capture, detail and download failures keep owned frozen history and recover only through their original explicit actions", async () => {
@@ -263,6 +263,7 @@ export type CoverLetter = {
export type CoverLetterDocumentSettings = {
schemaVersion: 1;
language?: "en" | "nb-NO";
senderName: string; senderTitle: string; senderEmail: string; senderPhone: string; senderLocation: string;
recipient: string; subject: string; date: string;
themeId: string; accentColor: string; bodyFont: string;
@@ -666,6 +666,8 @@ export function ApplicationCoverLetterSection({ jobId, onDirtyChange }: { jobId:
<CoverLetterAiAssistant
jobId={jobId}
documentLanguage={settings?.language}
onDocumentLanguageChange={settings ? next => setSettingsDraft({ ...settings, language: next }) : undefined}
toolbarHost={aiToolbarHost}
currentText={text}
disabled={busy || confirming || loading || !data}
@@ -737,7 +739,7 @@ const COVER_LETTER_ACTIONS = [
{ key: "tailor", labelKey: "coverActionTailor" },
] as const;
function CoverLetterAiAssistant({ jobId, currentText, disabled, onApply, toolbarHost }: { jobId: number; currentText: string; disabled: boolean; onApply: (text: string, action: string) => void; toolbarHost: HTMLDivElement | null }) {
function CoverLetterAiAssistant({ jobId, currentText, disabled, onApply, toolbarHost, documentLanguage, onDocumentLanguageChange }: { jobId: number; currentText: string; disabled: boolean; onApply: (text: string, action: string) => void; toolbarHost: HTMLDivElement | null; documentLanguage?: "en" | "nb-NO"; onDocumentLanguageChange?: (language: "en" | "nb-NO") => void }) {
const labelId = useId();
const { canUseAi } = useAccountPlan();
const { language: uiLanguage, t } = useI18n();
@@ -746,11 +748,14 @@ function CoverLetterAiAssistant({ jobId, currentText, disabled, onApply, toolbar
const [mode, setMode] = useState("professional");
const [language, setLanguage] = useState<"en" | "nb-NO">(() => uiLanguage === "nb" ? "nb-NO" : "en");
const [instructions, setInstructions] = useState("");
const [suggestion, setSuggestion] = useState<{ text: string; action: string } | null>(null);
const [suggestion, setSuggestion] = useState<{ id: number; text: string; action: string } | null>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<AssetError | null>(null);
const busyRef = useRef(false);
const contentRef = useRef<HTMLDivElement>(null);
const disabledRef = useRef(disabled);
disabledRef.current = disabled;
useEffect(() => { if (documentLanguage) setLanguage(documentLanguage); }, [documentLanguage]);
const requestSequence = useRef(0);
useEffect(() => {
@@ -779,11 +784,39 @@ function CoverLetterAiAssistant({ jobId, currentText, disabled, onApply, toolbar
});
if (!isCurrent() || request !== requestSequence.current) return;
const text = result.result.text?.trim() ?? "";
setSuggestion(text ? { text, action: requestedAction } : null);
setSuggestion(text ? { id: result.id, text, action: requestedAction } : null);
} catch (err) {
if (isCurrent() && request === requestSequence.current) {
const sourceRequired = (err as { response?: { data?: unknown } })?.response?.data === "cover_letter_source_required";
setError(sourceRequired ? { kind: "error", message: "", messageKey: "coverAiSourceRequired", retryable: false } : assetError(err, "coverAiGenerateFailed"));
const sourceChanged = (err as { response?: { data?: { code?: string } } })?.response?.data?.code === "cover_letter_source_changed";
setError(sourceRequired || sourceChanged ? { kind: "error", message: "", messageKey: sourceChanged ? "coverAiSourceChanged" : "coverAiSourceRequired", retryable: false } : assetError(err, "coverAiGenerateFailed"));
}
} finally {
if (request === requestSequence.current) { busyRef.current = false; setBusy(false); }
}
};
const applySuggestion = async () => {
if (!suggestion || busyRef.current || disabledRef.current || loadingCv) return;
if (!Number.isSafeInteger(suggestion.id) || suggestion.id <= 0) {
setSuggestion(null);
setError({ kind: "error", message: "", messageKey: "coverAiSourceChanged", retryable: false });
return;
}
const request = ++requestSequence.current;
busyRef.current = true;
setBusy(true);
setError(null);
try {
await aiWorkspaceApi.validateSource(jobId, suggestion.id);
if (!isCurrent() || request !== requestSequence.current || disabledRef.current) return;
onApply(suggestion.text, suggestion.action);
setSuggestion(null);
} catch (err) {
if (isCurrent() && request === requestSequence.current) {
setSuggestion(null);
const sourceChanged = (err as { response?: { data?: { code?: string } } })?.response?.data?.code === "cover_letter_source_changed";
setError(sourceChanged ? { kind: "error", message: "", messageKey: "coverAiSourceChanged", retryable: false } : assetError(err, "coverAiGenerateFailed"));
}
} finally {
if (request === requestSequence.current) { busyRef.current = false; setBusy(false); }
@@ -835,7 +868,7 @@ function CoverLetterAiAssistant({ jobId, currentText, disabled, onApply, toolbar
</FormControl>
<FormControl size="small" sx={{ minWidth: 190 }}>
<InputLabel id={`${labelId}-coverAiDocumentLanguage`}>{t("coverAiDocumentLanguage")}</InputLabel>
<Select disabled={busy || disabled || loadingCv} labelId={`${labelId}-coverAiDocumentLanguage`} label={t("coverAiDocumentLanguage")} value={language} onChange={(event) => setLanguage(event.target.value as "en" | "nb-NO")}>
<Select disabled={busy || disabled || loadingCv} labelId={`${labelId}-coverAiDocumentLanguage`} label={t("coverAiDocumentLanguage")} value={language} onChange={(event) => { const next = event.target.value as "en" | "nb-NO"; setLanguage(next); onDocumentLanguageChange?.(next); }}>
<MenuItem value="en">{t("languageEnglish")}</MenuItem>
<MenuItem value="nb-NO">{t("languageNorwegianBokmal")}</MenuItem>
</Select>
@@ -875,7 +908,7 @@ function CoverLetterAiAssistant({ jobId, currentText, disabled, onApply, toolbar
<TextField label={t("coverAiSuggestion")} fullWidth multiline minRows={3} value={suggestion.text} disabled={busy || disabled}
onChange={event => setSuggestion({ ...suggestion, text: event.target.value })} />
<Stack direction="row" spacing={1} sx={{ mt: 2 }}>
<Button size="small" variant="contained" disabled={busy || disabled || loadingCv || !suggestion.text.trim()} onClick={() => { if (!busyRef.current && !disabled && !loadingCv) { onApply(suggestion.text, suggestion.action); setSuggestion(null); } }}>{t("coverAiApply")}</Button>
<Button size="small" variant="contained" disabled={busy || disabled || loadingCv || !suggestion.text.trim()} onClick={() => void applySuggestion()}>{t("coverAiApply")}</Button>
<Button size="small" disabled={busy || disabled || loadingCv} onClick={() => setSuggestion(null)}>{t("coverAiReject")}</Button>
</Stack>
</Paper>
@@ -1,5 +1,5 @@
import React, { useEffect, useId, useRef, useState } from "react";
import { Alert, Box, Button, Dialog, DialogActions, DialogContent, DialogTitle, MenuItem, Stack, TextField, Typography } from "@mui/material";
import { Alert, Box, Button, Checkbox, Dialog, DialogActions, DialogContent, DialogTitle, FormControlLabel, MenuItem, Stack, TextField, Typography } from "@mui/material";
import { api } from "../api";
import { applicationAssetsApi, CoverLetterDocumentSettings } from "../applicationWorkspace";
import { cvBuilderApi, CvTheme, CvVariantSummary, getCvPageMetrics, getCvPreviewFitZoom } from "../cvBuilder";
@@ -25,6 +25,11 @@ export function CoverLetterDocumentFields({ settings, disabled, onChange }: Prop
<Button disabled={disabled} onClick={() => setCopy("contact")}>{t("letterCopyContact")}</Button>
<Button disabled={disabled} onClick={() => setCopy("design")}>{t("letterMatchDesign")}</Button>
</Stack>
<TextField select size="small" label={t("letterDocumentLanguage")} value={settings.language ?? "en"} disabled={disabled}
onChange={event => onChange({ ...settings, language: event.target.value as "en" | "nb-NO" })}>
<MenuItem value="en">{t("languageEnglish")}</MenuItem>
<MenuItem value="nb-NO">{t("languageNorwegianBokmal")}</MenuItem>
</TextField>
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "minmax(0, 1fr)", sm: "repeat(2, minmax(0, 1fr))" }, gap: 1.5 }}>
{detailFields.map(key => <TextField key={key} label={t(fieldLabels[key])} size="small" fullWidth multiline={key === "recipient"} value={settings[key]} disabled={disabled}
slotProps={{ htmlInput: { maxLength: key === "recipient" ? 2000 : key === "subject" ? 500 : key === "date" || key === "senderPhone" ? 100 : 300 } }}
@@ -44,6 +49,7 @@ function CopyLetterSnapshot({ kind, settings, onClose, onApply }: { kind: "conta
const [failed, setFailed] = useState(false);
const [attempt, setAttempt] = useState(0);
const [loading, setLoading] = useState(true);
const [selectedContact, setSelectedContact] = useState<readonly typeof contactFields[number][]>(contactFields.filter(key => key !== "senderLocation"));
useEffect(() => {
let current = true;
setLoading(true); setFailed(false);
@@ -78,25 +84,37 @@ function CopyLetterSnapshot({ kind, settings, onClose, onApply }: { kind: "conta
// The dialog captures the original local draft until the user explicitly applies or cancels.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selected, kind, attempt]);
const apply = () => {
if (!snapshot) return;
if (kind === "design") { onApply(snapshot); return; }
const next = { ...settings };
const chosen = selectedContact.filter(key => snapshot[key].trim());
for (const key of chosen) next[key] = snapshot[key];
if (chosen.length) next.sourceCvId = snapshot.sourceCvId;
onApply(next);
};
return <Dialog open onClose={onClose} fullWidth maxWidth="sm" aria-labelledby={`${id}-title`} aria-describedby={`${id}-help`}>
<DialogTitle id={`${id}-title`}>{t(kind === "contact" ? "letterCopyContact" : "letterMatchDesign")}</DialogTitle>
<DialogContent dividers>
<Stack spacing={2}>
<Typography id={`${id}-help`} variant="body2">{t("letterCopyHelp")}</Typography>
{kind === "contact" && <Typography variant="body2">{t("letterCopyContactHelp")}</Typography>}
<TextField select fullWidth size="small" label={t("letterSourceCv")} value={selected} disabled={loading} onChange={event => setSelected(Number(event.target.value))}>
{variants.map(variant => <MenuItem key={variant.id} value={variant.id}>{variant.name}</MenuItem>)}
</TextField>
{!loading && !failed && variants.length === 0 && <Typography variant="body2">{t("assetsNoCvVariants")}</Typography>}
{failed && <Alert severity="error" action={<Button onClick={() => setAttempt(value => value + 1)}>{t("retry")}</Button>}>{t("letterCopyFailed")}</Alert>}
{snapshot && <Box role="region" aria-label={t(kind === "contact" ? "letterContactSnapshot" : "letterDesignSnapshot")} sx={{ overflowWrap: "anywhere" }}>
{kind === "contact" ? contactFields.map(key => <Typography key={key} variant="body2">{t(fieldLabels[key])}: {snapshot[key]}</Typography>) : <>
{kind === "contact" ? <Stack>{contactFields.map(key => <FormControlLabel key={key} label={`${t(fieldLabels[key])}: ${snapshot[key]}`}
control={<Checkbox size="small" disabled={!snapshot[key].trim()} checked={selectedContact.includes(key) && !!snapshot[key].trim()}
onChange={(_, checked) => setSelectedContact(current => checked ? [...current, key] : current.filter(field => field !== key))} />} />)}</Stack> : <>
<Typography variant="body2">{snapshot.themeId} · {snapshot.accentColor} · {snapshot.bodyFont}</Typography>
<Typography variant="body2">{snapshot.baseFontSizePt} pt · {snapshot.lineHeight} · {snapshot.pageMarginMm} mm · {snapshot.pageSize}</Typography>
</>}
</Box>}
</Stack>
</DialogContent>
<DialogActions><Button autoFocus onClick={onClose}>{t("cancel")}</Button><Button variant="contained" disabled={loading || failed || !snapshot} onClick={() => snapshot && onApply(snapshot)}>{t("letterCopyApply")}</Button></DialogActions>
<DialogActions><Button autoFocus onClick={onClose}>{t("cancel")}</Button><Button variant="contained" disabled={loading || failed || !snapshot} onClick={apply}>{t("letterCopyApply")}</Button></DialogActions>
</Dialog>;
}
@@ -12,11 +12,11 @@ const mockedApi = api as jest.Mocked<typeof api>;
const settings: CoverLetterDocumentSettings = {
schemaVersion: 1, senderName: "Saved sender", senderTitle: "Saved title", senderEmail: "saved@example.test", senderPhone: "123", senderLocation: "Oslo",
recipient: "Preserve recipient", subject: "Preserve subject", date: "2026-10-25", themeId: "modern", accentColor: "#2563eb", bodyFont: "Arial, Helvetica, sans-serif",
baseFontSizePt: 10, lineHeight: 1.42, pageMarginMm: 16, pageSize: "a4",
baseFontSizePt: 10, lineHeight: 1.42, pageMarginMm: 16, pageSize: "a4", language: "nb-NO",
};
const letter = { revision: 7, text: "Preserve letter body", currentVersion: 2, aiSuggestionCount: 0, documentSettings: settings,
versions: [{ version: 1, source: "manual", aiAction: null, length: 20, createdAtUtc: "2026-10-25T10:00:00Z", isCurrent: false }] };
const variant = { id: 3, name: "Chosen CV", settings: { themeId: "modern", headline: null, accentColor: null, bodyFont: null, baseFontSizePt: null, lineHeight: null, pageMarginMm: null, pageSize: null, density: "compact" } };
const variant = { id: 3, name: "Chosen CV", settings: { themeId: "modern", headline: null, accentColor: null, bodyFont: null, baseFontSizePt: null, lineHeight: null, pageMarginMm: null, pageSize: null, density: "compact", language: "en" } };
const wrap = (ui: React.ReactNode) => <I18nProvider><ConfirmProvider>{ui}</ConfirmProvider></I18nProvider>;
function deferred<T>() {
let resolve!: (value: T) => void;
@@ -52,6 +52,8 @@ test("contact copy is a reviewed canonical snapshot; cancel is inert and apply p
expect(snapshot).toHaveTextContent("Canonical name");
expect(snapshot).toHaveTextContent("Canonical title");
expect(snapshot).not.toHaveTextContent("private");
for (const name of [/Sender name/, /Professional title/, /Email/, /Phone/]) expect(within(snapshot).getByRole("checkbox", { name })).toBeChecked();
expect(within(snapshot).getByRole("checkbox", { name: /Location/ })).not.toBeChecked();
expect(onChange).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument());
@@ -60,11 +62,52 @@ test("contact copy is a reviewed canonical snapshot; cancel is inert and apply p
await selectSource();
await screen.findByRole("region", { name: "Contact snapshot" });
fireEvent.click(screen.getByRole("button", { name: "Use this copy" }));
expect(onChange).toHaveBeenCalledWith({ ...settings, senderName: "Canonical name", senderTitle: "Canonical title", senderEmail: "canonical@example.test", senderPhone: "+47 555", senderLocation: "Bergen", sourceCvId: 3 });
expect(onChange).toHaveBeenCalledWith({ ...settings, senderName: "Canonical name", senderTitle: "Canonical title", senderEmail: "canonical@example.test", senderPhone: "+47 555", sourceCvId: 3 });
expect(mockedApi.get).not.toHaveBeenCalledWith("/auth/me");
expect(mockedApi.put).not.toHaveBeenCalled(); expect(mockedApi.post).not.toHaveBeenCalled();
});
test("contact location copies only after selection and unselected contact values preserve destination fields", async () => {
const onChange = jest.fn();
render(wrap(<CoverLetterDocumentFields settings={settings} disabled={false} onChange={onChange} />));
fireEvent.click(screen.getByRole("button", { name: "Copy contact from CV" }));
await selectSource();
const snapshot = within(await screen.findByRole("region", { name: "Contact snapshot" }));
for (const name of [/Sender name/, /Professional title/, /Email/, /Phone/]) fireEvent.click(snapshot.getByRole("checkbox", { name }));
fireEvent.click(snapshot.getByRole("checkbox", { name: /Location/ }));
fireEvent.click(screen.getByRole("button", { name: "Use this copy" }));
expect(onChange).toHaveBeenCalledWith({ ...settings, senderLocation: "Bergen", sourceCvId: 3 });
expect(mockedApi.put).not.toHaveBeenCalled(); expect(mockedApi.post).not.toHaveBeenCalled();
});
test("blank source contacts and choosing no fields never clear destination contacts", async () => {
const originalGet = mockedApi.get.getMockImplementation()!;
mockedApi.get.mockImplementation(((url: string) => url === "/career/profile" ? Promise.resolve({ data: { profile: { contact: { fullName: "Canonical name", email: "", phone: " " } } } }) : originalGet(url)) as any);
const onChange = jest.fn();
render(wrap(<CoverLetterDocumentFields settings={settings} disabled={false} onChange={onChange} />));
fireEvent.click(screen.getByRole("button", { name: "Copy contact from CV" }));
await selectSource();
const snapshot = within(await screen.findByRole("region", { name: "Contact snapshot" }));
expect(snapshot.getByRole("checkbox", { name: /Email/ })).toBeDisabled();
expect(snapshot.getByRole("checkbox", { name: /Phone/ })).toBeDisabled();
fireEvent.click(snapshot.getByRole("checkbox", { name: /Sender name/ }));
fireEvent.click(screen.getByRole("button", { name: "Use this copy" }));
expect(onChange).toHaveBeenCalledWith(settings);
});
test("letter language is an explicit document draft and saves independently of interface and CV language", async () => {
mockedApi.put.mockResolvedValue({ data: { ...letter, revision: 8, documentSettings: { ...settings, language: "en" } } } as any);
render(wrap(<ApplicationCoverLetterSection jobId={7} />));
const language = await screen.findByRole("combobox", { name: "Letter language" });
expect(language).toHaveTextContent(/Norsk bokmål/i);
fireEvent.mouseDown(language);
fireEvent.click(await screen.findByRole("option", { name: "English" }));
expect(mockedApi.put).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole("button", { name: "Save" }));
await waitFor(() => expect(mockedApi.put).toHaveBeenCalledWith("/jobapplications/7/cover-letter", expect.objectContaining({ documentSettings: { ...settings, language: "en" }, expectedVersion: 7 })));
expect(screen.getByRole("textbox", { name: "Cover letter" })).toHaveValue(letter.text);
});
test("design copy resolves null overrides through actual theme defaults and effective CV density", async () => {
const onChange = jest.fn();
render(wrap(<CoverLetterDocumentFields settings={settings} disabled={false} onChange={onChange} />));
@@ -109,7 +152,7 @@ test("failed copy reads are retryable and cannot apply or save a partial snapsho
});
test("restoring a historical letter restores saved details with text after explicit dirty confirmation", async () => {
mockedApi.post.mockResolvedValue({ data: { ...letter, revision: 8, text: "Historical body", documentSettings: { ...settings, subject: "Historical subject", senderName: "Historical sender" } } } as any);
mockedApi.post.mockResolvedValue({ data: { ...letter, revision: 8, text: "Historical body", documentSettings: { ...settings, subject: "Historical subject", senderName: "Historical sender", language: "en" } } } as any);
render(wrap(<ApplicationCoverLetterSection jobId={7} />));
fireEvent.change(await screen.findByRole("textbox", { name: "Subject" }), { target: { value: "Local unsaved subject" } });
expect(screen.queryByRole("region", { name: "Letter text preview" })).not.toBeInTheDocument();
@@ -118,6 +161,7 @@ test("restoring a historical letter restores saved details with text after expli
fireEvent.click(within(dialog).getByRole("button", { name: "Discard and restore" }));
await waitFor(() => expect(screen.getByRole("textbox", { name: "Subject" })).toHaveValue("Historical subject"));
expect(screen.getByRole("textbox", { name: "Sender name" })).toHaveValue("Historical sender");
expect(screen.getByRole("combobox", { name: "Letter language" })).toHaveTextContent("English");
expect(screen.getByRole("textbox", { name: "Cover letter" })).toHaveValue("Historical body");
expect(mockedApi.post).toHaveBeenCalledWith("/jobapplications/7/cover-letter/versions/1/restore", { expectedVersion: 7 });
expect(mockedApi.put).not.toHaveBeenCalled();
+6
View File
@@ -716,6 +716,9 @@ export const translations = {
letterRecipient: "Recipient",
letterSubject: "Subject",
letterDate: "Date",
letterDocumentLanguage: "Letter language",
coverAiSourceChanged: "The linked CV, Career Profile or job changed. Generate a new suggestion before applying.",
letterCopyContactHelp: "Choose the contact fields to copy. Location is optional and starts unselected. Blank or unselected fields keep your existing letter details.",
letterCopyContact: "Copy contact from CV",
letterMatchDesign: "Match CV design",
letterCopyHelp: "Review this snapshot before applying. Your letter text and recipient stay unchanged; future profile or CV edits do not update this copy.",
@@ -3638,6 +3641,9 @@ export const translations = {
letterRecipient: "Mottaker",
letterSubject: "Emne",
letterDate: "Dato",
letterDocumentLanguage: "Språk i brevet",
coverAiSourceChanged: "Den tilknyttede CV-en, karriereprofilen eller stillingen er endret. Generer et nytt forslag før du bruker det.",
letterCopyContactHelp: "Velg kontaktfeltene du vil kopiere. Sted er valgfritt og er ikke valgt som standard. Tomme eller ikke valgte felt beholder de eksisterende opplysningene i brevet.",
letterCopyContact: "Kopier kontakt fra CV",
letterMatchDesign: "Bruk CV-design",
letterCopyHelp: "Se gjennom kopien før du bruker den. Brevteksten og mottakeren beholdes; senere profil- eller CV-endringer oppdaterer ikke denne kopien.",