Improve CV rewrite flow and parser accuracy

This commit is contained in:
2026-04-01 11:30:37 +02:00
parent f402213526
commit f22c6791a7
9 changed files with 581 additions and 55 deletions
@@ -117,6 +117,7 @@ describe('AdminSystemPage', () => {
expect(screen.getByText(/25.8 ms probe/i)).toBeTruthy();
expect(screen.getByText('OCR eng')).toBeTruthy();
expect(screen.getAllByText(/ollama configured/i).length).toBeGreaterThan(0);
expect(screen.getByText('OCR avg latency')).toBeTruthy();
expect(screen.getByText('88.4 ms')).toBeTruthy();
});
+10
View File
@@ -434,6 +434,10 @@ export const translations = {
adminSystemLastProbe: "Last probe",
adminSystemLastSuccessfulProbe: "Last successful probe",
adminSystemLastSummarizationSuccess: "Last summarization success",
adminSystemOllamaConfigured: "Ollama configured",
adminSystemOllamaReachable: "Ollama reachable",
adminSystemOllamaModel: "Ollama model",
adminSystemOllamaModelAvailable: "Ollama model ready",
adminSystemRequests: "Requests",
adminSystemCacheHits: "Cache hits",
adminSystemCacheMisses: "Cache misses",
@@ -443,6 +447,7 @@ export const translations = {
adminSystemOcrRequests: "OCR requests",
adminSystemOcrAvgLatency: "OCR avg latency",
adminSystemOcrUnavailable: "OCR unavailable",
adminSystemOllamaOff: "Ollama off",
adminSystemAiProbeFailed: "Failed to run AI service probe.",
correspondenceNoMessages: "No messages yet.",
correspondenceMe: "Me",
@@ -1339,6 +1344,10 @@ export const translations = {
adminSystemLastProbe: "Siste probe",
adminSystemLastSuccessfulProbe: "Siste vellykkede probe",
adminSystemLastSummarizationSuccess: "Siste vellykkede oppsummering",
adminSystemOllamaConfigured: "Ollama konfigurert",
adminSystemOllamaReachable: "Ollama tilgjengelig",
adminSystemOllamaModel: "Ollama-modell",
adminSystemOllamaModelAvailable: "Ollama-modell klar",
adminSystemRequests: "Forespørsler",
adminSystemCacheHits: "Cache-treff",
adminSystemCacheMisses: "Cache-miss",
@@ -1348,6 +1357,7 @@ export const translations = {
adminSystemOcrRequests: "OCR-forespørsler",
adminSystemOcrAvgLatency: "OCR snittlatens",
adminSystemOcrUnavailable: "OCR utilgjengelig",
adminSystemOllamaOff: "Ollama av",
adminSystemAiProbeFailed: "Kunne ikke kjøre AI-tjenesteprobe.",
correspondenceNoMessages: "Ingen meldinger ennå.",
correspondenceMe: "Meg",
@@ -363,6 +363,10 @@ export default function AdminSystemPage() {
<DetailRow label={t("adminSystemDevice")} value={status?.ai.device || "-"} />
<DetailRow label={t("adminSystemGpuAvailable")} value={status?.ai.gpuAvailable ? t("yes") : t("noWord")} />
<DetailRow label={t("adminSystemGpuName")} value={status?.ai.gpuName || "-"} />
<DetailRow label={t("adminSystemOllamaConfigured")} value={status?.ai.ollamaConfigured ? t("yes") : t("noWord")} />
<DetailRow label={t("adminSystemOllamaReachable")} value={status?.ai.ollamaReachable ? t("yes") : t("noWord")} />
<DetailRow label={t("adminSystemOllamaModel")} value={status?.ai.ollamaModel || "-"} />
<DetailRow label={t("adminSystemOllamaModelAvailable")} value={status?.ai.ollamaModelAvailable ? t("yes") : t("noWord")} />
<DetailRow label={t("adminSystemHealthLatency")} value={status?.ai.healthLatencyMs != null ? `${status.ai.healthLatencyMs} ms` : "-"} />
<DetailRow label={t("adminSystemProbeLatency")} value={status?.ai.probeLatencyMs != null ? `${status.ai.probeLatencyMs} ms` : "-"} />
<DetailRow label={t("adminSystemLastProbe")} value={formatDate(status?.ai.lastProbeAt)} />
+180 -34
View File
@@ -1,10 +1,11 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Accordion, AccordionDetails, AccordionSummary, Alert, Avatar, Box, Button, Chip, Divider, FormControl, InputLabel, LinearProgress, MenuItem, Paper, Select, TextField, Typography } from "@mui/material";
import { Accordion, AccordionDetails, AccordionSummary, Alert, Avatar, Box, Button, Chip, Dialog, DialogContent, DialogTitle, Divider, FormControl, IconButton, InputLabel, LinearProgress, MenuItem, Paper, Select, TextField, Typography } from "@mui/material";
import DeleteOutlineIcon from "@mui/icons-material/DeleteOutline";
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
import PhotoCameraOutlinedIcon from "@mui/icons-material/PhotoCameraOutlined";
import ZoomInOutlinedIcon from "@mui/icons-material/ZoomInOutlined";
import { api } from "../api";
import GoogleAuthCard from "../components/GoogleAuthCard";
@@ -21,10 +22,11 @@ import {
StructuredCvFieldMetadata,
StructuredCvProfile,
} from "../profileCv";
import { JobApplication } from "../types";
type CvSectionOption = "Professional Summary" | "Core Skills" | "Experience Highlights" | "Selected Achievements" | "Projects";
type CvSectionStyle = "balanced" | "concise" | "impact" | "ats";
type CvSectionOption = "" | "Professional Summary" | "Core Skills" | "Experience Highlights" | "Selected Achievements" | "Projects";
type CvSectionStyle = "ats-minimal" | "harvard" | "auckland" | "edinburgh";
type ExtractionRun = {
id: number;
@@ -40,6 +42,24 @@ type ExtractionRun = {
errorMessage?: string;
};
type JobListResponse = {
items: JobApplication[];
total: number;
page: number;
pageSize: number;
};
type RewriteTemplateOption = {
id: CvSectionStyle;
title: string;
eyebrow: string;
accent: string;
blurb: string;
sampleHeading: string;
sampleMeta: string;
sampleBullets: string[];
};
type MeResponse = {
provider?: "local" | "google" | "external";
id?: string;
@@ -61,6 +81,48 @@ type MeResponse = {
const CV_UPLOAD_ACCEPT = ".pdf,.docx,.txt,.md,image/png,image/jpeg,image/webp,application/pdf,application/vnd.openxmlformats-officedocument.wordprocessingml.document,text/plain,text/markdown";
const AVATAR_UPLOAD_ACCEPT = "image/png,image/jpeg,image/webp";
const REWRITE_TEMPLATES: RewriteTemplateOption[] = [
{
id: "ats-minimal",
title: "ATS Minimal",
eyebrow: "Scanner-friendly",
accent: "#0f172a",
blurb: "Compact, direct, and easy for screening systems to parse.",
sampleHeading: "Senior Backend Engineer",
sampleMeta: "Acme Systems · Oslo · 2021 - Present",
sampleBullets: ["Built API workflows with measurable delivery outcomes.", "Kept skills and achievements easy to scan."]
},
{
id: "harvard",
title: "Harvard",
eyebrow: "Traditional",
accent: "#7f1d1d",
blurb: "Formal hierarchy and restrained tone for conservative hiring flows.",
sampleHeading: "Professional Summary",
sampleMeta: "Clear structure · precise dates · credible language",
sampleBullets: ["Emphasizes polished summaries.", "Works well for broad professional roles."]
},
{
id: "auckland",
title: "Auckland",
eyebrow: "Modern sidebar",
accent: "#0f766e",
blurb: "Sharper highlights with a more contemporary, design-forward rhythm.",
sampleHeading: "Selected Impact",
sampleMeta: "Focused strengths · compact highlights",
sampleBullets: ["Pulls skills into stronger highlight clusters.", "Good when you want a fresher feel."]
},
{
id: "edinburgh",
title: "Edinburgh",
eyebrow: "Editorial",
accent: "#5b21b6",
blurb: "More personality and stronger section contrast without losing clarity.",
sampleHeading: "Experience Highlights",
sampleMeta: "Premium spacing · stronger visual voice",
sampleBullets: ["Useful when the CV should feel more distinctive.", "Still keeps wording grounded and factual."]
},
];
function initialsFrom(values: Array<string | undefined>) {
const joined = values.map((x) => (x ?? "").trim()).filter(Boolean);
@@ -142,10 +204,13 @@ export default function ProfilePage() {
const [headline, setHeadline] = useState("");
const [profileCvText, setProfileCvText] = useState("");
const [rewritingSection, setRewritingSection] = useState(false);
const [cvSection, setCvSection] = useState<CvSectionOption>("Professional Summary");
const [cvSectionStyle, setCvSectionStyle] = useState<CvSectionStyle>("balanced");
const [cvSection, setCvSection] = useState<CvSectionOption>("");
const [cvSectionStyle, setCvSectionStyle] = useState<CvSectionStyle>("ats-minimal");
const [cvSectionTargetRole, setCvSectionTargetRole] = useState("");
const [selectedRewriteJobId, setSelectedRewriteJobId] = useState<string>("");
const [cvSectionDraft, setCvSectionDraft] = useState("");
const [rewritePreviewTemplate, setRewritePreviewTemplate] = useState<RewriteTemplateOption | null>(null);
const [savedJobs, setSavedJobs] = useState<JobApplication[]>([]);
const [parsingCvSections, setParsingCvSections] = useState(false);
const [reprocessingCv, setReprocessingCv] = useState(false);
const [structuredCv, setStructuredCv] = useState<StructuredCvProfile>(emptyStructuredCv());
@@ -155,9 +220,10 @@ export default function ProfilePage() {
const loadProfile = useCallback(async () => {
try {
const [profileResponse, runsResponse] = await Promise.all([
const [profileResponse, runsResponse, jobsResponse] = await Promise.all([
api.get<MeResponse>("/auth/me"),
api.get<ExtractionRun[]>("/profile-cv/runs").catch(() => ({ data: [] as ExtractionRun[] } as any)),
api.get<JobListResponse>("/jobapplications", { params: { page: 1, pageSize: 100, sortBy: "dateApplied", sortDir: "desc" } }).catch(() => ({ data: { items: [], total: 0, page: 1, pageSize: 100 } } as any)),
]);
const r = profileResponse;
setMe(r.data);
@@ -169,10 +235,12 @@ export default function ProfilePage() {
setProfileCvText(r.data?.profileCvText ?? "");
setStructuredCv(parseStructuredCvJson(r.data?.profileCvStructureJson));
setExtractionRuns(runsResponse.data ?? []);
setSavedJobs(jobsResponse.data?.items ?? []);
setHeadline(window.localStorage.getItem("profileHeadline") ?? "");
} catch {
setMe(null);
setExtractionRuns([]);
setSavedJobs([]);
}
}, []);
@@ -193,6 +261,8 @@ export default function ProfilePage() {
: t("profileGoogleNotLinked");
const cvLabel = profileCvText.trim() ? t("profileCvReady", { count: cvWordCount }) : t("profileCvMissing");
const latestRun = extractionRuns[0];
const selectedRewriteTemplate = REWRITE_TEMPLATES.find((option) => option.id === cvSectionStyle) ?? REWRITE_TEMPLATES[0];
const selectedRewriteJob = savedJobs.find((job) => String(job.id) === selectedRewriteJobId) ?? null;
return (
<Paper sx={{ mt: 0, p: 2.5 }}>
@@ -672,22 +742,24 @@ export default function ProfilePage() {
))}
</Box>
</Box>
<Box sx={{ mt: 2, p: 1.5, borderRadius: 3, border: "1px solid", borderColor: "divider", backgroundColor: "background.paper" }}>
<Box sx={{ mt: 2, p: 2, borderRadius: 3, border: "1px solid", borderColor: "divider", background: "linear-gradient(180deg, rgba(15,23,42,0.03) 0%, rgba(15,23,42,0) 100%)" }}>
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 2, flexWrap: "wrap", alignItems: "center", mb: 1.5 }}>
<Box>
<Typography variant="subtitle1" sx={{ fontWeight: 800 }}>{t("profileCvSectionTools")}</Typography>
<Typography variant="subtitle1" sx={{ fontWeight: 800 }}>CV style rewrite studio</Typography>
<Typography variant="body2" sx={{ color: "text.secondary" }}>{t("profileCvSectionToolsHelp")}</Typography>
</Box>
<Button
variant="outlined"
disabled={!isLocal || !profileCvText.trim() || rewritingSection || uploadingCv || improvingCv || rebuildingCv}
variant="contained"
disabled={!isLocal || (!profileCvText.trim() && structuredCv.sections.length === 0) || rewritingSection || uploadingCv || improvingCv || rebuildingCv}
onClick={async () => {
setRewritingSection(true);
try {
const res = await api.post<{ text?: string }>("/profile-cv/rewrite-section", {
sectionName: cvSection,
sectionName: cvSection || null,
style: cvSectionStyle,
templateId: cvSectionStyle,
targetRole: cvSectionTargetRole.trim() || null,
jobApplicationId: selectedRewriteJob ? selectedRewriteJob.id : null,
});
setCvSectionDraft(res.data?.text ?? "");
toast(t("profileCvSectionRewritten"), "success");
@@ -701,10 +773,59 @@ export default function ProfilePage() {
{rewritingSection ? t("profileCvSectionRewriting") : t("profileCvSectionRewrite")}
</Button>
</Box>
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr 1.2fr" }, gap: 1.5, mb: 1.5 }}>
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "repeat(4, minmax(0, 1fr))" }, gap: 1.5, mb: 2 }}>
{REWRITE_TEMPLATES.map((option) => {
const selected = option.id === cvSectionStyle;
return (
<Paper
key={option.id}
role="button"
tabIndex={0}
onClick={() => setCvSectionStyle(option.id)}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
setCvSectionStyle(option.id);
}
}}
sx={{
p: 1.5,
borderRadius: 3,
cursor: "pointer",
border: "1px solid",
borderColor: selected ? "primary.main" : "divider",
boxShadow: selected ? "0 0 0 1px rgba(25,118,210,0.15)" : "none",
backgroundColor: selected ? "rgba(25,118,210,0.06)" : "background.paper",
}}
>
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start", gap: 1, mb: 1 }}>
<Box>
<Typography variant="overline" sx={{ color: option.accent, fontWeight: 800 }}>{option.eyebrow}</Typography>
<Typography variant="subtitle2" sx={{ fontWeight: 800 }}>{option.title}</Typography>
</Box>
<IconButton size="small" onClick={(event) => { event.stopPropagation(); setRewritePreviewTemplate(option); }}>
<ZoomInOutlinedIcon fontSize="small" />
</IconButton>
</Box>
<Box sx={{ p: 1.25, borderRadius: 2.5, border: "1px solid", borderColor: "divider", backgroundColor: "background.default", minHeight: 148 }}>
<Typography variant="caption" sx={{ display: "block", color: option.accent, fontWeight: 700, mb: 0.5 }}>{option.sampleHeading}</Typography>
<Typography variant="caption" sx={{ display: "block", color: "text.secondary", mb: 1 }}>{option.sampleMeta}</Typography>
{option.sampleBullets.map((bullet) => (
<Typography key={bullet} variant="caption" sx={{ display: "block", color: "text.primary", mb: 0.5 }}> {bullet}</Typography>
))}
</Box>
<Typography variant="body2" sx={{ color: "text.secondary", mt: 1 }}>{option.blurb}</Typography>
</Paper>
);
})}
</Box>
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" }, gap: 1.5, mb: 1.5 }}>
<FormControl fullWidth size="small">
<InputLabel>{t("profileCvSectionLabel")}</InputLabel>
<Select value={cvSection} label={t("profileCvSectionLabel")} onChange={(e) => setCvSection(e.target.value as CvSectionOption)}>
<MenuItem value="">Whole CV</MenuItem>
<MenuItem value="Professional Summary">{t("profileCvSectionSummary")}</MenuItem>
<MenuItem value="Core Skills">{t("profileCvSectionSkills")}</MenuItem>
<MenuItem value="Experience Highlights">{t("profileCvSectionExperience")}</MenuItem>
@@ -712,31 +833,56 @@ export default function ProfilePage() {
<MenuItem value="Projects">{t("profileCvSectionProjects")}</MenuItem>
</Select>
</FormControl>
<FormControl fullWidth size="small">
<InputLabel>{t("profileCvSectionStyle")}</InputLabel>
<Select value={cvSectionStyle} label={t("profileCvSectionStyle")} onChange={(e) => setCvSectionStyle(e.target.value as CvSectionStyle)}>
<MenuItem value="balanced">{t("profileCvSectionStyleBalanced")}</MenuItem>
<MenuItem value="concise">{t("profileCvSectionStyleConcise")}</MenuItem>
<MenuItem value="impact">{t("profileCvSectionStyleImpact")}</MenuItem>
<MenuItem value="ats">{t("profileCvSectionStyleAts")}</MenuItem>
<TextField label={t("profileCvSectionTargetRole")} value={cvSectionTargetRole} onChange={(e) => setCvSectionTargetRole(e.target.value)} fullWidth helperText={selectedRewriteJob ? `Using saved job context: ${selectedRewriteJob.jobTitle}` : undefined} />
<FormControl fullWidth size="small" sx={{ gridColumn: { xs: "1 / -1", md: "1 / -1" } }}>
<InputLabel>Saved job context</InputLabel>
<Select value={selectedRewriteJobId} label="Saved job context" onChange={(e) => setSelectedRewriteJobId(String(e.target.value))}>
<MenuItem value="">None</MenuItem>
{savedJobs.map((job) => (
<MenuItem key={job.id} value={String(job.id)}>{job.jobTitle} · {job.company?.name ?? "Unknown company"}</MenuItem>
))}
</Select>
</FormControl>
<TextField label={t("profileCvSectionTargetRole")} value={cvSectionTargetRole} onChange={(e) => setCvSectionTargetRole(e.target.value)} fullWidth />
</Box>
<TextField
label={t("profileCvSectionDraft")}
value={cvSectionDraft}
onChange={(e) => setCvSectionDraft(e.target.value)}
multiline
minRows={6}
fullWidth
placeholder={t("profileCvSectionDraftPlaceholder")}
/>
<Box sx={{ mt: 1, display: "flex", justifyContent: "flex-end", gap: 1, flexWrap: "wrap" }}>
<Button variant="text" disabled={!cvSectionDraft.trim()} onClick={() => navigator.clipboard.writeText(cvSectionDraft)}>{t("profileCopyCvText")}</Button>
<Button variant="outlined" disabled={!cvSectionDraft.trim()} onClick={() => setProfileCvText((prev) => `${prev.trim()}\n\n${cvSection}\n${cvSectionDraft.trim()}`.trim())}>{t("profileCvSectionAppend")}</Button>
<Button variant="contained" disabled={!cvSectionDraft.trim()} onClick={() => setProfileCvText((prev) => replaceCvSection(prev, cvSection, cvSectionDraft))}>{t("profileCvSectionReplace")}</Button>
</Box>
<Paper sx={{ p: 1.5, borderRadius: 3, border: "1px solid", borderColor: "divider", backgroundColor: "background.paper" }}>
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 2, flexWrap: "wrap", alignItems: "center", mb: 1 }}>
<Box>
<Typography variant="subtitle2" sx={{ fontWeight: 800 }}>Rewrite preview</Typography>
<Typography variant="body2" sx={{ color: "text.secondary" }}>{selectedRewriteTemplate.title} · {cvSection || "Whole CV"}</Typography>
</Box>
{cvSectionDraft.trim() ? <Chip size="small" color="success" label="Draft ready" /> : null}
</Box>
<Box sx={{ minHeight: 180, borderRadius: 2.5, backgroundColor: "background.default", border: "1px dashed", borderColor: "divider", p: 1.5 }}>
{cvSectionDraft.trim() ? (
<Typography variant="body2" sx={{ whiteSpace: "pre-wrap" }}>{cvSectionDraft}</Typography>
) : (
<Typography variant="body2" sx={{ color: "text.secondary" }}>Choose a CV style, optionally aim it at a saved job, and generate a rewrite preview here.</Typography>
)}
</Box>
<Box sx={{ mt: 1, display: "flex", justifyContent: "flex-end", gap: 1, flexWrap: "wrap" }}>
<Button variant="text" disabled={!cvSectionDraft.trim()} onClick={() => navigator.clipboard.writeText(cvSectionDraft)}>{t("profileCopyCvText")}</Button>
<Button variant="outlined" disabled={!cvSectionDraft.trim()} onClick={() => setProfileCvText((prev) => cvSection ? `${prev.trim()}\n\n${cvSection}\n${cvSectionDraft.trim()}`.trim() : cvSectionDraft.trim())}>{cvSection ? t("profileCvSectionAppend") : "Use as full CV"}</Button>
<Button variant="contained" disabled={!cvSectionDraft.trim()} onClick={() => setProfileCvText((prev) => cvSection ? replaceCvSection(prev, cvSection, cvSectionDraft) : cvSectionDraft.trim())}>{cvSection ? t("profileCvSectionReplace") : "Replace full CV"}</Button>
</Box>
</Paper>
<Dialog open={Boolean(rewritePreviewTemplate)} onClose={() => setRewritePreviewTemplate(null)} maxWidth="sm" fullWidth>
<DialogTitle>{rewritePreviewTemplate?.title ?? "Template preview"}</DialogTitle>
<DialogContent>
{rewritePreviewTemplate ? (
<Box sx={{ p: 2, borderRadius: 3, border: "1px solid", borderColor: "divider", background: `linear-gradient(180deg, ${rewritePreviewTemplate.accent}12 0%, rgba(255,255,255,0) 100%)` }}>
<Typography variant="overline" sx={{ color: rewritePreviewTemplate.accent, fontWeight: 800 }}>{rewritePreviewTemplate.eyebrow}</Typography>
<Typography variant="h6" sx={{ fontWeight: 900, mb: 0.5 }}>{rewritePreviewTemplate.sampleHeading}</Typography>
<Typography variant="body2" sx={{ color: "text.secondary", mb: 1.5 }}>{rewritePreviewTemplate.sampleMeta}</Typography>
{rewritePreviewTemplate.sampleBullets.map((bullet) => (
<Typography key={bullet} variant="body2" sx={{ mb: 0.75 }}> {bullet}</Typography>
))}
<Typography variant="body2" sx={{ color: "text.secondary", mt: 1.5 }}>{rewritePreviewTemplate.blurb}</Typography>
</Box>
) : null}
</DialogContent>
</Dialog>
</Box>
<Box sx={{ mt: 1, display: "flex", justifyContent: "space-between", gap: 1, flexWrap: "wrap" }}>
<Typography variant="caption" sx={{ color: "text.secondary" }}>
+48
View File
@@ -108,6 +108,27 @@ beforeEach(() => {
],
} as any);
}
if (url === '/jobapplications') {
return Promise.resolve({
data: {
items: [
{
id: 42,
jobTitle: 'Senior Backend Engineer',
company: { id: 7, name: 'Acme Systems' },
status: 'Waiting',
dateApplied: '2026-03-20',
daysSince: 10,
description: 'Build API integrations and platform workflows.',
responseReceived: false,
},
],
total: 1,
page: 1,
pageSize: 100,
},
} as any);
}
return Promise.resolve({ data: {} } as any);
});
mockedApi.post.mockImplementation((url: string) => {
@@ -125,6 +146,9 @@ beforeEach(() => {
},
} as any);
}
if (url === '/profile-cv/rewrite-section') {
return Promise.resolve({ data: { text: 'Professional Summary\nClearer, sharper positioning for backend platform roles.' } } as any);
}
if (url === '/profile-cv/reprocess') {
return Promise.resolve({ data: { reprocessed: true } } as any);
}
@@ -201,6 +225,30 @@ test('profile page keeps raw extraction collapsed until expanded', async () => {
expect(copyButtons.some((button) => !button.hasAttribute('disabled'))).toBe(true);
});
test('profile page rewrite tools use selected template and saved job context', async () => {
renderPage();
expect(await screen.findByText(/cv style rewrite studio/i)).toBeInTheDocument();
fireEvent.click(screen.getByText(/harvard/i));
fireEvent.mouseDown(screen.getAllByRole('combobox')[1]);
fireEvent.click(await screen.findByText(/senior backend engineer · acme systems/i));
const rewriteButton = screen.getByRole('button', { name: /rewrite section/i });
fireEvent.click(rewriteButton);
await waitFor(() => {
expect(mockedApi.post).toHaveBeenCalledWith('/profile-cv/rewrite-section', expect.objectContaining({
sectionName: null,
style: 'harvard',
templateId: 'harvard',
jobApplicationId: 42,
}));
});
expect(await screen.findByText(/draft ready/i)).toBeInTheDocument();
expect(screen.getByText(/clearer, sharper positioning for backend platform roles/i)).toBeInTheDocument();
});
test('saving profile persists structured cv json', async () => {
renderPage();