Files
flights_web/scripts/visual-diff.mjs
T
gnezim e7cf11e799 Visual parity fixes — drop pixel mismatch on 6+ pages
- OperatorLogo: accept BCP-47 codes (`ru-ru`) by trimming to first 2
  chars before picking the en/ru asset variant. Fixes the Russian
  flight-details page rendering ROSSIYA (Latin) instead of РОССИЯ.
- FlightCard / FlightList: thread `direction` from the search page so
  arrival results show Высадка (deboarding) instead of Посадка
  (boarding) — Angular parity. The arrival side reads from
  arrivalLeg.transition.deboarding when direction === 'arrival'.
- OnlineBoardFilter:
  - Дата рейса starts blank with `ДД.ММ.ГГГГ` placeholder; submit
    handler defaults to today on empty.
  - Город вылета / Город прилета placeholders flip to
    `Все направления` when the opposite-direction field is filled.
  - Filter content row now flows with $space-l vertical gap to match
    Angular's accordion-content rhythm (was ~6 px tighter).
- FlightsMiniList: `display: none` on mobile. Avoids the duplicate
  summary card that was floating above the main details on small
  viewports — Angular hides the sidebar mini-list there.
- FlightsMap calendar trigger: override PrimeReact's filled-blue
  button to a transparent outline so it reads as a glyph (matches
  Angular's outline calendar icon).

Pixel-mismatch results (re-diffed via scripts/visual-diff.mjs):
  en-onlineboard-route       5.50% → 4.62%
  onlineboard-arrival        5.53% → 4.63%
  onlineboard-departure      5.92% → 5.03%
  onlineboard-route          5.16% → 4.78%
  mobile-onlineboard-start  23.51% → 20.37%
  mobile-flight-details     18.82% → 17.92%
  flight-details            carrier-logo verified visually; pixel
                            count unchanged (height delta dominates)
  onlineboard-start         14.56% → 14.52%

Larger remaining mismatches (schedule-route 14%, flights-map 34%,
flight-details 11%) are dominated by structural Angular features the
React port doesn't yet ship (day grouping, code-share bundling on
schedule; geo-driven origin marker on map; height-delta on details).
Tracked as P1 follow-ups in the comparison report.
2026-04-19 20:18:15 +03:00

108 lines
3.8 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 });
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);
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}`);