mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-28 19:01:34 +00:00
327 lines
15 KiB
JavaScript
327 lines
15 KiB
JavaScript
// #53: the administrator PDF surface is lazy, modal and downloads one
|
|
// parseable A4 sheet without involving the editor runtime.
|
|
import { readFileSync } from 'node:fs';
|
|
import { getDocument } from 'pdfjs-dist/legacy/build/pdf.mjs';
|
|
import { launchColdView, checkAll, finish } from './serve.mjs';
|
|
import { fixtureWallKey } from './fixtures/wall-key.mjs';
|
|
|
|
const manifest = JSON.parse(readFileSync('dist/houseplan-assets.json', 'utf8'));
|
|
const runtimePath = manifest.lazyPdfFiles?.find((path) => /pdf-export-[^/]+\.js$/.test(path));
|
|
if (!runtimePath) throw new Error('PDF export chunk is absent from the bundle manifest');
|
|
const runtimeName = runtimePath.split('/').at(-1);
|
|
const runtimePattern = `**/${runtimeName}*`;
|
|
const NARROW_VIEWPORT = { width: 320, height: 760 };
|
|
const steppedPoly = [
|
|
[0.1, 0.1], [0.7, 0.1], [0.7, 0.3], [0.8, 0.3],
|
|
[0.8, 0.6], [0.7, 0.6], [0.7, 0.9], [0.1, 0.9],
|
|
];
|
|
const steppedSegments = steppedPoly.map((a, index) => ({
|
|
id: `pdf-step-wall-${index}`, a, b: steppedPoly[(index + 1) % steppedPoly.length], cm: 15,
|
|
}));
|
|
const steppedSpace = {
|
|
id: 'pdf-stepped-exterior', title: 'Stepped exterior', cell_cm: 5, view_box: [0, 0, 2, 2],
|
|
rooms: [{ id: 'pdf-step-room', name: '', area: null, poly: steppedPoly,
|
|
wall_ids: steppedSegments.map((wall) => wall.id) }],
|
|
walls: steppedSegments.map((wall) => ({
|
|
key: fixtureWallKey(wall.a, wall.b), a: wall.a, b: wall.b, cm: wall.cm,
|
|
})),
|
|
wall_segments: steppedSegments,
|
|
openings: [], partitions: [], wall_columns: [], decor: [], settings: {},
|
|
};
|
|
|
|
const clickPrinter = (page) => page.locator('houseplan-card').evaluate((card) => {
|
|
const root = card.shadowRoot || card.renderRoot;
|
|
[...root.querySelectorAll('button')]
|
|
.find((button) => button.getAttribute('aria-label') === card._t('title.export_pdf'))?.click();
|
|
});
|
|
|
|
const { page, browser } = await launchColdView(NARROW_VIEWPORT);
|
|
// The generic demo fixture deliberately has a fixed 780 px host so desktop
|
|
// geometry smokes do not inherit the viewport width. This smoke owns the phone
|
|
// contract, therefore make only its harness host behave like a real dashboard
|
|
// column before attributing document overflow to the PDF dialog.
|
|
await page.locator('#host').evaluate((host) => {
|
|
host.style.width = '100%';
|
|
host.style.maxWidth = '100%';
|
|
host.style.boxSizing = 'border-box';
|
|
host.style.margin = '0';
|
|
});
|
|
const requests = [];
|
|
page.on('request', (request) => requests.push(new URL(request.url()).pathname));
|
|
const initialResources = await page.evaluate(() => performance.getEntriesByType('resource')
|
|
.map((entry) => new URL(entry.name).pathname));
|
|
await clickPrinter(page);
|
|
await page.waitForFunction(() => window.__card.renderRoot.querySelector('hp-pdf-dialog'));
|
|
|
|
const dialog = await page.evaluate(async () => {
|
|
const frame = () => new Promise((resolve) => requestAnimationFrame(resolve));
|
|
const card = window.__card;
|
|
const pdf = card.renderRoot.querySelector('hp-pdf-dialog');
|
|
const root = pdf.shadowRoot || pdf.renderRoot;
|
|
const shell = root.querySelector('hp-dialog');
|
|
await shell?.updateComplete;
|
|
const native = shell?.shadowRoot?.querySelector('dialog');
|
|
const parent = pdf.parentNode;
|
|
const surface = shell?.shadowRoot?.querySelector('.surface');
|
|
const centred = () => {
|
|
const rect = surface?.getBoundingClientRect();
|
|
return !!rect
|
|
&& Math.abs((rect.left + rect.right) / 2 - innerWidth / 2) <= 1
|
|
&& Math.abs((rect.top + rect.bottom) / 2 - innerHeight / 2) <= 1;
|
|
};
|
|
const shellBeforeReconnect = native || shell?.shadowRoot?.querySelector('ha-dialog');
|
|
pdf.remove();
|
|
parent?.append(pdf);
|
|
await pdf.updateComplete;
|
|
await shell?.updateComplete;
|
|
await frame();
|
|
await frame();
|
|
const shellAfterReconnect = native || shell?.shadowRoot?.querySelector('ha-dialog');
|
|
const reconnectKeepsModalCentered = shellBeforeReconnect === shellAfterReconnect
|
|
&& (native ? native.open && native.matches(':modal') : !!shellAfterReconnect)
|
|
&& centred();
|
|
const inputs = [...root.querySelectorAll('input[type="checkbox"]')];
|
|
const defaults = {
|
|
dimensions: inputs[0]?.checked,
|
|
decor: inputs[1]?.checked,
|
|
names: inputs[2]?.checked,
|
|
backdrop: inputs[3]?.checked,
|
|
};
|
|
inputs[2]?.click(); // remember Names = false after Save
|
|
inputs[3]?.click(); // the no-asset path keeps this smoke self-contained
|
|
await pdf.updateComplete;
|
|
return {
|
|
count: inputs.length,
|
|
expectedCount: card._spaceModel().bg ? 4 : 3,
|
|
defaults,
|
|
modal: native ? native.open && native.matches(':modal') : true,
|
|
reconnectKeepsModalCentered,
|
|
editorStillAbsent: !card._editorRuntime,
|
|
};
|
|
});
|
|
|
|
const downloadPromise = page.waitForEvent('download');
|
|
await page.locator('houseplan-card').evaluate(() => {
|
|
const pdf = window.__card.renderRoot.querySelector('hp-pdf-dialog');
|
|
(pdf.shadowRoot || pdf.renderRoot).querySelector('button.primary')?.click();
|
|
});
|
|
const download = await downloadPromise;
|
|
const path = await download.path();
|
|
if (!path) throw new Error('PDF download has no temporary file');
|
|
const bytes = new Uint8Array(readFileSync(path));
|
|
const downloadedByteLength = bytes.byteLength;
|
|
const task = getDocument({ data: bytes, useWorkerFetch: false, isEvalSupported: false });
|
|
const document = await task.promise;
|
|
const pdfPage = await document.getPage(1);
|
|
const viewport = pdfPage.getViewport({ scale: 1 });
|
|
const text = (await pdfPage.getTextContent()).items.map((item) => item.str).join(' ');
|
|
|
|
const rasterLimitError = await page.evaluate(() => {
|
|
try {
|
|
window.__card._pdfRuntime.assertRasterBudget([25 * 1024 * 1024, 1]);
|
|
return '';
|
|
} catch (error) {
|
|
return error instanceof Error ? error.message : String(error);
|
|
}
|
|
});
|
|
const firstPagePdfRequests = requests.filter((requestPath) => requestPath.endsWith(`/${runtimeName}`)).length;
|
|
|
|
// #484: exercise the complete browser dialog/download/writer path with the
|
|
// stepped exterior that used to lose its right-hand chain. Unit scene tests
|
|
// protect placement internals; this smoke proves that the shipped lazy chunk
|
|
// still exports the six reconstructable axis-aligned dimensions.
|
|
await page.evaluate((rawSpace) => {
|
|
const card = window.__card;
|
|
card._pdfDialog = false;
|
|
card._serverCfg = { model_version: 10, spaces: [rawSpace], markers: [], settings: {} };
|
|
card._space = rawSpace.id;
|
|
card._layout = {};
|
|
card.requestUpdate();
|
|
}, steppedSpace);
|
|
await page.waitForFunction(() => window.__card?._spaceModel?.()?.id === 'pdf-stepped-exterior');
|
|
await clickPrinter(page);
|
|
await page.waitForFunction(() => window.__card.renderRoot.querySelector('hp-pdf-dialog'));
|
|
const steppedDownloadPromise = page.waitForEvent('download');
|
|
await page.locator('houseplan-card').evaluate(() => {
|
|
const pdf = window.__card.renderRoot.querySelector('hp-pdf-dialog');
|
|
(pdf.shadowRoot || pdf.renderRoot).querySelector('button.primary')?.click();
|
|
});
|
|
const steppedDownload = await steppedDownloadPromise;
|
|
const steppedPath = await steppedDownload.path();
|
|
if (!steppedPath) throw new Error('stepped PDF download has no temporary file');
|
|
const steppedTask = getDocument({
|
|
data: new Uint8Array(readFileSync(steppedPath)), useWorkerFetch: false, isEvalSupported: false,
|
|
});
|
|
const steppedDocument = await steppedTask.promise;
|
|
const steppedTextItems = (await (await steppedDocument.getPage(1)).getTextContent()).items
|
|
.map((item) => String(item.str || '').trim()).filter(Boolean);
|
|
const steppedDimensionLabels = steppedTextItems.filter((value) =>
|
|
/^\d+(?:[.,]\d+)?\s*(?:m|cm|ft|in)$/i.test(value));
|
|
const steppedExpectedLabels = ['1.20 m', '2.40 m', '3.60 m', '3.75 m', '7.35 m', '9.75 m'];
|
|
const steppedChainComplete = steppedDocument.numPages === 1
|
|
&& steppedExpectedLabels.every((value) => steppedDimensionLabels.includes(value));
|
|
await steppedTask.destroy();
|
|
|
|
// The contract is persistence across a real page load, not merely reopening
|
|
// the same element instance.
|
|
await page.reload();
|
|
await page.waitForFunction(() => window.__card?.renderRoot);
|
|
await page.locator('#host').evaluate((host) => {
|
|
host.style.width = '100%';
|
|
host.style.maxWidth = '100%';
|
|
host.style.boxSizing = 'border-box';
|
|
host.style.margin = '0';
|
|
});
|
|
|
|
await clickPrinter(page);
|
|
await page.waitForFunction(() => window.__card.renderRoot.querySelector('hp-pdf-dialog'));
|
|
const rememberedNames = await page.evaluate(() => {
|
|
const pdf = window.__card.renderRoot.querySelector('hp-pdf-dialog');
|
|
const inputs = (pdf.shadowRoot || pdf.renderRoot).querySelectorAll('input[type="checkbox"]');
|
|
return inputs[2]?.checked === false;
|
|
});
|
|
|
|
const narrowLocales = await page.evaluate(async () => {
|
|
const card = window.__card;
|
|
const expected = {
|
|
en: { cancel: 'Cancel', save: 'Save' },
|
|
ru: { cancel: 'Отмена', save: 'Сохранить' },
|
|
de: { cancel: 'Abbrechen', save: 'Speichern' },
|
|
fr: { cancel: 'Annuler', save: 'Enregistrer' },
|
|
};
|
|
const settle = async () => {
|
|
await card.updateComplete;
|
|
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
|
|
};
|
|
const closeDialog = async () => {
|
|
card._pdfDialog = false;
|
|
card.requestUpdate();
|
|
await settle();
|
|
};
|
|
const loadLanguage = async (language) => {
|
|
card._config = { ...(card._config || {}), language };
|
|
card.requestUpdate();
|
|
for (let attempt = 0; attempt < 100
|
|
&& card._t('btn.cancel') !== expected[language].cancel; attempt++) {
|
|
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
await settle();
|
|
}
|
|
};
|
|
const metrics = [];
|
|
for (const language of Object.keys(expected)) {
|
|
await closeDialog();
|
|
await loadLanguage(language);
|
|
card._pdfDialog = true;
|
|
card.requestUpdate();
|
|
await settle();
|
|
|
|
const pdf = card.renderRoot.querySelector('hp-pdf-dialog');
|
|
await pdf?.updateComplete;
|
|
const root = pdf?.shadowRoot || pdf?.renderRoot;
|
|
const shell = root?.querySelector('hp-dialog');
|
|
await shell?.updateComplete;
|
|
await settle();
|
|
const surface = shell?.shadowRoot?.querySelector('.surface');
|
|
const body = root?.querySelector('.body');
|
|
const footer = root?.querySelector('.row');
|
|
const buttons = [...(footer?.querySelectorAll('button') || [])];
|
|
const surfaceRect = surface?.getBoundingClientRect();
|
|
const footerRect = footer?.getBoundingClientRect();
|
|
const footerStyle = footer ? getComputedStyle(footer) : null;
|
|
const innerLeft = footerRect && footerStyle
|
|
? footerRect.left + parseFloat(footerStyle.paddingLeft) : 0;
|
|
const innerRight = footerRect && footerStyle
|
|
? footerRect.right - parseFloat(footerStyle.paddingRight) : 0;
|
|
const buttonRects = buttons.map((button) => button.getBoundingClientRect());
|
|
const firstBeforeSecond = buttons.length === 2
|
|
&& !!(buttons[0].compareDocumentPosition(buttons[1]) & Node.DOCUMENT_POSITION_FOLLOWING);
|
|
const metric = {
|
|
language,
|
|
copy: buttons.map((button) => button.textContent.trim()),
|
|
localized: buttons[0]?.textContent.trim() === expected[language].cancel
|
|
&& buttons[1]?.textContent.trim() === expected[language].save,
|
|
documentWidths: {
|
|
viewport: innerWidth,
|
|
rootClient: document.documentElement.clientWidth,
|
|
rootScroll: document.documentElement.scrollWidth,
|
|
bodyClient: document.body.clientWidth,
|
|
bodyScroll: document.body.scrollWidth,
|
|
},
|
|
documentFits: document.documentElement.scrollWidth
|
|
<= document.documentElement.clientWidth + 1
|
|
&& document.body.scrollWidth <= document.body.clientWidth + 1,
|
|
surfaceFits: !!surface && !!surfaceRect
|
|
&& surfaceRect.left >= -1 && surfaceRect.right <= innerWidth + 1
|
|
&& surface.scrollWidth <= surface.clientWidth + 1,
|
|
bodyFits: !!body && body.scrollWidth <= body.clientWidth + 1,
|
|
footerFits: !!footer && footer.scrollWidth <= footer.clientWidth + 1,
|
|
buttonsContained: !!footerRect && buttonRects.length === 2
|
|
&& buttonRects.every((rect) => rect.left >= innerLeft - 1
|
|
&& rect.right <= innerRight + 1
|
|
&& rect.top >= footerRect.top - 1 && rect.bottom <= footerRect.bottom + 1),
|
|
targetsAtLeast44: buttonRects.length === 2
|
|
&& buttonRects.every((rect) => rect.width >= 44 && rect.height >= 44),
|
|
stacked: buttonRects.length === 2 && buttonRects[1].top >= buttonRects[0].bottom - 1,
|
|
domAndFocusOrderPreserved: firstBeforeSecond
|
|
&& buttons.every((button) => button.tabIndex === 0),
|
|
cancelCloses: false,
|
|
};
|
|
buttons[0]?.click();
|
|
await settle();
|
|
metric.cancelCloses = !card.renderRoot.querySelector('hp-pdf-dialog');
|
|
metrics.push(metric);
|
|
}
|
|
await closeDialog();
|
|
return metrics;
|
|
});
|
|
|
|
const everyNarrowLocale = (key) => narrowLocales.every((metric) => metric[key] === true);
|
|
|
|
const out = {
|
|
pdfChunkAbsentBeforeIntent: !initialResources.some((path) => path.endsWith(`/${runtimeName}`)),
|
|
onePdfChunkRequest: firstPagePdfRequests === 1,
|
|
dialogHasConditionalOptions: dialog.count === dialog.expectedCount,
|
|
defaultsAndTouchModalWork: dialog.defaults.dimensions && dialog.defaults.names
|
|
&& !dialog.defaults.decor && (dialog.expectedCount === 3 || dialog.defaults.backdrop)
|
|
&& dialog.modal,
|
|
dialogRecoversAfterReconnect: dialog.reconnectKeepsModalCentered,
|
|
printerDoesNotLoadEditor: dialog.editorStillAbsent,
|
|
downloadNameIsStable: /^houseplan-.+-\d{4}-\d{2}-\d{2}\.pdf$/.test(download.suggestedFilename()),
|
|
oneA4Page: document.numPages === 1 && (
|
|
(Math.abs(viewport.width - 595.28) < 0.1 && Math.abs(viewport.height - 841.89) < 0.1)
|
|
|| (Math.abs(viewport.width - 841.89) < 0.1 && Math.abs(viewport.height - 595.28) < 0.1)
|
|
),
|
|
exportedTextIsExtractable: /Scale|Maßstab|Масштаб|Échelle/.test(text),
|
|
optionsPersistAcrossReload: rememberedNames,
|
|
narrowDialogLocalizesAllLocales: everyNarrowLocale('localized'),
|
|
narrowDialogHasNoHorizontalOverflow: everyNarrowLocale('documentFits')
|
|
&& everyNarrowLocale('surfaceFits') && everyNarrowLocale('bodyFits')
|
|
&& everyNarrowLocale('footerFits'),
|
|
narrowDialogButtonsAreContainedTargets: everyNarrowLocale('buttonsContained')
|
|
&& everyNarrowLocale('targetsAtLeast44'),
|
|
narrowDialogFooterStacksWithoutReordering: everyNarrowLocale('stacked')
|
|
&& everyNarrowLocale('domAndFocusOrderPreserved') && everyNarrowLocale('cancelCloses'),
|
|
narrowViewportExportStillWorks: downloadedByteLength > 0 && document.numPages === 1,
|
|
rasterLimitRejectsBeforePdfWrite: rasterLimitError === 'pdf.too_large',
|
|
steppedExteriorKeepsCompleteDimensionChain: steppedChainComplete,
|
|
};
|
|
await task.destroy();
|
|
|
|
const failed = await launchColdView();
|
|
let failedRequests = 0;
|
|
await failed.page.route(runtimePattern, async (route) => {
|
|
failedRequests += 1;
|
|
await route.abort('failed');
|
|
});
|
|
await clickPrinter(failed.page);
|
|
await failed.page.waitForFunction(() => window.__card._pdfRuntimeLoader.state === 'idle'
|
|
&& window.__card._toast);
|
|
out.failedChunkRetriesOnceAndKeepsView = failedRequests === 2
|
|
&& await failed.page.evaluate(() => !window.__card._pdfRuntime
|
|
&& !window.__card.renderRoot.querySelector('hp-pdf-dialog')
|
|
&& window.__card._mode === 'view');
|
|
|
|
await failed.browser.close();
|
|
checkAll(out);
|
|
await finish(browser, { ...out, narrowLocales });
|