Files
HMDesign/examples/org-chart/verify-p5-1a.mjs
T

373 lines
29 KiB
JavaScript

import { loadPlaywright, launchChromium } from '../../scripts/orgchart-browser-runtime.mjs';
import http from 'node:http';
import path from 'node:path';
import fs from 'node:fs/promises';
import { fileURLToPath } from 'node:url';
// P5-1A only: preserve previous suites and the baseline-files.json snapshot.
// Copy the pre-change public Core dist to baseline-dist before rebuilding.
const root = fileURLToPath(new URL('../../', import.meta.url));
const output = path.join(root, 'test-results/org-chart-p5-1a');
const storyOrigin = process.env.ORGCHART_STORY_ORIGIN ?? 'http://127.0.0.1:6052';
const { chromium } = await loadPlaywright();
const fixedFixture = JSON.parse(await fs.readFile(path.join(root, 'examples/org-chart/data-p1.json'), 'utf8'));
const expandedFixture = JSON.parse(await fs.readFile(path.join(root, 'examples/org-chart/data-expanded.json'), 'utf8'));
const presets = [
{ id: 'hanmac', name: '한맥기술', primary: '#CB2E28', secondary: ['#1D4289'] },
{ id: 'saman', name: '삼안', primary: '#F08300', secondary: ['#6D6C6C'] },
{ id: 'jangheon-industry', name: '장헌산업', primary: '#4580A6', secondary: ['#92BED4'] },
{ id: 'jangheon', name: '장헌', primary: '#1D418D', secondary: ['#AE232A'] },
{ id: 'ptc', name: '피티씨', primary: '#84338E', secondary: ['#F7AF00', '#0066B5'] },
{ id: 'halla', name: '한라산업개발', primary: '#0068B7', secondary: ['#231815'] },
{ id: 'baron', name: '바론컨설턴트', primary: '#0C367F', secondary: [] },
];
const results = [], measurements = [], errors = [], warnings = [];
const check = (pass, name, detail) => results.push({ name, status: pass ? 'PASS' : 'FAIL', ...(detail === undefined ? {} : { detail }) });
const near = (a, b) => Number.isFinite(a) && Number.isFinite(b) && Math.abs(a - b) <= 0.35;
const rgb = hex => `rgb(${[1, 3, 5].map(offset => parseInt(hex.slice(offset, offset + 2), 16)).join(', ')})`;
const expectedCompanyIds = presets.map(preset => preset.id);
const mime = { '.html': 'text/html', '.js': 'text/javascript', '.mjs': 'text/javascript', '.css': 'text/css', '.json': 'application/json', '.ttf': 'font/ttf', '.svg': 'image/svg+xml', '.png': 'image/png' };
const shellHtml = '<!doctype html><html lang="ko"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><link rel="icon" href="data:,"><style>@font-face{font-family:"Noto Sans KR";src:url("/fonts/NotoSansKR-variable.ttf");font-weight:100 900;font-style:normal}*{box-sizing:border-box}html,body{margin:0;width:100%;min-height:100%;font-family:"Noto Sans KR",sans-serif}#chart{width:100%;height:100vh;min-width:0}</style></head><body><main id="chart"></main></body></html>';
await fs.mkdir(output, { recursive: true });
const server = http.createServer(async (request, response) => {
try {
const url = new URL(request.url, 'http://127.0.0.1');
if (url.pathname === '/__p51a') return response.writeHead(200, { 'Content-Type': 'text/html' }).end(shellHtml);
const pathname = decodeURIComponent(url.pathname);
const file = path.resolve(root, '.' + (pathname.startsWith('/fonts/') ? '/public' + pathname : pathname));
const relative = path.relative(root, file);
if (relative.startsWith('..') || path.isAbsolute(relative)) return response.writeHead(403).end();
const bytes = await fs.readFile(file);
response.writeHead(200, { 'Content-Type': mime[path.extname(file)] ?? 'application/octet-stream' });
response.end(bytes);
} catch { response.writeHead(404).end(); }
});
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
const origin = `http://127.0.0.1:${server.address().port}`;
const browser = await launchChromium({ headless: true });
async function newPage(name, viewport = { width: 1440, height: 900 }) {
const page = await browser.newPage({ viewport });
page.on('pageerror', error => errors.push({ name, type: 'pageerror', message: error.message }));
page.on('console', message => {
if (message.type() === 'error') errors.push({ name, type: 'console', message: message.text() });
if (message.type() === 'warning') warnings.push({ name, message: message.text() });
});
return page;
}
async function ready(page, mode) {
await page.waitForFunction(expected => {
const shadow = document.querySelector('.hm-orgchart-host')?.shadowRoot;
const content = shadow?.querySelector(expected === 'auto' ? '.hm-orgchart-expanded' : '.hm-orgchart-composition');
return content?.querySelector('.hm-orgchart-employee') && [...shadow.querySelectorAll('link[rel=stylesheet]')].every(link => link.sheet);
}, mode, { timeout: 25000 });
await page.evaluate(async () => {
await document.fonts.load('600 24px "Noto Sans KR"', '홍 길 동');
await document.fonts.ready;
const shadow = document.querySelector('.hm-orgchart-host').shadowRoot;
const images = [...shadow.querySelectorAll('.hm-orgchart-employee img')];
for (const image of images) image.loading = 'eager';
await Promise.all(images.map(image => image.decode().catch(() => {})));
await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)));
});
}
async function mount(page, { baseline = false, fixture = expandedFixture, options = {} } = {}) {
await page.goto(`${origin}/__p51a`);
await page.evaluate(async ({ base, fixture, options }) => {
window.hmModule = await import(`${base}/index.js`);
window.fixture = structuredClone(fixture);
window.handle = window.hmModule.createOrgChart({
container: document.getElementById('chart'),
dataSource: new window.hmModule.JsonOrgChartDataSource(fixture),
stylesheetUrls: { engine: `${base}/engine.css`, core: `${base}/style.css` },
...options,
});
await window.handle.mount();
}, { base: `${origin}/${baseline ? 'test-results/org-chart-p5-1a/baseline-dist' : 'packages/org-chart/dist'}`, fixture, options });
await ready(page, options.layoutMode === 'auto' ? 'auto' : 'fixed');
return page.evaluate(() => window.handle.getState());
}
async function measure(page) {
return page.evaluate(() => {
const shadow = document.querySelector('.hm-orgchart-host').shadowRoot;
const stage = shadow.querySelector('.hm-orgchart-stage');
const base = stage?.getBoundingClientRect() ?? shadow.querySelector('.hm-orgchart-expanded').getBoundingClientRect();
const scale = stage ? base.width / stage.offsetWidth : 1;
const box = element => {
const bounds = element.getBoundingClientRect();
return { x: (bounds.x - base.x) / scale, y: (bounds.y - base.y) / scale, width: bounds.width / scale, height: bounds.height / scale };
};
const cards = [...shadow.querySelectorAll('.hm-orgchart-employee')].map(card => ({
key: `${card.closest('.hm-orgchart-status-group')?.dataset.workStatus ?? 'tree'}:${card.dataset.employeeId}`,
id: card.dataset.employeeId, kind: card.dataset.cardKind, size: card.dataset.cardAssetSize ?? null,
box: box(card), asset: card.querySelector('.hm-orgchart-card-background')?.src ?? null,
logo: card.querySelector('.hm-orgchart-affiliation-mark img')?.src ?? null,
name: card.querySelector('.hm-orgchart-person-name')?.textContent,
role: card.querySelector('.hm-orgchart-person-role')?.textContent,
font: getComputedStyle(card.querySelector('.hm-orgchart-person-name')).fontFamily,
badgeCount: card.querySelectorAll('.hm-orgchart-job-title,.hm-orgchart-status-label,.hm-orgchart-person-attributes').length,
placeholder: card.dataset.photoState,
}));
const nodes = [...shadow.querySelectorAll('.node[data-organization-id]')].map(node => {
const heading = node.querySelector('.hm-orgchart-organization-name');
const style = getComputedStyle(heading);
return { id: node.dataset.organizationId, companyId: node.dataset.companyId ?? null,
depth: Number(node.dataset.organizationDepth), text: heading.textContent, box: box(heading),
backgroundColor: style.backgroundColor, backgroundImage: style.backgroundImage,
primary: node.style.getPropertyValue('--hm-orgchart-expanded-primary'),
secondary: [...node.style].filter(property => property.startsWith('--hm-orgchart-expanded-secondary-')).map(property => node.style.getPropertyValue(property)),
};
});
const collisions = [];
for (let index = 0; index < cards.length; index++) for (const other of cards.slice(index + 1)) {
const a = cards[index].box, b = other.box;
if (Math.min(a.x + a.width, b.x + b.width) - Math.max(a.x, b.x) > 0.5
&& Math.min(a.y + a.height, b.y + b.height) - Math.max(a.y, b.y) > 0.5) collisions.push([cards[index].key, other.key]);
}
const viewport = shadow.querySelector('.hm-orgchart-viewport');
const expanded = shadow.querySelector('.hm-orgchart-expanded');
const summaries = [...shadow.querySelectorAll('[data-component="PositionSummary"],[data-component="AffiliationSummary"],[data-component="StatusSummary"]')].map(element => ({ kind: element.dataset.component, text: element.textContent }));
return { scale, cards, nodes, summaries, collisions,
hostCount: document.querySelectorAll('.hm-orgchart-host').length,
compositionCount: shadow.querySelectorAll('.hm-orgchart-composition,.hm-orgchart-expanded').length,
controls: shadow.querySelectorAll('.hm-orgchart-toggle,.zoom-controls,.pan-controls,.minimap').length,
layoutStatus: expanded?.dataset.layoutStatus ?? null,
phaseText: expanded?.textContent ?? '',
viewport: viewport ? { clientWidth: viewport.clientWidth, scrollWidth: viewport.scrollWidth, clientHeight: viewport.clientHeight, scrollHeight: viewport.scrollHeight, overflowX: getComputedStyle(viewport).overflowX } : null,
document: { clientWidth: document.documentElement.clientWidth, scrollWidth: document.documentElement.scrollWidth, clientHeight: document.documentElement.clientHeight, scrollHeight: document.documentElement.scrollHeight },
};
});
}
function compareFixed(before, after, name) {
check(JSON.stringify(before.cards.map(({ box, ...rest }) => rest)) === JSON.stringify(after.cards.map(({ box, ...rest }) => rest)), `${name}: card assets/content/typography/placeholder unchanged`);
check(before.cards.length === after.cards.length && before.cards.every((card, index) => ['x', 'y', 'width', 'height'].every(key => near(card.box[key], after.cards[index].box[key]))), `${name}: fixed card coordinates and dimensions unchanged`);
check(before.nodes.length === after.nodes.length && before.nodes.every((node, index) => {
const next = after.nodes[index];
return node.id === next.id && node.depth === next.depth && node.text === next.text
&& node.backgroundColor === next.backgroundColor && node.backgroundImage === next.backgroundImage
&& ['x', 'y', 'width', 'height'].every(key => near(node.box[key], next.box[key]));
}), `${name}: hierarchy headers and Workdesk gradients unchanged`);
check(JSON.stringify(before.summaries) === JSON.stringify(after.summaries), `${name}: summary counts unchanged`);
check(after.controls === 0 && after.compositionCount === 1 && after.layoutStatus === null, `${name}: fixed read-only renderer retained`);
}
function assertExpanded(value, name) {
const representative = value.cards.filter(card => card.kind === 'topLeader');
check(value.layoutStatus === 'preview' && value.phaseText.includes('P5-1A') && value.phaseText.includes('후속'), `${name}: deferred work is explicitly marked preview`);
check(representative.length === 1 && near(representative[0].box.width, 146) && near(representative[0].box.height, 74)
&& representative[0].name === '홍 길 동' && representative[0].role === '대표이사' && representative[0].badgeCount === 0, `${name}: representative 146x74/name/position/zero badges`);
check(value.cards.filter(card => card.kind === 'leader').length === 3
&& value.cards.filter(card => card.kind === 'leader').every(card => near(card.box.width, 146) && near(card.box.height, 68)), `${name}: reused 1Depth leader 146x68 cards`);
check(value.cards.filter(card => card.kind === 'employee').length === 6
&& value.cards.filter(card => card.kind === 'employee').every(card => near(card.box.width, 108) && near(card.box.height, 52)), `${name}: reused small 108x52 employee cards`);
check(value.cards.every(card => card.font.includes('Noto Sans KR') && card.placeholder === 'placeholder' && card.asset), `${name}: approved assets/common photo fallback/Noto font reused`);
check(value.nodes.length === 3 && value.nodes.every(node => node.depth === 1) && value.controls === 0, `${name}: only initial 1Depth groups without collapse/zoom/pan controls`);
check(value.cards.length === 10 && value.collisions.length === 0 && value.hostCount === 1 && value.compositionCount === 1, `${name}: no duplicate or overlapping cards`, value.collisions);
}
async function popup(page, name, id) {
const card = page.locator(`.hm-orgchart-employee[data-employee-id="${id}"]`).first();
await card.focus();
await card.press('Enter');
await page.locator('dialog[open]').waitFor();
const data = await page.evaluate(() => {
const shadow = document.querySelector('.hm-orgchart-host').shadowRoot;
const dialog = shadow.querySelector('dialog[open]');
return { employeeId: dialog.dataset.employeeId, text: dialog.textContent, count: shadow.querySelectorAll('dialog[open]').length, activeInside: dialog.contains(shadow.activeElement) };
});
check(data.employeeId === id && data.count === 1 && data.activeInside, `${name}: keyboard opens shared employee popup`, data);
await page.keyboard.press('Tab');
check(await page.evaluate(() => {
const shadow = document.querySelector('.hm-orgchart-host').shadowRoot;
return shadow.querySelector('dialog[open]').contains(shadow.activeElement);
}), `${name}: popup Tab focus remains inside`);
await page.keyboard.press('Escape');
await page.locator('dialog[open]').waitFor({ state: 'detached' });
check(await page.evaluate(id => document.querySelector('.hm-orgchart-host').shadowRoot.activeElement?.dataset.employeeId === id, id), `${name}: Escape restores initiating card focus`);
return data.text;
}
async function scenario(name, run) {
try { await run(); }
catch (error) { check(false, `${name}: verification completed`, { message: error.message, stack: error.stack }); }
}
try {
let baselineFixed, defaultFixed, autoVanilla;
let baselinePopup;
await scenario('Fixed baseline', async () => {
const page = await newPage('baseline-fixed');
try {
const state = await mount(page, { baseline: true, fixture: fixedFixture, options: { displayMode: 'readOnly' } });
check(state.status === 'ready', 'baseline Fixed: existing public Core ready');
baselineFixed = await measure(page);
measurements.push({ name: 'baseline-fixed', ...baselineFixed });
baselinePopup = await popup(page, 'baseline Fixed', 'mock-leader-00');
await page.screenshot({ path: path.join(output, 'baseline-fixed-1440.png'), fullPage: true });
} finally { await page.close(); }
});
for (const explicit of [false, true]) await scenario(`Fixed ${explicit ? 'explicit' : 'default'}`, async () => {
const name = explicit ? 'explicit-fixed' : 'default-fixed';
const page = await newPage(name);
try {
const state = await mount(page, { fixture: fixedFixture, options: { displayMode: 'readOnly', ...(explicit ? { layoutMode: 'fixed', companyId: 'unknown-is-ignored-for-fixed' } : {}) } });
const value = await measure(page);
measurements.push({ name, ...value });
check(state.status === 'ready' && state.summary.raw.total === fixedFixture.employees.length, `${name}: ready with preserved employee count`);
if (baselineFixed) compareFixed(baselineFixed, value, name);
else check(false, `${name}: baseline required for regression comparison`);
if (!explicit) {
defaultFixed = value;
check(await popup(page, name, 'mock-leader-00') === baselinePopup, `${name}: existing popup field content unchanged`);
await page.screenshot({ path: path.join(output, 'current-fixed-1440.png'), fullPage: true });
}
} finally { await page.close(); }
});
await scenario('Auto public Core', async () => {
const page = await newPage('vanilla-auto');
try {
const state = await mount(page, { options: { layoutMode: 'auto', displayMode: 'readOnly' } });
check(state.status === 'ready' && state.summary.raw.total === expandedFixture.employees.length, 'Auto: JsonOrgChartDataSource and full data summary retained');
autoVanilla = await measure(page);
measurements.push({ name: 'vanilla-auto', ...autoVanilla });
assertExpanded(autoVanilla, 'Vanilla Auto');
const popupText = await popup(page, 'Auto representative', 'expanded-representative');
check(popupText.includes('대표이사') && popupText.includes('(주)한맥기술'), 'Auto popup: original employee/affiliation information');
await popup(page, 'Auto employee', 'expanded-employee-01');
const presetData = await page.evaluate(ids => ids.map(id => window.hmModule.getCompanyColorPreset(id)), expectedCompanyIds);
check(presets.every((preset, index) => presetData[index]?.companyId === preset.id && presetData[index].primary === preset.primary && JSON.stringify(presetData[index].secondary) === JSON.stringify(preset.secondary)), 'Public lookup: all 7 P5-0 primary/secondary presets unchanged', presetData);
const invalid = await page.evaluate(() => {
const base = { dataSource: new window.hmModule.JsonOrgChartDataSource(window.fixture), stylesheetUrls: { core: 'core.css', engine: 'engine.css' } };
return [{ layoutMode: 'invalid' }, { layoutMode: 'auto', companyId: 'unknown-company' }].map(options => {
try { window.hmModule.createOrgChart({ ...base, ...options }); return false; }
catch (error) { return error instanceof TypeError; }
});
});
check(invalid.every(Boolean), 'New options: invalid layout mode and unknown Auto preset rejected', invalid);
const lifecycle = await page.evaluate(async () => {
const changed = structuredClone(window.fixture);
changed.metadata.dataVersion = 'p5-1a-update';
const removed = 'talent-growth';
changed.organizations = changed.organizations.filter(organization => organization.id !== removed);
changed.employees = changed.employees.filter(employee => employee.organizationId !== removed);
changed.presentation.organizationOrder = changed.presentation.organizationOrder.filter(id => id !== removed);
changed.organizations.find(organization => organization.id === 'business-management').name = '갱신된 사업관리';
await window.handle.update(changed);
const shadow = document.querySelector('.hm-orgchart-host').shadowRoot;
const updated = { state: window.handle.getState().status, version: window.handle.getState().dataVersion,
compositions: shadow.querySelectorAll('.hm-orgchart-expanded').length, nodes: shadow.querySelectorAll('.node[data-organization-id]').length,
cards: shadow.querySelectorAll('.hm-orgchart-employee').length, stale: !!shadow.querySelector('[data-organization-id="talent-growth"]'), text: shadow.textContent.includes('갱신된 사업관리') };
await window.handle.reload();
const reloaded = { state: window.handle.getState().status, version: window.handle.getState().dataVersion,
compositions: shadow.querySelectorAll('.hm-orgchart-expanded').length, nodes: shadow.querySelectorAll('.node[data-organization-id]').length,
cards: shadow.querySelectorAll('.hm-orgchart-employee').length, stale: shadow.textContent.includes('갱신된 사업관리') };
return { updated, reloaded };
});
check(lifecycle.updated.state === 'ready' && lifecycle.updated.version === 'p5-1a-update' && lifecycle.updated.compositions === 1
&& lifecycle.updated.nodes === 2 && lifecycle.updated.cards === 7 && !lifecycle.updated.stale && lifecycle.updated.text, 'Auto update: replaces groups/cards without duplicates or stale DOM', lifecycle.updated);
check(lifecycle.reloaded.state === 'ready' && lifecycle.reloaded.version === expandedFixture.metadata.dataVersion && lifecycle.reloaded.compositions === 1
&& lifecycle.reloaded.nodes === 3 && lifecycle.reloaded.cards === 10 && !lifecycle.reloaded.stale, 'Auto reload: restores JSON source without stale DOM', lifecycle.reloaded);
await ready(page, 'auto');
await page.screenshot({ path: path.join(output, 'vanilla-auto-1440.png'), fullPage: true });
const deep = await page.evaluate(async () => {
const deep = structuredClone(window.fixture);
deep.organizations.push({ id: 'deferred-depth-2', parentId: 'business-management', name: '후속 2Depth', type: 'team', leaderId: null, displayOrder: 0 },
{ id: 'deferred-depth-3', parentId: 'deferred-depth-2', name: '후속 3Depth', type: 'cell', leaderId: null, displayOrder: 0 });
for (const [index, id] of ['deferred-depth-2', 'deferred-depth-3'].entries()) deep.employees.push({ id: `deferred-employee-${index}`, organizationId: id, affiliationId: 'hanmac', name: '가상 직원', position: '연구', jobTitle: null, profileImage: null, workStatus: 'active' });
deep.presentation.organizationOrder.push('deferred-depth-2', 'deferred-depth-3');
deep.metadata.dataVersion = 'p5-1b-deferred-fixture';
const validated = window.hmModule.validateOrgChartDataset(deep);
const normalized = validated.valid ? window.hmModule.normalizeOrgChartDataset(validated.data) : null;
await window.handle.update(deep);
const shadow = document.querySelector('.hm-orgchart-host').shadowRoot;
return { valid: validated.valid, state: window.handle.getState().status, total: window.handle.getState().summary?.raw.total,
deepest: normalized?.roots[0].children[0].children[0].children[0].organization.id,
rendered: [...shadow.querySelectorAll('.node[data-organization-id]')].map(node => node.dataset.organizationId),
deferredCards: shadow.querySelectorAll('[data-employee-id^="deferred-employee-"]').length,
retained: deep.organizations.length === window.fixture.organizations.length + 2 };
});
check(deep.valid && deep.state === 'ready' && deep.deepest === 'deferred-depth-3' && deep.total === expandedFixture.employees.length + 2
&& deep.rendered.length === 3 && !deep.rendered.some(id => id.startsWith('deferred-')) && deep.deferredCards === 0 && deep.retained,
'P5-1B boundary: 3Depth data retained and validated; descendants intentionally unplaced', deep);
const destroyed = await page.evaluate(() => {
window.handle.destroy(); window.handle.destroy();
return { status: window.handle.getState().status, children: document.getElementById('chart').children.length };
});
check(destroyed.status === 'destroyed' && destroyed.children === 0, 'Auto destroy: terminal/idempotent cleanup', destroyed);
} finally { await page.close(); }
});
await scenario('Auto default displayMode', async () => {
const page = await newPage('auto-default-display');
try {
await mount(page, { options: { layoutMode: 'auto' } });
const initial = await measure(page);
check(initial.controls === 0 && initial.nodes.length === 3 && initial.layoutStatus === 'preview', 'Auto without displayMode: initial preview, no collapse controls');
const transforms = await page.evaluate(() => [...document.querySelector('.hm-orgchart-host').shadowRoot.querySelectorAll('.hm-orgchart-engine')].map(element => element.style.transform));
const viewport = page.locator('.hm-orgchart-viewport');
await viewport.hover(); await page.mouse.wheel(0, -100);
const bounds = await viewport.boundingBox();
await page.mouse.move(bounds.x + bounds.width / 2, bounds.y + 10); await page.mouse.down();
await page.mouse.move(bounds.x + bounds.width / 2 + 40, bounds.y + 10, { steps: 4 }); await page.mouse.up();
const after = await page.evaluate(() => [...document.querySelector('.hm-orgchart-host').shadowRoot.querySelectorAll('.hm-orgchart-engine')].map(element => element.style.transform));
check(JSON.stringify(transforms) === JSON.stringify(after), 'Auto: wheel/drag do not activate engine zoom or pan');
} finally { await page.close(); }
});
await scenario('Actual Storybook', async () => {
const page = await newPage('storybook-auto');
try {
const response = await page.request.get(`${storyOrigin}/index.json`);
const index = await response.json();
const visible = Object.values(index.entries).filter(entry => entry.type === 'story' && entry.title === 'OrgChart' && !entry.tags?.includes('internal')).map(entry => entry.id).sort();
const expected = ['orgchart--logo-personnel-summary', 'orgchart--department-colors', 'orgchart--full-composition', 'orgchart--expanded-org-chart'].sort();
check(response.ok() && JSON.stringify(visible) === JSON.stringify(expected), 'Storybook: existing 3 public menus plus Expanded OrgChart only', visible);
await page.goto(`${storyOrigin}/iframe.html?id=orgchart--expanded-org-chart&viewMode=story`);
await ready(page, 'auto');
const vanillaStyles = autoVanilla?.cards.map(card => ({ id: card.id, kind: card.kind, width: card.box.width, height: card.box.height, asset: card.asset, font: card.font }));
const reactValue = await measure(page);
check(Boolean(vanillaStyles) && reactValue.cards.length === vanillaStyles.length && reactValue.cards.every((card, index) => {
const vanilla = vanillaStyles[index];
return card.id === vanilla.id && card.kind === vanilla.kind && near(card.box.width, vanilla.width) && near(card.box.height, vanilla.height) && card.asset === vanilla.asset && card.font === vanilla.font;
}), 'React Story / Vanilla: same Core fixture/cards/assets/font');
for (const preset of presets) {
const select = page.getByRole('combobox', { name: '회사별 컬러 테마' });
await select.click();
await page.getByRole('option', { name: preset.name, exact: true }).click();
await page.waitForFunction(companyId => {
const nodes = [...document.querySelector('.hm-orgchart-host')?.shadowRoot.querySelectorAll('.node[data-organization-id]') ?? []];
return nodes.length === 3 && nodes.every(node => node.dataset.companyId === companyId);
}, preset.id);
await ready(page, 'auto');
const value = await measure(page);
measurements.push({ name: `storybook-company-${preset.id}`, nodes: value.nodes });
check(value.nodes.every(node => node.companyId === preset.id && node.primary === preset.primary && node.backgroundColor === rgb(preset.primary)
&& JSON.stringify(node.secondary) === JSON.stringify(preset.secondary)), `HM Select: ${preset.name} primary and all secondary colors connected`, value.nodes);
}
await page.getByRole('combobox', { name: '회사별 컬러 테마' }).click();
await page.getByRole('option', { name: '한맥기술', exact: true }).click();
await page.waitForFunction(() => document.querySelector('.hm-orgchart-host')?.shadowRoot.querySelector('.node[data-company-id="hanmac"]'));
for (const width of [390, 768, 1280, 1440]) {
await page.setViewportSize({ width, height: width < 1000 ? 1024 : 900 });
await ready(page, 'auto');
const value = await measure(page);
measurements.push({ name: `storybook-${width}`, ...value });
assertExpanded(value, `Storybook ${width}`);
check(value.document.scrollWidth <= value.document.clientWidth + 1
&& value.viewport && ['auto', 'scroll'].includes(value.viewport.overflowX), `Storybook ${width}: horizontal overflow contained by Core viewport`, { document: value.document, viewport: value.viewport });
await page.screenshot({ path: path.join(output, `storybook-expanded-${width}.png`), fullPage: true });
}
await popup(page, 'React Auto employee', 'expanded-employee-01');
await page.goto(`${storyOrigin}/iframe.html?id=orgchart--full-composition&viewMode=story`);
await ready(page, 'fixed');
const value = await measure(page);
check(value.cards.length === defaultFixed?.cards.length && value.controls === 0 && value.layoutStatus === null && value.compositionCount === 1,
'Existing Full Composition Story: fixed read-only route retained');
} finally { await page.close(); }
});
} finally {
await browser.close();
await new Promise(resolve => server.close(resolve));
check(errors.length === 0, 'Focused browser scenarios: no new console/runtime errors', errors);
const summary = { total: results.length, pass: results.filter(result => result.status === 'PASS').length, fail: results.filter(result => result.status === 'FAIL').length };
await fs.writeFile(path.join(output, 'browser.json'), JSON.stringify({ scope: 'P5-1A only', browser: chromium.name(), summary, results, measurements, errors, warnings,
notVerified: ['P5-1B recursive automatic layout/recalculation/collision solver', 'Collapse/expand and Zoom/Pan implementation', 'Arbitrary data/themes/browsers and real touch/screen reader speech', 'Entire historical P2/P3 suites and complete Storybook production build'] }, null, 2));
console.log(JSON.stringify(summary));
if (summary.fail) process.exitCode = 1;
}