Files
HMDesign/examples/org-chart/verify-card-design.mjs
T

202 lines
16 KiB
JavaScript

import http from 'node:http';
import path from 'node:path';
import { readFile, writeFile, mkdir, readdir } from 'node:fs/promises';
import { createHash } from 'node:crypto';
import { fileURLToPath, pathToFileURL } from 'node:url';
// Verify only the new card assets/layout; do not repeat Core/Summary suites.
const root = fileURLToPath(new URL('../../', import.meta.url));
const output = path.join(root, 'test-results/org-chart-card-design');
await mkdir(output, { recursive: true });
const { chromium } = await import(process.env.ORGCHART_PLAYWRIGHT_MODULE ? pathToFileURL(process.env.ORGCHART_PLAYWRIGHT_MODULE).href : 'playwright');
const results = [], measurements = [], errors = [], servers = [];
const check = (value, name, detail) => results.push({ name, status: value ? 'PASS' : 'FAIL', ...(detail === undefined ? {} : { detail }) });
const mime = { '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css', '.json': 'application/json', '.ttf': 'font/ttf', '.svg': 'image/svg+xml' };
async function serve(directory) {
const server = http.createServer(async (request, response) => {
try {
const url = new URL(request.url, 'http://127.0.0.1');
if (url.pathname === '/profile-fixture.svg') {
response.setHeader('Content-Type', 'image/svg+xml');
return response.end('<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32"><circle cx="16" cy="16" r="16" fill="#45639f"/></svg>');
}
const file = path.resolve(directory, '.' + decodeURIComponent(url.pathname === '/' ? '/index.html' : url.pathname));
const relative = path.relative(directory, file);
if (relative.startsWith('..') || path.isAbsolute(relative)) return response.writeHead(403).end();
response.setHeader('Content-Type', mime[path.extname(file)] ?? 'application/octet-stream');
response.end(await readFile(file));
} catch { response.writeHead(404).end(); }
});
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
servers.push(server);
return `http://127.0.0.1:${server.address().port}`;
}
async function ready(page) {
await page.waitForFunction(() => {
const shadow = document.querySelector('.hm-orgchart-host, .hm-orgchart-component-host')?.shadowRoot;
return shadow?.querySelector('[data-card-asset]') && [...shadow.querySelectorAll('link')].every(link => !!link.sheet);
});
await page.evaluate(async () => {
await document.fonts.ready;
const shadow = document.querySelector('.hm-orgchart-host, .hm-orgchart-component-host').shadowRoot;
await Promise.all([...shadow.querySelectorAll('img')].map(img => img.decode().catch(() => {})));
});
}
async function measure(page) {
return page.evaluate(() => {
const shadow = document.querySelector('.hm-orgchart-host, .hm-orgchart-component-host').shadowRoot;
const cards = [...shadow.querySelectorAll('[data-card-asset]')].map(card => {
const bounds = card.getBoundingClientRect(), background = card.querySelector('.hm-orgchart-card-background');
const avatar = card.querySelector('.hm-orgchart-avatar').getBoundingClientRect();
const logo = card.querySelector('.hm-orgchart-affiliation-mark')?.getBoundingClientRect();
const style = getComputedStyle(card), issues = [];
const contained = rect => rect.left >= bounds.left - 1 && rect.right <= bounds.right + 1 && rect.top >= bounds.top - 1 && rect.bottom <= bounds.bottom + 1;
const overlap = (a, b) => b && Math.min(a.right, b.right) - Math.max(a.left, b.left) > 1 && Math.min(a.bottom, b.bottom) - Math.max(a.top, b.top) > 1;
if (!contained(avatar)) issues.push('avatar outside card');
if (logo && !contained(logo)) issues.push('logo outside card');
for (const element of card.querySelectorAll('.hm-orgchart-person-name, .hm-orgchart-person-role, .hm-orgchart-job-title, .hm-orgchart-status-label')) {
const range = document.createRange(); range.selectNodeContents(element);
for (const rect of range.getClientRects()) {
if (!contained(rect)) issues.push(`${element.className}: text outside card`);
if (overlap(rect, avatar)) issues.push(`${element.className}: avatar overlap`);
if (overlap(rect, logo)) issues.push(`${element.className}: logo overlap`);
}
}
return { id: card.dataset.employeeId, department: card.dataset.cardAsset, size: card.dataset.cardAssetSize, kind: card.dataset.cardKind, inTopSlot: !!card.closest('.hm-orgchart-top-leaders'),
width: bounds.width, height: bounds.height, backgroundLoaded: background.complete && background.naturalWidth > 0,
naturalWidth: background.naturalWidth, naturalHeight: background.naturalHeight, embedded: background.src.startsWith('data:image/svg+xml'),
border: style.borderWidth, radius: style.borderRadius, fill: style.backgroundColor,
decoration: getComputedStyle(card, '::before').content, issues,
logoLoaded: !!card.querySelector('.hm-orgchart-affiliation-mark img')?.naturalWidth,
fallbackHidden: getComputedStyle(card.querySelector('.hm-orgchart-affiliation-fallback')).visibility === 'hidden',
logoBackground: getComputedStyle(card.querySelector('.hm-orgchart-affiliation-mark img')).backgroundColor,
avatarImage: !!card.querySelector('.hm-orgchart-avatar img')?.naturalWidth };
});
return { cards, documentOverflow: document.documentElement.scrollWidth > innerWidth + 1,
legacyCards: [...shadow.querySelectorAll('.hm-orgchart-employee:not([data-card-asset])')].map(card => ({ id: card.dataset.employeeId, status: card.dataset.workStatus, kind: card.dataset.cardKind })),
contrastCount: shadow.querySelectorAll('.hm-orgchart-contrast-result').length,
headings: [...shadow.querySelectorAll('.hm-orgchart-color-department-name')].map(item => item.textContent) };
});
}
function verify(value, label) {
check(value.cards.length > 0 && value.cards.every(card => card.backgroundLoaded && card.embedded), `${label}: actual embedded SVGs load`);
check(value.cards.every(card => card.width === (card.size === 'employee' ? 108 : 146) && card.height === (card.size === 'representative' ? 74 : card.size === 'leader' ? 68 : 52)
&& card.naturalWidth === card.width && card.naturalHeight === card.height), `${label}: native card sizes/ratios`);
check(value.cards.every(card => card.size === (card.kind === 'topLeader' ? 'representative' : card.kind === 'employee' ? 'employee' : 'leader')), `${label}: representative/leader/employee remain distinct`);
check(value.cards.every(card => card.border === '0px' && card.radius === '0px' && card.fill === 'rgba(0, 0, 0, 0)' && card.decoration === 'none'), `${label}: original SVG shape without legacy box decoration`);
check(value.cards.every(card => card.issues.length === 0), `${label}: text/avatar/logo bounds and collisions`, value.cards.filter(card => card.issues.length));
check(value.cards.every(card => card.logoLoaded && card.fallbackHidden && card.logoBackground === 'rgba(0, 0, 0, 0)'), `${label}: existing transparent logos/fallback preserved`);
check(!value.documentOverflow, `${label}: no page overflow`);
}
let browser;
try {
const origins = await Promise.all(['storybook-static', 'examples/org-chart/dist', 'examples/org-chart-react/dist'].map(directory => serve(path.join(root, directory))));
browser = await chromium.launch({ executablePath: process.env.ORGCHART_CHROME ?? 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe', headless: true });
const context = await browser.newContext({ viewport: { width: 1440, height: 1000 }, deviceScaleFactor: 1, reducedMotion: 'reduce' });
const index = JSON.parse(await readFile(path.join(root, 'storybook-static/index.json'), 'utf8'));
const entries = Object.values(index.entries).filter(entry => entry.type === 'story' && entry.title.startsWith('OrgChart'));
check(entries.filter(entry => !entry.tags?.includes('internal')).map(entry => entry.id).sort().join() === ['logo', 'department-colors', 'position-summary', 'affiliation-summary', 'full-composition'].map(id => `orgchart--${id}`).sort().join(), 'five public Storybook menus preserved');
check(entries.filter(entry => entry.title === 'OrgChart/Content' && entry.tags.includes('internal')).length === 8, 'eight internal Stories preserved');
const assets = await readdir(path.join(root, 'packages/org-chart/src/assets/cards'));
const source = process.env.ORGCHART_CARD_SOURCE;
if (source) for (const file of assets.filter(file => file.endsWith('.svg') && !['대표자.svg', '사업관리팀.svg', '사업관리팀-sm.svg'].includes(file))) {
const hash = buffer => createHash('sha256').update(buffer).digest('hex');
check(hash(await readFile(path.join(source, file))) === hash(await readFile(path.join(root, 'packages/org-chart/src/assets/cards', file))), `original SHA-256 preserved: ${file}`);
}
const routes = [
['department-colors', `${origins[0]}/iframe.html?id=orgchart--department-colors&viewMode=story`],
['full-composition', `${origins[0]}/iframe.html?id=orgchart--full-composition&viewMode=story`],
['html', `${origins[1]}/p1.html`], ['react', `${origins[2]}/p1.html`],
['file-classic', pathToFileURL(path.join(root, 'examples/org-chart/dist/p1-classic.html')).href],
];
for (const [label, url] of routes) {
const page = await context.newPage();
page.on('pageerror', error => errors.push({ label, message: error.message }));
page.on('console', message => { if (message.type() === 'error') errors.push({ label, message: message.text() }); });
await page.goto(url); await ready(page);
for (const width of [390, 768, 1280, 1440]) {
await page.setViewportSize({ width, height: 1000 }); await ready(page);
const value = await measure(page); measurements.push({ label, width, ...value }); verify(value, `${label}/${width}`);
if (label === 'department-colors') {
check(value.cards.length === 13 && new Set(value.cards.filter(card => card.department !== 'representative').map(card => card.department)).size === 6, `${label}/${width}: six departments, both card sizes plus representative`);
check(value.contrastCount === 24 && value.headings.length === 6, `${label}/${width}: existing contrast display retained`);
} else {
check(new Set(value.cards.filter(card => card.department !== 'representative').map(card => card.department)).size === 6, `${label}/${width}: all six mapped branches present`);
check(value.cards.filter(card => card.department === 'representative').length === 1 && value.cards.some(card => card.id === 'mock-leader-00' && card.size === 'representative' && card.inTopSlot), `${label}/${width}: representative alone in top-leader slot`);
check(value.cards.some(card => card.id === 'mock-leader-01' && card.department === 'business-management' && card.size === 'leader')
&& value.cards.some(card => card.id === 'mock-employee-01' && card.department === 'business-management' && card.size === 'employee'), `${label}/${width}: business-management maps its own two sizes`);
check(value.legacyCards.some(card => card.status !== 'active'), `${label}/${width}: supplementary status cards unchanged`);
}
}
if (label === 'department-colors') {
await page.screenshot({ path: path.join(output, 'department-colors-1440.png'), fullPage: true });
await page.locator('.hm-orgchart-representative-preview').screenshot({ path: path.join(output, 'representative-card.png') });
for (const [number, department] of [[0, 'business-management'], [1, 'business-planning'], [2, 'talent-growth'], [3, 'integrated-system'], [4, 'design-planning'], [5, 'solution-sales']]) {
await page.locator('.hm-orgchart-preview-grid').nth(number).screenshot({ path: path.join(output, `${department}-cards.png`) });
}
}
if (label === 'full-composition') {
for (const department of ['business-management', 'business-planning', 'integrated-system', 'solution-sales']) {
await page.locator(`.node[data-organization-id="${department}"]`).scrollIntoViewIfNeeded();
await page.screenshot({ path: path.join(output, `composition-${department}-1440.png`) });
}
}
if (label === 'html') {
// A local image fixture tests the retained profile data layer without
// changing the source JSON or copying personal employee data.
await page.evaluate(async () => {
const next = structuredClone(window.OrgChartP1.data);
for (const id of ['mock-leader-00', 'mock-leader-02', 'mock-employee-02']) next.employees.find(item => item.id === id).profileImage = `${location.origin}/profile-fixture.svg`;
await window.OrgChartP1.setData(next);
});
await ready(page);
const value = await measure(page);
const profiles = value.cards.filter(card => ['mock-leader-00', 'mock-leader-02', 'mock-employee-02'].includes(card.id));
check(profiles.length === 3 && profiles.every(card => card.avatarImage && card.issues.length === 0), 'representative/leader/employee profile images retain bounds over SVG');
}
await page.close();
}
for (const width of [390, 768, 1280, 1440]) {
const canonical = value => JSON.stringify(value.cards);
const html = measurements.find(item => item.label === 'html' && item.width === width);
const react = measurements.find(item => item.label === 'react' && item.width === width);
const classic = measurements.find(item => item.label === 'file-classic' && item.width === width);
check(canonical(html) === canonical(react) && canonical(html) === canonical(classic), `HTML/React/file:// shared card rendering parity/${width}`);
}
const colorPage = await context.newPage();
const reference = await readFile(path.join(output, 'business-management-reference.png'));
const vector = await readFile(path.join(root, 'packages/org-chart/src/assets/cards/사업관리팀.svg'));
const comparison = await colorPage.evaluate(async ({ reference, vector }) => {
const render = async (src) => {
const image = new Image(); image.src = src; await image.decode();
const canvas = document.createElement('canvas'); canvas.width = 408; canvas.height = 191;
const ctx = canvas.getContext('2d'); ctx.drawImage(image, 0, 0, 408, 191);
return { pixels: ctx.getImageData(0, 0, 408, 191).data, png: canvas.toDataURL() };
};
const a = await render(reference), b = await render(vector), errors = [];
for (let i = 0; i < a.pixels.length; i += 4) {
for (let channel = 0; channel < 3; channel++) {
const ca = a.pixels[i + channel] * a.pixels[i + 3] / 255 + 255 - a.pixels[i + 3];
const cb = b.pixels[i + channel] * b.pixels[i + 3] / 255 + 255 - b.pixels[i + 3];
errors.push(Math.abs(ca - cb));
}
}
const mean = errors.reduce((sum, value) => sum + value, 0) / errors.length;
errors.sort((x, y) => x - y);
return { meanRGBChannelError: mean, percentile95ChannelError: errors[Math.floor(errors.length * 0.95)], generatedPreview: b.png };
}, { reference: `data:image/png;base64,${reference.toString('base64')}`, vector: `data:image/svg+xml;base64,${vector.toString('base64')}` });
await writeFile(path.join(output, 'business-management-generated.png'), Buffer.from(comparison.generatedPreview.split(',')[1], 'base64'));
delete comparison.generatedPreview;
await writeFile(path.join(output, 'business-management-comparison.json'), JSON.stringify({ ...comparison, method: 'Chrome canvas, reference and generated SVG at reference raster dimensions, RGB over white, all pixels. Geometry/antialias differences included; not a perceptual certification.' }, null, 2));
check(comparison.meanRGBChannelError < 3 && comparison.percentile95ChannelError < 8, 'business-management PNG versus SVG rendered color similarity', comparison);
await colorPage.close();
check(errors.length === 0, 'browser console/runtime errors', errors);
} finally {
await writeFile(path.join(output, 'browser.json'), JSON.stringify({ browser: browser?.version(), results, errors, measurements }, null, 2));
await browser?.close();
for (const server of servers) await new Promise(resolve => server.close(resolve));
}
const failures = results.filter(result => result.status === 'FAIL');
console.log(JSON.stringify({ checks: results.length, failures: failures.map(result => ({ name: result.name, detail: result.detail?.length })) }, null, 2));
if (failures.length) process.exitCode = 1;