feat(cv): qualify templates and reliable local import processing

This commit is contained in:
cesnimda
2026-10-28 14:36:12 +01:00
parent 73ea888375
commit 3f4db8ca81
69 changed files with 2597 additions and 180 deletions
@@ -0,0 +1,124 @@
import { expect, test } from '@playwright/test';
import fs from 'node:fs/promises';
import path from 'node:path';
import { createHash } from 'node:crypto';
import { previewDocumentHtml } from '../src/cvPreview';
const api = 'http://localhost:5302/api';
const output = path.resolve(process.env.LETTER_LIBRARY_OUTPUT || '../.artifacts/pre-phase10-ux/matching-letters-01');
const sha256 = (value: string | Buffer) => createHash('sha256').update(value).digest('hex');
test('[PRE-P10 B] nine matching letter designs retain short EN and long NB physical documents', async ({ page, context, browser }, info) => {
test.setTimeout(600_000);
expect(process.env.PLAYWRIGHT_REUSE_SERVERS).not.toBe('true');
await context.route('**/*', route => {
const url = new URL(route.request().url());
return ['localhost', '127.0.0.1'].includes(url.hostname) || ['data:', 'blob:', 'about:'].includes(url.protocol) ? route.continue() : route.abort();
});
expect((await page.request.post(`${api}/auth/login`, { data: { email: 'e2e@example.test', password: 'E2ePassword123!' } })).status()).toBe(200);
const me = await (await page.request.get(`${api}/auth/me`)).json();
const headers = { 'X-CSRF-TOKEN': (await page.request.storageState()).cookies.find(cookie => cookie.name === 'XSRF-TOKEN')!.value, 'X-Session-Context': me.sessionContext as string };
const sourceBefore = await (await page.request.get(`${api}/career/profile`)).json();
const themes = await (await page.request.get(`${api}/cv/themes`)).json();
expect(themes).toHaveLength(9);
const companyResponse = await page.request.post(`${api}/companies`, { headers, data: { name: 'Fictional Fjord Systems AS', source: 'User' } });
expect(companyResponse.status()).toBe(201);
const company = await companyResponse.json();
await fs.mkdir(output, { recursive: true });
const fixtures = [];
for (const language of ['en', 'nb'] as const) {
const nb = language === 'nb';
const paragraphs = Array.from({ length: nb ? 20 : 3 }, (_, index) => {
const sentence = nb ? 'Jeg dokumenterte pålitelige arbeidsflyter, tilgjengelige grensesnitt og tydelig ansvar i et oppdiktet prosjekt. '
: 'I documented reliable workflows, accessible interfaces and clear ownership in a fictional project. ';
return `B${String(index + 1).padStart(2, '0')} ${sentence.repeat(nb ? 4 : 1)}`.trim();
});
paragraphs.push(nb ? 'R99 Med vennlig hilsen, Åse Ødegård-Sørensen. Dette er kun syntetiske eksempler.'
: 'R99 Kind regards, Åse Ødegård-Sørensen. These are synthetic examples only.');
const text = paragraphs.join('\n\n');
for (const theme of themes) {
const fixture = `${nb ? 'long-nb' : 'short-en'}-${theme.id}`;
const directory = path.join(output, fixture);
await fs.mkdir(directory, { recursive: true });
const documentSettings = { schemaVersion: 1, designVersion: 2, language: nb ? 'nb-NO' : 'en', themeId: theme.id,
senderName: 'R01 Åse Ødegård-Sørensen', senderTitle: nb ? 'P01 Plattformingeniør' : 'P01 Platform engineer',
senderEmail: 'fictional.ase@example.test', senderLocation: 'Tromsø, Norge',
recipient: 'R03 Fictional hiring team\nFictional Fjord Systems AS\nTromsø, Norge',
subject: nb ? 'R02 Søknad om plattformingeniør' : 'R02 Application for platform engineer', date: '2026-10-27', pageSize: 'a4' };
const created = await page.request.post(`${api}/jobapplications`, { headers, data: { companyId: company.id, jobTitle: fixture, status: 'Saved', description: 'Synthetic matching-letter physical proof.' } });
expect(created.status(), fixture).toBe(201);
const job = await created.json();
const letterUrl = `${api}/jobapplications/${job.id}/cover-letter`;
const original = await (await page.request.get(letterUrl)).json();
const savedResponse = await page.request.put(letterUrl, { headers, data: { text, documentSettings, source: 'manual', expectedVersion: original.revision } });
expect(savedResponse.status(), fixture).toBe(200);
const saved = await (await page.request.get(letterUrl)).json();
expect(saved.text).toBe(text);
expect(saved.documentSettings).toMatchObject({ designVersion: 2, themeId: theme.id, accentColor: theme.accent, bodyFont: theme.bodyFont, headingFont: theme.headingFont });
const previewResponse = await page.request.post(`${letterUrl}/preview`, { headers, data: { text: saved.text, documentSettings: saved.documentSettings } });
expect(previewResponse.status(), fixture).toBe(200);
const rendered = await previewResponse.json();
expect(rendered.html).toContain(`letter-theme-${theme.id}`);
expect(rendered.html).toContain('data:font/ttf;base64,');
expect(rendered.html).toContain(theme.accent);
await fs.writeFile(path.join(directory, 'render.html'), rendered.html);
const expectedMarkers = [documentSettings.senderName, documentSettings.senderTitle, documentSettings.recipient, documentSettings.subject, text].join('\n').match(/\b[SEBPR]\d{2}\b/g)!;
await page.setViewportSize({ width: 794, height: 1123 });
await page.setContent(previewDocumentHtml(rendered.html, 297 * 96 / 25.4));
await expect.poll(() => page.evaluate(() => document.documentElement.dataset.cvPreviewReady), { message: fixture }).toBe('true');
const measured = await page.evaluate(() => {
const data = document.documentElement.dataset;
const width = Number(data.cvPreviewWidth), height = Number(data.cvPreviewHeight);
const tokens = [];
const walker = document.createTreeWalker(document.querySelector('.page')!, NodeFilter.SHOW_TEXT);
let node;
while ((node = walker.nextNode())) {
if (['SCRIPT', 'STYLE'].includes(node.parentElement?.tagName || '')) continue;
for (const match of (node.textContent || '').matchAll(/\b[SEBPR]\d{2}\b/g)) {
const range = document.createRange(); range.setStart(node, match.index!); range.setEnd(node, match.index! + match[0].length);
const rect = range.getClientRects()[0], style = getComputedStyle(node.parentElement!);
const index = Math.floor(rect.x / width);
tokens.push({ token: match[0], x: rect.x - index * width, y: rect.y + index * height, page: index + 1, fontSize: style.fontSize, color: style.color });
}
}
const header = getComputedStyle(document.querySelector('header')!);
const heading = getComputedStyle(document.querySelector('h1')!);
const contact = getComputedStyle(document.querySelector('.contact')!);
return { width, height, contentHeight: Number(data.cvPreviewPages) * height, pages: Number(data.cvPreviewPages),
effectiveMargin: getComputedStyle(document.documentElement).getPropertyValue('--cv-page-margin'), layoutDurationMs: Number(data.cvPreviewDurationMs), tokens,
text: document.querySelector('.page')!.textContent, bodyFont: getComputedStyle(document.body).fontFamily,
headingFont: heading.fontFamily, headingColor: heading.color, headingSize: heading.fontSize,
headerBackground: header.backgroundColor, headerAlign: header.textAlign, headerBorder: header.borderBottomWidth,
headerGridColumn: header.gridColumn, headerBefore: getComputedStyle(document.querySelector('header')!, '::before').content,
pageDisplay: getComputedStyle(document.querySelector('.page')!).display, contactFont: contact.fontFamily };
});
expect(measured.tokens.map(token => token.token).sort(), `${fixture} complete preview markers`).toEqual([...expectedMarkers].sort());
for (const paragraph of paragraphs) expect(measured.text, `${fixture} full body paragraph`).toContain(paragraph);
expect(measured.text).toContain(documentSettings.recipient);
if (theme.id === 'modern') expect(measured.headerBackground).not.toBe('rgba(0, 0, 0, 0)');
if (theme.id === 'minimal') expect(measured.headerBorder).toBe('0px');
if (theme.id === 'executive') expect(measured.headerAlign).toBe('center');
if (theme.id === 'elegant') expect(measured.headerBefore).toContain(nb ? 'Søknadsbrev' : 'Cover letter');
if (theme.id === 'code') expect(measured.contactFont).toContain('Roboto Mono');
if (['technical', 'nordic', 'creative'].includes(theme.id)) expect(measured.pageDisplay).toBe('grid');
const exported = await page.request.post(`${letterUrl}/export-pdf?expectedVersion=${saved.revision}`, { headers });
expect(exported.status(), fixture).toBe(200);
const bytes = await exported.body();
expect(bytes.subarray(0, 5).toString()).toBe('%PDF-');
await fs.writeFile(path.join(directory, 'export.pdf'), bytes);
for (let index = 0; index < measured.pages; index++) {
await page.setContent(previewDocumentHtml(rendered.html, measured.height, index));
await expect.poll(() => page.evaluate(() => document.documentElement.dataset.cvPreviewReady)).toBe('true');
await page.screenshot({ path: path.join(directory, `preview-${index + 1}.png`) });
}
expect(await (await page.request.get(letterUrl)).json()).toEqual(saved);
fixtures.push({ fixture, settings: saved.documentSettings, expectedMarkers, expectedParagraphs: paragraphs,
expectedRecipient: documentSettings.recipient, expectedSender: documentSettings.senderName, expectedSubject: documentSettings.subject,
previewPages: measured.pages, measured, savedRevision: saved.revision, htmlSha256: sha256(rendered.html), pdfSha256: sha256(bytes) });
await fs.writeFile(path.join(output, 'measurements.json'), JSON.stringify({ browserVersion: browser.version(), fixtures }, null, 2));
}
}
expect(fixtures).toHaveLength(18);
expect(await (await page.request.get(`${api}/career/profile`)).json()).toEqual(sourceBefore);
await info.attach('matching-letter-library-measurements', { path: path.join(output, 'measurements.json'), contentType: 'application/json' });
});
@@ -0,0 +1,40 @@
import { emptyStructuredCv } from '../src/profileCv';
// Synthetic comparison corpus: each theme receives identical facts and marker membership.
export const templateFixtures = [
{ key: 'short-en', language: 'en', jobs: 1, bullets: 2, skills: 5 },
{ key: 'medium-nb', language: 'nb', jobs: 4, bullets: 3, skills: 12 },
{ key: 'long-en', language: 'en', jobs: 10, bullets: 4, skills: 25 },
{ key: 'dense-nb', language: 'nb', jobs: 12, bullets: 5, skills: 60 },
] as const;
export function templateProfile(sample: typeof templateFixtures[number]) {
const nb = sample.language === 'nb';
const profile = {
...emptyStructuredCv(),
contact: { ...emptyStructuredCv().contact,
fullName: 'S01 Åse Ødegård-Sørensen LangtEtternavn',
headline: nb ? 'S02 Senior plattformingeniør og fagansvarlig' : 'S02 Senior platform engineer and technical lead',
email: 'ase.odegard-sorensen.long-account-identifier@example.test', location: nb ? 'Tromsø, Norge' : 'Tromsø, Norway' },
summary: [nb ? 'S03 Bygger pålitelige tjenester med tydelig dokumentasjon, tilgjengelighet og målbare resultater.' : 'S03 Builds reliable services with clear documentation, accessibility and measurable results.'],
jobs: Array.from({ length: sample.jobs }, (_, index) => ({
id: `comparison-job-${index + 1}`,
title: `E${String(index + 1).padStart(2, '0')} ${nb ? 'Senior utvikler og teknisk rådgiver' : 'Senior software engineer and technical adviser'}`,
company: `${nb ? 'Eksempel Teknologi' : 'Example Technology'} ${index + 1}`,
startDate: `${2025 - index}-01`, endDate: index ? `${2026 - index}-01` : undefined, isCurrent: index === 0,
bullets: Array.from({ length: sample.bullets }, (_, detail) => {
const marker = `B${String(index * sample.bullets + detail + 1).padStart(2, '0')}`;
return nb ? `${marker} Forbedret stabile arbeidsflyter gjennom samarbeid, grundig testing og tilgjengelige grensesnitt for norske brukere. Dokumenterte ansvar, beslutninger og målbare resultater uten å miste historiske opplysninger.`
: `${marker} Improved reliable workflows through collaboration, thorough testing and accessible interfaces. Documented ownership, engineering decisions and measurable outcomes while preserving historical information.`;
}),
})),
skills: Array.from({ length: sample.skills }, (_, index) => `P${String(index + 1).padStart(2, '0')} ${['C# og .NET', 'React og TypeScript', 'Tilgjengelighet', 'Distribuerte systemer', 'PostgreSQL', 'Docker'][index % 6]}`),
// Short intentionally omits education; all fixtures omit optional phone/website/projects.
education: sample.key === 'short-en' ? [] : [{ id: 'comparison-education', qualification: 'R01 Master i informatikk', institution: 'Universitetet i Tromsø', startDate: '2014', endDate: '2019' }],
languages: [{ name: 'R02 Norsk bokmål', level: 'Morsmål' }, { name: 'R03 English', level: 'Fluent' }],
interests: ['R04 Friluftsliv og åpen kildekode'],
};
const markers = JSON.stringify(profile).match(/\b[SEBPR]\d{2}\b/g)!;
if (new Set(markers).size !== markers.length) throw new Error(`Duplicate fixture markers: ${sample.key}`);
return { profile, markers };
}
@@ -0,0 +1,59 @@
import { expect, test } from '@playwright/test';
import fs from 'node:fs/promises';
import path from 'node:path';
import { createHash } from 'node:crypto';
import { templateFixtures, templateProfile } from './pre-p10-template-fixtures';
const api = 'http://localhost:5302/api';
const baseline = path.resolve('../.artifacts/pre-phase10-ux/templates-legacy');
const output = path.resolve(process.env.CV_TEMPLATE_OUTPUT || '../.artifacts/pre-phase10-ux/templates-revised-01');
const hash = (text: string) => createHash('sha256').update(text).digest('hex');
// The existing avatar endpoint now normalizes image pixels with Skia. Retain font data
// verbatim; exclude only embedded image payload bytes when comparing document markup.
const normalizeAvatar = (html: string) => html.replace(/data:image\/(?:png|jpeg|webp);base64,[A-Za-z0-9+/=]+/g, 'data:image/normalized-avatar');
test('[PRE-P10 B] existing design-one markup, CSS and source markers survive design-two additions', async ({ request }, info) => {
test.setTimeout(180_000);
expect((await request.post(`${api}/auth/login`, { data: { email: 'e2e@example.test', password: 'E2ePassword123!' } })).status()).toBe(200);
const me = await (await request.get(`${api}/auth/me`)).json();
const headers = { 'X-CSRF-TOKEN': (await request.storageState()).cookies.find(cookie => cookie.name === 'XSRF-TOKEN')!.value, 'X-Session-Context': me.sessionContext as string };
const avatar = await fs.readFile(path.join(baseline, 'synthetic-nonhuman-color-sample.png'));
expect((await request.post(`${api}/auth/avatar`, { headers, multipart: { file: { name: 'synthetic-nonhuman.png', mimeType: 'image/png', buffer: avatar } } })).status()).toBe(200);
const themes = await (await request.get(`${api}/cv/themes`)).json();
const results = [];
await fs.mkdir(path.join(output, 'legacy-current'), { recursive: true });
for (const sample of templateFixtures) {
const { profile } = templateProfile(sample);
const source = await (await request.get(`${api}/career/profile`)).json();
expect((await request.put(`${api}/career/profile`, { headers, data: { expectedVersion: source.revision, profile } })).status()).toBe(200);
for (const theme of themes) {
const fixture = `${sample.key}-${theme.id}`;
const created = await request.post(`${api}/cv/variants`, { headers, data: { name: fixture, settings: { themeId: theme.id, language: sample.language, showPhoto: true, showIcons: true } } });
expect(created.status(), fixture).toBe(200);
const variant = await created.json();
const response = await request.get(`${api}/cv/variants/${variant.id}/preview`);
expect(response.status(), fixture).toBe(200);
const current = (await response.json()).html as string;
const original = await fs.readFile(path.join(baseline, fixture, 'render.html'), 'utf8');
const style = (html: string) => html.match(/<style>([\s\S]*?)<\/style>/)![1];
const body = (html: string) => html.match(/<body>([\s\S]*?)<\/body>/)![1];
const originalMarkers = body(original).match(/\b[SEBPR]\d{2}\b/g);
const currentMarkers = body(current).match(/\b[SEBPR]\d{2}\b/g);
const measured = { fixture, rawOriginalHash: hash(original), rawCurrentHash: hash(current),
normalizedOriginalHash: hash(normalizeAvatar(original)), normalizedCurrentHash: hash(normalizeAvatar(current)),
originalCssHash: hash(style(original)), currentCssHash: hash(style(current)),
originalBodyHash: hash(normalizeAvatar(body(original))), currentBodyHash: hash(normalizeAvatar(body(current))),
markersEqual: JSON.stringify(currentMarkers) === JSON.stringify(originalMarkers) };
results.push(measured);
await fs.writeFile(path.join(output, 'legacy-current', `${fixture}.html`), current);
await fs.writeFile(path.join(output, 'legacy-preservation.json'), JSON.stringify({
normalization: 'Only data:image/{png,jpeg,webp};base64 payload bytes excluded because the upload pipeline now reencodes the synthetic avatar with Skia. Font resources, CSS and all other HTML bytes remain exact.', fixtures: results }, null, 2));
expect.soft(measured.normalizedCurrentHash, `${fixture} normalized full HTML`).toBe(measured.normalizedOriginalHash);
expect.soft(measured.currentCssHash, `${fixture} exact CSS including embedded licensed fonts`).toBe(measured.originalCssHash);
expect.soft(measured.currentBodyHash, `${fixture} body excluding avatar bytes`).toBe(measured.originalBodyHash);
expect.soft(measured.markersEqual, `${fixture} marker order`).toBe(true);
}
}
expect(results).toHaveLength(36);
await info.attach('legacy-preservation', { path: path.join(output, 'legacy-preservation.json'), contentType: 'application/json' });
});
@@ -0,0 +1,115 @@
import { expect, test } from '@playwright/test';
import fs from 'node:fs/promises';
import path from 'node:path';
import { createHash } from 'node:crypto';
import { previewDocumentHtml } from '../src/cvPreview';
import { templateFixtures, templateProfile } from './pre-p10-template-fixtures';
const api = 'http://localhost:5302/api';
const output = path.resolve(process.env.CV_TEMPLATE_OUTPUT || '../.artifacts/pre-phase10-ux/templates-legacy');
const designVersion = process.env.PRE_P10_DESIGN_VERSION ? Number(process.env.PRE_P10_DESIGN_VERSION) : undefined;
const hash = (value: string | Buffer) => createHash('sha256').update(value).digest('hex');
test('[PRE-P10 B] equivalent synthetic histories retain preview and native PDF evidence across all nine themes', async ({ page, context, browser }, info) => {
test.setTimeout(600_000);
expect(process.env.PLAYWRIGHT_REUSE_SERVERS).not.toBe('true');
await context.route('**/*', route => {
const url = new URL(route.request().url());
return ['localhost', '127.0.0.1'].includes(url.hostname) || ['data:', 'blob:', 'about:'].includes(url.protocol) ? route.continue() : route.abort();
});
expect((await page.request.post(`${api}/auth/login`, { data: { email: 'e2e@example.test', password: 'E2ePassword123!' } })).status()).toBe(200);
const me = await (await page.request.get(`${api}/auth/me`)).json();
const headers = { 'X-CSRF-TOKEN': (await page.request.storageState()).cookies.find(cookie => cookie.name === 'XSRF-TOKEN')!.value, 'X-Session-Context': me.sessionContext as string };
const themes = await (await page.request.get(`${api}/cv/themes`)).json();
expect(themes.map((theme: { id: string }) => theme.id).sort()).toEqual(['ats-classic', 'code', 'creative', 'elegant', 'executive', 'minimal', 'modern', 'nordic', 'technical']);
await fs.mkdir(output, { recursive: true });
await page.setContent('<canvas width="512" height="512"></canvas>');
const image = await page.evaluate(() => {
const canvas = document.querySelector('canvas')!;
const drawing = canvas.getContext('2d')!;
drawing.fillStyle = '#cbd5e1'; drawing.fillRect(0, 0, 512, 512);
drawing.fillStyle = '#0f766e'; drawing.fillRect(32, 32, 448, 224);
drawing.fillStyle = '#f59e0b'; drawing.fillRect(128, 288, 256, 160);
return canvas.toDataURL('image/png').split(',')[1];
});
const avatar = Buffer.from(image, 'base64');
await fs.writeFile(path.join(output, 'synthetic-nonhuman-color-sample.png'), avatar);
expect((await page.request.post(`${api}/auth/avatar`, { headers, multipart: { file: { name: 'synthetic-nonhuman.png', mimeType: 'image/png', buffer: avatar } } })).status()).toBe(200);
const results: unknown[] = [];
for (const sample of templateFixtures) {
const { profile, markers } = templateProfile(sample);
const current = await (await page.request.get(`${api}/career/profile`)).json();
expect((await page.request.put(`${api}/career/profile`, { headers, data: { expectedVersion: current.revision, profile } })).status()).toBe(200);
const source = await (await page.request.get(`${api}/career/profile`)).json();
for (const theme of themes) {
const fixture = `${sample.key}-${theme.id}`;
const directory = path.join(output, fixture);
await fs.mkdir(directory, { recursive: true });
const started = performance.now();
const created = await page.request.post(`${api}/cv/variants`, { headers, data: { name: fixture, settings: {
themeId: theme.id, language: sample.language, showPhoto: true, showIcons: true,
...(designVersion === undefined ? {} : { designVersion }),
} } });
expect(created.status(), fixture).toBe(200);
const variant = await created.json();
const renderedResponse = await page.request.get(`${api}/cv/variants/${variant.id}/preview`);
expect(renderedResponse.status(), fixture).toBe(200);
const rendered = await renderedResponse.json();
await fs.writeFile(path.join(directory, 'render.html'), rendered.html);
await page.setViewportSize({ width: 794, height: 1123 });
await page.setContent(previewDocumentHtml(rendered.html, 297 * 96 / 25.4));
await expect.poll(() => page.evaluate(() => document.documentElement.dataset.cvPreviewReady), { message: fixture }).toBe('true');
const measured = await page.evaluate(() => {
const data = document.documentElement.dataset;
const width = Number(data.cvPreviewWidth), height = Number(data.cvPreviewHeight);
const tokens: { token: string; x: number; y: number; page: number; fontSize: string; color: string }[] = [];
const nameLines: { text: string; page: number; x: number; y: number; width: number; height: number }[] = [];
const walker = document.createTreeWalker(document.querySelector('.page')!, NodeFilter.SHOW_TEXT);
let node;
while ((node = walker.nextNode())) {
if (['SCRIPT', 'STYLE'].includes(node.parentElement?.tagName || '')) continue;
for (const match of (node.textContent || '').matchAll(/\b[SEBPR]\d{2}\b/g)) {
const range = document.createRange(); range.setStart(node, match.index!); range.setEnd(node, match.index! + match[0].length);
const rect = range.getClientRects()[0], style = getComputedStyle(node.parentElement!);
const index = Math.floor(rect.x / width);
tokens.push({ token: match[0], x: rect.x - index * width, y: rect.y + index * height, page: index + 1, fontSize: style.fontSize, color: style.color });
}
if (node.parentElement?.closest('.name')) {
for (const match of (node.textContent || '').matchAll(/\S+/g)) {
const range = document.createRange(); range.setStart(node, match.index!); range.setEnd(node, match.index! + match[0].length);
for (const rect of range.getClientRects()) nameLines.push({ text: match[0], page: Math.floor(rect.x / width) + 1, x: rect.x % width, y: rect.y, width: rect.width, height: rect.height });
}
}
}
const sidebar = document.querySelector('.sidebar');
const sidebarStyle = sidebar ? getComputedStyle(sidebar) : null;
return { width, contentHeight: Number(data.cvPreviewPages) * height, usableHeight: Number(data.cvPreviewUsableHeight), layoutDurationMs: Number(data.cvPreviewDurationMs),
effectiveMargin: getComputedStyle(document.documentElement).getPropertyValue('--cv-page-margin'), tokens, nameLines,
nameLineCount: new Set(nameLines.map(rect => `${rect.page}:${rect.y.toFixed(1)}`)).size,
splitNameWords: nameLines.filter((rect, index) => nameLines.findIndex(item => item.text === rect.text) !== index).map(rect => rect.text),
sidebar: sidebarStyle ? { background: sidebarStyle.backgroundColor, color: sidebarStyle.color, padding: sidebarStyle.padding, width: sidebar?.getBoundingClientRect().width } : null,
photo: !!document.querySelector('.photo img'), previewPages: Number(data.cvPreviewPages), overflow: data.cvPreviewOverflow, userAgent: navigator.userAgent };
});
expect(measured.tokens.map(token => token.token).sort(), `${fixture} preview marker completeness`).toEqual([...markers].sort());
expect(measured.previewPages, fixture).toBeGreaterThan(0);
if (designVersion === 2) expect(measured.splitNameWords.filter(word => !word.includes('-')), `${fixture} unhyphenated name words`).toEqual([]);
const exported = await page.request.post(`${api}/cv/variants/${variant.id}/export-pdf?expectedVersion=${variant.version}`, { headers });
expect(exported.status(), fixture).toBe(200);
const bytes = await exported.body();
expect(bytes.subarray(0, 5).toString()).toBe('%PDF-');
await fs.writeFile(path.join(directory, 'export.pdf'), bytes);
for (let index = 0; index < measured.previewPages; index++) {
await page.setContent(previewDocumentHtml(rendered.html, 297 * 96 / 25.4, index));
await expect.poll(() => page.evaluate(() => document.documentElement.dataset.cvPreviewReady)).toBe('true');
await page.screenshot({ path: path.join(directory, `preview-${index + 1}.png`) });
}
expect((await (await page.request.get(`${api}/cv/variants/${variant.id}`)).json()).version).toBe(variant.version);
expect(await (await page.request.get(`${api}/career/profile`)).json()).toEqual(source);
results.push({ fixture, settings: variant.settings, expectedMarkers: markers, profileSha256: hash(JSON.stringify(profile)), htmlSha256: hash(rendered.html), pdfSha256: hash(bytes),
durationMs: performance.now() - started, previewPages: measured.previewPages, measured });
await fs.writeFile(path.join(output, 'measurements.json'), JSON.stringify({ browserVersion: browser.version(), designVersion: designVersion ?? 'legacy-absent', syntheticPhoto: 'Colored rectangles only; no human portrait', fixtures: results }, null, 2));
}
}
expect(results).toHaveLength(36);
await info.attach('template-library-measurements', { path: path.join(output, 'measurements.json'), contentType: 'application/json' });
});
@@ -0,0 +1,230 @@
import { expect, test, type APIRequestContext, type Page } from '@playwright/test';
import { translations } from '../src/i18n/translations';
import { emptyStructuredCv } from '../src/profileCv';
const api = 'http://localhost:5302/api';
const password = 'E2ePassword123!';
const themeNames = {
modern: 'cvTemplateModernName', minimal: 'cvTemplateMinimalName', executive: 'cvTemplateExecutiveName',
technical: 'cvTemplateTechnicalName', 'ats-classic': 'cvTemplateAtsClassicName', nordic: 'cvTemplateNordicName',
elegant: 'cvTemplateElegantName', creative: 'cvTemplateCreativeName', code: 'cvTemplateCodeName',
} as const;
async function login(request: APIRequestContext, email: string) {
expect((await request.post(`${api}/auth/login`, { data: { email, password } })).status()).toBe(200);
const me = await (await request.get(`${api}/auth/me`)).json();
return { 'X-CSRF-TOKEN': (await request.storageState()).cookies.find(cookie => cookie.name === 'XSRF-TOKEN')!.value,
'X-Session-Context': me.sessionContext as string };
}
async function owner(page: Page, admin: APIRequestContext, language: 'en' | 'nb') {
const adminHeaders = await login(admin, 'e2e@example.test');
const email = `template-selection-${Date.now()}-${Math.random().toString(36).slice(2)}@example.test`;
expect((await admin.post(`${api}/users`, { headers: adminHeaders, data: { email, password, roles: ['Premium'] } })).status()).toBe(200);
const headers = await login(page.request, email);
const initial = await (await page.request.get(`${api}/career/profile`)).json();
const profile = { ...emptyStructuredCv(), contact: { ...emptyStructuredCv().contact, fullName: 'Owner Canonical Marker', headline: 'Canonical headline marker', email: 'synthetic@example.test' },
summary: ['Canonical summary marker'], skills: ['TypeScript', 'Documented evidence'] };
expect((await page.request.put(`${api}/career/profile`, { headers, data: { expectedVersion: initial.revision, profile } })).status()).toBe(200);
expect((await page.request.put(`${api}/auth/preferences/language`, { headers, data: { language: language === 'nb' ? 'nb-NO' : 'en' } })).status()).toBe(204);
await page.addInitScript(language => { localStorage.setItem('uiLanguage', language); localStorage.setItem('jobtracker.themeMode', 'light'); }, language);
await page.setViewportSize({ width: language === 'nb' ? 390 : 1440, height: 1000 });
const source = await (await page.request.get(`${api}/career/profile`)).json();
return { headers, source };
}
async function createCv(page: Page, headers: Record<string, string>, showPhoto = false) {
const response = await page.request.post(`${api}/cv/variants`, { headers, data: { name: 'Template selection CV', settings: {
themeId: 'modern', sectionsExplicit: true, includeProfileContact: true, sections: [{ key: 'summary' }], headline: 'Authored CV headline marker', showPhoto,
} } });
expect(response.status()).toBe(200);
return response.json();
}
test.beforeEach(async ({ context }) => {
await context.route('**/*', route => {
const url = new URL(route.request().url());
return ['localhost', '127.0.0.1'].includes(url.hostname) || ['data:', 'blob:', 'about:'].includes(url.protocol) ? route.continue() : route.abort();
});
});
for (const language of ['en', 'nb'] as const) {
test(`[PRE-P10 B] ${language} actual nine-theme gallery and candidate preview remain read-only until Apply`, async ({ page, playwright }, info) => {
test.setTimeout(180_000);
const t = translations[language];
const admin = await playwright.request.newContext();
let fixture: Awaited<ReturnType<typeof owner>>;
try { fixture = await owner(page, admin, language); } finally { await admin.dispose(); }
const cv = await createCv(page, fixture.headers);
const cvUrl = `${api}/cv/variants/${cv.id}`;
const read = async () => (await page.request.get(cvUrl)).json();
const requests: { path: string; status: number }[] = [];
let writes = 0;
page.on('response', response => { if (/\/cv\/themes\/[^/]+\/preview$/.test(new URL(response.url()).pathname)) requests.push({ path: new URL(response.url()).pathname, status: response.status() }); });
page.on('request', request => { if (request.url() === cvUrl && request.method() === 'PUT') writes++; });
await page.goto(`/career/builder/${cv.id}`);
await page.getByRole('tab', { name: t.cvEditorTemplate, exact: true }).click();
await page.getByRole('button', { name: t.cvEditorAllTemplates, exact: true }).click();
for (const [id, key] of Object.entries(themeNames)) {
const card = page.getByRole('button', { name: t[key], exact: true });
await card.scrollIntoViewIfNeeded();
await expect(card).toHaveAttribute('aria-disabled', 'false');
const frame = card.frameLocator('iframe');
await expect(frame.locator('.name')).toHaveText('Alex Morgan');
await expect(frame.locator('body')).not.toContainText('Owner Canonical Marker');
await expect.poll(() => requests.some(request => request.path === `/api/cv/themes/${id}/preview` && request.status === 200)).toBe(true);
}
const candidate = page.getByRole('button', { name: t.cvTemplateTechnicalName, exact: true });
await candidate.click();
const dialog = page.getByRole('dialog');
await expect(dialog.frameLocator('iframe').locator('.name')).toHaveText('Alex Morgan');
await expect(dialog.getByRole('button', { name: t.cvEditorApplyTemplate, exact: true })).toBeEnabled();
expect(await read()).toEqual(cv);
expect(writes).toBe(0);
await dialog.getByRole('button', { name: t.cancel, exact: true }).click();
expect(await read()).toEqual(cv);
await candidate.click();
await expect(dialog.frameLocator('iframe').locator('.name')).toHaveText('Alex Morgan');
const applied = page.waitForResponse(response => response.url() === cvUrl && response.request().method() === 'PUT');
await dialog.getByRole('button', { name: t.cvEditorApplyTemplate, exact: true }).click();
expect((await applied).status()).toBe(200);
await expect.poll(async () => (await read()).settings.themeId).toBe('technical');
const saved = await read();
expect(saved.settings).toMatchObject({ designVersion: 2, themeId: 'technical', sectionsExplicit: true, includeProfileContact: true,
sections: [{ key: 'summary' }], headline: 'Authored CV headline marker' });
expect(saved.version).toBe(cv.version + 1);
if (language === 'nb') await page.getByRole('button', { name: t.cvEditorPreview, exact: true }).click();
const live = page.frameLocator(`iframe[title="${t.cvEditorPreviewPage.replace('{page}', '1')}"]`);
await expect(live.locator('.name')).toHaveText('Owner Canonical Marker');
await expect(live.locator('body')).toContainText('Authored CV headline marker');
await expect.poll(() => live.locator('body').evaluate(node => getComputedStyle(node, '::before').backgroundColor)).toBe('rgb(15, 76, 92)');
expect(await (await page.request.get(`${api}/career/profile`)).json()).toEqual(fixture.source);
expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1)).toBe(true);
await info.attach('actual-template-selection', { body: JSON.stringify({ language, samples: requests, savedSettings: saved.settings, writes }), contentType: 'application/json' });
await page.screenshot({ path: info.outputPath('template-selection.png'), fullPage: true });
});
test(`[PRE-P10 B] ${language} actual letter template Apply previews current authored text and preserves saved source`, async ({ page, playwright }, info) => {
test.setTimeout(180_000);
const t = translations[language];
const admin = await playwright.request.newContext();
let fixture: Awaited<ReturnType<typeof owner>>;
try { fixture = await owner(page, admin, language); } finally { await admin.dispose(); }
const companyResponse = await page.request.post(`${api}/companies`, { headers: fixture.headers, data: { name: 'Letter Selection Synthetic Company', source: 'User' } });
expect(companyResponse.status()).toBe(201);
const company = await companyResponse.json();
const jobResponse = await page.request.post(`${api}/jobapplications`, { headers: fixture.headers, data: { companyId: company.id, jobTitle: 'Letter template fixture', status: 'Saved', description: 'Synthetic controlled fixture' } });
expect(jobResponse.status()).toBe(201);
const job = await jobResponse.json();
const letterUrl = `${api}/jobapplications/${job.id}/cover-letter`;
const original = await (await page.request.get(letterUrl)).json();
const documentSettings = { schemaVersion: 1, senderName: 'Letter sender marker', senderTitle: 'Reviewed title', senderEmail: 'letter@example.test', senderPhone: '+47 12345678', senderLocation: 'Oslo',
recipient: 'Preserved recipient marker', subject: 'Preserved subject marker', date: '2026-10-27', themeId: 'modern', accentColor: '#2563eb', bodyFont: 'Arial, Helvetica, sans-serif',
baseFontSizePt: 10, lineHeight: 1.42, pageMarginMm: 16, pageSize: 'letter', language: language === 'nb' ? 'nb-NO' : 'en' };
expect((await page.request.put(letterUrl, { headers: fixture.headers, data: { text: 'Saved letter body marker', source: 'manual', expectedVersion: original.revision, documentSettings } })).status()).toBe(200);
const saved = await (await page.request.get(letterUrl)).json();
let writes = 0;
const cvSamples: string[] = [];
page.on('request', request => {
if (request.method() === 'PUT' && request.url() === letterUrl) writes++;
if (/\/cv\/themes\/[^/]+\/preview$/.test(new URL(request.url()).pathname)) cvSamples.push(request.url());
});
await page.goto(`/jobs/${job.id}?section=application&sub=letter#materials`);
const body = page.getByRole('textbox', { name: t.jobDetailsCoverLetter, exact: true });
await expect(body).toHaveValue('Saved letter body marker');
const draft = 'Current authored letter marker — Åse Østby';
await body.fill(draft);
await page.getByRole('button', { name: t.letterChooseTemplate, exact: true }).click();
const dialog = page.getByRole('dialog');
await expect(dialog.getByRole('button', { name: t.letterTemplateApply, exact: true })).toBeEnabled();
await dialog.getByRole('combobox', { name: new RegExp(`^${t.letterTemplate}(?: |$)`) }).click();
await expect(page.getByRole('option')).toHaveCount(9);
await page.getByRole('option', { name: 'Nordic', exact: true }).click();
await dialog.getByRole('button', { name: t.cancel, exact: true }).click();
await expect(body).toHaveValue(draft);
expect(await (await page.request.get(letterUrl)).json()).toEqual(saved);
await page.getByRole('button', { name: t.letterChooseTemplate, exact: true }).click();
await expect(dialog.getByRole('button', { name: t.letterTemplateApply, exact: true })).toBeEnabled();
await dialog.getByRole('combobox', { name: new RegExp(`^${t.letterTemplate}(?: |$)`) }).click();
await page.getByRole('option', { name: 'Nordic', exact: true }).click();
const previewResponse = page.waitForResponse(response => response.url() === `${letterUrl}/preview` && response.request().method() === 'POST'
&& response.request().postDataJSON()?.documentSettings?.themeId === 'nordic');
await dialog.getByRole('button', { name: t.letterTemplateApply, exact: true }).click();
await expect(body).toHaveValue(draft);
if (language === 'nb') await page.getByRole('tab', { name: t.assetsLetterPreview, exact: true }).click();
const preview = await previewResponse;
expect(preview.status()).toBe(200);
const submitted = preview.request().postDataJSON();
expect(submitted.text).toBe(draft);
expect(submitted.documentSettings).toMatchObject({ designVersion: 2, themeId: 'nordic', pageSize: 'letter', language: documentSettings.language,
senderName: documentSettings.senderName, recipient: documentSettings.recipient, date: documentSettings.date, subject: documentSettings.subject });
const frame = page.frameLocator(`iframe[title="${t.letterDocumentPreview}"]`);
await expect(frame.locator('.letter-theme-nordic')).toBeVisible();
await expect(frame.locator('body')).toContainText(draft);
await expect(frame.locator('body')).toContainText(documentSettings.recipient);
expect(await (await page.request.get(letterUrl)).json()).toEqual(saved);
expect(await (await page.request.get(`${api}/career/profile`)).json()).toEqual(fixture.source);
expect(writes).toBe(0);
expect(cvSamples).toEqual([]);
expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1)).toBe(true);
await info.attach('actual-letter-template-preview', { body: JSON.stringify({ language, submitted, unchangedSavedRevision: saved.revision, writes, cvSamples }), contentType: 'application/json' });
await page.screenshot({ path: info.outputPath('letter-template-selection.png'), fullPage: true });
});
test(`[PRE-P10 B] ${language} keyboard crop uses existing avatar storage and CV photo remains optional`, async ({ page, playwright }, info) => {
test.setTimeout(180_000);
const t = translations[language];
const admin = await playwright.request.newContext();
let fixture: Awaited<ReturnType<typeof owner>>;
try { fixture = await owner(page, admin, language); } finally { await admin.dispose(); }
const cv = await createCv(page, fixture.headers, true);
await page.goto(`/career/builder/${cv.id}`);
await page.getByRole('tab', { name: t.cvEditorCustomize, exact: true }).click();
await page.getByRole('link', { name: t.cvEditorManagePhoto, exact: true }).click();
await expect(page).toHaveURL(/\/career\?section=profile$/);
await expect(page.getByRole('button', { name: t.profileChangeImage, exact: true })).toBeEnabled();
const png = await page.evaluate(() => {
const canvas = document.createElement('canvas'); canvas.width = 512; canvas.height = 384;
const draw = canvas.getContext('2d')!;
draw.fillStyle = '#0f766e'; draw.fillRect(0, 0, 256, 384);
draw.fillStyle = '#f59e0b'; draw.fillRect(256, 0, 256, 384);
return canvas.toDataURL('image/png').split(',')[1];
});
await page.locator('input[type=file]').nth(1).setInputFiles({ name: 'synthetic-nonhuman-color-blocks.png', mimeType: 'image/png', buffer: Buffer.from(png, 'base64') });
const crop = page.getByRole('dialog');
await expect(crop.getByAltText(t.cropDialogPreviewAlt, { exact: true })).toBeVisible();
const zoom = crop.getByRole('slider', { name: t.cropDialogZoom, exact: true });
await zoom.focus(); await zoom.press('End');
await expect(zoom).toHaveAttribute('aria-valuenow', '3');
const horizontal = crop.getByRole('slider', { name: t.cropDialogHorizontal, exact: true });
const vertical = crop.getByRole('slider', { name: t.cropDialogVertical, exact: true });
for (const slider of [horizontal, vertical]) {
await expect(slider).toBeEnabled();
await slider.focus(); await slider.press('Home');
await expect.poll(async () => Number(await slider.getAttribute('aria-valuenow'))).toBeLessThan(0);
await slider.press('End');
await expect(slider).toHaveAttribute('aria-valuenow', '0');
}
const uploadResponse = page.waitForResponse(response => new URL(response.url()).pathname === '/api/auth/avatar' && response.request().method() === 'POST');
await crop.getByRole('button', { name: t.cropDialogSave, exact: true }).press('Enter');
expect((await uploadResponse).status()).toBe(200);
await expect(crop).not.toBeVisible();
const avatar = (await (await page.request.get(`${api}/auth/me`)).json()).avatarImageDataUrl;
expect(avatar).toBeTruthy();
await page.goto(`/career/builder/${cv.id}`);
if (language === 'nb') await page.getByRole('button', { name: t.cvEditorPreview, exact: true }).click();
const live = page.frameLocator(`iframe[title="${t.cvEditorPreviewPage.replace('{page}', '1')}"]`);
await expect(live.locator('.photo img')).toBeVisible();
if (language === 'nb') await page.getByRole('button', { name: t.cvEditorEdit, exact: true }).click();
await page.getByRole('tab', { name: t.cvEditorCustomize, exact: true }).click();
await page.getByRole('switch', { name: t.cvEditorShowPhoto, exact: true }).uncheck();
await expect.poll(async () => (await (await page.request.get(`${api}/cv/variants/${cv.id}`)).json()).settings.showPhoto).toBe(false);
if (language === 'nb') await page.getByRole('button', { name: t.cvEditorPreview, exact: true }).click();
await expect(live.locator('.photo img')).toHaveCount(0);
expect((await (await page.request.get(`${api}/auth/me`)).json()).avatarImageDataUrl).toBe(avatar);
expect(await (await page.request.get(`${api}/career/profile`)).json()).toEqual(fixture.source);
expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1)).toBe(true);
await page.screenshot({ path: info.outputPath('optional-photo-after-keyboard-crop.png'), fullPage: true });
await info.attach('keyboard-crop', { body: JSON.stringify({ language, syntheticNonhumanFixture: true, keyboardZoom: 3, keyboardAxes: ['horizontal', 'vertical'], optionalCvPhoto: false, avatarRetained: true }), contentType: 'application/json' });
});
}
+1
View File
@@ -54,6 +54,7 @@ export default defineConfig({
Database__Provider: "sqlite",
ConnectionStrings__JobTracker: "",
Workers__AiOperationsEnabled: "false",
Workers__CvDocumentImportsEnabled: "false",
// Browser provider responses are fixtures; the server must not start a real NAV search.
Workers__SavedJobSearchAlertsEnabled: "false",
HttpsRedirection__Enabled: "false",
@@ -9,5 +9,5 @@ export default defineConfig({
testMatch: 'cv-import-docling.spec.ts',
timeout: 180_000,
retries: 0,
webServer: [{ ...servers[0], env: { ...servers[0].env, Workers__AiOperationsEnabled: 'true', Ai__BaseUrl: process.env.CV_IMPORT_SIDECAR_URL, Ai__ProbeEnabled: 'false', Email__Enabled: 'false' } }, ...servers.slice(1)],
webServer: [{ ...servers[0], env: { ...servers[0].env, Workers__AiOperationsEnabled: 'false', Workers__CvDocumentImportsEnabled: 'true', Ai__BaseUrl: process.env.CV_IMPORT_SIDECAR_URL, Ai__ProbeEnabled: 'false', Email__Enabled: 'false' } }, ...servers.slice(1)],
});
+2 -1
View File
@@ -263,10 +263,11 @@ export type CoverLetter = {
export type CoverLetterDocumentSettings = {
schemaVersion: 1;
designVersion?: number | null;
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;
themeId: string; accentColor: string; bodyFont: string; headingFont?: string | null;
baseFontSizePt: number; lineHeight: number; pageMarginMm: number; pageSize: string;
sourceCvId?: number | null;
};
@@ -76,6 +76,26 @@ beforeEach(() => {
mockedApi.post.mockResolvedValue(source as any);
});
test.each(["en", "nb"] as const)("disabled CV worker upload gives actionable %s recovery without changing the profile", async language => {
window.localStorage.setItem("uiLanguage", language);
const strings = translations[language];
mockedApi.post.mockRejectedValueOnce({ isAxiosError: true, response: { status: 503, data: { code: "cv_worker_disabled", message: "Raw worker disabled message" } } });
renderPage();
await waitFor(() => expect(screen.getByLabelText(strings.profileCvContactFullName)).toBeEnabled());
const upload = document.querySelector<HTMLInputElement>('input[type="file"][accept*=".pdf"]')!;
fireEvent.change(upload, { target: { files: [new File(["Synthetic CV"], "synthetic.pdf", { type: "application/pdf" })] } });
const expected = language === "en"
? "CV importing is currently unavailable. Ask an administrator to enable CV processing, then try uploading again."
: "CV-import er ikke tilgjengelig nå. Be en administrator om å aktivere CV-behandling, og prøv deretter å laste opp på nytt.";
await failure(importView(), expected);
expect(importView().queryByText("Raw worker disabled message")).not.toBeInTheDocument();
expect(screen.getByLabelText(strings.profileCvContactFullName)).toHaveValue("Recorded owner");
expect(mockedApi.post).toHaveBeenCalledTimes(1);
expect(mockedApi.post).toHaveBeenCalledWith("/profile-cv/import", expect.any(FormData), expect.any(Object));
expect(mockedApi.put).not.toHaveBeenCalled();
expect(readCount("/career/profile")).toBe(1);
});
test("failed history reads have a scoped Retry that preserves unsaved source fields", async () => {
const read = mockedApi.get.getMockImplementation()!;
let failHistory = true;
@@ -19,11 +19,13 @@ type Props = { settings: CoverLetterDocumentSettings; disabled: boolean; onChang
export function CoverLetterDocumentFields({ settings, disabled, onChange }: Props) {
const { t } = useI18n();
const [copy, setCopy] = useState<"contact" | "design" | null>(null);
const [choosingTemplate, setChoosingTemplate] = useState(false);
return <Stack spacing={1.5} sx={{ mb: 2 }}>
<Typography component="h4" variant="subtitle2">{t("letterDetails")}</Typography>
<Stack direction="row" gap={1} flexWrap="wrap">
<Button disabled={disabled} onClick={() => setCopy("contact")}>{t("letterCopyContact")}</Button>
<Button disabled={disabled} onClick={() => setCopy("design")}>{t("letterMatchDesign")}</Button>
<Button disabled={disabled} onClick={() => setChoosingTemplate(true)}>{t("letterChooseTemplate")}</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" })}>
@@ -36,10 +38,52 @@ export function CoverLetterDocumentFields({ settings, disabled, onChange }: Prop
onChange={event => onChange({ ...settings, [key]: event.target.value })} />)}
</Box>
{copy && <CopyLetterSnapshot kind={copy} settings={settings} onClose={() => setCopy(null)} onApply={next => { if (!disabled) onChange(next); setCopy(null); }} />}
{choosingTemplate && <LetterTemplatePicker settings={settings} disabled={disabled} onClose={() => setChoosingTemplate(false)} onApply={next => { if (!disabled) onChange(next); setChoosingTemplate(false); }} />}
</Stack>;
}
type ThemeDefaults = CvTheme & { bodyFont: string; baseFontSizePt: number; lineHeight: number };
function LetterTemplatePicker({ settings, disabled, onClose, onApply }: Omit<Props, "onChange"> & { onClose: () => void; onApply: (settings: CoverLetterDocumentSettings) => void }) {
const { t } = useI18n();
const id = useId();
const [themes, setThemes] = useState<CvTheme[]>([]);
const [selected, setSelected] = useState(settings.themeId);
const [loading, setLoading] = useState(true);
const [failed, setFailed] = useState(false);
const [attempt, setAttempt] = useState(0);
useEffect(() => {
let current = true;
setLoading(true); setFailed(false);
cvBuilderApi.themes().then(rows => {
if (current) setThemes(rows);
}).catch(() => { if (current) setFailed(true); }).finally(() => { if (current) setLoading(false); });
return () => { current = false; };
}, [attempt]);
const theme = themes.find(row => row.id === selected);
const ready = theme?.available && !!theme.bodyFont && !!theme.baseFontSizePt && !!theme.lineHeight && !!theme.pageMarginMm;
const apply = () => {
if (!ready || !theme || disabled) return;
onApply({ ...settings, designVersion: 2, themeId: theme.id, accentColor: theme.accent,
bodyFont: theme.bodyFont!, headingFont: theme.headingFont ?? theme.bodyFont!, baseFontSizePt: theme.baseFontSizePt!,
lineHeight: theme.lineHeight!, pageMarginMm: theme.pageMarginMm! });
};
return <Dialog open onClose={onClose} fullWidth maxWidth="sm" aria-labelledby={`${id}-title`} aria-describedby={`${id}-help`}>
<DialogTitle id={`${id}-title`}>{t("letterChooseTemplate")}</DialogTitle>
<DialogContent dividers><Stack spacing={2}>
<Typography id={`${id}-help`} variant="body2">{t("letterTemplateHelp")}</Typography>
{loading && <Typography role="status">{t("loading")}</Typography>}
{failed && <Alert severity="error" action={<Button disabled={disabled} onClick={() => setAttempt(value => value + 1)}>{t("retry")}</Button>}>{t("letterTemplateFailed")}</Alert>}
<TextField select fullWidth size="small" label={t("letterTemplate")} disabled={loading || failed || disabled}
value={themes.some(row => row.id === selected) ? selected : ""} onChange={event => setSelected(event.target.value)}>
{themes.map(row => <MenuItem key={row.id} value={row.id} disabled={!row.available}>{row.name ?? row.id}{row.available ? "" : ` — ${t("cvEditorPro")}`}</MenuItem>)}
</TextField>
{!loading && !failed && !ready && <Typography variant="body2">{t("letterTemplateUnavailable")}</Typography>}
</Stack></DialogContent>
<DialogActions><Button autoFocus onClick={onClose}>{t("cancel")}</Button><Button variant="contained" disabled={disabled || loading || failed || !ready} onClick={apply}>{t("letterTemplateApply")}</Button></DialogActions>
</Dialog>;
}
function CopyLetterSnapshot({ kind, settings, onClose, onApply }: { kind: "contact" | "design"; settings: CoverLetterDocumentSettings; onClose: () => void; onApply: (settings: CoverLetterDocumentSettings) => void }) {
const { t } = useI18n();
const id = useId();
@@ -75,7 +119,8 @@ function CopyLetterSnapshot({ kind, settings, onClose, onApply }: { kind: "conta
const source = variant.settings;
const density = source.density === "compact" ? 0.82 : source.density === "roomy" ? 1.18 : 1;
const margin = Math.min(28, Math.max(8, Math.round((source.pageMarginMm ?? theme.pageMarginMm) * density * 100) / 100));
return { ...settings, themeId: theme.id, accentColor: source.accentColor ?? theme.accent, bodyFont: source.bodyFont ?? theme.bodyFont,
return { ...settings, designVersion: 2, themeId: theme.id, accentColor: source.accentColor ?? theme.accent, bodyFont: source.bodyFont ?? theme.bodyFont,
headingFont: source.headingFont ?? theme.headingFont ?? theme.bodyFont,
baseFontSizePt: source.baseFontSizePt ?? theme.baseFontSizePt, lineHeight: source.lineHeight ?? theme.lineHeight,
pageMarginMm: margin, pageSize: source.pageSize ?? "a4", sourceCvId: selected };
};
@@ -210,7 +210,17 @@ export default function CropImageDialog({
<Box sx={{ px: 1 }}>
<Typography variant="body2" sx={{ mb: 1, fontWeight: 700 }}>{t("cropDialogZoom")}</Typography>
<Slider min={1} max={3} step={0.01} value={zoom} onChange={(_, value) => setZoom(value as number)} />
<Slider aria-label={t("cropDialogZoom")} disabled={saving} min={1} max={3} step={0.01} value={zoom} onChange={(_, value) => setZoom(value as number)} />
<Typography variant="body2" color="text.secondary">{t("cropDialogKeyboardHelp")}</Typography>
{(["x", "y"] as const).map(axis => {
const min = rendered ? Math.min(0, CROPPER_SIZE - (axis === "x" ? rendered.width : rendered.height)) : 0;
const label = t(axis === "x" ? "cropDialogHorizontal" : "cropDialogVertical");
return <Box key={axis}>
<Typography variant="body2">{label}</Typography>
<Slider aria-label={label} disabled={saving || min === 0} min={min} max={0} step={1} value={position[axis]}
onChange={(_, value) => setPosition(previous => ({ ...previous, [axis]: clamp(value as number, min, 0) }))} />
</Box>;
})}
</Box>
</DialogContent>
<DialogActions>
@@ -33,7 +33,7 @@ export function getCvTemplateLabels(theme: CvTheme, language: string, t: (key: T
return { name: labels ? t(labels[0]) : theme.name, category: labels ? t(labels[1]) : theme.category, description: labels ? t(labels[2]) : theme.description };
}
export default function CvTemplateGallery({ themes, selectedId, onSelect, previewHeight = 170, showDescriptions = true, disabled = false }: Props) {
export default function CvTemplateGallery({ themes, selectedId, onSelect, previewHeight = 230, showDescriptions = true, disabled = false }: Props) {
const { t, language } = useI18n();
const id = useId();
const [atsOnly, setAtsOnly] = useState(true);
@@ -1,34 +1,64 @@
import React from "react";
import { Box } from "@mui/material";
import React, { useEffect, useRef, useState } from "react";
import { Box, Skeleton, Typography } from "@mui/material";
import { cvBuilderApi, CvTheme, getCvPageMetrics } from "../cvBuilder";
import { useI18n } from "../i18n/I18nProvider";
import { CvTheme } from "../cvBuilder";
// Only the server's synthetic template samples are cached, never a user's CV.
const samples = new Map<string, Promise<string>>();
function sample(themeId: string) {
let pending = samples.get(themeId);
if (!pending) {
pending = cvBuilderApi.themePreview(themeId).then(result => {
if (typeof result.html !== "string") throw new Error("Template preview unavailable");
return result.html;
}).catch(error => { samples.delete(themeId); throw error; });
samples.set(themeId, pending);
}
return pending;
}
export default function CvTemplateThumbnail({ theme, height = 150 }: { theme?: CvTheme; height?: number }) {
const accent = theme?.swatches?.[0] || "#3157d5";
const sidebar = theme?.swatches?.[1] || "#eef1f4";
const paper = theme?.swatches?.[2] || "#ffffff";
const layout = theme?.layout || "header-band";
const hasSidebar = layout === "sidebar-left" || layout === "sidebar-right";
const sidebarRight = layout === "sidebar-right";
const lines = [92, 72, 84, 61, 88, 76, 95, 67];
const { t } = useI18n();
const host = useRef<HTMLDivElement>(null);
const [visible, setVisible] = useState(typeof IntersectionObserver === "undefined");
const [html, setHtml] = useState<{ id: string; value: string } | null>(null);
const [failed, setFailed] = useState(false);
const [width, setWidth] = useState(210 * height / 297);
const metrics = getCvPageMetrics("a4");
const scale = Math.min(width / metrics.widthPx, height / metrics.heightPx);
const body = <Box sx={{ p: "7%", minWidth: 0 }}>
<Box sx={{ width: "48%", height: 5, borderRadius: 2, bgcolor: accent, mb: 1 }} />
{lines.map((width, index) => <Box key={index} sx={{ width: `${width}%`, height: index === 4 ? 4 : 2.5, borderRadius: 2, bgcolor: index === 4 ? accent : "rgba(51,65,85,.24)", mt: index === 4 ? 1.25 : 0, mb: 0.7 }} />)}
</Box>;
const side = <Box sx={{ bgcolor: sidebar, p: "13% 12%", minWidth: 0 }}>
<Box sx={{ width: 24, height: 24, borderRadius: theme?.photoShape === "circle" ? "50%" : 1, bgcolor: "rgba(255,255,255,.78)", border: "1px solid rgba(15,23,42,.1)", mb: 1.2 }} />
{[66, 86, 54, 74, 62, 79].map((width) => <Box key={width} sx={{ width: `${width}%`, height: 2.5, borderRadius: 2, bgcolor: "rgba(30,41,59,.3)", mb: 0.8 }} />)}
</Box>;
useEffect(() => {
const element = host.current;
if (!element || typeof ResizeObserver === "undefined") return;
const resize = new ResizeObserver(() => setWidth(element.clientWidth));
resize.observe(element);
return () => resize.disconnect();
}, []);
return (
<Box aria-hidden sx={{ height, bgcolor: paper, border: "1px solid", borderColor: "divider", borderRadius: 1.5, overflow: "hidden", boxShadow: "0 8px 20px rgba(15,23,42,.08)", display: "flex", flexDirection: "column" }}>
{!hasSidebar && <Box sx={{ height: layout === "header-band" ? "25%" : 8, bgcolor: layout === "header-band" ? accent : paper, borderBottom: layout === "header-band" ? 0 : `2px solid ${accent}`, p: layout === "header-band" ? "6%" : 0 }}>
{layout === "header-band" && <><Box sx={{ width: "42%", height: 5, bgcolor: "rgba(255,255,255,.9)", mb: 0.75 }} /><Box sx={{ width: "30%", height: 2.5, bgcolor: "rgba(255,255,255,.65)" }} /></>}
</Box>}
<Box sx={{ flex: 1, minHeight: 0, display: "grid", gridTemplateColumns: hasSidebar ? (sidebarRight ? "1fr 32%" : "32% 1fr") : "1fr" }}>
{hasSidebar && !sidebarRight ? side : null}{body}{hasSidebar && sidebarRight ? side : null}
</Box>
</Box>
);
useEffect(() => {
if (visible || !host.current || typeof IntersectionObserver === "undefined") return;
const observer = new IntersectionObserver(entries => {
if (entries.some(entry => entry.isIntersecting)) { setVisible(true); observer.disconnect(); }
}, { rootMargin: "200px" });
observer.observe(host.current);
return () => observer.disconnect();
}, [visible]);
useEffect(() => {
if (!visible || !theme?.id) return;
let alive = true;
setFailed(false);
sample(theme.id).then(value => { if (alive) setHtml({ id: theme.id, value }); })
.catch(() => { if (alive) setFailed(true); });
return () => { alive = false; };
}, [theme?.id, visible]);
const current = html?.id === theme?.id ? html?.value : null;
return <Box ref={host} aria-hidden sx={{ height, bgcolor: "grey.100", border: "1px solid", borderColor: "divider", borderRadius: 1, overflow: "hidden", display: "flex", justifyContent: "center", pointerEvents: "none" }}>
{current ? <Box sx={{ width: metrics.widthPx * scale, height: metrics.heightPx * scale, overflow: "hidden", bgcolor: "common.white" }}>
<iframe title={t("cvEditorTemplateSampleTitle")} srcDoc={current} sandbox="" tabIndex={-1}
style={{ border: 0, display: "block", width: metrics.widthPx, height: metrics.heightPx, transform: `scale(${scale})`, transformOrigin: "top left", pointerEvents: "none" }} />
</Box> : failed ? <Typography variant="caption" sx={{ p: 1 }}>{t("cvEditorPreviewUnavailable")}</Typography>
: <Skeleton variant="rectangular" width="100%" height="100%" />}
</Box>;
}
@@ -1,4 +1,5 @@
import React, { useEffect, useId, useState } from "react";
import { Link as RouterLink } from "react-router-dom";
import CheckIcon from "@mui/icons-material/Check";
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
import {
@@ -34,6 +35,7 @@ export default function CvCustomizeTab({ mode, settings, update, themes, section
const [candidate, setCandidate] = useState<CvTheme | null>(null);
const activeTheme = themes.find(theme => theme.id === settings.themeId);
const supports = (setting: string) => !activeTheme?.supportedSettings?.length || activeTheme.supportedSettings.includes(setting);
const supportsPhoto = !!activeTheme && activeTheme.photoShape !== "none" && supports("photo");
const sidebarSections = settings.sidebarSections ?? ["contact", "skills", "languages"];
const layout = settings.layout ?? activeTheme?.layout;
const selectTemplate = async (theme: CvTheme) => {
@@ -98,6 +100,13 @@ export default function CvCustomizeTab({ mode, settings, update, themes, section
</>}
</Stack>
{supportsPhoto && <Stack component="section" aria-labelledby={id + "-photo"} spacing={1}>
<Typography id={id + "-photo"} component="h3" variant="subtitle2">{t("cvEditorProfilePhoto")}</Typography>
<FormControlLabel control={<Switch checked={settings.showPhoto} onChange={event => update({ showPhoto: event.target.checked })} />} label={t("cvEditorShowPhoto")} />
<Typography variant="body2" color="text.secondary">{t(settings.includeProfileContact === false ? "cvEditorPhotoContactHelp" : "cvEditorPhotoHelp")}</Typography>
<Button component={RouterLink} to="/career?section=profile" sx={{ alignSelf: "flex-start" }}>{t("cvEditorManagePhoto")}</Button>
</Stack>}
<Accordion disableGutters elevation={0} sx={{ border: "1px solid", borderColor: "divider", "&:before": { display: "none" } }}>
<AccordionSummary id={id + "-advanced"} aria-controls={id + "-advanced-content"} expandIcon={<ExpandMoreIcon />}>
<Typography component="h3" variant="body2" sx={{ fontWeight: 600 }}>{t("cvEditorAdvancedSettings")}</Typography>
@@ -130,7 +139,6 @@ export default function CvCustomizeTab({ mode, settings, update, themes, section
</>}
</Stack>}
<Stack>
{supports("photo") && <FormControlLabel control={<Switch checked={settings.showPhoto} onChange={event => update({ showPhoto: event.target.checked })} />} label={t("cvEditorShowPhoto")} />}
{supports("icons") && <FormControlLabel control={<Switch checked={settings.showIcons} onChange={event => update({ showIcons: event.target.checked })} />} label={t("cvEditorContactIcons")} />}
</Stack>
</Stack></AccordionDetails>
@@ -1,7 +1,7 @@
import React from "react";
import "@testing-library/jest-dom";
import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import { CoverLetterDocumentFields } from "./components/CoverLetterDocument";
import { CoverLetterDocumentFields, CoverLetterDocumentPreview } from "./components/CoverLetterDocument";
import { ApplicationCoverLetterSection } from "./components/ApplicationAssets";
import { CoverLetterDocumentSettings } from "./applicationWorkspace";
import { api } from "./api";
@@ -116,11 +116,93 @@ test("design copy resolves null overrides through actual theme defaults and effe
await screen.findByRole("region", { name: "Design snapshot" });
expect(onChange).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole("button", { name: "Use this copy" }));
expect(onChange).toHaveBeenCalledWith({ ...settings, accentColor: "#112233", bodyFont: "Georgia, 'Times New Roman', serif", baseFontSizePt: 11, lineHeight: 1.5, pageMarginMm: 13.12, sourceCvId: 3 });
expect(onChange).toHaveBeenCalledWith({ ...settings, designVersion: 2, accentColor: "#112233", bodyFont: "Georgia, 'Times New Roman', serif", headingFont: "Georgia, 'Times New Roman', serif", baseFontSizePt: 11, lineHeight: 1.5, pageMarginMm: 13.12, sourceCvId: 3 });
expect(mockedApi.get).not.toHaveBeenCalledWith("/career/profile");
expect(mockedApi.put).not.toHaveBeenCalled(); expect(mockedApi.post).not.toHaveBeenCalled();
});
test("letter template chooser uses nine actual matching themes and preserves destination letter details", async () => {
const originalGet = mockedApi.get.getMockImplementation()!;
const themeRows = ["Modern", "Minimal", "Executive", "Technical", "ATS Classic", "Nordic", "Elegant", "Creative", "Code"].map(name => ({
id: name.toLowerCase().replace(/ /g, "-"), name, description: `${name} letter design`, available: name !== "Executive", requiresPro: name === "Executive",
accent: "#3157d5", bodyFont: "Arial, Helvetica, sans-serif", headingFont: "Georgia, 'Times New Roman', serif", baseFontSizePt: 10, lineHeight: 1.42, pageMarginMm: 18,
}));
mockedApi.get.mockImplementation(((url: string) => url === "/cv/themes" ? Promise.resolve({ data: themeRows }) : originalGet(url)) as any);
const onChange = jest.fn();
render(wrap(<CoverLetterDocumentFields settings={{ ...settings, pageSize: "letter" }} disabled={false} onChange={onChange} />));
fireEvent.click(screen.getByRole("button", { name: "Choose letter template" }));
const select = await screen.findByRole("combobox", { name: "Letter template" });
await waitFor(() => expect(select).not.toHaveAttribute("aria-disabled", "true"));
fireEvent.mouseDown(select);
expect(await screen.findAllByRole("option")).toHaveLength(9);
expect(screen.getByRole("option", { name: /Executive/ })).toHaveAttribute("aria-disabled", "true");
fireEvent.click(screen.getByRole("option", { name: "Nordic" }));
expect(onChange).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole("button", { name: "Use this template" }));
expect(onChange).toHaveBeenCalledWith({ ...settings, pageSize: "letter", designVersion: 2, themeId: "nordic", accentColor: "#3157d5", bodyFont: "Arial, Helvetica, sans-serif", headingFont: "Georgia, 'Times New Roman', serif", baseFontSizePt: 10, lineHeight: 1.42, pageMarginMm: 18 });
expect(mockedApi.put).not.toHaveBeenCalled();
expect(mockedApi.post).not.toHaveBeenCalled();
});
test("letter template load failures retry and cancel never changes the local letter", async () => {
const originalGet = mockedApi.get.getMockImplementation()!;
let attempts = 0;
mockedApi.get.mockImplementation(((url: string) => url === "/cv/themes" && ++attempts === 1 ? Promise.reject(new Error("Synthetic theme failure")) : originalGet(url)) as any);
const onChange = jest.fn();
render(wrap(<CoverLetterDocumentFields settings={settings} disabled={false} onChange={onChange} />));
fireEvent.click(screen.getByRole("button", { name: "Choose letter template" }));
expect(await screen.findByRole("alert")).toHaveTextContent("Could not load letter templates.");
expect(screen.getByRole("button", { name: "Use this template" })).toBeDisabled();
fireEvent.click(screen.getByRole("button", { name: "Retry" }));
await waitFor(() => expect(screen.getByRole("button", { name: "Use this template" })).toBeEnabled());
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
expect(onChange).not.toHaveBeenCalled();
});
test("applying a letter template updates the existing letter preview using the current body without saving", async () => {
mockedApi.post.mockResolvedValue({ data: { html: '<main class="page">Actual letter preview</main>' } } as any);
function Draft() {
const [draft, setDraft] = React.useState(settings);
return <><CoverLetterDocumentFields settings={draft} disabled={false} onChange={setDraft} />
<CoverLetterDocumentPreview jobId={7} text="Current authored letter body" settings={draft} active /></>;
}
render(wrap(<Draft />));
await waitFor(() => expect(mockedApi.post).toHaveBeenCalledTimes(1));
fireEvent.click(screen.getByRole("button", { name: "Choose letter template" }));
await waitFor(() => expect(screen.getByRole("button", { name: "Use this template" })).toBeEnabled());
fireEvent.click(screen.getByRole("button", { name: "Use this template" }));
await waitFor(() => expect(mockedApi.post).toHaveBeenLastCalledWith('/jobapplications/7/cover-letter/preview', {
text: 'Current authored letter body', documentSettings: expect.objectContaining({ designVersion: 2, headingFont: "Georgia, 'Times New Roman', serif", recipient: settings.recipient, language: settings.language }),
}));
expect(mockedApi.put).not.toHaveBeenCalled();
expect(mockedApi.get).not.toHaveBeenCalledWith('/cv/themes/modern/preview');
});
test("disabled letter drafting cannot open the template chooser", () => {
const onChange = jest.fn();
render(wrap(<CoverLetterDocumentFields settings={settings} disabled onChange={onChange} />));
const chooser = screen.getByRole('button', { name: 'Choose letter template' });
expect(chooser).toBeDisabled();
fireEvent.click(chooser);
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
expect(onChange).not.toHaveBeenCalled();
expect(mockedApi.get).not.toHaveBeenCalled();
});
test("matching a customized CV copies heading density and paper but preserves letter language", async () => {
const originalGet = mockedApi.get.getMockImplementation()!;
mockedApi.get.mockImplementation(((url: string) => url === "/cv/variants/3" ? Promise.resolve({ data: { ...variant, settings: {
...variant.settings, headingFont: "Georgia, 'Times New Roman', serif", bodyFont: "Arial, Helvetica, sans-serif", accentColor: "#abcdef", pageMarginMm: 20, density: "roomy", pageSize: "letter",
} } }) : originalGet(url)) as any);
const onChange = jest.fn();
render(wrap(<CoverLetterDocumentFields settings={settings} disabled={false} onChange={onChange} />));
fireEvent.click(screen.getByRole("button", { name: "Match CV design" }));
await selectSource();
await screen.findByRole("region", { name: "Design snapshot" });
fireEvent.click(screen.getByRole("button", { name: "Use this copy" }));
expect(onChange).toHaveBeenCalledWith({ ...settings, pageSize: "letter", designVersion: 2, headingFont: "Georgia, 'Times New Roman', serif", bodyFont: "Arial, Helvetica, sans-serif", accentColor: "#abcdef", baseFontSizePt: 11, lineHeight: 1.5, pageMarginMm: 23.6, sourceCvId: 3 });
});
test("closing a copy dialog ignores a late selected CV read", async () => {
const selected = deferred<any>();
const originalGet = mockedApi.get.getMockImplementation()!;
+5 -4
View File
@@ -235,7 +235,7 @@ test('held CV creation locks the draft and gallery, then keeps a failed draft fo
expect(mockNavigate).not.toHaveBeenCalled();
fireEvent.click(within(dialog).getByRole('button', { name: 'Create CV' }));
await waitFor(() => expect(mockedApi.post).toHaveBeenLastCalledWith('/cv/variants', { name: 'Application-specific CV', settings: { ...emptyCvVariantSettings('clean'), sectionsExplicit: true, includeProfileContact: false } }));
await waitFor(() => expect(mockedApi.post).toHaveBeenLastCalledWith('/cv/variants', { name: 'Application-specific CV', settings: { ...emptyCvVariantSettings('clean'), designVersion: 2, sectionsExplicit: true, includeProfileContact: false } }));
await waitFor(() => expect(mockNavigate).toHaveBeenCalledWith('/career/builder/42'));
});
@@ -252,7 +252,7 @@ test('a locked CV template remains identifiable and cannot change the creation r
fireEvent.keyDown(premium, { key: 'Enter' });
expect(within(dialog).getByRole('button', { name: 'Modern' })).toHaveAttribute('aria-pressed', 'true');
fireEvent.click(within(dialog).getByRole('button', { name: 'Create CV' }));
await waitFor(() => expect(mockedApi.post).toHaveBeenCalledWith('/cv/variants', expect.objectContaining({ settings: { ...emptyCvVariantSettings('modern'), sectionsExplicit: true, includeProfileContact: false } })));
await waitFor(() => expect(mockedApi.post).toHaveBeenCalledWith('/cv/variants', expect.objectContaining({ settings: { ...emptyCvVariantSettings('modern'), designVersion: 2, sectionsExplicit: true, includeProfileContact: false } })));
});
test('a held duplicate blocks another action for the same CV and shows the persisted refreshed copy', async () => {
@@ -344,7 +344,8 @@ test('a failed creation keeps its draft and translates its failure after closing
expect(await within(dialog).findByRole('alert')).toHaveTextContent('Could not create a CV.');
fireEvent.click(within(dialog).getByRole('button', { name: 'Cancel' }));
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument());
const reads = mockedApi.get.mock.calls.length;
const profileReads = () => mockedApi.get.mock.calls.filter(([url]) => url === '/cv/variants' || url === '/cv/themes').length;
const reads = profileReads();
act(() => { window.dispatchEvent(new StorageEvent('storage', { key: 'uiLanguage', newValue: 'nb' })); });
fireEvent.click(screen.getByRole('button', { name: 'Ny CV' }));
const reopened = await screen.findByRole('dialog', { name: 'Opprett en CV' });
@@ -352,7 +353,7 @@ test('a failed creation keeps its draft and translates its failure after closing
expect(within(reopened).getByRole('button', { name: 'Clean' })).toHaveAttribute('aria-pressed', 'true');
expect(within(reopened).getByRole('alert')).toHaveTextContent('Kunne ikke opprette en CV.');
expect(within(reopened).queryByText('Could not create a CV.')).not.toBeInTheDocument();
expect(mockedApi.get).toHaveBeenCalledTimes(reads);
expect(profileReads()).toBe(reads);
expect(mockedApi.post).toHaveBeenCalledTimes(1);
expect(mockNavigate).not.toHaveBeenCalled();
});
@@ -1,4 +1,5 @@
import React, { useState } from "react";
import { MemoryRouter } from "react-router-dom";
import "@testing-library/jest-dom";
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import CvCustomizeTab from "./components/cv/CvCustomizeTab";
@@ -34,7 +35,7 @@ function mount(initial = emptyCvVariantSettings(), mode: "customize" | "template
current = settings;
return <CvCustomizeTab mode={mode} settings={settings} themes={availableThemes} sectionLabel={(_, fallback) => fallback} update={patch => { update(patch); setSettings(previous => ({ ...previous, ...patch })); }} />;
}
render(<I18nProvider><ConfirmProvider><Harness /></ConfirmProvider></I18nProvider>);
render(<MemoryRouter><I18nProvider><ConfirmProvider><Harness /></ConfirmProvider></I18nProvider></MemoryRouter>);
return { update, settings: () => current };
}
@@ -0,0 +1,66 @@
import React from "react";
import "@testing-library/jest-dom";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
import CropImageDialog from "./components/CropImageDialog";
import CvCustomizeTab from "./components/cv/CvCustomizeTab";
import { emptyCvVariantSettings, type CvTheme } from "./cvBuilder";
import { ConfirmProvider } from "./confirm";
import { I18nProvider } from "./i18n/I18nProvider";
beforeEach(() => {
localStorage.clear();
URL.createObjectURL = jest.fn(() => "blob:portrait");
URL.revokeObjectURL = jest.fn();
});
afterEach(() => jest.restoreAllMocks());
test("keyboard crop positions are bounded and feed the same exported square as dragging", async () => {
const drawImage = jest.fn();
jest.spyOn(HTMLCanvasElement.prototype, "getContext").mockReturnValue({ drawImage } as any);
jest.spyOn(HTMLCanvasElement.prototype, "toBlob").mockImplementation(callback => callback(new Blob(["portrait"], { type: "image/png" })));
const save = jest.fn();
render(<I18nProvider><CropImageDialog open file={new File(["portrait"], "portrait.png", { type: "image/png" })} onClose={jest.fn()} onSave={save} /></I18nProvider>);
const image = screen.getByRole("img", { name: "Crop preview" });
Object.defineProperties(image, { naturalWidth: { value: 400 }, naturalHeight: { value: 200 } });
fireEvent.load(image);
const horizontal = screen.getByRole("slider", { name: "Horizontal position" });
const vertical = screen.getByRole("slider", { name: "Vertical position" });
expect(vertical).toBeDisabled();
fireEvent.keyDown(horizontal, { key: "Home" });
expect(horizontal).toHaveAttribute("aria-valuenow", "-280");
fireEvent.keyDown(horizontal, { key: "ArrowLeft" });
expect(horizontal).toHaveAttribute("aria-valuenow", "-280");
fireEvent.change(screen.getByRole("slider", { name: "Zoom" }), { target: { value: "2" } });
fireEvent.keyDown(vertical, { key: "Home" });
expect(vertical).toHaveAttribute("aria-valuenow", "-280");
fireEvent.click(screen.getByRole("button", { name: "Save image" }));
await waitFor(() => expect(save).toHaveBeenCalledTimes(1));
expect(drawImage).toHaveBeenLastCalledWith(image, 100, 100, 100, 100, 0, 0, 512, 512);
});
const theme: CvTheme = { id: "modern", name: "Modern", category: "Professional", description: "", layout: "single", accent: "#3157d5", photoShape: "circle", supportsIcons: true, atsFriendly: true, requiresPro: false, available: true, swatches: [] };
function mountPhoto(photoShape: CvTheme["photoShape"], includeProfileContact = true) {
const update = jest.fn();
render(<MemoryRouter><I18nProvider><ConfirmProvider><CvCustomizeTab mode="customize" settings={{ ...emptyCvVariantSettings(), includeProfileContact }} themes={[{ ...theme, photoShape }]} update={update} sectionLabel={(_, fallback) => fallback} /></ConfirmProvider></I18nProvider></MemoryRouter>);
return update;
}
test("capable CV designs link directly to existing portrait management without changing the CV", () => {
const update = mountPhoto("circle");
expect(screen.getByRole("link", { name: "Manage profile photo" })).toHaveAttribute("href", "/career?section=profile");
expect(screen.getByRole("switch", { name: "Show profile photo" })).toBeInTheDocument();
expect(update).not.toHaveBeenCalled();
});
test("photo-free designs do not offer portrait controls", () => {
mountPhoto("none");
fireEvent.click(screen.getByRole("button", { name: "Advanced document settings" }));
expect(screen.queryByRole("switch", { name: "Show profile photo" })).not.toBeInTheDocument();
expect(screen.queryByRole("link", { name: "Manage profile photo" })).not.toBeInTheDocument();
});
test("explicitly excluded profile contact explains why the portrait is omitted", () => {
mountPhoto("circle", false);
expect(screen.getByText("Include Career Profile contact details in Content to show its photo. A missing photo leaves the document unchanged.")).toBeInTheDocument();
});
+17 -74
View File
@@ -1,76 +1,19 @@
import React from "react";
import "@testing-library/jest-dom";
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
import CvTemplatePreviewDialog from "./components/cv/CvTemplatePreviewDialog";
import { cvBuilderApi, CvRender, CvTheme } from "./cvBuilder";
import { I18nProvider } from "./i18n/I18nProvider";
import React from 'react';
import '@testing-library/jest-dom';
import { render, screen } from '@testing-library/react';
import CvTemplateThumbnail from './components/CvTemplateThumbnail';
import { cvBuilderApi, CvTheme } from './cvBuilder';
import { I18nProvider } from './i18n/I18nProvider';
const modern: CvTheme = { id: "modern", name: "Modern", category: "Professional", description: "A clear document", layout: "single", accent: "#3157d5", photoShape: "none", supportsIcons: false, atsFriendly: true, requiresPro: false, available: true, swatches: [] };
const code = { ...modern, id: "code", name: "Code" };
const sample = (id: string): CvRender => ({ themeId: id, html: `<html><body><div class="page">${id} sample</div></body></html>`, suggestedFileName: "sample.pdf" });
function deferred<T>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>(yes => { resolve = yes; });
return { promise, resolve };
}
beforeEach(() => {
localStorage.clear();
global.ResizeObserver = class { observe() {} unobserve() {} disconnect() {} };
jest.spyOn(cvBuilderApi, "themePreview").mockResolvedValue(sample("modern"));
});
afterEach(() => jest.restoreAllMocks());
test("candidate loads a labelled sample without applying; Cancel preserves the draft", async () => {
const onApply = jest.fn(); const onClose = jest.fn();
render(<I18nProvider><CvTemplatePreviewDialog theme={modern} onClose={onClose} onApply={onApply} /></I18nProvider>);
expect(await screen.findByTitle("Template sample preview")).toHaveAttribute("srcdoc", expect.stringContaining("modern sample"));
expect(screen.getByText(/Sample content/)).toBeVisible();
expect(onApply).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
expect(onClose).toHaveBeenCalledTimes(1);
expect(onApply).not.toHaveBeenCalled();
});
test("sample scales down to a narrow preview host", async () => {
const width = jest.spyOn(HTMLElement.prototype, "clientWidth", "get").mockReturnValue(280);
render(<I18nProvider><CvTemplatePreviewDialog theme={modern} onClose={jest.fn()} onApply={jest.fn()} /></I18nProvider>);
const frame = await screen.findByTitle("Template sample preview");
expect(Number((frame as HTMLIFrameElement).style.transform.match(/scale\(([^)]+)\)/)?.[1])).toBeLessThan(0.4);
width.mockRestore();
});
test("a late candidate response cannot replace the selected template", async () => {
const held = deferred<CvRender>();
jest.mocked(cvBuilderApi.themePreview).mockImplementation(id => id === "modern" ? held.promise : Promise.resolve(sample("code")));
const view = render(<I18nProvider><CvTemplatePreviewDialog theme={modern} onClose={jest.fn()} onApply={jest.fn()} /></I18nProvider>);
view.rerender(<I18nProvider><CvTemplatePreviewDialog theme={code} onClose={jest.fn()} onApply={jest.fn()} /></I18nProvider>);
await waitFor(() => expect(screen.getByTitle("Template sample preview")).toHaveAttribute("srcdoc", expect.stringContaining("code sample")));
await act(async () => held.resolve(sample("modern")));
expect(screen.getByTitle("Template sample preview")).toHaveAttribute("srcdoc", expect.stringContaining("code sample"));
});
test("failed preview has an explicit retry and cannot apply until it succeeds", async () => {
jest.mocked(cvBuilderApi.themePreview).mockRejectedValueOnce(new Error("synthetic failure"));
const onApply = jest.fn();
render(<I18nProvider><CvTemplatePreviewDialog theme={modern} onClose={jest.fn()} onApply={onApply} /></I18nProvider>);
expect(await screen.findByRole("alert")).toHaveTextContent("Could not preview this design");
expect(screen.getByRole("button", { name: "Apply template" })).toBeDisabled();
fireEvent.click(screen.getByRole("button", { name: "Retry preview" }));
await waitFor(() => expect(screen.getByRole("button", { name: "Apply template" })).toBeEnabled());
fireEvent.click(screen.getByRole("button", { name: "Apply template" }));
expect(onApply).toHaveBeenCalledWith(modern);
});
test("locked candidates cannot request or apply a preview", async () => {
render(<I18nProvider><CvTemplatePreviewDialog theme={{ ...modern, available: false }} onClose={jest.fn()} onApply={jest.fn()} /></I18nProvider>);
expect(screen.getByRole("button", { name: "Apply template" })).toBeDisabled();
expect(cvBuilderApi.themePreview).not.toHaveBeenCalled();
});
test("Bokmål sample labelling and actions are localised", async () => {
localStorage.setItem("uiLanguage", "nb");
render(<I18nProvider><CvTemplatePreviewDialog theme={modern} onClose={jest.fn()} onApply={jest.fn()} /></I18nProvider>);
expect(await screen.findByTitle("Eksempelforhåndsvisning av mal")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Bruk mal" })).toBeEnabled();
test('template cards use the server-rendered sample in a sandboxed noninteractive preview', async () => {
const theme = { id: 'preview-test', name: 'Preview test', layout: 'single', swatches: [] } as unknown as CvTheme;
const request = jest.spyOn(cvBuilderApi, 'themePreview').mockResolvedValue({ themeId: theme.id, html: '<html><body><h1>Rendered sample</h1></body></html>', suggestedFileName: 'sample.pdf' });
try {
render(<I18nProvider><CvTemplateThumbnail theme={theme} /></I18nProvider>);
const iframe = await screen.findByTitle('Template sample preview');
expect(iframe).toHaveAttribute('srcdoc', expect.stringContaining('Rendered sample'));
expect(iframe).toHaveAttribute('sandbox', '');
expect(iframe).toHaveAttribute('tabindex', '-1');
expect(request).toHaveBeenCalledWith(theme.id);
} finally { request.mockRestore(); }
});
@@ -1,8 +1,9 @@
import React from "react";
import { render } from "@testing-library/react";
import { render, waitFor } from "@testing-library/react";
import CvTemplateThumbnail from "./components/CvTemplateThumbnail";
import { CvTheme } from "./cvBuilder";
import { cvBuilderApi, CvTheme } from "./cvBuilder";
import { I18nProvider } from "./i18n/I18nProvider";
const theme = (id: string, layout: CvTheme["layout"], swatches: string[]): CvTheme => ({
id,
@@ -19,13 +20,20 @@ const theme = (id: string, layout: CvTheme["layout"], swatches: string[]): CvThe
swatches,
});
test("template thumbnails stay distinct and never replace themselves with sandboxed frames", () => {
const minimal = render(<CvTemplateThumbnail theme={theme("minimal", "single", ["#111827", "#ffffff", "#ffffff"])} />);
const minimalMarkup = minimal.container.innerHTML;
test("template thumbnails retain distinct server-rendered samples in noninteractive sandboxed frames", async () => {
const preview = jest.spyOn(cvBuilderApi, "themePreview").mockImplementation(async id => ({ html: `<html><body>${id} synthetic sample</body></html>` } as any));
const minimal = render(<I18nProvider><CvTemplateThumbnail theme={theme("minimal", "single", ["#111827", "#ffffff", "#ffffff"])} /></I18nProvider>);
await waitFor(() => expect(minimal.container.querySelector("iframe")?.srcdoc).toContain("minimal synthetic sample"));
const minimalMarkup = minimal.container.querySelector("iframe")!.srcdoc;
minimal.unmount();
const nordic = render(<CvTemplateThumbnail theme={theme("nordic", "sidebar-right", ["#3b6ea5", "#eef3f8", "#ffffff"])} />);
const nordic = render(<I18nProvider><CvTemplateThumbnail theme={theme("nordic", "sidebar-right", ["#3b6ea5", "#eef3f8", "#ffffff"])} /></I18nProvider>);
expect(nordic.container.querySelector("iframe")).toBeNull();
expect(nordic.container.innerHTML).not.toBe(minimalMarkup);
await waitFor(() => expect(nordic.container.querySelector("iframe")?.srcdoc).toContain("nordic synthetic sample"));
const frame = nordic.container.querySelector("iframe")!;
expect(frame.getAttribute("sandbox")).toBe("");
expect(frame.tabIndex).toBe(-1);
expect(frame.srcdoc).not.toBe(minimalMarkup);
expect(preview.mock.calls.map(([id]) => id)).toEqual(["minimal", "nordic"]);
preview.mockRestore();
});
+17 -1
View File
@@ -1,4 +1,4 @@
import { applyCvTheme, getCvPageCount, getCvPageMetrics, getCvPreviewFitZoom, getVisibleCvThemes, moveItem, normalizeCvLanguage, wrapSelection } from "./cvBuilder";
import { applyCvTheme, emptyCvVariantSettings, newCvVariantSettings, getCvPageCount, getCvPageMetrics, getCvPreviewFitZoom, getVisibleCvThemes, moveItem, normalizeCvLanguage, wrapSelection } from "./cvBuilder";
describe("moveItem", () => {
test("moves an item forward", () => {
@@ -68,6 +68,22 @@ describe("CV language compatibility", () => {
});
describe("CV template selection", () => {
test("new documents opt into revised design while legacy fixture defaults stay unchanged", () => {
expect(newCvVariantSettings("nordic")).toEqual({ ...emptyCvVariantSettings("nordic"), designVersion: 2 });
expect(emptyCvVariantSettings()).not.toHaveProperty("designVersion");
});
test("explicit template Apply opts into revised design and preserves inclusion and authored content", () => {
const legacy = {
themeId: "modern", showPhoto: true, showPageNumbers: false, showIcons: true,
designVersion: 1, sectionsExplicit: true, includeProfileContact: false,
headline: "Authored headline", sections: [{ key: "summary", items: ["Authored summary"] }], overrides: {}, customSections: [],
};
const next = applyCvTheme(legacy, "technical");
expect(next).toMatchObject({ designVersion: 2, themeId: "technical", sectionsExplicit: true, includeProfileContact: false,
headline: "Authored headline", sections: legacy.sections, showPhoto: true });
expect(legacy.designVersion).toBe(1);
});
test("restores the selected template visual defaults without discarding user content", () => {
const settings = {
themeId: "minimal",
+10
View File
@@ -23,6 +23,7 @@ export type CvSkillGroupSetting = { name: string; items: string[] };
export type CvVariantSettings = {
themeId: string;
designVersion?: number | null;
accentColor?: string | null;
headingFont?: string | null;
bodyFont?: string | null;
@@ -73,6 +74,10 @@ export type CvTheme = {
swatches: string[];
supportedSettings?: string[];
pageMarginMm?: number;
headingFont?: string;
bodyFont?: string;
baseFontSizePt?: number;
lineHeight?: number;
};
// Master profile resolved to sections+entries (GET /api/cv/outline) — what the Content tab edits.
@@ -197,6 +202,7 @@ export function applyCvTheme(settings: CvVariantSettings, themeId: string): CvVa
return {
...settings,
themeId,
designVersion: 2,
accentColor: null,
headingFont: null,
bodyFont: null,
@@ -243,6 +249,10 @@ export function emptyCvVariantSettings(themeId = "modern"): CvVariantSettings {
};
}
export function newCvVariantSettings(themeId = "modern"): CvVariantSettings {
return { ...emptyCvVariantSettings(themeId), designVersion: 2 };
}
export async function getCvExportFailureKey(error: unknown): Promise<"cvEmptyContent" | "cvPdfBusy" | "cvPdfTimedOut" | undefined> {
const response = (error as { response?: { status?: number; data?: unknown } })?.response;
if (response?.status === 503) return "cvPdfBusy";
+28
View File
@@ -796,6 +796,12 @@ export const translations = {
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",
letterChooseTemplate: "Choose letter template",
letterTemplate: "Letter template",
letterTemplateHelp: "Choose a design matching the CV templates. Only styling changes; your letter text, details, language and paper size stay unchanged. Review the result in Document preview.",
letterTemplateApply: "Use this template",
letterTemplateFailed: "Could not load letter templates. Try again.",
letterTemplateUnavailable: "Choose an available template with complete design defaults.",
letterCopyHelp: "Review this snapshot before applying. Your letter text and recipient stay unchanged; future profile or CV edits do not update this copy.",
letterSourceCv: "Source CV",
letterCopyApply: "Use this copy",
@@ -1044,6 +1050,10 @@ export const translations = {
cvEditorSidebarWidth: "Sidebar width",
cvEditorContactDetails: "Contact details",
cvEditorShowPhoto: "Show profile photo",
cvEditorProfilePhoto: "Profile photo",
cvEditorManagePhoto: "Manage profile photo",
cvEditorPhotoHelp: "Upload, preview, crop or remove your photo in Career Profile. It is shared with your account and appears here only when enabled; a missing photo leaves the document unchanged.",
cvEditorPhotoContactHelp: "Include Career Profile contact details in Content to show its photo. A missing photo leaves the document unchanged.",
cvEditorContactIcons: "Contact icons",
cvEditorOpenFailed: "Could not open this CV.",
cvEditorSourceUnavailable: "This CV is unavailable or you no longer have access to it.",
@@ -1557,6 +1567,7 @@ export const translations = {
profileCopyCvText: "Copy CV text",
profileCvUploaded: "CV uploaded and processed.",
profileCvUploadFailed: "Failed to upload CV.",
cvImportWorkerDisabled: "CV importing is currently unavailable. Ask an administrator to enable CV processing, then try uploading again.",
profileCvQueuedUpload: "Queued CV upload (run {id}).",
profileCvQueuedRebuild: "Queued CV rebuild (run {id}).",
profileCvQueuedImprove: "Queued CV improvement (run {id}).",
@@ -1811,6 +1822,9 @@ export const translations = {
cropDialogTitle: "Crop profile image",
cropDialogBody: "Position and zoom your image. The saved avatar will be exported as a 512×512 square.",
cropDialogZoom: "Zoom",
cropDialogHorizontal: "Horizontal position",
cropDialogVertical: "Vertical position",
cropDialogKeyboardHelp: "Use the position sliders with arrow keys, or Home and End, to adjust the crop without dragging.",
cropDialogPreviewAlt: "Crop preview",
cropDialogSave: "Save image",
dashboardSnapshotScope: "Your tracker today. Activity charts have a separate period.",
@@ -3808,6 +3822,12 @@ export const translations = {
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",
letterChooseTemplate: "Velg brevmal",
letterTemplate: "Brevmal",
letterTemplateHelp: "Velg et design som passer til CV-malene. Bare utformingen endres; brevtekst, opplysninger, språk og papirstørrelse beholdes. Se resultatet i dokumentforhåndsvisningen.",
letterTemplateApply: "Bruk denne malen",
letterTemplateFailed: "Kunne ikke laste brevmalene. Prøv igjen.",
letterTemplateUnavailable: "Velg en tilgjengelig mal med fullstendige designinnstillinger.",
letterCopyHelp: "Se gjennom kopien før du bruker den. Brevteksten og mottakeren beholdes; senere profil- eller CV-endringer oppdaterer ikke denne kopien.",
letterSourceCv: "Kilde-CV",
letterCopyApply: "Bruk denne kopien",
@@ -4056,6 +4076,10 @@ export const translations = {
cvEditorSidebarWidth: "Bredde på sidestolpe",
cvEditorContactDetails: "Kontaktopplysninger",
cvEditorShowPhoto: "Vis profilbilde",
cvEditorProfilePhoto: "Profilbilde",
cvEditorManagePhoto: "Administrer profilbilde",
cvEditorPhotoHelp: "Last opp, forhåndsvis, beskjær eller fjern bildet ditt i Karriereprofil. Bildet deles med kontoen din og vises her bare når det er aktivert. Et manglende bilde endrer ikke dokumentet.",
cvEditorPhotoContactHelp: "Ta med kontaktopplysninger fra Karriereprofil under Innhold for å vise bildet. Et manglende bilde endrer ikke dokumentet.",
cvEditorContactIcons: "Kontaktikoner",
cvEditorOpenFailed: "Kunne ikke åpne denne CV-en.",
cvEditorSourceUnavailable: "Denne CV-en er utilgjengelig, eller du har ikke lenger tilgang til den.",
@@ -4569,6 +4593,7 @@ export const translations = {
profileCopyCvText: "Kopier CV-tekst",
profileCvUploaded: "CV lastet opp og behandlet.",
profileCvUploadFailed: "Kunne ikke laste opp CV.",
cvImportWorkerDisabled: "CV-import er ikke tilgjengelig nå. Be en administrator om å aktivere CV-behandling, og prøv deretter å laste opp på nytt.",
profileCvQueuedUpload: "CV-opplasting satt i kø (kjøring {id}).",
profileCvQueuedRebuild: "Gjenoppbygging av CV satt i kø (kjøring {id}).",
profileCvQueuedImprove: "CV-forbedring satt i kø (kjøring {id}).",
@@ -4823,6 +4848,9 @@ export const translations = {
cropDialogTitle: "Beskjær profilbilde",
cropDialogBody: "Plasser og zoom bildet. Det lagrede avataren eksporteres som en kvadratisk 512×512-fil.",
cropDialogZoom: "Zoom",
cropDialogHorizontal: "Vannrett posisjon",
cropDialogVertical: "Loddrett posisjon",
cropDialogKeyboardHelp: "Bruk piltastene eller Home og End på posisjonsglidebryterne for å justere beskjæringen uten å dra.",
cropDialogPreviewAlt: "Forhåndsvisning av beskjæring",
cropDialogSave: "Lagre bilde",
dashboardSnapshotScope: "Jobbsøket ditt nå. Aktivitetsdiagrammene har et eget periodevalg.",
@@ -344,7 +344,10 @@ export default function CareerProfilePage() {
await loadExtractionRuns();
toast(t("profileCvQueuedUpload", { id: res.data.extractionRunId }), "info");
} catch (error) {
const message = getApiErrorMessage(error, t("profileCvUploadFailed"));
const response = (error as { response?: { status?: number; data?: { code?: string } } }).response;
const message = response?.status === 503 && response.data?.code === "cv_worker_disabled"
? translationRef.current("cvImportWorkerDisabled")
: getApiErrorMessage(error, t("profileCvUploadFailed"));
setUploadError(message);
toast(message, "error");
} finally {
+2 -2
View File
@@ -10,7 +10,7 @@ import PublicIcon from "@mui/icons-material/Public";
import { api, getApiErrorMessage } from "../api";
import { useToast } from "../toast";
import { CvOutline, CvTheme, CvVariantSummary, cvBuilderApi, emptyCvVariantSettings, getCvExportFailureKey } from "../cvBuilder";
import { CvOutline, CvTheme, CvVariantSummary, cvBuilderApi, newCvVariantSettings, getCvExportFailureKey } from "../cvBuilder";
import { useDialogActions } from "../dialogs";
import CvTemplateGallery from "../components/CvTemplateGallery";
import CvTemplateThumbnail from "../components/CvTemplateThumbnail";
@@ -92,7 +92,7 @@ export default function CvBuilderPage() {
setCreating(true);
try {
const variant = await cvBuilderApi.create({ name: newName.trim(), settings: {
...emptyCvVariantSettings(newTheme),
...newCvVariantSettings(newTheme),
sectionsExplicit: true,
includeProfileContact: contentSource === "profile" && includeContact,
sections: contentSource === "profile" ? selectedSections.map((key) => ({ key })) : [],