Harden CV rewrite diagnostics and preview PDFs

This commit is contained in:
2026-04-11 21:36:45 +02:00
parent fcccecefa3
commit 534534b333
4 changed files with 371 additions and 48 deletions
@@ -556,6 +556,129 @@ public sealed class ProfileCvControllerTests
Assert.Equal("Warwickshire College, UK", structured.Education[0].Location); Assert.Equal("Warwickshire College, UK", structured.Education[0].Location);
} }
[Fact]
public async Task Rewrite_section_returns_ai_service_unavailable_detail_when_ai_health_is_unhealthy()
{
var user = new ApplicationUser { Id = "user-1", ProfileCvText = "Professional Summary\nBuilt backend systems." };
var userManager = CreateUserManager();
userManager.Setup(x => x.GetUserAsync(It.IsAny<ClaimsPrincipal>())).ReturnsAsync(user);
var aiService = new Mock<ISummarizerService>();
aiService
.Setup(x => x.SummarizeSectionAsync(It.IsAny<string>(), It.IsAny<string>(), 1800, 400))
.ReturnsAsync(string.Empty);
aiService
.Setup(x => x.GetMetricsAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new AiServiceMetrics(
Healthy: false,
Model: "distilbart",
Device: "cpu",
GpuAvailable: false,
GpuName: null,
OcrAvailable: true,
OcrLanguages: "eng",
OllamaConfigured: true,
OllamaReachable: true,
OllamaModel: "qwen2.5:7b",
OllamaModelAvailable: true,
OllamaVersion: "0.6.0",
OllamaInstalledModels: new List<string> { "qwen2.5:7b" },
OllamaLoadedModels: new List<string>(),
OllamaLoadedCount: 0,
HealthLatencyMs: 21,
ProbeLatencyMs: null,
LastProbeAt: null,
LastProbeSuccessAt: null,
LastProbeFailureAt: null,
ProbeFailures: 1,
Requests: 1,
CacheHits: 0,
CacheMisses: 1,
Failures: 1,
AverageLatencyMs: 21,
OcrRequests: 0,
OcrFailures: 0,
AverageOcrLatencyMs: null,
LastOcrSuccessAt: null,
LastOcrFailureAt: null,
LastSuccessAt: null,
LastFailureAt: DateTimeOffset.UtcNow,
LastError: "Model loading is disabled by AI_SERVICE_SKIP_MODEL_LOAD."));
await using var db = CreateDb();
var controller = CreateController(userManager.Object, aiService.Object, db, CreatePaths());
var result = await controller.RewriteSection(new ProfileCvController.RewriteSectionRequest());
var objectResult = Assert.IsType<ObjectResult>(result);
Assert.Equal(StatusCodes.Status502BadGateway, objectResult.StatusCode);
var payload = Assert.IsType<ProfileCvController.CvRewriteFailureDto>(objectResult.Value);
Assert.Equal("ai-service-unavailable", payload.Code);
Assert.Contains("could not rewrite", payload.Message, StringComparison.OrdinalIgnoreCase);
Assert.Contains("unavailable", payload.Detail ?? string.Empty, StringComparison.OrdinalIgnoreCase);
Assert.Contains("AI_SERVICE_SKIP_MODEL_LOAD", payload.LastAiError ?? string.Empty, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task Rewrite_section_returns_rewrite_empty_detail_when_ai_health_is_healthy()
{
var user = new ApplicationUser { Id = "user-1", ProfileCvText = "Professional Summary\nBuilt backend systems." };
var userManager = CreateUserManager();
userManager.Setup(x => x.GetUserAsync(It.IsAny<ClaimsPrincipal>())).ReturnsAsync(user);
var aiService = new Mock<ISummarizerService>();
aiService
.Setup(x => x.SummarizeSectionAsync(It.IsAny<string>(), It.IsAny<string>(), 1800, 400))
.ReturnsAsync(string.Empty);
aiService
.Setup(x => x.GetMetricsAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new AiServiceMetrics(
Healthy: true,
Model: "distilbart",
Device: "cpu",
GpuAvailable: false,
GpuName: null,
OcrAvailable: true,
OcrLanguages: "eng",
OllamaConfigured: true,
OllamaReachable: true,
OllamaModel: "qwen2.5:7b",
OllamaModelAvailable: true,
OllamaVersion: "0.6.0",
OllamaInstalledModels: new List<string> { "qwen2.5:7b" },
OllamaLoadedModels: new List<string>(),
OllamaLoadedCount: 0,
HealthLatencyMs: 21,
ProbeLatencyMs: null,
LastProbeAt: null,
LastProbeSuccessAt: null,
LastProbeFailureAt: null,
ProbeFailures: 0,
Requests: 1,
CacheHits: 0,
CacheMisses: 1,
Failures: 0,
AverageLatencyMs: 21,
OcrRequests: 0,
OcrFailures: 0,
AverageOcrLatencyMs: null,
LastOcrSuccessAt: null,
LastOcrFailureAt: null,
LastSuccessAt: DateTimeOffset.UtcNow,
LastFailureAt: null,
LastError: null));
await using var db = CreateDb();
var controller = CreateController(userManager.Object, aiService.Object, db, CreatePaths());
var result = await controller.RewriteSection(new ProfileCvController.RewriteSectionRequest());
var objectResult = Assert.IsType<ObjectResult>(result);
Assert.Equal(StatusCodes.Status502BadGateway, objectResult.StatusCode);
var payload = Assert.IsType<ProfileCvController.CvRewriteFailureDto>(objectResult.Value);
Assert.Equal("rewrite-empty", payload.Code);
Assert.Contains("empty", payload.Message, StringComparison.OrdinalIgnoreCase);
Assert.Contains("no usable text", payload.Detail ?? string.Empty, StringComparison.OrdinalIgnoreCase);
}
[Fact] [Fact]
public async Task Rewrite_section_can_target_saved_job_context_and_whole_cv() public async Task Rewrite_section_can_target_saved_job_context_and_whole_cv()
{ {
@@ -113,6 +113,7 @@ public sealed class ProfileCvController : ControllerBase
public sealed record ParseCvRequest(string? Text); public sealed record ParseCvRequest(string? Text);
public sealed record CvTemplateDescriptor(string Id, string Title, string Tone, string AccentColor, string PreviewTagline, string PreviewSummary, List<string> PreviewBullets); public sealed record CvTemplateDescriptor(string Id, string Title, string Tone, string AccentColor, string PreviewTagline, string PreviewSummary, List<string> PreviewBullets);
public sealed record ProfileCvPreviewDto(string TemplateId, string Html, string SuggestedFileName, string FullText, string RewrittenText, string? SectionName, StructuredCvProfile StructuredCv, TailoredCvDocument Document, string? TargetRole, int? JobApplicationId); public sealed record ProfileCvPreviewDto(string TemplateId, string Html, string SuggestedFileName, string FullText, string RewrittenText, string? SectionName, StructuredCvProfile StructuredCv, TailoredCvDocument Document, string? TargetRole, int? JobApplicationId);
public sealed record CvRewriteFailureDto(string Code, string Message, string? Detail = null, string? LastAiError = null);
private sealed record ExtractionPipelineResult(string RawText, string NormalizedText, StructuredCvProfile StructuredCv); private sealed record ExtractionPipelineResult(string RawText, string NormalizedText, StructuredCvProfile StructuredCv);
private sealed record ClassifiedCvBlock(int Index, string OriginalBlock, string SectionName, string Content, CvBlockClassificationResult? Classification); private sealed record ClassifiedCvBlock(int Index, string OriginalBlock, string SectionName, string Content, CvBlockClassificationResult? Classification);
@@ -337,9 +338,23 @@ public sealed class ProfileCvController : ControllerBase
if (string.IsNullOrWhiteSpace(rewritten)) if (string.IsNullOrWhiteSpace(rewritten))
{ {
_logger.LogWarning("CV rewrite returned empty output. Section={SectionName} Template={TemplateId} TargetRole={TargetRole} JobApplicationId={JobApplicationId} HasSourceText={HasSourceText} StructuredSections={StructuredSectionCount}", var metrics = await _aiService.GetMetricsAsync(HttpContext.RequestAborted);
sectionName ?? "<whole-cv>", templateId, effectiveTargetRole ?? "<none>", jobApplicationId, !string.IsNullOrWhiteSpace(sourceText), structuredCv.Sections.Count); var detail = metrics.Healthy
return StatusCode(StatusCodes.Status502BadGateway, "The AI service could not rewrite your CV right now."); ? "The rewrite request reached the AI service, but it returned no usable text."
: "The AI rewrite service is unavailable or not ready.";
var failureCode = metrics.Healthy ? "rewrite-empty" : "ai-service-unavailable";
var message = metrics.Healthy
? "The AI service returned an empty CV rewrite."
: "The AI service could not rewrite your CV right now.";
_logger.LogWarning("CV rewrite returned empty output. Section={SectionName} Template={TemplateId} TargetRole={TargetRole} JobApplicationId={JobApplicationId} HasSourceText={HasSourceText} StructuredSections={StructuredSectionCount} AiHealthy={AiHealthy} AiLastError={AiLastError}",
sectionName ?? "<whole-cv>", templateId, effectiveTargetRole ?? "<none>", jobApplicationId, !string.IsNullOrWhiteSpace(sourceText), structuredCv.Sections.Count, metrics.Healthy, metrics.LastError ?? "<none>");
return StatusCode(StatusCodes.Status502BadGateway, new CvRewriteFailureDto(
failureCode,
message,
detail,
metrics.LastError));
} }
return Ok(new return Ok(new
+191 -35
View File
@@ -7,7 +7,7 @@ import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
import PhotoCameraOutlinedIcon from "@mui/icons-material/PhotoCameraOutlined"; import PhotoCameraOutlinedIcon from "@mui/icons-material/PhotoCameraOutlined";
import ZoomInOutlinedIcon from "@mui/icons-material/ZoomInOutlined"; import ZoomInOutlinedIcon from "@mui/icons-material/ZoomInOutlined";
import { api } from "../api"; import { api, getApiErrorMessage } from "../api";
import GoogleAuthCard from "../components/GoogleAuthCard"; import GoogleAuthCard from "../components/GoogleAuthCard";
import CropImageDialog from "../components/CropImageDialog"; import CropImageDialog from "../components/CropImageDialog";
import { useToast } from "../toast"; import { useToast } from "../toast";
@@ -78,6 +78,24 @@ type CvBuilderPreview = {
jobApplicationId?: number | null; jobApplicationId?: number | null;
}; };
type PdfCarouselItem = {
templateId: CvSectionStyle;
title: string;
fileName: string;
pdfUrl?: string;
status: "loading" | "ready" | "error";
error?: string;
};
type RewriteRequestPayload = {
sectionName: string | null;
style: CvSectionStyle;
templateId: CvSectionStyle;
targetRole: string | null;
jobApplicationId: number | null;
sourceText: string | null;
};
type MeResponse = { type MeResponse = {
provider?: "local" | "google" | "external"; provider?: "local" | "google" | "external";
id?: string; id?: string;
@@ -227,6 +245,10 @@ export default function ProfilePage() {
const [selectedRewriteJobId, setSelectedRewriteJobId] = useState<string>(""); const [selectedRewriteJobId, setSelectedRewriteJobId] = useState<string>("");
const [rewritePreview, setRewritePreview] = useState<CvBuilderPreview | null>(null); const [rewritePreview, setRewritePreview] = useState<CvBuilderPreview | null>(null);
const [rewritePreviewTemplate, setRewritePreviewTemplate] = useState<RewriteTemplateOption | null>(null); const [rewritePreviewTemplate, setRewritePreviewTemplate] = useState<RewriteTemplateOption | null>(null);
const [pdfCarousel, setPdfCarousel] = useState<PdfCarouselItem[]>([]);
const [activePdfIndex, setActivePdfIndex] = useState(0);
const [buildingPdfDeck, setBuildingPdfDeck] = useState(false);
const [downloadingPdf, setDownloadingPdf] = useState(false);
const [savedJobs, setSavedJobs] = useState<JobApplication[]>([]); const [savedJobs, setSavedJobs] = useState<JobApplication[]>([]);
const [parsingCvSections, setParsingCvSections] = useState(false); const [parsingCvSections, setParsingCvSections] = useState(false);
const [reprocessingCv, setReprocessingCv] = useState(false); const [reprocessingCv, setReprocessingCv] = useState(false);
@@ -236,6 +258,16 @@ export default function ProfilePage() {
const [currentPassword, setCurrentPassword] = useState(""); const [currentPassword, setCurrentPassword] = useState("");
const [newPassword, setNewPassword] = useState(""); const [newPassword, setNewPassword] = useState("");
useEffect(() => {
return () => {
pdfCarousel.forEach((item) => {
if (item.pdfUrl) {
window.URL.revokeObjectURL(item.pdfUrl);
}
});
};
}, [pdfCarousel]);
const loadProfile = useCallback(async () => { const loadProfile = useCallback(async () => {
setLoading(true); setLoading(true);
try { try {
@@ -312,6 +344,100 @@ export default function ProfilePage() {
const selectedRewriteTemplate = REWRITE_TEMPLATES.find((option) => option.id === cvSectionStyle) ?? REWRITE_TEMPLATES[0]; const selectedRewriteTemplate = REWRITE_TEMPLATES.find((option) => option.id === cvSectionStyle) ?? REWRITE_TEMPLATES[0];
const selectedRewriteJob = savedJobs.find((job) => String(job.id) === selectedRewriteJobId) ?? null; const selectedRewriteJob = savedJobs.find((job) => String(job.id) === selectedRewriteJobId) ?? null;
const rewriteReady = Boolean(rewritePreview?.html && rewritePreview.fullText.trim()); const rewriteReady = Boolean(rewritePreview?.html && rewritePreview.fullText.trim());
const activePdfItem = pdfCarousel[activePdfIndex] ?? null;
const releasePdfCarousel = useCallback((items: PdfCarouselItem[]) => {
items.forEach((item) => {
if (item.pdfUrl) {
window.URL.revokeObjectURL(item.pdfUrl);
}
});
}, []);
const buildRewritePayload = useCallback((templateId: CvSectionStyle): RewriteRequestPayload => ({
sectionName: cvSection || null,
style: templateId,
templateId,
targetRole: cvSectionTargetRole.trim() || null,
jobApplicationId: selectedRewriteJob ? selectedRewriteJob.id : null,
sourceText: profileCvText.trim() || null,
}), [cvSection, cvSectionTargetRole, profileCvText, selectedRewriteJob]);
const resetPdfCarousel = useCallback(() => {
setPdfCarousel((current) => {
releasePdfCarousel(current);
return [];
});
setActivePdfIndex(0);
}, [releasePdfCarousel]);
const savePdfToCarousel = useCallback(async (templateId: CvSectionStyle, download = false) => {
const template = REWRITE_TEMPLATES.find((option) => option.id === templateId) ?? REWRITE_TEMPLATES[0];
const payload = buildRewritePayload(templateId);
const response = await api.post("/profile-cv/export-pdf", payload, { responseType: "blob" });
const blob = new Blob([response.data], { type: "application/pdf" });
const url = window.URL.createObjectURL(blob);
const item: PdfCarouselItem = {
templateId,
title: template.title,
fileName: rewritePreview?.suggestedFileName || `${templateId}-cv.pdf`,
pdfUrl: url,
status: "ready",
};
setPdfCarousel((current) => {
const existing = current.find((entry) => entry.templateId === templateId);
if (existing?.pdfUrl) {
window.URL.revokeObjectURL(existing.pdfUrl);
}
const next = existing
? current.map((entry) => (entry.templateId === templateId ? item : entry))
: [...current, item];
setActivePdfIndex(next.findIndex((entry) => entry.templateId === templateId));
return next;
});
if (download) {
const link = document.createElement("a");
link.href = url;
link.download = item.fileName;
document.body.appendChild(link);
link.click();
link.remove();
}
return item;
}, [buildRewritePayload, rewritePreview?.suggestedFileName]);
const buildPdfCarousel = useCallback(async () => {
setBuildingPdfDeck(true);
resetPdfCarousel();
const orderedTemplates = [selectedRewriteTemplate.id, ...REWRITE_TEMPLATES.map((option) => option.id).filter((id) => id !== selectedRewriteTemplate.id)];
const seedItems = orderedTemplates.map((templateId) => ({
templateId,
title: REWRITE_TEMPLATES.find((option) => option.id === templateId)?.title ?? templateId,
fileName: `${templateId}-cv.pdf`,
status: "loading" as const,
}));
setPdfCarousel(seedItems);
setActivePdfIndex(0);
for (const templateId of orderedTemplates) {
try {
const item = await savePdfToCarousel(templateId, false);
setPdfCarousel((current) => current.map((entry) => entry.templateId === templateId ? item : entry));
} catch (error: any) {
const message = getApiErrorMessage(error, `Failed to generate the ${templateId} PDF preview.`);
setPdfCarousel((current) => current.map((entry) => entry.templateId === templateId ? { ...entry, status: "error", error: message } : entry));
}
}
setBuildingPdfDeck(false);
}, [resetPdfCarousel, savePdfToCarousel, selectedRewriteTemplate.id]);
useEffect(() => {
resetPdfCarousel();
}, [rewritePreview?.fullText, rewritePreview?.templateId, rewritePreview?.targetRole, resetPdfCarousel]);
return ( return (
<Paper sx={{ mt: 0, p: 2.5 }}> <Paper sx={{ mt: 0, p: 2.5 }}>
@@ -903,19 +1029,13 @@ export default function ProfilePage() {
disabled={!isLocal || (!profileCvText.trim() && structuredCv.sections.length === 0) || rewritingSection || uploadingCv || improvingCv || rebuildingCv} disabled={!isLocal || (!profileCvText.trim() && structuredCv.sections.length === 0) || rewritingSection || uploadingCv || improvingCv || rebuildingCv}
onClick={async () => { onClick={async () => {
setRewritingSection(true); setRewritingSection(true);
resetPdfCarousel();
try { try {
const res = await api.post<CvBuilderPreview>("/profile-cv/rewrite-preview", { const res = await api.post<CvBuilderPreview>("/profile-cv/rewrite-preview", buildRewritePayload(cvSectionStyle));
sectionName: cvSection || null,
style: cvSectionStyle,
templateId: cvSectionStyle,
targetRole: cvSectionTargetRole.trim() || null,
jobApplicationId: selectedRewriteJob ? selectedRewriteJob.id : null,
sourceText: profileCvText.trim() || null,
});
setRewritePreview(res.data); setRewritePreview(res.data);
toast(t("profileCvSectionRewritten"), "success"); toast(t("profileCvSectionRewritten"), "success");
} catch (e: any) { } catch (e: any) {
toast(String(e?.response?.data || e?.message || t("profileCvSectionRewriteFailed")), "error"); toast(getApiErrorMessage(e, t("profileCvSectionRewriteFailed")), "error");
} finally { } finally {
setRewritingSection(false); setRewritingSection(false);
} }
@@ -925,33 +1045,27 @@ export default function ProfilePage() {
</Button> </Button>
<Button <Button
variant="outlined" variant="outlined"
disabled={!rewriteReady} disabled={!rewriteReady || downloadingPdf}
onClick={async () => { onClick={async () => {
setDownloadingPdf(true);
try { try {
const response = await api.post("/profile-cv/export-pdf", { await savePdfToCarousel(cvSectionStyle, true);
sectionName: cvSection || null, toast("CV PDF downloaded and added to the carousel.", "success");
style: cvSectionStyle,
templateId: cvSectionStyle,
targetRole: cvSectionTargetRole.trim() || null,
jobApplicationId: selectedRewriteJob ? selectedRewriteJob.id : null,
sourceText: profileCvText.trim() || null,
}, { responseType: "blob" });
const blob = new Blob([response.data], { type: "application/pdf" });
const url = window.URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = rewritePreview?.suggestedFileName || `${cvSectionStyle}-cv.pdf`;
document.body.appendChild(link);
link.click();
link.remove();
window.URL.revokeObjectURL(url);
toast("CV PDF downloaded.", "success");
} catch (e: any) { } catch (e: any) {
toast(String(e?.response?.data || e?.message || "Failed to export the CV PDF."), "error"); toast(getApiErrorMessage(e, "Failed to export the CV PDF."), "error");
} finally {
setDownloadingPdf(false);
} }
}} }}
> >
Download PDF {downloadingPdf ? "Generating PDF…" : "Download PDF"}
</Button>
<Button
variant="text"
disabled={!rewriteReady || buildingPdfDeck}
onClick={buildPdfCarousel}
>
{buildingPdfDeck ? "Building PDF carousel…" : "Build PDF carousel"}
</Button> </Button>
</Box> </Box>
</Box> </Box>
@@ -987,22 +1101,64 @@ export default function ProfilePage() {
<Paper sx={{ p: 1.5, borderRadius: 3, border: "1px solid", borderColor: "divider", backgroundColor: "background.paper" }}> <Paper sx={{ p: 1.5, borderRadius: 3, border: "1px solid", borderColor: "divider", backgroundColor: "background.paper" }}>
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 1, alignItems: "center", mb: 1 }}> <Box sx={{ display: "flex", justifyContent: "space-between", gap: 1, alignItems: "center", mb: 1 }}>
<Box> <Box>
<Typography variant="subtitle2" sx={{ fontWeight: 800 }}>Styled preview</Typography> <Typography variant="subtitle2" sx={{ fontWeight: 800 }}>PDF carousel</Typography>
<Typography variant="body2" sx={{ color: "text.secondary" }}>{selectedRewriteTemplate.title} · print-ready layout</Typography> <Typography variant="body2" sx={{ color: "text.secondary" }}>
{activePdfItem?.title ? `${activePdfItem.title} · generated PDF` : `${selectedRewriteTemplate.title} · print-ready layout`}
</Typography>
</Box> </Box>
{rewriteReady ? <Chip size="small" variant="outlined" label={rewritePreview?.suggestedFileName || "preview.pdf"} /> : null} {activePdfItem?.fileName ? <Chip size="small" variant="outlined" label={activePdfItem.fileName} /> : rewriteReady ? <Chip size="small" variant="outlined" label={rewritePreview?.suggestedFileName || "preview.pdf"} /> : null}
</Box> </Box>
{pdfCarousel.length > 0 ? (
<>
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", mb: 1.25 }}>
{pdfCarousel.map((item, index) => (
<Button
key={item.templateId}
size="small"
variant={index === activePdfIndex ? "contained" : "outlined"}
color={item.status === "error" ? "error" : item.status === "ready" ? "primary" : "inherit"}
onClick={() => setActivePdfIndex(index)}
>
{item.title}
</Button>
))}
</Box>
<Box sx={{ borderRadius: 2.5, border: "1px solid", borderColor: "divider", backgroundColor: "background.default", overflow: "hidden", minHeight: 520 }}>
{activePdfItem?.status === "ready" && activePdfItem.pdfUrl ? (
<iframe title={`${activePdfItem.title} PDF preview`} src={activePdfItem.pdfUrl} style={{ width: "100%", minHeight: 520, border: 0, background: "white" }} />
) : activePdfItem?.status === "error" ? (
<Box sx={{ minHeight: 520, display: "grid", placeItems: "center", p: 3 }}>
<Box sx={{ maxWidth: 420, textAlign: "center" }}>
<Typography variant="subtitle2" sx={{ fontWeight: 900, mb: 1 }}>{activePdfItem.title} PDF unavailable</Typography>
<Typography variant="body2" sx={{ color: "text.secondary" }}>{activePdfItem.error || "This template could not be rendered as a PDF right now."}</Typography>
</Box>
</Box>
) : (
<Box sx={{ minHeight: 520, display: "grid", placeItems: "center", p: 3 }}>
<Box sx={{ maxWidth: 420, textAlign: "center" }}>
<Typography variant="subtitle2" sx={{ fontWeight: 900, mb: 1 }}>{activePdfItem?.title || "Preparing PDF preview"}</Typography>
<Typography variant="body2" sx={{ color: "text.secondary" }}>
{buildingPdfDeck ? "The carousel is generating PDFs across the current template set." : "Generate the PDF carousel to inspect rendered export files without leaving the page."}
</Typography>
</Box>
</Box>
)}
</Box>
</>
) : (
<Box sx={{ borderRadius: 2.5, border: "1px solid", borderColor: "divider", backgroundColor: "background.default", overflow: "hidden", minHeight: 520 }}> <Box sx={{ borderRadius: 2.5, border: "1px solid", borderColor: "divider", backgroundColor: "background.default", overflow: "hidden", minHeight: 520 }}>
{rewriteReady ? ( {rewriteReady ? (
<iframe title="Profile CV preview" srcDoc={rewritePreview?.html} style={{ width: "100%", minHeight: 520, border: 0, background: "white" }} /> <iframe title="Profile CV preview" srcDoc={rewritePreview?.html} style={{ width: "100%", minHeight: 520, border: 0, background: "white" }} />
) : ( ) : (
<Box sx={{ minHeight: 520, display: "grid", placeItems: "center", p: 3 }}> <Box sx={{ minHeight: 520, display: "grid", placeItems: "center", p: 3 }}>
<Typography variant="body2" sx={{ color: "text.secondary", textAlign: "center", maxWidth: 360 }}> <Typography variant="body2" sx={{ color: "text.secondary", textAlign: "center", maxWidth: 360 }}>
The visual preview uses the same server-rendered HTML that the PDF exporter prints. Build a preview to inspect layout, spacing, and hierarchy before you apply it. The visual preview uses the same server-rendered HTML that the PDF exporter prints. Build a preview to inspect layout, then generate the PDF carousel to compare rendered files template by template.
</Typography> </Typography>
</Box> </Box>
)} )}
</Box> </Box>
)}
</Paper> </Paper>
</Box> </Box>
+30 -1
View File
@@ -6,6 +6,17 @@ import { I18nProvider } from './i18n/I18nProvider';
import ProfilePage from './pages/ProfilePage'; import ProfilePage from './pages/ProfilePage';
import { api } from './api'; import { api } from './api';
const createObjectURLMock = jest.fn(() => 'blob:mock-pdf');
const revokeObjectURLMock = jest.fn();
Object.defineProperty(window.URL, 'createObjectURL', {
writable: true,
value: createObjectURLMock,
});
Object.defineProperty(window.URL, 'revokeObjectURL', {
writable: true,
value: revokeObjectURLMock,
});
jest.mock('./api', () => ({ jest.mock('./api', () => ({
api: { api: {
get: jest.fn(), get: jest.fn(),
@@ -22,6 +33,8 @@ jest.mock('./components/CropImageDialog', () => () => null);
const mockedApi = api as jest.Mocked<typeof api>; const mockedApi = api as jest.Mocked<typeof api>;
const REWRITE_TEMPLATES_COUNT = 6;
const structuredCv = { const structuredCv = {
version: '1', version: '1',
metadata: { metadata: {
@@ -131,7 +144,7 @@ beforeEach(() => {
} }
return Promise.resolve({ data: {} } as any); return Promise.resolve({ data: {} } as any);
}); });
mockedApi.post.mockImplementation((url: string) => { mockedApi.post.mockImplementation((url: string, payload?: any, config?: any) => {
if (url === '/profile-cv/parse') { if (url === '/profile-cv/parse') {
return Promise.resolve({ return Promise.resolve({
data: { data: {
@@ -149,6 +162,9 @@ beforeEach(() => {
if (url === '/profile-cv/rewrite-preview') { if (url === '/profile-cv/rewrite-preview') {
return Promise.resolve({ data: { templateId: 'harvard', html: '<html><body>Preview</body></html>', suggestedFileName: 'harvard-preview.pdf', fullText: 'Professional Summary\nClearer, sharper positioning for backend platform roles.', rewrittenText: 'Professional Summary\nClearer, sharper positioning for backend platform roles.', structuredCv, sectionName: null, jobApplicationId: 42, targetRole: 'Senior Backend Engineer' } } as any); return Promise.resolve({ data: { templateId: 'harvard', html: '<html><body>Preview</body></html>', suggestedFileName: 'harvard-preview.pdf', fullText: 'Professional Summary\nClearer, sharper positioning for backend platform roles.', rewrittenText: 'Professional Summary\nClearer, sharper positioning for backend platform roles.', structuredCv, sectionName: null, jobApplicationId: 42, targetRole: 'Senior Backend Engineer' } } as any);
} }
if (url === '/profile-cv/export-pdf') {
return Promise.resolve({ data: new Blob([`pdf-${payload?.templateId ?? 'ats-minimal'}`], { type: 'application/pdf' }), config } as any);
}
if (url === '/profile-cv/reprocess') { if (url === '/profile-cv/reprocess') {
return Promise.resolve({ data: { reprocessed: true } } as any); return Promise.resolve({ data: { reprocessed: true } } as any);
} }
@@ -160,6 +176,8 @@ beforeEach(() => {
afterEach(() => { afterEach(() => {
jest.clearAllMocks(); jest.clearAllMocks();
createObjectURLMock.mockClear();
revokeObjectURLMock.mockClear();
}); });
test('profile page loads persisted structured cv and can re-parse it', async () => { test('profile page loads persisted structured cv and can re-parse it', async () => {
@@ -247,6 +265,17 @@ test('profile page rewrite tools use selected template and saved job context', a
expect(await screen.findByText(/preview ready/i)).toBeInTheDocument(); expect(await screen.findByText(/preview ready/i)).toBeInTheDocument();
expect(screen.getByText(/clearer, sharper positioning for backend platform roles/i)).toBeInTheDocument(); expect(screen.getByText(/clearer, sharper positioning for backend platform roles/i)).toBeInTheDocument();
expect(screen.getByRole('heading', { name: /pdf carousel/i })).toBeInTheDocument();
const buildCarouselButton = screen.getByRole('button', { name: /build pdf carousel/i });
fireEvent.click(buildCarouselButton);
await waitFor(() => {
const exportCalls = mockedApi.post.mock.calls.filter(([url]) => url === '/profile-cv/export-pdf');
expect(exportCalls.length).toBe(REWRITE_TEMPLATES_COUNT);
});
await waitFor(() => expect(createObjectURLMock).toHaveBeenCalledTimes(REWRITE_TEMPLATES_COUNT));
}); });
test('saving profile persists structured cv json', async () => { test('saving profile persists structured cv json', async () => {