27b1ab1329
ScheduleStartPage now starts dateFrom/dateTo as null so the input shows the `ДД.ММ.ГГГГ - ДД.ММ.ГГГГ` placeholder Angular ships instead of pre-filling with the current week. The submit handler defaults to current-week range when the user submits without picking dates, preserving the legacy "find this week" UX. Same pattern as the onlineboard date fix.
206 lines
6.9 KiB
JavaScript
206 lines
6.9 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* Generate pixel diffs for the side-by-side Angular/React screenshots
|
|
* captured into `comparison-report/visual/screenshots/full/` and emit a
|
|
* JSON file the report.html consumes for the quantitative side of the
|
|
* parity comparison.
|
|
*
|
|
* Pairs are inferred by filename: every `react-{stem}.png` is matched
|
|
* against `angular-{stem}.png` in the same directory. Diffs are written
|
|
* to `comparison-report/visual/diffs-full/{stem}.png` (white = match,
|
|
* red overlay = mismatched pixel) and stats land in
|
|
* `comparison-report/visual/diff-stats.json`.
|
|
*
|
|
* Image-dimension mismatches are handled by padding both sides to the
|
|
* larger canvas with white before pixel-matching — same approach as
|
|
* `tests/parity/visual/screenshot-diff-multi.ts`.
|
|
*/
|
|
|
|
import { readdirSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
|
|
import { resolve, join } from "node:path";
|
|
import { PNG } from "pngjs";
|
|
import pixelmatch from "pixelmatch";
|
|
|
|
const ROOT = resolve(process.cwd());
|
|
const SRC_DIR = join(ROOT, "comparison-report/visual/screenshots/full");
|
|
const DIFFS_DIR = join(ROOT, "comparison-report/visual/diffs-full");
|
|
const STATS_PATH = join(ROOT, "comparison-report/visual/diff-stats.json");
|
|
|
|
mkdirSync(DIFFS_DIR, { recursive: true });
|
|
|
|
/**
|
|
* Paint a rectangle white. Used to mask Angular-only test-env chrome
|
|
* (orange `Тестовая версия` badge top-left, `rc/2026-04-06` build tag
|
|
* top-right, chat-widget bubble bottom-right) before pixel-matching
|
|
* so those regions don't pollute the parity score.
|
|
*/
|
|
function maskRect(png, x, y, w, h) {
|
|
const x0 = Math.max(0, x);
|
|
const y0 = Math.max(0, y);
|
|
const x1 = Math.min(png.width, x + w);
|
|
const y1 = Math.min(png.height, y + h);
|
|
for (let yy = y0; yy < y1; yy++) {
|
|
for (let xx = x0; xx < x1; xx++) {
|
|
const idx = (yy * png.width + xx) * 4;
|
|
png.data[idx] = 255;
|
|
png.data[idx + 1] = 255;
|
|
png.data[idx + 2] = 255;
|
|
png.data[idx + 3] = 255;
|
|
}
|
|
}
|
|
}
|
|
|
|
/** Apply the standard chrome masks to both PNGs in-place. */
|
|
function applyChromeMasks(png) {
|
|
// Top-left debug counter / orange "Тестовая версия" badge.
|
|
maskRect(png, 0, 0, 200, 90);
|
|
// Top-right `rc/...` build tag.
|
|
maskRect(png, png.width - 240, 0, 240, 50);
|
|
// Bottom-right chat widget.
|
|
maskRect(png, png.width - 90, png.height - 90, 90, 90);
|
|
}
|
|
|
|
/**
|
|
* Shift the contents of a PNG vertically by `dy` pixels in-place.
|
|
* Negative `dy` moves content up (rows above are dropped, the gap at
|
|
* the bottom is filled white). Used to compensate for the Angular
|
|
* test-env chrome that pushes the main layout down by a fixed
|
|
* amount, so that content rows align with React's chrome-less render
|
|
* before pixel-matching.
|
|
*/
|
|
function shiftUp(png, dy) {
|
|
if (dy <= 0) return;
|
|
const w = png.width;
|
|
const h = png.height;
|
|
const stride = w * 4;
|
|
// Move row y+dy → row y.
|
|
for (let y = 0; y < h - dy; y++) {
|
|
png.data.copyWithin(y * stride, (y + dy) * stride, (y + dy + 1) * stride);
|
|
}
|
|
// Fill last `dy` rows with white.
|
|
for (let y = h - dy; y < h; y++) {
|
|
for (let x = 0; x < w; x++) {
|
|
const idx = (y * w + x) * 4;
|
|
png.data[idx] = 255;
|
|
png.data[idx + 1] = 255;
|
|
png.data[idx + 2] = 255;
|
|
png.data[idx + 3] = 255;
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Find the y-coordinate of the orange `Тестовая версия` badge in an
|
|
* Angular screenshot, if present. The badge is solid #ff9000 / similar
|
|
* orange — distinctive enough to detect by row-scanning. Returns -1
|
|
* when no badge band is found.
|
|
*/
|
|
function findOrangeBadgeBottom(png) {
|
|
const w = png.width;
|
|
const h = Math.min(png.height, 200);
|
|
let bandStart = -1;
|
|
let bandEnd = -1;
|
|
for (let y = 0; y < h; y++) {
|
|
let orangeCount = 0;
|
|
// Sample first 200px of the row for orange pixels.
|
|
for (let x = 0; x < Math.min(200, w); x++) {
|
|
const idx = (y * w + x) * 4;
|
|
const r = png.data[idx];
|
|
const g = png.data[idx + 1];
|
|
const b = png.data[idx + 2];
|
|
// ~ #ff9000 / #f37b09 family.
|
|
if (r > 220 && g > 100 && g < 180 && b < 60) orangeCount++;
|
|
}
|
|
if (orangeCount > 30) {
|
|
if (bandStart === -1) bandStart = y;
|
|
bandEnd = y;
|
|
}
|
|
}
|
|
return bandEnd === -1 ? -1 : bandEnd;
|
|
}
|
|
|
|
const files = readdirSync(SRC_DIR);
|
|
const reactFiles = files.filter((f) => f.startsWith("react-") && f.endsWith(".png"));
|
|
|
|
const stats = {};
|
|
let processed = 0;
|
|
|
|
for (const reactFile of reactFiles) {
|
|
const stem = reactFile.slice("react-".length); // e.g. "onlineboard-start.png"
|
|
const angularFile = `angular-${stem}`;
|
|
if (!files.includes(angularFile)) {
|
|
console.log(` skip (no angular pair): ${reactFile}`);
|
|
continue;
|
|
}
|
|
|
|
const aBuf = readFileSync(join(SRC_DIR, angularFile));
|
|
const rBuf = readFileSync(join(SRC_DIR, reactFile));
|
|
const aPng = PNG.sync.read(aBuf);
|
|
const rPng = PNG.sync.read(rBuf);
|
|
|
|
const width = Math.max(aPng.width, rPng.width);
|
|
const height = Math.max(aPng.height, rPng.height);
|
|
|
|
const pad = (src) => {
|
|
const out = new PNG({ width, height });
|
|
for (let i = 0; i < out.data.length; i += 4) {
|
|
out.data[i] = 255; out.data[i + 1] = 255; out.data[i + 2] = 255; out.data[i + 3] = 255;
|
|
}
|
|
for (let y = 0; y < src.height; y++) {
|
|
for (let x = 0; x < src.width; x++) {
|
|
const srcIdx = (y * src.width + x) * 4;
|
|
const dstIdx = (y * width + x) * 4;
|
|
out.data[dstIdx] = src.data[srcIdx];
|
|
out.data[dstIdx + 1] = src.data[srcIdx + 1];
|
|
out.data[dstIdx + 2] = src.data[srcIdx + 2];
|
|
out.data[dstIdx + 3] = src.data[srcIdx + 3];
|
|
}
|
|
}
|
|
return PNG.sync.read(PNG.sync.write(out));
|
|
};
|
|
|
|
const aP = pad(aPng);
|
|
const rP = pad(rPng);
|
|
|
|
// Strip Angular-only chrome (test-env badge, build tag, chat widget)
|
|
// from both sides — those regions are environmental noise that
|
|
// shouldn't drag the parity score down.
|
|
applyChromeMasks(aP);
|
|
applyChromeMasks(rP);
|
|
|
|
const diffPng = new PNG({ width, height });
|
|
|
|
const mismatchCount = pixelmatch(
|
|
aP.data, rP.data, diffPng.data, width, height,
|
|
{ threshold: 0.1, alpha: 0.3 },
|
|
);
|
|
|
|
const totalPixels = width * height;
|
|
const mismatchPct = (mismatchCount / totalPixels) * 100;
|
|
const heightDiff = rPng.height - aPng.height;
|
|
|
|
const diffName = stem;
|
|
writeFileSync(join(DIFFS_DIR, diffName), PNG.sync.write(diffPng));
|
|
|
|
const key = stem.replace(/\.png$/, "");
|
|
stats[key] = {
|
|
angular: `screenshots/full/${angularFile}`,
|
|
react: `screenshots/full/${reactFile}`,
|
|
diff: `diffs-full/${diffName}`,
|
|
mismatchPct: Number(mismatchPct.toFixed(3)),
|
|
mismatchCount,
|
|
totalPixels,
|
|
heightDiff,
|
|
angular_w: aPng.width, angular_h: aPng.height,
|
|
react_w: rPng.width, react_h: rPng.height,
|
|
};
|
|
|
|
processed++;
|
|
console.log(` ${stem}: ${mismatchPct.toFixed(2)}% diff (${mismatchCount.toLocaleString()} / ${totalPixels.toLocaleString()} px)`);
|
|
}
|
|
|
|
writeFileSync(STATS_PATH, JSON.stringify(stats, null, 2));
|
|
console.log(`\nDone — ${processed} pairs diffed.`);
|
|
console.log(` Diffs: ${DIFFS_DIR}/`);
|
|
console.log(` Stats: ${STATS_PATH}`);
|