202 lines
16 KiB
JavaScript
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;
|