Files
flights_web/tests/parity/visual/report-template.html
T
gnezim e82289b979 Add interactive HTML report generator for visual parity diffs
Self-contained dark-themed HTML report with summary stats, filter
buttons (pass/warn/fail/error), side-by-side image comparison per
route and viewport, lazy-loaded images, and full-size overlay on click.
The generator script reads report.json, converts absolute paths to
relative, and injects data into the template.
2026-04-16 17:41:47 +03:00

333 lines
9.6 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Visual Parity Report</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body {
background: #1a1a2e;
color: #e0e0e0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, monospace;
padding: 24px;
line-height: 1.5;
}
h1 { color: #fff; margin-bottom: 8px; font-size: 1.6rem; }
.timestamp { color: #888; font-size: 0.85rem; margin-bottom: 24px; }
/* Summary bar */
.summary {
display: flex; gap: 16px; flex-wrap: wrap;
margin-bottom: 24px;
}
.summary-card {
padding: 12px 20px;
border-radius: 8px;
font-size: 1.1rem;
font-weight: 600;
min-width: 120px;
text-align: center;
}
.summary-card .count { font-size: 2rem; display: block; }
.summary-card.pass { background: #0d3320; color: #4ade80; }
.summary-card.warn { background: #3b2f0a; color: #fbbf24; }
.summary-card.fail { background: #3b0d0d; color: #f87171; }
.summary-card.error { background: #2d1b4e; color: #c084fc; }
/* Filters */
.filters {
display: flex; gap: 8px; flex-wrap: wrap;
margin-bottom: 24px;
}
.filters button {
padding: 8px 16px;
border: 1px solid #444;
border-radius: 6px;
background: #16213e;
color: #e0e0e0;
cursor: pointer;
font-size: 0.9rem;
transition: background 0.15s;
}
.filters button:hover { background: #1a3a5c; }
.filters button.active { background: #2563eb; border-color: #2563eb; color: #fff; }
/* Route group */
.route-group {
margin-bottom: 32px;
border: 1px solid #2a2a4a;
border-radius: 10px;
overflow: hidden;
}
.route-header {
background: #16213e;
padding: 12px 20px;
display: flex; align-items: center; gap: 12px;
cursor: pointer;
user-select: none;
}
.route-header:hover { background: #1a2a4e; }
.route-name { font-size: 1.15rem; font-weight: 600; color: #fff; }
.badge {
padding: 2px 10px;
border-radius: 12px;
font-size: 0.75rem;
font-weight: 700;
text-transform: uppercase;
}
.badge.pass { background: #065f46; color: #6ee7b7; }
.badge.warn { background: #78350f; color: #fde68a; }
.badge.fail { background: #7f1d1d; color: #fca5a5; }
.badge.error { background: #4c1d95; color: #ddd6fe; }
.route-body { padding: 0; }
.route-body.collapsed { display: none; }
/* Viewport row */
.viewport-row {
padding: 16px 20px;
border-top: 1px solid #2a2a4a;
}
.viewport-row-header {
display: flex; align-items: center; gap: 12px;
margin-bottom: 12px;
}
.viewport-label {
font-weight: 600; color: #93c5fd; font-size: 0.95rem;
}
.stats {
display: flex; gap: 16px; font-size: 0.82rem; color: #999;
}
.stats span { white-space: nowrap; }
.stats .pct { font-weight: 700; }
.stats .pct.pass { color: #4ade80; }
.stats .pct.warn { color: #fbbf24; }
.stats .pct.fail { color: #f87171; }
.images {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: 12px;
}
.img-cell {
text-align: center;
}
.img-cell .label {
font-size: 0.75rem; color: #888;
margin-bottom: 4px; text-transform: uppercase; letter-spacing: 0.5px;
}
.img-cell img {
width: 100%;
height: auto;
border-radius: 6px;
border: 1px solid #333;
cursor: zoom-in;
background: #111;
}
.error-msg {
color: #f87171;
font-size: 0.9rem;
padding: 8px 0;
}
/* Overlay */
.overlay {
display: none;
position: fixed;
inset: 0;
background: rgba(0,0,0,0.92);
z-index: 1000;
cursor: zoom-out;
overflow: auto;
padding: 20px;
}
.overlay.visible { display: flex; align-items: center; justify-content: center; }
.overlay img {
max-width: 95vw;
max-height: 95vh;
border-radius: 8px;
}
</style>
</head>
<body>
<h1>Visual Parity Report: Angular vs React</h1>
<div class="timestamp" id="timestamp"></div>
<div class="summary" id="summary"></div>
<div class="filters" id="filters"></div>
<div id="content"></div>
<div class="overlay" id="overlay" onclick="this.classList.remove('visible')">
<img id="overlay-img" src="" alt="Full size">
</div>
<script>
const DATA = %%DATA%%;
// Classify a result
function classify(r) {
if (r.error) return 'error';
if (r.mismatchPct > 2) return 'fail';
if (r.mismatchPct >= 0.5) return 'warn';
return 'pass';
}
// Group results by route
function groupByRoute(results) {
const map = new Map();
for (const r of results) {
if (!map.has(r.route)) map.set(r.route, []);
map.get(r.route).push(r);
}
return map;
}
// Worst status for a group
function worstStatus(items) {
const order = ['pass', 'warn', 'fail', 'error'];
let worst = 0;
for (const item of items) {
const idx = order.indexOf(classify(item));
if (idx > worst) worst = idx;
}
return order[worst];
}
// Summary counts
const counts = { pass: 0, warn: 0, fail: 0, error: 0 };
for (const r of DATA.results) counts[classify(r)]++;
document.getElementById('timestamp').textContent = 'Generated: ' + DATA.timestamp;
// Render summary
const summaryEl = document.getElementById('summary');
for (const [key, label] of [['pass','Pass (<0.5%)'],['warn','Warn (0.5-2%)'],['fail','Fail (>2%)'],['error','Error']]) {
summaryEl.innerHTML += `<div class="summary-card ${key}"><span class="count">${counts[key]}</span>${label}</div>`;
}
// Render filters
const filtersEl = document.getElementById('filters');
let activeFilter = 'all';
const filterButtons = [
['all', 'All (' + DATA.results.length + ')'],
['pass', 'Pass (' + counts.pass + ')'],
['warn', 'Warn (' + counts.warn + ')'],
['fail', 'Fail (' + counts.fail + ')'],
['error', 'Error (' + counts.error + ')'],
];
for (const [key, label] of filterButtons) {
const btn = document.createElement('button');
btn.textContent = label;
btn.dataset.filter = key;
if (key === 'all') btn.classList.add('active');
btn.addEventListener('click', () => {
activeFilter = key;
document.querySelectorAll('.filters button').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
applyFilter();
});
filtersEl.appendChild(btn);
}
function applyFilter() {
document.querySelectorAll('.route-group').forEach(g => {
if (activeFilter === 'all') {
g.style.display = '';
g.querySelectorAll('.viewport-row').forEach(r => r.style.display = '');
} else {
let hasVisible = false;
g.querySelectorAll('.viewport-row').forEach(r => {
const show = r.dataset.status === activeFilter;
r.style.display = show ? '' : 'none';
if (show) hasVisible = true;
});
g.style.display = hasVisible ? '' : 'none';
}
});
}
// Render content
const contentEl = document.getElementById('content');
const grouped = groupByRoute(DATA.results);
for (const [route, items] of grouped) {
const worst = worstStatus(items);
const group = document.createElement('div');
group.className = 'route-group';
const header = document.createElement('div');
header.className = 'route-header';
header.innerHTML = `<span class="route-name">${route}</span><span class="badge ${worst}">${worst}</span>`;
const body = document.createElement('div');
body.className = 'route-body';
header.addEventListener('click', () => body.classList.toggle('collapsed'));
for (const r of items) {
const status = classify(r);
const row = document.createElement('div');
row.className = 'viewport-row';
row.dataset.status = status;
if (r.error) {
row.innerHTML = `
<div class="viewport-row-header">
<span class="viewport-label">${r.viewport}</span>
<span class="badge error">error</span>
</div>
<div class="error-msg">${r.error}</div>`;
} else {
const pctClass = status;
row.innerHTML = `
<div class="viewport-row-header">
<span class="viewport-label">${r.viewport}</span>
<span class="badge ${status}">${status}</span>
<div class="stats">
<span>Diff: <span class="pct ${pctClass}">${r.mismatchPct.toFixed(2)}%</span></span>
<span>Pixels: ${r.mismatchCount.toLocaleString()} / ${r.totalPixels.toLocaleString()}</span>
<span>Height delta: ${r.heightDiff}px</span>
<span>Angular: ${r.angularDimensions.width}x${r.angularDimensions.height}</span>
<span>React: ${r.reactDimensions.width}x${r.reactDimensions.height}</span>
</div>
</div>
<div class="images">
<div class="img-cell">
<div class="label">Angular</div>
<img loading="lazy" src="${r.angularPath}" alt="Angular ${r.route} ${r.viewport}" onclick="showOverlay(this.src)">
</div>
<div class="img-cell">
<div class="label">React</div>
<img loading="lazy" src="${r.reactPath}" alt="React ${r.route} ${r.viewport}" onclick="showOverlay(this.src)">
</div>
<div class="img-cell">
<div class="label">Diff</div>
<img loading="lazy" src="${r.diffPath}" alt="Diff ${r.route} ${r.viewport}" onclick="showOverlay(this.src)">
</div>
</div>`;
}
body.appendChild(row);
}
group.appendChild(header);
group.appendChild(body);
contentEl.appendChild(group);
}
// Overlay
function showOverlay(src) {
const overlay = document.getElementById('overlay');
document.getElementById('overlay-img').src = src;
overlay.classList.add('visible');
}
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') document.getElementById('overlay').classList.remove('visible');
});
</script>
</body>
</html>