143 lines
11 KiB
JavaScript
143 lines
11 KiB
JavaScript
import { loadPlaywright, launchChromium } from '../../scripts/orgchart-browser-runtime.mjs';
|
|
import { readFile, writeFile, mkdir } from 'node:fs/promises';
|
|
import { createHash } from 'node:crypto';
|
|
|
|
// Only the eight final card requirements; existing 368-check evidence is untouched.
|
|
await loadPlaywright();
|
|
const output = 'test-results/org-chart-final-check';
|
|
await mkdir(output, { recursive: true });
|
|
const fixture = JSON.parse(await readFile('examples/org-chart/data-p1.json', 'utf8'));
|
|
const results = [], measurements = [], errors = [];
|
|
const check = (ok, name, actual) => results.push({ name, status: ok ? 'PASS' : 'FAIL', actual });
|
|
const browser = await launchChromium({ headless: true });
|
|
const origin = process.env.ORGCHART_STORY_ORIGIN ?? 'http://127.0.0.1:6031';
|
|
const collisionRepair = process.argv.includes('--collision-repair');
|
|
async function capture(story, scenario, data) {
|
|
const page = await browser.newPage({ viewport: { width: 1440, height: 1000 } });
|
|
page.on('pageerror', error => errors.push({ story, scenario, message: error.message }));
|
|
page.on('console', message => { if (message.type() === 'error') errors.push({ story, scenario, message: message.text() }); });
|
|
if (data) await page.route('**/examples/org-chart/data-p1.json*', route => route.fulfill({
|
|
status: 200, contentType: 'application/javascript', body: `export default ${JSON.stringify(data)};`,
|
|
}));
|
|
await page.goto(`${origin}/iframe.html?id=orgchart--${story}&viewMode=story`);
|
|
await page.waitForFunction(() => {
|
|
const shadow = document.querySelector('.hm-orgchart-host, .hm-orgchart-component-host')?.shadowRoot;
|
|
return shadow?.querySelector('[data-card-asset] .hm-orgchart-person-name[data-name-overflow]')
|
|
&& [...shadow.querySelectorAll('link')].every(link => link.sheet);
|
|
});
|
|
await page.evaluate(async () => {
|
|
await document.fonts.ready;
|
|
const root = document.querySelector('.hm-orgchart-host, .hm-orgchart-component-host').shadowRoot;
|
|
await Promise.all([...root.querySelectorAll('img')].map(image => image.decode().catch(() => {})));
|
|
});
|
|
const cards = await page.evaluate(() => {
|
|
const shadow = document.querySelector('.hm-orgchart-host, .hm-orgchart-component-host').shadowRoot;
|
|
const box = element => { const r = element.getBoundingClientRect(); return { x: r.x, y: r.y, right: r.right, bottom: r.bottom, width: r.width, height: r.height }; };
|
|
// Collision checks use painted logo pixels, not the transparent container.
|
|
const logoInk = mark => {
|
|
const img = mark?.querySelector('img');
|
|
if (!img?.naturalWidth) return null;
|
|
const canvas = document.createElement('canvas'); canvas.width = img.naturalWidth; canvas.height = img.naturalHeight;
|
|
const ctx = canvas.getContext('2d'); ctx.drawImage(img, 0, 0);
|
|
const pixels = ctx.getImageData(0, 0, canvas.width, canvas.height).data;
|
|
let left = canvas.width, right = -1, top = canvas.height, bottom = -1;
|
|
for (let y = 0; y < canvas.height; y++) for (let x = 0; x < canvas.width; x++) if (pixels[(y * canvas.width + x) * 4 + 3] > 16) {
|
|
left = Math.min(left, x); right = Math.max(right, x + 1); top = Math.min(top, y); bottom = Math.max(bottom, y + 1);
|
|
}
|
|
if (right < 0) return null;
|
|
const b = box(img), scale = Math.min(b.width / canvas.width, b.height / canvas.height);
|
|
const x = b.x + (b.width - canvas.width * scale) / 2, y = b.y + (b.height - canvas.height * scale) / 2;
|
|
return { x: x + left * scale, y: y + top * scale, right: x + right * scale, bottom: y + bottom * scale };
|
|
};
|
|
return [...shadow.querySelectorAll('[data-card-asset]')].map(card => {
|
|
const avatar = card.querySelector('.hm-orgchart-avatar'), text = card.querySelector('.hm-orgchart-person-text');
|
|
const name = card.querySelector('.hm-orgchart-person-name'), position = card.querySelector('.hm-orgchart-person-role');
|
|
const row = card.querySelector('.hm-orgchart-position-row');
|
|
const badge = card.querySelector('.hm-orgchart-job-title'), attrs = card.querySelector('.hm-orgchart-person-attributes');
|
|
const logo = card.querySelector('.hm-orgchart-affiliation-mark');
|
|
const nameStyle = getComputedStyle(name), positionStyle = getComputedStyle(position), cardStyle = getComputedStyle(card);
|
|
const a = box(avatar), t = box(text), n = box(name), p = box(position), b = badge && box(badge), l = logoInk(logo);
|
|
const overlap = (u, v) => Math.min(u.right, v.right) - Math.max(u.x, v.x) > 1 && Math.min(u.bottom, v.bottom) - Math.max(u.y, v.y) > 1;
|
|
const collisions = [];
|
|
if (overlap(a, t)) collisions.push('avatar/text');
|
|
if (b && overlap(b, a)) collisions.push('badge/avatar');
|
|
if (b && overlap(b, n)) collisions.push('badge/name');
|
|
if (l && overlap(l, a)) collisions.push('logo/avatar');
|
|
if (l && overlap(l, n)) collisions.push('logo/name');
|
|
if (l && b && overlap(l, b)) collisions.push('logo/badge');
|
|
if (l) {
|
|
const range = document.createRange(); range.selectNodeContents(position);
|
|
for (const r of range.getClientRects()) if (overlap(l, { x: r.x, y: r.y, right: r.right, bottom: r.bottom })) collisions.push('logo/position');
|
|
}
|
|
const bs = badge && getComputedStyle(badge);
|
|
return { id: card.dataset.employeeId, size: card.dataset.cardAssetSize, bounds: box(card), avatar: a,
|
|
columnGap: cardStyle.columnGap, photoTextGap: t.x - a.right, textGap: getComputedStyle(text).rowGap,
|
|
actualTextGap: n.y - p.bottom, nameColor: nameStyle.color,
|
|
row: row ? { display: getComputedStyle(row).display, alignItems: getComputedStyle(row).alignItems, gap: getComputedStyle(row).columnGap,
|
|
nameGap: n.y - box(row).bottom, badgeGap: b ? b.x - p.right : null, badgeMargin: bs?.margin ?? null, children: row.children.length } : null,
|
|
nameMargin: nameStyle.margin, namePadding: nameStyle.padding, positionMargin: positionStyle.margin, positionPadding: positionStyle.padding,
|
|
badge: badge ? { text: badge.textContent, fontSize: bs.fontSize, fontWeight: bs.fontWeight,
|
|
color: bs.color, background: bs.backgroundColor, bounds: b } : null,
|
|
attributesHeight: box(attrs).height, attributesPosition: getComputedStyle(attrs).position,
|
|
collisions };
|
|
});
|
|
});
|
|
measurements.push({ story, scenario, cards });
|
|
for (const card of cards) {
|
|
const prefix = `${story}/${scenario}/${card.id}/${card.size}`;
|
|
if (!collisionRepair) {
|
|
if (card.size === 'leader') check(card.columnGap === '10px' && Math.abs(card.photoTextGap - 10) < 0.1, `${prefix}: leader gap`, [card.columnGap, card.photoTextGap]);
|
|
if (card.size === 'employee') {
|
|
check(card.columnGap === '4px' && Math.abs(card.photoTextGap - 4) < 0.1, `${prefix}: employee gap`, [card.columnGap, card.photoTextGap]);
|
|
check(card.nameColor === 'rgb(50, 50, 50)', `${prefix}: name color`, card.nameColor);
|
|
}
|
|
check(card.textGap === '0px' && Math.abs(card.actualTextGap) < 0.1 && [card.nameMargin, card.namePadding, card.positionMargin, card.positionPadding].every(value => value === '0px'), `${prefix}: text gap`, card.actualTextGap);
|
|
const person = (data ?? fixture).employees.find(item => item.id === card.id);
|
|
check(card.badge?.text === person.jobTitle || (!person.jobTitle && !card.badge), `${prefix}: jobTitle-driven badge`, { jobTitle: person.jobTitle, badge: card.badge?.text ?? null });
|
|
if (!person.jobTitle) check(card.attributesHeight === 0 && card.attributesPosition === 'absolute', `${prefix}: no empty badge space`, card.attributesHeight);
|
|
if (card.badge && card.size !== 'representative') check(card.badge.fontSize === (card.size === 'leader' ? '9px' : '8.5px') && card.badge.fontWeight === (card.size === 'leader' ? '700' : '600'), `${prefix}: badge typography`, card.badge);
|
|
if (card.size !== 'representative') {
|
|
check(card.row?.display === 'flex' && card.row.alignItems === 'center' && card.row.gap === '4px' && Math.abs(card.row.nameGap) < 0.1, `${prefix}: position/badge row and zero name gap`, card.row);
|
|
if (card.badge) check(Math.abs(card.row.badgeGap - 4) < 0.1 && card.row.badgeMargin === '0px', `${prefix}: actual position/badge gap and margin`, card.row);
|
|
else check(card.row.children === 1, `${prefix}: position only without badge`, card.row.children);
|
|
}
|
|
const dimensions = card.size === 'employee' ? [108, 52, 24] : card.size === 'leader' ? [146, 68, 32] : [146, 74, 36];
|
|
check(card.bounds.width === dimensions[0] && card.bounds.height === dimensions[1] && card.avatar.width === dimensions[2] && card.avatar.height === dimensions[2], `${prefix}: preserved dimensions`, dimensions);
|
|
}
|
|
check(card.collisions.length === 0, `${prefix}: related collisions`, card.collisions);
|
|
}
|
|
if (!collisionRepair && (scenario === 'normal' || scenario === 'roles')) await page.screenshot({ path: `${output}/${story}-${scenario}.png`, fullPage: true });
|
|
await page.close();
|
|
return cards;
|
|
}
|
|
try {
|
|
const sample = await capture('department-colors', 'normal');
|
|
await capture('full-composition', 'normal');
|
|
if (!collisionRepair) {
|
|
const smallIds = sample.filter(card => card.size === 'employee').slice(0, 2).map(card => card.id);
|
|
check(smallIds.length === 2, 'two small-card role fixtures available', smallIds);
|
|
const roles = structuredClone(fixture);
|
|
smallIds.forEach((id, index) => { roles.employees.find(item => item.id === id).jobTitle = index ? '셀장' : '팀장'; });
|
|
const noRole = structuredClone(fixture);
|
|
const leaderId = sample.find(card => card.size === 'leader').id;
|
|
noRole.employees.find(item => item.id === leaderId).jobTitle = null;
|
|
for (const story of ['department-colors', 'full-composition']) {
|
|
const cards = await capture(story, 'roles', roles);
|
|
for (const [index, id] of smallIds.entries()) check(cards.some(card => card.id === id && card.size === 'employee' && card.badge?.text === (index ? '셀장' : '팀장')), `${story}: small ${index ? 'cell' : 'team'} badge`, id);
|
|
const empty = await capture(story, 'no-role', noRole);
|
|
check(empty.some(card => card.id === leaderId && !card.badge && card.attributesHeight === 0), `${story}: leader without role has no badge/space`, leaderId);
|
|
}
|
|
}
|
|
} finally { await browser.close(); }
|
|
check(errors.length === 0, 'browser console/runtime errors', errors);
|
|
const baseline = JSON.parse(await readFile(`${output}/baseline.json`, 'utf8'));
|
|
const changed = [];
|
|
for (const [file, hash] of Object.entries(baseline)) {
|
|
const current = createHash('sha256').update(await readFile(file)).digest('hex');
|
|
if (current !== hash) changed.push(file);
|
|
}
|
|
if (!collisionRepair) check(changed.length === 3 && changed.includes('packages/org-chart/src/core/employee-card.ts') && changed.includes('packages/org-chart/src/styles/employee-card.css') && changed.includes('packages/org-chart/src/styles/summary-sections.css'), 'preserved SVG/logo/JSON/API/Stories/368 evidence/Guard; only 3 existing files changed', changed);
|
|
await writeFile(`${output}/${collisionRepair ? 'card-collision-repair' : 'browser'}.json`, JSON.stringify({ results, measurements, errors, changed, fixtureNote: 'Role/no-role scenarios alter only intercepted in-memory browser JSON modules; repository JSON is unchanged.' }, null, 2));
|
|
console.log(JSON.stringify({ checks: results.length, pass: results.filter(item => item.status === 'PASS').length, failures: results.filter(item => item.status === 'FAIL'), errors, changed }, null, 2));
|
|
if (results.some(item => item.status === 'FAIL')) process.exitCode = 1;
|