feat(ui): refine live CV preview presentation
This commit is contained in:
@@ -0,0 +1,240 @@
|
||||
import AxeBuilder from '@axe-core/playwright';
|
||||
import { expect, test, type APIRequestContext, type Locator, type Page, type TestInfo } from '@playwright/test';
|
||||
import type { CvVariant } from '../src/cvBuilder';
|
||||
import { translations } from '../src/i18n/translations';
|
||||
|
||||
const apiUrl = 'http://localhost:5302/api';
|
||||
const password = 'E2ePassword123!';
|
||||
async function login(api: APIRequestContext, email: string) {
|
||||
expect((await api.post(`${apiUrl}/auth/login`, { data: { email, password } })).status()).toBe(200);
|
||||
const me = await (await api.get(`${apiUrl}/auth/me`)).json();
|
||||
return { 'X-CSRF-TOKEN': (await api.storageState()).cookies.find(cookie => cookie.name === 'XSRF-TOKEN')!.value, 'X-Session-Context': me.sessionContext as string };
|
||||
}
|
||||
async function capture(scope: Locator, info: TestInfo, state: string) {
|
||||
const path = info.outputPath(`${state}.png`);
|
||||
const geometry = await scope.evaluate(node => {
|
||||
const region = node.querySelector('[role="region"]')!;
|
||||
const bounds = (element: Element) => { const rect = element.getBoundingClientRect(); return { top: rect.top, bottom: rect.bottom, width: rect.width, height: rect.height }; };
|
||||
return { viewport: { width: innerWidth, height: innerHeight }, chrome: bounds(node), region: bounds(region), scrollTop: region.scrollTop, focused: region === document.activeElement };
|
||||
});
|
||||
await scope.page().screenshot({ path, animations: 'disabled' });
|
||||
await info.attach(state, { path, contentType: 'image/png' });
|
||||
await info.attach(`${state}-viewport`, { body: JSON.stringify(geometry), contentType: 'application/json' });
|
||||
}
|
||||
async function scan(page: Page, info: TestInfo, state: string, region: string) {
|
||||
const result = await new AxeBuilder({ page }).include(`.MuiPaper-root:has(> [role="region"][aria-label=${JSON.stringify(region)}])`).withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa']).analyze();
|
||||
await info.attach(`${state}-axe`, { body: JSON.stringify({ violations: result.violations, incomplete: result.incomplete }), contentType: 'application/json' });
|
||||
expect(result.violations).toEqual([]);
|
||||
}
|
||||
|
||||
for (const [mode, language] of [['light', 'en'], ['dark', 'nb']] as const) {
|
||||
test(`[PRES-C06 C08 C09 C10 C12 E08 E09] ${mode}/${language} native preview chrome, auto-fit, long pages and recovery`, async ({ page, context, playwright }, info) => {
|
||||
test.setTimeout(300_000);
|
||||
const t = translations[language];
|
||||
const admin = await playwright.request.newContext();
|
||||
try {
|
||||
const adminHeaders = await login(admin, 'e2e@example.test');
|
||||
const email = `p511-${Date.now()}-${mode}-${language}@example.test`;
|
||||
expect((await admin.post(`${apiUrl}/users`, { headers: adminHeaders, data: { email, password, roles: [] } })).status()).toBe(200);
|
||||
const headers = await login(page.request, email);
|
||||
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();
|
||||
});
|
||||
await context.addInitScript(({ mode, language }) => { localStorage.setItem('jobtracker.themeMode', mode); localStorage.setItem('uiLanguage', language); }, { mode, language });
|
||||
await page.emulateMedia({ reducedMotion: 'reduce' });
|
||||
expect((await page.request.put(`${apiUrl}/auth/preferences/language`, { headers, data: { language: language === 'nb' ? 'nb-NO' : 'en' } })).status()).toBe(204);
|
||||
const sentence = language === 'nb'
|
||||
? 'Utviklet pålitelige arbeidsflyter med tilgjengelig navigasjon, dokumentert kildegrunnlag og tydelige vurderinger for norske arbeidssøkere.'
|
||||
: 'Developed reliable workflows with accessible navigation, documented source evidence and explicit review for people managing their careers.';
|
||||
const shortProfile = { version: '1', contact: { fullName: 'Åse Eksempel', headline: 'Canonical engineer — æøå', location: 'Oslo, Norge' }, summary: ['Documented career evidence.'], skills: ['C#', 'React', 'Norsk'] };
|
||||
expect((await page.request.put(`${apiUrl}/career/profile`, { headers, data: { profile: shortProfile } })).status()).toBe(200);
|
||||
const create = async (name: string, themeId: string, customItems: string[] = []): Promise<CvVariant> => {
|
||||
const response = await page.request.post(`${apiUrl}/cv/variants`, { headers, data: { name, settings: {
|
||||
themeId, language, sections: [{ key: 'summary' }, { key: 'experience' }, { key: 'skills' }, ...(customItems.length ? [{ key: 'custom:preview-evidence' }] : [])],
|
||||
customSections: customItems.length ? [{ key: 'preview-evidence', title: 'Documented CV contributions — æøå', contentType: 'bullets', items: customItems }] : [],
|
||||
} } });
|
||||
expect(response.status()).toBe(200); return response.json();
|
||||
};
|
||||
const onePage = await create('One-page Code fixture — æøå', 'code');
|
||||
const multiple = await create('Multi-page preview fixture — æøå', 'minimal', Array.from({ length: 45 }, (_, index) => `Custom contribution ${index + 1}: ${sentence}`));
|
||||
const read = async (id: number): Promise<CvVariant> => (await page.request.get(`${apiUrl}/cv/variants/${id}`)).json();
|
||||
const region = page.getByRole('region', { name: t.cvEditorLivePreview, exact: true });
|
||||
const chrome = region.locator('..');
|
||||
const editing = page.getByRole('region', { name: t.cvEditorEditingRegion, exact: true });
|
||||
const measurement = page.getByTitle(t.cvEditorPreviewTitle, { exact: true });
|
||||
const pages = region.locator('[data-cv-page]');
|
||||
const frames = pages.locator('iframe');
|
||||
const firstFrame = page.getByTitle(t.cvEditorPreviewPage.replace('{page}', '1'), { exact: true });
|
||||
const firstDocument = page.frameLocator(`iframe[title=${JSON.stringify(t.cvEditorPreviewPage.replace('{page}', '1'))}]`);
|
||||
const saved = async () => { await expect(page.getByText(t.cvEditorSaved, { exact: true })).toBeVisible(); };
|
||||
const measurements: Record<string, unknown>[] = [];
|
||||
const settle = async (state: string, minimum = 1) => {
|
||||
await expect(region).toHaveAttribute('aria-busy', 'false');
|
||||
await expect.poll(() => measurement.evaluate(element => (element as HTMLIFrameElement).contentDocument?.documentElement.dataset.cvPreviewReady)).toBe('true');
|
||||
const native = await measurement.evaluate(element => {
|
||||
const data = (element as HTMLIFrameElement).contentDocument!.documentElement.dataset;
|
||||
return { pages: Number(data.cvPreviewPages), width: Number(data.cvPreviewWidth), height: Number(data.cvPreviewHeight), usableHeight: Number(data.cvPreviewUsableHeight), layoutDurationMs: Number(data.cvPreviewDurationMs), overflow: data.cvPreviewOverflow === 'true' };
|
||||
});
|
||||
expect(native.pages).toBeGreaterThanOrEqual(minimum);
|
||||
await expect(pages).toHaveCount(native.pages);
|
||||
await expect.poll(() => frames.evaluateAll(elements => elements.every(element => (element as HTMLIFrameElement).contentDocument?.documentElement.dataset.cvPreviewReady === 'true'))).toBe(true);
|
||||
await expect(chrome.getByText(t.cvEditorPageCount.replace('{count}', String(native.pages)), { exact: true })).toBeVisible();
|
||||
expect(Number.isFinite(native.layoutDurationMs)).toBe(true); expect(native.layoutDurationMs).toBeGreaterThanOrEqual(0);
|
||||
expect(native.width).toBeCloseTo(210 * 96 / 25.4, 0); expect(native.height).toBeCloseTo(297 * 96 / 25.4, 0);
|
||||
const titles = await frames.evaluateAll(elements => elements.map(element => element.getAttribute('title')));
|
||||
expect(titles).toEqual(Array.from({ length: native.pages }, (_, index) => t.cvEditorPreviewPage.replace('{page}', String(index + 1))));
|
||||
measurements.push({ state, ...native }); return native;
|
||||
};
|
||||
const enterPreview = async () => {
|
||||
const desktopEntry = page.getByRole('button', { name: t.cvEditorFocusPreview, exact: true });
|
||||
if (await desktopEntry.isVisible()) {
|
||||
await desktopEntry.focus(); await page.keyboard.press('Enter');
|
||||
} else {
|
||||
await page.getByRole('button', { name: t.cvEditorPreview, exact: true }).press('Enter');
|
||||
await chrome.getByRole('button', { name: t.cvEditorFocusEditor, exact: true }).focus();
|
||||
await page.keyboard.press('Tab');
|
||||
}
|
||||
await expect(region).toBeFocused(); await expect(region).toBeVisible();
|
||||
};
|
||||
const returnEditor = async (draft: string) => {
|
||||
await expect(region).toBeFocused(); await page.keyboard.press('Shift+Tab');
|
||||
await expect(chrome.getByRole('button', { name: t.cvEditorFocusEditor, exact: true })).toBeFocused();
|
||||
await page.keyboard.press('Enter');
|
||||
await expect(editing).toBeFocused(); await expect(editing).toHaveAttribute('tabindex', '-1');
|
||||
await expect(page.getByLabel(t.cvEditorHeadlineOverride, { exact: true })).toHaveValue(draft);
|
||||
};
|
||||
const autoFit = async (state: string, widths: number[]) => {
|
||||
const originalSources = await frames.evaluateAll(elements => elements.map(element => element.getAttribute('srcdoc')));
|
||||
const originalFrames = await frames.elementHandles();
|
||||
const originalMeasurement = await measurement.getAttribute('srcdoc');
|
||||
for (const width of widths) {
|
||||
await enterPreview(); await page.setViewportSize({ width, height: width < 900 ? 844 : 900 });
|
||||
await expect(region).toBeFocused(); await expect(region).toBeVisible();
|
||||
const available = await region.evaluate(node => node.clientWidth);
|
||||
const expectedScale = Math.min(1, Math.max(0.2, (available - 16) / (210 * 96 / 25.4)));
|
||||
await expect.poll(() => firstFrame.evaluate(element => new DOMMatrixReadOnly(getComputedStyle(element).transform).a)).toBeCloseTo(expectedScale, 3);
|
||||
const scale = await firstFrame.evaluate(element => new DOMMatrixReadOnly(getComputedStyle(element).transform).a);
|
||||
await expect(chrome.getByText(t.cvEditorPreviewScale.replace('{percent}', String(Math.round(scale * 100))), { exact: true })).toBeVisible();
|
||||
expect(await frames.evaluateAll(elements => elements.map(element => element.getAttribute('srcdoc')))).toEqual(originalSources);
|
||||
expect(await measurement.getAttribute('srcdoc')).toBe(originalMeasurement);
|
||||
for (let index = 0; index < originalFrames.length; index++) expect(await frames.nth(index).evaluate((node, original) => node === original, originalFrames[index])).toBe(true);
|
||||
expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1)).toBe(true);
|
||||
await capture(chrome, info, `${state}-fit-${width}`);
|
||||
}
|
||||
for (const handle of originalFrames) await handle.dispose();
|
||||
};
|
||||
const bottomSnapshot = async () => {
|
||||
await enterPreview(); await page.keyboard.press('Control+End');
|
||||
await expect.poll(() => region.evaluate(node => Math.abs(node.scrollHeight - node.clientHeight - node.scrollTop))).toBeLessThan(3);
|
||||
return { scroll: await region.evaluate(node => ({ top: node.scrollTop, height: node.scrollHeight })),
|
||||
sources: await frames.evaluateAll(elements => elements.map(element => element.getAttribute('srcdoc'))), handles: await frames.elementHandles() };
|
||||
};
|
||||
const retainedBottom = async (snapshot: Awaited<ReturnType<typeof bottomSnapshot>>, state: string) => {
|
||||
const current = await region.evaluate(node => ({ top: node.scrollTop, height: node.scrollHeight }));
|
||||
await info.attach(`${state}-scroll-retention`, { body: JSON.stringify({ before: snapshot.scroll, after: current }), contentType: 'application/json' });
|
||||
expect(Math.abs(current.top - snapshot.scroll.top), `${state}: retained scroll position`).toBeLessThanOrEqual(1);
|
||||
expect(Math.abs(current.height - snapshot.scroll.height), `${state}: retained scroll height`).toBeLessThanOrEqual(1);
|
||||
expect(await frames.evaluateAll(elements => elements.map(element => element.getAttribute('srcdoc')))).toEqual(snapshot.sources);
|
||||
for (let index = 0; index < snapshot.handles.length; index++) expect(await frames.nth(index).evaluate((node, original) => node === original, snapshot.handles[index])).toBe(true);
|
||||
};
|
||||
|
||||
await page.setViewportSize({ width: 1440, height: 900 });
|
||||
await page.goto(`/career/builder/${onePage.id}`);
|
||||
await expect(page.getByLabel(t.cvEditorName, { exact: true })).toHaveValue(onePage.name);
|
||||
expect((await settle('one-page-code')).pages).toBe(1);
|
||||
await capture(chrome, info, 'one-page-code-default');
|
||||
await enterPreview(); await capture(chrome, info, 'one-page-code-focus');
|
||||
await autoFit('one-page-code', [1440, 768, 320]);
|
||||
await scan(page, info, 'one-page-phone-preview', t.cvEditorLivePreview);
|
||||
await returnEditor(''); expect(await read(onePage.id)).toEqual(onePage);
|
||||
|
||||
await page.setViewportSize({ width: 1440, height: 900 });
|
||||
await page.goto(`/career/builder/${multiple.id}`);
|
||||
await expect(page.getByLabel(t.cvEditorName, { exact: true })).toHaveValue(multiple.name);
|
||||
await settle('multiple-pages', 2); await enterPreview();
|
||||
await expect(region).toBeFocused();
|
||||
const pageDownSettled = region.evaluate(node => new Promise<void>(resolve => node.addEventListener('scrollend', () => resolve(), { once: true })));
|
||||
await page.keyboard.press('PageDown');
|
||||
await pageDownSettled;
|
||||
await expect.poll(() => region.evaluate(node => node.scrollTop)).toBeGreaterThan(100);
|
||||
await expect(region).toBeFocused(); await page.keyboard.press('Control+End');
|
||||
await expect.poll(() => region.evaluate(node => Math.abs(node.scrollHeight - node.clientHeight - node.scrollTop))).toBeLessThan(3);
|
||||
await capture(chrome, info, 'multiple-pages-end');
|
||||
await page.keyboard.press('Control+Home'); await expect.poll(() => region.evaluate(node => node.scrollTop)).toBe(0);
|
||||
await capture(chrome, info, 'multiple-pages-start');
|
||||
await returnEditor(''); expect(await read(multiple.id)).toEqual(multiple);
|
||||
|
||||
const longProfile = { ...shortProfile, jobs: Array.from({ length: 3 }, (_, index) => ({ id: `long-job-${index + 1}`, title: `Source-grounded engineer ${index + 1} — æøå`, company: 'Nordlys Fixture AS', startDate: '2017-01', endDate: '2025-12',
|
||||
bullets: Array.from({ length: 30 }, (_, bullet) => `Source contribution ${index * 30 + bullet + 1}: ${sentence} ${sentence}`) })) };
|
||||
expect((await page.request.put(`${apiUrl}/career/profile`, { headers, data: { profile: longProfile } })).status()).toBe(200);
|
||||
const source = await (await page.request.get(`${apiUrl}/career/profile`)).json();
|
||||
const veryLong = await create('Very long source and custom CV — æøå', 'minimal', Array.from({ length: 45 }, (_, index) => `CV-only long contribution ${index + 1}: ${sentence} ${sentence}`));
|
||||
const unchanged = async () => {
|
||||
expect(await (await page.request.get(`${apiUrl}/career/profile`)).json()).toEqual(source);
|
||||
expect(await read(onePage.id)).toEqual(onePage); expect(await read(multiple.id)).toEqual(multiple);
|
||||
};
|
||||
await page.goto(`/career/builder/${veryLong.id}`);
|
||||
await expect(page.getByLabel(t.cvEditorName, { exact: true })).toHaveValue(veryLong.name);
|
||||
const longMeasurement = await settle('very-long-source-and-custom', 5);
|
||||
await autoFit('very-long', [1440, 768, 320]);
|
||||
await scan(page, info, 'very-long-phone-preview', t.cvEditorLivePreview);
|
||||
await page.keyboard.press('Control+End');
|
||||
await expect.poll(() => region.evaluate(node => Math.abs(node.scrollHeight - node.clientHeight - node.scrollTop))).toBeLessThan(3);
|
||||
await capture(chrome, info, 'very-long-phone-last-page');
|
||||
await page.keyboard.press('Control+Home'); await expect.poll(() => region.evaluate(node => node.scrollTop)).toBe(0);
|
||||
await returnEditor(''); expect(await read(veryLong.id)).toEqual(veryLong); await unchanged();
|
||||
|
||||
await page.setViewportSize({ width: 1440, height: 900 });
|
||||
const heldBottom = await bottomSnapshot();
|
||||
await returnEditor('');
|
||||
let release!: () => void;
|
||||
const hold = new Promise<void>(resolve => { release = resolve; });
|
||||
let pending = false;
|
||||
const lastGood = await firstFrame.getAttribute('srcdoc');
|
||||
await page.route(`${apiUrl}/cv/preview`, async route => { pending = true; await hold; await route.continue(); }, { times: 1 });
|
||||
const realRender = page.waitForResponse(response => response.url() === `${apiUrl}/cv/preview` && response.request().method() === 'POST');
|
||||
const headline = page.getByLabel(t.cvEditorHeadlineOverride, { exact: true });
|
||||
try {
|
||||
await headline.fill('Preview draft retained — æøå');
|
||||
await expect.poll(() => pending).toBe(true);
|
||||
await expect(region).toHaveAttribute('aria-busy', 'true');
|
||||
await enterPreview(); await expect(chrome.getByText(t.cvEditorUpdatingPreview, { exact: true })).toBeVisible();
|
||||
await retainedBottom(heldBottom, 'held-request');
|
||||
expect(await firstFrame.getAttribute('srcdoc')).toBe(lastGood); await expect(pages).toHaveCount(longMeasurement.pages);
|
||||
await capture(chrome, info, 'loading-retains-last-good-pages');
|
||||
await returnEditor('Preview draft retained — æøå');
|
||||
} finally { release(); await realRender; for (const handle of heldBottom.handles) await handle.dispose(); }
|
||||
expect((await realRender).status()).toBe(200);
|
||||
await settle('native-render-after-held-request', 5);
|
||||
await expect(firstDocument.getByText('Preview draft retained — æøå', { exact: true })).toBeVisible();
|
||||
await expect.poll(async () => (await read(veryLong.id)).settings.headline).toBe('Preview draft retained — æøå'); await saved();
|
||||
|
||||
const failedBottom = await bottomSnapshot();
|
||||
await returnEditor('Preview draft retained — æøå');
|
||||
const goodSources = await frames.evaluateAll(elements => elements.map(element => element.getAttribute('srcdoc')));
|
||||
const goodCount = await pages.count();
|
||||
await page.route(`${apiUrl}/cv/preview`, route => route.abort('failed'), { times: 1 });
|
||||
await headline.fill('Recovered native preview — æøå');
|
||||
await enterPreview();
|
||||
await expect(chrome.getByText(t.cvEditorPreviewUnavailable, { exact: true })).toBeVisible();
|
||||
await expect(region).toHaveAttribute('aria-busy', 'false'); await expect(region).toBeFocused();
|
||||
await retainedBottom(failedBottom, 'failed-request');
|
||||
for (const handle of failedBottom.handles) await handle.dispose();
|
||||
await expect(pages).toHaveCount(goodCount);
|
||||
expect(await frames.evaluateAll(elements => elements.map(element => element.getAttribute('srcdoc')))).toEqual(goodSources);
|
||||
await capture(chrome, info, 'error-retains-last-good-pages');
|
||||
await scan(page, info, 'error-preview', t.cvEditorLivePreview);
|
||||
const retried = page.waitForResponse(response => response.url() === `${apiUrl}/cv/preview` && response.request().method() === 'POST');
|
||||
await chrome.getByRole('button', { name: t.cvEditorRetryPreview, exact: true }).press('Enter');
|
||||
expect((await retried).status()).toBe(200); await settle('native-retry-recovered', 5);
|
||||
await expect(chrome.getByText(t.cvEditorPreviewUnavailable, { exact: true })).toHaveCount(0);
|
||||
await expect(firstDocument.getByText('Recovered native preview — æøå', { exact: true })).toBeVisible();
|
||||
await enterPreview(); await capture(chrome, info, 'recovered-preview-focus');
|
||||
await returnEditor('Recovered native preview — æøå');
|
||||
await expect.poll(async () => (await read(veryLong.id)).settings.headline).toBe('Recovered native preview — æøå'); await saved();
|
||||
await unchanged();
|
||||
await info.attach('native-preview-measurements', { body: JSON.stringify({ mode, language, sourceBullets: 90, customBullets: 45, measurements }), contentType: 'application/json' });
|
||||
} finally { await admin.dispose(); }
|
||||
});
|
||||
}
|
||||
@@ -385,7 +385,10 @@ test("Career Workspace loads from the authenticated application shell", async ({
|
||||
if (width === 375) {
|
||||
await page.getByRole("button", { name: "Preview" }).click();
|
||||
await expect(page.getByTitle("CV preview page 1")).toBeVisible();
|
||||
await page.getByRole("button", { name: "Edit" }).click();
|
||||
const editMode = page.getByRole("button", { name: "Edit", exact: true });
|
||||
await editMode.click();
|
||||
await expect(editMode).toHaveAttribute("aria-pressed", "true");
|
||||
await expect(page.getByRole("region", { name: "CV editing controls", exact: true })).toBeVisible();
|
||||
}
|
||||
const overflow = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth);
|
||||
expect(overflow).toBeLessThanOrEqual(1);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from 'react';
|
||||
import '@testing-library/jest-dom';
|
||||
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react';
|
||||
import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
|
||||
import { createMemoryRouter, RouterProvider } from 'react-router-dom';
|
||||
|
||||
import CvBuilderEditor from './views/CvBuilderEditor';
|
||||
@@ -655,12 +655,99 @@ test('preview failure is visible and retryable without leaving the editor', asyn
|
||||
renderAt(3);
|
||||
|
||||
expect(await screen.findByText('Preview unavailable')).toBeInTheDocument();
|
||||
expect(screen.getByRole('region', { name: 'Live preview' })).toHaveAttribute('aria-busy', 'false');
|
||||
expect(screen.queryByText('updating…')).not.toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Retry preview' }));
|
||||
await waitFor(() => expect(screen.getByTitle('CV preview').getAttribute('srcdoc')).toContain('<p>retry</p>'));
|
||||
expect(screen.getByTitle('CV preview')).toHaveAttribute('sandbox', 'allow-same-origin allow-scripts');
|
||||
await waitFor(() => expect(screen.queryByText('Preview unavailable')).not.toBeInTheDocument());
|
||||
});
|
||||
|
||||
function settlePreview(count: number) {
|
||||
const frame = screen.getByTitle('CV preview') as HTMLIFrameElement;
|
||||
const doc = frame.contentDocument!;
|
||||
doc.body.innerHTML = '<div class="page">Measured document</div>';
|
||||
delete doc.documentElement.dataset.cvPreviewReady;
|
||||
fireEvent.load(frame);
|
||||
act(() => {
|
||||
doc.documentElement.dataset.cvPreviewPages = String(count);
|
||||
doc.documentElement.dataset.cvPreviewReady = 'true';
|
||||
frame.contentWindow!.dispatchEvent(new Event('cv-preview-ready'));
|
||||
});
|
||||
}
|
||||
|
||||
test('[P5-11 PRES-C06 E08] automatic fit attaches when the asynchronously loaded preview pane appears', async () => {
|
||||
const width = jest.spyOn(HTMLElement.prototype, 'clientWidth', 'get').mockImplementation(function (this: HTMLElement) {
|
||||
return this.getAttribute('aria-label') === 'Live preview' ? 400 : 0;
|
||||
});
|
||||
try {
|
||||
routeGet(() => Promise.resolve({ data: variant }));
|
||||
renderAt(3);
|
||||
await screen.findByDisplayValue('Backend CV');
|
||||
expect(await screen.findByText('Fit width · 48%')).toBeInTheDocument();
|
||||
} finally { width.mockRestore(); }
|
||||
});
|
||||
|
||||
test('[P5-11 PRES-C06 C12] page count is announced only after the renderer has settled', async () => {
|
||||
routeGet(() => Promise.resolve({ data: variant }));
|
||||
renderAt(3);
|
||||
const region = await screen.findByRole('region', { name: 'Live preview' });
|
||||
await waitFor(() => expect(screen.getByTitle('CV preview').getAttribute('srcdoc')).toContain('<p>cv</p>'));
|
||||
expect(region).toHaveAttribute('aria-busy', 'true');
|
||||
expect(screen.queryByText('1-page CV')).not.toBeInTheDocument();
|
||||
settlePreview(3);
|
||||
expect(region).toHaveAttribute('aria-busy', 'false');
|
||||
expect(screen.getByText('3-page CV')).toBeInTheDocument();
|
||||
expect(region.querySelectorAll('[data-cv-page]')).toHaveLength(3);
|
||||
});
|
||||
|
||||
test('[P5-11 PRES-C06 E09] overlapping preview successes and failures cannot replace the newest document', async () => {
|
||||
const pending: { resolve: (value: any) => void; reject: (reason: Error) => void }[] = [];
|
||||
mockedApi.post.mockImplementation(() => new Promise((resolve, reject) => { pending.push({ resolve, reject }); }));
|
||||
try {
|
||||
routeGet(() => Promise.resolve({ data: variant }));
|
||||
renderAt(3);
|
||||
await openContentTab();
|
||||
const headline = screen.getByLabelText('Headline override');
|
||||
const region = screen.getByRole('region', { name: 'Live preview' });
|
||||
await waitFor(() => expect(pending).toHaveLength(1));
|
||||
fireEvent.change(headline, { target: { value: 'Newer draft' } });
|
||||
await waitFor(() => expect(pending).toHaveLength(2));
|
||||
await act(async () => { pending[1].resolve({ data: { html: '<p>Newer document</p>' } }); });
|
||||
settlePreview(2);
|
||||
await act(async () => { pending[0].resolve({ data: { html: '<p>Stale document</p>' } }); });
|
||||
expect(screen.getByTitle('CV preview').getAttribute('srcdoc')).toContain('Newer document');
|
||||
expect(region).toHaveAttribute('aria-busy', 'false');
|
||||
fireEvent.change(headline, { target: { value: 'Intermediate draft' } });
|
||||
await waitFor(() => expect(pending).toHaveLength(3));
|
||||
fireEvent.change(headline, { target: { value: 'Newest draft' } });
|
||||
await waitFor(() => expect(pending).toHaveLength(4));
|
||||
await act(async () => { pending[3].resolve({ data: { html: '<p>Newest document</p>' } }); });
|
||||
settlePreview(4);
|
||||
await act(async () => { pending[2].reject(new Error('stale failure')); });
|
||||
expect(screen.getByTitle('CV preview').getAttribute('srcdoc')).toContain('Newest document');
|
||||
expect(screen.queryByText('Preview unavailable')).not.toBeInTheDocument();
|
||||
expect(region).toHaveAttribute('aria-busy', 'false');
|
||||
expect(screen.getByText('4-page CV')).toBeInTheDocument();
|
||||
expect(headline).toHaveValue('Newest draft');
|
||||
} finally { mockedApi.post.mockResolvedValue({ data: { html: '<p>cv</p>' } } as any); }
|
||||
});
|
||||
|
||||
test('[P5-11 PRES-C06 C10 E08] returning from preview focuses the existing editor without changing the pending draft', async () => {
|
||||
routeGet(() => Promise.resolve({ data: variant }));
|
||||
renderAt(3);
|
||||
await openContentTab();
|
||||
const headline = screen.getByLabelText('Headline override');
|
||||
fireEvent.change(headline, { target: { value: 'Retained preview draft' } });
|
||||
const editor = screen.getByRole('region', { name: 'CV editing controls' });
|
||||
editor.scrollIntoView = jest.fn();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Go to editor' }));
|
||||
await waitFor(() => expect(editor).toHaveFocus());
|
||||
expect(headline).toHaveValue('Retained preview draft');
|
||||
expect(screen.getByText('Unsaved')).toBeInTheDocument();
|
||||
expect(mockedApi.put).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
async function reviewDocumentSuggestion(result: string) {
|
||||
routeGet(() => Promise.resolve({ data: variant }), { headline: 'Canonical headline', sections: [] });
|
||||
mockedApi.post.mockImplementation((url: string) => Promise.resolve({ data: url === '/cv/ai/assist'
|
||||
|
||||
@@ -555,6 +555,11 @@ export const translations = {
|
||||
cvEditorAiTools: "AI Tools",
|
||||
cvEditorHistory: "History",
|
||||
cvEditorLivePreview: "Live preview",
|
||||
cvEditorPreviewScale: "Fit width · {percent}%",
|
||||
cvEditorFocusEditor: "Go to editor",
|
||||
cvEditorPreviewHelp: "Use Page Up/Down to scroll. Tab moves into or out of the preview.",
|
||||
cvEditorPreviewPrevious: "Showing the last successful preview. You can continue editing; PDF export is separate.",
|
||||
cvEditorPreviewEmpty: "You can continue editing and retry the preview. PDF export is separate.",
|
||||
cvEditorUpdatingPreview: "updating…",
|
||||
cvEditorPreviewUnavailable: "Preview unavailable",
|
||||
cvEditorRetryPreview: "Retry preview",
|
||||
@@ -3109,6 +3114,11 @@ export const translations = {
|
||||
cvEditorAiTools: "AI-verktøy",
|
||||
cvEditorHistory: "Historikk",
|
||||
cvEditorLivePreview: "Direkte forhåndsvisning",
|
||||
cvEditorPreviewScale: "Tilpass bredden · {percent} %",
|
||||
cvEditorFocusEditor: "Gå til redigering",
|
||||
cvEditorPreviewHelp: "Bruk Page Up/Down for å rulle. Tab flytter fokus inn i eller ut av forhåndsvisningen.",
|
||||
cvEditorPreviewPrevious: "Viser siste vellykkede forhåndsvisning. Du kan fortsette å redigere; PDF-eksport er separat.",
|
||||
cvEditorPreviewEmpty: "Du kan fortsette å redigere og prøve forhåndsvisningen på nytt. PDF-eksport er separat.",
|
||||
cvEditorUpdatingPreview: "oppdaterer…",
|
||||
cvEditorPreviewUnavailable: "Forhåndsvisning utilgjengelig",
|
||||
cvEditorRetryPreview: "Prøv forhåndsvisning på nytt",
|
||||
|
||||
@@ -64,6 +64,7 @@ export default function CvBuilderEditor() {
|
||||
const [actionsAnchor, setActionsAnchor] = useState<HTMLElement | null>(null);
|
||||
const [tab, setTab] = useState(1);
|
||||
const [html, setHtml] = useState("");
|
||||
const [measuredHtml, setMeasuredHtml] = useState<string | null>(null);
|
||||
const [zoom, setZoom] = useState(0.62);
|
||||
const [previewing, setPreviewing] = useState(false);
|
||||
const [previewError, setPreviewError] = useState(false);
|
||||
@@ -90,10 +91,14 @@ export default function CvBuilderEditor() {
|
||||
const previewRequest = useRef(0);
|
||||
const iframeRef = useRef<HTMLIFrameElement | null>(null);
|
||||
const scrollRef = useRef<HTMLDivElement | null>(null);
|
||||
const editorRef = useRef<HTMLDivElement | null>(null);
|
||||
const blockerPromptOpen = useRef(false);
|
||||
const undoStack = useRef<CvVariantSettings[]>([]);
|
||||
const redoStack = useRef<CvVariantSettings[]>([]);
|
||||
const pageMetrics = useMemo(() => getCvPageMetrics(settings?.pageSize), [settings?.pageSize]);
|
||||
const editorReady = settings !== null;
|
||||
const previewBusy = previewing || (!previewError && (!html || measuredHtml !== html));
|
||||
const previewReady = !!html && measuredHtml === html && !previewBusy && !previewError;
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
@@ -411,6 +416,7 @@ export default function CvBuilderEditor() {
|
||||
try {
|
||||
setPages(Number(doc?.documentElement.dataset.cvPreviewPages) || 1);
|
||||
setPreviewOverflow(doc?.documentElement.dataset.cvPreviewOverflow === "true");
|
||||
setMeasuredHtml(html);
|
||||
} catch {
|
||||
setPages(1);
|
||||
setPreviewOverflow(false);
|
||||
@@ -436,7 +442,7 @@ export default function CvBuilderEditor() {
|
||||
window.removeEventListener("resize", resize);
|
||||
observer?.disconnect();
|
||||
};
|
||||
}, [compactEditor, fitPreview, mobilePane]);
|
||||
}, [compactEditor, editorReady, fitPreview, mobilePane]);
|
||||
|
||||
if (loadError) {
|
||||
return (
|
||||
@@ -495,7 +501,7 @@ export default function CvBuilderEditor() {
|
||||
</Paper>
|
||||
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "minmax(0, 1fr)", md: "minmax(360px, 1fr) minmax(0, 1fr)" }, gap: 1.5, alignItems: "start" }}>
|
||||
<Paper variant="outlined" role="region" aria-label={t("cvEditorEditingRegion")} onFocusCapture={() => setMobilePane("edit")} sx={{ display: compactEditor && mobilePane !== "edit" ? "none" : "block", position: { md: "sticky" }, top: 12, minWidth: 0, maxHeight: { md: "calc(100vh - 104px)" }, overflowY: { md: "auto" }, scrollbarWidth: "none", "&::-webkit-scrollbar": { display: "none" } }}>
|
||||
<Paper ref={editorRef} tabIndex={-1} variant="outlined" role="region" aria-label={t("cvEditorEditingRegion")} onFocusCapture={() => setMobilePane("edit")} sx={{ display: compactEditor && mobilePane !== "edit" ? "none" : "block", position: { md: "sticky" }, top: 12, minWidth: 0, maxHeight: { md: "calc(100vh - 104px)" }, overflowY: { md: "auto" }, scrollbarWidth: "none", "&::-webkit-scrollbar": { display: "none" } }}>
|
||||
<Stack direction="row" justifyContent="space-between" alignItems="center" sx={{ px: 1.5, py: 0.5, borderBottom: "1px solid", borderColor: "divider" }}>
|
||||
<Typography component="h2" variant="subtitle2">{t("cvEditorEditingRegion")}</Typography>
|
||||
<Stack direction="row">
|
||||
@@ -524,23 +530,38 @@ export default function CvBuilderEditor() {
|
||||
</Box>
|
||||
</Paper>
|
||||
|
||||
<Box onFocusCapture={() => setMobilePane("preview")} sx={{ display: compactEditor && mobilePane !== "preview" ? "none" : "block", minWidth: 0 }}>
|
||||
<Stack direction="row" alignItems="center" spacing={1} sx={{ mb: 1, flexWrap: "wrap" }}>
|
||||
<Typography variant="caption" sx={{ fontWeight: 700 }}>{t("cvEditorLivePreview")}</Typography>
|
||||
{previewing && <Chip size="small" label={t("cvEditorUpdatingPreview")} variant="outlined" />}
|
||||
{previewError && <Chip size="small" label={t("cvEditorPreviewUnavailable")} color="error" variant="outlined" />}
|
||||
{previewError && <Button size="small" onClick={() => setPreviewRevision((revision) => revision + 1)}>{t("cvEditorRetryPreview")}</Button>}
|
||||
<Box sx={{ flex: 1 }} />
|
||||
<Typography variant="caption" color="text.secondary">{t("cvEditorPageCount", { count: pages })}</Typography>
|
||||
<Paper variant="outlined" onFocusCapture={() => setMobilePane("preview")} sx={{ display: compactEditor && mobilePane !== "preview" ? "none" : "block", minWidth: 0 }}>
|
||||
<Stack direction="row" alignItems="center" gap={1} sx={{ px: 1.5, py: 1, flexWrap: "wrap", borderBottom: "1px solid", borderColor: "divider" }}>
|
||||
<Box sx={{ flex: "1 1 140px", minWidth: 0 }}>
|
||||
<Typography component="h2" variant="subtitle2">{t("cvEditorLivePreview")}</Typography>
|
||||
<Typography variant="caption" color="text.secondary">{t("cvEditorPreviewScale", { percent: Math.round(zoom * 100) })}</Typography>
|
||||
</Box>
|
||||
<Typography variant="caption" color="text.secondary" role="status">
|
||||
{previewBusy ? t("cvEditorUpdatingPreview") : previewReady ? t("cvEditorPageCount", { count: pages }) : null}
|
||||
</Typography>
|
||||
<Button size="small" onClick={() => {
|
||||
setMobilePane("edit");
|
||||
requestAnimationFrame(() => {
|
||||
editorRef.current?.focus({ preventScroll: true });
|
||||
editorRef.current?.scrollIntoView({ block: "nearest", inline: "nearest" });
|
||||
});
|
||||
}}>{t("cvEditorFocusEditor")}</Button>
|
||||
</Stack>
|
||||
{previewOverflow && <Alert severity="warning" sx={{ mb: 1 }}>{t("cvEditorOverflowWarning")}</Alert>}
|
||||
{pages >= 3 && <Alert severity="info" sx={{ mb: 1 }}>{t("cvEditorLongDocument", { pages })}</Alert>}
|
||||
{previewError && !previewing && <Alert severity="error" action={<Button size="small" onClick={() => setPreviewRevision((revision) => revision + 1)}>{t("cvEditorRetryPreview")}</Button>} sx={{ m: 1, "& .MuiAlert-action": { alignSelf: "center", flexShrink: 1 } }}>
|
||||
<Typography variant="body2" fontWeight={600}>{t("cvEditorPreviewUnavailable")}</Typography>
|
||||
<Typography variant="body2">{t(html ? "cvEditorPreviewPrevious" : "cvEditorPreviewEmpty")}</Typography>
|
||||
</Alert>}
|
||||
{previewReady && previewOverflow && <Alert severity="warning" sx={{ m: 1 }}>{t("cvEditorOverflowWarning")}</Alert>}
|
||||
{previewReady && pages >= 3 && <Alert severity="info" sx={{ m: 1 }}>{t("cvEditorLongDocument", { pages })}</Alert>}
|
||||
<Typography id="cv-preview-help" variant="caption" color="text.secondary" sx={{ display: "block", px: 1.5, py: 0.75 }}>{t("cvEditorPreviewHelp")}</Typography>
|
||||
<iframe ref={iframeRef} title={t("cvEditorPreviewTitle")} srcDoc={previewDocumentHtml(html, pageMetrics.heightPx)} sandbox="allow-same-origin allow-scripts" onLoad={onIframeLoad} aria-hidden tabIndex={-1} style={{ position: "absolute", left: "-10000px", top: 0, width: `${pageMetrics.widthMm}mm`, height: `${pageMetrics.heightMm}mm`, border: "none", visibility: "hidden", pointerEvents: "none" }} />
|
||||
<Box ref={scrollRef} role="region" aria-label={t("cvEditorLivePreview")} tabIndex={0} sx={{ overflow: "auto", maxHeight: "86vh", pr: 0.5, scrollbarWidth: "none", "&::-webkit-scrollbar": { display: "none" } }}>
|
||||
<Box ref={scrollRef} role="region" aria-label={t("cvEditorLivePreview")} aria-describedby="cv-preview-help" aria-busy={previewBusy} tabIndex={0} sx={{ overflow: "auto", maxHeight: "86vh", px: 1, py: 1.5, bgcolor: "background.default", scrollbarWidth: "thin" }}>
|
||||
<Stack spacing={2} alignItems="center">
|
||||
{Array.from({ length: pages }).map((_, index) => (
|
||||
{!html && previewBusy && <Skeleton variant="rectangular" width="100%" height={320} />}
|
||||
{!!html && Array.from({ length: pages }).map((_, index) => (
|
||||
<Box key={index} data-cv-page={index + 1} sx={{ flex: "0 0 auto" }}>
|
||||
<Box sx={{ position: "relative", width: `calc(${pageMetrics.widthMm}mm * ${zoom})`, height: `calc(${pageMetrics.heightMm}mm * ${zoom})`, bgcolor: "#fff", boxShadow: "0 8px 24px rgba(15,23,42,.16)", overflow: "hidden" }}>
|
||||
<Typography aria-hidden={!previewReady} variant="caption" color="text.secondary" sx={{ display: "block", mb: 0.5, visibility: previewReady ? "visible" : "hidden" }}>{t("cvEditorPageOf", { page: index + 1, pages })}</Typography>
|
||||
<Box sx={{ position: "relative", width: `calc(${pageMetrics.widthMm}mm * ${zoom})`, height: `calc(${pageMetrics.heightMm}mm * ${zoom})`, bgcolor: "common.white", boxShadow: 1, outline: "1px solid", outlineColor: "divider", overflow: "hidden" }}>
|
||||
<iframe
|
||||
title={t("cvEditorPreviewPage", { page: index + 1 })}
|
||||
srcDoc={previewDocumentHtml(html, pageMetrics.heightPx, index)}
|
||||
@@ -552,7 +573,7 @@ export default function CvBuilderEditor() {
|
||||
))}
|
||||
</Stack>
|
||||
</Box>
|
||||
</Box>
|
||||
</Paper>
|
||||
</Box>
|
||||
</Stack>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user