mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-01 12:18:51 +00:00
Аудит 14.09, §4 и §9. `golden-report.json` не нёс платформу вовсе, поэтому приёмщик вызывал `captureEnvironment()` у себя и записывал СВОЮ платформу как платформу кадров. На `ad4000f9` это дало `"platform": "win32"` у кадров, снятых Linux-прогоном 34853080375, а причина осознанного обхода осталась в stdout и в индекс не попала — хотя `AGENTS.md` обещает след в обоих местах. Сами PNG были и остаются целы: 101 свидетель совпал байт-в-байт. Врал провенанс. Что сделано: - отчёт съёмки получил раздел `capture`: платформа, архитектура, сборка Chromium, отпечаток материала и — в CI — прогон с попыткой и SHA. Схема отчёта поднята до 2; - приёмка читает среду съёмки из отчёта. Гейт чужой среды теперь судит обе стороны: съёмку (из отчёта) и приёмку (свою). Отказ — до единой записи; - индекс эталонов поднят до схемы 2 и различает `capturedOn` и `acceptedOn`, несёт раздел `capture` и причину осознанного обхода в `foreignCapture`; - отчёт схемы 1 платформы съёмки не несёт физически: это отдельная явная ветка, `capturedOn` уезжает `null`. Выдумывать платформу нельзя — ровно этим задача и вызвана; - `--baselines=<dir>` у приёмки: без него проверить «отказ произошёл ДО записи» можно было бы только порчей рабочего дерева, то есть никак (#556). Осознанно отменено решение #455 «не трогать run.mjs»: ради ГЕЙТА цена фингерпринта не окупалась, ради ПРОВЕНАНСА окупилась — платформу кадров знает только тот, кто их снял. Плата разовая: пересобран бандл, индекс скриншотов документации переснят отдельным коммитом. Тест #455 переписан под новый инвариант, а не удалён. Свидетели: `test/golden-capture-provenance.test.mjs` — семь проверок, все пять сценариев приёмки из issue, включая «отказ до записи» и «подмена PNG и неполный артефакт по-прежнему fail-closed». Мутанты `golden-index-invents-capture-platform` (возвращает платформу приёмщика) и `golden-report-provenance-optional` (разрешает отчёт схемы 2 без провенанса) прогнаны лично: оба краснеют. npm test 2762/2761/0 fail, typecheck чистый. Issue: #571 User-Visible: no
1228 lines
60 KiB
JavaScript
1228 lines
60 KiB
JavaScript
#!/usr/bin/env node
|
|
import { createHash } from 'node:crypto';
|
|
import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';
|
|
import { dirname, resolve } from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
import { launch } from '../serve.mjs';
|
|
import { assertFreshDemoBundle } from '../bundle-freshness.mjs';
|
|
import { goldenClip, prepareGoldenScenario } from './harness.mjs';
|
|
import { GOLDEN_MATRIX_VERSION, GOLDEN_SCENARIOS } from './matrix.mjs';
|
|
import { CAPTURE_PROVENANCE_SCHEMA, captureProvenance } from '../../scripts/capture-environment.mjs';
|
|
import {
|
|
assertGoldenInvocation,
|
|
GOLDEN_BASELINE_MANIFEST,
|
|
goldenRunFailed,
|
|
goldenScenarioSetsMatch,
|
|
} from './policy.mjs';
|
|
|
|
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '../..');
|
|
const mode = process.argv.find((arg) => arg.startsWith('--mode='))?.slice(7) || 'capture';
|
|
const scenarioFilter = process.argv.find((arg) => arg.startsWith('--scenario='))?.slice(11) || '';
|
|
assertGoldenInvocation(mode, scenarioFilter);
|
|
const scenarios = scenarioFilter
|
|
? GOLDEN_SCENARIOS.filter((scenario) => scenario.id === scenarioFilter)
|
|
: GOLDEN_SCENARIOS;
|
|
if (!scenarios.length) throw new Error(`unknown golden scenario: ${scenarioFilter}`);
|
|
|
|
const artifactRoot = resolve(ROOT, 'artifacts/golden');
|
|
const actualRoot = resolve(artifactRoot, 'actual');
|
|
const diffRoot = resolve(artifactRoot, 'diff');
|
|
const baselineRoot = resolve(ROOT, 'demo/golden/baselines');
|
|
mkdirSync(actualRoot, { recursive: true });
|
|
mkdirSync(diffRoot, { recursive: true });
|
|
|
|
const sha256 = (buffer) => createHash('sha256').update(buffer).digest('hex');
|
|
|
|
async function renderPdfGoldenPage(page, semanticContract = null) {
|
|
const downloadPromise = page.waitForEvent('download');
|
|
await page.evaluate(async () => {
|
|
const card = window.__goldenCard;
|
|
if (!card || !(await card._pdfRuntimeLoader.ensure()))
|
|
throw new Error('golden PDF runtime did not load');
|
|
card._pdfDialog = true;
|
|
card.requestUpdate();
|
|
await card.updateComplete;
|
|
const dialog = card.renderRoot.querySelector('hp-pdf-dialog');
|
|
if (!dialog) throw new Error('golden PDF dialog did not open');
|
|
await dialog.updateComplete;
|
|
dialog.context.now = new Date('2026-09-07T00:00:00Z');
|
|
await dialog.context.save({
|
|
dimensions: true, roomNames: true, decor: false, backdrop: false,
|
|
});
|
|
dialog.context.close();
|
|
await card.updateComplete;
|
|
});
|
|
const download = await downloadPromise;
|
|
const path = await download.path();
|
|
if (!path) throw new Error('golden PDF download has no local path');
|
|
const pdfBase64 = readFileSync(path).toString('base64');
|
|
const rendered = await page.evaluate(async ({ base64, semanticContract }) => {
|
|
const pdfjs = await import('/__pdfjs/pdf.mjs');
|
|
pdfjs.GlobalWorkerOptions.workerSrc = '/__pdfjs/pdf.worker.mjs';
|
|
const bytes = Uint8Array.from(atob(base64), (character) => character.charCodeAt(0));
|
|
const pdfDocument = await pdfjs.getDocument({ data: bytes }).promise;
|
|
if (pdfDocument.numPages !== 1) throw new Error(`golden PDF has ${pdfDocument.numPages} pages`);
|
|
const sheet = await pdfDocument.getPage(1);
|
|
const natural = sheet.getViewport({ scale: 1 });
|
|
const scale = Math.min((innerWidth - 48) / natural.width, (innerHeight - 48) / natural.height);
|
|
const viewport = sheet.getViewport({ scale });
|
|
const overlay = document.createElement('div');
|
|
overlay.id = 'golden-pdf-page';
|
|
Object.assign(overlay.style, {
|
|
position: 'fixed', inset: '0', zIndex: '2147483647', display: 'grid', placeItems: 'center',
|
|
background: '#d9dde2',
|
|
});
|
|
const canvas = document.createElement('canvas');
|
|
canvas.width = Math.ceil(viewport.width);
|
|
canvas.height = Math.ceil(viewport.height);
|
|
Object.assign(canvas.style, {
|
|
width: `${canvas.width}px`, height: `${canvas.height}px`, background: '#fff',
|
|
boxShadow: '0 8px 28px rgb(0 0 0 / 24%)',
|
|
});
|
|
overlay.append(canvas);
|
|
document.body.append(overlay);
|
|
const context = canvas.getContext('2d');
|
|
if (!context) throw new Error('golden PDF canvas is unavailable');
|
|
await sheet.render({ canvasContext: context, viewport }).promise;
|
|
const textContent = await sheet.getTextContent();
|
|
const textItems = textContent.items
|
|
.filter((item) => typeof item.str === 'string' && item.str.trim())
|
|
.map((item) => ({ str: item.str.trim(), transform: [...item.transform] }));
|
|
|
|
let semantic = null;
|
|
if (semanticContract) {
|
|
const image = context.getImageData(0, 0, canvas.width, canvas.height);
|
|
const pixels = image.data;
|
|
const pointPerMm = 72 / 25.4;
|
|
const pxPerPointX = canvas.width / natural.width;
|
|
const pxPerPointY = canvas.height / natural.height;
|
|
const pxPerMmX = pxPerPointX * pointPerMm;
|
|
const pxPerMmY = pxPerPointY * pointPerMm;
|
|
const field = {
|
|
left: Math.floor(12 * pxPerMmX),
|
|
top: Math.floor(30 * pxPerMmY),
|
|
right: Math.ceil(canvas.width - 12 * pxPerMmX),
|
|
bottom: Math.ceil(canvas.height - 36 * pxPerMmY),
|
|
};
|
|
const clampX = (value) => Math.max(0, Math.min(canvas.width - 1, value));
|
|
const clampY = (value) => Math.max(0, Math.min(canvas.height - 1, value));
|
|
const at = (x, y) => {
|
|
const offset = (clampY(y) * canvas.width + clampX(x)) * 4;
|
|
return [pixels[offset], pixels[offset + 1], pixels[offset + 2], pixels[offset + 3]];
|
|
};
|
|
const wallGray = ([red, green, blue, alpha]) => alpha > 0
|
|
&& red >= 126 && red <= 128
|
|
&& Math.max(Math.abs(red - green), Math.abs(red - blue), Math.abs(green - blue)) <= 1;
|
|
const wallGrayAt = (x, y) => x >= 0 && x < canvas.width && y >= 0 && y < canvas.height
|
|
&& wallGray(at(x, y));
|
|
// Anti-aliased black text also crosses 127 on isolated edge pixels. A
|
|
// two-sided same-colour neighbour distinguishes actual wall fill from
|
|
// those glyph fringes before we reason about the plan-field boundary.
|
|
const wallGrayCoreAt = (x, y) => wallGrayAt(x, y) && (
|
|
(wallGrayAt(x - 1, y) && wallGrayAt(x + 1, y))
|
|
|| (wallGrayAt(x, y - 1) && wallGrayAt(x, y + 1))
|
|
);
|
|
const darkInk = ([red, green, blue, alpha]) => alpha > 0
|
|
&& red < 100 && green < 100 && blue < 100;
|
|
const sceneInk = ([red, green, blue, alpha]) => alpha > 0
|
|
&& (red < 242 || green < 242 || blue < 242);
|
|
const white = ([red, green, blue, alpha]) => alpha > 0
|
|
&& red >= 245 && green >= 245 && blue >= 245;
|
|
const emptyBounds = () => ({ minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity });
|
|
const include = (bounds, x, y) => {
|
|
bounds.minX = Math.min(bounds.minX, x);
|
|
bounds.minY = Math.min(bounds.minY, y);
|
|
bounds.maxX = Math.max(bounds.maxX, x);
|
|
bounds.maxY = Math.max(bounds.maxY, y);
|
|
};
|
|
const validBounds = (bounds) => Number.isFinite(bounds.minX)
|
|
&& Number.isFinite(bounds.minY) && bounds.maxX >= bounds.minX && bounds.maxY >= bounds.minY;
|
|
|
|
let sceneInkPixels = 0;
|
|
let wallGrayPixels = 0;
|
|
let wallGrayOutsideFieldPixels = 0;
|
|
const sceneBounds = emptyBounds();
|
|
const grayBounds = emptyBounds();
|
|
for (let y = 0; y < canvas.height; y++) for (let x = 0; x < canvas.width; x++) {
|
|
const sample = at(x, y);
|
|
const insideField = x >= field.left && x < field.right && y >= field.top && y < field.bottom;
|
|
if (insideField && sceneInk(sample)) {
|
|
sceneInkPixels++;
|
|
include(sceneBounds, x, y);
|
|
}
|
|
if (wallGrayCoreAt(x, y)) {
|
|
if (insideField) {
|
|
wallGrayPixels++;
|
|
include(grayBounds, x, y);
|
|
} else wallGrayOutsideFieldPixels++;
|
|
}
|
|
}
|
|
|
|
let interiorHatchPixels = 0;
|
|
if (validBounds(grayBounds)) {
|
|
const oppositeOffsets = [[2, 0], [0, 2], [2, 2], [2, -2]];
|
|
for (let y = grayBounds.minY; y <= grayBounds.maxY; y++) {
|
|
for (let x = grayBounds.minX; x <= grayBounds.maxX; x++) {
|
|
if (!darkInk(at(x, y))) continue;
|
|
if (oppositeOffsets.some(([dx, dy]) =>
|
|
wallGrayAt(x - dx, y - dy) && wallGrayAt(x + dx, y + dy))) {
|
|
interiorHatchPixels++;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
let opening = null;
|
|
if (validBounds(grayBounds) && semanticContract.opening) {
|
|
const width = grayBounds.maxX - grayBounds.minX + 1;
|
|
const height = grayBounds.maxY - grayBounds.minY + 1;
|
|
const bandBottom = Math.min(grayBounds.maxY,
|
|
grayBounds.minY + Math.max(5, Math.round(height * 0.08)));
|
|
let wallRow = grayBounds.minY;
|
|
let rowGray = -1;
|
|
for (let y = grayBounds.minY; y <= bandBottom; y++) {
|
|
let count = 0;
|
|
for (let x = grayBounds.minX; x <= grayBounds.maxX; x++) {
|
|
if (wallGrayAt(x, y)) count++;
|
|
}
|
|
if (count > rowGray) { rowGray = count; wallRow = y; }
|
|
}
|
|
const halfWidth = Math.max(2,
|
|
Math.round(width * semanticContract.opening.halfWidthFraction));
|
|
const samplePatch = (centerX) => {
|
|
let gray = 0, pale = 0, total = 0;
|
|
for (let y = wallRow - 2; y <= wallRow + 2; y++) {
|
|
for (let x = Math.round(centerX) - halfWidth;
|
|
x <= Math.round(centerX) + halfWidth; x++) {
|
|
const sample = at(x, y);
|
|
if (wallGray(sample)) gray++;
|
|
if (white(sample)) pale++;
|
|
total++;
|
|
}
|
|
}
|
|
return { grayFraction: gray / total, whiteFraction: pale / total };
|
|
};
|
|
const centerX = grayBounds.minX + width * semanticContract.opening.xFraction;
|
|
const offset = width * semanticContract.opening.controlOffsetFraction;
|
|
const openingPatch = samplePatch(centerX);
|
|
const leftControl = samplePatch(centerX - offset);
|
|
const rightControl = samplePatch(centerX + offset);
|
|
const controlGrayFraction = (leftControl.grayFraction + rightControl.grayFraction) / 2;
|
|
opening = {
|
|
wallRow, centerX, ...openingPatch, controlGrayFraction,
|
|
grayRatio: controlGrayFraction > 0
|
|
? openingPatch.grayFraction / controlGrayFraction : Infinity,
|
|
};
|
|
}
|
|
|
|
const footerY = natural.height - 25 * pointPerMm;
|
|
const compassRegion = {
|
|
left: Math.floor((natural.width / 2 - 8 * pointPerMm) * pxPerPointX),
|
|
right: Math.ceil((natural.width / 2 + 8 * pointPerMm) * pxPerPointX),
|
|
top: Math.floor((footerY + pointPerMm) * pxPerPointY),
|
|
bottom: Math.ceil((footerY + 14 * pointPerMm) * pxPerPointY),
|
|
};
|
|
let compassInkPixels = 0;
|
|
for (let y = compassRegion.top; y < compassRegion.bottom; y++) {
|
|
for (let x = compassRegion.left; x < compassRegion.right; x++) {
|
|
if (darkInk(at(x, y))) compassInkPixels++;
|
|
}
|
|
}
|
|
|
|
const dimensionPattern = /^\d+(?:[.,]\d+)?\s*(?:m|cm|ft|in)$/i;
|
|
const dimensions = textItems.filter((item) => dimensionPattern.test(item.str));
|
|
const axisError = (transform) => {
|
|
const degrees = Math.atan2(Number(transform[1]), Number(transform[0])) * 180 / Math.PI;
|
|
const remainder = ((degrees % 90) + 90) % 90;
|
|
return Math.min(remainder, 90 - remainder);
|
|
};
|
|
const dimensionCounts = new Map();
|
|
for (const item of dimensions) {
|
|
dimensionCounts.set(item.str, (dimensionCounts.get(item.str) || 0) + 1);
|
|
}
|
|
const sceneWidth = validBounds(sceneBounds) ? sceneBounds.maxX - sceneBounds.minX + 1 : 0;
|
|
const sceneHeight = validBounds(sceneBounds) ? sceneBounds.maxY - sceneBounds.minY + 1 : 0;
|
|
const fieldWidth = field.right - field.left;
|
|
const fieldHeight = field.bottom - field.top;
|
|
semantic = {
|
|
text: textItems.map((item) => item.str).join(' '),
|
|
fieldPixels: field,
|
|
sceneBoundsPixels: sceneBounds,
|
|
wallBoundsPixels: grayBounds,
|
|
compassRegionPixels: compassRegion,
|
|
sceneInkPixels,
|
|
sceneCoverage: fieldWidth > 0 && fieldHeight > 0
|
|
? sceneWidth * sceneHeight / (fieldWidth * fieldHeight) : 0,
|
|
centerOffsetMm: validBounds(sceneBounds) ? Math.max(
|
|
Math.abs((sceneBounds.minX + sceneBounds.maxX - field.left - field.right) / 2 / pxPerMmX),
|
|
Math.abs((sceneBounds.minY + sceneBounds.maxY - field.top - field.bottom) / 2 / pxPerMmY),
|
|
) : Infinity,
|
|
sceneClearanceMm: validBounds(sceneBounds) ? Math.min(
|
|
(sceneBounds.minX - field.left) / pxPerMmX,
|
|
(field.right - 1 - sceneBounds.maxX) / pxPerMmX,
|
|
(sceneBounds.minY - field.top) / pxPerMmY,
|
|
(field.bottom - 1 - sceneBounds.maxY) / pxPerMmY,
|
|
) : -Infinity,
|
|
wallGrayPixels,
|
|
wallGrayOutsideFieldPixels,
|
|
wallGrayOutsideFieldRatio: wallGrayPixels > 0
|
|
? wallGrayOutsideFieldPixels / wallGrayPixels : Infinity,
|
|
interiorHatchPixels,
|
|
opening,
|
|
compassInkPixels,
|
|
dimensionLabels: dimensions.map((item) => item.str),
|
|
maxDimensionAxisErrorDeg: dimensions.length
|
|
? Math.max(...dimensions.map((item) => axisError(item.transform))) : Infinity,
|
|
repeatedDimensionLabels: [...dimensionCounts.values()].filter((count) => count >= 2).length,
|
|
};
|
|
}
|
|
return {
|
|
pages: pdfDocument.numPages, width: natural.width, height: natural.height, semantic,
|
|
};
|
|
}, { base64: pdfBase64, semanticContract });
|
|
const a4 = [rendered.width, rendered.height].sort((a, b) => a - b);
|
|
if (Math.abs(a4[0] - 595.28) > 0.2 || Math.abs(a4[1] - 841.89) > 0.2)
|
|
throw new Error(`golden PDF is not A4: ${rendered.width}x${rendered.height}`);
|
|
if (semanticContract) {
|
|
const semantic = rendered.semantic;
|
|
const fail = (message) => {
|
|
throw new Error(`semantic golden PDF assertion failed: ${message}; ${JSON.stringify(semantic)}`);
|
|
};
|
|
if (!semantic || semantic.sceneInkPixels < semanticContract.minSceneInkPixels)
|
|
fail(`architectural ink is below ${semanticContract.minSceneInkPixels} pixels`);
|
|
if (semantic.sceneCoverage < semanticContract.minSceneCoverage)
|
|
fail(`scene coverage is below ${semanticContract.minSceneCoverage}`);
|
|
if (semantic.centerOffsetMm > semanticContract.maxCenterOffsetMm)
|
|
fail(`scene centre is offset by more than ${semanticContract.maxCenterOffsetMm} mm`);
|
|
if (semantic.sceneClearanceMm < semanticContract.minSceneClearanceMm)
|
|
fail(`scene clearance is below ${semanticContract.minSceneClearanceMm} mm`);
|
|
if (semantic.wallGrayPixels < semanticContract.minWallGrayPixels)
|
|
fail(`wall-grey area is below ${semanticContract.minWallGrayPixels} pixels`);
|
|
if (semantic.wallGrayOutsideFieldRatio > semanticContract.maxWallGrayOutsideFieldRatio)
|
|
fail('wall material escaped the plan field');
|
|
if (semantic.interiorHatchPixels < semanticContract.minInteriorHatchPixels)
|
|
fail(`interior hatch is below ${semanticContract.minInteriorHatchPixels} pixels`);
|
|
if (semanticContract.opening && (!semantic.opening
|
|
|| semantic.opening.grayRatio > semanticContract.opening.maxGrayRatio
|
|
|| semantic.opening.whiteFraction < semanticContract.opening.minWhiteFraction))
|
|
fail('the exterior opening is not clean against its wall controls');
|
|
if (semantic.compassInkPixels < semanticContract.minCompassInkPixels)
|
|
fail(`compass ink is below ${semanticContract.minCompassInkPixels} pixels`);
|
|
if (semantic.maxDimensionAxisErrorDeg > semanticContract.maxDimensionAxisErrorDeg)
|
|
fail(`a dimension label is not horizontal or vertical`);
|
|
if (semantic.repeatedDimensionLabels < semanticContract.minRepeatedDimensionLabels)
|
|
fail('equal dimensions from distinct local contexts were globally deduplicated');
|
|
if (semanticContract.requiredDimensionLabels
|
|
&& semanticContract.requiredDimensionLabels.some((value) =>
|
|
!semantic.dimensionLabels.includes(value))) {
|
|
fail(`dimension labels omit the required reconstructable chain: `
|
|
+ semanticContract.requiredDimensionLabels.join(', '));
|
|
}
|
|
const normalizedText = semantic.text.toLowerCase();
|
|
const forbidden = semanticContract.forbiddenText.find((token) =>
|
|
normalizedText.includes(String(token).toLowerCase()));
|
|
if (forbidden) fail(`forbidden text is present: ${forbidden}`);
|
|
}
|
|
return rendered;
|
|
}
|
|
|
|
async function comparePng(page, actual, baseline, threshold) {
|
|
return page.evaluate(async ({ actual64, baseline64, threshold }) => {
|
|
const decode = async (base64) => {
|
|
const bytes = Uint8Array.from(atob(base64), (char) => char.charCodeAt(0));
|
|
return createImageBitmap(new Blob([bytes], { type: 'image/png' }));
|
|
};
|
|
const [actualImage, baselineImage] = await Promise.all([decode(actual64), decode(baseline64)]);
|
|
if (actualImage.width !== baselineImage.width || actualImage.height !== baselineImage.height) {
|
|
return {
|
|
dimensionsMatch: false,
|
|
actualSize: [actualImage.width, actualImage.height],
|
|
baselineSize: [baselineImage.width, baselineImage.height],
|
|
differingPixels: null,
|
|
diffRatio: 1,
|
|
maxObservedDelta: 255,
|
|
passed: false,
|
|
diffPngBase64: null,
|
|
};
|
|
}
|
|
const width = actualImage.width;
|
|
const height = actualImage.height;
|
|
const canvas = document.createElement('canvas');
|
|
const baselineCanvas = document.createElement('canvas');
|
|
const diffCanvas = document.createElement('canvas');
|
|
for (const item of [canvas, baselineCanvas, diffCanvas]) {
|
|
item.width = width;
|
|
item.height = height;
|
|
}
|
|
const context = canvas.getContext('2d', { willReadFrequently: true });
|
|
const baselineContext = baselineCanvas.getContext('2d', { willReadFrequently: true });
|
|
const diffContext = diffCanvas.getContext('2d');
|
|
context.drawImage(actualImage, 0, 0);
|
|
baselineContext.drawImage(baselineImage, 0, 0);
|
|
const actualData = context.getImageData(0, 0, width, height).data;
|
|
const baselineData = baselineContext.getImageData(0, 0, width, height).data;
|
|
const diff = diffContext.createImageData(width, height);
|
|
let differingPixels = 0;
|
|
let maxObservedDelta = 0;
|
|
for (let offset = 0; offset < actualData.length; offset += 4) {
|
|
let delta = 0;
|
|
for (let channel = 0; channel < 4; channel++)
|
|
delta = Math.max(delta, Math.abs(actualData[offset + channel] - baselineData[offset + channel]));
|
|
maxObservedDelta = Math.max(maxObservedDelta, delta);
|
|
if (delta > threshold.maxChannelDelta) {
|
|
differingPixels++;
|
|
diff.data[offset] = 255;
|
|
diff.data[offset + 1] = 0;
|
|
diff.data[offset + 2] = 180;
|
|
diff.data[offset + 3] = 255;
|
|
} else {
|
|
const gray = Math.round((actualData[offset] + actualData[offset + 1] + actualData[offset + 2]) / 9);
|
|
diff.data[offset] = gray;
|
|
diff.data[offset + 1] = gray;
|
|
diff.data[offset + 2] = gray;
|
|
diff.data[offset + 3] = 90;
|
|
}
|
|
}
|
|
diffContext.putImageData(diff, 0, 0);
|
|
const diffRatio = differingPixels / (width * height);
|
|
return {
|
|
dimensionsMatch: true,
|
|
actualSize: [width, height],
|
|
baselineSize: [width, height],
|
|
differingPixels,
|
|
diffRatio,
|
|
maxObservedDelta,
|
|
passed: diffRatio <= threshold.maxDiffRatio,
|
|
diffPngBase64: differingPixels
|
|
? diffCanvas.toDataURL('image/png').slice('data:image/png;base64,'.length)
|
|
: null,
|
|
};
|
|
}, {
|
|
actual64: actual.toString('base64'),
|
|
baseline64: baseline.toString('base64'),
|
|
threshold,
|
|
});
|
|
}
|
|
|
|
/** Capture the identical visual frame with only the rendered sun-ray SVG
|
|
* hidden. Comparing this control frame with the reviewed golden proves that
|
|
* the layer changes real browser pixels, not merely that its DOM exists. */
|
|
async function captureWithoutSunRays(page, screenshotOptions) {
|
|
const layerState = await page.evaluate(async () => {
|
|
const layer = window.__goldenCard?.renderRoot?.querySelector('.sunlayer');
|
|
if (!layer) throw new Error('semantic golden sun layer is missing');
|
|
const shapes = layer.querySelectorAll('path, polygon').length;
|
|
if (!shapes) throw new Error('semantic golden sun layer has no painted shapes');
|
|
const previous = layer.style.visibility;
|
|
layer.style.visibility = 'hidden';
|
|
await new Promise((done) => requestAnimationFrame(() => requestAnimationFrame(done)));
|
|
return { previous, shapes };
|
|
});
|
|
try {
|
|
return { png: await page.screenshot(screenshotOptions), shapes: layerState.shapes };
|
|
} finally {
|
|
await page.evaluate(async (previous) => {
|
|
const layer = window.__goldenCard?.renderRoot?.querySelector('.sunlayer');
|
|
if (layer) layer.style.visibility = previous;
|
|
await new Promise((done) => requestAnimationFrame(() => requestAnimationFrame(done)));
|
|
}, layerState.previous);
|
|
}
|
|
}
|
|
|
|
/** Capture a control frame with only the transient opening symbol hidden.
|
|
* Comparing it with the actual frame proves the preview paints browser pixels;
|
|
* the smoke test separately locks its DOM order above the wall body. */
|
|
async function captureWithoutOpeningPreview(page, screenshotOptions) {
|
|
const layerState = await page.evaluate(async () => {
|
|
const card = window.__goldenCard;
|
|
const parts = [...(card?.renderRoot?.querySelectorAll(
|
|
'.opening-preview, .opening-preview-dot',
|
|
) || [])];
|
|
if (!parts.length) throw new Error('semantic golden opening preview is missing');
|
|
const previous = parts.map((part) => part.style.visibility);
|
|
for (const part of parts) part.style.visibility = 'hidden';
|
|
await new Promise((done) => requestAnimationFrame(() => requestAnimationFrame(done)));
|
|
return { previous, count: parts.length };
|
|
});
|
|
try {
|
|
return { png: await page.screenshot(screenshotOptions), parts: layerState.count };
|
|
} finally {
|
|
await page.evaluate(async (previous) => {
|
|
const card = window.__goldenCard;
|
|
const parts = [...(card?.renderRoot?.querySelectorAll(
|
|
'.opening-preview, .opening-preview-dot',
|
|
) || [])];
|
|
parts.forEach((part, index) => { part.style.visibility = previous[index] || ''; });
|
|
await new Promise((done) => requestAnimationFrame(() => requestAnimationFrame(done)));
|
|
}, layerState.previous);
|
|
}
|
|
}
|
|
|
|
async function countChangedPixels(page, actual, control, spec) {
|
|
return page.evaluate(async ({ actual64, control64, minChannelDelta }) => {
|
|
const decode = async (base64) => {
|
|
const bytes = Uint8Array.from(atob(base64), (char) => char.charCodeAt(0));
|
|
return createImageBitmap(new Blob([bytes], { type: 'image/png' }));
|
|
};
|
|
const [actualImage, controlImage] = await Promise.all([decode(actual64), decode(control64)]);
|
|
if (actualImage.width !== controlImage.width || actualImage.height !== controlImage.height)
|
|
throw new Error('semantic golden control frame has different dimensions');
|
|
const canvas = document.createElement('canvas');
|
|
const controlCanvas = document.createElement('canvas');
|
|
canvas.width = controlCanvas.width = actualImage.width;
|
|
canvas.height = controlCanvas.height = actualImage.height;
|
|
const context = canvas.getContext('2d', { willReadFrequently: true });
|
|
const controlContext = controlCanvas.getContext('2d', { willReadFrequently: true });
|
|
context.drawImage(actualImage, 0, 0);
|
|
controlContext.drawImage(controlImage, 0, 0);
|
|
const pixels = context.getImageData(0, 0, canvas.width, canvas.height).data;
|
|
const controlPixels = controlContext.getImageData(0, 0, canvas.width, canvas.height).data;
|
|
let changed = 0;
|
|
let maxDelta = 0;
|
|
for (let offset = 0; offset < pixels.length; offset += 4) {
|
|
const delta = Math.max(
|
|
Math.abs(pixels[offset] - controlPixels[offset]),
|
|
Math.abs(pixels[offset + 1] - controlPixels[offset + 1]),
|
|
Math.abs(pixels[offset + 2] - controlPixels[offset + 2]),
|
|
);
|
|
maxDelta = Math.max(maxDelta, delta);
|
|
if (delta >= minChannelDelta) changed++;
|
|
}
|
|
return { changed, maxDelta, size: [canvas.width, canvas.height] };
|
|
}, {
|
|
actual64: actual.toString('base64'),
|
|
control64: control.toString('base64'),
|
|
minChannelDelta: spec.minChannelDelta,
|
|
});
|
|
}
|
|
|
|
/** Count preview pixels which are actually painted over the physical wall
|
|
* fill. A global changed-pixel threshold can pass even when the complete
|
|
* symbol is accidentally hidden below masonry because its swing arc remains
|
|
* outside the body. */
|
|
async function countOpeningPreviewPixelsInsideWall(
|
|
page, actual, control, clip, minChannelDelta,
|
|
) {
|
|
return page.evaluate(async ({ actual64, control64, clipRect, minDelta }) => {
|
|
const decode = async (base64) => {
|
|
const bytes = Uint8Array.from(atob(base64), (char) => char.charCodeAt(0));
|
|
return createImageBitmap(new Blob([bytes], { type: 'image/png' }));
|
|
};
|
|
const [actualImage, controlImage] = await Promise.all([decode(actual64), decode(control64)]);
|
|
const canvas = document.createElement('canvas');
|
|
const controlCanvas = document.createElement('canvas');
|
|
canvas.width = controlCanvas.width = actualImage.width;
|
|
canvas.height = controlCanvas.height = actualImage.height;
|
|
const context = canvas.getContext('2d', { willReadFrequently: true });
|
|
const controlContext = controlCanvas.getContext('2d', { willReadFrequently: true });
|
|
context.drawImage(actualImage, 0, 0);
|
|
controlContext.drawImage(controlImage, 0, 0);
|
|
const pixels = context.getImageData(0, 0, canvas.width, canvas.height).data;
|
|
const controls = controlContext.getImageData(0, 0, canvas.width, canvas.height).data;
|
|
const card = window.__goldenCard;
|
|
const preview = card?.renderRoot?.querySelector('.opening-preview');
|
|
const walls = [...(card?.renderRoot?.querySelectorAll('.wallbody-fill') || [])]
|
|
.map((wall) => {
|
|
const matrix = wall.getScreenCTM?.();
|
|
return matrix ? { wall, rect: wall.getBoundingClientRect(), inverse: matrix.inverse() } : null;
|
|
})
|
|
.filter(Boolean);
|
|
if (!preview || !walls.length) return { changed: 0, sampled: 0 };
|
|
const previewRect = preview.getBoundingClientRect();
|
|
const candidates = walls.filter(({ rect }) => rect.right >= previewRect.left
|
|
&& rect.left <= previewRect.right && rect.bottom >= previewRect.top
|
|
&& rect.top <= previewRect.bottom);
|
|
if (!candidates.length) return { changed: 0, sampled: 0 };
|
|
const left = Math.ceil(previewRect.left);
|
|
const top = Math.ceil(previewRect.top);
|
|
const right = Math.floor(previewRect.right);
|
|
const bottom = Math.floor(previewRect.bottom);
|
|
const originX = clipRect?.x || 0;
|
|
const originY = clipRect?.y || 0;
|
|
let changed = 0;
|
|
let sampled = 0;
|
|
for (let y = top; y <= bottom; y++) {
|
|
for (let x = left; x <= right; x++) {
|
|
const screenPoint = new DOMPoint(x + 0.5, y + 0.5);
|
|
const insideWall = candidates.some(({ wall, rect, inverse }) => {
|
|
if (x < rect.left || x > rect.right || y < rect.top || y > rect.bottom) return false;
|
|
return wall.isPointInFill(screenPoint.matrixTransform(inverse));
|
|
});
|
|
if (!insideWall) continue;
|
|
const px = Math.round(x - originX);
|
|
const py = Math.round(y - originY);
|
|
if (px < 0 || py < 0 || px >= canvas.width || py >= canvas.height) continue;
|
|
sampled += 1;
|
|
const offset = (py * canvas.width + px) * 4;
|
|
const delta = Math.max(
|
|
Math.abs(pixels[offset] - controls[offset]),
|
|
Math.abs(pixels[offset + 1] - controls[offset + 1]),
|
|
Math.abs(pixels[offset + 2] - controls[offset + 2]),
|
|
Math.abs(pixels[offset + 3] - controls[offset + 3]),
|
|
);
|
|
if (delta >= minDelta) changed += 1;
|
|
}
|
|
}
|
|
return { changed, sampled };
|
|
}, {
|
|
actual64: actual.toString('base64'),
|
|
control64: control.toString('base64'),
|
|
clipRect: clip || null,
|
|
minDelta: minChannelDelta,
|
|
});
|
|
}
|
|
|
|
/** Assert scenario semantics against the actual capture, not only its data.
|
|
* This protects a reviewed-but-empty baseline from becoming the reference. */
|
|
async function countWarmPixels(page, png, region) {
|
|
return page.evaluate(async ({ png64, region }) => {
|
|
const bytes = Uint8Array.from(atob(png64), (char) => char.charCodeAt(0));
|
|
const image = await createImageBitmap(new Blob([bytes], { type: 'image/png' }));
|
|
const canvas = document.createElement('canvas');
|
|
canvas.width = image.width;
|
|
canvas.height = image.height;
|
|
const context = canvas.getContext('2d', { willReadFrequently: true });
|
|
context.drawImage(image, 0, 0);
|
|
const left = Math.max(0, Math.min(image.width, Math.floor(region.x * image.width)));
|
|
const top = Math.max(0, Math.min(image.height, Math.floor(region.y * image.height)));
|
|
const right = Math.max(left, Math.min(image.width, Math.ceil((region.x + region.w) * image.width)));
|
|
const bottom = Math.max(top, Math.min(image.height, Math.ceil((region.y + region.h) * image.height)));
|
|
const pixels = context.getImageData(left, top, right - left, bottom - top).data;
|
|
let warm = 0;
|
|
for (let offset = 0; offset < pixels.length; offset += 4) {
|
|
if (pixels[offset] - pixels[offset + 2] > region.minRedBlueDelta) warm++;
|
|
}
|
|
return { warm, bounds: [left, top, right, bottom] };
|
|
}, { png64: png.toString('base64'), region });
|
|
}
|
|
|
|
/** Semantic guard for #231. Sample named plan points from the actual browser
|
|
* capture; the decor node merely existing in DOM is insufficient when an
|
|
* opaque floor layer is painted after it. */
|
|
async function inspectDecorPixels(page, png, clip, spec) {
|
|
return page.evaluate(async ({ png64, clip, spec }) => {
|
|
const card = window.__goldenCard;
|
|
const svg = card?.renderRoot?.querySelector('.stage .zoomwrap > svg');
|
|
const decor = card?.renderRoot?.querySelector('.decorlayer');
|
|
const matrix = svg?.getScreenCTM?.();
|
|
if (!svg || !decor || !matrix) throw new Error('semantic golden decor layer is missing');
|
|
const bytes = Uint8Array.from(atob(png64), (char) => char.charCodeAt(0));
|
|
const image = await createImageBitmap(new Blob([bytes], { type: 'image/png' }));
|
|
const canvas = document.createElement('canvas');
|
|
canvas.width = image.width; canvas.height = image.height;
|
|
const context = canvas.getContext('2d', { willReadFrequently: true });
|
|
context.drawImage(image, 0, 0);
|
|
const pixels = context.getImageData(0, 0, image.width, image.height).data;
|
|
const cssWidth = clip?.width || document.documentElement.clientWidth;
|
|
const cssHeight = clip?.height || document.documentElement.clientHeight;
|
|
const scaleX = image.width / Math.max(1, cssWidth);
|
|
const scaleY = image.height / Math.max(1, cssHeight);
|
|
const originX = clip?.x || 0, originY = clip?.y || 0;
|
|
const expected = String(spec.color || '').match(/^#([0-9a-f]{6})$/i)?.[1];
|
|
if (!expected) throw new Error(`semantic golden decor color is invalid: ${spec.color}`);
|
|
const rgb = [0, 2, 4].map((offset) => parseInt(expected.slice(offset, offset + 2), 16));
|
|
const radius = Math.max(0, Math.round(spec.radius || 0));
|
|
return spec.points.map((probe) => {
|
|
const point = svg.createSVGPoint();
|
|
point.x = Number(probe.x) * 1000; point.y = Number(probe.y) * 1000;
|
|
const screen = point.matrixTransform(matrix);
|
|
const cx = Math.round((screen.x - originX) * scaleX);
|
|
const cy = Math.round((screen.y - originY) * scaleY);
|
|
let matching = 0, sampled = 0;
|
|
for (let y = cy - radius; y <= cy + radius; y++) {
|
|
for (let x = cx - radius; x <= cx + radius; x++) {
|
|
if (x < 0 || y < 0 || x >= image.width || y >= image.height) continue;
|
|
const offset = (y * image.width + x) * 4;
|
|
const distance = Math.max(
|
|
Math.abs(pixels[offset] - rgb[0]),
|
|
Math.abs(pixels[offset + 1] - rgb[1]),
|
|
Math.abs(pixels[offset + 2] - rgb[2]),
|
|
);
|
|
if (pixels[offset + 3] > 240 && distance <= 48) matching++;
|
|
sampled++;
|
|
}
|
|
}
|
|
return {
|
|
id: probe.id, matching, sampled,
|
|
fraction: sampled ? matching / sampled : 0,
|
|
pixel: [cx, cy],
|
|
};
|
|
});
|
|
}, { png64: png.toString('base64'), clip, spec });
|
|
}
|
|
|
|
/** Semantic guard for issue #68: the reviewed bubble must contain rendered
|
|
* glyph pixels, not just an empty surface or a stale open-state flag. */
|
|
async function countHelpTextPixels(page, png, clip, spec) {
|
|
return page.evaluate(async ({ png64, clip, spec }) => {
|
|
const card = window.__goldenCard;
|
|
const help = card?.renderRoot?.querySelector(`hp-help[data-help-key="${spec.key}"]`);
|
|
const surface = help?.renderRoot?.querySelector('.tooltip:popover-open')
|
|
|| card?.renderRoot?.querySelector('hp-dialog')?.renderRoot
|
|
?.querySelector('[data-hp-overlay="help"]')?.shadowRoot?.querySelector('.tooltip');
|
|
if (!surface) throw new Error(`semantic golden help missing: ${spec.key}`);
|
|
const bounds = surface.getBoundingClientRect();
|
|
const match = getComputedStyle(surface).color.match(/[\d.]+/g)?.slice(0, 3).map(Number);
|
|
if (!match || match.length !== 3) throw new Error(`semantic golden help has invalid text color: ${spec.key}`);
|
|
const bytes = Uint8Array.from(atob(png64), (char) => char.charCodeAt(0));
|
|
const image = await createImageBitmap(new Blob([bytes], { type: 'image/png' }));
|
|
const canvas = document.createElement('canvas');
|
|
canvas.width = image.width;
|
|
canvas.height = image.height;
|
|
const context = canvas.getContext('2d', { willReadFrequently: true });
|
|
context.drawImage(image, 0, 0);
|
|
const pixels = context.getImageData(0, 0, image.width, image.height).data;
|
|
const originX = clip?.x || 0, originY = clip?.y || 0;
|
|
const left = Math.max(0, Math.ceil(bounds.left - originX) + 5);
|
|
const top = Math.max(0, Math.ceil(bounds.top - originY) + 5);
|
|
const right = Math.min(image.width - 1, Math.floor(bounds.right - originX) - 5);
|
|
const bottom = Math.min(image.height - 1, Math.floor(bounds.bottom - originY) - 5);
|
|
let textPixels = 0;
|
|
for (let y = top; y <= bottom; y++) {
|
|
for (let x = left; x <= right; x++) {
|
|
const offset = (y * image.width + x) * 4;
|
|
const distance = Math.abs(pixels[offset] - match[0])
|
|
+ Math.abs(pixels[offset + 1] - match[1])
|
|
+ Math.abs(pixels[offset + 2] - match[2]);
|
|
if (pixels[offset + 3] > 240 && distance <= 90) textPixels++;
|
|
}
|
|
}
|
|
return { textPixels, bounds: [left, top, right, bottom] };
|
|
}, { png64: png.toString('base64'), clip, spec });
|
|
}
|
|
|
|
/** Detect one-pixel SVG seams inside a room-coloured opening tunnel. Sample a
|
|
* narrow strip around local y=0: this crosses the join between both tunnel
|
|
* half-faces at any opening angle while excluding legitimate outer-profile
|
|
* steps where adjacent wall intervals have different physical thicknesses. */
|
|
async function inspectTunnelContinuity(page, png, clip, spec) {
|
|
return page.evaluate(async ({ png64, clip, spec }) => {
|
|
const card = window.__goldenCard;
|
|
const tunnel = card?.renderRoot?.querySelector(
|
|
`.opening-tunnels[data-layer="data"] [data-hp="opening-tunnel"][data-id="${spec.openingId}"]`,
|
|
);
|
|
if (!tunnel) throw new Error(`semantic golden tunnel missing: ${spec.openingId}`);
|
|
const rect = tunnel.getBoundingClientRect();
|
|
const matrix = tunnel.getScreenCTM();
|
|
if (!matrix) throw new Error(`semantic golden tunnel has no screen transform: ${spec.openingId}`);
|
|
const inverse = matrix.inverse();
|
|
const localBounds = tunnel.getBBox();
|
|
const bytes = Uint8Array.from(atob(png64), (char) => char.charCodeAt(0));
|
|
const image = await createImageBitmap(new Blob([bytes], { type: 'image/png' }));
|
|
const canvas = document.createElement('canvas');
|
|
canvas.width = image.width;
|
|
canvas.height = image.height;
|
|
const context = canvas.getContext('2d', { willReadFrequently: true });
|
|
context.drawImage(image, 0, 0);
|
|
const originX = clip?.x || 0, originY = clip?.y || 0;
|
|
// Screenshots may be captured at DPR > 1. DOMRect/clip are CSS pixels,
|
|
// image coordinates are device pixels, so derive the scale instead of
|
|
// silently sampling the wrong strip at high DPI.
|
|
const cssWidth = clip?.width || document.documentElement.clientWidth;
|
|
const cssHeight = clip?.height || document.documentElement.clientHeight;
|
|
const scaleX = image.width / Math.max(1, cssWidth);
|
|
const scaleY = image.height / Math.max(1, cssHeight);
|
|
const insetX = Math.max(1, Math.round(spec.insetPx * scaleX));
|
|
const insetY = Math.max(1, Math.round(spec.insetPx * scaleY));
|
|
const left = Math.max(0, Math.ceil((rect.left - originX) * scaleX) + insetX);
|
|
const top = Math.max(0, Math.ceil((rect.top - originY) * scaleY) + insetY);
|
|
const right = Math.min(image.width - 1, Math.floor((rect.right - originX) * scaleX) - insetX);
|
|
const bottom = Math.min(image.height - 1, Math.floor((rect.bottom - originY) * scaleY) - insetY);
|
|
if (right - left < 3 || bottom - top < 3)
|
|
throw new Error(`semantic golden tunnel is too small: ${left},${top},${right},${bottom}`);
|
|
const pixels = context.getImageData(0, 0, image.width, image.height).data;
|
|
const rgb = (x, y) => {
|
|
const offset = (y * image.width + x) * 4;
|
|
return [pixels[offset], pixels[offset + 1], pixels[offset + 2]];
|
|
};
|
|
let maxJump = 0, maxPair = null, samplePairs = 0;
|
|
const compare = (a, b, x, y, direction) => {
|
|
const jump = Math.max(
|
|
Math.abs(a[0] - b[0]), Math.abs(a[1] - b[1]), Math.abs(a[2] - b[2]));
|
|
if (jump > maxJump) {
|
|
maxJump = jump;
|
|
maxPair = { x, y, direction, a, b };
|
|
}
|
|
samplePairs++;
|
|
};
|
|
const localXScale = Math.max(1e-6, Math.hypot(matrix.a, matrix.b));
|
|
const localYScale = Math.max(1e-6, Math.hypot(matrix.c, matrix.d));
|
|
const endInset = Math.max(1, spec.insetPx) / localXScale;
|
|
const axisBand = Math.max(1.5, spec.axisBandPx || 2.5) / localYScale;
|
|
const insideAxisBand = (x, y) => {
|
|
const cssX = originX + (x + 0.5) / scaleX;
|
|
const cssY = originY + (y + 0.5) / scaleY;
|
|
const local = new DOMPoint(cssX, cssY).matrixTransform(inverse);
|
|
return local.x >= localBounds.x + endInset
|
|
&& local.x <= localBounds.x + localBounds.width - endInset
|
|
&& Math.abs(local.y) <= axisBand;
|
|
};
|
|
for (let y = top; y <= bottom; y++) {
|
|
for (let x = left + 1; x <= right; x++) {
|
|
if (insideAxisBand(x - 1, y) && insideAxisBand(x, y))
|
|
compare(rgb(x - 1, y), rgb(x, y), x, y, 'horizontal');
|
|
}
|
|
}
|
|
for (let x = left; x <= right; x++) {
|
|
for (let y = top + 1; y <= bottom; y++) {
|
|
if (insideAxisBand(x, y - 1) && insideAxisBand(x, y))
|
|
compare(rgb(x, y - 1), rgb(x, y), x, y, 'vertical');
|
|
}
|
|
}
|
|
if (!samplePairs) throw new Error(`semantic golden tunnel axis strip is empty: ${spec.openingId}`);
|
|
return {
|
|
maxJump, maxPair, samplePairs, bounds: [left, top, right, bottom],
|
|
scale: [scaleX, scaleY],
|
|
};
|
|
}, { png64: png.toString('base64'), clip, spec });
|
|
}
|
|
|
|
/** Prove the opening-centre contract from rendered geometry before a PNG is
|
|
* compared. The screenshot remains useful for human review, but a shifted
|
|
* symbol, shallow jamb or cancelled gate turn must fail semantically even
|
|
* when no reviewed baseline exists yet. */
|
|
async function assertOpeningSymbolContract(page, contract) {
|
|
return page.evaluate((expected) => {
|
|
const card = window.__goldenCard;
|
|
const root = card?.renderRoot;
|
|
const space = card?._serverCfg?.spaces?.find((item) => item.id === card._space);
|
|
if (!card || !root || !space) throw new Error('semantic golden opening card is missing');
|
|
const epsilon = 1e-5;
|
|
const parsePair = (value, name) => {
|
|
const match = String(value || '').match(new RegExp(`${name}\\(([-+0-9.eE]+)[ ,]+([-+0-9.eE]+)\\)`));
|
|
if (!match) throw new Error(`semantic golden cannot parse ${name}: ${value}`);
|
|
return [Number(match[1]), Number(match[2])];
|
|
};
|
|
const parseTurn = (leaf) => {
|
|
const value = leaf?.style?.transform || leaf?.getAttribute?.('style') || '';
|
|
const match = String(value).match(/rotate\(([-+0-9.eE]+)deg\)/);
|
|
if (!match) throw new Error(`semantic golden cannot parse gate turn: ${value}`);
|
|
return Number(match[1]);
|
|
};
|
|
const openingCfg = new Map((space.openings || []).map((opening) => [opening.id, opening]));
|
|
const turns = new Map();
|
|
const rows = [];
|
|
|
|
if (expected.surface === 'flat') {
|
|
const renderedById = new Map((card._openingsR || []).map((opening) => [opening.id, opening]));
|
|
const fullDepth = (expected.wallCm / card._cellCm) * card._gridPitch;
|
|
for (const item of expected.openings) {
|
|
const cfg = openingCfg.get(item.id);
|
|
const rendered = renderedById.get(item.id);
|
|
const group = root.querySelector(`.opening[data-id="${CSS.escape(item.id)}"]`);
|
|
if (!cfg || !rendered || !group || cfg.type !== item.type
|
|
|| !!cfg.flip_v !== item.flipV) {
|
|
throw new Error(`semantic golden opening config/render mismatch: ${item.id}`);
|
|
}
|
|
if (Math.hypot(rendered.rx - cfg.x * 1000, rendered.ry - cfg.y * 1000) > epsilon)
|
|
throw new Error(`semantic golden opening left its wall centerline: ${item.id}`);
|
|
const scale = group.querySelector(':scope > g[transform^="scale("]');
|
|
const body = scale?.querySelector(':scope > g[transform^="translate("]');
|
|
if (!scale || !body) throw new Error(`semantic golden visible group is missing: ${item.id}`);
|
|
const translation = parsePair(body.getAttribute('transform'), 'translate');
|
|
const offset = Math.hypot(...translation);
|
|
const expectedOffset = 0;
|
|
if (Math.abs(offset - expectedOffset) > epsilon) {
|
|
throw new Error(`semantic golden visible-group offset failed for ${item.id}: `
|
|
+ `${offset} != ${expectedOffset}`);
|
|
}
|
|
const jambs = [...scale.querySelectorAll(':scope > line')];
|
|
if (jambs.length !== 2 || jambs.some((line) => {
|
|
const depth = Math.abs(Number(line.getAttribute('y2')) - Number(line.getAttribute('y1')));
|
|
return Math.abs(depth - fullDepth) > epsilon;
|
|
})) {
|
|
throw new Error(`semantic golden full-depth jamb failed: ${item.id}`);
|
|
}
|
|
if (item.type === 'window' && !body.querySelector('.op-glass'))
|
|
throw new Error(`semantic golden window group lost its glass: ${item.id}`);
|
|
let turn = null;
|
|
if (item.type === 'gate') {
|
|
const scalePair = parsePair(scale.getAttribute('transform'), 'scale');
|
|
if (Math.abs(scalePair[1] - 1) > epsilon)
|
|
throw new Error(`semantic golden gate regained scaleY flip: ${item.id}`);
|
|
turn = parseTurn(group.querySelector('.op-leaf'));
|
|
if (Math.abs(Math.abs(turn) - 10) > epsilon)
|
|
throw new Error(`semantic golden gate turn is not 10 degrees: ${item.id}`);
|
|
if (item.turnPair) turns.set(item.id, { pair: item.turnPair, flipV: item.flipV, turn });
|
|
}
|
|
rows.push({ id: item.id, offset, jambDepth: fullDepth, turn });
|
|
}
|
|
} else {
|
|
const bases = card._isoSource?.()?.build?.().openings || [];
|
|
const pointDistance = (a, b) => Math.hypot(a[0] - b[0], a[1] - b[1]);
|
|
for (const item of expected.openings) {
|
|
const cfg = openingCfg.get(item.id);
|
|
const basis = bases.find((opening) => opening.id === item.id);
|
|
if (!cfg || !basis || cfg.type !== item.type || !!cfg.flip_v !== item.flipV
|
|
|| !basis.leaves.length) {
|
|
throw new Error(`semantic golden Iso opening mismatch: ${item.id}`);
|
|
}
|
|
const centre = basis.leaves.length === 1
|
|
? [
|
|
basis.leaves[0].hinge[0] + basis.leaves[0].closedVector[0] / 2,
|
|
basis.leaves[0].hinge[1] + basis.leaves[0].closedVector[1] / 2,
|
|
]
|
|
: [
|
|
basis.leaves.reduce((sum, leaf) => sum + leaf.hinge[0], 0) / basis.leaves.length,
|
|
basis.leaves.reduce((sum, leaf) => sum + leaf.hinge[1], 0) / basis.leaves.length,
|
|
];
|
|
const offset = Math.hypot(centre[0] - cfg.x * 1000, centre[1] - cfg.y * 1000);
|
|
const faceOffset = Math.max(...basis.leaves.map((leaf) => pointDistance(
|
|
leaf.hinge,
|
|
leaf.leaf === 0 ? basis.face.selectedStart : basis.face.selectedEnd,
|
|
)));
|
|
if (item.offset === 'center') {
|
|
if (item.type !== 'window' || offset > epsilon)
|
|
throw new Error(`semantic golden Iso centre failed for ${item.id}: ${offset}`);
|
|
} else if (item.offset === 'selected-face') {
|
|
if (item.type === 'window' || faceOffset > epsilon)
|
|
throw new Error(`semantic golden Iso selected face failed for ${item.id}: ${faceOffset}`);
|
|
} else {
|
|
throw new Error(`semantic golden Iso offset contract is unknown: ${item.id}`);
|
|
}
|
|
const turn = item.type === 'gate' ? basis.leaves[0].turnDeg : null;
|
|
if (turn != null && Math.abs(Math.abs(turn) - 10) > epsilon)
|
|
throw new Error(`semantic golden Iso gate turn failed: ${item.id}`);
|
|
if (item.turnPair) turns.set(item.id, { pair: item.turnPair, flipV: item.flipV, turn });
|
|
rows.push({ id: item.id, offset, faceOffset, turn });
|
|
}
|
|
if (root.querySelectorAll('.iso-opening-panel').length < expected.openings.length)
|
|
throw new Error('semantic golden Iso opening panels are missing');
|
|
}
|
|
|
|
for (const pairName of new Set([...turns.values()].map((item) => item.pair))) {
|
|
const pair = [...turns.values()].filter((item) => item.pair === pairName)
|
|
.sort((a, b) => Number(a.flipV) - Number(b.flipV));
|
|
if (pair.length !== 2 || pair[0].flipV || !pair[1].flipV
|
|
|| Math.abs(pair[0].turn + pair[1].turn) > epsilon) {
|
|
throw new Error(`semantic golden gate flip does not reverse turn: ${pairName}`);
|
|
}
|
|
}
|
|
return { surface: expected.surface, rows };
|
|
}, contract);
|
|
}
|
|
|
|
let baselineManifest = null;
|
|
const baselineManifestPath = resolve(baselineRoot, GOLDEN_BASELINE_MANIFEST);
|
|
if (existsSync(baselineManifestPath)) {
|
|
try { baselineManifest = JSON.parse(readFileSync(baselineManifestPath, 'utf8')); }
|
|
catch { baselineManifest = { invalid: true }; }
|
|
}
|
|
|
|
const browserArgs = ['--force-color-profile=srgb', '--font-render-hinting=none', '--disable-lcd-text'];
|
|
const browserContext = { locale: 'en-US', timezoneId: 'UTC', colorScheme: 'dark', reducedMotion: 'reduce' };
|
|
const { page: primaryPage, browser } = await launch(
|
|
{ width: 1000, height: 900 },
|
|
1,
|
|
browserArgs,
|
|
browserContext,
|
|
);
|
|
const chromium = await browser.version();
|
|
const results = [];
|
|
const pagesByScale = new Map([[1, primaryPage]]);
|
|
const extraBrowsers = [];
|
|
for (const scale of new Set(scenarios.map((scenario) => scenario.deviceScaleFactor || 1))) {
|
|
if (scale === 1) continue;
|
|
const scaled = await launch(
|
|
{ width: 1000, height: 900 }, scale, browserArgs, browserContext,
|
|
);
|
|
pagesByScale.set(scale, scaled.page);
|
|
extraBrowsers.push(scaled.browser);
|
|
}
|
|
const pageErrors = new Map();
|
|
for (const page of pagesByScale.values()) {
|
|
const errors = [];
|
|
pageErrors.set(page, errors);
|
|
page.on('pageerror', (error) => errors.push(error.message));
|
|
for (const name of ['pdf.mjs', 'pdf.worker.mjs']) {
|
|
await page.route(`http://demo.local/__pdfjs/${name}`, (route) => route.fulfill({
|
|
contentType: 'text/javascript',
|
|
body: readFileSync(resolve(ROOT, 'node_modules', 'pdfjs-dist', 'build', name)),
|
|
}));
|
|
}
|
|
}
|
|
let buildFingerprint = null;
|
|
try {
|
|
buildFingerprint = await assertFreshDemoBundle(primaryPage, ROOT);
|
|
for (const scenario of scenarios) {
|
|
const expectedScale = scenario.deviceScaleFactor || 1;
|
|
const page = pagesByScale.get(expectedScale);
|
|
if (!page) throw new Error(`golden renderer scale is unavailable: ${expectedScale}`);
|
|
const activePageErrors = pageErrors.get(page);
|
|
const result = {
|
|
id: scenario.id,
|
|
threshold: scenario.threshold,
|
|
status: 'error',
|
|
actualSha256: null,
|
|
};
|
|
try {
|
|
activePageErrors.length = 0;
|
|
result.runtime = await prepareGoldenScenario(page, scenario);
|
|
if (scenario.pdfExport) {
|
|
result.pdfExport = await renderPdfGoldenPage(page, scenario.pdfSemantic || null);
|
|
}
|
|
const actualScale = await page.evaluate(() => devicePixelRatio);
|
|
result.deviceScaleFactor = actualScale;
|
|
if (Math.abs(actualScale - expectedScale) > 0.001)
|
|
throw new Error(`golden renderer scale mismatch: ${actualScale} != ${expectedScale}`);
|
|
if (activePageErrors.length)
|
|
throw new Error(`browser exception: ${activePageErrors.join(' | ')}`);
|
|
if (scenario.openingSymbolContract) {
|
|
result.openingSymbolContract = await assertOpeningSymbolContract(
|
|
page, scenario.openingSymbolContract,
|
|
);
|
|
}
|
|
if (scenario.openingGeometry) {
|
|
result.openingGeometry = await page.evaluate((expected) => {
|
|
const card = window.__goldenCard;
|
|
const opening = card?.renderRoot?.querySelector(
|
|
`.opening[data-id="${CSS.escape(expected.id)}"]`,
|
|
);
|
|
if (!opening) return null;
|
|
const transform = opening.getAttribute('transform') || '';
|
|
const angle = Number(transform.match(/rotate\(([-+0-9.eE]+)\)/)?.[1]);
|
|
const painted = [...opening.querySelectorAll('.op-leaf, .op-arc, .op-glass')]
|
|
.map((part) => part.getBoundingClientRect())
|
|
.filter((rect) => rect.width > 0 || rect.height > 0);
|
|
const bounds = painted.length ? {
|
|
width: Math.max(...painted.map((rect) => rect.right))
|
|
- Math.min(...painted.map((rect) => rect.left)),
|
|
height: Math.max(...painted.map((rect) => rect.bottom))
|
|
- Math.min(...painted.map((rect) => rect.top)),
|
|
} : { width: 0, height: 0 };
|
|
return {
|
|
type: opening.getAttribute('data-kind'), angle,
|
|
width: bounds.width, height: bounds.height,
|
|
visibleParts: opening.querySelectorAll('.op-leaf, .op-arc, .op-glass').length,
|
|
};
|
|
}, scenario.openingGeometry);
|
|
const expected = scenario.openingGeometry;
|
|
const actualOpening = result.openingGeometry;
|
|
if (!actualOpening || actualOpening.type !== expected.type
|
|
|| Math.abs(actualOpening.angle - expected.angle) > 0.001
|
|
|| actualOpening.width <= 0 || actualOpening.height <= 0
|
|
|| actualOpening.visibleParts <= 0) {
|
|
throw new Error(
|
|
`semantic golden opening geometry failed for ${expected.id}: `
|
|
+ JSON.stringify(actualOpening),
|
|
);
|
|
}
|
|
}
|
|
const clip = await goldenClip(page, scenario.capture);
|
|
const screenshotOptions = {
|
|
...(clip ? { clip } : {}),
|
|
animations: 'disabled',
|
|
caret: 'hide',
|
|
scale: 'css',
|
|
};
|
|
const actual = await page.screenshot(screenshotOptions);
|
|
const actualPath = resolve(actualRoot, `${scenario.id}.png`);
|
|
writeFileSync(actualPath, actual);
|
|
result.actualSha256 = sha256(actual);
|
|
result.actual = actualPath;
|
|
if (scenario.sunRayPixels) {
|
|
const control = await captureWithoutSunRays(page, screenshotOptions);
|
|
const sample = await countChangedPixels(page, actual, control.png, scenario.sunRayPixels);
|
|
result.sunRayShapes = control.shapes;
|
|
result.sunRayChangedPixels = sample.changed;
|
|
result.sunRayMaxChannelDelta = sample.maxDelta;
|
|
if (sample.changed < scenario.sunRayPixels.minPixels) {
|
|
throw new Error(
|
|
`semantic golden assertion failed: sun rays paint ${sample.changed} pixels, expected at least `
|
|
+ `${scenario.sunRayPixels.minPixels}`,
|
|
);
|
|
}
|
|
}
|
|
if (scenario.openingPreviewPixels) {
|
|
const control = await captureWithoutOpeningPreview(page, screenshotOptions);
|
|
const sample = await countChangedPixels(
|
|
page, actual, control.png, scenario.openingPreviewPixels,
|
|
);
|
|
result.openingPreviewParts = control.parts;
|
|
result.openingPreviewChangedPixels = sample.changed;
|
|
result.openingPreviewMaxChannelDelta = sample.maxDelta;
|
|
const insideWall = await countOpeningPreviewPixelsInsideWall(
|
|
page, actual, control.png, clip, scenario.openingPreviewPixels.minChannelDelta,
|
|
);
|
|
result.openingPreviewPixelsInsideWall = insideWall.changed;
|
|
result.openingPreviewWallSamples = insideWall.sampled;
|
|
if (control.parts < 2) {
|
|
throw new Error(
|
|
`semantic golden assertion failed: opening preview is incomplete (${control.parts} parts)`,
|
|
);
|
|
}
|
|
if (sample.changed < scenario.openingPreviewPixels.minPixels) {
|
|
throw new Error(
|
|
`semantic golden assertion failed: opening preview paints ${sample.changed} pixels, `
|
|
+ `expected at least ${scenario.openingPreviewPixels.minPixels}`,
|
|
);
|
|
}
|
|
if (insideWall.changed < scenario.openingPreviewPixels.minInsideWallPixels) {
|
|
throw new Error(
|
|
`semantic golden assertion failed: opening preview paints ${insideWall.changed} pixels `
|
|
+ `inside the wall body, expected at least `
|
|
+ `${scenario.openingPreviewPixels.minInsideWallPixels}`,
|
|
);
|
|
}
|
|
}
|
|
if (scenario.warmPixelRegion) {
|
|
const sample = await countWarmPixels(page, actual, scenario.warmPixelRegion);
|
|
result.warmPixels = sample.warm;
|
|
result.warmPixelBounds = sample.bounds;
|
|
if (sample.warm < scenario.warmPixelRegion.minPixels) {
|
|
throw new Error(
|
|
`semantic golden assertion failed: ${sample.warm} warm pixels, expected at least `
|
|
+ `${scenario.warmPixelRegion.minPixels}`,
|
|
);
|
|
}
|
|
}
|
|
if (scenario.decorPixelProbes) {
|
|
const samples = await inspectDecorPixels(page, actual, clip, scenario.decorPixelProbes);
|
|
result.decorPixelProbes = samples;
|
|
const failed = samples.filter((sample) =>
|
|
sample.fraction < scenario.decorPixelProbes.minMatchingFraction);
|
|
if (failed.length) {
|
|
throw new Error(
|
|
`semantic golden assertion failed: decor is hidden/tinted at `
|
|
+ failed.map((sample) => `${sample.id} (${sample.matching}/${sample.sampled})`).join(', '),
|
|
);
|
|
}
|
|
}
|
|
if (scenario.helpTextRegion) {
|
|
const sample = await countHelpTextPixels(page, actual, clip, scenario.helpTextRegion);
|
|
result.helpTextPixels = sample.textPixels;
|
|
result.helpPixelBounds = sample.bounds;
|
|
if (sample.textPixels < scenario.helpTextRegion.minPixels) {
|
|
throw new Error(
|
|
`semantic golden assertion failed: help ${scenario.helpTextRegion.key} contains `
|
|
+ `${sample.textPixels} text pixels, expected at least ${scenario.helpTextRegion.minPixels}`,
|
|
);
|
|
}
|
|
}
|
|
if (scenario.tunnelContinuity) {
|
|
const sample = await inspectTunnelContinuity(
|
|
page, actual, clip, scenario.tunnelContinuity,
|
|
);
|
|
result.tunnelMaxChannelJump = sample.maxJump;
|
|
result.tunnelMaxJumpPair = sample.maxPair;
|
|
result.tunnelSamplePairs = sample.samplePairs;
|
|
result.tunnelPixelBounds = sample.bounds;
|
|
result.tunnelImageScale = sample.scale;
|
|
if (sample.maxJump > scenario.tunnelContinuity.maxChannelJump) {
|
|
throw new Error(
|
|
`semantic golden assertion failed: opening ${scenario.tunnelContinuity.openingId} `
|
|
+ `has a ${sample.maxJump}-channel local jump, expected at most `
|
|
+ `${scenario.tunnelContinuity.maxChannelJump}`,
|
|
);
|
|
}
|
|
if (scenario.tunnelContinuity.dpr2) {
|
|
// A CSS-pixel capture cannot prove that half-device-pixel joins are
|
|
// clean. Run the same semantic assertion once at DPR 2 without
|
|
// adding a second reviewed baseline to the matrix.
|
|
const highDpi = await launch(
|
|
scenario.viewport, 2, browserArgs, browserContext,
|
|
);
|
|
try {
|
|
await assertFreshDemoBundle(highDpi.page, ROOT);
|
|
await prepareGoldenScenario(highDpi.page, scenario);
|
|
const highDpiClip = await goldenClip(highDpi.page, scenario.capture);
|
|
const highDpiPng = await highDpi.page.screenshot({
|
|
...(highDpiClip ? { clip: highDpiClip } : {}),
|
|
animations: 'disabled', caret: 'hide', scale: 'device',
|
|
});
|
|
const highDpiSample = await inspectTunnelContinuity(
|
|
highDpi.page, highDpiPng, highDpiClip, scenario.tunnelContinuity,
|
|
);
|
|
result.tunnelDpr2MaxChannelJump = highDpiSample.maxJump;
|
|
result.tunnelDpr2SamplePairs = highDpiSample.samplePairs;
|
|
result.tunnelDpr2PixelBounds = highDpiSample.bounds;
|
|
if (highDpiSample.maxJump > scenario.tunnelContinuity.maxChannelJump) {
|
|
throw new Error(
|
|
`semantic golden assertion failed at DPR 2: opening ${scenario.tunnelContinuity.openingId} `
|
|
+ `has a ${highDpiSample.maxJump}-channel local jump, expected at most `
|
|
+ `${scenario.tunnelContinuity.maxChannelJump}`,
|
|
);
|
|
}
|
|
} finally {
|
|
await highDpi.browser.close();
|
|
}
|
|
}
|
|
}
|
|
const baselinePath = resolve(baselineRoot, `${scenario.id}.png`);
|
|
result.baseline = baselinePath;
|
|
if (!existsSync(baselinePath)) {
|
|
result.status = 'missing-baseline';
|
|
} else {
|
|
const baseline = readFileSync(baselinePath);
|
|
const expectedBaselineSha256 = baselineManifest?.scenarios?.[scenario.id];
|
|
result.baselineSha256 = sha256(baseline);
|
|
if (!expectedBaselineSha256 || result.baselineSha256 !== expectedBaselineSha256) {
|
|
result.status = 'invalid-baseline';
|
|
result.error = expectedBaselineSha256
|
|
? 'baseline PNG does not match its reviewed manifest hash'
|
|
: 'baseline PNG is not listed in the reviewed manifest';
|
|
results.push(result);
|
|
console.log(`${result.status.padEnd(17)} ${scenario.id}`);
|
|
continue;
|
|
}
|
|
const comparison = await comparePng(page, actual, baseline, scenario.threshold);
|
|
Object.assign(result, comparison);
|
|
result.status = comparison.passed ? 'passed' : 'different';
|
|
if (comparison.diffPngBase64) {
|
|
const diffPath = resolve(diffRoot, `${scenario.id}.png`);
|
|
writeFileSync(diffPath, Buffer.from(comparison.diffPngBase64, 'base64'));
|
|
result.diff = diffPath;
|
|
}
|
|
delete result.diffPngBase64;
|
|
}
|
|
} catch (error) {
|
|
result.error = error instanceof Error ? error.message : String(error);
|
|
} finally {
|
|
// Tab-divider goldens deliberately hold the real pointer through the
|
|
// screenshot. Always release it before the shared page is reused, even
|
|
// when semantic validation or capture failed.
|
|
if (scenario.tabDrag) {
|
|
try { await page.mouse.up(); } catch { /* the browser may already be closing */ }
|
|
}
|
|
}
|
|
results.push(result);
|
|
console.log(`${result.status.padEnd(17)} ${scenario.id}`);
|
|
}
|
|
} finally {
|
|
await Promise.all([...extraBrowsers.map((item) => item.close()), browser.close()]);
|
|
}
|
|
|
|
const expectedScenarioIds = GOLDEN_SCENARIOS.map((scenario) => scenario.id);
|
|
const indexedScenarioIds = Object.keys(baselineManifest?.scenarios || {});
|
|
const baselineScenarioIds = readdirSync(baselineRoot)
|
|
.filter((name) => name.endsWith('.png'))
|
|
.map((name) => name.slice(0, -'.png'.length));
|
|
const manifestValid = !!baselineManifest
|
|
&& !baselineManifest.invalid
|
|
&& baselineManifest.matrixVersion === GOLDEN_MATRIX_VERSION
|
|
&& baselineManifest.chromium === chromium
|
|
&& expectedScenarioIds.every((id) => typeof baselineManifest.scenarios?.[id] === 'string')
|
|
&& goldenScenarioSetsMatch(expectedScenarioIds, indexedScenarioIds, baselineScenarioIds);
|
|
const report = {
|
|
// #571: схема 2 обязана нести провенанс съёмки. До неё отчёт платформу не
|
|
// нёс, и приёмщик записывал в индекс эталонов свою — на `ad4000f9` кадры
|
|
// Linux-прогона 34853080375 уехали в индекс как `win32`.
|
|
schema: CAPTURE_PROVENANCE_SCHEMA,
|
|
mode,
|
|
capture: captureProvenance({ chromium, buildFingerprint }),
|
|
generatedAt: new Date().toISOString(),
|
|
matrixVersion: GOLDEN_MATRIX_VERSION,
|
|
buildFingerprint,
|
|
chromium,
|
|
baselineManifest: baselineManifest ? {
|
|
present: true,
|
|
valid: manifestValid,
|
|
matrixVersion: baselineManifest.matrixVersion ?? null,
|
|
chromium: baselineManifest.chromium ?? null,
|
|
} : { present: false, valid: false, matrixVersion: null, chromium: null },
|
|
results,
|
|
};
|
|
writeFileSync(resolve(artifactRoot, 'golden-report.json'), `${JSON.stringify(report, null, 2)}\n`, 'utf8');
|
|
|
|
if (goldenRunFailed(mode, manifestValid, results)) process.exitCode = 1;
|