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

206 lines
17 KiB
JavaScript

import { loadPlaywright, launchChromium } from '../../scripts/orgchart-browser-runtime.mjs';
import http from 'node:http';
import path from 'node:path';
import { readFile, writeFile, mkdir } from 'node:fs/promises';
import { fileURLToPath } from 'node:url';
// Stage 2 only: typography, data placement, overflow and accessible tooltip.
// Do not repeat Stage 1's gradient/data/Summary/engine suites.
const root = fileURLToPath(new URL('../../', import.meta.url));
const output = path.join(root, 'test-results/org-chart-card-content');
await mkdir(output, { recursive: true });
await loadPlaywright();
const results = [], errors = [], measurements = [], 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' };
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="40" height="40"><circle cx="20" cy="20" r="20" 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] .hm-orgchart-person-name[data-name-overflow]')
&& [...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 rect = element => { const b = element.getBoundingClientRect(); return { left: b.left, right: b.right, top: b.top, bottom: b.bottom, width: b.width, height: b.height }; };
const typography = element => {
const style = getComputedStyle(element);
return Object.fromEntries(['color', 'fontSize', 'fontWeight', 'lineHeight', 'letterSpacing', 'margin', 'padding', 'whiteSpace', 'textOverflow', 'fontFamily'].map(key => [key, style[key]]));
};
const cards = [...shadow.querySelectorAll('[data-card-asset]')].map(card => {
const role = card.querySelector('.hm-orgchart-person-role'), name = card.querySelector('.hm-orgchart-person-name');
const avatar = card.querySelector('.hm-orgchart-avatar'), logo = card.querySelector('.hm-orgchart-affiliation-mark');
const bounds = rect(card), roleBounds = rect(role), nameBounds = rect(name), issues = [];
const overlap = (a, 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;
const contained = b => b.left >= bounds.left - 1 && b.right <= bounds.right + 1 && b.top >= bounds.top - 1 && b.bottom <= bounds.bottom + 1;
for (const [label, element] of [['role', role], ['name', name], ['avatar', avatar], ['logo', logo]]) if (!contained(rect(element))) issues.push(`${label} outside card`);
for (const element of [role, name]) {
// A clipped name's DOM Range intentionally retains the full text width;
// visible text is intersected with its clipping box for collision checks.
const range = document.createRange(); range.selectNodeContents(element);
for (const r of range.getClientRects()) {
const box = rect(element);
const visible = { left: Math.max(r.left, box.left), right: Math.min(r.right, box.right), top: Math.max(r.top, box.top), bottom: Math.min(r.bottom, box.bottom) };
if (overlap(visible, rect(avatar))) issues.push(`${element.className}: avatar overlap`);
if (overlap(visible, rect(logo))) issues.push(`${element.className}: logo overlap`);
}
}
const badge = card.querySelector('.hm-orgchart-job-title');
if (badge) {
if (!contained(rect(badge))) issues.push('badge outside card');
if (overlap(rect(badge), rect(avatar)) || overlap(rect(badge), nameBounds)) issues.push('badge overlap');
}
return { id: card.dataset.employeeId, size: card.dataset.cardAssetSize, name: name.textContent, role: typography(role), person: typography(name),
width: bounds.width, height: bounds.height, avatarWidth: rect(avatar).width, avatarHeight: rect(avatar).height,
textGap: nameBounds.top - roleBounds.bottom, cssGap: getComputedStyle(card.querySelector('.hm-orgchart-person-text')).gap,
overflow: name.dataset.nameOverflow, focusable: name.getAttribute('tabindex'), description: !!name.getAttribute('aria-describedby'),
nameWidth: name.clientWidth, nameScrollWidth: name.scrollWidth, issues,
imageLoaded: !!avatar.querySelector('img')?.naturalWidth,
logoTransparent: getComputedStyle(logo.querySelector('img')).backgroundColor === 'rgba(0, 0, 0, 0)',
fallbackHidden: getComputedStyle(logo.querySelector('.hm-orgchart-affiliation-fallback')).visibility === 'hidden' };
});
const viewport = shadow.querySelector('.hm-orgchart-viewport');
return { cards, pageOverflow: document.documentElement.scrollWidth > innerWidth + 1,
extents: { document: [document.documentElement.scrollWidth, document.documentElement.scrollHeight, document.documentElement.clientWidth, document.documentElement.clientHeight],
viewport: viewport ? [viewport.scrollWidth, viewport.scrollHeight, viewport.clientWidth, viewport.clientHeight] : null },
orphanTooltips: [...shadow.querySelectorAll('[role=tooltip]')].filter(tip => !shadow.querySelector(`[aria-describedby="${tip.id}"]`)).length };
});
}
function verifyTypography(value, label) {
for (const size of ['representative', 'leader', 'employee']) {
const cards = value.cards.filter(card => card.size === size), small = size === 'employee';
check(cards.length > 0 && cards.every(card => card.role.color === (size === 'representative' ? 'rgb(34, 67, 135)' : 'rgb(121, 121, 121)')
&& card.role.fontSize === (small ? '15px' : '20px') && card.role.fontWeight === '400' && card.role.lineHeight === (small ? '18px' : '22px')
&& card.role.letterSpacing === (small ? '-0.6px' : '-0.8px')), `${label}/${size}: position exact computed typography`);
check(cards.every(card => card.person.color === 'rgb(7, 29, 58)' && card.person.fontSize === (small ? '20px' : '24px') && card.person.fontWeight === '700'
&& card.person.lineHeight === (small ? '24px' : '30px') && card.person.letterSpacing === (size === 'leader' ? '-0.96px' : '-0.8px')), `${label}/${size}: name exact computed typography`);
check(cards.every(card => card.cssGap === '0px' && card.textGap === 0 && card.role.margin === '0px' && card.role.padding === '0px'
&& card.person.margin === '0px' && card.person.padding === '0px'), `${label}/${size}: zero gap/margins/padding between two text rows`);
check(cards.every(card => card.width === (small ? 108 : 146) && card.height === (size === 'representative' ? 74 : small ? 52 : 68)
&& card.avatarWidth === (size === 'representative' ? 36 : small ? 24 : 32) && card.avatarHeight === card.avatarWidth), `${label}/${size}: original card/profile dimensions retained`);
}
check(value.cards.every(card => card.issues.length === 0), `${label}: content collisions/bounds`, value.cards.filter(card => card.issues.length));
check(value.cards.every(card => card.logoTransparent && card.fallbackHidden), `${label}: transparent original logos/fallback retained`);
check(!value.pageOverflow && value.orphanTooltips === 0, `${label}: no new page overflow/orphan tooltip`);
}
async function tooltipChecks(page, label) {
const names = page.locator('[data-card-asset] .hm-orgchart-person-name[data-name-overflow=true]');
for (const size of ['representative', 'leader', 'employee']) {
const name = page.locator(`[data-card-asset-size="${size}"] .hm-orgchart-person-name[data-name-overflow=true]`).first();
await name.scrollIntoViewIfNeeded();
await name.hover();
const description = await name.getAttribute('aria-describedby'), tip = page.locator(`#${description}`);
check(await tip.isVisible() && await tip.innerText() === await name.innerText(), `${label}/${size}: hover shows complete original name`);
await tip.hover();
check(await tip.isVisible(), `${label}/${size}: tooltip hoverable`);
await page.mouse.move(0, 0);
await page.waitForFunction(() => [...document.querySelector('.hm-orgchart-host, .hm-orgchart-component-host').shadowRoot.querySelectorAll('[role=tooltip]')].every(tip => tip.hidden));
// Tab through the real keyboard order from this explicit preceding control.
await name.evaluate(element => { const previous = document.createElement('button'); previous.textContent = 'keyboard test start'; element.before(previous); previous.focus(); });
await page.keyboard.press('Tab');
check(await name.evaluate(element => element.getRootNode().activeElement === element) && await tip.isVisible(), `${label}/${size}: Tab focus reveals full name`);
check((await name.ariaSnapshot()).includes(await name.innerText()) && await name.getAttribute('aria-describedby') === await tip.getAttribute('id'), `${label}/${size}: full text retained in Chromium accessibility representation/description`);
await page.keyboard.press('Escape');
check(!(await tip.isVisible()) && await name.evaluate(element => element.getRootNode().activeElement === element), `${label}/${size}: Escape dismisses, focus retained`);
const tipBox = await tip.evaluate(element => { element.hidden = false; const b = element.getBoundingClientRect(); element.hidden = true; return { left: b.left, right: b.right, top: b.top, bottom: b.bottom }; });
const screen = page.viewportSize();
check(tipBox.left >= 0 && tipBox.right <= screen.width + 1 && tipBox.top >= 0 && tipBox.bottom <= screen.height + 1, `${label}/${size}: tooltip inside browser viewport`);
await name.evaluate(element => element.previousElementSibling.remove());
}
check(await names.count() === 3, `${label}: only the three explicit long-name cases are focusable`);
}
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 launchChromium({ headless: true });
const context = await browser.newContext({ viewport: { width: 1440, height: 1000 }, reducedMotion: 'reduce' });
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`]];
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 }); verifyTypography(value, `${label}/${width}`);
check(value.cards.every(card => /^[가-힣]{2,3}$/.test(card.name) && card.overflow === 'false' && card.focusable === null && card.person.textOverflow === 'clip'), `${label}/${width}: natural short names remain complete and outside tab order`);
}
if (label === 'department-colors') {
await page.locator('.hm-orgchart-representative-preview').screenshot({ path: path.join(output, 'representative-normal.png') });
for (const [index, department] of [[0, 'business-management'], [1, 'business-planning'], [3, 'integrated-system']]) await page.locator('.hm-orgchart-preview-grid').nth(index).screenshot({ path: path.join(output, `${department}-normal.png`) });
await page.getByRole('button', { name: '긴 이름 검증 시작' }).click(); await ready(page);
for (const width of [390, 1440]) {
await page.setViewportSize({ width, height: 1000 }); await ready(page);
const value = await measure(page); measurements.push({ label: 'storybook-long', width, ...value }); verifyTypography(value, `storybook-long/${width}`);
check(value.cards.filter(card => card.overflow === 'true').length === 3 && value.cards.filter(card => card.overflow === 'true').every(card => card.person.textOverflow === 'ellipsis' && card.focusable === '0' && card.description), `storybook-long/${width}: ellipsis and focus only on overflowing names`);
await tooltipChecks(page, `storybook-long/${width}`);
}
await page.locator('.hm-orgchart-representative-preview').scrollIntoViewIfNeeded();
await page.locator('[data-card-asset-size=representative] .hm-orgchart-person-name').focus();
await page.screenshot({ path: path.join(output, 'representative-long-focus.png') });
await page.getByRole('button', { name: '긴 이름 검증 종료' }).click(); await ready(page);
check((await measure(page)).orphanTooltips === 0 && await page.locator('[role=tooltip]').count() === 0, 'Story long -> normal disposes tooltip nodes');
}
if (label === 'full-composition') {
await page.locator('.node[data-organization-id=business-management]').scrollIntoViewIfNeeded();
await page.screenshot({ path: path.join(output, 'composition-1440.png') });
}
if (label === 'html') {
await page.evaluate(async () => {
const next = structuredClone(window.OrgChartP1.data);
for (const id of ['mock-leader-00', 'mock-leader-01', 'mock-employee-01']) {
const person = next.employees.find(item => item.id === id);
person.name = '아주긴가상이름표시검증사례'; person.profileImage = `${location.origin}/profile-fixture.svg`;
}
await window.OrgChartP1.setData(next);
}); await ready(page);
const value = await measure(page); verifyTypography(value, 'html-long-images');
check(value.cards.filter(card => card.overflow === 'true').every(card => card.imageLoaded), 'long names keep original-size profile images');
await tooltipChecks(page, 'html-long-images');
}
await page.close();
}
for (const width of [390, 768, 1280, 1440]) {
const html = measurements.find(item => item.label === 'html' && item.width === width);
const react = measurements.find(item => item.label === 'react' && item.width === width);
check(JSON.stringify(html.cards) === JSON.stringify(react.cards), `HTML/React computed typography/content parity/${width}`);
}
const before = JSON.parse(await readFile(path.join(output, 'data-p1-before.json'), 'utf8'));
const after = JSON.parse(await readFile(path.join(root, 'examples/org-chart/data-p1.json'), 'utf8'));
for (const employee of before.employees) employee.name = after.employees.find(item => item.id === employee.id).name;
check(JSON.stringify(before) === JSON.stringify(after), 'fixture change is exclusively synthetic display names; contract/count/membership/profile preserved');
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, issues: result.detail?.length })) }, null, 2));
if (failures.length) process.exitCode = 1;