feat(org-chart): finalize chart features and AI generation rules

This commit is contained in:
shkim4 committed 2026-10-13 14:39:42 +09:00
1 parent 7614507583
commit d0828a7ff4
80 files changed
+8152 -221

No files matched your search

+545
View File
@@ -0,0 +1,545 @@
// P7 change-only integration verification. Historical suites are not replayed.
import fs from 'node:fs/promises';
import { writeFileSync } from 'node:fs';
import http from 'node:http';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { launchChromium } from '../../scripts/orgchart-browser-runtime.mjs';
const root = fileURLToPath(new URL('../../', import.meta.url));
const output = path.join(root, 'test-results/org-chart-p7');
await fs.mkdir(output, { recursive: true });
const template = JSON.parse(await fs.readFile(path.join(root, 'examples/org-chart/data-expanded-large.json'), 'utf8'));
const small = JSON.parse(await fs.readFile(path.join(root, 'examples/org-chart/data-expanded-small.json'), 'utf8'));
const fixed = JSON.parse(await fs.readFile(path.join(root, 'examples/org-chart/data-p1.json'), 'utf8'));
const results = [], measurements = [], errors = [];
const only = process.argv.slice(2);
const run = section => (!only.length && !['repair', 'react-repair', 'options-tail', 'story-menu', 'lr-reference', 'lr-residue', 'performance', 'dialog-narrow'].includes(section)) || only.includes(section);
const check = (pass, name, detail) => {
results.push({ name, status: pass ? 'PASS' : 'FAIL', detail });
writeFileSync(path.join(output, 'progress.json'), JSON.stringify({ results }, null, 2));
if (!pass) console.log('FAIL', name, JSON.stringify(detail));
};
const mime = { '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css', '.ttf': 'font/ttf', '.json': 'application/json', '.svg': 'image/svg+xml' };
const server = http.createServer(async (request, response) => {
try {
const pathname = decodeURIComponent(new URL(request.url, 'http://localhost').pathname);
if (pathname === '/favicon.ico') return response.writeHead(204).end();
if (pathname === '/shell') return response.writeHead(200, { 'Content-Type': 'text/html' }).end('<!doctype html><html lang="ko"><head><link rel="icon" href="data:,"><style>@font-face{font-family:"Noto Sans KR";src:url(/public/fonts/NotoSansKR-variable.ttf);font-weight:100 900}body{margin:0}#chart{width:100%}</style></head><body><main id="chart"></main></body></html>');
const file = path.resolve(root, pathname.startsWith('/assets/') ? 'test-results/org-chart-p7/react-build/dist' + pathname : '.' + pathname);
const relative = path.relative(root, file);
if (relative.startsWith('..') || path.isAbsolute(relative)) return response.writeHead(403).end();
response.writeHead(200, { 'Content-Type': mime[path.extname(file)] ?? 'application/octet-stream' }).end(await fs.readFile(file));
} 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 });
const page = await browser.newPage({ viewport: { width: 1440, height: 1100 } });
page.on('pageerror', e => errors.push(e.message));
page.on('console', m => { if (m.type() === 'error') errors.push({ message: m.text(), location: m.location() }); });
const settle = () => page.evaluate(async () => { await document.fonts.ready; for (let i = 0; i < 5; i++) await new Promise(resolve => requestAnimationFrame(resolve)); });
async function ready(count) {
await page.waitForFunction(count => {
const s = document.querySelector('.hm-orgchart-host')?.shadowRoot;
return s && (count === undefined || s.querySelectorAll('.hm-orgchart-employee').length === count) && [...s.querySelectorAll('link')].every(link => link.sheet) && !s.querySelector('.hm-orgchart-shell[aria-busy="true"]');
}, count);
await settle();
}
async function mount(data, options = {}, baseline = false) {
await page.goto(origin + '/shell');
await page.evaluate(async ({ origin, data, options, baseline }) => {
const base = origin + (baseline ? '/test-results/org-chart-p7/baseline-dist' : '/packages/org-chart/dist');
window.mod = await import(base + '/index.js');
window.handle = window.mod.createOrgChart({ container: document.getElementById('chart'), dataSource: new window.mod.JsonOrgChartDataSource(data), stylesheetUrls: { engine: base + '/engine.css', core: base + '/style.css' }, ...options });
const started = performance.now();
await window.handle.mount();
window.mountMs = performance.now() - started;
}, { origin, data, options, baseline });
await ready();
}
async function example(scope, employeeCount, companyIds) {
return page.evaluate(({ template, scope, employeeCount, companyIds }) => window.mod.createOrgChartExampleDataset(template, { scope, employeeCount, companyIds }), { template, scope, employeeCount, companyIds });
}
async function measure() {
return page.evaluate(() => {
const s = document.querySelector('.hm-orgchart-host').shadowRoot;
const box = e => { const r = e.getBoundingClientRect(); return { x: r.x, y: r.y, width: r.width, height: r.height }; };
const cards = [...s.querySelectorAll('.hm-orgchart-employee')].map(c => {
const cs = getComputedStyle(c), a = c.closest('.hm-orgchart-organization'), p = a?.querySelector(`path[data-employee-id="${c.dataset.employeeId}"]`);
let x = 0, y = 0, cur = c;
while (a && cur && cur !== a) { x += cur.offsetLeft; y += cur.offsetTop; cur = cur.offsetParent; }
const d = c.dataset.connectionDirection;
const target = d === 'left' ? [x, y + c.offsetHeight / 2] : d === 'right' ? [x + c.offsetWidth, y + c.offsetHeight / 2] : [x + c.offsetWidth / 2, y];
return { id: c.dataset.employeeId, company: c.dataset.companyId, kind: c.dataset.cardKind, size: [c.offsetWidth, c.offsetHeight], box: box(c), name: c.querySelector('.hm-orgchart-person-name')?.textContent, role: c.querySelector('.hm-orgchart-person-role')?.textContent, asset: c.dataset.cardAsset, direction: d, border: [cs.borderTopColor, cs.borderRightColor, cs.borderBottomColor, cs.borderLeftColor], background: cs.backgroundImage, endpointError: p ? Math.hypot(Number(p.dataset.endX) - target[0], Number(p.dataset.endY) - target[1]) : null };
});
const v = s.querySelector('.hm-orgchart-viewport'), f = s.querySelector('.hm-orgchart-forest');
return { cards, nodes: [...s.querySelectorAll('.node[data-depth]')].map(n => ({ id: n.dataset.organizationId, parent: n.dataset.parentId, depth: Number(n.dataset.depth), company: n.dataset.companyId, box: box(n), article: box(n.querySelector('.hm-orgchart-organization')), collapsed: n.dataset.collapsed })), titles: [...s.querySelectorAll('.hm-orgchart-organization-name')].map(t => ({ text: t.textContent, background: getComputedStyle(t).backgroundColor, color: getComputedStyle(t).color, company: t.closest('[data-company-id]')?.dataset.companyId })), viewport: box(v), forest: box(f), scroll: [v.scrollLeft, v.scrollTop, v.scrollWidth, v.clientWidth, v.scrollHeight, v.clientHeight], document: [document.documentElement.scrollWidth, innerWidth, document.documentElement.scrollHeight, document.documentElement.clientHeight], layout: s.querySelector('.hm-orgchart-expanded')?.dataset.autoLayout, paths: [...s.querySelectorAll('.hm-orgchart-organization-connectors path')].map(p => ({ ...p.dataset, d: p.getAttribute('d') })), heap: performance.memory?.usedJSHeapSize, domCount: s.querySelectorAll('*').length };
});
}
const overlap = boxes => boxes.some((a, i) => boxes.slice(i + 1).some(b => 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));
const fit = m => m.forest.x >= m.viewport.x - 1 && m.forest.y >= m.viewport.y - 1 && m.forest.x + m.forest.width <= m.viewport.x + m.viewport.width + 1 && m.forest.y + m.forest.height <= m.viewport.y + m.viewport.height + 1;
const endpoint = m => m.cards.every(c => c.endpointError !== null && c.endpointError < 1);
const border = m => m.cards.every(c => { const i = { top: 0, right: 1, left: 3 }[c.direction]; return i === undefined ? c.border.every(b => b === c.border[2]) : c.border[i] !== c.border[2] && c.border.every((b, j) => j === i || b === c.border[2]); });
async function screenshot(name) { await page.screenshot({ path: path.join(output, name + '.png'), fullPage: true }); }
async function setLayout(options) { await page.evaluate(options => window.handle.setAutoLayout(options), options); await ready(); }
async function select(label, option) {
await page.getByRole('combobox', { name: label, exact: true }).click();
await page.getByRole('option', { name: option, exact: true }).click();
await settle();
}
async function hmPopup(name) {
const first = page.locator('.hm-orgchart-employee').first();
const id = await first.getAttribute('data-employee-id');
await first.focus(); await first.press('Enter');
const d = page.locator('[data-component="HMOrgChartEmployeeDialog"]');
await d.waitFor();
const details = await d.evaluate(d => ({ role: d.getAttribute('role'), employee: d.dataset.employeeId, focus: d.contains(document.activeElement), unscaled: !d.closest('.hm-orgchart-forest'), bodyPortal: d.parentElement === document.body, text: d.textContent, transform: getComputedStyle(d).transform }));
check(details.employee === id && details.role === 'dialog' && details.focus && details.unscaled, name + ': actual HM Dialog, exact employee, Portal and focus', details);
check(await page.locator('dialog[open]').count() === 0 && await page.getByRole('dialog').count() === 1, name + ': only one popup');
await screenshot(name.replaceAll(' ', '-') + '-dialog');
await page.keyboard.press('Tab'); await page.keyboard.press('Shift+Tab');
check(await d.evaluate(d => d.contains(document.activeElement)), name + ': keyboard focus remains in Dialog');
await page.keyboard.press('Escape'); await d.waitFor({ state: 'hidden' }); await settle();
check(await page.evaluate(id => document.querySelector('.hm-orgchart-host').shadowRoot.activeElement?.dataset.employeeId === id, id), name + ': ESC returns focus to employee');
await first.press('Space'); await d.waitFor();
await d.getByRole('button', { name: '닫기', exact: true }).last().click(); await d.waitFor({ state: 'hidden' }); await settle();
check(await page.evaluate(id => document.querySelector('.hm-orgchart-host').shadowRoot.activeElement?.dataset.employeeId === id, id), name + ': close button returns focus');
}
try {
if (run('core')) {
// Exact old fixture checks compare only unchanged layout/card geometry, since white titles predate P7.
await mount(small, { layoutMode: 'auto', displayMode: 'readOnly' }, true); const oldTB = await measure();
await mount(small, { layoutMode: 'auto', displayMode: 'readOnly' }); const newTB = await measure();
const signature = m => ({ cards: m.cards.map(c => [c.id, c.box, c.size, c.role, c.name]), nodes: m.nodes.map(n => [n.id, n.box, n.article]) });
check(JSON.stringify(signature(oldTB)) === JSON.stringify(signature(newTB)), 'TB existing fixture geometry remains unchanged');
for (const count of [20, 100, 300]) {
const data = await example('total', count);
await page.evaluate(data => window.handle.update(data), data); await ready(count);
const m = await measure();
check(m.cards.length === count && new Set(m.cards.map(c => c.id)).size === count && new Set(m.nodes.map(n => n.company)).size === 7, `Total ${count}: all seven companies and unique employees`);
check(!overlap(m.cards.map(c => c.box)) && !overlap(m.nodes.map(n => n.article)), `Total ${count}: no card or organization overlap`);
check(fit(m), `Total ${count}: data update refreshes Fit bounds`, { forest: m.forest, viewport: m.viewport });
}
for (const count of [20, 100, 300]) {
const data = await example('per-company', count, ['saman']);
await page.evaluate(data => window.handle.update(data), data); await ready(count);
check((await measure()).cards.every(c => c.company === 'saman'), `Single company ${count}: exact selected company`);
}
const data = await example('per-company', 100, ['hanmac', 'saman']);
await page.evaluate(data => window.handle.update(data), data); await ready(200);
check(new Set((await measure()).cards.map(c => c.company)).size === 2, 'Two companies ×100 render exactly 200');
const all = await example('total', 300);
await page.evaluate(data => window.handle.update(data), all); await ready(300);
const dims = (await measure()).cards.map(c => c.size);
for (const options of [{ type: 'lr' }, { type: 'tb' }, { type: 'hybrid', hybridStartDepth: 2 }, { type: 'tb' }, { type: 'lr' }, { type: 'hybrid', hybridStartDepth: 3 }, { type: 'hybrid', hybridStartDepth: 4 }]) {
const started = Date.now(); await setLayout(options); const ms = Date.now() - started; const m = await measure();
check(m.cards.length === 300 && new Set(m.cards.map(c => c.id)).size === 300, `${JSON.stringify(options)}: preserves complete data`);
check(!overlap(m.cards.map(c => c.box)) && !overlap(m.nodes.map(n => n.article)), `${JSON.stringify(options)}: no card or group overlap`);
check(fit(m), `${JSON.stringify(options)}: complete Fit`, { forest: m.forest, viewport: m.viewport });
check(endpoint(m) && border(m), `${JSON.stringify(options)}: connector endpoints and single accent side agree`);
check(JSON.stringify(m.cards.map(c => c.size)) === JSON.stringify(dims), `${JSON.stringify(options)}: card dimensions preserved`);
if (options.type === 'lr') {
check(m.nodes.every(n => !n.parent || m.nodes.some(p => p.id === n.parent && p.article.x + p.article.width < n.article.x)), 'LR: every child to the right of its parent');
const parents = new Set(m.nodes.map(n => n.parent).filter(Boolean));
check([...parents].every(id => { const siblings = m.nodes.filter(n => n.parent === id); return siblings.every((n, i) => i === 0 || n.article.y >= siblings[i - 1].article.y + siblings[i - 1].article.height); }), 'LR: siblings stack vertically');
check(m.cards.every(c => c.direction === 'left'), 'LR: cards use left incoming accent');
} else {
check(m.nodes.every(n => !n.parent || m.nodes.some(p => p.id === n.parent && p.article.y + p.article.height < n.article.y)), `${JSON.stringify(options)}: each child remains under its own parent`);
if (options.type === 'hybrid') {
const parents = new Set(m.nodes.filter(n => n.depth + 1 >= options.hybridStartDepth).map(n => n.parent).filter(Boolean));
check([...parents].every(id => { const siblings = m.nodes.filter(n => n.parent === id); return siblings.every((n, i) => i === 0 || n.article.y >= siblings[i - 1].article.y + siblings[i - 1].article.height); }), `Hybrid Depth ${options.hybridStartDepth}: siblings below own parent form a vertical list`);
}
}
measurements.push({ name: JSON.stringify(options), switchAndSettleMs: ms, value: m });
if (options.type === 'lr' || options.type === 'hybrid') await screenshot(options.type + '-' + (options.hybridStartDepth ?? '300'));
}
await setLayout({ type: 'tb' });
const rootId = (await measure()).nodes.find(n => !n.parent).id;
let toggle = page.locator(`.hm-orgchart-expanded-toggle[data-organization-id="${rootId}"]`);
await toggle.focus(); await toggle.press('Enter'); await ready();
const collapsedCount = (await measure()).cards.length;
for (const type of ['lr', 'hybrid', 'tb']) { await setLayout({ type }); check((await measure()).cards.length === collapsedCount, `Collapse state preserved switching to ${type}`); }
toggle = page.locator(`.hm-orgchart-expanded-toggle[data-organization-id="${rootId}"]`); await toggle.focus(); await toggle.press('Space'); await ready(300);
check(await toggle.getAttribute('aria-expanded') === 'true', 'Keyboard expands group after mode transitions');
for (const depth of [1, 2, 3]) {
await setLayout({ type: 'hybrid', initialVisibleDepth: depth }); const m = await measure();
check(m.nodes.every(n => n.depth + 1 <= depth), `Initial visible Depth ${depth}: no deeper group displayed`);
}
await setLayout({ type: 'lr', initialVisibleDepth: 'all' }); await ready(300);
await page.evaluate(() => window.handle.setSelectedCompanies(['hanmac', 'saman'])); await ready();
check(new Set((await measure()).nodes.map(n => n.company)).size === 2, 'Company selection independent of LR');
await setLayout({ type: 'hybrid', hybridStartDepth: 2 });
check(new Set((await measure()).nodes.map(n => n.company)).size === 2, 'Selected companies preserved changing to Hybrid');
await page.evaluate(() => window.handle.setSelectedCompanies([])); await ready(0);
check(await page.locator('.hm-orgchart-expanded-empty').count() === 1, 'Empty company selection safe');
await page.evaluate(() => window.handle.setSelectedCompanies()); await ready(300);
for (const type of ['tb', 'lr', 'hybrid']) {
await setLayout({ type }); const m = await measure();
check(m.titles.every(t => t.color === 'rgb(255, 255, 255)'), `${type}: all company title text remains white`);
const v = page.locator('.hm-orgchart-viewport'), vb = await v.boundingBox();
const before = await page.evaluate(() => window.handle.getViewportState());
await page.mouse.move(vb.x + 10, vb.y + 10); await page.mouse.down(); await page.mouse.move(vb.x + 50, vb.y + 30); await page.mouse.up();
const after = await page.evaluate(() => window.handle.getViewportState());
check(after.x !== before.x && after.y !== before.y, `${type}: trusted pointer Pan`);
await page.getByRole('button', { name: '100% 복원', exact: true }).click();
await page.getByRole('button', { name: '확대', exact: true }).click();
check((await page.evaluate(() => window.handle.getViewportState())).scale > 1, `${type}: Zoom independent`);
await page.getByRole('button', { name: 'Fit to View', exact: true }).click(); await settle();
check(fit(await measure()), `${type}: toolbar Fit`);
}
// Vanilla popup remains outside the scaled forest, closes with ESC and restores card.
const first = page.locator('.hm-orgchart-employee').first(), id = await first.getAttribute('data-employee-id');
await first.focus(); await first.press('Enter'); await page.locator('dialog[open]').waitFor();
check(await page.locator('dialog[open]').evaluate(d => !d.closest('.hm-orgchart-forest') && getComputedStyle(d).transform === 'none' && d.contains(d.getRootNode().activeElement)), 'Vanilla default popup focus and unscaled layer');
await page.keyboard.press('Escape'); await settle();
check(await page.evaluate(id => document.querySelector('.hm-orgchart-host').shadowRoot.activeElement?.dataset.employeeId === id, id), 'Vanilla ESC restores employee focus');
// Higher scale has its own measurement; P5's 300 benchmark is not used.
const huge = await example('per-company', 300);
await mount(huge, { layoutMode: 'auto', displayMode: 'readOnly', autoLayout: { type: 'lr' } }); await ready(2100);
const hugeLR = await measure();
check(!overlap(hugeLR.cards.map(c => c.box)) && fit(hugeLR), '2100 LR: full rendering, no card overlap and Fit');
measurements.push({ name: '2100 LR initial full', mountMs: await page.evaluate(() => window.mountMs), value: hugeLR }); await screenshot('2100-lr');
const largeStart = Date.now(); await setLayout({ type: 'hybrid', hybridStartDepth: 3 }); await ready(2100); const hugeHybrid = await measure();
check(!overlap(hugeHybrid.cards.map(c => c.box)) && fit(hugeHybrid), '2100 Hybrid: full rendering, no card overlap and Fit');
measurements.push({ name: '2100 Hybrid full', switchAndSettleMs: Date.now() - largeStart, value: hugeHybrid });
await setLayout({ type: 'hybrid', initialVisibleDepth: 2 }); const limited = await measure();
check(limited.cards.length < 2100 && limited.nodes.every(n => n.depth < 2), '2100 independent visible depth reduces initial rendering');
measurements.push({ name: '2100 Depth2', value: limited });
// Minimum Fixed regression from captured pre-P7 public distribution.
await mount(fixed, { displayMode: 'readOnly' }, true); const beforeFixed = await measure();
await mount(fixed, { displayMode: 'readOnly' }); const afterFixed = await measure();
const fixedSig = m => m.cards.map(c => [c.id, c.box, c.size, c.name, c.role, c.asset, c.border, c.background]);
check(JSON.stringify(fixedSig(beforeFixed)) === JSON.stringify(fixedSig(afterFixed)), 'Fixed card geometry/SVG/text/gradient/border unchanged');
check(await page.locator('.hm-orgchart-expanded-toggle').count() === 0, 'Fixed contains no new Auto controls');
await page.locator('.hm-orgchart-employee').first().press('Enter'); await page.locator('dialog[open]').waitFor(); await page.keyboard.press('Escape');
check(await page.locator('dialog[open]').count() === 0, 'Fixed original popup retains open/ESC'); await screenshot('fixed-1440');
}
if (run('repair')) {
await mount(small, { layoutMode: 'auto', displayMode: 'readOnly' });
for (const count of [20, 100, 300]) {
const data = await example('total', count);
await page.evaluate(data => window.handle.update(data), data); await ready(count);
check(fit(await measure()), `Total ${count}: data update refreshes Fit bounds`);
}
for (const options of [{ type: 'lr' }, { type: 'hybrid', hybridStartDepth: 2 }, { type: 'hybrid', hybridStartDepth: 3 }, { type: 'hybrid', hybridStartDepth: 4 }, { type: 'tb', hybridOrganizationIds: ['hanmac-root'] }]) {
await setLayout(options); const m = await measure();
check(!overlap(m.cards.map(c => c.box)) && !overlap(m.nodes.map(n => n.article)), `Corrected ${JSON.stringify(options)}: no group/card overlap`);
check(fit(m) && endpoint(m) && border(m), `Corrected ${JSON.stringify(options)}: Fit, card endpoints and accents`);
if (options.type === 'lr') {
check(m.nodes.every(n => !n.parent || m.nodes.some(p => p.id === n.parent && p.article.x + p.article.width < n.article.x)), 'Corrected LR: every child right of its parent');
const parents = new Set(m.nodes.map(n => n.parent).filter(Boolean));
check([...parents].every(id => { const siblings = m.nodes.filter(n => n.parent === id); return siblings.every((n, i) => i === 0 || n.article.y >= siblings[i - 1].article.y + siblings[i - 1].article.height); }), 'LR: siblings stack vertically');
} else {
check(m.nodes.every(n => !n.parent || m.nodes.some(p => p.id === n.parent && p.article.y + p.article.height < n.article.y)), `${JSON.stringify(options)}: each child remains under its own parent`);
const parents = new Set(m.nodes.filter(n => options.type === 'hybrid' ? n.depth + 1 >= options.hybridStartDepth : n.company === 'hanmac').map(n => n.parent).filter(Boolean));
check([...parents].every(id => { const siblings = m.nodes.filter(n => n.parent === id); return siblings.every((n, i) => i === 0 || n.article.y >= siblings[i - 1].article.y + siblings[i - 1].article.height); }), `Hybrid Depth ${options.hybridStartDepth ?? 'group'}: siblings below own parent form a vertical list`);
}
const pathValid = await page.evaluate(() => {
const s = document.querySelector('.hm-orgchart-host').shadowRoot;
return [...s.querySelectorAll('.hm-orgchart-tree')].every(tree => {
const nodes = [...tree.querySelectorAll('.node[data-organization-id]')];
const paths = [...tree.querySelectorAll('.hm-orgchart-organization-connectors path')];
const scale = Number(s.querySelector('.hm-orgchart-viewport').dataset.scale);
return paths.length === nodes.filter(n => n.dataset.parentId).length && paths.every(p => {
const node = nodes.find(n => n.dataset.organizationId === p.dataset.organizationId);
if (!node || node.dataset.parentId !== p.dataset.parentId) return false;
const r = node.querySelector('.hm-orgchart-organization-name').getBoundingClientRect(), t = tree.getBoundingClientRect();
const nums = p.getAttribute('d').match(/-?\d+(?:\.\d+)?/g).map(Number);
const direction = p.dataset.direction;
const x = direction === 'left' ? nums.at(-1) : nums.at(-2);
const y = direction === 'left' ? nums.at(-2) : nums.at(-1);
return Math.abs(x - ((r.left - t.left) / scale + (direction === 'left' ? 0 : node.querySelector('.hm-orgchart-organization-name').offsetWidth / 2))) < 1
&& Math.abs(y - ((r.top - t.top) / scale + (direction === 'left' ? node.querySelector('.hm-orgchart-organization-name').offsetHeight / 2 : 0))) < 1;
});
});
});
check(pathValid, `Corrected ${JSON.stringify(options)}: actual organization paths follow own parent and heading endpoints`);
measurements.push({ name: 'Corrected ' + JSON.stringify(options), value: m });
await screenshot('corrected-' + options.type + '-' + (options.hybridStartDepth ?? 'group'));
}
const huge = await example('per-company', 300);
await mount(huge, { layoutMode: 'auto', displayMode: 'readOnly', autoLayout: { type: 'lr' } }); await ready(2100);
const m = await measure();
check(!overlap(m.cards.map(c => c.box)) && fit(m), 'Corrected 2100 LR: full rendering/no overlap/Fit');
measurements.push({ name: 'Corrected 2100 LR full', mountMs: await page.evaluate(() => window.mountMs), value: m });
await screenshot('2100-lr');
const started = Date.now(); await setLayout({ type: 'hybrid', hybridStartDepth: 3 }); await ready(2100);
const hybrid = await measure();
check(!overlap(hybrid.cards.map(c => c.box)) && fit(hybrid), 'Corrected 2100 Hybrid: full rendering/no overlap/Fit');
measurements.push({ name: 'Corrected 2100 Hybrid full', switchAndSettleMs: Date.now() - started, value: hybrid });
await screenshot('2100-hybrid');
}
if (run('story')) {
await page.goto((process.env.ORGCHART_STORY_ORIGIN ?? 'http://127.0.0.1:6056') + '/iframe.html?id=orgchart--expanded-org-chart&viewMode=story');
await ready(20);
check(await page.locator('.hm-orgchart-story-companies').first().getByRole('checkbox').count() === 7 && new Set((await measure()).nodes.map(n => n.company)).size === 7, 'Story total20: all seven company controls and rendered trees');
for (const count of [100, 300]) {
await select('전체 직원 수', `${count}명`);
check(await page.locator('.hm-orgchart-employee').count() !== count, `Story total${count}: draft choice does not render before Apply`);
await page.getByRole('button', { name: '데이터 적용', exact: true }).click(); await ready(count);
check((await measure()).cards.length === count, `Story total${count}: actual selector applies count`);
}
await page.getByRole('button', { name: '선택 해제', exact: true }).click();
await page.getByRole('checkbox', { name: '한맥기술', exact: true }).check();
await select('데이터 범위', '회사별');
for (const count of [20, 100, 300]) {
await select('회사당 직원 수', `${count}명`);
await page.getByRole('button', { name: '데이터 적용', exact: true }).click(); await ready(count);
check((await measure()).cards.every(c => c.company === 'hanmac'), `Story per-company${count}: exact chosen roster`);
}
await page.getByRole('checkbox', { name: '삼안', exact: true }).check();
await select('회사당 직원 수', '100명');
await page.getByRole('button', { name: '데이터 적용', exact: true }).click(); await ready(200);
check(new Set((await measure()).cards.map(c => c.company)).size === 2, 'Story two companies100: actual 200');
for (const [label, type] of [['가로형 LR', 'lr'], ['혼합형 Hybrid', 'hybrid'], ['세로형 TB', 'tb']]) {
await select('조직도 표시 유형', label); await ready(200);
check((await measure()).layout === type, `Story layout ${type}: real screen switched`);
await screenshot('story-' + type + '-200');
}
check(await page.getByRole('combobox', { name: '혼합형 시작 Depth', exact: true }).isDisabled(), 'Story Hybrid depth limited outside Hybrid');
await select('조직도 표시 유형', '혼합형 Hybrid');
for (const depth of [2, 3, 4]) { await select('혼합형 시작 Depth', `Depth ${depth}부터 세로 배치`); await ready(200); check(!overlap((await measure()).cards.map(c => c.box)), `Story HybridDepth${depth}: actual control applied safely`); }
await hmPopup('Story HM');
// Source/company disappearance closes the external popup safely.
await page.locator('.hm-orgchart-employee').first().press('Enter'); await page.locator('[data-component="HMOrgChartEmployeeDialog"]').waitFor();
await page.evaluate(() => {
const overlay = document.querySelector('[data-component="HMOrgChartEmployeeDialog"]');
// Escape to dismiss first; normal UI then commits an empty company view.
overlay.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
});
await page.keyboard.press('Escape');
await page.getByRole('button', { name: '선택 해제', exact: true }).click();
await page.getByRole('button', { name: '데이터 적용', exact: true }).click(); await ready(0);
check(await page.locator('[data-component="HMOrgChartEmployeeDialog"]').count() === 0 && await page.locator('.hm-orgchart-expanded-empty').count() === 1, 'Story empty selection safely clears view/popup');
await page.getByRole('button', { name: '초기화', exact: true }).click(); await ready(20);
await select('조직도 표시 유형', '세로형 TB');
const rootId = (await measure()).nodes.find(n => !n.parent).id;
const toggle = page.locator(`.hm-orgchart-expanded-toggle[data-organization-id="${rootId}"]`);
await toggle.focus(); await toggle.press('Enter'); await ready(); const collapsed = await page.locator('.hm-orgchart-employee').count();
await select('직원 정보 팝업', 'Core 기본 팝업'); await ready();
check(await page.locator('.hm-orgchart-employee').count() === collapsed, 'Story popup ownership switch retains independent collapse');
await page.locator('.hm-orgchart-employee').first().press('Enter'); await page.locator('dialog[open]').waitFor(); await page.keyboard.press('Escape');
await select('직원 정보 팝업', '사용 안 함'); await ready();
await page.locator('.hm-orgchart-employee').first().press('Enter'); await settle();
check(await page.getByRole('dialog').count() === 0, 'Story popup none: no default or HM popup');
await select('직원 정보 팝업', 'HMDesign Dialog'); await ready();
await toggle.focus(); await toggle.press('Enter'); await ready(20);
for (const width of [390, 768, 1280, 1440]) {
await page.setViewportSize({ width, height: width === 390 ? 844 : 1100 }); await settle();
await page.getByRole('button', { name: 'Fit to View', exact: true }).click(); await settle();
const m = await measure();
check(m.document[0] <= width && m.scroll[0] === 0 && m.scroll[1] === 0, `Story ${width}px: no document overflow/hidden viewport scroll`, { document: m.document, scroll: m.scroll });
measurements.push({ name: `Story ${width}px`, value: m });
await screenshot('story-' + width);
}
await select('조직도 표시 유형', '가로형 LR');
await select('초기 표시 Depth', '2Depth까지');
await page.getByRole('button', { name: '전체 회사', exact: true }).click();
await select('데이터 범위', '회사별'); await select('회사당 직원 수', '300명');
check(await page.getByText(/대용량 2,100명 전환 예정/).count() === 1, 'Story2100: scale disclosed before data commit');
await page.getByRole('button', { name: '데이터 적용', exact: true }).click(); await ready();
check(await page.getByText(/실제 직원 합계 2,100명/).count() === 1 && (await measure()).nodes.every(n => n.depth < 2), 'Story2100: source count retained with independent initial-depth reduction');
await screenshot('story-2100-depth2');
await page.getByRole('button', { name: '초기화', exact: true }).click();
await select('초기 표시 Depth', '전체 펼침'); await select('조직도 표시 유형', '세로형 TB'); await ready(20);
check(await page.getByRole('alert').count() === 0, 'Story has no lifecycle error alert');
}
if (run('react')) {
await page.goto(origin + '/test-results/org-chart-p7/react-build/dist/index.html'); await ready(20);
await hmPopup('React production');
for (const type of ['lr', 'hybrid']) {
await page.evaluate(type => window.p7.setAutoLayout({ type }), type); await ready(20);
await hmPopup('React ' + type);
}
// Layout replacement while Dialog is open preserves selected identity and restores replacement card.
const id = await page.locator('.hm-orgchart-employee').first().getAttribute('data-employee-id');
await page.locator('.hm-orgchart-employee').first().press('Enter'); await page.locator('[data-component="HMOrgChartEmployeeDialog"]').waitFor();
await page.evaluate(() => window.p7.setAutoLayout({ type: 'tb' })); await settle();
check(await page.locator('[data-component="HMOrgChartEmployeeDialog"]').getAttribute('data-employee-id') === id, 'React layout change retains popup employee identity');
await page.keyboard.press('Escape'); await page.locator('[data-component="HMOrgChartEmployeeDialog"]').waitFor({ state: 'hidden' }); await settle();
check(await page.evaluate(id => document.querySelector('.hm-orgchart-host').shadowRoot.activeElement?.dataset.employeeId === id, id), 'React mode rerender returns focus to replacement card');
await page.locator('.hm-orgchart-employee').first().press('Enter'); await page.locator('[data-component="HMOrgChartEmployeeDialog"]').waitFor();
await page.evaluate(() => window.p7.setCompanies(['saman'])); await settle();
check(await page.locator('[data-component="HMOrgChartEmployeeDialog"]').count() === 0, 'React company disappearance clears external popup');
await page.evaluate(() => window.p7.setCompanies(undefined)); await ready(20);
await page.locator('.hm-orgchart-employee').first().press('Enter'); await page.locator('[data-component="HMOrgChartEmployeeDialog"]').waitFor();
await page.evaluate(() => window.p7.setInput({ scope: 'per-company', employeeCount: 100, companyIds: ['saman'] })); await ready(100);
await page.locator('[data-component="HMOrgChartEmployeeDialog"]').waitFor({ state: 'hidden' });
check(await page.locator('[data-component="HMOrgChartEmployeeDialog"]').count() === 0, 'React source change clears stale popup');
const m = await measure(); check(m.cards.every(c => c.company === 'saman'), 'React generated100: source identity matches shared Vanilla contract');
measurements.push({ name: 'React generated100', value: m });
}
if (run('react-repair')) {
await page.goto(origin + '/test-results/org-chart-p7/react-build/dist/index.html'); await ready(20);
const id = await page.locator('.hm-orgchart-employee').first().getAttribute('data-employee-id');
await page.locator('.hm-orgchart-employee').first().press('Enter'); const d = page.locator('[data-component="HMOrgChartEmployeeDialog"]'); await d.waitFor();
await page.evaluate(() => window.p7.setAutoLayout({ type: 'lr' })); await settle();
await page.keyboard.press('Escape'); await d.waitFor({ state: 'hidden' }); await settle();
check(await page.evaluate(id => document.querySelector('.hm-orgchart-host').shadowRoot.activeElement?.dataset.employeeId === id, id), 'React mode rerender returns focus to replacement card');
await page.locator('.hm-orgchart-employee').first().press('Enter'); await d.waitFor();
await page.evaluate(() => window.p7.setInput({ scope: 'per-company', employeeCount: 100, companyIds: ['saman'] })); await ready(100); await d.waitFor({ state: 'hidden' });
check(await d.count() === 0, 'React source change clears stale popup');
}
if (run('options')) {
await mount(small, { layoutMode: 'auto', displayMode: 'readOnly' });
for (const type of ['tb', 'lr', 'hybrid']) {
await setLayout({ type });
for (const config of [{ collapse: false, zoom: true, pan: true }, { collapse: true, zoom: false, pan: true }, { collapse: true, zoom: true, pan: false }, { collapse: false, zoom: false, pan: false }]) {
const before = await page.evaluate(() => window.handle.getViewportState());
await page.evaluate(config => window.handle.setInteractions(config), config); await ready(20);
const after = await page.evaluate(() => window.handle.getViewportState());
const v = page.locator('.hm-orgchart-viewport');
const root = page.locator('.hm-orgchart-expanded-toggle').first();
check(await root.isDisabled() === !config.collapse && await page.getByRole('button', { name: '확대', exact: true }).isDisabled() === !config.zoom && await v.getAttribute('data-pan-enabled') === String(config.pan), `${type} independent ${JSON.stringify(config)}: only requested capabilities disabled`);
check(JSON.stringify(before) === JSON.stringify(after), `${type} independent ${JSON.stringify(config)}: setter retains viewport state`);
const box = await v.boundingBox(); const panBefore = await page.evaluate(() => window.handle.getViewportState());
await page.mouse.move(box.x + 8, box.y + 8); await page.mouse.down(); await page.mouse.move(box.x + 60, box.y + 30); await page.mouse.up();
const panAfter = await page.evaluate(() => window.handle.getViewportState());
check((panAfter.x !== panBefore.x || panAfter.y !== panBefore.y) === config.pan, `${type} independent ${JSON.stringify(config)}: trusted Pan respects flag`);
if (!config.zoom) {
await page.keyboard.down('Control'); await page.mouse.wheel(0, -80); await page.keyboard.up('Control'); await settle();
check((await page.evaluate(() => window.handle.getViewportState())).scale === panAfter.scale, `${type} disabled Zoom ignores Ctrl-wheel`);
}
await page.getByRole('button', { name: 'Fit to View', exact: true }).click(); await settle();
check(fit(await measure()), `${type} independent ${JSON.stringify(config)}: Fit remains usable`);
}
await page.evaluate(() => window.handle.setInteractions({ collapse: true, zoom: true, pan: true })); await ready(20);
await page.locator('.hm-orgchart-employee').first().press('Enter'); await page.locator('dialog[open]').waitFor(); await page.keyboard.press('Escape');
check(await page.locator('dialog[open]').count() === 0, `${type}: popup works independently of feature flags`);
}
await page.evaluate(() => { window.handle.zoomTo(1); });
await page.evaluate(() => window.handle.setInteractions({ pan: false, zoom: true, collapse: true })); await ready(20);
await page.locator('.hm-orgchart-employee').last().focus(); await settle();
const focus = await page.evaluate(() => {
const s = document.querySelector('.hm-orgchart-host').shadowRoot, v = s.querySelector('.hm-orgchart-viewport'), c = s.activeElement;
const r = c.getBoundingClientRect(), b = v.getBoundingClientRect();
return { visible: r.left >= b.left && r.right <= b.right && r.top >= b.top && r.bottom <= b.bottom, scale: v.dataset.scale, scroll: [v.scrollLeft, v.scrollTop] };
});
check(focus.visible && focus.scale === '1' && focus.scroll.every(n => n === 0), 'Pan disabled: keyboard reveals offscreen card at unchanged Zoom with no hidden scroll', focus);
// Actual new React/Story settings and changed content layout, without replaying all data scenarios.
await page.goto((process.env.ORGCHART_STORY_ORIGIN ?? 'http://127.0.0.1:6056') + '/iframe.html?id=orgchart--expanded-org-chart&viewMode=story'); await ready(20);
for (const [label, type] of [['세로형 TB', 'tb'], ['가로형 LR', 'lr'], ['혼합형 Hybrid', 'hybrid']]) {
await select('조직도 표시 유형', label); await ready(20);
await page.getByRole('checkbox', { name: '접기·펼치기 사용', exact: true }).uncheck();
await page.getByRole('checkbox', { name: 'Zoom 사용', exact: true }).uncheck();
await page.getByRole('checkbox', { name: 'Pan 사용', exact: true }).uncheck(); await ready(20);
check(await page.locator('.hm-orgchart-expanded-toggle').first().isDisabled() && await page.getByRole('button', { name: '확대', exact: true }).isDisabled() && await page.locator('.hm-orgchart-viewport').getAttribute('data-pan-enabled') === 'false', `Story ${type}: all independent checkboxes control actual Core`);
await hmPopup('Final ' + type);
await page.getByRole('checkbox', { name: '접기·펼치기 사용', exact: true }).check();
await page.getByRole('checkbox', { name: 'Zoom 사용', exact: true }).check();
await page.getByRole('checkbox', { name: 'Pan 사용', exact: true }).check(); await ready(20);
}
await select('직원 정보 팝업', '사용 안 함'); await ready(20);
check(await page.locator('.hm-orgchart-employee[role="button"]').count() === 0 && await page.locator('.hm-orgchart-employee[tabindex="0"]').count() === 0, 'Story popup none: no false actionable card semantics');
await select('직원 정보 팝업', 'HMDesign Dialog'); await ready(20);
}
if (run('options') || run('options-tail')) {
if (run('options-tail')) {
await page.goto((process.env.ORGCHART_STORY_ORIGIN ?? 'http://127.0.0.1:6056') + '/iframe.html?id=orgchart--expanded-org-chart&viewMode=story'); await ready(20);
}
await select('조직도 표시 유형', '세로형 TB');
await select('특정 그룹 세로 배치', '한맥기술 · 가상 조직'); await ready(20);
check(await page.locator('[data-group-hybrid="true"]').count() === 1, 'Story per-group Hybrid: real applied local branch');
await select('조직도 표시 유형', '가로형 LR'); await ready(20);
check(await page.getByRole('combobox', { name: '특정 그룹 세로 배치', exact: true }).isDisabled(), 'Story per-group Hybrid: incompatible LR selection disabled and cleared');
await select('조직도 표시 유형', '세로형 TB');
for (const width of [390, 768, 1280, 1440]) {
await page.setViewportSize({ width, height: width === 390 ? 844 : 1100 }); await settle();
await page.getByRole('button', { name: 'Fit to View', exact: true }).click(); await settle(); const m = await measure();
check(m.document[0] <= width && m.scroll[0] === 0 && m.scroll[1] === 0 && fit(m), `Final Story ${width}px: added options fit and no overflow`, { document: m.document, scroll: m.scroll, forest: m.forest, viewport: m.viewport });
measurements.push({ name: `Final Story ${width}px`, value: m }); await screenshot('story-' + width);
}
const presetColors = { hanmac: 'rgb(203, 46, 40)', saman: 'rgb(240, 131, 0)', 'jangheon-industry': 'rgb(69, 128, 166)', jangheon: 'rgb(29, 65, 141)', ptc: 'rgb(132, 51, 142)', halla: 'rgb(0, 104, 183)', baron: 'rgb(12, 54, 127)' };
const colors = (await measure()).titles;
check(colors.every(t => t.color === 'rgb(255, 255, 255)' && t.background === presetColors[t.company]), 'Seven company titles preserve exact preset colors and requested white text', colors);
const title = page.locator('.hm-orgchart-organization-name').first();
await page.locator('.hm-orgchart-expanded-toggle').first().hover();
check(await title.evaluate(t => getComputedStyle(t).color) === 'rgb(255, 255, 255)', 'White company title remains on hover');
await page.locator('.hm-orgchart-expanded-toggle').first().focus();
check(await title.evaluate(t => getComputedStyle(t).color) === 'rgb(255, 255, 255)', 'White company title remains on keyboard focus');
await page.locator('.hm-orgchart-expanded-toggle').first().press('Enter'); await settle();
check(await title.evaluate(t => getComputedStyle(t).color) === 'rgb(255, 255, 255)', 'White company title remains collapsed');
await page.locator('.hm-orgchart-expanded-toggle').first().press('Enter'); await settle();
check(await title.evaluate(t => getComputedStyle(t).color) === 'rgb(255, 255, 255)', 'White company title remains expanded');
}
if (run('options-tail') || run('story-menu')) {
const index = await (await page.request.get((process.env.ORGCHART_STORY_ORIGIN ?? 'http://127.0.0.1:6056') + '/index.json')).json();
const names = Object.values(index.entries).filter(e => e.title === 'OrgChart' && e.type === 'story' && !e.tags.includes('internal') && !e.tags.includes('verification')).map(e => e.name).sort();
check(JSON.stringify(names) === JSON.stringify(['Department Colors', 'Expanded OrgChart', 'Full Composition', 'Logo · Personnel Summary'].sort()), 'Story menu retains exactly the original four user entries', names);
}
if (run('lr-reference') || run('lr-residue')) {
await mount(template, { layoutMode: 'auto', displayMode: 'readOnly', autoLayout: { type: 'lr' } }); await ready(300);
if (run('lr-reference')) {
const upright = await page.evaluate(() => {
const s = document.querySelector('.hm-orgchart-host').shadowRoot;
const elements = [...s.querySelectorAll('.orgchart, .node, .hm-orgchart-organization, .hm-orgchart-employee, .hm-orgchart-avatar, .hm-orgchart-person-name, .hm-orgchart-person-role')];
return { l2r: s.querySelectorAll('.orgchart.l2r').length, rotated: elements.filter(e => getComputedStyle(e).transform !== 'none').map(e => [e.className, getComputedStyle(e).transform]), photos: [...s.querySelectorAll('.hm-orgchart-avatar')].every(e => e.offsetWidth === 36 && e.offsetHeight === 36) };
});
check(upright.l2r === 7 && upright.rotated.length === 0 && upright.photos, 'LR reference: actual l2r engine class on all seven trees; photos/text/card remain upright', upright);
const route = async () => page.evaluate(() => {
const s = document.querySelector('.hm-orgchart-host').shadowRoot, scale = Number(s.querySelector('.hm-orgchart-viewport').dataset.scale);
return [...s.querySelectorAll('.hm-orgchart-tree')].every(tree => {
const nodes = [...tree.querySelectorAll('.node[data-organization-id]')], paths = [...tree.querySelectorAll('.hm-orgchart-organization-connectors path')], t = tree.getBoundingClientRect();
return paths.length === nodes.filter(n => n.dataset.parentId).length && paths.every(p => {
const node = nodes.find(n => n.dataset.organizationId === p.dataset.organizationId), parent = nodes.find(n => n.dataset.organizationId === p.dataset.parentId);
if (!node || !parent || node.dataset.parentId !== parent.dataset.organizationId) return false;
const start = parent.querySelector('.hm-orgchart-organization-name').getBoundingClientRect(), end = node.querySelector('.hm-orgchart-organization-name').getBoundingClientRect();
const values = p.getAttribute('d').match(/-?\d+(?:\.\d+)?/g).map(Number);
return Math.abs(values[0] - (start.right - t.left) / scale) < 1 && Math.abs(values[1] - (start.top + start.height / 2 - t.top) / scale) < 1
&& Math.abs(values.at(-1) - (end.left - t.left) / scale) < 1 && Math.abs(values.at(-2) - (end.top + end.height / 2 - t.top) / scale) < 1;
});
});
});
const branch = page.locator('.node[data-organization-id="hanmac-division-0"] > .hm-orgchart-organization .hm-orgchart-expanded-toggle').first();
const before = await measure(); await branch.press('Enter'); await ready(); const closed = await measure();
check(closed.cards.length < before.cards.length && closed.nodes.length < before.nodes.length && await route() && !overlap(closed.nodes.map(n => n.article)) && endpoint(closed) && border(closed), 'LR reference: collapsed branch removes descendants and recalculates both connector endpoints and layout');
await branch.press('Enter'); await ready(300); const opened = await measure();
check(await route() && !overlap(opened.nodes.map(n => n.article)) && endpoint(opened) && border(opened), 'LR reference: reopened branch updates both parent/child endpoints without overlap');
}
await setLayout({ type: 'tb' }); await setLayout({ type: 'hybrid', hybridStartDepth: 3 }); await setLayout({ type: 'lr' });
const clean = await page.evaluate(() => {
const s = document.querySelector('.hm-orgchart-host').shadowRoot;
return { trees: s.querySelectorAll('.orgchart').length, l2r: s.querySelectorAll('.orgchart.l2r').length, vertical: s.querySelectorAll('.nodes.vertical').length, svgs: s.querySelectorAll('.hm-orgchart-organization-connectors').length, paths: s.querySelectorAll('.hm-orgchart-organization-connectors path').length, nodes: [...s.querySelectorAll('.node[data-parent-id]')].filter(n => n.dataset.parentId).length };
});
check(clean.trees === 7 && clean.l2r === 7 && clean.vertical === 0 && clean.svgs === 7 && clean.paths === clean.nodes, 'LR reference: TB/Hybrid/LR return has one fresh engine and SVG per company, no stale vertical lists', clean);
await page.evaluate(() => window.handle.zoomTo(1)); await page.locator('.hm-orgchart-employee').first().focus(); await settle(); await screenshot('lr-card-upright');
measurements.push({ name: 'LR reference upright and refreshed paths', value: await measure() });
}
if (run('performance')) {
for (const options of [{ type: 'lr' }, { type: 'hybrid', hybridStartDepth: 2 }, { type: 'hybrid', hybridStartDepth: 3 }, { type: 'hybrid', hybridStartDepth: 4 }]) {
const started = Date.now(); await mount(template, { layoutMode: 'auto', displayMode: 'readOnly', autoLayout: options }); await ready(300);
const readyMs = Date.now() - started, mountMs = await page.evaluate(() => window.mountMs);
const toggle = page.locator('.node[data-organization-id="hanmac-division-0"] > .hm-orgchart-organization .hm-orgchart-expanded-toggle').first();
const collapseStarted = Date.now(); await toggle.press('Enter'); await ready(); const collapseMs = Date.now() - collapseStarted;
await toggle.press('Enter'); await ready(300);
const zoomStarted = Date.now(); await page.getByRole('button', { name: '확대', exact: true }).click(); await settle(); const zoomMs = Date.now() - zoomStarted;
const fitStarted = Date.now(); await page.getByRole('button', { name: 'Fit to View', exact: true }).click(); await settle(); const fitMs = Date.now() - fitStarted;
const value = await measure();
measurements.push({ name: '300 performance ' + JSON.stringify(options), mountMs, fullReadyMs: readyMs, collapseMs, zoomMs, fitMs, value });
check(value.cards.length === 300 && fit(value), '300 performance sample completes at full data: ' + JSON.stringify(options), { mountMs, fullReadyMs: readyMs, collapseMs, zoomMs, fitMs, heap: value.heap, domCount: value.domCount });
}
}
if (run('dialog-narrow')) {
await page.setViewportSize({ width: 390, height: 844 });
await page.goto((process.env.ORGCHART_STORY_ORIGIN ?? 'http://127.0.0.1:6056') + '/iframe.html?id=orgchart--expanded-org-chart&viewMode=story'); await ready(20);
await page.locator('.hm-orgchart-employee').first().press('Enter'); const d = page.locator('[data-component="HMOrgChartEmployeeDialog"]'); await d.waitFor(); await settle();
const bounds = await d.evaluate(d => {
const r = d.getBoundingClientRect();
return { x: r.left, y: r.top, right: r.right, bottom: r.bottom, width: r.width, height: r.height, scrollWidth: d.scrollWidth, clientWidth: d.clientWidth, fields: [...d.querySelectorAll('dd')].map(e => ({ text: e.textContent, scroll: e.scrollWidth, client: e.clientWidth })), active: d.contains(document.activeElement) };
});
check(bounds.x >= 0 && bounds.y >= 0 && bounds.right <= 390 && bounds.bottom <= 844 && bounds.scrollWidth <= bounds.clientWidth && bounds.fields.every(f => f.scroll <= f.client) && bounds.active, 'HM Dialog 390px: content, fields, focus fit viewport without horizontal overflow', bounds);
await screenshot('hm-dialog-390'); await page.keyboard.press('Escape'); await d.waitFor({ state: 'hidden' });
}
check(errors.length === 0, 'No new console or runtime errors', errors);
} catch (error) {
check(false, 'Verification runner completed', { message: error.message, stack: error.stack });
} finally {
const pass = results.filter(r => r.status === 'PASS').length;
const fail = results.filter(r => r.status === 'FAIL').length;
await fs.writeFile(path.join(output, only.length ? `browser-${only.join('-')}.json` : 'browser.json'), JSON.stringify({ pass, fail, results, errors, measurements, chrome: await browser.version() }, null, 2));
console.log(JSON.stringify({ pass, fail, errors: errors.length }));
await browser.close(); await new Promise(resolve => server.close(resolve));
process.exitCode = fail ? 1 : 0;
}
+376
View File
@@ -0,0 +1,376 @@
// P8 Storybook presentation-only browser checks. P7 units/full suites stay unrun.
import fs from 'node:fs/promises';
import { writeFileSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { launchChromium } from '../../scripts/orgchart-browser-runtime.mjs';
const root = fileURLToPath(new URL('../../', import.meta.url));
const output = path.join(root, 'test-results/org-chart-p8');
const origin = (process.env.ORGCHART_STORY_ORIGIN ?? 'http://127.0.0.1:6057').replace(/\/$/, '');
const sections = process.argv.slice(2);
const run = section => (!sections.length && !['full-repair', 'route-final'].includes(section)) || sections.includes(section);
const results = [], measurements = [], errors = [];
const startedAt = new Date().toISOString();
await fs.mkdir(output, { recursive: true });
const persist = () => writeFileSync(path.join(output, 'browser-progress.json'), JSON.stringify({ startedAt, origin, sections, results, measurements, errors }, null, 2));
function check(pass, name, detail) {
results.push({ name, status: pass ? 'PASS' : 'FAIL', ...(detail === undefined ? {} : { detail }) });
persist();
if (!pass) console.log('FAIL', name, JSON.stringify(detail));
}
const stories = [
{ id: 'orgchart--logo-personnel-summary', name: 'Overview', file: 'overview' },
{ id: 'orgchart--department-colors', name: 'Department Colors', file: 'department-colors' },
{ id: 'orgchart--full-composition', name: 'Full Composition', file: 'full-composition' },
{ id: 'orgchart--expanded-org-chart', name: 'Expanded OrgChart', file: 'expanded-org-chart' },
];
const patterns = ['patterns-data-filter-bar--all', 'patterns-layout-page-header--all', 'patterns-layout-loading-screen--all'];
const internalIds = [
'orgchart--logo', 'orgchart--position-summary', 'orgchart--affiliation-summary',
...['business-management', 'business-planning', 'talent-growth', 'integrated-system', 'design-planning', 'solution-sales', 'top-leader', 'organization-leader', 'employee'].map(name => 'orgchart-components-cards--' + name),
...['position-summary', 'affiliation-summary', 'status-summary'].map(name => 'orgchart-components-summaries--' + name),
...['basic-organization', 'organization-hierarchy', 'employee-cards', 'organization-summary', 'json-data-update', 'loading-empty-error', 'responsive-container', 'multiple-instances'].map(name => 'orgchart-content--' + name),
];
const browser = await launchChromium({ headless: true });
const context = await browser.newContext({ viewport: { width: 1440, height: 1100 } });
const page = await context.newPage();
context.on('page', p => {
p.on('pageerror', error => errors.push({ url: p.url(), type: 'pageerror', message: error.message }));
p.on('console', message => { if (message.type() === 'error') errors.push({ url: p.url(), type: 'console', message: message.text(), location: message.location() }); });
});
// The initial page predates the context event listener.
page.on('pageerror', error => errors.push({ url: page.url(), type: 'pageerror', message: error.message }));
page.on('console', message => { if (message.type() === 'error') errors.push({ url: page.url(), type: 'console', message: message.text(), location: message.location() }); });
page.setDefaultTimeout(20000);
async function settle(target = page) {
await target.evaluate(async () => { await document.fonts.ready; for (let i = 0; i < 5; i++) await new Promise(resolve => requestAnimationFrame(resolve)); });
}
async function ready(expected) {
await page.locator('.sb-page-title').waitFor();
await page.waitForFunction(expected => {
const hosts = [...document.querySelectorAll('.hm-orgchart-host,.hm-orgchart-component-host')];
if (!hosts.length) return false;
return hosts.every(host => {
const shadow = host.shadowRoot;
return shadow && [...shadow.querySelectorAll('link[rel="stylesheet"]')].every(link => link.sheet)
&& !shadow.querySelector('[aria-busy="true"]')
&& (expected === undefined || shadow.querySelectorAll('.hm-orgchart-employee').length === expected);
});
}, expected);
await settle();
}
async function gotoStory(story, width = 1440) {
await page.setViewportSize({ width, height: 1100 });
await page.goto(`${origin}/iframe.html?id=${story.id}&viewMode=story`);
await ready(story.file === 'expanded-org-chart' ? 20 : undefined);
}
async function capture(file, fullPage = true) { await page.screenshot({ path: path.join(output, file + '.png'), fullPage }); }
async function section(name, work) {
if (!run(name)) return;
try { await work(); }
catch (error) { check(false, `${name}: runner completed`, { message: error.message, stack: error.stack }); }
}
async function commonStyle() {
return page.evaluate(() => {
const properties = {
'.sb-page': ['fontFamily', 'color', 'paddingTop', 'paddingBottom', 'boxSizing', 'width'],
'.sb-page-inner': ['width', 'boxSizing'],
'.sb-page-header': ['paddingBottom', 'borderBottomWidth', 'borderBottomColor', 'fontSize', 'lineHeight'],
'.sb-page-kicker': ['fontSize', 'fontWeight', 'color', 'marginBottom'],
'.sb-page-title': ['fontSize', 'fontWeight', 'lineHeight', 'marginTop', 'marginBottom'],
'.sb-page-description': ['fontSize', 'fontWeight', 'lineHeight', 'marginBottom'],
'.sb-page-section': ['marginTop', 'scrollMarginTop'],
'.sb-page-section-header': ['paddingBottom', 'marginBottom', 'borderBottomWidth', 'borderBottomColor'],
'.sb-page-section-title': ['fontSize', 'lineHeight', 'fontWeight', 'marginTop', 'marginBottom'],
'.sb-page-section-description': ['fontSize', 'lineHeight', 'fontWeight', 'marginTop', 'color'],
};
return Object.fromEntries(Object.entries(properties).map(([selector, keys]) => {
const element = document.querySelector(selector);
if (!element) return [selector, null];
const css = getComputedStyle(element);
const style = Object.fromEntries(keys.map(key => [key, css[key]]));
if (selector === '.sb-page-inner') {
// Chrome can expose auto margins as 0px after the Core's Typed OM
// ancestor inspection. Actual symmetric gutters are the user contract.
const outer = element.parentElement.getBoundingClientRect(), inner = element.getBoundingClientRect();
style.leftGutter = inner.left - outer.left;
style.rightGutter = outer.right - inner.right;
}
return [selector, style];
}));
});
}
async function pageGeometry() {
return page.evaluate(() => {
const rect = element => { if (!element) return null; const r = element.getBoundingClientRect(); return { x: r.x, y: r.y, width: r.width, height: r.height, right: r.right, bottom: r.bottom }; };
const documentGeometry = { scrollWidth: document.documentElement.scrollWidth, clientWidth: document.documentElement.clientWidth, innerWidth, scrollHeight: document.documentElement.scrollHeight, clientHeight: document.documentElement.clientHeight };
const frame = { page: rect(document.querySelector('.sb-page')), inner: rect(document.querySelector('.sb-page-inner')), title: document.querySelector('.sb-page-title')?.textContent };
const shadow = document.querySelector('.hm-orgchart-host')?.shadowRoot;
const wrapper = shadow?.querySelector('.hm-orgchart-composition');
const stage = shadow?.querySelector('.hm-orgchart-stage');
const fixed = wrapper && stage ? {
wrapper: rect(wrapper), stage: rect(stage), baseWidth: stage.offsetWidth, baseHeight: stage.offsetHeight,
scale: Number(wrapper.dataset.scale), matrix: getComputedStyle(stage).transform,
scroll: ['.hm-orgchart-shell', '.hm-orgchart-content', '.hm-orgchart-composition', '.hm-orgchart-viewport'].map(selector => {
const element = shadow.querySelector(selector);
if (!element) return { selector, missing: true };
const css = getComputedStyle(element);
return { selector, clientWidth: element.clientWidth, scrollWidth: element.scrollWidth, clientHeight: element.clientHeight, scrollHeight: element.scrollHeight, scrollLeft: element.scrollLeft, scrollTop: element.scrollTop, overflowX: css.overflowX, overflowY: css.overflowY };
}),
visibleBounds: [...stage.querySelectorAll('.hm-orgchart-employee,.hm-orgchart-organization,.hm-orgchart-summary,.hm-orgchart-logo,.hm-orgchart-workdesk-overlay')].map(element => ({ className: element.className, box: rect(element) })),
} : null;
return { document: documentGeometry, frame, fixed };
});
}
async function autoState() {
return page.evaluate(() => {
const shadow = document.querySelector('.hm-orgchart-host').shadowRoot;
const viewport = shadow.querySelector('.hm-orgchart-viewport'), forest = shadow.querySelector('.hm-orgchart-forest');
const rect = element => { const r = element.getBoundingClientRect(); return { x: r.x, y: r.y, width: r.width, height: r.height, right: r.right, bottom: r.bottom }; };
return { type: shadow.querySelector('.hm-orgchart-expanded')?.dataset.autoLayout, employees: shadow.querySelectorAll('.hm-orgchart-employee').length,
organizations: shadow.querySelectorAll('.node[data-depth]').length, scale: Number(viewport.dataset.scale), x: Number(viewport.dataset.panX), y: Number(viewport.dataset.panY),
viewport: rect(viewport), forest: rect(forest), zoomEnabled: viewport.dataset.zoomEnabled, panEnabled: viewport.dataset.panEnabled,
scroll: { left: viewport.scrollLeft, top: viewport.scrollTop, width: viewport.scrollWidth, clientWidth: viewport.clientWidth, height: viewport.scrollHeight, clientHeight: viewport.clientHeight } };
});
}
const autoFits = state => state.forest.x >= state.viewport.x - 1 && state.forest.y >= state.viewport.y - 1 && state.forest.right <= state.viewport.right + 1 && state.forest.bottom <= state.viewport.bottom + 1;
const fixedHasNoInternalScroll = fixed => fixed.scroll.filter(item => !item.missing).every(item => item.scrollLeft === 0 && item.scrollTop === 0 && (
item.selector === '.hm-orgchart-composition'
? item.overflowX === 'hidden' && item.overflowY === 'hidden'
: item.scrollWidth <= item.clientWidth + 1 && item.scrollHeight <= item.clientHeight + 1
));
async function select(label, option) {
await page.getByRole('combobox', { name: label, exact: true }).click();
await page.getByRole('option', { name: option, exact: true }).click();
await settle();
}
try {
await section('manager', async () => {
const response = await page.request.get(origin + '/index.json');
check(response.ok(), 'Storybook index responds', { status: response.status() });
const index = await response.json();
await fs.writeFile(path.join(output, 'story-index.json'), JSON.stringify(index, null, 2));
const entries = Object.values(index.entries);
const publicEntries = entries.filter(entry => (entry.title === 'OrgChart' || entry.title.startsWith('OrgChart/')) && entry.type === 'story' && !entry.tags?.some(tag => ['internal', 'verification'].includes(tag)));
check(publicEntries.length === 4 && stories.every(story => publicEntries.some(entry => entry.id === story.id && entry.name === story.name)), 'Exactly four public names and original Story IDs', publicEntries.map(({ id, name, title, tags }) => ({ id, name, title, tags })));
check(internalIds.every(id => index.entries[id]?.tags?.includes('internal')), 'Existing internal Story IDs retained', { expected: internalIds, missingOrPublic: internalIds.filter(id => !index.entries[id]?.tags?.includes('internal')) });
check(new Set(entries.map(entry => entry.id)).size === entries.length, 'Story index contains no duplicate IDs');
const manager = await context.newPage();
await manager.goto(`${origin}/?path=/story/${stories[0].id}`);
await manager.locator('#storybook-explorer-tree').waitFor();
await manager.locator(`#storybook-explorer-tree a[id="${stories[0].id}"]`).waitFor();
await manager.waitForFunction(() => [...document.querySelectorAll('#storybook-explorer-tree [data-item-id]')].some(row => row.dataset.itemId === 'orgchart' && row.dataset.hmDepth === '0'));
await manager.waitForFunction(() => {
const preview = document.querySelector('#storybook-preview-iframe')?.contentDocument;
const hosts = [...preview?.querySelectorAll('.hm-orgchart-component-host') ?? []];
return preview?.querySelector('.sb-page-title')?.textContent === 'Overview' && hosts.length > 0
&& hosts.every(host => host.shadowRoot && [...host.shadowRoot.querySelectorAll('link[rel="stylesheet"]')].every(link => link.sheet));
});
await manager.frameLocator('#storybook-preview-iframe').locator('.sb-page-title').evaluate(async element => {
const document = element.ownerDocument, view = document.defaultView;
await document.fonts.ready;
for (let i = 0; i < 5; i++) await new Promise(resolve => view.requestAnimationFrame(resolve));
});
await settle(manager);
const sidebar = await manager.evaluate(() => [...document.querySelectorAll('#storybook-explorer-tree [data-item-id]')].map(row => {
const primary = row.querySelector(':scope > a[id],:scope > button[id]');
const label = primary?.querySelector('.hm-manager-label')?.textContent ?? primary?.textContent ?? row.querySelector(':scope > button')?.textContent ?? row.textContent;
return { id: row.dataset.itemId, text: label?.trim(), depth: Number(row.dataset.hmDepth), section: row.dataset.hmSection, type: row.dataset.nodetype, href: primary?.getAttribute('href'), visible: row.getBoundingClientRect().height > 0 };
}));
await fs.writeFile(path.join(output, 'manager-sidebar.json'), JSON.stringify(sidebar, null, 2));
const roots = sidebar.filter(row => row.depth === 0 && row.visible);
const patternsRoot = roots.findIndex(row => row.id === 'patterns'), orgRoot = roots.findIndex(row => row.id === 'orgchart'), centerRoot = roots.findIndex(row => row.id === 'center-graphic');
check(patternsRoot >= 0 && orgRoot === patternsRoot + 1 && centerRoot === orgRoot + 1, 'OrgChart is between Patterns and Center Graphic at manager depth 0', roots);
const publicRows = sidebar.filter(row => stories.some(story => story.id === row.id));
check(JSON.stringify(publicRows.map(row => row.text)) === JSON.stringify(stories.map(story => story.name)) && publicRows.every(row => row.depth === 1), 'Four manager pages follow requested order at one child depth', publicRows);
check(sidebar.filter(row => row.id.startsWith('orgchart--')).length === 4 && internalIds.every(id => !sidebar.some(row => row.id === id)), 'Internal pages remain default hidden with no duplicate public row');
const sidebarHeight = await manager.locator('#storybook-sidebar-region').evaluate(element => Math.max(element.scrollHeight, ...[...element.querySelectorAll('[data-radix-scroll-area-content]')].map(item => item.scrollHeight)));
await manager.setViewportSize({ width: 1440, height: Math.max(1100, Math.min(2400, sidebarHeight + 100)) });
await settle(manager);
await manager.screenshot({ path: path.join(output, 'manager-full-sidebar.png'), fullPage: true });
await manager.locator('#storybook-sidebar-region').screenshot({ path: path.join(output, 'manager-sidebar.png') });
await manager.close();
});
await section('route-final', async () => {
const expected = {
overview: { hosts: 3, selectors: ['[data-component="Logo"]', '[data-component="PositionSummary"]', '[data-component="AffiliationSummary"]'] },
'department-colors': { hosts: 1, selectors: ['[data-component="DepartmentColors"]', '.hm-orgchart-employee'] },
'full-composition': { hosts: 1, selectors: ['.hm-orgchart-composition', '.hm-orgchart-stage', '.hm-orgchart-employee'] },
'expanded-org-chart': { hosts: 1, selectors: ['.hm-orgchart-expanded', '.hm-orgchart-viewport', '.hm-orgchart-employee'] },
};
for (const story of stories) {
await gotoStory(story);
const rendered = await page.evaluate(({ selectors }) => {
const hosts = [...document.querySelectorAll('.hm-orgchart-host,.hm-orgchart-component-host')];
return { title: document.querySelector('.sb-page-title')?.textContent, hosts: hosts.length,
specimens: Object.fromEntries(selectors.map(selector => [selector, hosts.reduce((total, host) => total + host.shadowRoot.querySelectorAll(selector).length, 0)])),
employees: hosts.reduce((total, host) => total + host.shadowRoot.querySelectorAll('.hm-orgchart-employee').length, 0),
autoLayout: hosts.find(host => host.classList.contains('hm-orgchart-host'))?.shadowRoot.querySelector('.hm-orgchart-expanded')?.dataset.autoLayout };
}, expected[story.file]);
check(rendered.title === story.name && rendered.hosts === expected[story.file].hosts && Object.values(rendered.specimens).every(count => count > 0)
&& (story.file !== 'expanded-org-chart' || (rendered.employees === 20 && rendered.autoLayout === 'tb')), `${story.name}: final CSF old URL loads expected Core specimens`, { url: page.url(), ...rendered });
measurements.push({ name: story.name + ' final retained route', rendered });
await capture(story.file + '-1440');
}
});
await section('styles', async () => {
const references = [];
for (const id of patterns) {
await page.setViewportSize({ width: 1440, height: 1100 });
await page.goto(`${origin}/iframe.html?id=${id}&viewMode=story`);
await page.locator('.sb-page-title').waitFor(); await settle();
references.push({ id, style: await commonStyle() });
}
const reference = references[0].style;
check(references.every(item => JSON.stringify(item.style) === JSON.stringify(reference)), 'Three existing Pattern pages share the measured StoryPage frame', references);
for (const story of stories) {
await gotoStory(story);
const style = await commonStyle();
measurements.push({ name: story.name + ' common StoryPage computed styles', style });
check(JSON.stringify(style) === JSON.stringify(reference), story.name + ': StoryPage title, spacing, section and inner styles match Pattern pages', { reference: patterns[0], difference: Object.keys(reference).filter(key => JSON.stringify(reference[key]) !== JSON.stringify(style[key])), style });
await capture(story.file + '-1440');
if (['full-composition', 'expanded-org-chart'].includes(story.file)) {
const shielding = await page.evaluate(() => {
const pageFrame = document.querySelector('.sb-page');
const shadow = document.querySelector('.hm-orgchart-host').shadowRoot;
const keys = ['fontFamily', 'fontSize', 'lineHeight', 'color', 'backgroundColor', 'backgroundImage', 'borderTopColor', 'borderRightColor', 'borderBottomColor', 'borderLeftColor', 'borderTopWidth', 'borderRightWidth', 'borderBottomWidth', 'borderLeftWidth', 'borderRadius', 'paddingTop', 'paddingRight', 'paddingBottom', 'paddingLeft', 'width', 'height'];
const snapshot = () => ['.hm-orgchart-employee', '.hm-orgchart-organization', '.hm-orgchart-organization-name', '.hm-orgchart-person-name', '.hm-orgchart-summary'].flatMap(selector => [...shadow.querySelectorAll(selector)].map(element => ({ selector, id: element.dataset.employeeId ?? element.dataset.organizationId, style: Object.fromEntries(keys.map(key => [key, getComputedStyle(element)[key]])) })));
const withFrame = snapshot(), previous = pageFrame.className;
try { pageFrame.className = ''; return { withFrame, withoutFrame: snapshot() }; }
finally { pageFrame.className = previous; }
});
check(JSON.stringify(shielding.withFrame) === JSON.stringify(shielding.withoutFrame), story.name + ': StoryPage frame does not override Core shadow typography, colors, borders or dimensions', { elements: shielding.withFrame.length, changed: shielding.withFrame.filter((value, index) => JSON.stringify(value) !== JSON.stringify(shielding.withoutFrame[index])) });
await settle();
}
}
});
await section('widths', async () => {
for (const story of stories) for (const width of [390, 768, 1280, 1440]) {
await gotoStory(story, width);
const geometry = await pageGeometry();
measurements.push({ name: `${story.name} ${width}px`, geometry });
check(geometry.document.scrollWidth <= geometry.document.clientWidth + 1 && geometry.document.scrollWidth <= width + 1, `${story.name} ${width}px: no document horizontal overflow`, geometry.document);
check(geometry.frame.title === story.name, `${story.name} ${width}px: direct URL renders requested page`, { url: page.url(), title: geometry.frame.title });
if (geometry.fixed) {
const fixed = geometry.fixed;
const matrix = fixed.matrix === 'none' ? [1, 0, 0, 1] : fixed.matrix.slice(7, -1).split(',').map(Number);
const uniform = Math.abs(matrix[0] - matrix[3]) < 0.0001 && Math.abs(matrix[1]) < 0.0001 && Math.abs(matrix[2]) < 0.0001;
const bounds = fixed.stage.x >= fixed.wrapper.x - 1 && fixed.stage.y >= fixed.wrapper.y - 1 && fixed.stage.right <= fixed.wrapper.right + 1 && fixed.stage.bottom <= fixed.wrapper.bottom + 1;
const noScroll = fixedHasNoInternalScroll(fixed);
check(fixed.scale > 0 && fixed.scale <= 1 && uniform && Math.abs(fixed.stage.width / fixed.baseWidth - matrix[0]) < 0.001 && Math.abs(fixed.stage.height / fixed.baseHeight - matrix[3]) < 0.001 && bounds, `Full Composition ${width}px: uniform scale and entire stage fit content wrapper`, fixed);
check(noScroll, `Full Composition ${width}px: no internal horizontal or vertical scroll`, { note: 'The overflow-hidden composition retains unscaled transformed extents in CSSOM; complete visible stage bounds are checked separately.', scroll: fixed.scroll });
check(fixed.visibleBounds.every(({ box }) => box.x >= fixed.stage.x - 1 && box.y >= fixed.stage.y - 1 && box.right <= fixed.stage.right + 1 && box.bottom <= fixed.stage.bottom + 1), `Full Composition ${width}px: cards and summaries remain inside complete stage`, fixed.visibleBounds);
}
if (width === 390) await capture(story.file + '-390');
}
});
await section('full-repair', async () => {
await page.setViewportSize({ width: 1440, height: 1100 });
await page.goto(`${origin}/iframe.html?id=${patterns[0]}&viewMode=story`);
await page.locator('.sb-page-title').waitFor(); await settle();
const reference = await commonStyle();
await gotoStory(stories[2]);
const style = await commonStyle();
const inspection = await page.evaluate(() => ['.sb-page', '.sb-page-inner'].map(selector => {
const element = document.querySelector(selector), css = getComputedStyle(element), rect = element.getBoundingClientRect();
return { selector, display: css.display, marginLeft: css.marginLeft, marginRight: css.marginRight, marginInline: css.marginInline, rect: { x: rect.x, y: rect.y, width: rect.width, right: rect.right }, inline: element.getAttribute('style') };
}));
check(JSON.stringify(style) === JSON.stringify(reference), 'Full Composition repair: common styles and actual centered gutters match existing Pattern', { style, inspection, reference });
await capture('full-composition-1440');
for (const width of [390, 768, 1280, 1440]) {
await gotoStory(stories[2], width);
const before = await pageGeometry();
const wrapper = await page.locator('.hm-orgchart-composition').boundingBox();
await page.mouse.move(wrapper.x + wrapper.width / 2, wrapper.y + Math.min(30, wrapper.height / 2));
await page.mouse.wheel(80, 80); await settle();
const after = await pageGeometry();
const fixed = after.fixed;
const bounds = fixed.stage.x >= fixed.wrapper.x - 1 && fixed.stage.y >= fixed.wrapper.y - 1 && fixed.stage.right <= fixed.wrapper.right + 1 && fixed.stage.bottom <= fixed.wrapper.bottom + 1;
check(fixedHasNoInternalScroll(fixed) && bounds && fixed.visibleBounds.every(({ box }) => box.x >= fixed.stage.x - 1 && box.y >= fixed.stage.y - 1 && box.right <= fixed.stage.right + 1 && box.bottom <= fixed.stage.bottom + 1), `Full Composition repair ${width}px: no wheel-scroll area or clipped transformed contents`, { before: before.fixed.scroll, after: fixed.scroll, wrapper: fixed.wrapper, stage: fixed.stage, document: after.document });
measurements.push({ name: `Full Composition repair ${width}px`, geometry: after });
}
});
await section('smoke', async () => {
await gotoStory(stories[3]);
for (const label of ['데이터 범위', '전체 직원 수', '조직도 표시 유형', '혼합형 시작 Depth', '특정 그룹 세로 배치', '초기 표시 Depth', '직원 정보 팝업']) check(await page.getByRole('combobox', { name: label, exact: true }).count() === 1, `Expanded: ${label} control preserved`);
for (const label of ['접기·펼치기 사용', 'Zoom 사용', 'Pan 사용']) check(await page.getByRole('checkbox', { name: label, exact: true }).isChecked(), `Expanded: independent ${label} control enabled`);
check(await page.getByRole('checkbox').count() === 10 && await page.getByRole('button', { name: '데이터 적용', exact: true }).count() === 1 && await page.getByRole('button', { name: '초기화', exact: true }).count() === 1, 'Expanded: seven company selections and apply/reset actions preserved');
await select('전체 직원 수', '100명');
check((await autoState()).employees === 20, 'Expanded: draft count waits for data apply');
await page.getByRole('button', { name: '데이터 적용', exact: true }).click(); await ready(100);
check((await autoState()).employees === 100 && (await page.locator('[data-component="OrgChartExampleCounts"]').textContent()).includes('100'), 'Expanded: data apply renders 100 employee records');
for (const [option, type] of [['세로형 TB', 'tb'], ['가로형 LR', 'lr'], ['혼합형 Hybrid', 'hybrid']]) {
await select('조직도 표시 유형', option); await ready(100);
const state = await autoState();
check(state.type === type && state.employees === 100 && autoFits(state), `Expanded: ${type} switch preserves data and Fit`, state);
}
await select('초기 표시 Depth', '2Depth까지'); await ready();
const depths = await page.locator('.node[data-depth]').evaluateAll(nodes => nodes.map(node => Number(node.dataset.depth)));
check(depths.every(depth => depth < 2), 'Expanded: initial Depth control still limits visible groups', depths);
await select('초기 표시 Depth', '전체 펼침'); await ready(100);
const beforeCollapse = await autoState();
const toggle = page.locator('.hm-orgchart-expanded-toggle').first();
const organizationId = await toggle.getAttribute('data-organization-id');
await toggle.focus(); await toggle.press('Enter'); await ready();
const collapsed = await autoState();
check(await toggle.getAttribute('aria-expanded') === 'false' && collapsed.organizations < beforeCollapse.organizations && collapsed.employees < beforeCollapse.employees, 'Expanded: Enter collapses a group and its descendants', { organizationId, before: beforeCollapse, after: collapsed });
await page.locator(`.hm-orgchart-expanded-toggle[data-organization-id="${organizationId}"]`).press('Space'); await ready(100);
check(await page.locator(`.hm-orgchart-expanded-toggle[data-organization-id="${organizationId}"]`).getAttribute('aria-expanded') === 'true', 'Expanded: Space expands the same group');
await page.getByRole('checkbox', { name: 'Zoom 사용', exact: true }).uncheck(); await ready();
check((await autoState()).zoomEnabled === 'false' && await page.getByRole('button', { name: '확대', exact: true }).isDisabled(), 'Expanded: independent Zoom switch disables only Zoom action');
await page.getByRole('checkbox', { name: 'Zoom 사용', exact: true }).check(); await ready();
await page.getByRole('button', { name: '100% 복원', exact: true }).click();
const beforeZoom = await autoState();
await page.getByRole('button', { name: '확대', exact: true }).click(); await settle();
check((await autoState()).scale > beforeZoom.scale, 'Expanded: Zoom changes actual forest scale');
await page.getByRole('button', { name: 'Fit to View', exact: true }).click(); await settle();
const beforePan = await autoState();
await page.locator('.hm-orgchart-viewport').scrollIntoViewIfNeeded();
const viewport = await page.locator('.hm-orgchart-viewport').boundingBox();
await page.mouse.move(viewport.x + 10, viewport.y + 10); await page.mouse.down(); await page.mouse.move(viewport.x + 50, viewport.y + 35); await page.mouse.up(); await settle();
const afterPan = await autoState();
check(afterPan.x !== beforePan.x && afterPan.y !== beforePan.y, 'Expanded: pointer Pan changes both forest coordinates', { before: beforePan, after: afterPan });
await page.getByRole('checkbox', { name: 'Pan 사용', exact: true }).uncheck(); await ready();
check((await autoState()).panEnabled === 'false' && (await autoState()).zoomEnabled === 'true', 'Expanded: independent Pan switch leaves Zoom enabled');
await page.getByRole('checkbox', { name: 'Pan 사용', exact: true }).check(); await ready();
await page.getByRole('button', { name: 'Fit to View', exact: true }).click(); await settle();
check(autoFits(await autoState()), 'Expanded: toolbar Fit contains complete forest');
const employee = page.locator('.hm-orgchart-employee').first(), employeeId = await employee.getAttribute('data-employee-id');
await employee.focus(); await employee.press('Enter');
const dialog = page.locator('[data-component="HMOrgChartEmployeeDialog"]');
await dialog.waitFor({ state: 'visible' }); await settle();
const popup = await dialog.evaluate(element => ({ role: element.getAttribute('role'), employeeId: element.dataset.employeeId, portal: element.parentElement === document.body, focusInside: element.contains(document.activeElement), text: element.textContent, box: (() => { const r = element.getBoundingClientRect(); return { x: r.x, right: r.right, width: r.width }; })() }));
check(popup.role === 'dialog' && popup.employeeId === employeeId && popup.portal && popup.focusInside && await page.getByRole('dialog').count() === 1 && await page.locator('dialog[open]').count() === 0, 'Expanded: actual single HM Dialog with exact employee and Portal focus', popup);
await page.keyboard.press('Tab'); await page.keyboard.press('Shift+Tab');
check(await dialog.evaluate(element => element.contains(document.activeElement)), 'Expanded: HM Dialog traps Tab focus');
await capture('expanded-hm-dialog', false);
await page.keyboard.press('Escape'); await dialog.waitFor({ state: 'hidden' }); await settle();
const focusedEmployee = () => page.evaluate(() => document.querySelector('.hm-orgchart-host').shadowRoot.activeElement?.dataset.employeeId);
check(await focusedEmployee() === employeeId, 'Expanded: ESC closes HM Dialog and returns employee focus');
await employee.press('Space'); await dialog.waitFor({ state: 'visible' });
await dialog.getByRole('button', { name: '닫기', exact: true }).click(); await dialog.waitFor({ state: 'hidden' }); await settle();
check(await focusedEmployee() === employeeId, 'Expanded: HM close button returns employee focus');
await page.setViewportSize({ width: 390, height: 844 }); await settle();
await employee.focus(); await employee.press('Enter'); await dialog.waitFor({ state: 'visible' }); await settle();
const narrow = await dialog.evaluate(element => { const r = element.getBoundingClientRect(); return { x: r.x, right: r.right, width: r.width, scrollWidth: element.scrollWidth, clientWidth: element.clientWidth, documentScrollWidth: document.documentElement.scrollWidth, innerWidth }; });
check(narrow.x >= -1 && narrow.right <= 391 && narrow.scrollWidth <= narrow.clientWidth + 1 && narrow.documentScrollWidth <= 390, 'Expanded: actual HM Dialog fits 390px', narrow);
await capture('expanded-hm-dialog-390', false);
await page.keyboard.press('Escape'); await dialog.waitFor({ state: 'hidden' });
// Department Colors remains a live Core specimen with its original option.
await gotoStory(stories[1]);
const longNameButton = page.getByRole('button', { name: '긴 이름 검증 시작', exact: true });
await longNameButton.click(); await ready();
check(await page.getByRole('button', { name: '긴 이름 검증 종료', exact: true }).getAttribute('aria-pressed') === 'true' && await page.locator('.hm-orgchart-person-name').filter({ hasText: '아주긴가상이름표시검증사례' }).count() > 0, 'Department Colors: original long-name specimen option remains functional');
});
} finally {
check(errors.length === 0, 'No new browser page or console errors', errors);
const result = { startedAt, completedAt: new Date().toISOString(), origin, sections, browser: browser.version(), results, measurements, errors, pass: results.filter(item => item.status === 'PASS').length, fail: results.filter(item => item.status === 'FAIL').length,
limitations: ['Historical P7 units/full suites were not repeated.', 'Common computed styles prove runtime parity; design intent approval remains separate.', 'Document vertical scrolling caused by StoryPage text is reported separately from internal chart scrolling.', 'The smoke checks are a narrow regression sample, not full option-combination or accessibility certification.'] };
const suffix = sections.length ? '-' + sections.join('-') : '';
await fs.writeFile(path.join(output, `browser${suffix}.json`), JSON.stringify(result, null, 2));
console.log(JSON.stringify({ pass: result.pass, fail: result.fail, errors: errors.length, output: `test-results/org-chart-p8/browser${suffix}.json` }));
await context.close(); await browser.close();
if (result.fail) process.exitCode = 1;
}
+280
View File
@@ -0,0 +1,280 @@
// Focused P9.1 before/after evidence. Does not repeat P7-P9 or interact with Expanded.
import fs from 'node:fs/promises';
import { writeFileSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { launchChromium } from '../../scripts/orgchart-browser-runtime.mjs';
const root = fileURLToPath(new URL('../../', import.meta.url));
const output = path.join(root, 'test-results/org-chart-p9-1');
const origin = (process.env.ORGCHART_STORY_ORIGIN ?? 'http://127.0.0.1:6061').replace(/\/$/, '');
const baselineMode = process.argv.includes('--baseline');
const resizeOnly = process.argv.includes('--resize-only');
const sizes = [{ width: 1920, height: 1080 }, { width: 1440, height: 900 }, { width: 390, height: 844 }];
const stories = { department: 'orgchart--department-colors', fixed: 'orgchart--full-composition' };
const publicStories = ['orgchart--logo-personnel-summary', ...Object.values(stories), 'orgchart--expanded-org-chart'];
const startedAt = new Date().toISOString(), results = [], snapshots = {}, errors = [];
await fs.mkdir(output, { recursive: true });
const progressFile = path.join(output, resizeOnly ? 'browser-resize-progress.json' : baselineMode ? 'browser-baseline-progress.json' : 'browser-progress.json');
const persist = () => writeFileSync(progressFile, JSON.stringify({ startedAt, origin, baselineMode, resizeOnly, results, snapshots, errors }, null, 2));
const check = (pass, name, detail) => {
results.push({ name, status: pass ? 'PASS' : 'FAIL', ...(detail === undefined ? {} : { detail }) });
persist();
if (!pass) console.log('FAIL', name, JSON.stringify(detail));
};
const equal = (a, b) => JSON.stringify(a) === JSON.stringify(b);
const near = (a, b, tolerance = 0.15) => a && b && Object.keys(a).every(key => Math.abs(a[key] - b[key]) <= tolerance);
const contains = (outer, inner, tolerance = 1) => outer && inner && inner.x >= outer.x - tolerance && inner.y >= outer.y - tolerance && inner.right <= outer.right + tolerance && inner.bottom <= outer.bottom + tolerance;
const browser = await launchChromium({ headless: true });
const context = await browser.newContext({ viewport: sizes[0] });
context.on('page', target => {
target.on('pageerror', error => { errors.push({ type: 'pageerror', url: target.url(), message: error.message }); persist(); });
target.on('console', message => { if (message.type() === 'error') { errors.push({ type: 'console', url: target.url(), message: message.text(), location: message.location() }); persist(); } });
});
const page = await context.newPage();
page.setDefaultTimeout(20000);
async function settle() {
await page.evaluate(async () => { await document.fonts.ready; for (let i = 0; i < 8; i++) await new Promise(resolve => requestAnimationFrame(resolve)); });
}
async function goto(name, size) {
await page.setViewportSize(size);
await page.goto(`${origin}/iframe.html?id=${stories[name]}&viewMode=story`);
await page.locator('.sb-page-title').waitFor();
await page.waitForFunction(() => {
const hosts = [...document.querySelectorAll('.hm-orgchart-host,.hm-orgchart-component-host')];
return hosts.length === 1 && hosts.every(host => host.shadowRoot && !host.shadowRoot.querySelector('[aria-busy="true"]') && [...host.shadowRoot.querySelectorAll('link[rel="stylesheet"]')].every(link => link.sheet));
});
// Force offscreen lazy placeholders to load in verification only. The same
// browser-only attribute is applied to both baseline and final DOM hashes.
await page.evaluate(async () => {
const shadow = document.querySelector('.hm-orgchart-host,.hm-orgchart-component-host').shadowRoot;
await Promise.all([...shadow.querySelectorAll('img')].map(async image => { image.loading = 'eager'; await image.decode().catch(() => {}); }));
});
await page.waitForFunction(() => [...document.querySelector('.hm-orgchart-host,.hm-orgchart-component-host').shadowRoot.querySelectorAll('img')].every(image => image.complete && image.naturalWidth > 0));
await settle();
}
async function snapshot() {
return page.evaluate(async () => {
const host = document.querySelector('.hm-orgchart-host,.hm-orgchart-component-host');
const shadow = host?.shadowRoot;
const all = selector => [...(shadow?.querySelectorAll(selector) ?? [])];
const first = selector => shadow?.querySelector(selector);
const rect = element => {
if (!element) return null;
const r = element.getBoundingClientRect();
return { x: r.x, y: r.y, width: r.width, height: r.height, right: r.right, bottom: r.bottom };
};
const union = elements => {
const boxes = elements.map(rect).filter(box => box && box.width > 0 && box.height > 0);
if (!boxes.length) return null;
const x = Math.min(...boxes.map(box => box.x)), y = Math.min(...boxes.map(box => box.y));
const right = Math.max(...boxes.map(box => box.right)), bottom = Math.max(...boxes.map(box => box.bottom));
return { x, y, width: right - x, height: bottom - y, right, bottom };
};
const styleKeys = ['fontFamily', 'fontSize', 'fontWeight', 'lineHeight', 'letterSpacing', 'color', 'backgroundColor', 'backgroundImage', 'borderTopWidth', 'borderRightWidth', 'borderBottomWidth', 'borderLeftWidth', 'borderTopColor', 'borderRightColor', 'borderBottomColor', 'borderLeftColor', 'borderRadius', 'boxShadow', 'paddingTop', 'paddingRight', 'paddingBottom', 'paddingLeft', 'marginTop', 'marginRight', 'marginBottom', 'marginLeft', 'width', 'height', 'minWidth', 'minHeight', 'textAlign', 'whiteSpace', 'overflowWrap', 'verticalAlign', 'objectFit', 'objectPosition', 'gap', 'rowGap', 'columnGap'];
const style = element => element ? Object.fromEntries(styleKeys.map(key => [key, getComputedStyle(element)[key]])) : null;
const hash = async input => [...new Uint8Array(await crypto.subtle.digest('SHA-256', typeof input === 'string' ? new TextEncoder().encode(input) : input))].map(byte => byte.toString(16).padStart(2, '0')).join('');
const assetHashes = new Map();
const assets = async element => Promise.all([...element.querySelectorAll('img')].map(async image => {
const url = image.currentSrc || image.src;
if (!assetHashes.has(url)) assetHashes.set(url, fetch(url).then(response => { if (!response.ok) throw new Error(`Asset HTTP ${response.status}: ${url.slice(0, 120)}`); return response.arrayBuffer(); }).then(hash));
return { hash: await assetHashes.get(url), naturalWidth: image.naturalWidth, naturalHeight: image.naturalHeight, loaded: image.complete && image.naturalWidth > 0 };
}));
const visible = element => { const r = rect(element); return r?.width > 0 && r.height > 0 && getComputedStyle(element).display !== 'none'; };
const scroll = element => {
if (!element) return null;
const css = getComputedStyle(element);
return { scrollWidth: element.scrollWidth, clientWidth: element.clientWidth, scrollHeight: element.scrollHeight, clientHeight: element.clientHeight, left: element.scrollLeft, top: element.scrollTop, overflowX: css.overflowX, overflowY: css.overflowY };
};
const common = Object.fromEntries(['.sb-page', '.sb-page-inner', '.sb-page-title', '.sb-page-section-title', '.sb-page-section-header', '.sb-page-section-body'].map(selector => [selector, style(document.querySelector(selector))]));
// Page/section height is content-dependent; typography, borders and gutters are invariant.
for (const value of Object.values(common)) if (value) for (const key of ['height', 'minHeight']) delete value[key];
const documentScroll = { viewportWidth: innerWidth, viewportHeight: innerHeight, root: scroll(document.documentElement), body: scroll(document.body), scrollingElement: scroll(document.scrollingElement) };
const departmentRoot = first('.hm-orgchart-department-colors');
const departments = departmentRoot ? await Promise.all([...departmentRoot.querySelectorAll('.hm-orgchart-color-preview')].map(async row => {
const heading = row.querySelector('.hm-orgchart-color-department-name');
const preview = row.querySelector('.hm-orgchart-preview-grid');
const cards = [...row.querySelectorAll('.hm-orgchart-employee')];
const headingBox = rect(heading), cardBox = union(cards);
return {
name: heading?.textContent, box: rect(row), heading: headingBox, headingStyle: style(heading), preview: rect(preview), previewStyle: style(preview),
gap: headingBox && cardBox ? cardBox.y - headingBox.bottom : null,
visibleChildren: [...row.children].filter(visible).map(child => ({ tag: child.tagName, className: child.className })),
cards: await Promise.all(cards.map(async card => ({
kind: card.dataset.cardKind, width: card.offsetWidth, height: card.offsetHeight, box: rect(card), hash: await hash(card.outerHTML), assets: await assets(card),
subtree: [card, ...card.querySelectorAll('*')].map(child => ({ tag: child.tagName, className: typeof child.className === 'string' ? child.className : child.className?.baseVal, style: style(child) })),
}))),
};
})) : null;
const unwantedSelectors = '.hm-orgchart-color-legend,.hm-orgchart-contrast-note,.hm-orgchart-contrast-comparison,.hm-orgchart-card-caption,.hm-orgchart-representative-preview,.hm-orgchart-department-colors > p,.hm-orgchart-color-preview > p';
const unwanted = departmentRoot ? [...departmentRoot.querySelectorAll(unwantedSelectors)].filter(visible).map(element => ({ className: element.className, text: element.textContent.slice(0, 150) })) : [];
const wrapper = first('.hm-orgchart-composition'), stage = first('.hm-orgchart-stage');
let fixed = null;
if (stage) {
const stageBox = rect(stage), scale = stageBox.width / stage.offsetWidth;
const available = rect(document.querySelector('.hm-orgchart-story-full'));
const trees = all('.hm-orgchart-forest[data-layout="workdesk"] > .hm-orgchart-tree');
// Measure rendered visible six-column content, never the full-width tree viewport.
const bodyElements = trees.flatMap(tree => [...tree.querySelectorAll('.hm-orgchart-organization-name,.hm-orgchart-employee')]);
const body = union(bodyElements);
const normalized = box => box ? { x: (box.x - stageBox.x) / scale, y: (box.y - stageBox.y) / scale, width: box.width / scale, height: box.height / scale } : null;
const selectors = '.hm-orgchart-employee,.hm-orgchart-organization-name,.hm-orgchart-organization-totals,.hm-orgchart-organization-totals-title,.hm-orgchart-workdesk-summary-frame,.hm-orgchart-workdesk-position-table,.hm-orgchart-workdesk-position-table th,.hm-orgchart-workdesk-position-table td,.hm-orgchart-slot-side,.hm-orgchart-slot-belowTree,.hm-orgchart-status-group,.hm-orgchart-summary-title,.hm-orgchart-slot-footer,.hm-orgchart-affiliation-bar,.hm-orgchart-affiliation-bar-item,.hm-orgchart-axis,.hm-orgchart-top-leaders,.hm-orgchart-hub,.hm-orgchart-workdesk-overlay,.hm-orgchart-workdesk-decoration';
const elements = [...stage.querySelectorAll(selectors)];
const invariants = await Promise.all(elements.map(async (element, index) => ({
key: `${element.tagName}:${typeof element.className === 'string' ? element.className : element.className.baseVal}:${element.dataset.employeeId ?? element.dataset.organizationId ?? element.dataset.contentSlot ?? element.dataset.workdeskDecoration ?? ''}:${index}`,
box: normalized(rect(element)), style: style(element), htmlHash: await hash(element.outerHTML),
})));
const collisionElements = [...stage.querySelectorAll('.hm-orgchart-employee,.hm-orgchart-organization-name,.hm-orgchart-organization-totals,.hm-orgchart-affiliation-bar,.hm-orgchart-status-group > .hm-orgchart-summary-title')].filter(visible);
const collisions = [];
for (let i = 0; i < collisionElements.length; i++) for (let j = i + 1; j < collisionElements.length; j++) {
const a = collisionElements[i], b = collisionElements[j];
if (a.contains(b) || b.contains(a)) continue;
const ra = normalized(rect(a)), rb = normalized(rect(b));
const width = Math.min(ra.x + ra.width, rb.x + rb.width) - Math.max(ra.x, rb.x);
const height = Math.min(ra.y + ra.height, rb.y + rb.height) - Math.max(ra.y, rb.y);
if (width > 0.5 && height > 0.5) collisions.push({ pair: `${i}:${j}`, a: a.className, b: b.className, width, height });
}
const matrix = getComputedStyle(stage).transform.match(/matrix\(([^)]+)\)/)?.[1].split(',').map(Number);
fixed = {
scale, verticalScale: stageBox.height / parseFloat(getComputedStyle(stage).height), matrix, stage: stageBox, available, wrapper: rect(wrapper), host: rect(host), baseWidth: stage.offsetWidth, baseHeight: stage.offsetHeight, computedBaseWidth: parseFloat(getComputedStyle(stage).width), computedBaseHeight: parseFloat(getComputedStyle(stage).height),
treeCount: trees.length, body, bodyStage: normalized(body), bodyElementCount: bodyElements.length, bodyCenter: body?.x + body?.width / 2, availableCenter: available?.x + available?.width / 2,
centerDelta: body && available ? body.x + body.width / 2 - available.x - available.width / 2 : null,
htmlHash: await hash(stage.innerHTML), invariants, assets: await assets(stage), collisions,
scroll: ['.hm-orgchart-shell', '.hm-orgchart-content', '.hm-orgchart-composition', '.hm-orgchart-viewport'].map(selector => ({ selector, ...scroll(first(selector)) })),
allVisibleContained: bodyElements.every(element => { const b = rect(element); return b.x >= stageBox.x - 1 && b.y >= stageBox.y - 1 && b.right <= stageBox.right + 1 && b.bottom <= stageBox.bottom + 1; }),
};
}
return {
title: document.querySelector('.sb-page-title')?.textContent, common, documentScroll, departments, unwanted,
optionsPresent: [...document.querySelectorAll('button')].filter(visible).some(button => /긴 이름 검증/.test(button.textContent)),
sectionTitles: [...document.querySelectorAll('.sb-page-section-title')].map(element => element.textContent), fixed,
};
});
}
function documentFits(state, bothAxes) {
const d = state.documentScroll;
return [d.root, d.body, d.scrollingElement].filter(Boolean).every(value => value.scrollWidth <= d.viewportWidth + 1 && (!bothAxes || value.scrollHeight <= d.viewportHeight + 1));
}
function preserveDepartment(before, after) {
return before.departments?.length === after.departments?.length && after.departments.every((row, index) => {
const old = before.departments[index];
return row.name === old.name && equal(row.headingStyle, old.headingStyle) && row.cards.length === old.cards.length && row.cards.every((card, i) => equal({ kind: card.kind, width: card.width, height: card.height, hash: card.hash, assets: card.assets, subtree: card.subtree }, { kind: old.cards[i].kind, width: old.cards[i].width, height: old.cards[i].height, hash: old.cards[i].hash, assets: old.cards[i].assets, subtree: old.cards[i].subtree }));
});
}
function preserveFixed(before, after) {
return before.htmlHash === after.htmlHash && equal(before.assets, after.assets) && before.invariants.length === after.invariants.length && after.invariants.every((item, index) => {
const old = before.invariants[index];
return old.key === item.key && old.htmlHash === item.htmlHash && equal(old.style, item.style) && near(old.box, item.box);
});
}
let fatal;
try {
const baseline = baselineMode ? null : JSON.parse(await fs.readFile(path.join(output, 'visual-baseline.json'), 'utf8'));
if (resizeOnly) {
// Mount once; every following measurement reuses this exact document and
// proves ResizeObserver handles both viewport axes without a page reload.
await goto('fixed', sizes[1]);
snapshots.fixed = {};
for (const size of [sizes[0], sizes[2], { width: 1440, height: 640 }, sizes[1]]) {
await page.setViewportSize(size);
await page.waitForFunction(() => {
const shadow = document.querySelector('.hm-orgchart-host')?.shadowRoot;
const available = document.querySelector('.hm-orgchart-story-full')?.getBoundingClientRect();
const elements = [...(shadow?.querySelectorAll('.hm-orgchart-forest[data-layout="workdesk"] > .hm-orgchart-tree .hm-orgchart-organization-name,.hm-orgchart-forest[data-layout="workdesk"] > .hm-orgchart-tree .hm-orgchart-employee') ?? [])];
if (!available || !elements.length) return false;
const boxes = elements.map(element => element.getBoundingClientRect());
const left = Math.min(...boxes.map(box => box.left)), right = Math.max(...boxes.map(box => box.right));
return Math.abs((left + right) / 2 - available.x - available.width / 2) <= 1;
});
await settle();
const state = await snapshot(), fixed = state.fixed, key = `${size.width}x${size.height}`;
const before = baseline.snapshots.fixed[size.width].fixed;
const stability = await page.evaluate(async () => {
const stage = document.querySelector('.hm-orgchart-host').shadowRoot.querySelector('.hm-orgchart-stage');
let styleMutations = 0;
const observer = new MutationObserver(records => { styleMutations += records.length; });
observer.observe(stage, { attributes: true, attributeFilter: ['style'] });
const signatures = [stage.getAttribute('style')];
for (let frame = 0; frame < 10; frame++) {
await new Promise(resolve => requestAnimationFrame(resolve));
signatures.push(stage.getAttribute('style'));
}
styleMutations += observer.takeRecords().length;
observer.disconnect();
return { frames: 10, styleMutations, stable: signatures.every(signature => signature === signatures[0]), signatures: [...new Set(signatures)] };
});
snapshots.fixed[key] = { ...state, stability };
check(fixed.treeCount === 6 && Math.abs(fixed.centerDelta) <= 1 && contains(fixed.available, fixed.stage) && contains(fixed.host, fixed.stage) && fixed.allVisibleContained && Math.abs(fixed.scale - fixed.verticalScale) <= 0.0001,
`Resize ${key}: six-group body stays centered and full stage fits with uniform scale`, { bodyCenter: fixed.bodyCenter, availableCenter: fixed.availableCenter, delta: fixed.centerDelta, scale: fixed.scale, verticalScale: fixed.verticalScale, stage: fixed.stage, available: fixed.available });
const offsets = await page.evaluate(() => ({ x: scrollX, y: scrollY }));
check(documentFits(state, true) && offsets.x === 0 && offsets.y === 0 && fixed.scroll.every(item => item.left === undefined || (item.left === 0 && item.top === 0)),
`Resize ${key}: no document overflow or document/internal scroll offsets`, { document: state.documentScroll, offsets, internal: fixed.scroll });
check(preserveFixed(before, fixed), `Resize ${key}: cards, connectors and statistics keep baseline stage-relative geometry and styles`, { beforeHTMLHash: before.htmlHash, afterHTMLHash: fixed.htmlHash, elementCount: fixed.invariants.length });
check(stability.stable && stability.styleMutations === 0, `Resize ${key}: stage style stops mutating over 10 animation frames`, stability);
await page.screenshot({ path: path.join(output, `after-resize-fixed-${key}.png`), fullPage: true });
}
} else {
for (const name of Object.keys(stories)) {
snapshots[name] = {};
for (const size of sizes) {
await goto(name, size);
const state = await snapshot();
snapshots[name][size.width] = state;
await page.screenshot({ path: path.join(output, `${baselineMode ? 'before' : 'after'}-${name}-${size.width}.png`), fullPage: true });
if (baselineMode) {
check(name === 'department' ? state.departments?.length === 6 : state.fixed?.treeCount === 6, `Baseline ${name} ${size.width}: six groups and actual browser geometry captured`);
continue;
}
const before = baseline.snapshots[name][size.width];
check(state.title === before.title && equal(state.common, before.common), `${name} ${size.width}: common StoryPage title, typography and gutters preserved`);
if (name === 'department') {
check(state.departments?.length === 6 && state.departments.every(row => row.name && row.cards.length === 2 && row.cards.some(card => card.kind === 'leader') && row.cards.some(card => card.kind === 'employee')), `Department ${size.width}: six titles, 1Depth large leaders and 2Depth small employee cards`, state.departments?.map(row => ({ name: row.name, cards: row.cards.map(card => ({ kind: card.kind, width: card.width, height: card.height })) })));
check(state.unwanted.length === 0 && !state.optionsPresent && !state.sectionTitles.includes('표시 옵션') && state.departments.every(row => row.visibleChildren.length === 2), `Department ${size.width}: descriptions, provenance, legends, contrast and captions removed`, { unwanted: state.unwanted, optionsPresent: state.optionsPresent, sections: state.sectionTitles, children: state.departments.map(row => row.visibleChildren) });
check(preserveDepartment(before, state), `Department ${size.width}: exact existing header colors/gradient, card DOM, assets, typography and computed styles preserved`);
check(state.departments.every(row => row.gap >= 1 && row.cards.every(card => contains(row.box, card.box)) && contains(row.box, row.heading)), `Department ${size.width}: header/card separation and complete card containment`, state.departments.map(row => ({ name: row.name, beforeGap: before.departments.find(old => old.name === row.name)?.gap, afterGap: row.gap })));
check(state.departments.every((row, i, rows) => rows.every((other, j) => i === j || Math.abs(row.box.y - other.box.y) > 1 || Math.abs(row.preview.y - other.preview.y) <= 1)), `Department ${size.width}: card starts align across each shared desktop row`);
check(documentFits(state, false), `Department ${size.width}: no document horizontal overflow`, state.documentScroll);
} else {
const fixed = state.fixed, old = before.fixed;
check(fixed.treeCount === 6 && Math.abs(fixed.centerDelta) <= 1, `Full Composition ${size.width}: actual six-group body centered in available Storybook region`, { before: { bodyCenter: old.bodyCenter, availableCenter: old.availableCenter, delta: old.centerDelta, bounds: old.body }, after: { bodyCenter: fixed.bodyCenter, availableCenter: fixed.availableCenter, delta: fixed.centerDelta, bounds: fixed.body } });
check(fixed.baseWidth === 1920 && fixed.baseHeight === 915 && Math.abs(fixed.computedBaseHeight - 914.5) <= 0.02 && fixed.scale > 0 && fixed.scale <= 1 && Math.abs(fixed.scale - fixed.verticalScale) <= 0.0001 && fixed.matrix && Math.abs(fixed.matrix[0] - fixed.matrix[3]) <= 0.0001 && contains(fixed.available, fixed.stage) && contains(fixed.host, fixed.stage) && fixed.allVisibleContained, `Full Composition ${size.width}: original 1920 x 914.5 canvas fully fits with uniform scale`, { scale: fixed.scale, verticalScale: fixed.verticalScale, matrix: fixed.matrix, stage: fixed.stage, available: fixed.available, host: fixed.host, naturalSize: [fixed.baseWidth, fixed.baseHeight], computedBaseHeight: fixed.computedBaseHeight });
check(preserveFixed(old, fixed), `Full Composition ${size.width}: all stage-relative cards, headers, axis, connectors, decorations, side/status/footer geometry and styles preserved`, { beforeHTMLHash: old.htmlHash, afterHTMLHash: fixed.htmlHash, elementCount: fixed.invariants.length, assetCount: fixed.assets.length });
const newCollisions = fixed.collisions.filter(collision => !old.collisions.some(previous => previous.pair === collision.pair));
check(newCollisions.length === 0, `Full Composition ${size.width}: no new card/header/statistics overlaps`, { baselineOverlapCount: old.collisions.length, afterOverlapCount: fixed.collisions.length, newCollisions });
// Transformed fixed-size child layout can retain an unscaled scrollWidth
// in the existing hidden fit shell. Full painted-stage containment is
// separately mandatory; only unchanged hidden-shell overflow is exempt.
const internalFits = fixed.scroll.every((item, index) => item.scrollWidth === undefined || (item.left === 0 && item.top === 0
&& (item.scrollWidth <= item.clientWidth + 1 || (item.overflowX === 'hidden' && old.scroll[index].overflowX === 'hidden'))
&& (item.scrollHeight <= item.clientHeight + 1 || (item.overflowY === 'hidden' && old.scroll[index].overflowY === 'hidden'))));
check(documentFits(state, true) && internalFits, `Full Composition ${size.width}: no document or internal horizontal/vertical scroll`, { document: state.documentScroll, internal: fixed.scroll });
const wheelBefore = await page.evaluate(() => ({ x: scrollX, y: scrollY }));
await page.mouse.move(fixed.available.x + fixed.available.width / 2, fixed.available.y + Math.min(40, fixed.available.height / 2));
await page.mouse.wheel(120, 120);
await settle();
const wheelAfter = await page.evaluate(() => ({ x: scrollX, y: scrollY }));
check(equal(wheelBefore, wheelAfter), `Full Composition ${size.width}: wheel does not scroll document`, { before: wheelBefore, after: wheelAfter });
}
}
}
if (!baselineMode) {
const response = await page.request.get(`${origin}/index.json`), index = await response.json();
const entries = Object.values(index.entries).filter(entry => entry.type === 'story' && entry.title.startsWith('OrgChart/') && !(entry.tags ?? []).includes('internal'));
check(entries.length === 4 && equal(entries.map(entry => entry.id).sort(), publicStories.sort()), 'Storybook: all four public OrgChart menu entries retained', entries.map(entry => ({ id: entry.id, title: entry.title, name: entry.name })));
}
}
} catch (error) {
fatal = { message: error.message, stack: error.stack };
check(false, 'Focused P9.1 runner completed', fatal);
} finally {
check(errors.length === 0, 'No browser console or page errors', errors);
const result = { startedAt, completedAt: new Date().toISOString(), origin, browser: browser.version(), baselineMode, resizeOnly, sizes, results, snapshots, errors, fatal, pass: results.filter(result => result.status === 'PASS').length, fail: results.filter(result => result.status === 'FAIL').length,
limitations: ['Only the two P9.1 pages and public Storybook index checked; Expanded interactions and historical P7-P9 suites were not repeated.', 'Browser rendering and computed style preservation do not replace user design approval.', 'Other browsers, real devices, screen readers and production deployment remain NOT VERIFIED.'] };
const resultName = resizeOnly ? 'browser-resize.json' : baselineMode ? 'visual-baseline.json' : 'browser.json';
await fs.writeFile(path.join(output, resultName), JSON.stringify(result, null, 2));
console.log(JSON.stringify({ pass: result.pass, fail: result.fail, errors: errors.length, output: `test-results/org-chart-p9-1/${resultName}`, centers: Object.entries(snapshots.fixed ?? {}).map(([viewport, state]) => ({ viewport, bodyCenter: state.fixed?.bodyCenter, availableCenter: state.fixed?.availableCenter, delta: state.fixed?.centerDelta })) }));
await context.close();
await browser.close();
if (result.fail) process.exitCode = 1;
}
@@ -0,0 +1,150 @@
// P9.2 only: measure the actual visible Content Wrapper and the entire scaled Stage.
// --baseline captures P9.1 without asserting its centering; the final run compares invariants.
import fs from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { launchChromium } from '../../scripts/orgchart-browser-runtime.mjs';
const root = fileURLToPath(new URL('../../', import.meta.url));
const output = path.join(root, 'test-results/org-chart-p9-2');
const origin = (process.env.ORGCHART_STORY_ORIGIN ?? 'http://127.0.0.1:6062').replace(/\/$/, '');
const baselineMode = process.argv.includes('--baseline');
const prefix = baselineMode ? 'before' : 'after';
const sizes = [{ width: 1920, height: 1080 }, { width: 1440, height: 900 }, { width: 390, height: 844 }];
const startedAt = new Date().toISOString(), results = [], snapshots = {}, errors = [];
await fs.mkdir(output, { recursive: true });
const browser = await launchChromium({ headless: true });
const page = await browser.newPage({ viewport: sizes[0] });
page.on('pageerror', error => errors.push({ type: 'pageerror', message: error.message }));
page.on('console', message => { if (message.type() === 'error') errors.push({ type: 'console', message: message.text() }); });
const check = (pass, name, detail) => {
results.push({ name, status: pass ? 'PASS' : 'FAIL', detail });
if (!pass) console.log('FAIL', name, JSON.stringify(detail));
};
const contains = (a, b, tolerance = 1) => b.left >= a.left - tolerance && b.right <= a.right + tolerance && b.top >= a.top - tolerance && b.bottom <= a.bottom + tolerance;
const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
const near = (a, b) => Object.keys(a).every(key => Math.abs(a[key] - b[key]) <= .15);
const preserved = (a, b) => a && a.htmlHash === b.htmlHash && same(a.assets, b.assets) && a.elements.length === b.elements.length && b.elements.every((item, i) => {
const old = a.elements[i]; return old.key === item.key && old.hash === item.hash && same(old.style, item.style) && near(old.box, item.box);
});
async function settle() {
await page.evaluate(async () => { await document.fonts.ready; for (let i = 0; i < 12; i++) await new Promise(resolve => requestAnimationFrame(resolve)); });
}
async function open(size) {
await page.setViewportSize(size);
await page.goto(`${origin}/iframe.html?id=orgchart--full-composition&viewMode=story`);
await page.locator('.sb-page-title').waitFor();
await page.waitForFunction(() => {
const shadow = document.querySelector('.hm-orgchart-host')?.shadowRoot;
return shadow?.querySelector('.hm-orgchart-stage[data-workdesk-artwork]') && !shadow.querySelector('[aria-busy="true"]') && [...shadow.querySelectorAll('link[rel="stylesheet"]')].every(link => link.sheet);
});
await page.evaluate(async () => {
await Promise.all([...document.querySelector('.hm-orgchart-host').shadowRoot.querySelectorAll('img')].map(async image => { image.loading = 'eager'; await image.decode(); }));
});
await settle();
}
async function snapshot() {
return page.evaluate(async () => {
const wrapper = document.querySelector('.hm-orgchart-story-full');
const shadowHost = wrapper.querySelector('.hm-orgchart-host');
const shadow = shadowHost.shadowRoot;
const scaleWrapper = shadow.querySelector('.hm-orgchart-composition');
const stage = shadow.querySelector('.hm-orgchart-stage[data-workdesk-artwork]');
const rect = element => { const r = element.getBoundingClientRect(); return Object.fromEntries(['left', 'right', 'top', 'bottom', 'width', 'height'].map(key => [key, r[key]])); };
const css = getComputedStyle(wrapper), wb = rect(wrapper), sb = rect(stage), sc = getComputedStyle(stage);
const inset = Object.fromEntries(['paddingLeft', 'paddingRight', 'paddingTop', 'paddingBottom', 'borderLeftWidth', 'borderRightWidth', 'borderTopWidth', 'borderBottomWidth'].map(key => [key, parseFloat(css[key]) || 0]));
const content = {
left: wb.left + inset.borderLeftWidth + inset.paddingLeft,
right: wb.right - inset.borderRightWidth - inset.paddingRight,
top: wb.top + inset.borderTopWidth + inset.paddingTop,
bottom: wb.bottom - inset.borderBottomWidth - inset.paddingBottom,
};
content.width = content.right - content.left; content.height = content.bottom - content.top;
const scale = sb.width / stage.offsetWidth;
const normalized = box => ({ x: (box.left - sb.left) / scale, y: (box.top - sb.top) / scale, width: box.width / scale, height: box.height / scale });
const hash = async value => [...new Uint8Array(await crypto.subtle.digest('SHA-256', typeof value === 'string' ? new TextEncoder().encode(value) : value))].map(byte => byte.toString(16).padStart(2, '0')).join('');
const selectors = '.hm-orgchart-employee,.hm-orgchart-organization-name,.hm-orgchart-organization-totals,.hm-orgchart-organization-totals-title,.hm-orgchart-workdesk-summary-frame,.hm-orgchart-workdesk-position-table,.hm-orgchart-workdesk-position-table th,.hm-orgchart-workdesk-position-table td,.hm-orgchart-slot-side,.hm-orgchart-slot-belowTree,.hm-orgchart-status-group,.hm-orgchart-summary-title,.hm-orgchart-slot-footer,.hm-orgchart-affiliation-bar,.hm-orgchart-affiliation-bar-item,.hm-orgchart-axis,.hm-orgchart-top-leaders,.hm-orgchart-hub,.hm-orgchart-workdesk-overlay,.hm-orgchart-workdesk-decoration';
const styleKeys = ['fontFamily', 'fontSize', 'fontWeight', 'lineHeight', 'letterSpacing', 'color', 'backgroundColor', 'backgroundImage', 'border', 'borderRadius', 'boxShadow', 'padding', 'margin', 'width', 'height', 'gap', 'objectFit'];
const elements = await Promise.all([...stage.querySelectorAll(selectors)].map(async (element, index) => ({
key: `${element.tagName}:${typeof element.className === 'string' ? element.className : element.className.baseVal}:${element.dataset.employeeId ?? element.dataset.organizationId ?? element.dataset.contentSlot ?? element.dataset.workdeskDecoration ?? ''}:${index}`,
box: normalized(rect(element)), hash: await hash(element.outerHTML),
style: Object.fromEntries(styleKeys.map(key => [key, getComputedStyle(element)[key]])),
})));
const assetHashes = new Map();
const assets = await Promise.all([...stage.querySelectorAll('img')].map(async image => {
const url = image.currentSrc || image.src;
if (!assetHashes.has(url)) assetHashes.set(url, fetch(url).then(r => r.arrayBuffer()).then(hash));
return { hash: await assetHashes.get(url), width: image.naturalWidth, height: image.naturalHeight, loaded: image.complete && image.naturalWidth > 0 };
}));
const visible = element => { const r = rect(element); return r.width > 0 && r.height > 0 && getComputedStyle(element).display !== 'none'; };
const painted = [...stage.querySelectorAll(selectors)].filter(visible).map(element => ({ key: typeof element.className === 'string' ? element.className : element.className.baseVal, bounds: rect(element) }));
const collisionElements = [...stage.querySelectorAll('.hm-orgchart-employee,.hm-orgchart-organization-name,.hm-orgchart-organization-totals,.hm-orgchart-affiliation-bar,.hm-orgchart-status-group > .hm-orgchart-summary-title')].filter(visible);
const collisions = [];
for (let i = 0; i < collisionElements.length; i++) for (let j = i + 1; j < collisionElements.length; j++) {
const a = collisionElements[i], b = collisionElements[j]; if (a.contains(b) || b.contains(a)) continue;
const ra = normalized(rect(a)), rb = normalized(rect(b));
const width = Math.min(ra.x + ra.width, rb.x + rb.width) - Math.max(ra.x, rb.x);
const height = Math.min(ra.y + ra.height, rb.y + rb.height) - Math.max(ra.y, rb.y);
if (width > .5 && height > .5) collisions.push({ pair: `${i}:${j}`, a: a.className, b: b.className, width, height });
}
const scroll = element => { const cs = getComputedStyle(element); return { scrollWidth: element.scrollWidth, clientWidth: element.clientWidth, scrollHeight: element.scrollHeight, clientHeight: element.clientHeight, overflowX: cs.overflowX, overflowY: cs.overflowY }; };
return {
wrapper: { selector: '.hm-orgchart-story-full', tag: wrapper.tagName, ariaLabel: wrapper.getAttribute('aria-label'), background: css.backgroundColor, bounds: wb, inset, content },
sectionBody: rect(wrapper.closest('.sb-page-section-body')), shadowHost: rect(shadowHost), scaleWrapper: rect(scaleWrapper),
stage: sb, contentCenter: (content.left + content.right) / 2, stageCenter: (sb.left + sb.right) / 2,
centerDifference: (sb.left + sb.right - content.left - content.right) / 2,
viewportCenter: innerWidth / 2, baseWidth: parseFloat(sc.width), baseHeight: parseFloat(sc.height), scale, verticalScale: sb.height / parseFloat(sc.height),
coreTransform: sc.transform, additionalScale: sc.scale, translate: sc.translate, htmlHash: await hash(stage.innerHTML), elements, assets, painted, collisions,
counts: { trees: stage.querySelectorAll('.hm-orgchart-forest[data-layout="workdesk"] > .hm-orgchart-tree').length, cards: stage.querySelectorAll('.hm-orgchart-employee').length, overlays: stage.querySelectorAll('.hm-orgchart-workdesk-overlay').length, paths: stage.querySelectorAll('.hm-orgchart-workdesk-overlay path').length, side: stage.querySelectorAll('.hm-orgchart-slot-side').length, status: stage.querySelectorAll('.hm-orgchart-slot-belowTree').length, footer: stage.querySelectorAll('.hm-orgchart-slot-footer').length },
document: { width: innerWidth, height: innerHeight, root: scroll(document.documentElement), body: scroll(document.body), scrolling: scroll(document.scrollingElement) },
scroll: [wrapper, shadowHost, ...['.hm-orgchart-shell', '.hm-orgchart-content', '.hm-orgchart-composition', '.hm-orgchart-viewport'].map(selector => shadow.querySelector(selector)).filter(Boolean)].map(element => ({ selector: element.className, ...scroll(element) })),
};
});
}
async function stability() {
return page.evaluate(async () => {
const stage = document.querySelector('.hm-orgchart-host').shadowRoot.querySelector('.hm-orgchart-stage');
let changes = 0; const observer = new MutationObserver(records => { changes += records.length; });
observer.observe(stage, { attributes: true, attributeFilter: ['style'] });
for (let i = 0; i < 12; i++) await new Promise(resolve => requestAnimationFrame(resolve));
observer.disconnect(); return { styleMutations: changes };
});
}
const baseline = baselineMode ? null : JSON.parse(await fs.readFile(path.join(output, 'before-composition.json'), 'utf8'));
async function record(key) {
const state = await snapshot(); snapshots[key] = state;
await page.screenshot({ path: path.join(output, `${prefix}-full-composition-${key}.png`), fullPage: true });
if (baselineMode) return;
check(Math.abs(state.centerDifference) <= 1, `${key}: whole Stage centered in visible wrapper content box`, { wrapper: state.wrapper, stage: state.stage, contentCenter: state.contentCenter, stageCenter: state.stageCenter, centerDifference: state.centerDifference, viewportCenter: state.viewportCenter });
check(state.baseWidth === 1920 && state.baseHeight === 914.5 && state.scale > 0 && state.scale <= 1 && Math.abs(state.scale - state.verticalScale) <= .0001 && contains(state.wrapper.content, state.stage), `${key}: original canvas fully contained with uniform proportional fit`, { baseWidth: state.baseWidth, baseHeight: state.baseHeight, scale: state.scale, verticalScale: state.verticalScale, stage: state.stage, content: state.wrapper.content });
const outside = state.painted.filter(item => !contains(state.stage, item.bounds) || !contains(state.wrapper.content, item.bounds));
check(outside.length === 0 && state.collisions.length === 0, `${key}: painted cards, lines and statistics remain visible without collisions`, { outside, collisions: state.collisions, counts: state.counts });
check(preserved(baseline.snapshots[key], state), `${key}: P9.1 inner DOM, assets, styles and normalized geometry preserved`, { beforeHash: baseline.snapshots[key]?.htmlHash, afterHash: state.htmlHash, elements: state.elements.length, assets: state.assets.length });
const d = state.document;
const documentFits = [d.root, d.body, d.scrolling].every(item => item.scrollWidth <= d.width + 1 && item.scrollHeight <= d.height + 1);
const scrollable = state.scroll.filter(item => ['auto', 'scroll'].includes(item.overflowX) && item.scrollWidth > item.clientWidth + 1 || ['auto', 'scroll'].includes(item.overflowY) && item.scrollHeight > item.clientHeight + 1);
check(documentFits && scrollable.length === 0, `${key}: no document or interactive chart scrollbars`, { document: d, scrollable, scroll: state.scroll });
check((await stability()).styleMutations === 0, `${key}: alignment settles without observer feedback loop`);
}
let fatal;
try {
for (const size of sizes) { await open(size); await record(String(size.width)); }
await open(sizes[1]);
await page.evaluate(() => {
const wrapper = document.querySelector('.hm-orgchart-story-full');
// Perturb the actual visible wrapper, not a proxy box; keep standard document flow.
Object.assign(wrapper.style, { boxSizing: 'border-box', width: 'calc(100% - 80px)', marginLeft: '23px', padding: '17px 53px 19px 11px', borderStyle: 'solid', borderColor: '#789', borderWidth: '2px 7px 5px 3px' });
});
await settle(); await record('asymmetric-1440');
await page.evaluate(() => { document.querySelector('.hm-orgchart-story-full').style.padding = '17px 19px 19px 45px'; });
await settle(); await record('padding-shift-1440');
await page.setViewportSize({ width: 1010, height: 840 }); await settle(); await record('asymmetric-resize-1010');
await page.setViewportSize(sizes[2]); await settle(); await record('asymmetric-resize-390');
if (!baselineMode) check(errors.length === 0, 'No new Full Composition console or page errors', errors);
} catch (error) { fatal = error.stack; check(false, 'Runner completed', fatal); }
finally {
const result = { startedAt, completedAt: new Date().toISOString(), origin, browser: browser.version(), baselineMode, sizes, results, snapshots, errors, fatal, pass: results.filter(item => item.status === 'PASS').length, fail: results.filter(item => item.status === 'FAIL').length };
await fs.writeFile(path.join(output, baselineMode ? 'before-composition.json' : 'browser-composition.json'), JSON.stringify(result, null, 2));
await browser.close(); console.log(JSON.stringify({ baselineMode, pass: result.pass, fail: result.fail, output }));
if (result.fail) process.exitCode = 1;
}
+183
View File
@@ -0,0 +1,183 @@
// P9.2 Expanded selection integration only. Historical suites are preserved.
import fs from 'node:fs/promises';
import { writeFileSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { launchChromium } from '../../scripts/orgchart-browser-runtime.mjs';
import { companyColorPresets, createOrgChartExampleDataset } from '../../packages/org-chart/dist/index.js';
const root = fileURLToPath(new URL('../../', import.meta.url));
const output = path.join(root, 'test-results/org-chart-p9-2');
const origin = process.env.ORGCHART_STORY_ORIGIN ?? 'http://127.0.0.1:6062';
const only = process.argv.slice(2), run = name => !only.length || only.includes(name);
const suffix = only.length ? '-' + only.join('-') : '';
const template = JSON.parse(await fs.readFile(path.join(root, 'examples/org-chart/data-expanded-large.json'), 'utf8'));
const companies = Object.values(companyColorPresets), ids = companies.map(company => company.companyId);
const results = [], measurements = [], errors = [], startedAt = new Date().toISOString();
await fs.mkdir(output, { recursive: true });
const persist = () => writeFileSync(path.join(output, `expanded-progress${suffix}.json`), JSON.stringify({ startedAt, results, measurements, errors }, null, 2));
const check = (pass, name, detail) => { results.push({ name, status: pass ? 'PASS' : 'FAIL', ...(detail === undefined ? {} : { detail }) }); persist(); if (!pass) console.log('FAIL', name, JSON.stringify(detail)); };
const browser = await launchChromium({ headless: true });
const page = await browser.newPage({ viewport: { width: 1440, height: 1100 } });
page.setDefaultTimeout(15000);
page.on('pageerror', error => { errors.push({ type: 'pageerror', message: error.message }); persist(); });
page.on('console', message => { if (message.type() === 'error') { errors.push({ type: 'console', message: message.text(), location: message.location() }); persist(); } });
const trigger = () => page.getByRole('button', { name: /^회사 선택/ });
const panel = () => page.getByRole('dialog', { name: '회사 선택', exact: true });
const checkbox = name => panel().getByRole('checkbox', { name, exact: true });
const settle = () => page.evaluate(async () => { await document.fonts.ready; for (let i = 0; i < 8; i++) await new Promise(requestAnimationFrame); });
async function ready(count) {
await page.waitForFunction(count => {
const shadow = document.querySelector('.hm-orgchart-host')?.shadowRoot;
return shadow && !shadow.querySelector('[aria-busy="true"]') && [...shadow.querySelectorAll('link[rel="stylesheet"]')].every(link => link.sheet)
&& (count === undefined || shadow.querySelectorAll('.hm-orgchart-employee').length === count);
}, count);
await settle();
}
async function goto(size = { width: 1440, height: 1100 }) {
await page.setViewportSize(size);
await page.goto(`${origin}/iframe.html?id=orgchart--expanded-org-chart&viewMode=story`);
await trigger().waitFor(); await ready(20);
}
async function open() { await trigger().click(); await panel().waitFor(); await settle(); }
async function close() { await page.keyboard.press('Escape'); await panel().waitFor({ state: 'hidden' }); await settle(); }
async function select(label, option) { await page.getByRole('combobox', { name: label, exact: true }).click(); await page.getByRole('option', { name: option, exact: true }).click(); await ready(); }
async function apply(count) { await page.getByRole('button', { name: '데이터 적용', exact: true }).click(); await ready(count); }
const expectedTotal = (count, selected) => selected.length ? createOrgChartExampleDataset(template, { scope: 'total', employeeCount: count, companyIds: selected }).employees.length : 0;
async function state() {
return page.evaluate(() => {
const shadow = document.querySelector('.hm-orgchart-host').shadowRoot, expanded = shadow.querySelector('.hm-orgchart-expanded');
const viewport = shadow.querySelector('.hm-orgchart-viewport'), forest = shadow.querySelector('.hm-orgchart-forest');
const rect = element => { if (!element) return null; const r = element.getBoundingClientRect(); return { x: r.x, y: r.y, width: r.width, height: r.height, right: r.right, bottom: r.bottom }; };
return { countText: document.querySelector('[data-component="OrgChartExampleCounts"]').textContent.trim(), employees: shadow.querySelectorAll('.hm-orgchart-employee').length,
roots: [...shadow.querySelectorAll('.hm-orgchart-company-tree')].map(element => element.dataset.companyId), type: expanded?.dataset.autoLayout,
empty: forest?.children.length === 0, scale: Number(viewport?.dataset.scale), panX: Number(viewport?.dataset.panX), panY: Number(viewport?.dataset.panY), viewport: rect(viewport), forest: rect(forest),
documentWidth: document.documentElement.scrollWidth, width: innerWidth,
cards: [...shadow.querySelectorAll('.hm-orgchart-employee')].map(element => ({ id: element.dataset.employeeId, companyId: element.dataset.companyId, direction: element.dataset.connectionDirection })),
titles: [...shadow.querySelectorAll('.node[data-company-id] .hm-orgchart-organization-name')].map(element => ({ companyId: element.closest('.node').dataset.companyId, color: getComputedStyle(element).color })),
autoControlsInFixed: Boolean(shadow.querySelector('.hm-orgchart-stage .hm-orgchart-expanded-toggle')) };
});
}
async function snap(name, fullPage = false) { await page.screenshot({ path: path.join(output, `${name}.png`), fullPage }); }
async function section(name, action) { if (!run(name)) return; try { await action(); } catch (error) { check(false, `${name}: execution completed`, { message: error.message, stack: error.stack }); await snap(`failure-${name}`, true).catch(() => {}); } }
await section('dropdown', async () => {
await goto(); await snap('expanded-closed', true);
check((await trigger().innerText()).includes('전체 회사 (7개)') && await page.locator('.hm-orgchart-story-settings').first().getByRole('checkbox').count() === 0, 'Closed: summary and no external company checkboxes');
await trigger().focus(); await trigger().press('Enter'); await panel().waitFor(); await settle();
const structure = await panel().evaluate(element => ({ portal: element.closest('.sb-page') === null, count: element.querySelectorAll('[role="checkbox"]').length, focusedFirst: element.querySelector('[role="checkbox"]') === document.activeElement }));
check(structure.portal && structure.count === 8 && structure.focusedFirst && await checkbox('전체 선택').getAttribute('aria-checked') === 'true', 'Open via keyboard: HM Portal and focus on select-all', structure);
await snap('expanded-open-all');
await page.keyboard.press('Space'); await ready(0);
let s = await state(); measurements.push({ name: 'empty', state: s });
check(await checkbox('전체 선택').getAttribute('aria-checked') === 'false' && (await trigger().innerText()).includes('회사를 선택하세요') && s.employees === 0 && s.countText.includes('0개 회사') && Boolean(s.empty), 'Space select-all off: empty state without error', s);
await page.keyboard.press('Tab');
check(await checkbox('한맥기술').evaluate(element => element === document.activeElement), 'Tab reaches first individual checkbox');
await page.keyboard.press('Space'); await ready(expectedTotal(20, ['hanmac']));
check(await checkbox('한맥기술').getAttribute('aria-checked') === 'true' && await checkbox('전체 선택').getAttribute('aria-checked') === 'mixed' && (await trigger().innerText()).includes('한맥기술'), 'Individual Space: one company summary and indeterminate');
await page.keyboard.press('Tab'); await page.keyboard.press('Space'); await ready(expectedTotal(20, ['hanmac', 'saman']));
check(await checkbox('삼안').getAttribute('aria-checked') === 'true' && (await trigger().innerText()).includes('2개 회사 선택'), 'Multiple keyboard selection: two company summary');
await snap('expanded-open-partial'); await close();
check(await trigger().evaluate(element => element === document.activeElement) && await trigger().getAttribute('aria-expanded') === 'false', 'ESC closes and restores focus');
await open();
check(await checkbox('한맥기술').getAttribute('aria-checked') === 'true' && await checkbox('삼안').getAttribute('aria-checked') === 'true', 'Selection persists after reopen');
await checkbox('한맥기술').click(); await ready(expectedTotal(20, ['saman']));
check((await trigger().innerText()).includes('삼안'), 'Individual deselect removes only that company');
await checkbox('전체 선택').click(); await ready(20);
check(await checkbox('전체 선택').getAttribute('aria-checked') === 'true' && await panel().getByRole('checkbox', { checked: true }).count() === 8, 'Indeterminate select-all selects seven companies');
await snap('expanded-all-selected');
await trigger().click(); await panel().waitFor({ state: 'hidden' }); check(await trigger().getAttribute('aria-expanded') === 'false', 'Trigger click closes');
await open(); await page.locator('.sb-page-title').click(); await panel().waitFor({ state: 'hidden' }); check(true, 'Outside click closes');
});
await section('data', async () => {
await goto(); await open(); await checkbox('전체 선택').click(); await ready(0); await checkbox('한맥기술').click(); await ready(expectedTotal(20, ['hanmac'])); await close();
await select('데이터 범위', '회사별'); await select('회사당 직원 수', '100명'); await apply(100);
let s = await state(); check(s.employees === 100 && s.countText.includes('회사당 100명') && s.countText.includes('합계 100명'), 'Per-company 100: one company produces 100', s.countText);
await open(); await checkbox('삼안').click(); await ready(200); s = await state(); measurements.push({ name: 'per-company-two', state: s });
check(s.employees === 200 && s.countText.includes('2개 회사') && s.countText.includes('합계 200명'), 'Second company immediately produces 200 without Apply', s.countText);
await close(); await snap('expanded-closed-partial', true);
await select('데이터 범위', '전체 회사 합산'); await select('전체 직원 수', '20명');
await open(); await checkbox('삼안').click(); await ready(100); s = await state();
check(s.employees === 100 && s.countText.includes('회사당 100명'), 'Company change preserves pending scope/count Apply boundary', s.countText);
await close(); await apply(expectedTotal(20, ['hanmac'])); s = await state();
check(s.employees === expectedTotal(20, ['hanmac']) && !s.countText.includes('회사당'), 'Total20 preserves P7 weighted partition without redistribution', s.countText);
for (const count of [100, 300]) {
await select('전체 직원 수', `${count}명`); await apply(expectedTotal(count, ['hanmac'])); s = await state();
check(s.employees === expectedTotal(count, ['hanmac']), `Total${count} selected-company partition`, { count: s.employees, expected: expectedTotal(count, ['hanmac']) });
}
await select('데이터 범위', '회사별'); await select('회사당 직원 수', '20명'); await apply(20); check((await state()).employees === 20, 'Per-company20 selection retained');
await select('회사당 직원 수', '300명'); await apply(300); check((await state()).employees === 300, 'Per-company300 selection retained');
await page.getByRole('button', { name: '초기화', exact: true }).click(); await ready(20); check((await trigger().innerText()).includes('전체 회사 (7개)'), 'Reset restores original total20 and all seven');
});
await section('modes', async () => {
await goto(); await open(); await checkbox('바론컨설턴트').click(); await ready(expectedTotal(20, ids.filter(id => id !== 'baron'))); await close();
const selected = ids.filter(id => id !== 'baron'), count = expectedTotal(20, selected);
for (const [option, type] of [['세로형 TB', 'tb'], ['가로형 LR', 'lr'], ['혼합형 Hybrid', 'hybrid']]) {
if (type !== 'tb') await select('조직도 표시 유형', option);
await ready(count); const s = await state(); measurements.push({ name: type, state: s });
check(s.type === type && s.employees === count && s.cards.every(card => selected.includes(card.companyId)) && new Set(s.cards.map(card => card.companyId)).size === 6, `${type}: chosen companies render and excluded company absent`, s.countText);
check(s.titles.length > 0 && s.titles.every(title => title.color === 'rgb(255, 255, 255)') && s.cards.every(card => ['top', 'left', 'right'].includes(card.direction)), `${type}: existing white titles and directional cards`);
}
const toggle = page.locator('.hm-orgchart-expanded-toggle:not(:disabled)').first();
const id = await toggle.getAttribute('data-organization-id'); await toggle.click(); await ready();
const current = () => page.locator(`.hm-orgchart-expanded-toggle[data-organization-id="${id}"]`);
check(await current().getAttribute('aria-expanded') === 'false', 'Group collapse after selection'); await current().press('Space'); await ready(); check(await current().getAttribute('aria-expanded') === 'true', 'Group expands with Space');
const employee = page.locator('.hm-orgchart-employee').first(), employeeId = await employee.getAttribute('data-employee-id'); await employee.click();
const dialog = page.locator('[data-component="HMOrgChartEmployeeDialog"]'); await dialog.waitFor();
check(await dialog.getAttribute('data-employee-id') === employeeId && await page.getByRole('dialog').count() === 1, 'Employee HM Dialog identity after company changes');
await page.keyboard.press('Escape'); await dialog.waitFor({ state: 'hidden' }); await settle();
check(await page.evaluate(() => document.querySelector('.hm-orgchart-host').shadowRoot.activeElement?.dataset.employeeId) === employeeId, 'Employee Dialog ESC returns card focus');
await page.getByRole('button', { name: 'Fit to View', exact: true }).click(); await settle(); const before = await state();
await page.getByRole('button', { name: '확대', exact: true }).click(); await settle(); const zoom = await state(); check(zoom.scale > before.scale, 'Zoom remains functional');
const viewport = page.locator('.hm-orgchart-viewport'); await viewport.scrollIntoViewIfNeeded(); const box = await viewport.boundingBox(); const panBefore = await state();
await page.mouse.move(box.x + 8, box.y + box.height - 8); await page.mouse.down(); await page.mouse.move(box.x + 58, box.y + box.height - 38, { steps: 5 }); await page.mouse.up(); await settle(); const pan = await state();
check(Math.abs(pan.panX - panBefore.panX) >= 40 && Math.abs(pan.panY - panBefore.panY) >= 20, 'Pointer Pan remains functional', { before: [panBefore.panX, panBefore.panY], after: [pan.panX, pan.panY] });
await page.getByRole('button', { name: 'Fit to View', exact: true }).click(); await settle(); const fit = await state();
check(fit.forest.x >= fit.viewport.x - 1 && fit.forest.right <= fit.viewport.right + 1 && fit.forest.y >= fit.viewport.y - 1 && fit.forest.bottom <= fit.viewport.bottom + 1, 'Fit contains complete selected forest', fit);
});
await section('responsive', async () => {
for (const size of [{ width: 1920, height: 1080 }, { width: 768, height: 1024 }, { width: 390, height: 844 }]) {
await goto(size); await open();
const bounds = await panel().evaluate(element => { const r = element.getBoundingClientRect(); return { x: r.x, right: r.right, y: r.y, bottom: r.bottom, width: r.width, height: r.height, scrollWidth: element.scrollWidth, clientWidth: element.clientWidth, portal: !element.closest('.sb-page'), checkboxesVisible: [...element.querySelectorAll('[role="checkbox"]')].every(child => { const b = child.getBoundingClientRect(); return b.left >= r.left && b.right <= r.right && b.top >= r.top && b.bottom <= r.bottom; }) }; });
check(bounds.portal && bounds.x >= -1 && bounds.right <= size.width + 1 && bounds.y >= -1 && bounds.bottom <= size.height + 1 && bounds.scrollWidth <= bounds.clientWidth + 1 && bounds.checkboxesVisible, `${size.width}: Portal panel and all options fit without clipping`, bounds);
check((await state()).documentWidth <= size.width + 1, `${size.width}: no horizontal page overflow`);
await snap(`expanded-open-${size.width}`); await close();
const summary = await trigger().evaluate(element => { const r = element.getBoundingClientRect(); return { width: r.width, scrollWidth: element.scrollWidth, clientWidth: element.clientWidth, label: element.textContent }; });
check(summary.scrollWidth <= summary.clientWidth + 1, `${size.width}: concise trigger fits control`, summary);
await snap(`expanded-closed-${size.width}`, true);
}
});
// Isolate a corrected empty-forest probe without replaying successful sections.
if (only.includes('empty')) await section('empty', async () => {
await goto(); await trigger().focus(); await trigger().press('Enter'); await panel().waitFor();
await page.keyboard.press('Space'); await ready(0); const s = await state();
check(await checkbox('전체 선택').getAttribute('aria-checked') === 'false' && (await trigger().innerText()).includes('회사를 선택하세요') && s.employees === 0 && s.countText.includes('0개 회사') && s.empty, 'Space select-all off: empty forest without error', s);
await snap('expanded-empty');
});
// Runtime evidence for Radix's public positioning variable, opaque to Guard.
if (only.includes('token')) await section('token', async () => {
await goto(); await open();
const evidence = await panel().evaluate(element => {
const css = getComputedStyle(element);
return { variable: '--radix-popover-content-available-height', actual: css.getPropertyValue('--radix-popover-content-available-height').trim(), maxHeight: css.maxHeight, component: element.dataset.component, width: innerWidth, height: innerHeight };
});
measurements.push({ name: 'Radix runtime variable', evidence });
check(parseFloat(evidence.actual) > 0 && evidence.maxHeight === evidence.actual, 'HM Popover declares Radix available-height variable at runtime', evidence);
});
await section('menu', async () => {
const index = await (await page.request.get(`${origin}/index.json`)).json();
const entries = Object.values(index.entries).filter(entry => entry.type === 'story' && entry.title.startsWith('OrgChart/') && !(entry.tags ?? []).includes('internal'));
const expected = ['orgchart--logo-personnel-summary', 'orgchart--department-colors', 'orgchart--full-composition', 'orgchart--expanded-org-chart'];
check(entries.length === 4 && entries.every(entry => expected.includes(entry.id)), 'Existing four public Story IDs retained', entries.map(entry => ({ id: entry.id, title: entry.title })));
});
check(errors.length === 0, 'New console/page errors: zero', errors);
const result = { startedAt, completedAt: new Date().toISOString(), browser: browser.version(), origin, sections: only, results, measurements, errors, pass: results.filter(item => item.status === 'PASS').length, fail: results.filter(item => item.status === 'FAIL').length };
await fs.writeFile(path.join(output, `browser-expanded${suffix}.json`), JSON.stringify(result, null, 2));
console.log(JSON.stringify({ pass: result.pass, fail: result.fail, errors: errors.length, output: `test-results/org-chart-p9-2/browser-expanded${suffix}.json` }));
await browser.close(); if (result.fail) process.exitCode = 1;
+371
View File
@@ -0,0 +1,371 @@
// P9.3 public Vanilla Core API and engine bridge checks only.
// Requires the parent-controlled static server and an already built Core.
import fs from 'node:fs/promises';
import { writeFileSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { launchChromium } from '../../scripts/orgchart-browser-runtime.mjs';
const root = fileURLToPath(new URL('../../', import.meta.url));
const output = path.join(root, 'test-results/org-chart-p9-3');
const origin = process.env.ORGCHART_CORE_ORIGIN ?? 'http://127.0.0.1:6064';
const only = process.argv.slice(2), run = section => !only.length || only.includes(section);
const suffix = only.length ? '-' + only.join('-') : '';
const startedAt = new Date().toISOString(), results = [], measurements = [], errors = [];
await fs.mkdir(output, { recursive: true });
const persist = () => writeFileSync(path.join(output, `core-progress${suffix}.json`), JSON.stringify({ startedAt, results, measurements, errors }, null, 2));
const check = (pass, name, detail) => {
results.push({ name, status: pass ? 'PASS' : 'FAIL', ...(detail === undefined ? {} : { detail }) });
persist(); if (!pass) console.log('FAIL', name, JSON.stringify(detail));
};
const browser = await launchChromium({ headless: true });
const page = await browser.newPage({ viewport: { width: 1440, height: 1000 } });
page.setDefaultTimeout(15000);
page.on('pageerror', error => { errors.push({ type: 'pageerror', message: error.message }); persist(); });
page.on('console', message => { if (message.type() === 'error') { errors.push({ type: 'console', message: message.text(), location: message.location() }); persist(); } });
const settle = () => page.evaluate(async () => { await document.fonts.ready; for (let i = 0; i < 6; i++) await new Promise(requestAnimationFrame); });
async function ready() {
await page.waitForFunction(() => window.fixtureReady && window.chart?.getState().status === 'ready'
&& [...document.querySelector('.hm-orgchart-host').shadowRoot.querySelectorAll('link[rel="stylesheet"]')].every(link => link.sheet));
await settle();
}
async function fresh(extra) {
await page.goto(`${origin}/vanilla.html`); await ready();
if (extra) { await page.evaluate(extra => window.mountChart('auto', extra), extra); await ready(); }
}
const employee = id => page.locator(`.hm-orgchart-employee[data-employee-id="${id}"]`);
const title = id => page.locator(`.hm-orgchart-expanded-toggle[data-organization-id="${id}"]`);
async function pointerMove(source, target) {
const from = await source.boundingBox(), to = await target.boundingBox();
if (!from || !to) throw new Error('Pointer move source/target has no rendered bounds');
const start = { x: from.x + from.width / 2, y: from.y + from.height / 2 }, end = { x: to.x + to.width / 2, y: to.y + to.height / 2 };
await page.mouse.move(start.x, start.y); await page.mouse.down();
await page.mouse.move(start.x + 8, start.y + 8, { steps: 2 });
await page.mouse.move(end.x, end.y, { steps: 10 });
const feedback = await page.evaluate(() => [...window.shadow.querySelectorAll('.node[data-drop-state]')].map(node => ({ id: node.dataset.organizationId, state: node.dataset.dropState, hover: node.dataset.dropHover })));
await page.mouse.up(); await ready();
return feedback;
}
async function snapshot() {
return page.evaluate(() => {
const data = window.chart.getData(), state = window.chart.getState(), shadow = window.shadow;
return { data, summary: state.status === 'ready' ? state.summary.raw : undefined,
viewport: window.chart.getViewportState(), moves: window.moves, events: window.bridgeEvents,
cards: [...shadow.querySelectorAll('.hm-orgchart-employee')].map(card => ({ id: card.dataset.employeeId, organization: card.closest('.node').dataset.organizationId, direction: card.dataset.connectionDirection })),
groups: [...shadow.querySelectorAll('.node[data-organization-id]')].map(node => ({ id: node.dataset.organizationId, parent: node.dataset.parentId, collapsed: node.dataset.collapsed })),
organizationPaths: [...shadow.querySelectorAll('.hm-orgchart-organization-connectors path')].map(node => ({ id: node.dataset.organizationId, parent: node.dataset.parentId })),
dialogCount: shadow.querySelectorAll('dialog[open]').length,
feedback: shadow.querySelector('.hm-orgchart-move-feedback')?.textContent };
});
}
try {
if (run('api')) {
await fresh();
const initial = await snapshot();
check(initial.data.employees.length === 20 && initial.data.organizations.length === 15, 'Vanilla public Core mounts raw multi-company fixture');
const frozen = await page.evaluate(() => {
const data = window.chart.getData(), before = JSON.stringify(data);
const deep = value => value === null || typeof value !== 'object' || (Object.isFrozen(value) && Object.values(value).every(deep));
try { data.employees[0].name = 'consumer mutation'; } catch { /* Frozen snapshots may throw in strict mode. */ }
return { deepFrozen: deep(data), unchanged: JSON.stringify(data) === before, copied: data !== window.fixture, fixtureUnchanged: JSON.stringify(window.fixture) === window.fixtureOriginal };
});
check(frozen.deepFrozen && frozen.unchanged && frozen.copied && frozen.fixtureUnchanged, 'getData is a deeply frozen owned snapshot; consumer input preserved', frozen);
const moved = await page.evaluate(() => window.chart.move({ kind: 'employee', sourceId: 'virtual-006', targetOrganizationId: 'hanmac-division-1' }));
await ready(); const afterEmployee = await snapshot();
check(moved.moved && moved.previousOrganizationId === 'hanmac-office-0' && moved.request.sourceId === 'virtual-006', 'Public employee move reports original relation and request', moved.moved ? { previousOrganizationId: moved.previousOrganizationId, request: moved.request } : moved);
const beforePerson = initial.data.employees.find(person => person.id === 'virtual-006'), afterPerson = afterEmployee.data.employees.find(person => person.id === 'virtual-006');
check(afterPerson.organizationId === 'hanmac-division-1' && JSON.stringify({ ...afterPerson, organizationId: beforePerson.organizationId }) === JSON.stringify(beforePerson), 'Employee identity, affiliation and personal fields survive new organization');
check(afterEmployee.data.employees.length === initial.data.employees.length && new Set(afterEmployee.cards.map(card => card.id)).size === 20
&& afterEmployee.cards.filter(card => card.id === 'virtual-006').length === 1 && afterEmployee.cards.find(card => card.id === 'virtual-006').organization === 'hanmac-division-1', 'Employee count unchanged and one rendered card under new group');
check(afterEmployee.summary.total === initial.summary.total
&& afterEmployee.summary.byOrganization['hanmac-office-0'].direct === initial.summary.byOrganization['hanmac-office-0'].direct - 1
&& afterEmployee.summary.byOrganization['hanmac-division-1'].direct === initial.summary.byOrganization['hanmac-division-1'].direct + 1, 'Public summary direct totals update without changing overall count');
const group = await page.evaluate(() => window.chart.move({ kind: 'organization', sourceId: 'hanmac-division-0', targetOrganizationId: 'hanmac-division-1' }));
await ready(); const afterGroup = await snapshot();
check(group.moved && group.previousOrganizationId === 'hanmac-root'
&& afterGroup.data.organizations.find(org => org.id === 'hanmac-division-0').parentId === 'hanmac-division-1'
&& afterGroup.data.organizations.find(org => org.id === 'hanmac-office-0').parentId === 'hanmac-division-0'
&& JSON.stringify(afterGroup.data.employees) === JSON.stringify(afterEmployee.data.employees), 'Public organization move updates parent only and retains descendants/staff', group.moved ? group.request : group);
const unchanged = JSON.stringify(afterGroup.data);
const invalid = [
[{ kind: 'organization', sourceId: 'hanmac-division-0', targetOrganizationId: 'hanmac-office-0' }, 'DESCENDANT_TARGET'],
[{ kind: 'organization', sourceId: 'hanmac-division-0', targetOrganizationId: 'hanmac-division-0' }, 'SELF_TARGET'],
[{ kind: 'organization', sourceId: 'hanmac-root', targetOrganizationId: 'hanmac-division-1' }, 'ROOT_PROTECTED'],
[{ kind: 'employee', sourceId: 'virtual-001', targetOrganizationId: 'hanmac-division-1' }, 'LEADER_PROTECTED'],
[{ kind: 'employee', sourceId: 'virtual-006', targetOrganizationId: 'hanmac-division-1' }, 'SAME_ORGANIZATION'],
[{ kind: 'employee', sourceId: 'missing-employee', targetOrganizationId: 'hanmac-division-1' }, 'SOURCE_NOT_FOUND'],
[{ kind: 'employee', sourceId: 'virtual-006', targetOrganizationId: 'missing-group' }, 'TARGET_NOT_FOUND'],
];
for (const [request, code] of invalid) {
const result = await page.evaluate(request => window.chart.move(request), request);
check(!result.moved && result.code === code && await page.evaluate(expected => JSON.stringify(window.chart.getData()) === expected, unchanged), `Public move rejects ${code} and preserves complete dataset`, result);
}
check(await page.evaluate(expected => window.moves.length === expected && window.moves.filter(move => move.moved).length === 2, 2 + invalid.length), 'onMove receives successful moves and every policy rejection exactly once');
const callback = await page.evaluate(async () => { window.throwCallback = true; const result = await window.chart.move({ kind: 'employee', sourceId: 'virtual-006', targetOrganizationId: 'hanmac-office-1' }); window.throwCallback = false; return { moved: result.moved, status: window.chart.getState().status }; });
await ready(); check(callback.moved && callback.status === 'ready', 'Consumer onMove exception cannot fail Core transaction', callback);
check(await page.evaluate(() => JSON.stringify(window.fixture) === window.fixtureOriginal), 'All public edits leave original JSON input unchanged');
measurements.push({ section: 'api', state: await snapshot() });
}
if (run('cross')) {
// User's later instruction permits cross-company moves. The earlier
// CROSS_COMPANY rejection evidence remains preserved as superseded history.
await fresh(); const before = await snapshot();
const employeeResult = await page.evaluate(() => window.chart.move({ kind: 'employee', sourceId: 'virtual-006', targetOrganizationId: 'saman-division-1' }));
await ready(); const afterEmployee = await snapshot();
const oldEmployee = before.data.employees.find(person => person.id === 'virtual-006'), newEmployee = afterEmployee.data.employees.find(person => person.id === 'virtual-006');
check(employeeResult.moved && employeeResult.previousOrganizationId === 'hanmac-office-0'
&& newEmployee.organizationId === 'saman-division-1' && newEmployee.affiliationId === 'saman'
&& JSON.stringify({ ...newEmployee, organizationId: oldEmployee.organizationId, affiliationId: oldEmployee.affiliationId }) === JSON.stringify(oldEmployee),
'Cross-company public employee move updates actual organizationId/affiliationId and preserves identity', employeeResult.moved ? { request: employeeResult.request, person: newEmployee } : employeeResult);
check(afterEmployee.data.employees.length === 20 && afterEmployee.cards.length === 20 && new Set(afterEmployee.cards.map(card => card.id)).size === 20
&& afterEmployee.summary.total === before.summary.total
&& afterEmployee.summary.byAffiliation.hanmac === before.summary.byAffiliation.hanmac - 1
&& afterEmployee.summary.byAffiliation.saman === before.summary.byAffiliation.saman + 1
&& afterEmployee.summary.byOrganization['hanmac-root'].inclusive === before.summary.byOrganization['hanmac-root'].inclusive - 1
&& afterEmployee.summary.byOrganization['saman-root'].inclusive === before.summary.byOrganization['saman-root'].inclusive + 1,
'Cross-company employee move retains total and updates source/target company plus affiliation counts');
const employeeColor = await employee('virtual-006').evaluate(card => {
const probe = document.createElement('span'); probe.style.color = window.mod.companyColorPresets.saman.primary; window.shadow.append(probe);
const expected = getComputedStyle(probe).color; probe.remove();
return { company: card.dataset.companyId, organization: card.closest('.node').dataset.organizationId, border: getComputedStyle(card).borderTopColor, expected };
});
check(employeeColor.company === 'saman' && employeeColor.organization === 'saman-division-1' && employeeColor.border === employeeColor.expected,
'Cross-company employee card uses destination existing company preset', employeeColor);
await employee('virtual-006').click(); const popup = page.locator('.hm-orgchart-employee-dialog[open]'); await popup.waitFor();
const popupState = await popup.evaluate(dialog => {
const field = name => [...dialog.querySelectorAll('dt')].find(term => term.textContent === name)?.nextElementSibling?.textContent;
return { id: dialog.dataset.employeeId, organization: field('소속'), affiliation: field('관계사') };
});
check(popupState.id === 'virtual-006' && popupState.organization === afterEmployee.data.organizations.find(org => org.id === 'saman-division-1').name
&& popupState.affiliation === afterEmployee.data.affiliations.find(affiliation => affiliation.id === 'saman').name,
'Vanilla employee Dialog reflects destination company and organization after cross move', popupState);
await page.keyboard.press('Escape'); await popup.waitFor({ state: 'hidden' }); await settle();
const groupResult = await page.evaluate(() => window.chart.move({ kind: 'organization', sourceId: 'hanmac-division-0', targetOrganizationId: 'saman-division-1' }));
await ready(); const afterGroup = await snapshot();
const subtree = new Set(['hanmac-division-0', 'hanmac-office-0']);
const subtreePeople = afterEmployee.data.employees.filter(person => subtree.has(person.organizationId));
check(groupResult.moved && groupResult.previousOrganizationId === 'hanmac-root'
&& afterGroup.data.organizations.find(org => org.id === 'hanmac-division-0').parentId === 'saman-division-1'
&& afterGroup.data.organizations.find(org => org.id === 'hanmac-office-0').parentId === 'hanmac-division-0'
&& afterGroup.data.organizations.filter(org => subtree.has(org.id)).every(org => org.companyId === 'saman')
&& afterGroup.data.employees.filter(person => subtree.has(person.organizationId)).every(person => person.affiliationId === 'saman')
&& subtreePeople.every(old => JSON.stringify(afterGroup.data.employees.find(person => person.id === old.id)) === JSON.stringify({ ...old, affiliationId: 'saman' })),
'Cross-company public group move retargets subtree companyId and staff affiliationId while retaining hierarchy and identities', groupResult.moved ? { request: groupResult.request, subtreePeople } : groupResult);
check(afterGroup.data.employees.length === 20 && afterGroup.cards.length === 20 && new Set(afterGroup.cards.map(card => card.id)).size === 20
&& afterGroup.summary.byAffiliation.hanmac === afterEmployee.summary.byAffiliation.hanmac - subtreePeople.length
&& afterGroup.summary.byAffiliation.saman === afterEmployee.summary.byAffiliation.saman + subtreePeople.length
&& afterGroup.summary.byOrganization['hanmac-root'].inclusive === afterEmployee.summary.byOrganization['hanmac-root'].inclusive - subtreePeople.length
&& afterGroup.summary.byOrganization['saman-root'].inclusive === afterEmployee.summary.byOrganization['saman-root'].inclusive + subtreePeople.length,
'Cross-company group transfer updates company/affiliation aggregate totals without duplication');
const groupColors = await page.evaluate(() => {
const probe = document.createElement('span'); probe.style.color = window.mod.companyColorPresets.saman.primary; window.shadow.append(probe);
const expected = getComputedStyle(probe).color; probe.remove();
return [...window.shadow.querySelectorAll('.node')].filter(node => ['hanmac-division-0', 'hanmac-office-0'].includes(node.dataset.organizationId))
.map(node => ({ id: node.dataset.organizationId, company: node.dataset.companyId, title: getComputedStyle(node.querySelector('.hm-orgchart-organization-name')).backgroundColor, expected,
cards: [...node.querySelectorAll('.hm-orgchart-employee')].map(card => ({ company: card.dataset.companyId, border: getComputedStyle(card).borderTopColor })) }));
});
check(groupColors.length === 2 && groupColors.every(node => node.company === 'saman' && node.title === node.expected
&& node.cards.every(card => card.company === 'saman' && card.border === node.expected)), 'Transferred subtree uses destination presets on group headings and employee borders', groupColors);
check(await page.evaluate(() => window.moves.length === 2 && window.moves.every(result => result.moved)
&& JSON.stringify(window.fixture) === window.fixtureOriginal && window.mod.validateOrgChartDataset(window.chart.getData()).valid
&& Object.isFrozen(window.chart.getData()) && Object.isFrozen(window.chart.getData().employees[0])),
'Cross-company API callbacks occur once per success; data remains valid/frozen and original fixture unchanged');
await page.evaluate(() => window.chart.setSelectedCompanies(['hanmac'])); await ready();
check(await employee('virtual-006').count() === 0 && await title('hanmac-division-0').count() === 0, 'Destination-company filter removes transferred employee/subtree from source-company view');
await page.evaluate(() => window.chart.setSelectedCompanies(['saman'])); await ready();
check(await employee('virtual-006').count() === 1 && await title('hanmac-division-0').count() === 1 && await title('hanmac-office-0').count() === 1,
'Destination-company filter restores transferred employee/subtree exactly once');
measurements.push({ section: 'cross-api', state: await snapshot() });
await fresh();
const employeeMarks = await pointerMove(employee('virtual-006'), title('saman-division-1'));
const employeePointer = await snapshot();
check(employeeMarks.some(node => node.id === 'saman-division-1' && node.state === 'allowed' && node.hover === 'allowed')
&& employeePointer.events.some(event => event.type === 'otherdropped.orgchart' && event.defaultPrevented && event.targetId === 'saman-division-1')
&& employeePointer.data.employees.find(person => person.id === 'virtual-006').organizationId === 'saman-division-1'
&& employeePointer.data.employees.find(person => person.id === 'virtual-006').affiliationId === 'saman',
'Real cross-tree employee pointer marks destination allowed and reuses canceled upstream external-drop bridge', { marks: employeeMarks, events: employeePointer.events });
await page.evaluate(() => window.chart.fitToView()); await settle();
const groupMarks = await pointerMove(title('hanmac-division-0'), title('saman-division-1'));
const groupPointer = await snapshot();
check(groupMarks.some(node => node.id === 'saman-division-1' && node.state === 'allowed' && node.hover === 'allowed')
&& groupPointer.events.some(event => event.type === 'nodedrop.orgchart' && event.defaultPrevented && event.targetId === 'saman-division-1')
&& groupPointer.data.organizations.find(org => org.id === 'hanmac-division-0').parentId === 'saman-division-1'
&& groupPointer.data.organizations.find(org => org.id === 'hanmac-office-0').companyId === 'saman',
'Real cross-tree group pointer reuses canceled upstream node-drop bridge and retargets subtree', { marks: groupMarks, events: groupPointer.events });
check(groupPointer.moves.length === 2 && groupPointer.moves.every(move => move.moved) && groupPointer.dialogCount === 0
&& groupPointer.data.employees.length === 20 && new Set(groupPointer.cards.map(card => card.id)).size === 20
&& await page.evaluate(() => JSON.stringify(window.fixture) === window.fixtureOriginal),
'Cross-tree pointer transactions have one callback each, no popup/duplicate and immutable original fixture');
measurements.push({ section: 'cross-pointer', state: groupPointer });
await page.screenshot({ path: path.join(output, 'vanilla-cross-company.png'), fullPage: true });
}
if (run('bridge')) {
await fresh(); await page.evaluate(() => window.chart.setSelectedCompanies(['hanmac'])); await ready();
const before = await snapshot();
const marks = await pointerMove(employee('virtual-006'), title('hanmac-division-1'));
const staff = await snapshot();
check(marks.some(group => group.id === 'hanmac-division-1' && group.state === 'allowed' && group.hover === 'allowed')
&& marks.some(group => group.id === 'hanmac-office-0' && group.state === 'blocked'), 'Vanilla pointer card gesture exposes valid/invalid/hover targets', marks);
check(staff.events.some(event => event.type === 'otherdropped.orgchart' && event.defaultPrevented && event.sourceId === 'virtual-006' && event.targetId === 'hanmac-division-1')
&& staff.data.employees.find(person => person.id === 'virtual-006').organizationId === 'hanmac-division-1', 'Real employee pointer uses canceled upstream otherdropped bridge and updates data', staff.events);
check(staff.dialogCount === 0 && JSON.stringify(staff.viewport) === JSON.stringify(before.viewport), 'Employee drag opens no Dialog and retains Pan/Zoom transform', { before: before.viewport, after: staff.viewport, dialogs: staff.dialogCount });
await page.evaluate(() => window.chart.fitToView()); await settle();
const groupMarks = await pointerMove(title('hanmac-division-0'), title('hanmac-division-1'));
const groups = await snapshot();
check(groupMarks.some(group => group.id === 'hanmac-office-0' && group.state === 'blocked')
&& groups.events.some(event => event.type === 'nodedrop.orgchart' && event.defaultPrevented && event.sourceId === 'hanmac-division-0' && event.targetId === 'hanmac-division-1')
&& groups.data.organizations.find(org => org.id === 'hanmac-division-0').parentId === 'hanmac-division-1', 'Real organization pointer uses canceled upstream nodedrop bridge and protects descendant', { marks: groupMarks, events: groups.events });
check(groups.data.employees.length === 20 && new Set(groups.groups.map(group => group.id)).size === groups.groups.length
&& groups.groups.find(group => group.id === 'hanmac-office-0').parent === 'hanmac-division-0'
&& groups.groups.find(group => group.id === 'hanmac-division-0').collapsed === 'false', 'Group pointer move keeps complete subtree and avoids post-drag collapse');
await page.evaluate(() => window.chart.setAutoLayout({ type: 'lr' })); await ready();
const routed = await snapshot();
check(routed.organizationPaths.some(route => route.id === 'hanmac-division-0' && route.parent === 'hanmac-division-1')
&& !routed.organizationPaths.some(route => route.id === 'hanmac-division-0' && route.parent === 'hanmac-root')
&& routed.cards.every(card => card.direction === 'left'), 'LR regenerated connector follows changed parent and card incoming borders');
await page.screenshot({ path: path.join(output, 'vanilla-core-lr.png'), fullPage: true });
measurements.push({ section: 'bridge', state: routed });
}
if (run('lifecycle')) {
await fresh();
await page.evaluate(() => window.chart.move({ kind: 'employee', sourceId: 'virtual-006', targetOrganizationId: 'hanmac-division-1' })); await ready();
await page.evaluate(() => window.chart.setSelectedCompanies(['hanmac'])); await ready();
await title('hanmac-division-1').click(); await ready();
const collapsed = await snapshot();
check(collapsed.groups.find(group => group.id === 'hanmac-division-1').collapsed === 'true', 'Moved employee target remains ordinarily collapsible');
for (const type of ['lr', 'hybrid', 'tb']) {
await page.evaluate(type => window.chart.setAutoLayout({ type }), type); await ready();
check(await title('hanmac-division-1').getAttribute('aria-expanded') === 'false'
&& await page.evaluate(() => window.chart.getData().employees.find(person => person.id === 'virtual-006').organizationId === 'hanmac-division-1'), `Local relation and collapse survive layout ${type}`);
}
const hiddenState = await page.evaluate(() => JSON.stringify(window.chart.getData()));
await page.evaluate(() => window.chart.setSelectedCompanies(['saman'])); await ready();
check(await page.locator('.node[data-company-id="hanmac"]').count() === 0
&& await page.evaluate(expected => JSON.stringify(window.chart.getData()) === expected, hiddenState), 'Hiding a company preserves all browser-state relations');
await page.evaluate(() => window.chart.setSelectedCompanies(['hanmac'])); await ready();
check(await title('hanmac-division-1').getAttribute('aria-expanded') === 'false', 'Company restore keeps saved collapsed target');
await title('hanmac-division-1').click(); await ready();
check(await employee('virtual-006').count() === 1 && await employee('virtual-006').evaluate(card => card.closest('.node').dataset.organizationId) === 'hanmac-division-1', 'Company restore shows moved employee exactly once in new group');
const raced = await page.evaluate(async () => {
let resolveSource;
const delayed = { load: () => new Promise(resolve => { resolveSource = resolve; }) };
const first = window.chart.reload(delayed);
const rejected = await window.chart.move({ kind: 'employee', sourceId: 'virtual-006', targetOrganizationId: 'hanmac-office-1' });
const fresh = structuredClone(window.fixture); fresh.metadata.dataVersion = 'p9.3-newer-request';
await window.chart.update(fresh);
resolveSource(window.fixture); await first;
return { rejected, version: window.chart.getData().metadata.dataVersion, organization: window.chart.getData().employees.find(person => person.id === 'virtual-006').organizationId, status: window.chart.getState().status };
});
await ready();
check(!raced.rejected.moved && raced.rejected.code === 'INVALID_DATA', 'Move during pending source request is safely rejected', raced.rejected);
check(raced.version === 'p9.3-newer-request' && raced.organization === 'hanmac-office-0' && raced.status === 'ready', 'Stale asynchronous reload cannot overwrite newer dataset request', raced);
const simultaneous = await page.evaluate(async () => {
const one = window.chart.move({ kind: 'employee', sourceId: 'virtual-006', targetOrganizationId: 'hanmac-division-1' });
const two = window.chart.move({ kind: 'employee', sourceId: 'virtual-007', targetOrganizationId: 'hanmac-office-1' });
const outcomes = await Promise.all([one, two]);
return { outcomes: outcomes.map(result => ({ moved: result.moved, code: result.code })), data: window.chart.getData(), valid: window.mod.validateOrgChartDataset(window.chart.getData()).valid, status: window.chart.getState().status };
});
await ready();
check(simultaneous.outcomes[0].moved && !simultaneous.outcomes[1].moved && simultaneous.outcomes[1].code === 'INVALID_DATA'
&& simultaneous.valid && simultaneous.status === 'ready' && simultaneous.data.employees.length === 20
&& simultaneous.data.employees.find(person => person.id === 'virtual-006').organizationId === 'hanmac-division-1'
&& simultaneous.data.employees.find(person => person.id === 'virtual-007').organizationId === 'hanmac-division-1', 'Concurrent public moves serialize safely; loading request rejects without losing data', { outcomes: simultaneous.outcomes, status: simultaneous.status, valid: simultaneous.valid });
measurements.push({ section: 'lifecycle', state: await snapshot() });
}
if (run('touch')) {
await fresh(); await page.evaluate(() => window.chart.setSelectedCompanies(['hanmac'])); await ready();
await page.evaluate(() => {
window.touchPointers = [];
for (const type of ['pointerdown', 'pointermove', 'pointerup', 'pointercancel', 'gotpointercapture', 'lostpointercapture']) {
window.addEventListener(type, event => window.touchPointers.push({ type, pointerType: event.pointerType, isPrimary: event.isPrimary,
x: event.clientX, y: event.clientY, editing: window.shadow.querySelector('.hm-orgchart-viewport').dataset.editDragging,
feedback: window.shadow.querySelector('.hm-orgchart-move-feedback').textContent }));
}
});
const from = await employee('virtual-006').boundingBox(), to = await title('hanmac-division-1').boundingBox();
const start = { x: from.x + from.width / 2, y: from.y + from.height / 2 }, end = { x: to.x + to.width / 2, y: to.y + to.height / 2 };
const session = await page.context().newCDPSession(page);
await session.send('Emulation.setTouchEmulationEnabled', { enabled: true, maxTouchPoints: 1 });
await session.send('Input.dispatchTouchEvent', { type: 'touchStart', touchPoints: [start] });
for (let step = 1; step <= 8; step++) {
await session.send('Input.dispatchTouchEvent', { type: 'touchMove', touchPoints: [{ x: start.x + (end.x - start.x) * step / 8, y: start.y + (end.y - start.y) * step / 8 }] });
}
await session.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] });
await ready();
const touch = await page.evaluate(() => ({ pointers: window.touchPointers,
organization: window.chart.getData().employees.find(person => person.id === 'virtual-006').organizationId,
events: window.bridgeEvents, dialogs: window.shadow.querySelectorAll('dialog[open]').length }));
check(touch.pointers.some(event => event.pointerType === 'touch' && event.isPrimary)
&& touch.organization === 'hanmac-division-1' && touch.events.some(event => event.type === 'otherdropped.orgchart' && event.defaultPrevented) && touch.dialogs === 0,
'Synthetic browser touch input generates primary touch pointer, moves card and suppresses Dialog', touch);
measurements.push({ section: 'touch', state: touch, realDevice: 'NOT VERIFIED' });
await session.send('Emulation.setTouchEmulationEnabled', { enabled: false }); await session.detach();
}
if (run('touch-pan')) {
await fresh(); await page.evaluate(() => window.chart.setSelectedCompanies(['hanmac'])); await ready();
const setup = await page.evaluate(() => {
const viewport = window.shadow.querySelector('.hm-orgchart-viewport'), box = viewport.getBoundingClientRect();
const left = box.left + 24, right = Math.min(box.right - 72, innerWidth - 72), top = box.top + 24, bottom = Math.min(box.bottom - 72, innerHeight - 72);
for (let y = top; y <= bottom; y += 40) {
for (let x = left; x <= right; x += 40) {
const hit = window.shadow.elementFromPoint(x, y);
if (hit && viewport.contains(hit) && !hit.closest('button,a,.hm-orgchart-employee,.node')) {
window.touchPanPointers = [];
viewport.addEventListener('pointerdown', event => window.touchPanPointers.push({ pointerType: event.pointerType, isPrimary: event.isPrimary }));
return { point: { x, y }, sourceClass: String(hit.className), before: window.chart.getViewportState(), moves: window.moves.length };
}
}
}
throw new Error('No safe visible blank point for touch Pan');
});
const session = await page.context().newCDPSession(page);
await session.send('Emulation.setTouchEmulationEnabled', { enabled: true, maxTouchPoints: 1 });
await session.send('Input.dispatchTouchEvent', { type: 'touchStart', touchPoints: [setup.point] });
for (let step = 1; step <= 8; step++) {
await session.send('Input.dispatchTouchEvent', { type: 'touchMove', touchPoints: [{ x: setup.point.x + 40 * step / 8, y: setup.point.y + 30 * step / 8 }] });
}
await session.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] }); await settle();
const after = await page.evaluate(() => ({ viewport: window.chart.getViewportState(), moves: window.moves.length,
pointers: window.touchPanPointers, editing: window.shadow.querySelector('.hm-orgchart-viewport').dataset.editDragging,
panDragging: window.shadow.querySelector('.hm-orgchart-viewport').dataset.dragging }));
check(after.pointers.some(event => event.pointerType === 'touch' && event.isPrimary)
&& Math.abs(after.viewport.x - setup.before.x - 40) < 1 && Math.abs(after.viewport.y - setup.before.y - 30) < 1
&& after.viewport.scale === setup.before.scale && after.moves === setup.moves && !after.editing && !after.panDragging,
'Synthetic touch blank-space Pan translates forest, retains scale and triggers no card move', { setup, after });
measurements.push({ section: 'touch-pan', setup, after, realDevice: 'NOT VERIFIED' });
await session.send('Emulation.setTouchEmulationEnabled', { enabled: false }); await session.detach();
}
if (run('fixed')) {
await fresh(); await page.evaluate(() => window.mountChart('fixed')); await ready();
const fixed = await page.evaluate(async () => {
const before = window.shadow.querySelector('.hm-orgchart-stage')?.outerHTML;
let rejection;
try { await window.chart.move({ kind: 'employee', sourceId: 'virtual-006', targetOrganizationId: 'hanmac-division-1' }); }
catch (error) { rejection = error.message; }
return { rejection, unchanged: before === window.shadow.querySelector('.hm-orgchart-stage')?.outerHTML,
expanded: window.shadow.querySelectorAll('.hm-orgchart-expanded-toggle,.hm-orgchart-move-feedback,[data-edit-source],[data-drop-state]').length,
draggable: window.shadow.querySelectorAll('[draggable="true"]').length, status: window.chart.getState().status, data: window.chart.getData(), events: window.moves.length };
});
check(fixed.rejection?.includes('Auto mode') && fixed.unchanged && fixed.status === 'ready' && fixed.events === 0, 'Fixed public handle rejects editing and retains rendered stage', fixed);
check(fixed.expanded === 0 && fixed.draggable === 0 && fixed.data === undefined, 'Fixed has no editing controls, native drag or Auto browser-state API');
}
check(errors.length === 0, 'Vanilla Core change-only verification has no console/page errors', errors);
} catch (error) {
check(false, 'Unexpected verification error', { message: error.message, stack: error.stack });
await page.screenshot({ path: path.join(output, `vanilla-error${suffix}.png`), fullPage: true }).catch(() => {});
} finally {
await browser.close();
const completedAt = new Date().toISOString();
const counts = { PASS: results.filter(result => result.status === 'PASS').length, FAIL: results.filter(result => result.status === 'FAIL').length };
await fs.writeFile(path.join(output, `core-browser${suffix}.json`), JSON.stringify({ startedAt, completedAt, origin, counts, results, measurements, errors }, null, 2));
console.log(JSON.stringify({ counts, origin, output: `test-results/org-chart-p9-3/core-browser${suffix}.json` }));
if (counts.FAIL || errors.length) process.exitCode = 1;
}
@@ -0,0 +1,40 @@
import fs from 'node:fs/promises';
import path from 'node:path';
import { launchChromium } from '../../scripts/orgchart-browser-runtime.mjs';
const output = path.resolve('test-results/org-chart-p9-3');
const origin = process.env.ORGCHART_STORY_ORIGIN ?? 'http://127.0.0.1:6064';
const browser = await launchChromium({headless:true});
const page = await browser.newPage({viewport:{width:1440,height:900}});
const results=[],errors=[];
page.on('pageerror',e=>errors.push(e.message));
page.on('console',m=>{if(m.type()==='error')errors.push(m.text());});
const check=(ok,name,detail)=>{results.push({name,status:ok?'PASS':'FAIL',detail});if(!ok)console.log('FAIL',name,detail);};
const ready=async()=>{await page.waitForFunction(()=>{const s=document.querySelector('.hm-orgchart-host')?.shadowRoot;return s&&!s.querySelector('[aria-busy="true"]')&&s.querySelector('.hm-orgchart-employee')&&[...s.querySelectorAll('link')].every(l=>l.sheet);});await page.evaluate(async()=>{await document.fonts.ready;for(let i=0;i<12;i++)await new Promise(requestAnimationFrame);});};
await page.goto(`${origin}/iframe.html?id=orgchart--full-composition&viewMode=story`);await ready();
await page.evaluate(async()=>{await Promise.all([...document.querySelector('.hm-orgchart-host').shadowRoot.querySelectorAll('img')].map(async i=>{i.loading='eager';await i.decode();}));});
// Reuse the exact original measurement function; do not rerun P9.2's suite.
const priorSource=await fs.readFile('examples/org-chart/verify-p9-2-composition.mjs','utf8');
const from=priorSource.indexOf('async function snapshot()'),to=priorSource.indexOf('async function stability()',from);
const state=await new Function('page',priorSource.slice(from,to)+'; return snapshot();')(page);
const previous=JSON.parse(await fs.readFile('test-results/org-chart-p9-2/browser-composition.json','utf8')).snapshots['1440'];
const same=(a,b)=>JSON.stringify(a)===JSON.stringify(b);
const normalizedEqual=state.elements.every((e,i)=>{const old=previous.elements[i];return old?.key===e.key&&old.hash===e.hash&&same(old.style,e.style)&&Object.keys(e.box).every(k=>Math.abs(e.box[k]-old.box[k])<.15);});
check(previous.htmlHash===state.htmlHash&&same(previous.assets,state.assets)&&normalizedEqual,'Fixed P9.2 DOM, 139 image hashes, 152 element styles/geometry preserved',{before:previous.htmlHash,after:state.htmlHash,elements:state.elements.length,assets:state.assets.length});
check(Math.abs(state.centerDifference)<1&&state.collisions.length===0&&state.counts.cards===43&&state.counts.paths===5,'Fixed center/card/connector minimal regression',{centerDifference:state.centerDifference,counts:state.counts,collisions:state.collisions});
check(await page.evaluate(()=>!document.querySelector('.hm-orgchart-host').shadowRoot.querySelector('[data-edit-dragging],[data-drop-state],.hm-orgchart-move-feedback')),'Fixed has no editing UI');
await page.screenshot({path:path.join(output,'fixed-preserved-1440.png'),fullPage:true});
await page.goto(`${origin}/`);
await page.waitForFunction(()=>Boolean(document.querySelector('a[href*="orgchart--expanded-org-chart"]')));
const menus=await page.locator('a[href*="orgchart--"]').allTextContents();
check(menus.includes('Overview')&&menus.includes('Department Colors')&&menus.includes('Full Composition')&&menus.includes('Expanded OrgChart'),'Four existing Storybook menu entries preserved',menus);
for(const width of [390,768,1280,1440]){
await page.setViewportSize({width,height:1100});
await page.goto(`${origin}/iframe.html?id=orgchart--expanded-org-chart&viewMode=story`);await ready();
const metrics=await page.evaluate(()=>{const s=document.querySelector('.hm-orgchart-host').shadowRoot,v=s.querySelector('.hm-orgchart-viewport'),f=s.querySelector('.hm-orgchart-move-feedback'),c=getComputedStyle(f);return {width:innerWidth,scrollWidth:document.documentElement.scrollWidth,viewportWidth:v.clientWidth,rawInnerScrollWidth:v.scrollWidth,feedbackHeight:f.clientHeight,feedbackScrollHeight:f.scrollHeight,feedbackColor:c.color,feedbackFontWeight:c.fontWeight,unresolvedTokens:[...['--hm-orgchart-resolved-fg-secondary','--hm-orgchart-resolved-action-primary','--hm-orgchart-resolved-stroke-default','--hm-orgchart-resolved-stroke-input-focus']].filter(k=>!getComputedStyle(f).getPropertyValue(k).trim())};});
check(metrics.scrollWidth<=width+1&&metrics.viewportWidth>0&&metrics.feedbackScrollHeight<=metrics.feedbackHeight+1&&metrics.unresolvedTokens.length===0,`${width}: responsive move instructions, viewport and token declarations`,metrics);
await page.screenshot({path:path.join(output,`expanded-${width}.png`),fullPage:true});
}
check(errors.length===0,'No new console/page errors',errors);
await fs.writeFile(path.join(output,'preservation.json'),JSON.stringify({origin,results,errors,fixedSnapshot:state},null,2));
await browser.close();console.log(JSON.stringify({pass:results.filter(x=>x.status==='PASS').length,fail:results.filter(x=>x.status==='FAIL').length}));
if(results.some(x=>x.status==='FAIL'))process.exitCode=1;
+400
View File
@@ -0,0 +1,400 @@
// P9.3 browser-state editing only. Exact section names allow failed probes to
// rerun without replaying previously successful P7-P9.2 or P9.3 checks.
import fs from 'node:fs/promises';
import { writeFileSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { launchChromium } from '../../scripts/orgchart-browser-runtime.mjs';
import { companyColorPresets } from '../../packages/org-chart/dist/index.js';
const root = fileURLToPath(new URL('../../', import.meta.url));
const output = path.join(root, 'test-results/org-chart-p9-3');
const origin = process.env.ORGCHART_STORY_ORIGIN ?? 'http://127.0.0.1:6064';
const only = process.argv.slice(2), suffix = only.length ? `-${only.join('-')}` : '';
const results = [], measurements = [], errors = [], startedAt = new Date().toISOString();
const colors = Object.fromEntries(Object.values(companyColorPresets).map(preset => [preset.companyId,
`rgb(${[1, 3, 5].map(index => parseInt(preset.primary.slice(index, index + 2), 16)).join(', ')})`]));
await fs.mkdir(output, { recursive: true });
const persist = () => writeFileSync(path.join(output, `browser-progress${suffix}.json`), JSON.stringify({ startedAt, results, measurements, errors }, null, 2));
const check = (pass, name, detail) => {
results.push({ name, status: pass ? 'PASS' : 'FAIL', ...(detail === undefined ? {} : { detail }) }); persist();
if (!pass) console.log('FAIL', name, JSON.stringify(detail));
};
const browser = await launchChromium({ headless: true });
const page = await browser.newPage({ viewport: { width: 1920, height: 1700 } });
page.setDefaultTimeout(15000);
page.on('pageerror', error => { errors.push({ type: 'pageerror', message: error.message }); persist(); });
page.on('console', message => { if (message.type() === 'error') { errors.push({ type: 'console', message: message.text(), location: message.location() }); persist(); } });
const viewport = () => page.locator('.hm-orgchart-viewport');
const employee = id => page.locator(`.hm-orgchart-employee[data-employee-id="${id}"]`);
const node = id => page.locator(`.node[data-organization-id="${id}"]`);
const title = id => page.locator(`.hm-orgchart-expanded-toggle[data-organization-id="${id}"]`);
const feedback = () => page.locator('.hm-orgchart-move-feedback');
const companyTrigger = () => page.getByRole('button', { name: /^회사 선택/ });
const companyPanel = () => page.getByRole('dialog', { name: '회사 선택', exact: true });
const companyCheckbox = name => companyPanel().getByRole('checkbox', { name, exact: true });
const dialog = () => page.locator('[data-component="HMOrgChartEmployeeDialog"]');
const settle = () => page.evaluate(async () => { await document.fonts.ready; for (let i = 0; i < 8; i++) await new Promise(requestAnimationFrame); });
async function ready(count) {
await page.waitForFunction(count => {
const shadow = document.querySelector('.hm-orgchart-host')?.shadowRoot;
return shadow && !shadow.querySelector('[aria-busy="true"]') && [...shadow.querySelectorAll('link[rel="stylesheet"]')].every(link => link.sheet)
&& (count === undefined || shadow.querySelectorAll('.hm-orgchart-employee').length === count);
}, count);
await settle();
}
async function select(label, option) {
await page.getByRole('combobox', { name: label, exact: true }).click();
await page.getByRole('option', { name: option, exact: true }).click(); await ready();
}
async function chooseCompanies(names) {
await companyTrigger().click(); await companyPanel().waitFor();
const all = companyCheckbox('전체 선택');
// The established mixed select-all action selects all first, then none.
for (let i = 0; i < 2 && await all.getAttribute('aria-checked') !== 'false'; i++) { await all.click(); await ready(); }
await ready(0);
for (const name of names) { await companyCheckbox(name).click(); await ready(); }
await page.keyboard.press('Escape'); await companyPanel().waitFor({ state: 'hidden' }); await ready();
}
async function setup({ companies = ['한맥기술'], layout = 'tb' } = {}) {
await page.goto(`${origin}/iframe.html?id=orgchart--expanded-org-chart&viewMode=story`);
await companyTrigger().waitFor(); await ready(20);
await chooseCompanies(companies);
await select('데이터 범위', '회사별');
await page.getByRole('button', { name: '데이터 적용', exact: true }).click(); await ready(companies.length * 20);
if (layout !== 'tb') await select('조직도 표시 유형', layout === 'lr' ? '가로형 LR' : '혼합형 Hybrid');
await viewport().scrollIntoViewIfNeeded(); await page.getByRole('button', { name: 'Fit to View', exact: true }).click(); await settle();
}
async function state() {
return page.evaluate(() => {
const shadow = document.querySelector('.hm-orgchart-host').shadowRoot;
const viewport = shadow.querySelector('.hm-orgchart-viewport'), expanded = shadow.querySelector('.hm-orgchart-expanded');
const rect = element => { const r = element.getBoundingClientRect(); return { x: r.x, y: r.y, width: r.width, height: r.height, right: r.right, bottom: r.bottom }; };
const nodes = [...shadow.querySelectorAll('.node[data-organization-id]')].map(element => ({
id: element.dataset.organizationId, parentId: element.dataset.parentId, companyId: element.dataset.companyId,
domParentId: element.closest('.hierarchy')?.parentElement?.closest('.hierarchy')?.querySelector(':scope > .node')?.dataset.organizationId ?? '',
direct: Number(element.dataset.directEmployeeCount), inclusive: Number(element.dataset.inclusiveEmployeeCount), collapsed: element.dataset.collapsed,
}));
const cards = [...shadow.querySelectorAll('.hm-orgchart-employee')].map(element => ({
id: element.dataset.employeeId, organizationId: element.closest('.node').dataset.organizationId, companyId: element.dataset.companyId,
direction: element.dataset.connectionDirection, kind: element.dataset.cardKind,
border: { top: getComputedStyle(element).borderTopColor, left: getComputedStyle(element).borderLeftColor, right: getComputedStyle(element).borderRightColor },
}));
const paths = [...shadow.querySelectorAll('.hm-orgchart-organization-connectors path')].map(path => ({ id: path.dataset.organizationId, parentId: path.dataset.parentId, d: path.getAttribute('d') }));
const employeePaths = [...shadow.querySelectorAll('.hm-orgchart-employee-connectors path')].map(path => ({ id: path.dataset.employeeId, organizationId: path.closest('.node').dataset.organizationId, direction: path.dataset.direction, d: path.getAttribute('d') }));
const feedback = shadow.querySelector('.hm-orgchart-move-feedback');
return { layout: expanded?.dataset.autoLayout, scale: Number(viewport.dataset.scale), panX: Number(viewport.dataset.panX), panY: Number(viewport.dataset.panY),
viewport: rect(viewport), forest: rect(shadow.querySelector('.hm-orgchart-forest')), nodes, cards, paths, employeePaths, feedback: { status: feedback?.dataset.moveStatus, text: feedback?.textContent },
drop: [...shadow.querySelectorAll('[data-drop-state]')].map(element => ({ id: element.dataset.organizationId, state: element.dataset.dropState, hover: element.dataset.dropHover })),
dragging: viewport.dataset.editDragging, panDragging: viewport.dataset.dragging, activeEmployee: shadow.activeElement?.dataset.employeeId,
activeOrganization: shadow.activeElement?.dataset.organizationId,
countText: document.querySelector('[data-component="OrgChartExampleCounts"]')?.textContent.trim(), dialogs: document.querySelectorAll('[data-component="HMOrgChartEmployeeDialog"]').length,
employeesVisible: cards.length, employeeCount: cards.length, organizationCount: nodes.length,
companyRoots: nodes.filter(org => org.parentId === '').map(org => org.companyId),
};
});
}
const relationState = s => JSON.stringify({ nodes: s.nodes, cards: s.cards.map(({ id, organizationId, direction }) => ({ id, organizationId, direction })) });
async function blankPoint() {
return page.evaluate(() => {
const shadow = document.querySelector('.hm-orgchart-host').shadowRoot, viewport = shadow.querySelector('.hm-orgchart-viewport'), box = viewport.getBoundingClientRect();
for (const [dx, dy] of [[8, 8], [8, box.height - 8], [box.width - 8, 8], [box.width - 8, box.height - 8], [box.width / 2, box.height - 8]]) {
const point = { x: box.left + dx, y: box.top + dy }, hit = shadow.elementFromPoint(point.x, point.y);
if (hit && !hit.closest('button,a,.hm-orgchart-employee,.node')) return point;
}
throw new Error('No visible blank Pan point found');
});
}
async function pan(dx, dy) {
const p = await blankPoint(); await page.mouse.move(p.x, p.y); await page.mouse.down();
const v = await viewport().boundingBox();
// Cross the six-pixel Pan threshold inside the viewport before the requested
// motion. A large first upward step from its top edge never reaches the
// viewport's pointermove listener and therefore cannot establish capture.
await page.mouse.move(p.x + Math.sign(v.x + v.width / 2 - p.x) * 8, p.y, { steps: 2 });
await page.mouse.move(p.x + dx, p.y + dy, { steps: 8 }); await page.mouse.up(); await settle();
}
async function centerPair(source, target) {
await viewport().scrollIntoViewIfNeeded(); await source.focus(); await settle();
for (let pass = 0; pass < 3; pass++) {
const a = await source.boundingBox(), b = await target.boundingBox(), v = await viewport().boundingBox();
if (!a || !b || !v) throw new Error('Drag source or target has no geometry');
const left = Math.min(a.x, b.x), right = Math.max(a.x + a.width, b.x + b.width);
const top = Math.min(a.y, b.y), bottom = Math.max(a.y + a.height, b.y + b.height);
if (right - left > v.width - 24 || bottom - top > v.height - 24) throw new Error(`Drag pair exceeds viewport at current scale: ${JSON.stringify({ a, b, v })}`);
const dx = v.x + v.width / 2 - (left + right) / 2, dy = v.y + v.height / 2 - (top + bottom) / 2;
if (Math.abs(dx) < 1 && Math.abs(dy) < 1) break;
await pan(dx, dy);
}
}
async function drag(source, target, { inspect = false, screenshot } = {}) {
await centerPair(source, target);
const a = await source.boundingBox(), b = await target.boundingBox();
const before = await state();
await page.mouse.move(a.x + a.width / 2, a.y + a.height / 2); await page.mouse.down();
await page.mouse.move(a.x + a.width / 2 + 8, a.y + a.height / 2, { steps: 2 });
const started = inspect ? await state() : undefined;
await page.mouse.move(b.x + b.width / 2, b.y + b.height / 2, { steps: 14 });
const hover = inspect ? await state() : undefined;
if (screenshot) await snap(screenshot);
await page.mouse.up(); await ready();
return { before, started, hover, pointer: { source: a, target: b }, after: await state() };
}
async function snap(name) { await page.screenshot({ path: path.join(output, `${name}.png`), fullPage: false }); }
async function section(name, action) {
if (only.length && !only.includes(name) && !(name.startsWith('mode-') && only.includes('modes'))) return;
try { await action(); } catch (error) {
check(false, `${name}: execution completed`, { message: error.message, stack: error.stack });
await page.mouse.up().catch(() => {}); await snap(`failure-${name}`).catch(() => {});
}
}
function checkConsistency(s, name, total = 20) {
const uniqueEmployees = new Set(s.cards.map(card => card.id)), uniqueOrganizations = new Set(s.nodes.map(org => org.id));
check(uniqueEmployees.size === s.cards.length && uniqueOrganizations.size === s.nodes.length && s.cards.length === total, `${name}: unique cards/groups and total preserved`, { cards: s.cards.length, groups: s.nodes.length, total });
check(s.nodes.every(org => org.parentId === org.domParentId), `${name}: dabeng DOM hierarchy matches current parentId`);
check(s.cards.every(card => card.border[card.direction] === colors[card.companyId]), `${name}: company accent border matches each connector direction`);
check(s.employeePaths.length === s.cards.length && new Set(s.employeePaths.map(path => path.id)).size === s.cards.length
&& s.employeePaths.every(path => path.d && !/NaN|Infinity/.test(path.d) && s.cards.some(card => card.id === path.id && card.organizationId === path.organizationId && card.direction === path.direction)), `${name}: employee connectors refreshed once per card`);
check(s.paths.every(path => path.d && !/NaN|Infinity/.test(path.d) && s.nodes.some(org => org.id === path.id && org.parentId === path.parentId))
&& new Set(s.paths.map(path => path.id)).size === s.paths.length, `${name}: organization connectors use current parents without duplicate paths`);
}
await section('staff', async () => {
await setup();
const result = await drag(employee('virtual-022'), title('hanmac-office-sibling'), { inspect: true, screenshot: 'tb-staff-drop-hover' });
measurements.push({ name: 'TB employee move', ...result });
check(result.started.dragging === 'true' && result.started.drop.some(target => target.state === 'allowed') && result.started.drop.some(target => target.state === 'blocked'), 'Employee threshold starts editing and marks allowed/blocked targets');
check(result.hover.drop.find(target => target.id === 'hanmac-office-sibling')?.hover === 'allowed', 'Employee valid target hover is identified');
check(result.after.cards.find(card => card.id === 'virtual-022')?.organizationId === 'hanmac-office-sibling' && !result.after.cards.some(card => card.id === 'virtual-022' && card.organizationId === 'hanmac-office-0'), 'Employee membership moves to new group once');
check(result.after.feedback.status === 'success' && result.after.dialogs === 0 && result.after.drop.length === 0, 'Employee success announces completion and suppresses click/Dialog');
check(Math.abs(result.before.scale - result.after.scale) < .0001 && Math.abs(result.before.panX - result.hover.panX) < .1 && Math.abs(result.before.panY - result.hover.panY) < .1 && !result.hover.panDragging, 'Card Drag preserves Zoom and does not execute Pan');
const sourceBefore = result.before.nodes.find(org => org.id === 'hanmac-office-0'), sourceAfter = result.after.nodes.find(org => org.id === 'hanmac-office-0');
const targetBefore = result.before.nodes.find(org => org.id === 'hanmac-office-sibling'), targetAfter = result.after.nodes.find(org => org.id === 'hanmac-office-sibling');
check(sourceAfter.direct === sourceBefore.direct - 1 && sourceAfter.inclusive === sourceBefore.inclusive - 1 && targetAfter.direct === targetBefore.direct + 1 && targetAfter.inclusive === targetBefore.inclusive + 1, 'Employee source/destination direct and inclusive counts update');
checkConsistency(result.after, 'TB employee');
await employee('virtual-022').click(); await dialog().waitFor();
const organization = await dialog().locator('dl > div').filter({ has: page.locator('dt', { hasText: '소속 조직' }) }).locator('dd').innerText();
check(organization === '가상 계획실' && await dialog().getAttribute('data-employee-id') === 'virtual-022', 'HM Dialog shows new organization and same employee ID', organization);
await page.keyboard.press('Escape'); await dialog().waitFor({ state: 'hidden' }); await settle();
check((await state()).activeEmployee === 'virtual-022', 'HM Dialog returns focus to moved card');
await snap('tb-staff-moved');
});
await section('groups', async () => {
await setup();
const result = await drag(title('hanmac-office-0'), title('hanmac-office-1'), { inspect: true, screenshot: 'tb-group-drop-hover' });
measurements.push({ name: 'TB group move', ...result });
check(result.after.nodes.find(org => org.id === 'hanmac-office-0')?.parentId === 'hanmac-office-1', 'Group parentId changes to dropped organization');
const descendants = ['hanmac-cell-0', 'hanmac-team-0', 'hanmac-team-sibling'];
check(descendants.every(id => result.after.nodes.find(org => org.id === id)?.parentId === result.before.nodes.find(org => org.id === id)?.parentId)
&& result.after.cards.every(card => result.before.cards.find(before => before.id === card.id)?.organizationId === card.organizationId), 'Group move retains subtree and every employee membership');
const inclusive = result.before.nodes.find(org => org.id === 'hanmac-office-0').inclusive;
check(result.after.nodes.find(org => org.id === 'hanmac-division-0').inclusive === result.before.nodes.find(org => org.id === 'hanmac-division-0').inclusive - inclusive
&& result.after.nodes.find(org => org.id === 'hanmac-division-1').inclusive === result.before.nodes.find(org => org.id === 'hanmac-division-1').inclusive + inclusive, 'Group move updates ancestor inclusive counts');
check(result.after.feedback.status === 'success' && result.after.nodes.find(org => org.id === 'hanmac-office-0').collapsed === 'false' && result.after.dialogs === 0, 'Title Drag suppresses collapse and Dialog');
checkConsistency(result.after, 'TB group'); await snap('tb-group-moved');
await title('hanmac-office-0').click(); await ready(); check(await title('hanmac-office-0').getAttribute('aria-expanded') === 'false', 'Moved group title click still collapses');
await title('hanmac-office-0').press('Space'); await ready(20); check(await title('hanmac-office-0').getAttribute('aria-expanded') === 'true', 'Moved group Space still expands');
});
await section('invalid', async () => {
await setup({ companies: ['한맥기술', '삼안'] });
for (const [name, source, target, reason] of [
['protected leader', employee('virtual-002'), title('hanmac-office-sibling'), '조직장'],
['group descendant cycle', title('hanmac-office-0'), title('hanmac-cell-0'), '순환'],
['group self', title('hanmac-office-0'), title('hanmac-office-0'), '자기 자신'],
['company root', title('hanmac-root'), title('hanmac-office-sibling'), '최상위'],
]) {
const result = await drag(source, target, { inspect: true });
check(relationState(result.before) === relationState(result.after) && result.after.feedback.status === 'failure' && result.after.feedback.text.includes(reason), `${name}: rejects with reason and retains data`, result.after.feedback);
const targetId = await target.getAttribute('data-organization-id');
check(result.hover.drop.find(item => item.id === targetId)?.hover === 'blocked' && result.after.dialogs === 0, `${name}: blocked hover and no Dialog`);
}
await centerPair(employee('virtual-022'), title('hanmac-office-sibling'));
const before = await state(), source = await employee('virtual-022').boundingBox(), box = await viewport().boundingBox();
await page.mouse.move(source.x + source.width / 2, source.y + source.height / 2); await page.mouse.down();
await page.mouse.move(source.x + source.width / 2 + 8, source.y + source.height / 2);
await page.mouse.move(box.x + box.width + 12, box.y + 50, { steps: 12 }); await page.mouse.up(); await ready();
const after = await state(); check(relationState(before) === relationState(after) && after.feedback.status === 'failure' && !after.dragging && after.dialogs === 0, 'Outside viewport release cancels edit without changing data', after.feedback);
await snap('invalid-drop-rejected');
});
await section('pan-click', async () => {
await setup(); await centerPair(employee('virtual-022'), title('hanmac-office-sibling'));
const before = await state(); await pan(85, -45); const after = await state();
check(Math.abs(after.panX - before.panX - 85) < 1 && Math.abs(after.panY - before.panY + 45) < 1 && after.scale === before.scale && relationState(after) === relationState(before), 'Blank-space pointer Pan preserves Zoom and every relationship');
await centerPair(employee('virtual-022'), title('hanmac-office-sibling'));
const box = await employee('virtual-022').boundingBox();
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2); await page.mouse.down();
await page.mouse.move(box.x + box.width / 2 + 2, box.y + box.height / 2 + 1); const small = await state(); await page.mouse.up();
await dialog().waitFor(); check(!small.dragging && await dialog().getAttribute('data-employee-id') === 'virtual-022', 'Movement below six-pixel threshold remains employee click');
await page.keyboard.press('Escape'); await dialog().waitFor({ state: 'hidden' });
await title('hanmac-office-sibling').click(); await ready(); check(await title('hanmac-office-sibling').getAttribute('aria-expanded') === 'false', 'Simple group click collapses');
await title('hanmac-office-sibling').click(); await ready(20); check(await title('hanmac-office-sibling').getAttribute('aria-expanded') === 'true', 'Simple group click expands');
});
await section('zoom', async () => {
await setup();
let current = 'hanmac-office-0';
for (const scale of [1, .65, 1.4]) {
await page.getByRole('button', { name: '100% 복원', exact: true }).click(); await settle();
if (scale !== 1) { const p = await blankPoint(); await page.mouse.move(p.x, p.y); await page.keyboard.down('Control'); await page.mouse.wheel(0, -Math.log(scale) / .002); await page.keyboard.up('Control'); await settle(); }
check(Math.abs((await state()).scale - scale) < .002, `${scale}: actual Ctrl+wheel Zoom reaches requested scale`);
const target = current === 'hanmac-office-0' ? 'hanmac-office-sibling' : 'hanmac-office-0';
const result = await drag(employee('virtual-022'), title(target));
check(result.after.cards.find(card => card.id === 'virtual-022')?.organizationId === target && Math.abs(result.after.scale - scale) < .002 && result.after.dialogs === 0, `${scale}: actual pointer Drop uses transformed screen coordinates`);
measurements.push({ name: `zoom-${scale}`, before: result.before, after: result.after }); current = target;
}
await page.getByRole('button', { name: 'Fit to View', exact: true }).click(); await settle();
await pan(48, 24);
const target = current === 'hanmac-office-0' ? 'hanmac-office-sibling' : 'hanmac-office-0';
const result = await drag(employee('virtual-022'), title(target));
check(result.after.cards.find(card => card.id === 'virtual-022')?.organizationId === target && result.after.scale === result.before.scale, 'Drop after Fit and Pan uses current transformed target');
await snap('zoom-pan-drop-complete');
});
for (const layout of ['lr', 'hybrid']) await section(`mode-${layout}`, async () => {
await setup({ layout });
const staff = await drag(employee('virtual-022'), title('hanmac-office-sibling'));
check(staff.after.layout === layout && staff.after.cards.find(card => card.id === 'virtual-022')?.organizationId === 'hanmac-office-sibling' && staff.after.feedback.status === 'success', `${layout}: actual employee Drag supported`);
const group = await drag(title('hanmac-office-0'), title('hanmac-office-1'), { inspect: true });
check(group.after.nodes.find(org => org.id === 'hanmac-office-0')?.parentId === 'hanmac-office-1' && group.after.feedback.status === 'success', `${layout}: actual group Drag supported`);
check(group.after.paths.some(path => path.id === 'hanmac-office-0' && path.parentId === 'hanmac-office-1'), `${layout}: moved group connector uses new parent`);
checkConsistency(group.after, layout); measurements.push({ name: `${layout}-moves`, staff: staff.after, group: group.after, groupDrag: group });
await snap(`${layout}-group-moved`);
});
await section('retained', async () => {
await setup();
await title('hanmac-office-sibling').click(); await ready();
const moved = await drag(employee('virtual-022'), title('hanmac-office-sibling'));
check(moved.after.feedback.status === 'success' && await title('hanmac-office-sibling').getAttribute('aria-expanded') === 'false' && await employee('virtual-022').count() === 0, 'Drop onto collapsed target updates relationship and retains collapsed view');
await title('hanmac-office-sibling').click(); await ready(20);
check((await state()).cards.find(card => card.id === 'virtual-022')?.organizationId === 'hanmac-office-sibling', 'Expanding target reveals moved employee once');
await title('hanmac-office-0').click(); await ready();
const group = await drag(title('hanmac-office-0'), title('hanmac-office-1'));
check(group.after.nodes.find(org => org.id === 'hanmac-office-0')?.parentId === 'hanmac-office-1' && await title('hanmac-office-0').getAttribute('aria-expanded') === 'false', 'Moving collapsed source group retains its collapsed state');
await title('hanmac-office-0').click(); await ready(20);
check((await state()).nodes.find(org => org.id === 'hanmac-cell-0')?.parentId === 'hanmac-office-0', 'Moved collapsed subtree is restored when source expands');
await chooseCompanies([]); await ready(0); await chooseCompanies(['한맥기술']); await ready(20);
const shown = await state(); check(shown.cards.find(card => card.id === 'virtual-022')?.organizationId === 'hanmac-office-sibling' && shown.nodes.find(org => org.id === 'hanmac-office-0')?.parentId === 'hanmac-office-1', 'Company hide/re-show retains employee and group edits without duplicate data');
checkConsistency(shown, 'Re-shown company');
await page.getByRole('button', { name: '초기화', exact: true }).click(); await ready(20);
const reset = await state(); check(reset.companyRoots.length === 7 && reset.nodes.find(org => org.id === 'hanmac-office-0')?.parentId === 'hanmac-division-0' && reset.feedback.status === 'idle', 'Reset regenerates original virtual data/settings and clears move feedback');
});
// A failed reset assertion can be checked without repeating successful
// collapsed-target/company-selection probes in the retained section.
await section('reset', async () => {
await setup(); await drag(title('hanmac-office-0'), title('hanmac-office-1'));
const edited = await state();
await page.getByRole('button', { name: '초기화', exact: true }).click(); await ready(20);
const reset = await state();
check(edited.nodes.find(org => org.id === 'hanmac-office-0')?.parentId === 'hanmac-office-1'
&& reset.companyRoots.length === 7 && reset.nodes.find(org => org.id === 'hanmac-office-0')?.parentId === 'hanmac-division-0' && reset.feedback.status === 'idle',
'Reset clears actual group edit, restores seven company roots and original membership, and clears feedback', { before: edited.feedback, after: reset.feedback, roots: reset.companyRoots });
});
await section('hybrid-cross-staff', async () => {
await setup({ companies: ['한맥기술', '삼안'], layout: 'hybrid' });
const staff = await drag(employee('virtual-022'), title('saman-office-0'), { inspect: true, screenshot: 'hybrid-cross-company-staff-hover' });
const card = staff.after.cards.find(card => card.id === 'virtual-022');
check(staff.hover.drop.find(target => target.id === 'saman-office-0')?.hover === 'allowed'
&& card?.organizationId === 'saman-office-0' && card.companyId === 'saman' && card.border[card.direction] === colors.saman && staff.after.feedback.status === 'success',
'hybrid: cross-company staff Drag updates organization, company and color', staff.after.feedback);
check(staff.after.nodes.find(org => org.id === 'hanmac-root').inclusive === 19 && staff.after.nodes.find(org => org.id === 'saman-root').inclusive === 21
&& staff.after.cards.length === 40 && new Set(staff.after.cards.map(card => card.id)).size === 40 && staff.after.dialogs === 0,
'hybrid: cross-company staff updates both company totals without duplication/click');
await employee('virtual-022').click(); await dialog().waitFor();
const company = await dialog().locator('dl > div').filter({ has: page.locator('dt', { hasText: '소속 회사' }) }).locator('dd').innerText();
const organization = await dialog().locator('dl > div').filter({ has: page.locator('dt', { hasText: '소속 조직' }) }).locator('dd').innerText();
check(company === '삼안' && organization === '가상 1실', 'Hybrid cross-company HM Dialog shows changed company and organization', { company, organization });
});
await section('cross-company', async () => {
for (const layout of ['tb', 'lr', 'hybrid']) {
await setup({ companies: ['한맥기술', '삼안'], layout });
let initial = await state();
if (layout !== 'hybrid') {
const staff = await drag(employee('virtual-022'), title('saman-office-0'), { inspect: true, screenshot: `${layout}-cross-company-staff-hover` });
const card = staff.after.cards.find(card => card.id === 'virtual-022');
check(staff.hover.drop.find(target => target.id === 'saman-office-0')?.hover === 'allowed'
&& card?.organizationId === 'saman-office-0' && card.companyId === 'saman' && card.border[card.direction] === colors.saman && staff.after.feedback.status === 'success', `${layout}: cross-company staff Drag updates organization, company and color`, staff.after.feedback);
check(staff.after.nodes.find(org => org.id === 'hanmac-root').inclusive === 19 && staff.after.nodes.find(org => org.id === 'saman-root').inclusive === 21
&& staff.after.cards.length === 40 && staff.after.dialogs === 0, `${layout}: cross-company staff updates both company totals without duplication/click`);
if (layout === 'tb') {
await employee('virtual-022').click(); await dialog().waitFor();
const company = await dialog().locator('dl > div').filter({ has: page.locator('dt', { hasText: '소속 회사' }) }).locator('dd').innerText();
const organization = await dialog().locator('dl > div').filter({ has: page.locator('dt', { hasText: '소속 조직' }) }).locator('dd').innerText();
check(company === '삼안' && organization === '가상 1실', 'Cross-company HM Dialog shows changed company and organization', { company, organization });
await page.keyboard.press('Escape'); await dialog().waitFor({ state: 'hidden' }); await settle();
}
initial = staff.after;
}
const subtree = new Set(['hanmac-office-0', 'hanmac-cell-0', 'hanmac-team-0', 'hanmac-team-sibling']);
const transferred = initial.nodes.find(org => org.id === 'hanmac-office-0').inclusive;
const subtreeEmployees = initial.cards.filter(card => subtree.has(card.organizationId)).map(card => card.id);
const group = await drag(title('hanmac-office-0'), title('saman-office-1'), { inspect: true, screenshot: `${layout}-cross-company-group-hover` });
check(group.hover.drop.find(target => target.id === 'saman-office-1')?.hover === 'allowed'
&& group.after.nodes.find(org => org.id === 'hanmac-office-0')?.parentId === 'saman-office-1' && group.after.feedback.status === 'success', `${layout}: cross-company group Drag changes real parent`, group.after.feedback);
check(group.after.nodes.filter(org => subtree.has(org.id)).every(org => org.companyId === 'saman')
&& subtreeEmployees.every(id => { const card = group.after.cards.find(card => card.id === id); return card?.companyId === 'saman' && subtree.has(card.organizationId) && card.border[card.direction] === colors.saman; })
&& group.after.nodes.find(org => org.id === 'hanmac-cell-0')?.parentId === 'hanmac-office-0', `${layout}: entire transferred subtree retains IDs/hierarchy and receives target company colors`);
check(group.after.nodes.find(org => org.id === 'hanmac-root').inclusive === initial.nodes.find(org => org.id === 'hanmac-root').inclusive - transferred
&& group.after.nodes.find(org => org.id === 'saman-root').inclusive === initial.nodes.find(org => org.id === 'saman-root').inclusive + transferred, `${layout}: cross-company group updates both company headcounts`);
checkConsistency(group.after, `${layout} cross-company`, 40);
measurements.push({ name: `${layout} cross-company transfers`, initial, groupDrag: group }); await snap(`${layout}-cross-company-complete`);
if (layout === 'tb') {
const samanCount = group.after.nodes.find(org => org.id === 'saman-root').inclusive;
const hanmacCount = group.after.nodes.find(org => org.id === 'hanmac-root').inclusive;
await chooseCompanies(['삼안']); await ready(samanCount); let shown = await state();
check(shown.cards.length === samanCount && shown.countText.includes(`합계 ${samanCount}명`) && shown.cards.some(card => card.id === 'virtual-022')
&& shown.nodes.find(org => org.id === 'hanmac-office-0')?.parentId === 'saman-office-1', 'Destination-only selection retains transferred employee/subtree and shows current destination headcount', shown.countText);
await chooseCompanies(['한맥기술']); await ready(hanmacCount); shown = await state();
check(shown.cards.length === hanmacCount && shown.countText.includes(`합계 ${hanmacCount}명`) && !shown.cards.some(card => card.id === 'virtual-022')
&& !shown.nodes.some(org => org.id === 'hanmac-office-0'), 'Source-only selection excludes transferred data and shows current source headcount', shown.countText);
await chooseCompanies(['한맥기술', '삼안']); await ready(40); shown = await state();
check(new Set(shown.cards.map(card => card.id)).size === 40 && shown.nodes.find(org => org.id === 'hanmac-office-0')?.parentId === 'saman-office-1', 'Both companies re-show preserves cross-company edits without duplicate cards');
}
}
});
await section('keyboard', async () => {
await setup(); await employee('virtual-022').focus(); await settle();
await page.keyboard.press('Alt+m'); await settle(); const opened = await state();
check(opened.dragging === 'true' && opened.drop.some(target => target.hover === 'allowed'), 'Alt+M starts keyboard movement with an allowed target');
const initialHover = opened.drop.find(target => target.hover)?.id;
await page.keyboard.press('ArrowRight'); await settle(); check((await state()).drop.find(target => target.hover)?.id !== initialHover, 'Keyboard Arrow changes target');
await page.keyboard.press('Escape'); await settle(); const cancelled = await state();
check(!cancelled.dragging && cancelled.feedback.status === 'cancelled' && cancelled.activeEmployee === 'virtual-022' && cancelled.cards.find(card => card.id === 'virtual-022')?.organizationId === 'hanmac-office-0', 'Escape cancels keyboard edit, retains data and returns source focus');
await page.keyboard.press('Alt+m'); await settle();
for (let i = 0; i < 30 && (await state()).drop.find(target => target.hover)?.id !== 'hanmac-office-sibling'; i++) { await page.keyboard.press('ArrowRight'); await settle(); }
check((await state()).drop.find(target => target.hover)?.id === 'hanmac-office-sibling', 'Keyboard selects intended same-company destination');
await page.keyboard.press('Enter'); await ready(20); const completed = await state();
check(completed.cards.find(card => card.id === 'virtual-022')?.organizationId === 'hanmac-office-sibling' && completed.feedback.status === 'success' && completed.dialogs === 0, 'Keyboard Enter commits movement without employee Dialog');
await title('hanmac-office-0').focus(); await settle(); await page.keyboard.press('Alt+m'); await settle();
for (let i = 0; i < 30 && (await state()).drop.find(target => target.hover)?.id !== 'hanmac-office-1'; i++) { await page.keyboard.press('ArrowDown'); await settle(); }
await page.keyboard.press('Enter'); await ready(20);
check((await state()).nodes.find(org => org.id === 'hanmac-office-0')?.parentId === 'hanmac-office-1', 'Keyboard group move changes parent through same policy');
await snap('keyboard-move-complete');
});
await section('fit-cross', async () => {
for (const layout of ['tb', 'lr', 'hybrid']) {
await setup({ companies: ['한맥기술', '삼안'], layout });
await drag(title('hanmac-office-0'), title('saman-office-1'));
await page.getByRole('button', { name: 'Fit to View', exact: true }).click(); await settle();
const fitted = await state();
check(fitted.feedback.status === 'success' && fitted.forest.x >= fitted.viewport.x - 1 && fitted.forest.right <= fitted.viewport.right + 1
&& fitted.forest.y >= fitted.viewport.y - 1 && fitted.forest.bottom <= fitted.viewport.bottom + 1,
`${layout}: Fit contains recalculated forest bounds after cross-company subtree transfer`, { viewport: fitted.viewport, forest: fitted.forest, scale: fitted.scale });
measurements.push({ name: `${layout} post-transfer Fit`, state: fitted }); await snap(`${layout}-cross-company-fit`);
}
});
check(errors.length === 0, 'New console/page errors: zero', errors);
const result = { startedAt, completedAt: new Date().toISOString(), browser: browser.version(), origin, sections: only, results, measurements, errors,
notVerified: ['Physical touch device', 'Actual screen reader'], pass: results.filter(item => item.status === 'PASS').length, fail: results.filter(item => item.status === 'FAIL').length };
await fs.writeFile(path.join(output, `browser-editing${suffix}.json`), JSON.stringify(result, null, 2));
console.log(JSON.stringify({ pass: result.pass, fail: result.fail, errors: errors.length, output: `test-results/org-chart-p9-3/browser-editing${suffix}.json` }));
await browser.close(); if (result.fail) process.exitCode = 1;
+100
View File
@@ -0,0 +1,100 @@
// Focused public Vanilla API and dynamic-option parity with React Story evidence.
import fs from 'node:fs/promises';
import path from 'node:path';
import { launchChromium } from '../../scripts/orgchart-browser-runtime.mjs';
const output = path.resolve('test-results/org-chart-p9-4');
const origin = (process.env.ORGCHART_CORE_ORIGIN ?? 'http://127.0.0.1:6074').replace(/\/$/, '');
const only = process.argv.slice(2), run = name => !only.length || only.includes(name), suffix = only.length ? `-${only.join('-')}` : '';
const results = [], errors = [], measurements = [], startedAt = new Date().toISOString();
await fs.mkdir(output, { recursive:true });
const check = (pass, name, detail) => { results.push({ name, status:pass ? 'PASS':'FAIL', ...(detail === undefined ? {} : { detail }) }); if (!pass) console.log('FAIL', name, JSON.stringify(detail)); };
const browser = await launchChromium({ headless:true }), page = await browser.newPage({ viewport:{ width:1440,height:1000 } });
page.setDefaultTimeout(15000);
page.on('pageerror', error => errors.push({ type:'pageerror', message:error.message }));
page.on('console', message => { if (message.type() === 'error') errors.push({ type:'console', message:message.text() }); });
const settle = () => page.evaluate(async () => { await document.fonts.ready; for (let i = 0; i < 8; i++) await new Promise(requestAnimationFrame); });
async function ready() { await page.waitForFunction(() => window.fixtureReady && window.chart?.getState().status === 'ready' && [...window.shadow.querySelectorAll('link[rel="stylesheet"]')].every(link => link.sheet)); await settle(); }
async function fresh(extra, mode = 'auto') {
await page.goto(`${origin}/test-results/org-chart-p9-4/vanilla.html`); await ready();
if (extra || mode !== 'auto') { await page.evaluate(({ extra, mode }) => window.mountChart(mode, extra ?? {}), { extra,mode }); await ready(); }
}
async function section(name, action) { if (!run(name)) return; try { await action(); } catch (error) { check(false, `${name}: execution completed`, { message:error.message,stack:error.stack }); await page.mouse.up().catch(() => {}); await page.screenshot({ path:path.join(output,`core-failure-${name}.png`) }).catch(() => {}); } }
const employee = id => page.locator(`.hm-orgchart-employee[data-employee-id="${id}"]`);
const requests = [
{ kind:'employee',sourceId:'virtual-022',targetOrganizationId:'hanmac-office-sibling' },
{ kind:'organization',sourceId:'hanmac-office-0',targetOrganizationId:'hanmac-office-1' },
{ kind:'employee',sourceId:'virtual-022',targetOrganizationId:'saman-office-0' },
{ kind:'organization',sourceId:'hanmac-office-0',targetOrganizationId:'saman-office-1' },
];
await section('api', async () => {
await fresh();
const before = await page.evaluate(() => ({ enabled:window.chart.getDragDropEnabled(), data:JSON.stringify(window.chart.getData()), loads:window.loads }));
check(before.enabled === false && before.loads === 1, 'Vanilla omitted option defaults OFF and loads source once', before.loads);
for (const request of requests) {
const rejection = await page.evaluate(request => window.chart.move(request), request);
check(!rejection.moved && rejection.code === 'EDITING_DISABLED' && await page.evaluate(data => JSON.stringify(window.chart.getData()) === data, before.data), `OFF public move rejects ${request.kind} to ${request.targetOrganizationId} without changing data`, rejection);
}
const pure = await page.evaluate(request => window.mod.applyOrgChartMove(window.fixture, request), requests[0]);
check(pure.moved, 'Pure data transformation remains compatible outside interactive OFF policy', pure.moved ? pure.request : pure);
const legacy = await page.evaluate(() => {
const original = JSON.stringify(window.template);
const total = window.mod.createOrgChartExampleDataset(window.template, { scope:'total',employeeCount:100 });
const perCompany = window.mod.createOrgChartExampleDataset(window.template, { scope:'per-company',employeeCount:20,companyIds:['hanmac','saman'] });
return { total:total.employees.length,totalValid:window.mod.validateOrgChartDataset(total).valid,perCompany:perCompany.employees.length,sourceUnchanged:JSON.stringify(window.template) === original };
});
check(legacy.total === 100 && legacy.totalValid && legacy.perCompany === 40 && legacy.sourceUnchanged, 'Public total scope remains compatible while Expanded uses company counts', legacy);
const toggle = await page.evaluate(() => {
const data = window.chart.getData(), forest = window.shadow.querySelector('.hm-orgchart-forest'), viewport = window.chart.getViewportState();
window.chart.setDragDropEnabled(true); const on = window.chart.getDragDropEnabled(); window.chart.setDragDropEnabled(false); const off = window.chart.getDragDropEnabled();
return { on,off,dataSame:data === window.chart.getData(),forestSame:forest === window.shadow.querySelector('.hm-orgchart-forest'),viewportSame:JSON.stringify(viewport) === JSON.stringify(window.chart.getViewportState()),loads:window.loads };
});
check(toggle.on && !toggle.off && toggle.dataSame && toggle.forestSame && toggle.viewportSame && toggle.loads === before.loads, 'Vanilla setter changes policy without data reload, DOM remount or viewport reset', toggle);
await page.evaluate(() => window.chart.setDragDropEnabled(true));
for (const request of requests) {
const moved = await page.evaluate(request => window.chart.move(request), request); await ready();
check(moved.moved, `ON public move permits ${request.kind} to ${request.targetOrganizationId}`, moved.moved ? moved.request : moved);
}
const after = await page.evaluate(() => {
const data = window.chart.getData(), before = JSON.stringify(data), forest = window.shadow.querySelector('.hm-orgchart-forest'); window.chart.setDragDropEnabled(false);
return { same:JSON.stringify(window.chart.getData()) === before,owned:data === window.chart.getData(),forestSame:forest === window.shadow.querySelector('.hm-orgchart-forest'),
unique:new Set(data.employees.map(employee => employee.id)).size === data.employees.length,total:data.employees.length,valid:window.mod.validateOrgChartDataset(data).valid,
employee:data.employees.find(employee => employee.id === 'virtual-022'),group:data.organizations.find(group => group.id === 'hanmac-office-0'),fixturePreserved:JSON.stringify(window.fixture) === window.fixtureOriginal,loads:window.loads };
});
check(after.same && after.owned && after.forestSame && after.unique && after.total === 40 && after.valid && after.fixturePreserved && after.loads === 1
&& after.employee.organizationId === 'saman-office-0' && after.group.parentId === 'saman-office-1', 'Vanilla OFF preserves completed cross-company moves, unique IDs and original fixture without reload', after);
await page.screenshot({ path:path.join(output,'vanilla-on-off-preserved.png'),fullPage:true }); measurements.push({ name:'public API',before,toggle,after });
});
await section('cancel', async () => {
await fresh({ dragDropEnabled:true }); await page.evaluate(() => { window.pointerId = undefined; window.shadow.querySelector('.hm-orgchart-viewport').addEventListener('pointerdown', event => { window.pointerId = event.pointerId; }, { capture:true }); });
const box = await employee('virtual-022').boundingBox();
await page.mouse.move(box.x + box.width/2,box.y + box.height/2); await page.mouse.down(); await page.mouse.move(box.x + box.width/2 + 12,box.y + box.height/2,{ steps:2 }); await settle();
const before = await page.evaluate(() => { const v = window.shadow.querySelector('.hm-orgchart-viewport'); return { dragging:v.dataset.editDragging,captured:v.hasPointerCapture(window.pointerId),data:JSON.stringify(window.chart.getData()) }; });
const after = await page.evaluate(() => { window.chart.setDragDropEnabled(false); const v = window.shadow.querySelector('.hm-orgchart-viewport'); return { dragging:v.dataset.editDragging,captured:v.hasPointerCapture(window.pointerId),highlight:window.shadow.querySelectorAll('[data-drop-state],[data-drop-hover],[data-edit-source]').length,data:JSON.stringify(window.chart.getData()) }; });
await page.mouse.up(); await settle();
check(before.dragging === 'true' && before.captured && !after.dragging && !after.captured && after.highlight === 0 && before.data === after.data, 'Vanilla live pointer OFF releases capture and clears all pending/highlight state', { before:{ ...before,data:undefined },after:{ ...after,data:undefined } });
check(await page.locator('.hm-orgchart-employee-dialog[open]').count() === 0 && await page.evaluate(data => JSON.stringify(window.chart.getData()) === data,before.data), 'Vanilla canceled pointer release neither commits nor opens Dialog');
await page.evaluate(() => window.chart.setDragDropEnabled(true)); await employee('virtual-022').focus(); await page.keyboard.press('Alt+m'); await settle();
const keyboardBefore = await page.evaluate(() => window.shadow.querySelector('.hm-orgchart-viewport').dataset.editDragging);
await page.evaluate(() => window.chart.setDragDropEnabled(false)); await page.keyboard.press('Enter'); await settle();
check(keyboardBefore === 'true' && await page.evaluate(data => !window.shadow.querySelector('[data-edit-dragging],[data-drop-state],[data-drop-hover]') && JSON.stringify(window.chart.getData()) === data,before.data), 'Vanilla active keyboard move is canceled and stale Enter cannot commit');
});
await section('default', async () => {
for (const [flag,expected] of [[undefined,false],[false,false],[true,true]]) {
await fresh(flag === undefined ? undefined : { dragDropEnabled:flag });
const actual = await page.evaluate(() => ({ value:window.chart.getDragDropEnabled(),attribute:window.shadow.querySelector('.hm-orgchart-viewport').dataset.dragDropEnabled,shortcuts:[...window.shadow.querySelectorAll('.hm-orgchart-employee,.hm-orgchart-expanded-toggle')].map(element => element.getAttribute('aria-keyshortcuts')) }));
check(actual.value === expected && actual.attribute === String(expected) && actual.shortcuts.every(value => expected ? value === 'Alt+M' : value === null), `Vanilla option ${String(flag)} matches React default/setter policy and keyboard affordances`, actual.value);
}
});
await section('fixed', async () => {
await fresh({ dragDropEnabled:true },'fixed');
const fixed = await page.evaluate(async () => {
let setterRejected = false; try { window.chart.setDragDropEnabled(true); } catch (error) { setterRejected = /Auto/.test(error.message); }
let moveRejected = false; try { await window.chart.move({ kind:'employee',sourceId:'emp-001',targetOrganizationId:'management-plan' }); } catch (error) { moveRejected = /Auto/.test(error.message); }
return { enabled:window.chart.getDragDropEnabled(),setterRejected,moveRejected,editingUI:window.shadow.querySelectorAll('[data-drag-drop-enabled],[data-edit-dragging],[data-drop-state],.hm-orgchart-move-feedback').length,loads:window.loads };
});
check(!fixed.enabled && fixed.setterRejected && fixed.moveRejected && fixed.editingUI === 0, 'Fixed ignores dragDropEnabled option, rejects Auto setter/move and adds no editing UI', fixed);
});
check(errors.length === 0,'Vanilla new console/page errors: zero',errors);
const result = { startedAt,completedAt:new Date().toISOString(),browser:browser.version(),origin,sections:only,results,measurements,errors,pass:results.filter(item => item.status === 'PASS').length,fail:results.filter(item => item.status === 'FAIL').length };
await fs.writeFile(path.join(output,`core${suffix}.json`),JSON.stringify(result,null,2)); await browser.close();
console.log(JSON.stringify({ pass:result.pass,fail:result.fail,output:`test-results/org-chart-p9-4/core${suffix}.json` })); if (result.fail) process.exitCode = 1;
@@ -0,0 +1,38 @@
// Compare protected source hashes to the captured pre-P9.4 state and one Fixed
// screen to existing P9.2 evidence. No full historical regression suite replay.
import fs from 'node:fs/promises';
import path from 'node:path';
import { createHash } from 'node:crypto';
import { launchChromium } from '../../scripts/orgchart-browser-runtime.mjs';
const output = path.resolve('test-results/org-chart-p9-4'), origin = (process.env.ORGCHART_STORY_ORIGIN ?? 'http://127.0.0.1:6064').replace(/\/$/, '');
const results = [],errors = [],startedAt = new Date().toISOString();
const check = (pass,name,detail) => { results.push({ name,status:pass ? 'PASS':'FAIL', ...(detail === undefined ? {} : { detail }) }); if (!pass) console.log('FAIL',name,JSON.stringify(detail)); };
const fixedOnly = process.argv.includes('--fixed-only');
const baseline = fixedOnly ? { files:[] } : JSON.parse(await fs.readFile(path.join(output,'baseline.json'),'utf8'));
const protectedPath = name => /^examples\/org-chart\/data.*\.json$/.test(name) || /^packages\/org-chart\/src\/assets\//.test(name)
|| ['packages/org-chart/src/company-color-presets.ts','packages/org-chart/src/core/style-presets.ts','packages/org-chart/src/core/employee-card.ts',
'packages/org-chart/src/styles/employee-card.css','packages/org-chart/src/core/workdesk-artwork.ts','packages/org-chart/src/core/workdesk-layout.ts',
'src/org-chart/center-composition-preview.ts','src/org-chart/org-chart-full-composition.stories.tsx','src/org-chart/org-chart-department-colors.stories.tsx',
'src/org-chart/org-chart-overview-internal.stories.tsx'].includes(name);
const protectedFiles = baseline.files.filter(file => protectedPath(file.path));
const changed = [];
for (const file of protectedFiles) { const actual = createHash('sha256').update(await fs.readFile(path.resolve(file.path))).digest('hex').toUpperCase(); if (actual !== file.sha256.toUpperCase()) changed.push({ path:file.path,before:file.sha256,after:actual }); }
if (!fixedOnly) check(protectedFiles.length > 20 && changed.length === 0,'Pre-P9.4 fixtures, approved SVG/images, card renderer/styles, company/department presets and Fixed source hashes preserved',{ checked:protectedFiles.length,changed });
const browser = await launchChromium({ headless:true }), page = await browser.newPage({ viewport:{ width:1440,height:900 } });
page.on('pageerror',error => errors.push(error.message)); page.on('console',message => { if (message.type() === 'error') errors.push(message.text()); });
await page.goto(`${origin}/iframe.html?id=orgchart--full-composition&viewMode=story`);
await page.waitForFunction(() => { const shadow = document.querySelector('.hm-orgchart-host')?.shadowRoot; return shadow?.querySelector('.hm-orgchart-stage[data-workdesk-artwork]') && !shadow.querySelector('[aria-busy="true"]') && [...shadow.querySelectorAll('link[rel="stylesheet"]')].every(link => link.sheet); });
await page.evaluate(async () => { await document.fonts.ready; await Promise.all([...document.querySelector('.hm-orgchart-host').shadowRoot.querySelectorAll('img')].map(async image => { image.loading = 'eager'; await image.decode(); })); for (let i = 0; i < 12; i++) await new Promise(requestAnimationFrame); });
const priorSource = await fs.readFile('examples/org-chart/verify-p9-2-composition.mjs','utf8');
const from = priorSource.indexOf('async function snapshot()'),to = priorSource.indexOf('async function stability()',from);
const snapshot = await new Function('page',priorSource.slice(from,to) + '; return snapshot();')(page);
const previous = JSON.parse(await fs.readFile('test-results/org-chart-p9-2/browser-composition.json','utf8')).snapshots['1440'];
const same = (a,b) => JSON.stringify(a) === JSON.stringify(b);
const normalizedEqual = snapshot.elements.every((element,index) => { const old = previous.elements[index]; return old?.key === element.key && old.hash === element.hash && same(old.style,element.style) && Object.keys(element.box).every(key => Math.abs(element.box[key] - old.box[key]) < .15); });
check(previous.htmlHash === snapshot.htmlHash && same(previous.assets,snapshot.assets) && normalizedEqual,'Fixed DOM, 139 asset hashes and 152 styles/geometry match preserved P9.2 evidence',{ previous:previous.htmlHash,current:snapshot.htmlHash,elements:snapshot.elements.length,assets:snapshot.assets.length });
check(Math.abs(snapshot.centerDifference) < 1 && snapshot.collisions.length === 0 && snapshot.counts.cards === 43 && snapshot.counts.paths === 5,'Fixed minimal center/cards/connectors regression',{ centerDifference:snapshot.centerDifference,counts:snapshot.counts,collisions:snapshot.collisions });
check(await page.evaluate(() => !document.querySelector('.hm-orgchart-host').shadowRoot.querySelector('[data-drag-drop-enabled],[data-edit-dragging],[data-drop-state],.hm-orgchart-move-feedback')),'Fixed has no ON/OFF or editing UI');
await page.screenshot({ path:path.join(output,'fixed-preserved-1440.png'),fullPage:true });
check(errors.length === 0,'Preservation browser console/page errors: zero',errors);
const result = { startedAt,completedAt:new Date().toISOString(),origin,results,errors,protectedFiles:protectedFiles.map(file => file.path),fixedSnapshot:snapshot,pass:results.filter(item => item.status === 'PASS').length,fail:results.filter(item => item.status === 'FAIL').length };
await fs.writeFile(path.join(output,'fixed-preservation.json'),JSON.stringify(result,null,2)); await browser.close(); console.log(JSON.stringify({ pass:result.pass,fail:result.fail })); if (result.fail) process.exitCode = 1;
+447
View File
@@ -0,0 +1,447 @@
// Focused P9.4 Storybook deltas. Run named sections to repair only failed probes.
// Existing P7-P9.3 evidence is preserved and is not replayed by this harness.
import fs from 'node:fs/promises';
import { writeFileSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { createHash } from 'node:crypto';
import { launchChromium } from '../../scripts/orgchart-browser-runtime.mjs';
const root = fileURLToPath(new URL('../../', import.meta.url));
const output = path.join(root, 'test-results/org-chart-p9-4');
const origin = (process.env.ORGCHART_STORY_ORIGIN ?? 'http://127.0.0.1:6064').replace(/\/$/, '');
const only = process.argv.slice(2), suffix = only.length ? `-${only.join('-')}` : '';
const results = [], measurements = [], errors = [], startedAt = new Date().toISOString();
await fs.mkdir(output, { recursive: true });
const persist = () => writeFileSync(path.join(output, `browser-progress${suffix}.json`), JSON.stringify({ startedAt, results, measurements, errors }, null, 2));
const check = (pass, name, detail) => {
results.push({ name, status: pass ? 'PASS' : 'FAIL', ...(detail === undefined ? {} : { detail }) }); persist();
if (!pass) console.log('FAIL', name, JSON.stringify(detail));
};
const browser = await launchChromium({ headless: true });
const page = await browser.newPage({ viewport: { width: 1920, height: 1700 } });
page.setDefaultTimeout(20000);
page.on('pageerror', error => { errors.push({ type: 'pageerror', message: error.message }); persist(); });
page.on('console', message => { if (message.type() === 'error') { errors.push({ type: 'console', message: message.text(), location: message.location() }); persist(); } });
const viewport = () => page.locator('.hm-orgchart-viewport');
const employee = id => page.locator(`.hm-orgchart-employee[data-employee-id="${id}"]`);
const title = id => page.locator(`.hm-orgchart-expanded-toggle[data-organization-id="${id}"]`);
const companyTrigger = () => page.getByRole('button', { name: /^회사 선택/ });
const companyPanel = () => page.getByRole('dialog', { name: '회사 선택', exact: true });
const companyCheckbox = name => companyPanel().getByRole('checkbox', { name, exact: true });
const dialog = () => page.locator('[data-component="HMOrgChartEmployeeDialog"]');
const editSwitch = () => page.getByRole('switch', { name: /Drag & Drop|조직 이동/ });
const settle = () => page.evaluate(async () => { await document.fonts.ready; for (let i = 0; i < 8; i++) await new Promise(requestAnimationFrame); });
async function ready(count) {
await page.waitForFunction(count => {
const shadow = document.querySelector('.hm-orgchart-host,.hm-orgchart-component-host')?.shadowRoot;
return shadow && !shadow.querySelector('[aria-busy="true"]') && [...shadow.querySelectorAll('link[rel="stylesheet"]')].every(link => link.sheet)
&& (count === undefined || shadow.querySelectorAll('.hm-orgchart-employee').length === count);
}, count, { timeout: 45000 }); await settle();
}
async function select(label, option) {
await page.getByRole('combobox', { name: label, exact: true }).click();
await page.getByRole('option', { name: option, exact: true }).click(); await ready();
}
async function countPerCompany(count, expected) {
await select('회사당 직원 수', `${count}명`);
const apply = page.getByRole('button', { name: '데이터 적용', exact: true });
if (await apply.count() && await apply.isEnabled()) await apply.click();
await ready(expected);
}
async function chooseCompanies(names) {
await companyTrigger().click(); await companyPanel().waitFor();
const all = companyCheckbox('전체 선택');
for (let i = 0; i < 2 && await all.getAttribute('aria-checked') !== 'false'; i++) { await all.click(); await ready(); }
await ready(0);
for (const name of names) { await companyCheckbox(name).click(); await ready(); }
await page.keyboard.press('Escape'); await companyPanel().waitFor({ state: 'hidden' }); await ready();
}
async function toggle(enabled, { programmatic = false } = {}) {
const control = editSwitch();
if (await control.getAttribute('aria-checked') !== String(enabled)) {
// A live pointer must remain down while an external settings change cancels
// it. DOM click is the same React handler and does not synthesize pointerup.
if (programmatic) await control.evaluate(element => element.click()); else await control.click();
}
await settle();
}
async function setup({ companies = ['한맥기술'], layout = 'tb', enabled = false } = {}) {
await page.setViewportSize({ width: 1920, height: 1700 });
await page.goto(`${origin}/iframe.html?id=orgchart--expanded-org-chart&viewMode=story`);
await companyTrigger().waitFor(); await ready(140);
await chooseCompanies(companies); await ready(companies.length * 20);
if (layout !== 'tb') await select('조직도 표시 유형', layout === 'lr' ? '가로형 LR' : '혼합형 Hybrid');
await toggle(enabled); await viewport().scrollIntoViewIfNeeded();
await page.getByRole('button', { name: 'Fit to View', exact: true }).click(); await settle();
}
async function state() {
return page.evaluate(() => {
const shadow = document.querySelector('.hm-orgchart-host').shadowRoot;
const viewport = shadow.querySelector('.hm-orgchart-viewport'), expanded = shadow.querySelector('.hm-orgchart-expanded');
const rect = element => { const r = element.getBoundingClientRect(); return { x: r.x, y: r.y, width: r.width, height: r.height, right: r.right, bottom: r.bottom }; };
const nodes = [...shadow.querySelectorAll('.node[data-organization-id]')].map(element => ({
id: element.dataset.organizationId, parentId: element.dataset.parentId, companyId: element.dataset.companyId,
direct: Number(element.dataset.directEmployeeCount), inclusive: Number(element.dataset.inclusiveEmployeeCount), collapsed: element.dataset.collapsed,
}));
const cards = [...shadow.querySelectorAll('.hm-orgchart-employee')].map(element => ({
id: element.dataset.employeeId, organizationId: element.closest('.node').dataset.organizationId, companyId: element.dataset.companyId,
direction: element.dataset.connectionDirection, kind: element.dataset.cardKind,
border: { top: getComputedStyle(element).borderTopColor, left: getComputedStyle(element).borderLeftColor, right: getComputedStyle(element).borderRightColor },
}));
const feedback = shadow.querySelector('.hm-orgchart-move-feedback');
return { layout: expanded?.dataset.autoLayout, scale: Number(viewport.dataset.scale), panX: Number(viewport.dataset.panX), panY: Number(viewport.dataset.panY),
viewport: rect(viewport), forest: rect(shadow.querySelector('.hm-orgchart-forest')), nodes, cards,
feedback: { status: feedback?.dataset.moveStatus, text: feedback?.textContent },
drop: [...shadow.querySelectorAll('[data-drop-state]')].map(element => ({ id: element.dataset.organizationId, state: element.dataset.dropState, hover: element.dataset.dropHover })),
dragging: viewport.dataset.editDragging, panDragging: viewport.dataset.dragging, activeEmployee: shadow.activeElement?.dataset.employeeId,
activeOrganization: shadow.activeElement?.dataset.organizationId, dragDropEnabled: viewport.dataset.dragDropEnabled,
countText: document.querySelector('[data-component="OrgChartExampleCounts"]')?.textContent.trim(), dialogs: document.querySelectorAll('[data-component="HMOrgChartEmployeeDialog"]').length,
companyRoots: nodes.filter(org => org.parentId === '').map(org => org.companyId), documentWidth: document.documentElement.scrollWidth,
};
});
}
const relations = s => JSON.stringify({ nodes: s.nodes.map(({ id, parentId, companyId }) => ({ id, parentId, companyId })), cards: s.cards.map(({ id, organizationId, companyId }) => ({ id, organizationId, companyId })) });
async function blankPoint() {
return page.evaluate(() => {
const shadow = document.querySelector('.hm-orgchart-host').shadowRoot, viewport = shadow.querySelector('.hm-orgchart-viewport'), box = viewport.getBoundingClientRect();
for (const [dx, dy] of [[8, 8], [8, box.height - 8], [box.width - 8, 8], [box.width - 8, box.height - 8], [box.width / 2, box.height - 8]]) {
const point = { x: box.left + dx, y: box.top + dy }, hit = shadow.elementFromPoint(point.x, point.y);
if (hit && !hit.closest('button,a,.hm-orgchart-employee,.node')) return point;
} throw new Error('No visible blank Pan point found');
});
}
async function pan(dx, dy) {
const p = await blankPoint(); await page.mouse.move(p.x, p.y); await page.mouse.down(); const v = await viewport().boundingBox();
await page.mouse.move(p.x + Math.sign(v.x + v.width / 2 - p.x) * 8, p.y, { steps: 2 });
await page.mouse.move(p.x + dx, p.y + dy, { steps: 8 }); await page.mouse.up(); await settle();
}
async function centerPair(source, target) {
await viewport().scrollIntoViewIfNeeded(); await source.focus(); await settle();
for (let pass = 0; pass < 3; pass++) {
const a = await source.boundingBox(), b = await target.boundingBox(), v = await viewport().boundingBox();
if (!a || !b || !v) throw new Error('Drag source/target has no geometry');
const left = Math.min(a.x, b.x), right = Math.max(a.x + a.width, b.x + b.width), top = Math.min(a.y, b.y), bottom = Math.max(a.y + a.height, b.y + b.height);
if (right - left > v.width - 24 || bottom - top > v.height - 24) throw new Error('Drag pair exceeds viewport at current scale');
const dx = v.x + v.width / 2 - (left + right) / 2, dy = v.y + v.height / 2 - (top + bottom) / 2;
if (Math.abs(dx) < 1 && Math.abs(dy) < 1) break; await pan(dx, dy);
}
}
async function drag(source, target, beforeRelease) {
await centerPair(source, target); const a = await source.boundingBox(), b = await target.boundingBox(), before = await state();
await page.mouse.move(a.x + a.width / 2, a.y + a.height / 2); await page.mouse.down();
await page.mouse.move(a.x + a.width / 2 + 8, a.y + a.height / 2, { steps: 2 });
await page.mouse.move(b.x + b.width / 2, b.y + b.height / 2, { steps: 14 }); const hover = await state();
if (beforeRelease) await beforeRelease();
await page.mouse.up(); await ready(); return { before, hover, after: await state() };
}
async function snap(name, fullPage = false) { await page.screenshot({ path: path.join(output, `${name}.png`), fullPage }); }
async function section(name, action) {
if (only.length && !only.includes(name)) return;
try { await action(); } catch (error) { check(false, `${name}: execution completed`, { message: error.message, stack: error.stack }); await page.mouse.up().catch(() => {}); await snap(`failure-${name}`, true).catch(() => {}); }
}
function counts(s, companies, perCompany, name) {
const grouped = Object.fromEntries(companies.map(id => [id, s.cards.filter(card => card.companyId === id).length]));
check(s.cards.length === companies.length * perCompany && new Set(s.cards.map(card => card.id)).size === s.cards.length
&& new Set(s.nodes.map(org => org.id)).size === s.nodes.length && Object.values(grouped).every(count => count === perCompany), `${name}: unique IDs and exact per-company employee counts`, grouped);
check(s.companyRoots.length === companies.length && s.nodes.filter(org => org.parentId === '').every(org => org.inclusive === perCompany), `${name}: root totals count organizational leaders exactly once`);
check(s.countText?.includes(`합계 ${(companies.length * perCompany).toLocaleString('ko-KR')}명`), `${name}: displayed total matches Employee IDs`, s.countText);
}
await section('department', async () => {
await page.setViewportSize({ width: 1440, height: 1000 });
await page.goto(`${origin}/iframe.html?id=orgchart--department-colors&viewMode=story`); await ready();
await page.evaluate(async () => { await Promise.all([...document.querySelector('.hm-orgchart-component-host').shadowRoot.querySelectorAll('img')].map(async image => { image.loading = 'eager'; await image.decode(); })); });
const department = await page.evaluate(() => {
const shadow = document.querySelector('.hm-orgchart-component-host').shadowRoot, root = shadow.querySelector('.hm-orgchart-department-colors');
const cards = [...root.querySelectorAll('[data-card-asset-size="representative"]')], card = cards[0];
const styles = element => Object.fromEntries(['fontFamily','fontSize','fontWeight','lineHeight','letterSpacing','color'].map(key => [key, getComputedStyle(element)[key]]));
const renderedCard = element => ({ id: element?.dataset.employeeId, width: element?.offsetWidth, height: element?.offsetHeight,
role: element?.querySelector('.hm-orgchart-person-role')?.textContent, name: element?.querySelector('.hm-orgchart-person-name')?.textContent,
badgeCount: element?.querySelectorAll('.hm-orgchart-job-title,.hm-orgchart-status-label').length,
background: element?.querySelector('.hm-orgchart-card-background')?.src, photo: element?.querySelector('.hm-orgchart-avatar img')?.src,
roleStyle: element ? styles(element.querySelector('.hm-orgchart-person-role')) : {}, nameStyle: element ? styles(element.querySelector('.hm-orgchart-person-name')) : {} });
const rows = [...root.querySelectorAll('.hm-orgchart-color-preview')].map(row => ({ title: row.querySelector('.hm-orgchart-color-department-name')?.textContent,
gradient: getComputedStyle(row.querySelector('.hm-orgchart-color-department-name')).backgroundImage,
color: getComputedStyle(row.querySelector('.hm-orgchart-color-department-name')).color,
cards: [...row.querySelectorAll('.hm-orgchart-employee')].map(card => ({ kind: card.dataset.cardKind, width: card.offsetWidth, height: card.offsetHeight, asset: card.dataset.cardAsset })) }));
const unwanted = root.querySelectorAll('.hm-orgchart-color-legend,.hm-orgchart-contrast-note,.hm-orgchart-contrast-comparison,.hm-orgchart-card-caption,.hm-orgchart-color-preview > p').length;
const frame = root.querySelector('.tunnel-reference__sample'), stage = frame?.querySelector('.tunnel-reference__stage');
return { representativeCount: cards.length, card: renderedCard(card), rows, unwanted,
frameStyle: frame ? Object.fromEntries(['backgroundColor','border','borderRadius','boxShadow','padding','overflow'].map(key => [key, getComputedStyle(frame)[key]])) : {},
stageStyle: stage ? Object.fromEntries(['padding','overflowX','maxWidth'].map(key => [key, getComputedStyle(stage)[key]])) : {},
representativeFirst: Boolean(card && root.firstElementChild?.contains(card)), documentWidth: document.documentElement.scrollWidth };
});
check(department.representativeCount === 1 && department.card.width === 146 && department.card.height === 74, 'Department representative: one shared 146x74 card', department.card);
check(department.card.role === '대표이사' && department.card.name === '홍 길 동' && department.card.badgeCount === 0 && /Noto Sans KR/.test(department.card.nameStyle.fontFamily), 'Department representative: approved text, font and zero badges');
check(department.representativeFirst && department.rows.length === 6 && department.rows.every(row => row.cards.length === 2 && row.cards[0].kind === 'leader' && row.cards[0].width === 146 && row.cards[0].height === 68 && row.cards[1].width === 108 && row.cards[1].height === 52), 'Department order: representative, six group titles, large leaders and small employees');
check(department.unwanted === 0, 'Department compact preview retains no legends, contrast/debug prose or duplicate card captions');
await snap('department-representative'); await snap('department-full', true);
await page.goto(`${origin}/iframe.html?id=orgchart--full-composition&viewMode=story`); await ready();
const fixedCard = await page.locator('[data-card-asset-size="representative"]').evaluate(element => {
const styles = target => Object.fromEntries(['fontFamily','fontSize','fontWeight','lineHeight','letterSpacing','color'].map(key => [key, getComputedStyle(target)[key]]));
return { id: element.dataset.employeeId, background: element.querySelector('.hm-orgchart-card-background')?.src, photo: element.querySelector('.hm-orgchart-avatar img')?.src,
roleStyle: styles(element.querySelector('.hm-orgchart-person-role')), nameStyle: styles(element.querySelector('.hm-orgchart-person-name')) };
});
check(['id','background','photo','roleStyle','nameStyle'].every(key => JSON.stringify(fixedCard[key]) === JSON.stringify(department.card[key])), 'Department and Fixed representative reuse identical SVG, avatar and typography', { department: department.card, fixed: fixedCard });
const prior = JSON.parse(await fs.readFile(path.join(root, 'test-results/org-chart-p9-1/browser.json'), 'utf8')).snapshots?.department?.['1440'];
if (prior?.departments) check(department.rows.every((row, i) => row.gradient === prior.departments[i].headingStyle.backgroundImage && row.color === prior.departments[i].headingStyle.color), 'Department approved group gradients and white titles preserve P9.1 baseline');
else check(false, 'Department historical approved gradient evidence exists', 'Check P9.1 browser.json snapshot structure');
await page.goto(`${origin}/iframe.html?id=center-graphic-facility--tunnel&viewMode=story`); await page.locator('.tunnel-reference__sample').first().waitFor(); await settle();
const reference = await page.locator('.tunnel-reference__sample').first().evaluate(frame => {
const stage = frame.querySelector('.tunnel-reference__stage');
return { frameStyle: Object.fromEntries(['backgroundColor','border','borderRadius','boxShadow','padding','overflow'].map(key => [key, getComputedStyle(frame)[key]])),
stageStyle: Object.fromEntries(['padding','overflowX','maxWidth'].map(key => [key, getComputedStyle(stage)[key]])) };
});
check(JSON.stringify(reference.frameStyle) === JSON.stringify(department.frameStyle) && JSON.stringify(reference.stageStyle) === JSON.stringify(department.stageStyle), 'Department shares actual Tunnel preview frame, background, border, radius, shadow, padding and overflow', { department: { frameStyle: department.frameStyle, stageStyle: department.stageStyle }, reference });
measurements.push({ name: 'Department', department, fixedCard, reference });
});
await section('department-names', async () => {
// User's later naming correction only; keep every prior P9.4 PASS as evidence.
const previous = JSON.parse(await fs.readFile(path.join(output, 'browser.json'), 'utf8')).measurements.find(item => item.name === 'Department').department;
const oldStyles = JSON.parse(await fs.readFile(path.join(root, 'test-results/org-chart-p9-1/browser.json'), 'utf8')).snapshots.department['1440'].departments;
const fixture = JSON.parse(await fs.readFile(path.join(root, 'examples/org-chart/data-p1.json'), 'utf8'));
await page.setViewportSize({ width:1440, height:1000 });
await page.goto(`${origin}/iframe.html?id=orgchart--department-colors&viewMode=story`); await ready();
await page.evaluate(async () => { await Promise.all([...document.querySelector('.hm-orgchart-component-host').shadowRoot.querySelectorAll('img')].map(async image => { image.loading = 'eager'; await image.decode(); })); });
const current = await page.evaluate(async () => {
const shadow = document.querySelector('.hm-orgchart-component-host').shadowRoot, root = shadow.querySelector('.hm-orgchart-department-colors');
const hash = async data => [...new Uint8Array(await crypto.subtle.digest('SHA-256', data))].map(byte => byte.toString(16).padStart(2,'0')).join('');
const keys = ['fontFamily','fontSize','fontWeight','lineHeight','letterSpacing','color'];
const styles = element => Object.fromEntries(keys.map(key => [key,getComputedStyle(element)[key]]));
const card = async element => ({ id:element.dataset.employeeId,kind:element.dataset.cardKind,asset:element.dataset.cardAsset,width:element.offsetWidth,height:element.offsetHeight,
name:element.querySelector('.hm-orgchart-person-name')?.textContent,role:element.querySelector('.hm-orgchart-person-role')?.textContent,
badges:[...element.querySelectorAll('.hm-orgchart-job-title')].map(badge => badge.textContent),
roleStyle:styles(element.querySelector('.hm-orgchart-person-role')),nameStyle:styles(element.querySelector('.hm-orgchart-person-name')),
assets:await Promise.all([...element.querySelectorAll('img')].map(async image => ({ hash:await hash(await (await fetch(image.currentSrc || image.src)).arrayBuffer()),naturalWidth:image.naturalWidth,naturalHeight:image.naturalHeight,loaded:image.complete && image.naturalWidth > 0 }))) });
return { cards:await Promise.all([...root.querySelectorAll('.hm-orgchart-employee')].map(card)),
rows:await Promise.all([...root.querySelectorAll('.hm-orgchart-color-preview')].map(async row => ({ title:row.querySelector('.hm-orgchart-color-department-name').textContent,
gradient:getComputedStyle(row.querySelector('.hm-orgchart-color-department-name')).backgroundImage,color:getComputedStyle(row.querySelector('.hm-orgchart-color-department-name')).color,
cards:await Promise.all([...row.querySelectorAll('.hm-orgchart-employee')].map(card)) }))) };
});
check(current.cards.length === 13 && current.cards.every(card => card.name === '홍 길 동'), 'Department naming correction: all thirteen displayed names are 홍 길 동', current.cards.map(({ id,name }) => ({ id,name })));
const rolePreserved = current.cards.every(card => {
const person = fixture.employees.find(employee => employee.id === card.id);
const expectedRole = card.kind === 'topLeader' ? '대표이사' : person?.position.replace(/\s+/gu,'');
const expectedBadges = card.kind === 'topLeader' ? [] : person?.jobTitle ? [person.jobTitle] : [];
return Boolean(person) && card.role === expectedRole && JSON.stringify(card.badges) === JSON.stringify(expectedBadges);
});
check(rolePreserved && new Set(current.cards.map(card => card.id)).size === 13, 'Department naming correction preserves fixture Employee IDs, positions and organizational badges');
check(current.rows.length === previous.rows.length && current.rows.every((row,index) => {
const old = previous.rows[index]; return row.title === old.title && row.gradient === old.gradient && row.color === old.color
&& row.cards.length === old.cards.length && row.cards.every((card,i) => ['kind','width','height','asset'].every(key => card[key] === old.cards[i][key]));
}), 'Department naming correction preserves P9.4 group titles, colors, gradients, card sizes and artwork mappings');
const styleKeys = ['fontFamily','fontSize','fontWeight','lineHeight','letterSpacing','color'];
check(current.rows.every((row,index) => row.cards.every((card,i) => {
const old = oldStyles[index].cards[i];
const role = old.subtree.find(item => item.className === 'hm-orgchart-person-role')?.style, name = old.subtree.find(item => item.className === 'hm-orgchart-person-name')?.style;
return styleKeys.every(key => card.roleStyle[key] === role?.[key] && card.nameStyle[key] === name?.[key]) && JSON.stringify(card.assets) === JSON.stringify(old.assets);
})), 'Department naming correction preserves approved card SVG/photo hashes and typography from P9.1');
const baseline = JSON.parse(await fs.readFile(path.join(output, 'baseline.json'), 'utf8'));
const safe = baseline.files.filter(file => /^examples\/org-chart\/data.*\.json$/.test(file.path) || /^packages\/org-chart\/src\/assets\//.test(file.path)
|| ['packages/org-chart/src/core/employee-card.ts','packages/org-chart/src/styles/employee-card.css','packages/org-chart/src/core/org-chart-content.ts','packages/org-chart/src/core/render.ts','src/org-chart/org-chart-full-composition.stories.tsx'].includes(file.path));
const changed = [];
for (const file of safe) { const hash = createHash('sha256').update(await fs.readFile(path.join(root,file.path))).digest('hex'); if (hash.toUpperCase() !== file.sha256.toUpperCase()) changed.push(file.path); }
check(changed.length === 0 && safe.length > 20, 'Department naming correction leaves original fixtures, shared card renderer/styles, Fixed presenter and approved assets unchanged', { checked:safe.length,changed });
const representative = current.cards.find(card => card.kind === 'topLeader');
check(representative.width === previous.card.width && representative.height === previous.card.height && representative.badges.length === 0
&& JSON.stringify(representative.nameStyle) === JSON.stringify(previous.card.nameStyle) && JSON.stringify(representative.roleStyle) === JSON.stringify(previous.card.roleStyle), 'Department representative retains 146x74, zero badges and P9.4 typography');
await snap('department-names',true); measurements.push({ name:'Department naming correction',current });
});
await section('data', async () => {
await setup();
check(await page.getByRole('combobox', { name: '데이터 범위', exact: true }).count() === 0 && !await page.getByText('전체 회사 합산', { exact: true }).count(), 'Expanded removes both data-scope choices');
counts(await state(), ['hanmac'], 20, 'one company x20');
for (const count of [100, 300]) { await countPerCompany(count, count); counts(await state(), ['hanmac'], count, `one company x${count}`); }
await countPerCompany(100, 100); await chooseCompanies(['한맥기술', '삼안']); await ready(200);
counts(await state(), ['hanmac', 'saman'], 100, 'two companies x100'); await snap('expanded-scope-removed', true);
await companyTrigger().click(); await companyPanel().waitFor(); await companyCheckbox('전체 선택').click(); await ready(700);
await page.keyboard.press('Escape'); await companyPanel().waitFor({ state: 'hidden' }); await countPerCompany(300, 2100);
const allIds = ['hanmac','saman','jangheon-industry','jangheon','ptc','halla','baron'];
counts(await state(), allIds, 300, 'seven companies x300'); measurements.push({ name: 'seven x300', state: await state() });
await chooseCompanies([]); await ready(0); const empty = await state();
check(empty.cards.length === 0 && empty.companyRoots.length === 0 && empty.countText.includes('0개 회사') && await page.locator('.hm-orgchart-expanded-empty').count() === 1, 'Zero selected companies renders explicit empty state');
await snap('expanded-empty'); await chooseCompanies(['한맥기술']); await ready(300); counts(await state(), ['hanmac'], 300, 'selection after empty');
});
await section('dropdown', async () => {
await setup(); await chooseCompanies(['한맥기술']); await companyTrigger().focus(); await page.keyboard.press('Enter'); await companyPanel().waitFor();
check(await companyCheckbox('전체 선택').evaluate(element => element === document.activeElement), 'Company dropdown keyboard opening focuses select-all');
await page.keyboard.press('Tab'); await page.keyboard.press('Tab'); await page.keyboard.press('Space'); await ready(40);
check(await companyCheckbox('삼안').getAttribute('aria-checked') === 'true' && await companyCheckbox('전체 선택').getAttribute('aria-checked') === 'mixed', 'Company keyboard Space selects second company and mixed state');
await snap('company-dropdown'); await page.keyboard.press('Escape'); await companyPanel().waitFor({ state: 'hidden' });
check(await companyTrigger().evaluate(element => element === document.activeElement), 'Company ESC closes and restores trigger focus');
await companyTrigger().click(); await companyPanel().waitFor(); await page.locator('.sb-page-title').click(); await companyPanel().waitFor({ state: 'hidden' });
check(true, 'Company outside click closes existing HM Popover');
});
await section('off', async () => {
await setup({ companies: ['한맥기술', '삼안'] });
check(await editSwitch().getAttribute('aria-checked') === 'false', 'Drag & Drop defaults OFF'); await snap('drag-drop-off', true);
for (const [name, source, target] of [
['employee within company', employee('virtual-022'), title('hanmac-office-sibling')],
['group within company', title('hanmac-office-0'), title('hanmac-office-1')],
['employee across companies', employee('virtual-022'), title('saman-office-0')],
['group across companies', title('hanmac-office-0'), title('saman-office-1')],
]) {
const result = await drag(source, target);
check(relations(result.before) === relations(result.after) && !result.hover.dragging && result.hover.drop.length === 0 && result.after.drop.length === 0, `OFF blocks ${name} and all Drop highlights`);
}
for (const source of [employee('virtual-022'), title('hanmac-office-0')]) {
await source.focus(); const before = await state(); await page.keyboard.press('Alt+m'); await settle(); const after = await state();
check(!after.dragging && after.drop.length === 0 && relations(after) === relations(before), 'OFF blocks Alt+M on employee/group without highlighting');
}
await viewport().scrollIntoViewIfNeeded(); const before = await state(); await pan(70, -35); const after = await state();
check(Math.abs(after.panX - before.panX - 70) < 1 && Math.abs(after.panY - before.panY + 35) < 1 && relations(before) === relations(after), 'OFF preserves blank-space Pan');
await title('hanmac-office-sibling').click(); await ready(); check(await title('hanmac-office-sibling').getAttribute('aria-expanded') === 'false', 'OFF group click collapses');
await title('hanmac-office-sibling').press('Space'); await ready(40); check(await title('hanmac-office-sibling').getAttribute('aria-expanded') === 'true', 'OFF group Space expands');
await employee('virtual-022').click(); await dialog().waitFor(); check(await dialog().getAttribute('data-employee-id') === 'virtual-022', 'OFF employee click opens HM Dialog');
await page.keyboard.press('Escape'); await dialog().waitFor({ state: 'hidden' }); await settle();
check((await state()).activeEmployee === 'virtual-022', 'OFF Dialog closes and restores employee focus');
const zoomBefore = await state(); await page.getByRole('button', { name: '확대', exact: true }).click(); await settle(); check((await state()).scale > zoomBefore.scale, 'OFF Zoom remains available');
await page.getByRole('button', { name: 'Fit to View', exact: true }).click(); await settle(); const fit = await state();
check(fit.forest.x >= fit.viewport.x - 1 && fit.forest.right <= fit.viewport.right + 1 && fit.forest.y >= fit.viewport.y - 1 && fit.forest.bottom <= fit.viewport.bottom + 1, 'OFF Fit contains selected forest');
measurements.push({ name: 'OFF browsing', state: fit });
});
await section('on', async () => {
await setup({ companies: ['한맥기술', '삼안'], enabled: true }); await snap('drag-drop-on', true);
const inside = await drag(employee('virtual-022'), title('hanmac-office-sibling'));
check(inside.after.cards.find(card => card.id === 'virtual-022')?.organizationId === 'hanmac-office-sibling' && inside.after.feedback.status === 'success', 'ON employee move within company');
const groupInside = await drag(title('hanmac-office-0'), title('hanmac-office-1'));
check(groupInside.after.nodes.find(org => org.id === 'hanmac-office-0')?.parentId === 'hanmac-office-1' && groupInside.after.feedback.status === 'success', 'ON group move within company');
const cross = await drag(employee('virtual-022'), title('saman-office-0'));
const moved = cross.after.cards.find(card => card.id === 'virtual-022');
check(moved?.companyId === 'saman' && moved.organizationId === 'saman-office-0' && moved.border[moved.direction] === 'rgb(240, 131, 0)' && cross.after.feedback.status === 'success', 'ON cross-company employee move updates actual company, organization and approved accent');
const crossGroup = await drag(title('hanmac-office-0'), title('saman-office-1'));
check(crossGroup.after.nodes.find(org => org.id === 'hanmac-office-0')?.parentId === 'saman-office-1'
&& ['hanmac-office-0','hanmac-cell-0','hanmac-team-0','hanmac-team-sibling'].every(id => crossGroup.after.nodes.find(org => org.id === id)?.companyId === 'saman') && crossGroup.after.feedback.status === 'success', 'ON cross-company group move preserves and recolors complete subtree');
check(crossGroup.after.cards.length === 40 && new Set(crossGroup.after.cards.map(card => card.id)).size === 40 && crossGroup.after.nodes.filter(org => org.parentId === '').reduce((sum, org) => sum + org.inclusive, 0) === 40, 'ON cross-company moves preserve unique Employee IDs and aggregate total');
await snap('cross-company-moved', true);
const beforeOff = await state(); await toggle(false); const afterOff = await state();
check(relations(beforeOff) === relations(afterOff) && afterOff.drop.length === 0 && !afterOff.dragging, 'Turning OFF retains completed employee and group moves');
await toggle(true); check(relations(beforeOff) === relations(await state()), 'Turning ON again preserves completed moves without reloading data');
await employee('virtual-022').click(); await dialog().waitFor(); check(await dialog().getAttribute('data-employee-id') === 'virtual-022', 'ON moved employee remains clickable');
await page.keyboard.press('Escape'); await dialog().waitFor({ state: 'hidden' });
await title('hanmac-office-0').click(); await ready(); check(await title('hanmac-office-0').getAttribute('aria-expanded') === 'false', 'ON group click still collapses');
await title('hanmac-office-0').press('Space'); await ready(40); check(await title('hanmac-office-0').getAttribute('aria-expanded') === 'true', 'ON group Space still expands');
await countPerCompany(100, 200); const fresh = await state();
check(fresh.cards.find(card => card.id === 'virtual-022')?.organizationId === 'hanmac-office-0' && fresh.nodes.find(org => org.id === 'hanmac-office-0')?.parentId === 'hanmac-division-0' && fresh.drop.length === 0 && !fresh.dragging, 'Data Apply rebuilds fixture without mixing completed session moves or drag state');
measurements.push({ name: 'ON transfers', inside, groupInside, cross, crossGroup, afterOff });
});
await section('cancel', async () => {
await setup({ enabled: true });
let cancelledDuringPointer;
const pointer = await drag(employee('virtual-022'), title('hanmac-office-sibling'), async () => { await toggle(false, { programmatic: true }); cancelledDuringPointer = await state(); });
check(pointer.hover.dragging === 'true' && pointer.hover.drop.some(target => target.hover === 'allowed'), 'ON pointer edit is active before disable');
check(!cancelledDuringPointer.dragging && cancelledDuringPointer.drop.length === 0 && relations(pointer.before) === relations(pointer.after) && pointer.after.dialogs === 0, 'OFF during pointer move cancels edit/highlights and suppresses release click');
await toggle(true); await employee('virtual-022').focus(); await page.keyboard.press('Alt+m'); await settle(); const opened = await state();
check(opened.dragging === 'true' && opened.drop.some(target => target.hover === 'allowed'), 'ON Alt+M starts keyboard edit');
await toggle(false, { programmatic: true }); await page.keyboard.press('Enter'); await settle(); const cancelled = await state();
check(!cancelled.dragging && cancelled.drop.length === 0 && cancelled.cards.find(card => card.id === 'virtual-022')?.organizationId === 'hanmac-office-0' && cancelled.dialogs === 0, 'OFF cancels active keyboard edit and blocks stale Enter commit');
await toggle(true); await employee('virtual-022').focus(); await page.keyboard.press('Alt+m'); await settle();
for (let i = 0; i < 40 && (await state()).drop.find(target => target.hover)?.id !== 'hanmac-office-sibling'; i++) { await page.keyboard.press('ArrowRight'); await settle(); }
await page.keyboard.press('Enter'); await ready(20); const keyboard = await state();
check(keyboard.cards.find(card => card.id === 'virtual-022')?.organizationId === 'hanmac-office-sibling' && keyboard.feedback.status === 'success' && keyboard.dialogs === 0, 'Re-enabled keyboard move commits through common ON policy');
measurements.push({ name: 'disable cancellation', pointer, cancelledDuringPointer, opened, cancelled, keyboard });
});
await section('modes', async () => {
await setup({ companies: ['한맥기술', '삼안'] });
for (const [layout, option] of [['tb','세로형 TB'],['lr','가로형 LR'],['hybrid','혼합형 Hybrid']]) {
if (layout !== 'tb') await select('조직도 표시 유형', option); await ready(40); const s = await state();
check(s.layout === layout && s.cards.length === 40 && s.cards.every(card => ['top','left','right'].includes(card.direction)), `${layout}: layout and existing directional cards work with default OFF`);
}
});
await section('responsive', async () => {
for (const width of [390,768,1280,1440]) {
await page.setViewportSize({ width, height: 1100 }); await page.goto(`${origin}/iframe.html?id=orgchart--expanded-org-chart&viewMode=story`); await companyTrigger().waitFor(); await ready(140);
const metric = await editSwitch().evaluate(element => ({ label: element.getAttribute('aria-label') || element.getAttribute('aria-labelledby') || document.querySelector(`label[for="${element.id}"]`)?.textContent, checked: element.getAttribute('aria-checked'), scrollWidth: document.documentElement.scrollWidth, switchVisible: element.getBoundingClientRect().width > 0 }));
check(metric.scrollWidth <= width + 1 && metric.switchVisible && metric.checked === 'false' && Boolean(metric.label), `${width}: OFF switch is named and settings have no horizontal overflow`, metric);
await snap(`expanded-${width}`, true); await companyTrigger().click(); await companyPanel().waitFor();
const bounds = await companyPanel().evaluate(element => { const r = element.getBoundingClientRect(); return { x: r.left, right: r.right, y: r.top, bottom: r.bottom, scrollWidth: element.scrollWidth, clientWidth: element.clientWidth }; });
check(bounds.x >= -1 && bounds.right <= width + 1 && bounds.y >= -1 && bounds.bottom <= 1101 && bounds.scrollWidth <= bounds.clientWidth + 1, `${width}: retained company dropdown fits viewport`, bounds);
await page.keyboard.press('Escape'); await companyPanel().waitFor({ state: 'hidden' });
await page.goto(`${origin}/iframe.html?id=orgchart--department-colors&viewMode=story`); await ready();
const departmentOverflow = await page.evaluate(() => ({ width: innerWidth, scrollWidth: document.documentElement.scrollWidth }));
check(departmentOverflow.scrollWidth <= width + 1, `${width}: Department preview containers fit viewport`, departmentOverflow);
await snap(`department-${width}`, true);
}
});
await section('expanded-frame', async () => {
// Later outer-frame style correction only. Existing layout/move suites remain
// the evidence for those features; moving a sample below is for fresh capture.
await page.setViewportSize({ width:1440,height:1100 });
await page.goto(`${origin}/iframe.html?id=center-graphic-facility--tunnel&viewMode=story`); await page.locator('.tunnel-reference__sample').first().waitFor(); await settle();
const reference = await page.locator('.tunnel-reference__sample').first().evaluate(element => {
const css = getComputedStyle(element); return { backgroundColor:css.backgroundColor,border:css.border,borderRadius:css.borderRadius };
});
await page.goto(`${origin}/iframe.html?id=orgchart--expanded-org-chart&viewMode=story`); await companyTrigger().waitFor(); await ready(140);
const frameSnapshot = () => page.evaluate(() => {
const shadow = document.querySelector('.hm-orgchart-host').shadowRoot, root = shadow.querySelector('.hm-orgchart-expanded'), css = getComputedStyle(root);
return { frame:{ backgroundColor:css.backgroundColor,border:css.border,borderRadius:css.borderRadius },
inputs:Object.fromEntries(['--hm-orgchart-expanded-preview-background','--hm-orgchart-expanded-preview-border','--hm-orgchart-expanded-preview-radius'].map(name => [name,css.getPropertyValue(name).trim()])),
cards:[...shadow.querySelectorAll('.hm-orgchart-employee')].map(card => ({ id:card.dataset.employeeId,background:getComputedStyle(card).backgroundColor,width:card.offsetWidth,height:card.offsetHeight,kind:card.dataset.cardKind })),
titles:[...shadow.querySelectorAll('.node[data-company-id] .hm-orgchart-organization-name')].map(title => ({ company:title.closest('.node').dataset.companyId,color:getComputedStyle(title).color,background:getComputedStyle(title).backgroundColor })),
layout:root.dataset.autoLayout,width:innerWidth,documentWidth:document.documentElement.scrollWidth,
pan:shadow.querySelector('.hm-orgchart-viewport').dataset.panEnabled,zoom:shadow.querySelector('.hm-orgchart-viewport').dataset.zoomEnabled,
editing:shadow.querySelector('.hm-orgchart-viewport').dataset.dragDropEnabled };
});
const before = await frameSnapshot();
check(JSON.stringify(before.frame) === JSON.stringify(reference) && Object.values(before.inputs).every(value => value.length > 0), 'Expanded outer frame matches actual Tunnel shared background, border and radius', { expanded:before.frame,reference,inputs:before.inputs });
const expectedColors = { hanmac:'rgb(203, 46, 40)',saman:'rgb(240, 131, 0)','jangheon-industry':'rgb(69, 128, 166)',jangheon:'rgb(29, 65, 141)',ptc:'rgb(132, 51, 142)',halla:'rgb(0, 104, 183)',baron:'rgb(12, 54, 127)' };
check(before.cards.length === 140 && before.cards.every(card => card.background === 'rgb(255, 255, 255)') && before.titles.every(title => title.color === 'rgb(255, 255, 255)' && title.background === expectedColors[title.company]), 'Expanded outer-frame style preserves white employee backgrounds and approved company/white group titles');
check(before.layout === 'tb' && before.editing === 'false' && await editSwitch().getAttribute('aria-checked') === 'false'
&& await page.getByRole('checkbox',{ name:'Pan 사용',exact:true }).getAttribute('aria-checked') === 'true'
&& await page.getByRole('checkbox',{ name:'Zoom 사용',exact:true }).getAttribute('aria-checked') === 'true'
&& await page.getByRole('checkbox',{ name:'접기·펼치기 사용',exact:true }).getAttribute('aria-checked') === 'true', 'Expanded frame correction preserves TB, default OFF and enabled browsing controls');
await snap('expanded-frame-off',true); await snap('expanded-frame-scope-removed',true);
await companyTrigger().click(); await companyPanel().waitFor(); await snap('expanded-frame-company-dropdown');
await page.keyboard.press('Escape'); await companyPanel().waitFor({ state:'hidden' });
await toggle(true); const on = await frameSnapshot();
check(JSON.stringify(on.frame) === JSON.stringify(before.frame) && JSON.stringify(on.cards) === JSON.stringify(before.cards) && JSON.stringify(on.titles) === JSON.stringify(before.titles), 'ON toggle leaves corrected outer frame and all card/title styling unchanged');
await snap('expanded-frame-on',true);
await page.setViewportSize({ width:390,height:1100 }); await settle(); const narrow = await frameSnapshot();
check(narrow.documentWidth <= 391 && JSON.stringify(narrow.frame) === JSON.stringify(reference), '390px corrected Expanded frame fits page and retains shared style', { width:narrow.width,scrollWidth:narrow.documentWidth,frame:narrow.frame });
await snap('expanded-frame-390',true);
await setup({ companies:['한맥기술','삼안'],enabled:true });
const captureMove = await drag(employee('virtual-022'),title('saman-office-0'));
await page.getByRole('button',{ name:'Fit to View',exact:true }).click(); await settle(); await snap('expanded-frame-company-moved',true);
measurements.push({ name:'Expanded outer-frame correction',reference,before,on,narrow,
captureOnlyMove:{ status:captureMove.after.feedback.status,employee:captureMove.after.cards.find(card => card.id === 'virtual-022'),countText:captureMove.after.countText,note:'Fresh moved sample screenshot; previous P9.4 movement PASS is retained without rerunning its assertions.' } });
});
await section('names-mobile', async () => {
// Only the narrow viewport and current captures are new; all thirteen names,
// roles/artwork/typography already passed the department-names section.
await page.setViewportSize({ width:1440,height:1000 }); await page.goto(`${origin}/iframe.html?id=orgchart--department-colors&viewMode=story`); await ready();
await page.evaluate(async () => { await Promise.all([...document.querySelector('.hm-orgchart-component-host').shadowRoot.querySelectorAll('img')].map(async image => { image.loading = 'eager'; await image.decode(); })); });
await snap('department-full-current',true); await page.locator('.hm-orgchart-representative-preview').screenshot({ path:path.join(output,'department-representative-current.png') });
await page.setViewportSize({ width:390,height:1100 }); await settle();
const narrow = await page.evaluate(() => {
const shadow = document.querySelector('.hm-orgchart-component-host').shadowRoot;
const cards = [...shadow.querySelectorAll('.hm-orgchart-employee')].map(card => { const box = card.getBoundingClientRect(); return { id:card.dataset.employeeId,left:box.left,right:box.right,name:card.querySelector('.hm-orgchart-person-name').textContent }; });
return { width:innerWidth,documentWidth:document.documentElement.scrollWidth,cards };
});
check(narrow.documentWidth <= 391 && narrow.cards.every(card => card.left >= -1 && card.right <= 391), '390px corrected Department names/cards stay within page without horizontal overflow', narrow);
await snap('department-names-390',true); measurements.push({ name:'Department narrow capture',narrow });
});
await section('menu', async () => {
const index = await (await page.request.get(`${origin}/index.json`)).json();
const entries = Object.values(index.entries).filter(entry => entry.type === 'story' && entry.title.startsWith('OrgChart/') && !(entry.tags ?? []).includes('internal'));
const expected = ['orgchart--logo-personnel-summary','orgchart--department-colors','orgchart--full-composition','orgchart--expanded-org-chart'];
check(entries.length === 4 && entries.every(entry => expected.includes(entry.id)), 'Four existing public OrgChart Story IDs retained', entries.map(entry => ({ id: entry.id, title: entry.title })));
});
check(errors.length === 0, 'New console/page errors: zero', errors);
const result = { startedAt, completedAt: new Date().toISOString(), browser: browser.version(), origin, sections: only, results, measurements, errors,
notVerified: ['Physical touch device','Actual screen reader','Design intent approval'], pass: results.filter(item => item.status === 'PASS').length, fail: results.filter(item => item.status === 'FAIL').length };
await fs.writeFile(path.join(output, `browser${suffix}.json`), JSON.stringify(result, null, 2));
console.log(JSON.stringify({ pass: result.pass, fail: result.fail, errors: errors.length, output: `test-results/org-chart-p9-4/browser${suffix}.json` }));
await browser.close(); if (result.fail) process.exitCode = 1;
@@ -0,0 +1,54 @@
import fs from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { launchChromium } from '../../scripts/orgchart-browser-runtime.mjs';
const root=fileURLToPath(new URL('../../',import.meta.url));
const output=path.join(root,process.env.ORGCHART_ARROW_OUTPUT??'test-results/org-chart-p9-5-arrowhead');
const phase=process.argv[2]??'after', origin=process.env.ORGCHART_STORY_ORIGIN??'http://127.0.0.1:6064';
const browser=await launchChromium({headless:true}),page=await browser.newPage({viewport:{width:1920,height:1080}});
const checks=[],measurements=[],errors=[];
page.on('pageerror',e=>errors.push(e.message));page.on('console',m=>{if(m.type()==='error')errors.push(m.text());});
await fs.mkdir(output,{recursive:true});
const check=(pass,name,detail)=>{checks.push({name,status:pass?'PASS':'FAIL',detail});if(!pass)console.log('FAIL',name,JSON.stringify(detail));};
const settle=()=>page.evaluate(async()=>{await document.fonts.ready;for(let i=0;i<20;i++)await new Promise(requestAnimationFrame);});
const before=phase==='after'?JSON.parse(await fs.readFile(path.join(output,'before.json'),'utf8')):null;
const reference=await fs.readFile('D:/디자인시스템/gpd-workdesk/sample/조직도_v1.3/org-dashboard-data.js','utf8');
const originalD=reference.match(/id:\s*"jang-arrow-head",[\s\S]*?\bd:\s*"([^"]+)"/)[1];
try{
for(const size of [{width:1920,height:1080},{width:1920,height:1440},{width:390,height:844}]){
await page.setViewportSize(size);await page.goto(`${origin}/iframe.html?id=orgchart--full-composition&viewMode=story`);
await page.waitForFunction(()=>{const s=document.querySelector('.hm-orgchart-host')?.shadowRoot;return s?.querySelector('.hm-orgchart-stage[data-workdesk-artwork]')&&!s.querySelector('[aria-busy="true"]')&&[...s.querySelectorAll('link[rel="stylesheet"]')].every(l=>l.sheet);});await settle();
const snapshot=await page.evaluate(()=>{
const s=document.querySelector('.hm-orgchart-host').shadowRoot,stage=s.querySelector('.hm-orgchart-stage'),overlay=s.querySelector('.hm-orgchart-workdesk-overlay');
const r=e=>{const b=e.getBoundingClientRect();return{x:b.left,y:b.top,width:b.width,height:b.height,right:b.right,bottom:b.bottom};};
const o=r(overlay),scale=o.width/1920,local=e=>{const b=r(e);return{x:(b.x-o.x)/scale,y:(b.y-o.y)/scale,width:b.width/scale,height:b.height/scale};};
const card=s.querySelector('.hm-orgchart-top-leaders .hm-orgchart-employee'),hub=s.querySelector('.hm-orgchart-hub'),head=s.querySelector('[data-arrow-head="representative"]'),line=s.querySelector('[data-connector="representative-to-hub"]');
return{scale,offset:Number(s.querySelector('.hm-orgchart-composition').dataset.centerOffset),card:local(card),hub:local(hub),crop:{card:r(card),hub:r(hub)},head:head?{d:head.getAttribute('d'),fill:getComputedStyle(head).fill,stroke:getComputedStyle(head).stroke,transform:head.getAttribute('transform'),box:local(head),screen:r(head)}:null,headCount:s.querySelectorAll('[data-arrow-head="representative"]').length,
line:{d:line.getAttribute('d'),transform:line.getAttribute('transform'),start:line.getPointAtLength(0),end:line.getPointAtLength(line.getTotalLength())},team:[...s.querySelectorAll('image[data-arrow]')].map(e=>({id:e.dataset.arrow,box:local(e),href:e.getAttribute('href')})),
connections:s.querySelectorAll('[data-connector]').length,cards:s.querySelectorAll('.hm-orgchart-employee').length,scroll:[document.documentElement.scrollWidth,document.documentElement.scrollHeight]};
});
const name=`${size.width}x${size.height}`; measurements.push({name,...snapshot});
const clip={x:Math.max(0,Math.floor(snapshot.crop.card.x-30)),y:Math.max(0,Math.floor(snapshot.crop.card.y-10)),width:Math.ceil(snapshot.crop.card.width+60),height:Math.ceil(snapshot.crop.hub.bottom-snapshot.crop.card.y+25)};
await page.screenshot({path:path.join(output,`${phase}-${name}-detail.png`),clip,animations:'disabled'});
if(phase==='after'){
const old=before.measurements.find(m=>m.name===name),h=snapshot.head;
check(snapshot.headCount===1&&h?.d===originalD&&h.fill==='rgb(69, 99, 159)'&&h.stroke==='none',`${name}: exact original solid chevron restored once`,h);
if(!h)continue;
const gap=h.box.y-(snapshot.card.y+snapshot.card.height);
check(Math.abs(gap)<.01,`${name}: upward tip attaches to representative bottom edge`,gap);
check(Math.abs(h.box.x+h.box.width/2-(snapshot.card.x+snapshot.card.width/2))<.1,`${name}: arrow aligned to card/connector axis`,h.box);
const headBottom=h.box.y+h.box.height,lineStart=Number(snapshot.line.d.match(/M[^ ]+ ([^V]+)/)[1])-17;
check(Math.abs(lineStart-headBottom)<.2,`${name}: chevron meets existing dashed stem`,{headBottom,lineStart});
check(snapshot.connections===5&&snapshot.cards===old.cards&&snapshot.team.length===5&&snapshot.team.every((a,i)=>a.id===old.team[i].id&&a.href===old.team[i].href&&['x','y','width','height'].every(k=>Math.abs(a.box[k]-old.team[i].box[k])<.05)),`${name}: five team arrows/relations and cards unchanged`);
check(['card','hub'].every(key=>['x','y','width','height'].every(k=>Math.abs(snapshot[key][k]-old[key][k])<.05)),`${name}: representative/hub placement preserved`);
const target=page.locator('[data-arrow-head="representative"]');
const shown=await page.screenshot({clip,animations:'disabled'});await target.evaluate(e=>e.style.visibility='hidden');
const hidden=await page.screenshot({clip,animations:'disabled'});await target.evaluate(e=>e.style.removeProperty('visibility'));
check(!shown.equals(hidden),`${name}: restored arrow actually paints visible pixels`,{shownBytes:shown.length,hiddenBytes:hidden.length});
check(snapshot.scroll[0]<=size.width+1&&snapshot.scroll[1]<=size.height+1,`${name}: no new document scroll`,snapshot.scroll);
}
}
}catch(e){check(false,'Targeted arrow check completed',{message:e.message,stack:e.stack});}
check(!errors.length,'Console/page errors zero',errors);
const result={phase,origin,measurements,checks,errors,pass:checks.filter(c=>c.status==='PASS').length,fail:checks.filter(c=>c.status==='FAIL').length};
await fs.writeFile(path.join(output,`${phase}.json`),JSON.stringify(result,null,2));await browser.close();console.log(JSON.stringify({phase,pass:result.pass,fail:result.fail,measurements:measurements.map(m=>({name:m.name,head:m.head?.box,card:m.card,connections:m.connections}))}));if(result.fail)process.exitCode=1;
@@ -0,0 +1,101 @@
// Focused comparison with the real HM Dialog; no P9.4/P9.5 layout regression replay.
import fs from 'node:fs/promises';
import path from 'node:path';
import { launchChromium } from '../../scripts/orgchart-browser-runtime.mjs';
const output = path.resolve('test-results/org-chart-p9-5-core-dialog-close');
const origin = (process.env.ORGCHART_STORY_ORIGIN ?? 'http://127.0.0.1:6064').replace(/\/$/, '');
const oldCss = await fs.readFile(path.join(output, 'before-employee-info-dialog.css'), 'utf8');
const data = JSON.parse(await fs.readFile('examples/org-chart/data-p1.json', 'utf8'));
const checks = [], measurements = [], errors = [], startedAt = new Date().toISOString();
const check = (pass, name, detail) => { checks.push({ name, status:pass ? 'PASS':'FAIL', detail }); if (!pass) console.log('FAIL', name, JSON.stringify(detail)); };
const browser = await launchChromium({ headless:true });
const page = await browser.newPage({ viewport:{ width:1440,height:900 } });
page.on('pageerror', error => errors.push(error.message));
page.on('console', message => { if (message.type() === 'error') errors.push(message.text()); });
const settle = () => page.evaluate(async () => { await document.fonts.ready; for (let i = 0; i < 12; i++) await new Promise(requestAnimationFrame); });
const coreClose = '.hm-orgchart-employee-dialog-close', coreDialog = '.hm-orgchart-employee-dialog';
const refClose = '[data-slot="dialog-content"] button[data-variant="ghost"]';
async function measure(selector) {
return page.locator(selector).evaluate(button => {
const rect = el => { const r=el.getBoundingClientRect(); return { x:r.x,y:r.y,width:r.width,height:r.height }; };
const css=getComputedStyle(button), parent=button.closest('dialog,[data-slot="dialog-content"]'), box=rect(button), pbox=rect(parent), svg=button.querySelector('svg');
const fields=parent.querySelector('dl'), title=parent.querySelector('h2');
return { box,dialog:pbox,inset:{ top:box.y-pbox.y,right:pbox.x+pbox.width-box.x-box.width },
css:Object.fromEntries(['padding','border','borderRadius','color','backgroundColor','outlineStyle','outlineOffset','boxShadow','transition','cursor','fontSize','lineHeight','position','top','right','boxSizing','backgroundClip','userSelect'].map(k=>[k,css[k]])),
svg:svg && { box:rect(svg),attrs:Object.fromEntries(['viewBox','fill','stroke','stroke-width','stroke-linecap','stroke-linejoin','aria-hidden'].map(k=>[k,svg.getAttribute(k)])),paths:[...svg.querySelectorAll('path')].map(p=>p.getAttribute('d')) },
title:title && { box:rect(title),text:title.textContent },fields:fields && { box:rect(fields),html:fields.innerHTML },
container:Object.fromEntries(['padding','backgroundColor','color','border','borderRadius','fontFamily','fontSize','lineHeight','maxHeight'].map(k=>[k,getComputedStyle(parent)[k]])),
accessibleName:button.getAttribute('aria-label'),focusVisible:button.matches(':focus-visible'),open:parent.open
};
});
}
const equivalent=(a,b)=>JSON.stringify(a)===JSON.stringify(b);
const rectNear=(a,b)=>Object.keys(a).every(k=>Math.abs(a[k]-b[k])<.02);
await page.goto(`${origin}/iframe.html?id=components-overlay-dialog--all&viewMode=story`);
await page.getByRole('button',{ name:'기본',exact:true }).click(); await page.locator(refClose).waitFor(); await settle();
await page.locator(refClose).evaluate(el=>el.blur()); await page.mouse.move(0,0);
const reference={ normal:await measure(refClose) };
await page.locator(refClose).hover(); await settle(); reference.hover=await measure(refClose);
await page.mouse.move(0,0); await page.keyboard.press('Tab'); await page.locator(refClose).focus(); await settle(); reference.focus=await measure(refClose);
check(reference.focus.focusVisible, 'Actual HM Dialog keyboard focus reference captured', reference.focus.css);
await page.locator('[data-slot="dialog-content"]').screenshot({path:path.join(output,'hm-reference.png')});
measurements.push({name:'actual HM Dialog',...reference});
for (const mode of ['React Story / Core popup','Vanilla / no React']) {
for (const width of [1440,390]) {
await page.setViewportSize({width,height:900});
if (mode.startsWith('React')) {
await page.goto(`${origin}/iframe.html?id=orgchart--full-composition&viewMode=story`);
} else {
await page.route('**/p95-native-dialog-fixture', route=>route.fulfill({contentType:'text/html',body:'<!doctype html><html lang="ko"><meta charset="utf-8"><title>Core close verification</title><body><main id="fixture" style="height:850px"></main></body></html>'}));
await page.goto(`${origin}/p95-native-dialog-fixture`);
await page.evaluate(async data=>{
const api=await import('/core/index.js');
window.chart=api.createOrgChart({container:document.querySelector('#fixture'),dataSource:new api.JsonOrgChartDataSource(data),displayMode:'readOnly',stylesheetUrls:{engine:'/core/engine.css',core:'/core/style.css'}});
await window.chart.mount();
},data);
}
await page.locator('.hm-orgchart-top-leaders .hm-orgchart-employee').waitFor();
await page.waitForFunction(()=>[...document.querySelector('.hm-orgchart-host').shadowRoot.querySelectorAll('link[rel="stylesheet"]')].every(link=>link.sheet)); await settle();
const trigger=page.locator('.hm-orgchart-top-leaders .hm-orgchart-employee');
const label=`${mode} ${width}`;
await trigger.click(); await page.locator(`${coreDialog}[open]`).waitFor(); await settle();
// Reapply the captured pre-change stylesheet/markup on this same popup to
// compare body geometry at each width/runtime without a second application.
await page.locator(coreClose).evaluate((button,css)=>{
window.savedCloseNodes=[...button.childNodes]; button.textContent='닫기';
window.oldCloseStyle=document.createElement('style'); window.oldCloseStyle.textContent=`${css}\n.hm-orgchart-employee-dialog-close { all: revert; padding:var(--hm-orgchart-resolved-spacing-xs) var(--hm-orgchart-resolved-spacing-sm);border:var(--hm-orgchart-resolved-border-width) solid var(--hm-orgchart-resolved-stroke-default);border-radius:var(--hm-orgchart-resolved-radius-control-md);background:var(--hm-orgchart-resolved-bg-content);color:inherit;font:inherit;cursor:pointer;box-sizing:border-box; } .hm-orgchart-employee-dialog-header { min-height:0; }`;
button.getRootNode().append(window.oldCloseStyle);
},oldCss);
const before=await measure(coreClose);
await page.locator(coreClose).evaluate(button=>{button.replaceChildren(...window.savedCloseNodes);window.oldCloseStyle.remove();});
await page.locator(coreClose).evaluate(el=>el.blur()); await page.mouse.move(0,0); await settle();
const normal=await measure(coreClose);
const compareKeys=['padding','border','borderRadius','color','backgroundColor','outlineStyle','outlineOffset','transition','cursor','fontSize','lineHeight','position','top','right','boxSizing','backgroundClip','userSelect'];
check(compareKeys.every(k=>normal.css[k]===reference.normal.css[k]),`${label}: HM normal button styles match`,{actual:normal.css,reference:reference.normal.css});
check(normal.box.width===reference.normal.box.width && normal.box.height===reference.normal.box.height && equivalent(normal.inset,reference.normal.inset),`${label}: 28px button and top/right inset match`,{box:normal.box,inset:normal.inset});
check(normal.svg.box.width===16 && normal.svg.box.height===16 && equivalent(normal.svg.attrs,reference.normal.svg.attrs) && equivalent(normal.svg.paths,reference.normal.svg.paths),`${label}: actual Lucide X geometry/stroke matches`,normal.svg);
check(rectNear(before.dialog,normal.dialog) && rectNear(before.fields.box,normal.fields.box) && rectNear(before.title.box,normal.title.box) && equivalent(before.container,normal.container) && before.fields.html===normal.fields.html,`${label}: popup size, title, body geometry, background and data preserved`,{before,after:normal});
check(normal.accessibleName==='직원 정보 닫기',`${label}: accessible close name preserved`);
await page.locator(coreClose).hover(); await settle(); const hover=await measure(coreClose);
check(hover.css.backgroundColor===reference.hover.css.backgroundColor && hover.css.color===reference.hover.css.color,`${label}: HM hover matches`,hover.css);
await page.mouse.move(0,0); await page.keyboard.press('Tab'); await page.locator(coreClose).focus(); await settle(); const focus=await measure(coreClose);
check(focus.focusVisible && focus.css.border===reference.focus.css.border && focus.css.outlineStyle==='none' && focus.css.boxShadow.includes('oklab(0.462622 -0.0726663 0.0150061 / 0.5) 0px 0px 0px 3px'),`${label}: HM focus border and 3px 50% ring match`,focus.css);
await page.keyboard.press('Tab'); await page.keyboard.press('Shift+Tab');
check(await page.locator(coreClose).evaluate(el=>el.getRootNode().activeElement===el),`${label}: Tab/Shift+Tab focus trap retained`);
await page.mouse.click(5,5); await settle();
check(await page.locator(`${coreDialog}[open]`).count()===1,`${label}: existing outside-click stays open policy retained`);
await page.locator(coreClose).click(); await settle();
check(await page.locator(`${coreDialog}[open]`).count()===0 && await trigger.evaluate(el=>el.getRootNode().activeElement===el),`${label}: X click closes and restores employee focus`);
await trigger.click(); await page.keyboard.press('Escape'); await settle();
check(await page.locator(`${coreDialog}[open]`).count()===0 && await trigger.evaluate(el=>el.getRootNode().activeElement===el),`${label}: ESC closes and restores employee focus`);
await trigger.focus(); await page.keyboard.press('Enter'); await settle(); await page.keyboard.press('Tab');
const screen=mode.startsWith('React')?'react':'vanilla';
await page.locator(coreDialog).screenshot({path:path.join(output,`${screen}-${width}-popup.png`)});
if (mode.startsWith('Vanilla')) check(await page.evaluate(()=>!window.React && ![...document.querySelectorAll('script')].some(el=>/react/i.test(el.src))),`${label}: native Core mounting has no React runtime`);
measurements.push({name:label,before,normal,hover,focus});
}
}
check(errors.length===0,'No new console/page errors',errors);
const result={startedAt,completedAt:new Date().toISOString(),origin,browser:browser.version(),checks,measurements,errors,pass:checks.filter(c=>c.status==='PASS').length,fail:checks.filter(c=>c.status==='FAIL').length};
await fs.writeFile(path.join(output,'result.json'),JSON.stringify(result,null,2)); await browser.close();
console.log(JSON.stringify({pass:result.pass,fail:result.fail,output})); if(result.fail)process.exitCode=1;
@@ -0,0 +1,246 @@
// P9.5 Full Composition only: same-mount resize, artwork coordinates and native interactions.
import fs from 'node:fs/promises';
import path from 'node:path';
import { createHash } from 'node:crypto';
import { fileURLToPath } from 'node:url';
import { launchChromium } from '../../scripts/orgchart-browser-runtime.mjs';
const root = fileURLToPath(new URL('../../', import.meta.url));
const output = path.join(root, 'test-results/org-chart-p9-5-full');
const origin = (process.env.ORGCHART_STORY_ORIGIN ?? 'http://127.0.0.1:6064').replace(/\/$/, '');
const baseline = JSON.parse(await fs.readFile(path.join(output, 'before.json'), 'utf8'));
const fixture = JSON.parse(await fs.readFile(path.join(root, 'examples/org-chart/data-p1.json'), 'utf8'));
const names = ['team-arrow-blue-short.png', 'team-arrow-blue-purple.png', 'team-arrow-purple-teal.png', 'team-arrow-teal-green.png'];
const assetHashes = Object.fromEntries(await Promise.all(names.map(async name => [name, createHash('sha256').update(await fs.readFile(path.join(root, 'packages/org-chart/src/assets/workdesk', name))).digest('hex')])));
const arrowAssets = { 'blue-short-1': names[0], 'blue-short-2': names[0], 'blue-purple': names[1], 'purple-teal': names[2], 'teal-green': names[3] };
const checks = [], measurements = [], errors = [], arrowPaint = [];
const startedAt = new Date().toISOString();
const browser = await launchChromium({ headless: true });
const page = await browser.newPage({ viewport: { width: 1920, height: 1080 } });
page.setDefaultTimeout(30000);
page.on('pageerror', error => errors.push({ type: 'pageerror', message: error.message }));
page.on('console', message => { if (message.type() === 'error') errors.push({ type: 'console', message: message.text() }); });
const check = (pass, name, detail) => { checks.push({ name, status: pass ? 'PASS' : 'FAIL', detail }); if (!pass) console.log('FAIL', name, JSON.stringify(detail)); };
const notVerified = (name, detail) => checks.push({ name, status: 'NOT VERIFIED', detail });
const near = (a, b, tolerance = .15) => Object.keys(a).every(key => Math.abs(a[key] - b[key]) <= tolerance);
const settle = () => page.evaluate(async () => { await document.fonts.ready; for (let i = 0; i < 20; i++) await new Promise(requestAnimationFrame); });
async function ready() {
await page.waitForFunction(() => {
const shadow = document.querySelector('.hm-orgchart-host')?.shadowRoot;
return shadow?.querySelector('.hm-orgchart-stage[data-workdesk-artwork]') && !shadow.querySelector('[aria-busy="true"]') && [...shadow.querySelectorAll('link[rel="stylesheet"]')].every(link => link.sheet);
});
await page.evaluate(async () => { await Promise.all([...document.querySelector('.hm-orgchart-host').shadowRoot.querySelectorAll('img')].map(image => image.decode())); });
await settle();
}
async function snapshot(name) {
const value = await page.evaluate(async () => {
const host = document.querySelector('.hm-orgchart-story-full'), shadow = host.querySelector('.hm-orgchart-host').shadowRoot;
const stage = shadow.querySelector('.hm-orgchart-stage'), wrapper = shadow.querySelector('.hm-orgchart-composition');
const rect = element => { const r = element.getBoundingClientRect(); return { left: r.left, right: r.right, top: r.top, bottom: r.bottom, width: r.width, height: r.height }; };
const origin = rect(stage), scale = origin.width / stage.offsetWidth, offset = Number(wrapper.dataset.centerOffset) || 0;
const normal = element => { const r = rect(element); return { x: (r.left - origin.left) / scale, y: (r.top - origin.top) / scale - offset, width: r.width / scale, height: r.height / scale }; };
const hash = async bytes => [...new Uint8Array(await crypto.subtle.digest('SHA-256', bytes))].map(byte => byte.toString(16).padStart(2, '0')).join('');
const central = [...stage.querySelectorAll('.hm-orgchart-axis .hm-orgchart-employee,.hm-orgchart-hub,.hm-orgchart-viewport .hm-orgchart-employee,.hm-orgchart-viewport .hm-orgchart-organization-name')].map((element, index) => ({ key: `${element.className}:${element.dataset.employeeId ?? element.closest('.node')?.dataset.organizationId ?? ''}:${index}`, box: normal(element) }));
const arrows = await Promise.all([...stage.querySelectorAll('image[data-arrow]')].map(async element => {
const href = element.getAttribute('href'), image = new Image(); image.src = href; await image.decode();
return { id: element.dataset.arrow, box: normal(element), rect: rect(element), hash: await hash(await (await fetch(href)).arrayBuffer()), loaded: image.naturalWidth > 0, attributes: Object.fromEntries(['x', 'y', 'width', 'height', 'preserveAspectRatio'].map(key => [key, element.getAttribute(key)])) };
}));
const paths = [...stage.querySelectorAll('.hm-orgchart-workdesk-overlay path')].map(element => {
const point = element.getPointAtLength(element.getTotalLength()), screen = new DOMPoint(point.x, point.y).matrixTransform(element.getScreenCTM());
return { id: element.dataset.connector, d: element.getAttribute('d'), box: normal(element), endpoint: { x: screen.x, y: screen.y } };
});
const management = rect(stage.querySelector('.node[data-organization-id="business-management"] .hm-orgchart-organization-name'));
const hostRect = rect(host), css = getComputedStyle(host);
const left = hostRect.left + (parseFloat(css.borderLeftWidth) || 0) + (parseFloat(css.paddingLeft) || 0);
const right = hostRect.right - (parseFloat(css.borderRightWidth) || 0) - (parseFloat(css.paddingRight) || 0);
const line = paths.find(item => item.id === 'hub-to-business-management');
return { size: { width: innerWidth, height: innerHeight }, stage: origin, scale, offset, canvasHeight: Number(wrapper.dataset.canvasHeight), sameMount: stage === window.__p95MountedStage,
horizontalError: (origin.left + origin.right - left - right) / 2, central, arrows, paths,
managementEndpointError: { x: line.endpoint.x - (management.left + management.right) / 2, y: line.endpoint.y - management.top - 2 * scale },
links: [...stage.querySelectorAll('.hm-orgchart-organization-link')].map(element => ({ href: element.href, text: element.textContent })) };
});
measurements.push({ name, ...value });
return value;
}
async function stability() {
return page.evaluate(async () => {
const stage = document.querySelector('.hm-orgchart-host').shadowRoot.querySelector('.hm-orgchart-stage');
const records = [];
const observer = new MutationObserver(items => { for (const item of items) records.push({ oldStyle: item.oldValue, style: stage.getAttribute('style'), at: performance.now() }); });
observer.observe(stage, { attributes: true, attributeFilter: ['style'], attributeOldValue: true });
for (let i = 0; i < 20; i++) await new Promise(requestAnimationFrame);
observer.disconnect(); return { count: records.length, records };
});
}
// Directly affected by the adaptive-anchor notification: a bounded parent
// grows while viewport and scale remain unchanged. Do not replay other checks.
if (process.argv.includes('--anchor-notify-only')) {
const probes = [];
try {
await page.goto(`${origin}/iframe.html?id=orgchart--full-composition&viewMode=story`); await ready();
const value = await page.evaluate(async data => {
const api = await import('/core/index.js'), host = document.createElement('section');
Object.assign(host.style, { position: 'fixed', left: '0', top: '0', width: '1920px', height: '914.5px', zIndex: '1000', overflow: 'hidden' }); document.body.append(host);
const handle = api.createOrgChart({ container: host, dataSource: new api.JsonOrgChartDataSource(data), displayMode: 'readOnly', stylesheetUrls: { engine: '/core/engine.css', core: '/core/style.css' } });
try {
await handle.mount();
const shadow = host.querySelector('.hm-orgchart-host').shadowRoot;
await Promise.all([...shadow.querySelectorAll('link[rel="stylesheet"]')].map(link => link.sheet ? Promise.resolve() : new Promise((resolve, reject) => { link.addEventListener('load', resolve, { once: true }); link.addEventListener('error', reject, { once: true }); })));
const waitFrames = async () => { await document.fonts.ready; for (let i = 0; i < 20; i++) await new Promise(requestAnimationFrame); };
await waitFrames();
const wrapper = shadow.querySelector('.hm-orgchart-composition'), stage = shadow.querySelector('.hm-orgchart-stage');
let notifications = 0; wrapper.addEventListener('hm-orgchart-scale', () => { notifications++; });
const rect = element => { const r = element.getBoundingClientRect(); return { left: r.left, right: r.right, top: r.top, bottom: r.bottom, width: r.width, height: r.height }; };
const scroll = element => ({ width: element.scrollWidth, clientWidth: element.clientWidth, height: element.scrollHeight, clientHeight: element.clientHeight, overflowX: getComputedStyle(element).overflowX, overflowY: getComputedStyle(element).overflowY });
const measure = () => ({ size: { width: innerWidth, height: innerHeight }, host: rect(host), stage: rect(stage), scale: rect(stage).width / 1920, centerOffset: Number(wrapper.dataset.centerOffset), canvasHeight: Number(wrapper.dataset.canvasHeight), notifications,
representative: rect(stage.querySelector('.hm-orgchart-top-leaders .hm-orgchart-employee')), hub: rect(stage.querySelector('.hm-orgchart-hub')), tree: rect(stage.querySelector('.hm-orgchart-viewport')),
scroll: { root: scroll(document.documentElement), body: scroll(document.body), host: scroll(host), wrapper: scroll(wrapper), viewport: scroll(stage.querySelector('.hm-orgchart-viewport')) },
painted: [...stage.querySelectorAll('.hm-orgchart-employee,.hm-orgchart-organization-name,image[data-arrow],.hm-orgchart-heading,.hm-orgchart-slot-side,.hm-orgchart-slot-footer')].map(rect) });
const before = measure(); host.style.height = '1014.5px'; await waitFrames(); const after = measure();
return { before, after };
} finally { handle.destroy(); host.remove(); }
}, fixture);
probes.push(value);
check(value.before.scale === 1 && value.after.scale === 1 && value.before.size.width === value.after.size.width && value.before.size.height === value.after.size.height, 'Bounded parent height-only resize retains scale 1 without viewport resize', { before: value.before.scale, after: value.after.scale, viewport: value.after.size });
check(value.before.centerOffset === 0 && value.after.centerOffset > value.before.centerOffset && value.after.canvasHeight > value.before.canvasHeight && value.after.representative.top > value.before.representative.top && value.after.hub.top > value.before.hub.top && value.after.tree.top > value.before.tree.top, 'Bounded parent height-only resize recalculates central anchors', { beforeOffset: value.before.centerOffset, afterOffset: value.after.centerOffset, representativeDelta: value.after.representative.top - value.before.representative.top, hubDelta: value.after.hub.top - value.before.hub.top, treeDelta: value.after.tree.top - value.before.tree.top });
check(value.before.notifications === 0 && value.after.notifications > 0, 'Adaptive anchor change emits existing hm-orgchart-scale notification at unchanged scale', { before: value.before.notifications, after: value.after.notifications });
const scrollable = Object.entries(value.after.scroll).filter(([, item]) => ['auto', 'scroll'].includes(item.overflowX) && item.width > item.clientWidth + 1 || ['auto', 'scroll'].includes(item.overflowY) && item.height > item.clientHeight + 1);
const documentFits = ['root', 'body'].every(key => value.after.scroll[key].width <= 1921 && value.after.scroll[key].height <= 1081);
const inside = value.after.painted.every(item => item.left >= value.after.host.left - 1 && item.right <= value.after.host.right + 1 && item.top >= value.after.host.top - 1 && item.bottom <= value.after.host.bottom + 1);
check(!scrollable.length && documentFits && inside && value.after.stage.bottom <= value.after.host.bottom + 1, 'Bounded parent resize keeps actual content contained without document or internal scrollbars', { scrollable, scroll: value.after.scroll, inside });
} catch (error) { check(false, 'Anchor notification probe completed', { message: error.message, stack: error.stack }); }
check(errors.length === 0, 'Anchor notification probe console/page errors zero', errors);
const result = { checks, probes, errors, pass: checks.filter(item => item.status === 'PASS').length, fail: checks.filter(item => item.status === 'FAIL').length };
await fs.writeFile(path.join(output, 'anchor-notification.json'), JSON.stringify(result, null, 2));
await browser.close(); console.log(JSON.stringify({ pass: result.pass, fail: result.fail, checks, output: path.join(output, 'anchor-notification.json') })); process.exit(result.fail ? 1 : 0);
}
// Repeat only a failed stability probe, preserving its first-run evidence.
if (process.argv.includes('--stability-only')) {
const probes = [];
try {
await page.goto(`${origin}/iframe.html?id=orgchart--full-composition&viewMode=story`); await ready();
await page.evaluate(() => { window.__p95MountedStage = document.querySelector('.hm-orgchart-host').shadowRoot.querySelector('.hm-orgchart-stage'); });
for (const size of [{ width: 1920, height: 1080 }, { width: 1920, height: 1440 }, { width: 1440, height: 900 }, { width: 1920, height: 1080 }]) {
await page.setViewportSize(size); await settle();
const state = await page.evaluate(() => {
const host = document.querySelector('.hm-orgchart-story-full'), shadow = host.querySelector('.hm-orgchart-host').shadowRoot;
const stage = shadow.querySelector('.hm-orgchart-stage'), wrapper = shadow.querySelector('.hm-orgchart-composition');
const r = stage.getBoundingClientRect(), h = host.getBoundingClientRect(), css = getComputedStyle(host);
const left = h.left + (parseFloat(css.borderLeftWidth) || 0) + (parseFloat(css.paddingLeft) || 0), right = h.right - (parseFloat(css.borderRightWidth) || 0) - (parseFloat(css.paddingRight) || 0);
return { horizontalError: (r.left + r.right - left - right) / 2, sameMount: stage === window.__p95MountedStage, offset: Number(wrapper.dataset.centerOffset), scale: r.width / 1920 };
});
probes.push({ size, ...state });
check(state.sameMount && Math.abs(state.horizontalError) <= 1, `${size.width}x${size.height}-${probes.length}: modified centering helper retains same mount within 1px`, probes.at(-1));
if (size.width === 1440) {
const probe = await stability();
probes.at(-1).stability = probe;
check(probe.count === 0, '1440x900: twenty settled frames have zero Stage style mutations (failed item recheck)', probe);
}
}
const benchmark = await page.evaluate(async data => {
const api = await import('/core/index.js'), host = document.createElement('section');
Object.assign(host.style, { position: 'fixed', left: '0', top: '0', width: '1920px', height: '914.5px', zIndex: '1000', overflow: 'hidden' }); document.body.append(host);
const handle = api.createOrgChart({ container: host, dataSource: new api.JsonOrgChartDataSource(data), displayMode: 'readOnly', stylesheetUrls: { engine: '/core/engine.css', core: '/core/style.css' } });
try {
await handle.mount();
const shadow = host.querySelector('.hm-orgchart-host').shadowRoot;
await Promise.all([...shadow.querySelectorAll('link[rel="stylesheet"]')].map(link => link.sheet ? Promise.resolve() : new Promise((resolve, reject) => { link.addEventListener('load', resolve, { once: true }); link.addEventListener('error', reject, { once: true }); })));
await document.fonts.ready; for (let i = 0; i < 20; i++) await new Promise(requestAnimationFrame);
const stage = shadow.querySelector('.hm-orgchart-stage'), wrapper = shadow.querySelector('.hm-orgchart-composition');
const rect = element => { const r = element.getBoundingClientRect(); return { left: r.left, right: r.right, top: r.top, bottom: r.bottom, width: r.width, height: r.height }; };
const s = rect(stage), h = rect(host), status = rect(stage.querySelector('.hm-orgchart-slot-belowTree')), footer = rect(stage.querySelector('.hm-orgchart-slot-footer'));
return { scale: s.width / 1920, centerOffset: Number(wrapper.dataset.centerOffset), canvasHeight: Number(wrapper.dataset.canvasHeight), stage: s, host: h, status, footer, gap: footer.top - status.bottom, cards: stage.querySelectorAll('.hm-orgchart-employee').length, horizontalError: (s.left + s.right - h.left - h.right) / 2 };
} finally { handle.destroy(); host.remove(); }
}, fixture);
probes.push({ name: 'public-1920x914.5-base-benchmark', ...benchmark });
check(benchmark.scale === 1 && benchmark.centerOffset === 0 && benchmark.canvasHeight === 914.5 && Math.abs(benchmark.stage.height - 914.5) < .02, 'Original 1920x914.5 design benchmark retains scale 1 and center offset 0', benchmark);
check(Math.abs(benchmark.gap - 14.609375) < .02, 'Original design benchmark exposes 14.609375px status/footer gap', benchmark.gap);
check(benchmark.cards === 43 && Math.abs(benchmark.horizontalError) <= 1, 'Original design benchmark retains cards and content horizontal center', benchmark);
} catch (error) { check(false, 'Stability reproduction completed', { message: error.message, stack: error.stack }); }
check(errors.length === 0, 'Stability reproduction console/page errors zero', errors);
const result = { checks, probes, errors, pass: checks.filter(item => item.status === 'PASS').length, fail: checks.filter(item => item.status === 'FAIL').length };
await fs.writeFile(path.join(output, 'stability-recheck.json'), JSON.stringify(result, null, 2));
await browser.close(); console.log(JSON.stringify(result)); process.exit(result.fail ? 1 : 0);
}
try {
await page.goto(`${origin}/iframe.html?id=orgchart--full-composition&viewMode=story`); await ready();
await page.evaluate(() => { window.__p95MountedStage = document.querySelector('.hm-orgchart-host').shadowRoot.querySelector('.hm-orgchart-stage'); });
let first;
for (const size of [{ width: 1920, height: 1080 }, { width: 1920, height: 1440 }, { width: 1440, height: 900 }, { width: 1920, height: 1080 }]) {
await page.setViewportSize(size); await settle();
const name = `${size.width}x${size.height}-${measurements.length + 1}`, current = await snapshot(name); first ??= current;
check(current.sameMount, `${name}: same Full Composition mount retained`);
check(Math.abs(current.horizontalError) < .75, `${name}: actual wrapper horizontal center retained`, current.horizontalError);
check(current.central.length === first.central.length && current.central.every((item, index) => item.key === first.central[index].key && near(item.box, first.central[index].box)), `${name}: representative, hub and all organization/card relative coordinates retained after center offset`, { count: current.central.length, offset: current.offset });
check(current.paths.length === 5 && current.paths.every((item, index) => item.id === first.paths[index].id && item.d === first.paths[index].d && near(item.box, first.paths[index].box)), `${name}: all five SVG paths share central movement without coordinate drift`, current.paths);
check(Math.abs(current.managementEndpointError.x) < .05 && Math.abs(current.managementEndpointError.y) < .05, `${name}: management connector endpoint meets live header`, current.managementEndpointError);
check(current.arrows.length === 5 && current.arrows.every((item, index) => item.loaded && item.id === first.arrows[index].id && near(item.box, first.arrows[index].box) && item.hash === assetHashes[arrowAssets[item.id]]), `${name}: five original arrow assets and relative geometry retained`, current.arrows.map(({ id, hash, box }) => ({ id, hash, box })));
const old = baseline.measurements.find(item => item.name === `${size.width}x${size.height}`);
check(current.arrows.every(item => { const previous = old.arrows.find(arrow => arrow.id === item.id); return near(item.box, { x: previous.box.x, y: previous.box.y, width: previous.box.width, height: previous.box.height }); }), `${name}: collaboration arrow base placement unchanged from pre-P9.5 measurement`);
const settled = await stability();
check(settled.count === 0, `${name}: twenty settled frames have zero Stage style mutations`, settled);
}
check(measurements[1].offset > measurements[0].offset && Math.abs(measurements[3].offset - measurements[0].offset) < .01, 'Taller viewport increases center offset and reverse resize restores it', measurements.map(({ name, offset, canvasHeight }) => ({ name, offset, canvasHeight })));
check(first.links.length === 0, 'Canonical Full Story retains its existing unconfigured group titles', first.links);
// Compare each actually rendered arrow crop against the same crop with only
// that arrow hidden. A changed image proves painted pixels, not merely DOM presence.
for (const arrow of measurements.at(-1).arrows) {
const r = arrow.rect, left = Math.max(0, Math.floor(r.left)), top = Math.max(0, Math.floor(r.top));
const clip = { x: left, y: top, width: Math.ceil(r.right) - left, height: Math.ceil(r.bottom) - top };
const shown = await page.screenshot({ clip, animations: 'disabled' });
await page.evaluate(id => { const element = document.querySelector('.hm-orgchart-host').shadowRoot.querySelector(`image[data-arrow="${id}"]`); element.dataset.p95OldStyle = element.getAttribute('style') ?? ''; element.style.visibility = 'hidden'; }, arrow.id);
const hidden = await page.screenshot({ clip, animations: 'disabled' });
await page.evaluate(id => { const element = document.querySelector('.hm-orgchart-host').shadowRoot.querySelector(`image[data-arrow="${id}"]`); const style = element.dataset.p95OldStyle; if (style) element.setAttribute('style', style); else element.removeAttribute('style'); delete element.dataset.p95OldStyle; }, arrow.id);
const painted = !shown.equals(hidden); arrowPaint.push({ id: arrow.id, painted, clip, pngBytes: shown.length });
check(painted, `${arrow.id}: collaboration arrow contributes visible painted pixels`, arrowPaint.at(-1));
}
await page.screenshot({ path: path.join(output, 'interaction-artwork-1920x1080.png'), fullPage: true, animations: 'disabled' });
const employee = page.locator('.hm-orgchart-viewport .hm-orgchart-employee').first();
const employeeId = await employee.getAttribute('data-employee-id');
await employee.focus(); await page.keyboard.press('Enter');
const dialog = page.locator('.hm-orgchart-employee-dialog[open]'); await dialog.waitFor();
check(await dialog.getAttribute('data-employee-id') === employeeId && (await dialog.textContent()).includes('홍 길 동'), 'Full employee keyboard activation opens existing popup with employee identity', { employeeId });
await page.screenshot({ path: path.join(output, 'interaction-employee-popup.png'), fullPage: true });
await page.keyboard.press('Escape'); await dialog.waitFor({ state: 'hidden' }); await settle();
check(await employee.evaluate(element => element === element.getRootNode().activeElement), 'Full employee popup ESC restores card focus');
// The Story intentionally has no configured links. Exercise the public link
// API in an isolated temporary Workdesk mount; suppress native navigation.
const coreAvailable = await fetch(`${origin}/core/index.js`).then(response => response.ok).catch(() => false);
if (!coreAvailable) notVerified('Public organizationLinks native keyboard interaction', '/core/index.js route unavailable; canonical Story does not configure group links.');
else {
const linkState = await page.evaluate(async data => {
const api = await import('/core/index.js');
const host = document.createElement('section'); host.id = 'p95-link-probe';
Object.assign(host.style, { position: 'fixed', left: '0', top: '0', width: '1200px', height: '600px', zIndex: '1000', overflow: 'hidden' });
document.body.append(host);
const href = 'https://example.invalid/group/business';
const handle = api.createOrgChart({ container: host, dataSource: new api.JsonOrgChartDataSource(data), displayMode: 'readOnly', stylesheetUrls: { engine: '/core/engine.css', core: '/core/style.css' }, organizationLinks: { 'business-management': href } });
await handle.mount();
const shadow = host.querySelector('.hm-orgchart-host').shadowRoot;
await Promise.all([...shadow.querySelectorAll('link[rel="stylesheet"]')].map(link => link.sheet ? Promise.resolve() : new Promise((resolve, reject) => { link.addEventListener('load', resolve, { once: true }); link.addEventListener('error', reject, { once: true }); })));
for (let i = 0; i < 20; i++) await new Promise(requestAnimationFrame);
const link = shadow.querySelector('.hm-orgchart-organization-link');
window.__p95LinkProbe = { handle, host, shadow, clicks: 0 };
link.addEventListener('click', event => { event.preventDefault(); window.__p95LinkProbe.clicks++; });
return { links: shadow.querySelectorAll('.hm-orgchart-organization-link').length, href: link.href, nativeTag: link.tagName, tabIndex: link.tabIndex, beforeCards: shadow.querySelectorAll('.hm-orgchart-employee').length };
}, fixture);
const link = page.locator('#p95-link-probe .hm-orgchart-organization-link');
await link.focus();
check(await link.evaluate(element => element === element.getRootNode().activeElement) && linkState.links === 1 && linkState.nativeTag === 'A' && linkState.tabIndex === 0 && linkState.href === 'https://example.invalid/group/business', 'Public Full group link retains native anchor href and keyboard focus', linkState);
await page.keyboard.press('Enter'); await settle();
const afterLink = await page.evaluate(() => ({ clicks: window.__p95LinkProbe.clicks, cards: window.__p95LinkProbe.shadow.querySelectorAll('.hm-orgchart-employee').length, dialogs: window.__p95LinkProbe.shadow.querySelectorAll('dialog[open]').length, url: location.href }));
check(afterLink.clicks === 1 && afterLink.cards === linkState.beforeCards && afterLink.dialogs === 0 && afterLink.url.includes('orgchart--full-composition'), 'Group link keyboard activation preserves chart and suppresses network navigation', afterLink);
await page.evaluate(() => { window.__p95LinkProbe.handle.destroy(); window.__p95LinkProbe.host.remove(); delete window.__p95LinkProbe; });
}
} catch (error) { check(false, 'Focused Full interaction run completed', { message: error.message, stack: error.stack }); }
finally {
await page.evaluate(() => { if (window.__p95LinkProbe) { window.__p95LinkProbe.handle.destroy(); window.__p95LinkProbe.host.remove(); delete window.__p95LinkProbe; } }).catch(() => {});
check(errors.length === 0, 'No Full interaction console/page errors', errors);
const result = { startedAt, completedAt: new Date().toISOString(), origin, browser: browser.version(), checks, measurements, arrowPaint, assetHashes, errors, pass: checks.filter(item => item.status === 'PASS').length, fail: checks.filter(item => item.status === 'FAIL').length, notVerified: checks.filter(item => item.status === 'NOT VERIFIED').length };
await fs.writeFile(path.join(output, 'interactions.json'), JSON.stringify(result, null, 2));
await browser.close(); console.log(JSON.stringify({ pass: result.pass, fail: result.fail, notVerified: result.notVerified, output: path.join(output, 'interactions.json') }));
if (result.fail) process.exitCode = 1;
}
+94
View File
@@ -0,0 +1,94 @@
import fs from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { launchChromium } from '../../scripts/orgchart-browser-runtime.mjs';
const root = fileURLToPath(new URL('../../', import.meta.url));
const output = path.join(root,'test-results/org-chart-p9-5-full');
const phase = process.argv[2] ?? 'after';
const origin = process.env.ORGCHART_STORY_ORIGIN ?? 'http://127.0.0.1:6064';
const sizes = [{width:1920,height:1080},{width:1920,height:1440},{width:1440,height:900},{width:1280,height:1024},{width:390,height:844}];
const browser = await launchChromium({headless:true});
const page = await browser.newPage({viewport:sizes[0]});
const measurements = [], checks = [], errors = [];
page.setDefaultTimeout(30000);
page.on('pageerror',e=>errors.push(e.message));
page.on('console',m=>{if(m.type()==='error')errors.push(m.text());});
await fs.mkdir(output,{recursive:true});
const check=(pass,name,detail)=>{checks.push({name,status:pass?'PASS':'FAIL',detail});if(!pass)console.log('FAIL',name,JSON.stringify(detail));};
const settle=()=>page.evaluate(async()=>{await document.fonts.ready;for(let i=0;i<16;i++)await new Promise(requestAnimationFrame);});
async function ready(){
await page.waitForFunction(()=>{const s=document.querySelector('.hm-orgchart-host')?.shadowRoot;return s?.querySelector('.hm-orgchart-stage[data-workdesk-artwork]')&&!s.querySelector('[aria-busy="true"]')&&[...s.querySelectorAll('link[rel="stylesheet"]')].every(l=>l.sheet);});
await page.evaluate(async()=>{await Promise.all([...document.querySelector('.hm-orgchart-host').shadowRoot.querySelectorAll('img')].map(async i=>{i.loading='eager';await i.decode();}));}); await settle();
}
async function snapshot(name){
const s=await page.evaluate(async()=>{
const host=document.querySelector('.hm-orgchart-story-full'),shadow=host.querySelector('.hm-orgchart-host').shadowRoot;
const wrapper=shadow.querySelector('.hm-orgchart-composition'),stage=shadow.querySelector('.hm-orgchart-stage');
const rect=e=>{const r=e.getBoundingClientRect();return{left:r.left,right:r.right,top:r.top,bottom:r.bottom,width:r.width,height:r.height};};
const hb=rect(host),st=rect(stage),css=getComputedStyle(host),scale=st.width/1920;
const content={left:hb.left+parseFloat(css.borderLeftWidth)+parseFloat(css.paddingLeft),right:hb.right-parseFloat(css.borderRightWidth)-parseFloat(css.paddingRight),top:hb.top+parseFloat(css.borderTopWidth)+parseFloat(css.paddingTop),bottom:hb.bottom-parseFloat(css.borderBottomWidth)-parseFloat(css.paddingBottom)};
content.width=content.right-content.left;content.height=content.bottom-content.top;
const union=items=>{const b=[...items].map(rect).filter(r=>r.width&&r.height);const left=Math.min(...b.map(r=>r.left)),right=Math.max(...b.map(r=>r.right)),top=Math.min(...b.map(r=>r.top)),bottom=Math.max(...b.map(r=>r.bottom));return{left,right,top,bottom,width:right-left,height:bottom-top};};
const norm=b=>({x:(b.left-st.left)/scale,y:(b.top-st.top)/scale,width:b.width/scale,height:b.height/scale});
const heading=rect(stage.querySelector('.hm-orgchart-heading')),side=rect(stage.querySelector('.hm-orgchart-slot-side'));
const axis=rect(stage.querySelector('.hm-orgchart-axis')),tree=rect(stage.querySelector('.hm-orgchart-viewport'));
const status=rect(stage.querySelector('.hm-orgchart-slot-belowTree')),footer=rect(stage.querySelector('.hm-orgchart-slot-footer'));
const center=union(stage.querySelectorAll('.hm-orgchart-axis .hm-orgchart-employee,.hm-orgchart-hub,.hm-orgchart-viewport .hm-orgchart-employee,.hm-orgchart-viewport .hm-orgchart-organization-name'));
const cards=[...stage.querySelectorAll('.hm-orgchart-employee')].map(e=>({id:e.dataset.employeeId,rect:rect(e),box:norm(rect(e)),role:e.querySelector('.hm-orgchart-person-role')?.textContent,name:e.querySelector('.hm-orgchart-person-name')?.textContent,asset:e.dataset.cardAssetSize,style:Object.fromEntries(['fontFamily','fontSize','fontWeight','lineHeight','backgroundImage','backgroundColor','width','height'].map(k=>[k,getComputedStyle(e)[k]]))}));
const titles=[...stage.querySelectorAll('.hm-orgchart-viewport .hm-orgchart-organization-name')].map(e=>({id:e.closest('.node').dataset.organizationId,rect:rect(e),box:norm(rect(e)),style:Object.fromEntries(['fontFamily','fontSize','fontWeight','lineHeight','color','backgroundImage','width','height'].map(k=>[k,getComputedStyle(e)[k]]))}));
const arrows=await Promise.all([...stage.querySelectorAll('image[data-arrow]')].map(async e=>{
const img=new Image();img.src=e.getAttribute('href');await img.decode();
const box=rect(e);const blockers=[...stage.querySelectorAll('.hm-orgchart-employee,.hm-orgchart-organization-name,.hm-orgchart-hub')].filter(el=>{const r=rect(el);return Math.min(box.right,r.right)-Math.max(box.left,r.left)>.5&&Math.min(box.bottom,r.bottom)-Math.max(box.top,r.top)>.5;}).map(e=>e.className);
return{id:e.dataset.arrow,rect:box,box:norm(box),loaded:img.naturalWidth>0,natural:[img.naturalWidth,img.naturalHeight],blockers,display:getComputedStyle(e).display,visibility:getComputedStyle(e).visibility};
}));
const paths=[...stage.querySelectorAll('.hm-orgchart-workdesk-overlay path')].map(e=>({id:e.dataset.connector,d:e.getAttribute('d'),rect:rect(e)}));
const decorations=[...stage.querySelectorAll('.hm-orgchart-workdesk-decoration')].map(e=>({id:e.dataset.workdeskAsset,box:norm(rect(e)),rect:rect(e)}));
const centralCards=cards.filter(c=>!stage.querySelector('.hm-orgchart-slot-belowTree [data-employee-id="'+c.id+'"]'));
const collisions=centralCards.filter(c=>Math.min(c.rect.right,status.right)-Math.max(c.rect.left,status.left)>.5&&Math.min(c.rect.bottom,status.bottom)-Math.max(c.rect.top,status.top)>.5).map(c=>c.id);
const scroll=e=>({width:e.scrollWidth,clientWidth:e.clientWidth,height:e.scrollHeight,clientHeight:e.clientHeight,overflowX:getComputedStyle(e).overflowX,overflowY:getComputedStyle(e).overflowY});
return{size:{width:innerWidth,height:innerHeight},host:hb,content,wrapper:rect(wrapper),stage:st,scale,baseWidth:stage.offsetWidth,stageLayoutHeight:parseFloat(getComputedStyle(stage).height),top:{heading,side,bounds:union([stage.querySelector('.hm-orgchart-heading'),stage.querySelector('.hm-orgchart-slot-side')])},axis,tree,center,status,footer,
logical:{heading:norm(heading),side:norm(side),axis:norm(axis),tree:norm(tree),status:norm(status),footer:norm(footer),center:norm(center)},gap:footer.top-status.bottom,gapDesign:(footer.top-status.bottom)/scale,
horizontalError:(st.left+st.right-content.left-content.right)/2,centerOffset:Number(wrapper.dataset.centerOffset)||0,
centerAvailable:{top:Math.max(heading.bottom,side.bottom),bottom:status.top},centerErrorY:(center.top+center.bottom-Math.max(heading.bottom,side.bottom)-status.top)/2,
cards,titles,arrows,paths,decorations,collisions,totals:stage.querySelector('.hm-orgchart-slot-side').textContent,affiliations:stage.querySelector('.hm-orgchart-slot-footer').textContent,
layer:{overlay:getComputedStyle(stage.querySelector('.hm-orgchart-workdesk-overlay')).zIndex,tree:getComputedStyle(stage.querySelector('.hm-orgchart-viewport')).zIndex},
scroll:{root:scroll(document.documentElement),body:scroll(document.body),host:scroll(host),wrapper:scroll(wrapper)}};
});measurements.push({name,...s});await fs.writeFile(path.join(output,`${phase}-progress.json`),JSON.stringify({measurements,checks,errors},null,2));return s;
}
try{
const baseline=phase==='after'?JSON.parse(await fs.readFile(path.join(output,'before.json'),'utf8')):null;
for(const size of sizes){
await page.setViewportSize(size);await page.goto(`${origin}/iframe.html?id=orgchart--full-composition&viewMode=story`);await ready();
const name=`${size.width}x${size.height}`,s=await snapshot(name);
await page.screenshot({path:path.join(output,`${phase}-${name}.png`),fullPage:true});
if(phase==='after'){
const old=baseline.measurements.find(m=>m.name===name);
check(Math.abs(s.horizontalError)<=1,`${name}: wrapper horizontal center`,s.horizontalError);
check(s.arrows.length===5&&s.arrows.every(a=>a.loaded&&a.visibility==='visible'&&a.display!=='none'&&!a.blockers.length),`${name}: 5 loaded arrows without card/title intersections`,s.arrows);
check(s.gapDesign>=12&&s.gapDesign<=16,`${name}: status/footer design gap`,{before:old.gapDesign,after:s.gapDesign,visible:s.gap});
check(s.collisions.length===0,`${name}: center/status no collisions`,s.collisions);
check(s.cards.length===old.cards.length&&s.cards.every((c,i)=>c.id===old.cards[i].id&&JSON.stringify(c.style)===JSON.stringify(old.cards[i].style))&&s.titles.every((t,i)=>t.id===old.titles[i].id&&JSON.stringify(t.style)===JSON.stringify(old.titles[i].style)),`${name}: card/title typography and geometry preserved`);
check(s.totals===old.totals&&s.affiliations===old.affiliations,`${name}: headcounts/logos/text preserved`);
const movedBox=(current,previous)=>Math.abs(current.x-previous.x)<.1&&Math.abs(current.width-previous.width)<.1&&Math.abs(current.height-previous.height)<.1&&Math.abs(current.y-previous.y-s.centerOffset)<.1;
const inOldStatus=c=>c.box.x>=old.logical.status.x-.1&&c.box.x+c.box.width<=old.logical.status.x+old.logical.status.width+.1&&c.box.y>=old.logical.status.y-.1&&c.box.y+c.box.height<=old.logical.status.y+old.logical.status.height+.1;
check(s.cards.every((c,i)=>inOldStatus(old.cards[i])||movedBox(c.box,old.cards[i].box))&&s.titles.every((t,i)=>movedBox(t.box,old.titles[i].box))&&movedBox(s.logical.axis,old.logical.axis)&&movedBox(s.logical.tree,old.logical.tree),`${name}: entire central chart keeps relative coordinates`,s.centerOffset);
check(s.arrows.every((a,i)=>a.id===old.arrows[i].id&&movedBox(a.box,old.arrows[i].box))&&s.paths.every((p,i)=>p.id===old.paths[i].id&&p.d===old.paths[i].d)&&s.decorations.every((d,i)=>d.id===old.decorations[i].id&&movedBox(d.box,old.decorations[i].box)),`${name}: 5 arrows/connectors/decorations share center offset without resizing`);
check(s.scroll.root.width<=size.width+1&&s.scroll.root.height<=size.height+1&&s.scroll.body.width<=size.width+1&&s.scroll.body.height<=size.height+1,`${name}: document no horizontal/vertical scroll`,s.scroll);
const outside=[...s.cards.map(c=>c.rect),...s.titles.map(t=>t.rect),...s.arrows.map(a=>a.rect),s.top.heading,s.top.side,s.footer].filter(r=>r.left<s.content.left-1||r.right>s.content.right+1||r.top<s.content.top-1||r.bottom>s.content.bottom+1);
check(!outside.length,`${name}: all actual content contained, no overflow clipping`,outside);
check(Math.abs(s.logical.heading.y-old.logical.heading.y)<.2&&Math.abs(s.logical.side.y-old.logical.side.y)<.2,`${name}: top anchors unchanged`,{old:old.top,after:s.top});
const footerInset=(s.stage.bottom-s.footer.bottom)/s.scale;
check(Math.abs(footerInset-(old.stage.bottom-old.footer.bottom)/old.scale)<.2,`${name}: footer bottom anchor`,footerInset);
const centerBase=old.logical.center;
const deltas=s.cards.filter(c=>!c.id.startsWith('leave-')&&!c.id.startsWith('dispatch-')).map(c=>{const previous=old.cards.find(o=>o.id===c.id);return{x:c.box.x-previous.box.x,y:c.box.y-previous.box.y};});
const central=s.cards.filter(c=>s.tree.top<=c.rect.top&&c.rect.bottom<=s.tree.bottom+.5);
check(Math.abs(s.scale-Math.min(1,s.content.width/1920,s.content.height/914.5))<.002,`${name}: one proportional scale without enlargement`,s.scale);
if(s.centerAvailable.bottom-s.centerAvailable.top>=s.center.height)check(Math.abs(s.centerErrorY)<1,`${name}: center occupies available vertical middle`,s.centerErrorY);
}
}
}catch(e){check(false,'Full layout measurement completed',{message:e.message,stack:e.stack});}
check(!errors.length,'Console/page errors zero',errors);
const result={phase,origin,browser:browser.version(),measurements,checks,errors,pass:checks.filter(c=>c.status==='PASS').length,fail:checks.filter(c=>c.status==='FAIL').length};
await fs.writeFile(path.join(output,`${phase}.json`),JSON.stringify(result,null,2));await browser.close();
console.log(JSON.stringify({phase,measurements:measurements.map(m=>({name:m.name,scale:m.scale,h:m.stageLayoutHeight,center:m.logical.center,status:m.logical.status,footer:m.logical.footer,gap:m.gapDesign,arrows:m.arrows.map(a=>({id:a.id,blockers:a.blockers})),centerError:m.centerErrorY})),pass:result.pass,fail:result.fail}));if(result.fail)process.exitCode=1;
+187
View File
@@ -0,0 +1,187 @@
// Focused P9.5 transform geometry and pointer-coordinate probes; no P9.4 replay.
import fs from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { launchChromium } from '../../scripts/orgchart-browser-runtime.mjs';
const root = fileURLToPath(new URL('../../', import.meta.url));
const output = path.join(root, 'test-results/org-chart-p9-5');
const origin = (process.env.ORGCHART_STORY_ORIGIN ?? 'http://127.0.0.1:6064').replace(/\/$/, '');
const startedAt = new Date().toISOString(), checks = [], measurements = [], errors = [];
const pointerOnly = process.argv.includes('--pointer-only');
await fs.mkdir(output, { recursive: true });
if (pointerOnly) {
const initial = JSON.parse(await fs.readFile(path.join(output, 'geometry-initial.json'), 'utf8'));
measurements.push(...initial.measurements);
for (const c of initial.checks) {
if (c.name === 'Focused geometry browser console/page errors zero') continue;
if (c.name.includes(': pointer move starts at fitted scale')) continue;
if (c.status === 'PASS') checks.push(c);
else if (c.name.endsWith(': connector endpoints reach intended targets')) {
checks.push({ ...c, status: c.detail.maxLocalError < 0.75 ? 'PASS' : 'FAIL', detail: { ...c.detail, toleranceLocalPx: 0.75, reason: 'Integer offset geometry and fractional CSS widths differ by up to 0.5 local px; evaluated from preserved initial measurements without browser replay.' } });
}
}
}
const browser = await launchChromium({ headless: true });
const page = await browser.newPage({ viewport: { width: 1440, height: 1700 } });
page.setDefaultTimeout(30000);
page.on('pageerror', error => errors.push({ type: 'pageerror', message: error.message }));
page.on('console', message => { if (message.type() === 'error') errors.push({ type: 'console', message: message.text() }); });
const check = (pass, name, detail) => { checks.push({ name, status: pass ? 'PASS' : 'FAIL', detail }); fs.writeFile(path.join(output, 'geometry-progress.json'), JSON.stringify({ startedAt, checks, measurements, errors }, null, 2)).catch(() => {}); if (!pass) console.log('FAIL', name, JSON.stringify(detail)); };
const settle = () => page.evaluate(async () => { await document.fonts.ready; for (let i = 0; i < 10; i++) await new Promise(requestAnimationFrame); });
async function ready(count) {
await page.waitForFunction(count => {
const shadow = document.querySelector('.hm-orgchart-host')?.shadowRoot;
return shadow && !shadow.querySelector('[aria-busy="true"]') && [...shadow.querySelectorAll('link[rel="stylesheet"]')].every(link => link.sheet)
&& (count === undefined || shadow.querySelectorAll('.hm-orgchart-employee').length === count);
}, count, { timeout: 60000 });
await settle();
}
async function setup(type, label) {
await page.goto(`${origin}/iframe.html?id=orgchart--expanded-org-chart&viewMode=story`);
await page.getByRole('button', { name: /^회사 선택/ }).waitFor(); await ready(140);
await page.getByRole('button', { name: /^회사 선택/ }).click();
const panel = page.getByRole('dialog', { name: '회사 선택', exact: true }); await panel.waitFor();
const all = panel.getByRole('checkbox', { name: '전체 선택', exact: true });
for (let i = 0; i < 2 && await all.getAttribute('aria-checked') !== 'false'; i++) { await all.click(); await ready(); }
await ready(0); await panel.getByRole('checkbox', { name: '한맥기술', exact: true }).click(); await ready(20);
await page.keyboard.press('Escape'); await panel.waitFor({ state: 'hidden' });
if (type !== 'tb') {
await page.getByRole('combobox', { name: '조직도 표시 유형', exact: true }).click();
await page.getByRole('option', { name: label, exact: true }).click(); await ready(20);
}
await page.locator('.hm-orgchart-viewport').scrollIntoViewIfNeeded(); await settle();
}
async function snapshot(name) {
const measurement = await page.evaluate(() => {
const shadow = document.querySelector('.hm-orgchart-host').shadowRoot;
const viewport = shadow.querySelector('.hm-orgchart-viewport'), forest = shadow.querySelector('.hm-orgchart-forest');
const scale = Number(viewport.dataset.scale), origin = forest.getBoundingClientRect();
const localPoint = p => ({ x: (p.x - origin.left) / scale, y: (p.y - origin.top) / scale });
const localRect = element => { const r = element.getBoundingClientRect(); return { x: (r.left - origin.left) / scale, y: (r.top - origin.top) / scale, width: r.width / scale, height: r.height / scale }; };
const endPoint = p => localPoint(p.getPointAtLength(p.getTotalLength()).matrixTransform(p.getScreenCTM()));
const employeePaths = [...shadow.querySelectorAll('.hm-orgchart-employee-connectors path')].map(p => {
const article = p.closest('.hm-orgchart-organization');
const card = [...article.querySelectorAll('.hm-orgchart-employee')].find(card => card.dataset.employeeId === p.dataset.employeeId);
const box = localRect(card), direction = p.dataset.direction;
const expected = direction === 'left' ? { x: box.x, y: box.y + box.height / 2 }
: direction === 'right' ? { x: box.x + box.width, y: box.y + box.height / 2 } : { x: box.x + box.width / 2, y: box.y };
const endpoint = endPoint(p);
return { id: p.dataset.employeeId, direction, d: p.getAttribute('d'), endpoint, expected, error: Math.hypot(endpoint.x - expected.x, endpoint.y - expected.y) };
});
const organizationPaths = [...shadow.querySelectorAll('.hm-orgchart-organization-connectors path')].map(p => {
const tree = p.closest('.hm-orgchart-tree');
const node = [...tree.querySelectorAll('.node')].find(node => node.dataset.organizationId === p.dataset.organizationId);
const heading = localRect(node.querySelector('.hm-orgchart-organization-name'));
const expected = p.dataset.direction === 'left' ? { x: heading.x, y: heading.y + heading.height / 2 } : { x: heading.x + heading.width / 2, y: heading.y };
const endpoint = endPoint(p);
return { id: p.dataset.organizationId, parentId: p.dataset.parentId, direction: p.dataset.direction, d: p.getAttribute('d'), endpoint, expected, error: Math.hypot(endpoint.x - expected.x, endpoint.y - expected.y) };
});
const cards = [...shadow.querySelectorAll('.hm-orgchart-employee')].map(card => ({ id: card.dataset.employeeId, organizationId: card.closest('.node').dataset.organizationId, ...localRect(card) }));
return { layout: shadow.querySelector('.hm-orgchart-expanded').dataset.autoLayout, scale, x: Number(viewport.dataset.panX), y: Number(viewport.dataset.panY), cards, employeePaths, organizationPaths, organizationConnectorPresentation: organizationPaths.length ? 'measured-svg' : 'existing-TB-pseudo-elements' };
});
measurements.push({ name, ...measurement }); return measurement;
}
function validateEndpoints(s, label) {
const paths = [...s.employeePaths, ...s.organizationPaths];
check(s.cards.length === 20 && s.employeePaths.length === 20, `${label}: all 20 cards retain employee connectors`, { cards: s.cards.length, employeePaths: s.employeePaths.length });
check(paths.every(p => p.error < 0.75), `${label}: connector endpoints reach intended targets`, { paths: paths.length, organizationPaths: s.organizationPaths.length, maxLocalError: Math.max(0, ...paths.map(p => p.error)), toleranceLocalPx: 0.75 });
if (s.layout !== 'tb') check(s.organizationPaths.length > 0, `${label}: organization routes are measured`, s.organizationPaths.length);
}
function compareGeometry(before, after, label) {
let maxDelta = 0;
const cardKeys = ['x', 'y', 'width', 'height'];
for (const a of before.cards) {
const b = after.cards.find(b => b.id === a.id);
if (!b) { maxDelta = Infinity; break; }
for (const key of cardKeys) maxDelta = Math.max(maxDelta, Math.abs(a[key] - b[key]));
}
for (const key of ['employeePaths', 'organizationPaths']) for (const a of before[key]) {
const b = after[key].find(b => b.id === a.id);
if (!b || a.d !== b.d) { maxDelta = Infinity; break; }
maxDelta = Math.max(maxDelta, Math.abs(a.endpoint.x - b.endpoint.x), Math.abs(a.endpoint.y - b.endpoint.y));
}
check(maxDelta < 0.1, `${label}: forest-local card/connector geometry is unchanged`, { maxLocalDelta: maxDelta, beforeScale: before.scale, afterScale: after.scale });
}
async function pan() {
await page.locator('.hm-orgchart-viewport').focus();
await page.keyboard.press('ArrowLeft'); await page.keyboard.press('ArrowUp'); await settle();
}
async function pointerMove(type) {
const toggle = page.getByRole('switch', { name: /Drag & Drop|조직 이동/ });
if (await toggle.getAttribute('aria-checked') !== 'true') await toggle.click();
await page.getByRole('button', { name: 'Fit to View', exact: true }).click(); await settle();
const fitted = await snapshot(`${type}-drag-fit`); await pan();
const panned = await snapshot(`${type}-drag-pan`);
check(panned.x !== fitted.x && panned.y !== fitted.y && panned.scale === fitted.scale, `${type}: pointer move starts at fitted scale with nonzero manual Pan`, { fitted: { x: fitted.x, y: fitted.y, scale: fitted.scale }, panned: { x: panned.x, y: panned.y, scale: panned.scale } });
const pair = await page.evaluate(() => {
const shadow = document.querySelector('.hm-orgchart-host').shadowRoot, viewport = shadow.querySelector('.hm-orgchart-viewport').getBoundingClientRect();
const box = element => { const r = element.getBoundingClientRect(); return { left: r.left, top: r.top, right: r.right, bottom: r.bottom, x: r.left + r.width / 2, y: r.top + r.height / 2 }; };
const inside = r => r.left > viewport.left + 4 && r.right < viewport.right - 4 && r.top > viewport.top + 4 && r.bottom < viewport.bottom - 4
&& r.left > 4 && r.right < innerWidth - 4 && r.top > 4 && r.bottom < innerHeight - 4;
const sources = [...shadow.querySelectorAll('.hm-orgchart-employees .hm-orgchart-employee')].map(element => ({ element, rect: box(element) })).filter(item => inside(item.rect));
sources.sort((a, b) => Math.hypot(a.rect.x - (viewport.left + viewport.right) / 2, a.rect.y - (viewport.top + viewport.bottom) / 2) - Math.hypot(b.rect.x - (viewport.left + viewport.right) / 2, b.rect.y - (viewport.top + viewport.bottom) / 2));
for (const source of sources) {
const sourceOrg = source.element.closest('.node').dataset.organizationId;
const targets = [...shadow.querySelectorAll('.hm-orgchart-expanded-toggle')].filter(element => element.dataset.organizationId !== sourceOrg && element.closest('.node').dataset.parentId !== '').map(element => ({ element, rect: box(element) })).filter(item => inside(item.rect));
targets.sort((a, b) => Math.hypot(a.rect.x - source.rect.x, a.rect.y - source.rect.y) - Math.hypot(b.rect.x - source.rect.x, b.rect.y - source.rect.y));
if (targets.length) return { employeeId: source.element.dataset.employeeId, beforeOrganizationId: sourceOrg, targetOrganizationId: targets[0].element.dataset.organizationId, source: source.rect, target: targets[0].rect };
}
throw new Error('No visible employee/other-organization pointer pair after Fit/Pan');
});
// Native pointer focus may reveal/scroll a source. Settle that before taking
// client coordinates, and verify both endpoints through live shadow hit tests.
await page.locator(`.hm-orgchart-employee[data-employee-id="${pair.employeeId}"]`).focus(); await settle();
const actualPair = await page.evaluate(pair => {
const shadow = document.querySelector('.hm-orgchart-host').shadowRoot;
const employee = [...shadow.querySelectorAll('.hm-orgchart-employee')].find(item => item.dataset.employeeId === pair.employeeId);
const target = [...shadow.querySelectorAll('.hm-orgchart-expanded-toggle')].find(item => item.dataset.organizationId === pair.targetOrganizationId);
const center = element => { const r = element.getBoundingClientRect(); return { x: r.left + r.width / 2, y: r.top + r.height / 2 }; };
const sourcePoint = center(employee), targetPoint = center(target);
return { ...pair, source: sourcePoint, target: targetPoint, sourceHit: shadow.elementFromPoint(sourcePoint.x, sourcePoint.y)?.closest('.hm-orgchart-employee')?.dataset.employeeId, targetHit: shadow.elementFromPoint(targetPoint.x, targetPoint.y)?.closest('.node[data-organization-id]')?.dataset.organizationId };
}, pair);
check(actualPair.sourceHit === pair.employeeId && actualPair.targetHit === pair.targetOrganizationId, `${type}: focused pointer endpoints are visible and hit intended DOM targets`, actualPair);
await page.mouse.move(actualPair.source.x, actualPair.source.y); await page.mouse.down();
await page.mouse.move(actualPair.source.x + 8, actualPair.source.y, { steps: 2 });
await page.mouse.move(actualPair.target.x, actualPair.target.y, { steps: 14 });
const hover = await page.evaluate(() => {
const shadow = document.querySelector('.hm-orgchart-host').shadowRoot;
return { dragging: shadow.querySelector('.hm-orgchart-viewport').dataset.editDragging, target: shadow.querySelector('[data-drop-hover="allowed"]')?.dataset.organizationId, feedback: shadow.querySelector('.hm-orgchart-move-feedback').textContent };
});
check(hover.dragging === 'true' && hover.target === pair.targetOrganizationId, `${type}: actual pointer hit testing highlights intended transformed target`, { pair, hover });
await page.mouse.up();
await page.waitForFunction(({ employeeId, targetOrganizationId }) => {
const shadow = document.querySelector('.hm-orgchart-host').shadowRoot;
const card = [...shadow.querySelectorAll('.hm-orgchart-employee')].find(card => card.dataset.employeeId === employeeId);
return card?.closest('.node').dataset.organizationId === targetOrganizationId && shadow.querySelector('.hm-orgchart-move-feedback').dataset.moveStatus === 'success';
}, pair); await ready(20);
const moved = await snapshot(`${type}-after-pointer-move`);
check(moved.cards.find(card => card.id === pair.employeeId)?.organizationId === pair.targetOrganizationId, `${type}: pointer release moves employee to intended organization`, pair);
validateEndpoints(moved, `${type} after pointer move`);
}
try {
for (const [type, label] of [['tb', '세로형 TB'], ['lr', '가로형 LR'], ['hybrid', '혼합형 Hybrid']]) {
try {
await setup(type, label);
if (!pointerOnly) {
const centered = await snapshot(`${type}-centered`); validateEndpoints(centered, `${type} centered`);
await pan(); const panned = await snapshot(`${type}-panned`); compareGeometry(centered, panned, `${type} Pan`); validateEndpoints(panned, `${type} panned`);
await page.getByRole('button', { name: '확대', exact: true }).click(); await settle();
const zoomed = await snapshot(`${type}-zoomed`); compareGeometry(centered, zoomed, `${type} Zoom`); validateEndpoints(zoomed, `${type} zoomed`);
await page.getByRole('button', { name: 'Fit to View', exact: true }).click(); await settle();
const fitted = await snapshot(`${type}-fitted`); compareGeometry(centered, fitted, `${type} Fit`); validateEndpoints(fitted, `${type} fitted`);
}
await pointerMove(type);
if (type === 'hybrid') await page.screenshot({ path: path.join(output, 'geometry-pointer-hybrid.png'), fullPage: true });
} catch (error) {
check(false, `${type}: focused geometry section completed`, { message: error.message, stack: error.stack });
await page.mouse.up().catch(() => {});
}
}
} finally {
check(errors.length === 0, 'Focused geometry browser console/page errors zero', errors);
const result = { startedAt, completedAt: new Date().toISOString(), origin, browser: browser.version(), repairedPointerOnly: pointerOnly, initialEvidence: pointerOnly ? 'geometry-initial.json' : undefined, scope: 'P9.5 only: 1 company × 20 staff; TB/LR/Hybrid transforms and one actual employee pointer move per mode; existing TB organization pseudo-elements unchanged', measurements, checks, errors, pass: checks.filter(c => c.status === 'PASS').length, fail: checks.filter(c => c.status === 'FAIL').length };
await fs.writeFile(path.join(output, 'geometry.json'), JSON.stringify(result, null, 2));
console.log(JSON.stringify({ pass: result.pass, fail: result.fail, measurements: measurements.length, output: path.join(output, 'geometry.json') }));
await browser.close(); if (result.fail) process.exitCode = 1;
}
@@ -0,0 +1,87 @@
// Verify actual consumers and OPEN popup ownership, not just the presence of DOM.
import fs from 'node:fs/promises';
import path from 'node:path';
import { createHash } from 'node:crypto';
import { launchChromium } from '../../scripts/orgchart-browser-runtime.mjs';
const output=path.resolve('test-results/org-chart-p9-5-popup-parity');
const story=(process.env.ORGCHART_STORY_ORIGIN??'http://127.0.0.1:6064').replace(/\/$/,'');
const vanilla=(process.env.ORGCHART_VANILLA_ORIGIN??'http://127.0.0.1:4178').replace(/\/$/,'');
const checks=[],measurements=[],errors=[],startedAt=new Date().toISOString();
const check=(pass,name,detail)=>{checks.push({name,status:pass?'PASS':'FAIL',detail});if(!pass)console.log('FAIL',name,JSON.stringify(detail));};
const browser=await launchChromium({headless:true}),page=await browser.newPage({viewport:{width:1440,height:900}});
page.setDefaultTimeout(15000);
page.on('pageerror',error=>errors.push(error.message));page.on('console',msg=>{if(msg.type()==='error')errors.push(msg.text());});
const settle=()=>page.evaluate(async()=>{await document.fonts.ready;for(let i=0;i<12;i++)await new Promise(requestAnimationFrame);});
const coreOpen='dialog.hm-orgchart-employee-dialog[open]',hmOpen='[data-component="HMOrgChartEmployeeDialog"][data-state="open"]';
const refClose='[data-slot="dialog-content"] button[data-variant="ghost"]';
async function snapshot(selector){return page.locator(selector).evaluate(button=>{
const rect=el=>{const r=el.getBoundingClientRect();return{x:r.x,y:r.y,width:r.width,height:r.height};};
const c=getComputedStyle(button),box=rect(button),popup=button.closest('dialog,[data-slot="dialog-content"]'),pbox=rect(popup),svg=button.querySelector('svg');
return{box,popup:pbox,inset:{top:box.y-pbox.y,right:pbox.x+pbox.width-box.x-box.width},focusVisible:button.matches(':focus-visible'),
css:Object.fromEntries(['width','height','padding','border','borderRadius','color','backgroundColor','outlineStyle','outlineOffset','transition','cursor','position','top','right','boxSizing','backgroundClip','boxShadow'].map(k=>[k,c[k]])),
svg:svg&&{box:rect(svg),attrs:Object.fromEntries(['viewBox','fill','stroke','stroke-width','stroke-linecap','stroke-linejoin','aria-hidden'].map(k=>[k,svg.getAttribute(k)])),paths:[...svg.querySelectorAll('path')].map(p=>p.getAttribute('d'))},
popupType:popup.matches('dialog')?'Core native':'React HMDesign Dialog',employeeId:popup.dataset.employeeId,
content:popup.querySelector('dl')?.textContent,closeName:button.getAttribute('aria-label')||button.textContent.trim(),root:button.getRootNode() instanceof ShadowRoot?'ShadowRoot':'Document'
};
});}
await page.goto(`${story}/iframe.html?id=components-overlay-dialog--all&viewMode=story`);await page.getByRole('button',{name:'기본',exact:true}).click();await page.locator(refClose).waitFor();await settle();
await page.locator(refClose).evaluate(el=>el.blur());await page.mouse.move(0,0);const reference={normal:await snapshot(refClose)};
await page.locator(refClose).hover();await settle();reference.hover=await snapshot(refClose);
await page.mouse.move(0,0);await page.keyboard.press('Tab');await page.locator(refClose).focus();await settle();reference.focus=await snapshot(refClose);
await page.locator('[data-slot="dialog-content"]').screenshot({path:path.join(output,'hm-dialog-reference-popup.png')});
measurements.push({name:'HM Dialog actual public component',...reference});
const cases=[
{name:'storybook-full',url:`${story}/iframe.html?id=orgchart--full-composition&viewMode=story`,kind:'Core native'},
{name:'storybook-expanded-hm',url:`${story}/iframe.html?id=orgchart--expanded-org-chart&viewMode=story`,kind:'React HMDesign Dialog'},
{name:'storybook-expanded-core',url:`${story}/iframe.html?id=orgchart--expanded-org-chart&viewMode=story`,kind:'Core native',coreOption:true},
{name:'vanilla-index',url:`${vanilla}/index.html`,kind:'Core native',vanilla:true},
{name:'vanilla-full',url:`${vanilla}/p1.html?mode=readOnly`,kind:'Core native',vanilla:true},
{name:'vanilla-expanded',url:`${vanilla}/expanded.html`,kind:'Core native',vanilla:true},
];
const near=(a,b)=>Math.abs(a-b)<.02,eq=(a,b)=>JSON.stringify(a)===JSON.stringify(b);
for(const width of [1440,390])for(const item of cases){
const label=`${item.name} ${width}`;await page.setViewportSize({width,height:900});await page.goto(item.url);
await page.waitForFunction(()=>{const root=document.querySelector('.hm-orgchart-host')?.shadowRoot;return root?.querySelector('.hm-orgchart-employee[role="button"]')&&[...root.querySelectorAll('link[rel="stylesheet"]')].every(link=>link.sheet);});await settle();
if(item.coreOption){await page.getByRole('combobox',{name:'직원 정보 팝업',exact:true}).click();await page.getByRole('option',{name:'Core 기본 팝업',exact:true}).click();await settle();}
// Use a rendered card inside the horizontally clipped viewport; the Forest
// may legitimately extend beyond the viewport without shrinking its cards.
const choice=await page.evaluate(()=>{
const root=document.querySelector('.hm-orgchart-host').shadowRoot,v=root.querySelector('.hm-orgchart-viewport').getBoundingClientRect();
const cards=[...root.querySelectorAll('.hm-orgchart-employee[role="button"]')];
const candidates=cards.filter(el=>{const r=el.getBoundingClientRect();return r.width>0&&r.height>0&&r.left>=Math.max(0,v.left)&&r.right<=Math.min(innerWidth,v.right);});
const card=(candidates.length?candidates:cards).sort((a,b)=>Math.abs(a.getBoundingClientRect().left-innerWidth/2)-Math.abs(b.getBoundingClientRect().left-innerWidth/2))[0];
return{employeeId:card.dataset.employeeId,aria:card.getAttribute('aria-label')};
});
const trigger=page.locator(`.hm-orgchart-employee[data-employee-id="${choice.employeeId}"]`).first();
await trigger.click();const popupSelector=item.kind==='Core native'?coreOpen:hmOpen;await page.locator(popupSelector).waitFor();await settle();
const closeSelector=item.kind==='Core native'?`${coreOpen} .hm-orgchart-employee-dialog-close`:`${hmOpen} button[data-variant="ghost"]`;
const ownership=await page.evaluate(()=>({core:[...document.querySelectorAll('.hm-orgchart-host')].reduce((n,h)=>n+h.shadowRoot.querySelectorAll('dialog.hm-orgchart-employee-dialog[open]').length,0),hm:document.querySelectorAll('[data-component="HMOrgChartEmployeeDialog"][data-state="open"]').length}));
check(ownership.core===(item.kind==='Core native'?1:0)&&ownership.hm===(item.kind==='Core native'?0:1),`${label}: actual open popup owner matches`,ownership);
await page.locator(closeSelector).evaluate(el=>el.blur());await page.mouse.move(0,0);await settle();const normal=await snapshot(closeSelector);
const keys=['width','height','padding','border','borderRadius','color','backgroundColor','outlineStyle','outlineOffset','transition','cursor','position','top','right','boxSizing','backgroundClip'];
check(keys.every(k=>normal.css[k]===reference.normal.css[k]),`${label}: HM normal X button CSS matches`,normal.css);
check(near(normal.box.width,28)&&near(normal.box.height,28)&&near(normal.svg?.box.width,16)&&near(normal.svg?.box.height,16)&&eq(normal.inset,reference.normal.inset)&&eq(normal.svg?.attrs,reference.normal.svg.attrs)&&eq(normal.svg?.paths,reference.normal.svg.paths),`${label}: HM X shape, stroke, size and inset match`,normal);
await page.locator(popupSelector).screenshot({path:path.join(output,`${item.name}-${width}-popup.png`)});
await page.screenshot({path:path.join(output,`${item.name}-${width}-context.png`)});
await page.locator(closeSelector).hover();await settle();const hover=await snapshot(closeSelector);
check(hover.css.backgroundColor===reference.hover.css.backgroundColor&&hover.css.color===reference.hover.css.color,`${label}: HM hover matches`,hover.css);
await page.mouse.move(0,0);await page.keyboard.press('Tab');await page.locator(closeSelector).focus();await settle();const focus=await snapshot(closeSelector);
check(focus.focusVisible&&focus.css.border===reference.focus.css.border&&focus.css.outlineStyle==='none'&&focus.css.boxShadow.includes('oklab(0.462622 -0.0726663 0.0150061 / 0.5) 0px 0px 0px 3px'),`${label}: HM focus ring matches`,focus.css);
// Compare the actual policies: Core retains the open dialog on backdrop;
// Radix HM Dialog closes it. Closing must restore the original shadow card.
const expectedOpen=item.kind==='Core native';await page.mouse.click(2,2);await settle();
check((await page.locator(popupSelector).count()>0)===expectedOpen,`${label}: existing outside-click policy retained`,{expected:expectedOpen?'stay open':'close'});
if(!expectedOpen){check(await trigger.evaluate(el=>el.getRootNode().activeElement===el),`${label}: outside-close restores employee focus`);await trigger.click();await page.locator(popupSelector).waitFor();await settle();}
await page.locator(closeSelector).click();await settle();
check(await page.locator(popupSelector).count()===0&&await trigger.evaluate(el=>el.getRootNode().activeElement===el),`${label}: X closes and returns focus`);
await trigger.focus();await page.keyboard.press('Enter');await page.locator(popupSelector).waitFor();await settle();
const reopened=await snapshot(closeSelector);check(reopened.employeeId===normal.employeeId&&reopened.content===normal.content,`${label}: reopen keeps selected employee/body data`);
await page.keyboard.press('Escape');await settle();check(await page.locator(popupSelector).count()===0&&await trigger.evaluate(el=>el.getRootNode().activeElement===el),`${label}: ESC closes and returns focus`);
if(item.vanilla)check(await page.evaluate(()=>!window.React&&[...document.querySelectorAll('script[src]')].every(s=>!/(?:react|storybook)/i.test(s.src))),`${label}: actual example runs without React`);
measurements.push({name:label,url:item.url,expectedOwner:item.kind,ownership,choice,normal,hover,focus});
}
const copied=[];for(const file of ['index.js','style.css']){const hash=async location=>createHash('sha256').update(await fs.readFile(location)).digest('hex');copied.push({file,core:await hash(`packages/org-chart/dist/${file}`),vanilla:await hash(`examples/org-chart/dist/assets/${file}`)});}
check(copied.every(item=>item.core===item.vanilla),'Actual Vanilla example JS/CSS are byte-identical to modified Core build',copied);
check(errors.length===0,'New console/page errors: zero',errors);
const result={startedAt,completedAt:new Date().toISOString(),browser:browser.version(),checks,measurements,copied,errors,pass:checks.filter(c=>c.status==='PASS').length,fail:checks.filter(c=>c.status==='FAIL').length};
await fs.writeFile(path.join(output,'result.json'),JSON.stringify(result,null,2));await browser.close();console.log(JSON.stringify({pass:result.pass,fail:result.fail,output}));if(result.fail)process.exitCode=1;
+126
View File
@@ -0,0 +1,126 @@
import fs from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { launchChromium } from '../../scripts/orgchart-browser-runtime.mjs';
const root = fileURLToPath(new URL('../../', import.meta.url));
const output = path.join(root, 'test-results/org-chart-p9-5');
const phase = process.argv[2] ?? 'after';
const origin = process.env.ORGCHART_STORY_ORIGIN ?? 'http://127.0.0.1:6065';
await fs.mkdir(output, { recursive: true });
const browser = await launchChromium({ headless: true });
const page = await browser.newPage({ viewport: { width: 1440, height: 1100 } });
page.setDefaultTimeout(30000);
const errors = [], measurements = [], checks = [];
page.on('pageerror', error => errors.push(error.message));
page.on('console', message => { if (message.type() === 'error') errors.push(message.text()); });
const settle = () => page.evaluate(async () => { await document.fonts.ready; for (let i = 0; i < 12; i++) await new Promise(requestAnimationFrame); });
async function ready(count) {
await page.waitForFunction(count => {
const shadow = document.querySelector('.hm-orgchart-host')?.shadowRoot;
return shadow && !shadow.querySelector('[aria-busy="true"]') && [...shadow.querySelectorAll('link[rel="stylesheet"]')].every(link => link.sheet)
&& (count === undefined || shadow.querySelectorAll('.hm-orgchart-employee').length === count);
}, count, { timeout: 60000 });
await settle();
}
async function measure(name) {
const value = await page.evaluate(() => {
const shadow = document.querySelector('.hm-orgchart-host').shadowRoot;
const viewport = shadow.querySelector('.hm-orgchart-viewport'), forest = shadow.querySelector('.hm-orgchart-forest');
const rect = element => { const r = element.getBoundingClientRect(); return { left:r.left,top:r.top,width:r.width,height:r.height,right:r.right,bottom:r.bottom }; };
const union = elements => {
const boxes = [...elements].filter(element => element.getClientRects().length && getComputedStyle(element).visibility !== 'hidden').map(rect).filter(r => r.width > 0 && r.height > 0);
if (!boxes.length) return null;
const left = Math.min(...boxes.map(r => r.left)), right = Math.max(...boxes.map(r => r.right));
const top = Math.min(...boxes.map(r => r.top)), bottom = Math.max(...boxes.map(r => r.bottom));
return { left,right,top,bottom,width:right-left,height:bottom-top };
};
const v = rect(viewport), f = rect(forest);
const content = { left:v.left+viewport.clientLeft,top:v.top+viewport.clientTop,width:viewport.clientWidth,height:viewport.clientHeight };
const cx = content.left + content.width/2, cy = content.top + content.height/2;
const trees = union(shadow.querySelectorAll('.hm-orgchart-tree'));
const visible = union(shadow.querySelectorAll('.hm-orgchart-organization'));
const error = bounds => bounds ? { x:(bounds.left+bounds.right)/2-cx,y:(bounds.top+bounds.bottom)/2-cy } : null;
return { wrapper:rect(shadow.querySelector('.hm-orgchart-expanded')),viewport:v,content,contentCenter:{x:cx,y:cy},forest:f,trees,visible,
error:{forest:error(f),trees:error(trees),visible:error(visible)},scale:Number(viewport.dataset.scale),x:Number(viewport.dataset.panX),y:Number(viewport.dataset.panY),
unscaledForest:{width:forest.offsetWidth,height:forest.offsetHeight},employees:shadow.querySelectorAll('.hm-orgchart-employee').length,
companies:shadow.querySelectorAll('.node[data-parent-id=""]').length,layout:shadow.querySelector('.hm-orgchart-expanded').dataset.autoLayout };
});
measurements.push({ name, ...value });
await fs.writeFile(path.join(output, `${phase}-progress.json`), JSON.stringify({ measurements,checks,errors }, null, 2));
return value;
}
const check = (pass, name, detail) => { checks.push({name,status:pass?'PASS':'FAIL',detail}); if (!pass) console.log('FAIL',name,JSON.stringify(detail)); };
const centered = (s, name, vertical = false) => check(Math.abs(s.error.visible.x) < 0.75 && (!vertical || Math.abs(s.error.visible.y) < 0.75), name, s.error);
async function select(label, option) {
await page.getByRole('combobox', { name:label, exact:true }).click();
await page.getByRole('option', { name:option, exact:true }).click(); await ready();
}
async function companies(names) {
await page.getByRole('button',{name:/^회사 선택/}).click();
const panel = page.getByRole('dialog',{name:'회사 선택',exact:true}); await panel.waitFor();
const all = panel.getByRole('checkbox',{name:'전체 선택',exact:true});
for (let i=0;i<2 && await all.getAttribute('aria-checked') !== 'false';i++) { await all.click(); await ready(); }
await ready(0);
if (names.length === 7) { await all.click(); await ready(140); await page.keyboard.press('Escape'); await panel.waitFor({state:'hidden'}); return; }
for (const name of names) { await panel.getByRole('checkbox',{name,exact:true}).click(); await ready(); }
await page.keyboard.press('Escape'); await panel.waitFor({state:'hidden'}); await ready();
}
async function count(count, total) {
await select('회사당 직원 수',`${count}명`);
const apply = page.getByRole('button',{name:'데이터 적용',exact:true}); if (await apply.isEnabled()) await apply.click(); await ready(total);
}
async function open() {
await page.goto(`${origin}/iframe.html?id=orgchart--expanded-org-chart&viewMode=story`);
await page.getByRole('button',{name:/^회사 선택/}).waitFor(); await ready(140);
}
async function fit() { await page.getByRole('button',{name:'Fit to View',exact:true}).click(); await settle(); }
try {
await open(); const initial = await measure('initial-7x20-tb');
await page.screenshot({path:path.join(output,`${phase}-initial.png`),fullPage:true});
if (phase === 'after') { centered(initial,'Initial 7-company forest horizontally centered'); check(initial.scale === 1,'Initial oversized forest keeps 100% scale',initial.scale); }
for (const [companyNames, label] of [[['한맥기술'],'one'],[['한맥기술','삼안'],'two'],[['한맥기술','삼안','장헌산업','장헌','피티씨','한라','바론'],'seven']]) {
if (process.argv[3] && process.argv[3] !== label) continue;
for (const size of [20,100,300]) {
for (const [type,labelText] of [['tb','세로형 TB'],['lr','가로형 LR'],['hybrid','혼합형 Hybrid']]) {
await open(); await companies(companyNames); await count(size,companyNames.length*size);
await select('조직도 표시 유형',labelText);
const s = await measure(`${label}-${size}-${type}-auto`);
if (phase === 'after') { centered(s,`${label}/${size}/${type}: automatic horizontal center`); check(s.scale === 1,`${label}/${size}/${type}: no automatic shrink`,s.scale); }
await fit(); const fitted = await measure(`${label}-${size}-${type}-fit`);
if (phase === 'after') {
centered(fitted,`${label}/${size}/${type}: Fit center both axes`,true);
check(fitted.visible.left >= fitted.content.left+15 && fitted.visible.right <= fitted.content.left+fitted.content.width-15
&& fitted.visible.top >= fitted.content.top+15 && fitted.visible.bottom <= fitted.content.top+fitted.content.height-15,`${label}/${size}/${type}: Fit contains visible tree`,fitted.visible);
}
}
}
}
if (phase === 'after') {
// User Pan preservation across resize and deliberate content/layout changes.
await open(); await companies(['한맥기술']); await fit();
const viewport = page.locator('.hm-orgchart-viewport'); await viewport.scrollIntoViewIfNeeded(); await viewport.focus();
await page.keyboard.press('ArrowLeft'); await page.keyboard.press('ArrowUp'); await settle();
const panned = await measure('user-pan');
await page.setViewportSize({width:1280,height:1100}); await ready(); const resized = await measure('pan-resize');
await companies(['한맥기술','삼안']); const filtered = await measure('pan-company');
await count(100,200); const changed = await measure('pan-data');
await select('조직도 표시 유형','가로형 LR'); const switched = await measure('pan-layout');
for (const [name,s] of [['resize',resized],['company selection',filtered],['data',changed],['layout',switched]]) {
check(s.x===panned.x && s.y===panned.y && s.scale===panned.scale,`Manual Pan retained through ${name}`,{before:panned,after:s});
}
await fit(); centered(await measure('fit-after-pan'),'Explicit Fit restores center after user Pan',true);
for (const width of [390,768,1280,1440]) {
await page.setViewportSize({width,height:1100}); await open(); await companies(['한맥기술']);
centered(await measure(`width-${width}-initial`),`${width}px automatic horizontal center`);
await fit(); centered(await measure(`width-${width}-fit`),`${width}px Fit center`,true);
}
await page.setViewportSize({width:1440,height:1100}); await open(); await companies(['한맥기술']);
await fit(); await page.screenshot({path:path.join(output,'after-single-fit.png'),fullPage:true});
}
} catch(error) { check(false,'Measurement completed',{message:error.message,stack:error.stack}); }
check(errors.length===0,'Browser console/page errors zero',errors);
const result = {phase,origin,browser:browser.version(),measurements,checks,errors,pass:checks.filter(c=>c.status==='PASS').length,fail:checks.filter(c=>c.status==='FAIL').length};
await fs.writeFile(path.join(output,`${phase}.json`),JSON.stringify(result,null,2));
console.log(JSON.stringify({phase,measurements:measurements.length,pass:result.pass,fail:result.fail,initial:measurements[0],output:path.join(output,`${phase}.json`)}));
await browser.close(); if(result.fail) process.exitCode=1;
+251
View File
@@ -0,0 +1,251 @@
// P9 focused visual integration checks. Historical P7/P8 full suites stay unrun.
import fs from 'node:fs/promises';
import { writeFileSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { createHash } from 'node:crypto';
import { launchChromium } from '../../scripts/orgchart-browser-runtime.mjs';
const root = fileURLToPath(new URL('../../', import.meta.url));
const output = path.join(root, 'test-results/org-chart-p9');
const origin = (process.env.ORGCHART_STORY_ORIGIN ?? 'http://127.0.0.1:6061').replace(/\/$/, '');
const args = process.argv.slice(2), baselineMode = args.includes('--baseline');
const sections = args.flatMap((arg, index) => arg === '--section' ? (args[index + 1] ?? '').split(',') : arg.startsWith('--section=') ? arg.slice(10).split(',') : []).filter(Boolean);
const run = name => !sections.length || sections.includes(name);
const widths = args.filter(arg => arg.startsWith('--width=')).map(arg => Number(arg.slice(8)));
const suffix = (baselineMode ? '-baseline' : sections.length ? '-' + sections.join('-') : '') + (widths.length ? '-' + widths.join('-') : '');
const startedAt = new Date().toISOString(), results = [], measurements = [], errors = [];
await fs.mkdir(output, { recursive: true });
const persist = () => writeFileSync(path.join(output, `browser-progress${suffix}.json`), JSON.stringify({ startedAt, origin, baselineMode, sections, results, measurements, errors }, null, 2));
const check = (pass, name, detail) => {
results.push({ name, status: pass ? 'PASS' : 'FAIL', ...(detail === undefined ? {} : { detail }) }); persist();
if (!pass) console.log('FAIL', name, JSON.stringify(detail));
};
const stories = {
overview: 'orgchart--logo-personnel-summary', department: 'orgchart--department-colors',
fixed: 'orgchart--full-composition', expanded: 'orgchart--expanded-org-chart',
};
const sizes = [{ width: 1920, height: 1080 }, { width: 1440, height: 900 }, { width: 390, height: 844 }];
const selectedSizes = sizes.filter(size => !widths.length || widths.includes(size.width));
const colors = { hanmac: '#cb2e28', saman: '#f08300', 'jangheon-industry': '#4580a6', jangheon: '#1d418d', ptc: '#84338e', halla: '#0068b7', baron: '#0c367f' };
const browser = await launchChromium({ headless: true });
const context = await browser.newContext({ viewport: sizes[1] });
context.on('page', target => {
target.on('pageerror', error => { errors.push({ type: 'pageerror', url: target.url(), message: error.message }); persist(); });
target.on('console', message => { if (message.type() === 'error') { errors.push({ type: 'console', url: target.url(), message: message.text(), location: message.location() }); persist(); } });
});
const page = await context.newPage();
page.setDefaultTimeout(20000);
let expectedHosts = 1;
async function settle() { await page.evaluate(async () => { await document.fonts.ready; for (let i = 0; i < 5; i++) await new Promise(resolve => requestAnimationFrame(resolve)); }); }
async function ready(requirePage = true) {
if (requirePage) await page.locator('.sb-page-title').waitFor();
await page.waitForFunction(expected => {
const hosts = [...document.querySelectorAll('.hm-orgchart-host,.hm-orgchart-component-host')];
return hosts.length === expected && hosts.every(host => host.shadowRoot && [...host.shadowRoot.querySelectorAll('link[rel="stylesheet"]')].every(link => link.sheet) && !host.shadowRoot.querySelector('[aria-busy="true"]') && [...host.shadowRoot.querySelectorAll('img')].every(image => image.complete));
}, expectedHosts);
await settle();
}
async function goto(name, size = sizes[1]) { expectedHosts = name === 'overview' ? 3 : 1; await page.setViewportSize(size); await page.goto(`${origin}/iframe.html?id=${stories[name]}&viewMode=story`); await ready(); }
async function capture(name, fullPage = true) { await page.screenshot({ path: path.join(output, name + '.png'), fullPage }); }
async function select(label, option) { await page.getByRole('combobox', { name: label, exact: true }).click(); await page.getByRole('option', { name: option, exact: true }).click(); await ready(); }
async function section(name, work) { if (!run(name)) return; try { await work(); } catch (error) { check(false, `${name}: runner completed`, { message: error.message, stack: error.stack }); } }
const equal = (a, b) => JSON.stringify(a) === JSON.stringify(b);
async function snapshot() {
return page.evaluate(async () => {
const shadows = [...document.querySelectorAll('.hm-orgchart-host,.hm-orgchart-component-host')].map(host => host.shadowRoot).filter(Boolean);
const all = selector => shadows.flatMap(shadow => [...shadow.querySelectorAll(selector)]);
const first = selector => all(selector)[0];
const rect = element => { if (!element) return null; const r = element.getBoundingClientRect(); return { x: r.x, y: r.y, width: r.width, height: r.height, right: r.right, bottom: r.bottom }; };
const properties = ['fontFamily', 'fontSize', 'fontWeight', 'lineHeight', 'color', 'backgroundColor', 'backgroundImage', 'borderTopWidth', 'borderRightWidth', 'borderBottomWidth', 'borderLeftWidth', 'borderTopColor', 'borderRightColor', 'borderBottomColor', 'borderLeftColor', 'borderRadius', 'boxShadow', 'paddingTop', 'paddingRight', 'paddingBottom', 'paddingLeft', 'textAlign', 'whiteSpace', 'overflowWrap', 'verticalAlign'];
const style = (element, dimensions = false) => element ? Object.fromEntries([...properties, ...(dimensions ? ['width', 'height'] : [])].map(key => [key, getComputedStyle(element)[key]])) : null;
const table = first('.hm-orgchart-workdesk-position-table');
const summary = table ? {
title: first('.hm-orgchart-organization-totals-title')?.textContent,
rows: [...table.rows].map(row => [...row.cells].map(cell => ({ text: cell.textContent, tag: cell.tagName, scope: cell.getAttribute('scope'), position: cell.dataset.position ?? null, total: cell.dataset.totalCount ?? null }))),
styles: {
section: style(first('.hm-orgchart-organization-totals')),
title: style(first('.hm-orgchart-organization-totals-title'), true), frame: style(first('.hm-orgchart-workdesk-summary-frame'), true), table: style(table, true),
cells: [...table.rows].map(row => [...row.cells].map(cell => style(cell, true))),
},
footer: [...table.tFoot.rows[0].cells].slice(1).map(cell => Number(cell.textContent)),
body: [...table.tBodies[0].rows].map(row => [...row.cells].slice(1).map(cell => Number(cell.textContent))),
} : null;
const bar = first('.hm-orgchart-affiliation-bar');
const affiliation = bar ? {
items: [...bar.querySelectorAll('.hm-orgchart-affiliation-bar-item')].map(item => ({ id: item.dataset.affiliationId ?? 'total', count: item.dataset.employeeCount ?? item.dataset.totalCount, name: item.querySelector('dt')?.textContent, value: item.querySelector('dd')?.textContent })),
styles: { bar: style(bar), items: [...bar.querySelectorAll('.hm-orgchart-affiliation-bar-item')].map(item => ({ item: style(item), name: style(item.querySelector('dt')), value: style(item.querySelector('dd')), mark: style(item.querySelector('.hm-orgchart-affiliation-mark'), true), imageFit: item.querySelector('img') ? getComputedStyle(item.querySelector('img')).objectFit : null })) },
box: rect(bar),
} : null;
const wrapper = first('.hm-orgchart-composition'), stage = first('.hm-orgchart-stage');
const scale = Number(wrapper?.dataset.scale ?? 1), stageBox = rect(stage);
const round = value => Math.round(value * 20) / 20;
const fixed = stage ? {
scale, wrapper: rect(wrapper), stage: stageBox, baseWidth: stage.offsetWidth, baseHeight: stage.offsetHeight, matrix: getComputedStyle(stage).transform,
stageStyle: style(stage), background: getComputedStyle(wrapper).backgroundColor,
htmlHash: [...new Uint8Array(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(stage.innerHTML)))].map(byte => byte.toString(16).padStart(2, '0')).join(''),
preflightElements: all('.hm-orgchart-stage .hm-orgchart-employee,.hm-orgchart-stage .hm-orgchart-organization-totals,.hm-orgchart-stage .hm-orgchart-organization-totals-title,.hm-orgchart-stage .hm-orgchart-workdesk-summary-frame,.hm-orgchart-stage .hm-orgchart-workdesk-position-table,.hm-orgchart-stage .hm-orgchart-workdesk-position-table th,.hm-orgchart-stage .hm-orgchart-workdesk-position-table td,.hm-orgchart-stage .hm-orgchart-affiliation-bar,.hm-orgchart-stage .hm-orgchart-affiliation-bar-list,.hm-orgchart-stage .hm-orgchart-affiliation-bar-item,.hm-orgchart-stage .hm-orgchart-affiliation-bar-item dt,.hm-orgchart-stage .hm-orgchart-affiliation-bar-item dd,.hm-orgchart-stage .hm-orgchart-affiliation-bar .hm-orgchart-affiliation-mark').map(element => ({ className: element.className, text: element.textContent, style: Object.fromEntries(['fontFamily', 'fontSize', 'fontWeight', 'lineHeight', 'color', 'backgroundColor', 'backgroundImage', 'width', 'height', 'padding', 'border', 'borderRadius', 'boxShadow', 'textAlign', 'whiteSpace', 'gap', 'display', 'flexWrap', 'alignItems', 'justifyContent'].map(key => [key, getComputedStyle(element)[key]])) })),
invariants: all('.hm-orgchart-stage .hm-orgchart-employee,.hm-orgchart-stage .hm-orgchart-organization,.hm-orgchart-stage .hm-orgchart-organization-totals,.hm-orgchart-stage .hm-orgchart-affiliation-bar,.hm-orgchart-stage .hm-orgchart-workdesk-overlay').map(element => {
const r = rect(element); return { className: element.className, id: element.dataset.employeeId ?? element.dataset.organizationId ?? null, box: { x: round((r.x - stageBox.x) / scale), y: round((r.y - stageBox.y) / scale), width: round(r.width / scale), height: round(r.height / scale) }, style: style(element, true) };
}),
scroll: ['.hm-orgchart-shell', '.hm-orgchart-content', '.hm-orgchart-composition', '.hm-orgchart-viewport'].map(selector => {
const element = first(selector); if (!element) return { selector, missing: true };
const css = getComputedStyle(element); return { selector, left: element.scrollLeft, top: element.scrollTop, scrollWidth: element.scrollWidth, clientWidth: element.clientWidth, scrollHeight: element.scrollHeight, clientHeight: element.clientHeight, overflowX: css.overflowX, overflowY: css.overflowY };
}),
} : null;
const logos = all('[data-component="Logo"] .hm-orgchart-affiliation-mark').map(mark => {
const img = mark.querySelector('img'); return { id: mark.dataset.affiliationId, available: mark.dataset.logoAvailable, box: rect(mark), loaded: Boolean(img?.complete && img.naturalWidth > 0), naturalWidth: img?.naturalWidth, naturalHeight: img?.naturalHeight, fit: img ? getComputedStyle(img).objectFit : null, style: style(mark) };
});
const department = first('[data-component="DepartmentColors"]');
const departments = department ? [...department.querySelectorAll('.hm-orgchart-color-preview')].map(row => ({
name: row.querySelector('.hm-orgchart-color-department-name').textContent, box: rect(row), preview: rect(row.querySelector('.hm-orgchart-preview-grid')),
swatches: [...row.querySelectorAll('[data-color-variable]')].map(swatch => ({ variable: swatch.dataset.colorVariable, property: swatch.dataset.cssProperty, value: swatch.parentElement.querySelector('.hm-orgchart-color-value').textContent })),
cards: [...row.querySelectorAll('.hm-orgchart-employee')].map(card => ({ kind: card.dataset.cardKind, width: card.offsetWidth, height: card.offsetHeight, box: rect(card), font: getComputedStyle(card).fontFamily, asset: card.dataset.cardAssetSize })),
})) : null;
const viewport = first('.hm-orgchart-expanded .hm-orgchart-viewport'), forest = first('.hm-orgchart-forest');
const expanded = first('.hm-orgchart-expanded');
const hex = color => color.replace(/rgb\((\d+),\s*(\d+),\s*(\d+)\)/g, (_, r, g, b) => '#' + [r, g, b].map(x => Number(x).toString(16).padStart(2, '0')).join(''));
const auto = expanded ? {
type: expanded.dataset.autoLayout, viewport: rect(viewport), forest: rect(forest), employees: expanded.querySelectorAll('.hm-orgchart-employee').length,
titles: [...expanded.querySelectorAll('.node[data-company-id]')].map(node => { const title = node.querySelector('.hm-orgchart-organization-name'), toggle = node.querySelector('.hm-orgchart-expanded-toggle'); return { company: node.dataset.companyId, color: hex(getComputedStyle(title).color), buttonColor: hex(getComputedStyle(toggle).color), background: hex(getComputedStyle(title).backgroundColor) }; }),
cards: [...expanded.querySelectorAll('.hm-orgchart-employee')].map(card => { const css = getComputedStyle(card), direction = card.dataset.connectionDirection; return { company: card.dataset.companyId, direction, width: card.offsetWidth, height: card.offsetHeight, border: hex(css[direction === 'left' ? 'borderLeftColor' : direction === 'right' ? 'borderRightColor' : 'borderTopColor']), image: Boolean(card.querySelector('img')) }; }),
connectors: expanded.querySelectorAll('svg,.edge,.lines').length,
} : null;
const common = Object.fromEntries(['.sb-page-title', '.sb-page-description', '.sb-page-section-title', '.sb-page-section-header'].map(selector => [selector, style(document.querySelector(selector))]));
return { title: document.querySelector('.sb-page-title')?.textContent, titles: [...document.querySelectorAll('.sb-page-section-title')].map(element => element.textContent), document: { width: innerWidth, scrollWidth: document.documentElement.scrollWidth, scrollHeight: document.documentElement.scrollHeight, clientHeight: document.documentElement.clientHeight }, common, summary, affiliation, logos, departments, fixed, auto };
});
}
function noHorizontalOverflow(state) { return state.document.scrollWidth <= state.document.width + 1; }
function contains(outer, inner, tolerance = 1) { return outer && inner && inner.x >= outer.x - tolerance && inner.y >= outer.y - tolerance && inner.right <= outer.right + tolerance && inner.bottom <= outer.bottom + tolerance; }
function fitted(state) { return state.auto && contains(state.auto.viewport, state.auto.forest, 2); }
function affiliationStyles(styles) {
// Overview's permitted wrapping uses vertical content padding rather than a
// fixed 55.4px bar height. Every item/typography/logo style remains shared.
return { ...styles, bar: Object.fromEntries(Object.entries(styles.bar).filter(([key]) => !['paddingTop', 'paddingBottom'].includes(key))) };
}
function sourceInvariant(fixed) { return { width: fixed.baseWidth, height: fixed.baseHeight, style: fixed.stageStyle, contents: fixed.invariants }; }
function geometryNear(a, b) {
if (!a || !b || a.width !== b.width || a.height !== b.height || !equal(a.style, b.style) || a.contents.length !== b.contents.length) return false;
return a.contents.every((item, index) => { const other = b.contents[index]; return item.className === other.className && item.id === other.id && equal(item.style, other.style) && Object.keys(item.box).every(key => Math.abs(item.box[key] - other.box[key]) <= 0.1); });
}
try {
if (baselineMode) {
const snapshots = {};
for (const name of ['overview', 'department', 'fixed']) { await goto(name); snapshots[name] = await snapshot(); await capture(`baseline-${name}-1440`); }
check(Boolean(snapshots.fixed.summary && snapshots.fixed.affiliation && snapshots.fixed.fixed), 'Baseline: Fixed shared presenters and geometry captured');
await fs.writeFile(path.join(output, 'visual-baseline.json'), JSON.stringify({ startedAt, completedAt: new Date().toISOString(), origin, viewport: sizes[1], snapshots }, null, 2));
} else {
const baselinePath = await fs.access(path.join(output, 'preflight.json')).then(() => 'preflight.json', () => 'visual-baseline.json');
const baseline = JSON.parse(await fs.readFile(path.join(output, baselinePath), 'utf8'));
const preflightFixed = baselinePath === 'preflight.json' ? baseline.snapshots.fixed[0] : null;
const preflightHash = preflightFixed ? createHash('sha256').update(preflightFixed.stage.html).digest('hex') : null;
await goto('fixed'); const reference = await snapshot();
await section('overview', async () => {
for (const size of selectedSizes) {
await goto('overview', size); const state = await snapshot(); measurements.push({ name: `Overview ${size.width}`, state });
check(equal(state.titles, ['관계사·회사 로고', '조직·직위별 인원 현황', '관계사별 인원 현황']), `Overview ${size.width}: exact three confirmed section titles`, state.titles);
check(Boolean(state.summary) && equal(state.summary.rows, reference.summary.rows), `Overview ${size.width}: table rows, positions and totals equal Fixed`, state.summary?.rows);
check(Boolean(state.summary) && equal(state.summary.styles, reference.summary.styles), `Overview ${size.width}: table, heading, cells, borders and font reuse Fixed computed styles`, { overview: state.summary?.styles, fixed: reference.summary.styles });
check(Boolean(state.summary) && state.summary.footer.every(Number.isFinite) && state.summary.footer.slice(0, -1).reduce((a, b) => a + b, 0) === state.summary.footer.at(-1) && state.summary.body.every(row => row.slice(0, -1).reduce((a, b) => a + b, 0) === row.at(-1)), `Overview ${size.width}: numeric position sums and row subtotals reconcile`, { footer: state.summary?.footer, body: state.summary?.body });
check(Boolean(state.affiliation) && equal(state.affiliation.items, reference.affiliation.items) && equal(affiliationStyles(state.affiliation.styles), affiliationStyles(reference.affiliation.styles)), `Overview ${size.width}: affiliation data, typography, logos, border and background reuse Fixed`, { overview: state.affiliation, fixed: reference.affiliation });
const previewBox = await page.locator('.sb-page-section').last().locator('.sb-page-section-body').boundingBox();
check(state.affiliation?.box.x >= previewBox.x - 1 && state.affiliation.box.right <= previewBox.x + previewBox.width + 1 && state.affiliation.box.y >= previewBox.y - 1 && state.affiliation.box.bottom <= previewBox.y + previewBox.height + 1, `Overview ${size.width}: complete affiliation bar is contained in page flow`, { bar: state.affiliation?.box, preview: previewBox });
check(state.logos.length === 7 && state.logos.every(logo => logo.available === 'true' && logo.loaded && logo.fit === 'contain' && logo.naturalWidth > 0 && logo.naturalHeight > 0), `Overview ${size.width}: seven approved logos loaded with preserved contain sizing`, state.logos);
check(noHorizontalOverflow(state), `Overview ${size.width}: no document horizontal overflow`, state.document);
check(equal(state.common, reference.common), `Overview ${size.width}: common StoryPage typography and border remain identical`);
await capture(`overview-${size.width}`);
}
});
await section('department', async () => {
for (const size of selectedSizes) {
await goto('department', size); const state = await snapshot(); measurements.push({ name: `Department ${size.width}`, state });
check(state.departments?.length === 6 && state.departments.every(row => row.swatches.every(swatch => /#[0-9a-f]{6}/i.test(swatch.value))), `Department ${size.width}: six preserved organization color previews have computed HEX values`, state.departments?.map(row => ({ name: row.name, values: row.swatches })));
check(state.departments?.every(row => row.cards.every(card => card.width === (card.kind === 'employee' ? 108 : 146) && card.height === (card.kind === 'employee' ? 52 : 68) && card.font.includes('Noto Sans KR') && contains(row.box, card.box))), `Department ${size.width}: original card geometry, font and containment`, state.departments?.map(row => ({ name: row.name, cards: row.cards })));
check(noHorizontalOverflow(state) && equal(state.common, reference.common), `Department ${size.width}: responsive page retains shared frame styles`, state.document);
check(state.departments?.every((row, index, rows) => rows.every((other, otherIndex) => otherIndex === index || Math.abs(row.box.y - other.box.y) > 1 || Math.abs(row.preview.y - other.preview.y) <= 1)), `Department ${size.width}: card-preview starts align across each shared row`, state.departments?.map(row => ({ name: row.name, rowY: row.box.y, previewY: row.preview.y })));
measurements.push({ name: `Department ${size.width} card-preview alignment`, previews: state.departments?.map(row => ({ name: row.name, row: row.box, preview: row.preview })) });
await capture(`department-colors-${size.width}`);
}
});
await section('fixed', async () => {
for (const size of selectedSizes) {
await goto('fixed', size); const before = await snapshot();
await page.locator('.hm-orgchart-composition').scrollIntoViewIfNeeded();
const box = await page.locator('.hm-orgchart-composition').boundingBox();
await page.mouse.move(box.x + box.width / 2, box.y + Math.min(30, box.height / 2)); await page.mouse.wheel(80, 80); await settle();
const state = await snapshot(), fixed = state.fixed; measurements.push({ name: `Fixed ${size.width}`, state });
const matrix = fixed.matrix.match(/matrix\(([^)]+)\)/)?.[1].split(',').map(Number);
check(fixed.scale > 0 && fixed.scale <= 1 && matrix && Math.abs(matrix[0] - matrix[3]) < 0.0001 && contains(fixed.wrapper, fixed.stage) && fixed.invariants.every(item => item.box.x >= -1 && item.box.y >= -1 && item.box.x + item.box.width <= fixed.baseWidth + 1 && item.box.y + item.box.height <= fixed.baseHeight + 1), `Fixed ${size.width}: complete chart bounds and proportional fit`, { scale: fixed.scale, matrix, wrapper: fixed.wrapper, stage: fixed.stage });
check(fixed.scroll.every(item => item.missing || (item.left === 0 && item.top === 0 && (item.overflowX === 'hidden' || item.scrollWidth <= item.clientWidth + 1) && (item.overflowY === 'hidden' || item.scrollHeight <= item.clientHeight + 1))), `Fixed ${size.width}: no actual internal scroll after wheel`, { before: before.fixed.scroll, after: fixed.scroll });
const preserved = preflightFixed ? fixed.baseWidth === preflightFixed.stage.width && fixed.baseHeight === preflightFixed.stage.height && equal(fixed.preflightElements, preflightFixed.elements) && fixed.htmlHash === preflightHash : geometryNear(sourceInvariant(fixed), sourceInvariant(baseline.snapshots.fixed.fixed)) && equal(state.summary, baseline.snapshots.fixed.summary) && equal(state.affiliation.items, baseline.snapshots.fixed.affiliation.items) && equal(state.affiliation.styles, baseline.snapshots.fixed.affiliation.styles);
check(preserved, `Fixed ${size.width}: baseline card/layout and summary styling preserved`, { baseline: baselinePath, naturalWidth: fixed.baseWidth, naturalHeight: fixed.baseHeight, baselineElementCount: preflightFixed?.elements.length, elementCount: fixed.preflightElements.length, computedStylesEqual: preflightFixed ? equal(fixed.preflightElements, preflightFixed.elements) : undefined, stageDOMHashEqual: preflightFixed ? fixed.htmlHash === preflightHash : undefined });
check(noHorizontalOverflow(state), `Fixed ${size.width}: no document horizontal overflow`, state.document);
await page.evaluate(() => scrollTo(0, 0)); await capture(`full-composition-${size.width}`);
}
});
await section('expanded', async () => {
for (const size of selectedSizes) {
await goto('expanded', size);
for (const [option, type] of [['세로형 TB', 'tb'], ['가로형 LR', 'lr'], ['혼합형 Hybrid', 'hybrid']]) {
if (type !== 'tb') await select('조직도 표시 유형', option);
const state = await snapshot(); measurements.push({ name: `Expanded ${type} ${size.width}`, state });
check(state.auto?.type === type && state.auto.employees === 20 && fitted(state) && noHorizontalOverflow(state), `Expanded ${type} ${size.width}: representative layout renders unchanged sample and Fits`, state.auto);
check(state.auto?.titles.length > 0 && state.auto.titles.every(title => title.color === '#ffffff' && title.buttonColor === '#ffffff' && title.background === colors[title.company]) && new Set(state.auto.titles.map(title => title.company)).size === 7, `Expanded ${type} ${size.width}: all company title backgrounds and white text preserved`, state.auto?.titles);
check(state.auto?.cards.every(card => card.border === colors[card.company] && ['top', 'left', 'right'].includes(card.direction)) && state.auto.connectors > 0, `Expanded ${type} ${size.width}: company colored directional borders and connectors`, state.auto?.cards);
await capture(`expanded-${type}-${size.width}`);
}
}
if (!widths.length) {
await goto('expanded');
const toggle = page.locator('.hm-orgchart-expanded-toggle:not(:disabled)').first(), id = await toggle.getAttribute('data-organization-id');
await toggle.hover(); check(await toggle.evaluate(element => getComputedStyle(element).color) === 'rgb(255, 255, 255)', 'Expanded: hover title remains white');
await toggle.focus(); check(await toggle.evaluate(element => getComputedStyle(element).color) === 'rgb(255, 255, 255)', 'Expanded: focus title remains white');
await toggle.press('Enter'); await ready();
const current = () => page.locator(`.hm-orgchart-expanded-toggle[data-organization-id="${id}"]`);
check(await current().getAttribute('aria-expanded') === 'false' && await current().evaluate(element => getComputedStyle(element).color) === 'rgb(255, 255, 255)', 'Expanded: collapsed title remains white');
await current().press('Space'); await ready();
check(await current().getAttribute('aria-expanded') === 'true' && await current().evaluate(element => getComputedStyle(element).color) === 'rgb(255, 255, 255)', 'Expanded: expanded title remains white');
}
});
await section('dialog', async () => {
for (const size of [sizes[1], sizes[2]]) {
await goto('expanded', size);
const employee = page.locator('.hm-orgchart-employee').first(), employeeId = await employee.getAttribute('data-employee-id');
await employee.click(); const dialog = page.locator('[data-component="HMOrgChartEmployeeDialog"]'); await dialog.waitFor({ state: 'visible' }); await settle();
const state = await dialog.evaluate(element => { const r = element.getBoundingClientRect(); return { role: element.getAttribute('role'), employeeId: element.dataset.employeeId, portal: element.parentElement === document.body, focusInside: element.contains(document.activeElement), x: r.x, right: r.right, width: r.width, scrollWidth: element.scrollWidth, clientWidth: element.clientWidth }; });
check(state.role === 'dialog' && state.employeeId === employeeId && state.portal && state.focusInside && await page.getByRole('dialog').count() === 1 && await page.locator('dialog[open]').count() === 0 && state.x >= -1 && state.right <= size.width + 1 && state.scrollWidth <= state.clientWidth + 1, `HM Dialog ${size.width}: actual single HM Portal, identity, focus and responsive bounds`, state);
await page.keyboard.press('Tab'); await page.keyboard.press('Shift+Tab'); check(await dialog.evaluate(element => element.contains(document.activeElement)), `HM Dialog ${size.width}: Tab focus remains inside`);
await capture(`hm-dialog-${size.width}`, false);
const focusedEmployee = () => page.evaluate(() => document.querySelector('.hm-orgchart-host').shadowRoot.activeElement?.dataset.employeeId);
await page.keyboard.press('Escape'); await dialog.waitFor({ state: 'hidden' }); await settle(); check(await focusedEmployee() === employeeId, `HM Dialog ${size.width}: ESC returns focus to source card`);
await employee.press('Enter'); await dialog.waitFor({ state: 'visible' }); await dialog.getByRole('button', { name: '닫기', exact: true }).click(); await dialog.waitFor({ state: 'hidden' }); await settle(); check(await focusedEmployee() === employeeId, `HM Dialog ${size.width}: close button returns focus to source card`);
}
});
await section('menu', async () => {
const response = await page.request.get(`${origin}/index.json`), index = await response.json();
const entries = Object.values(index.entries).filter(entry => entry.type === 'story' && entry.title.startsWith('OrgChart/') && !(entry.tags ?? []).includes('internal'));
check(entries.length === 4 && equal(entries.map(entry => entry.id).sort(), Object.values(stories).sort()), 'Storybook: exactly four public OrgChart pages and all retained IDs', entries.map(entry => ({ id: entry.id, title: entry.title })));
});
await section('independent', async () => {
expectedHosts = 1; await page.setViewportSize(sizes[1]);
for (const [id, kind] of [['orgchart--position-summary', 'PositionSummary'], ['orgchart--affiliation-summary', 'AffiliationSummary']]) {
await page.goto(`${origin}/iframe.html?id=${id}&viewMode=story`); await ready(false);
const state = await page.evaluate(kind => {
const shadow = document.querySelector('.hm-orgchart-component-host').shadowRoot, root = shadow.querySelector(`[data-component="${kind}"]`);
return { kind, rootClass: root.className, total: root.querySelector('.hm-orgchart-raw-total')?.textContent, header: [...root.querySelectorAll('thead th')].map(element => element.textContent), footer: root.querySelector('tfoot')?.textContent, affiliationItems: root.querySelectorAll('.hm-orgchart-affiliation-item').length, workdeskPresenters: shadow.querySelectorAll('.hm-orgchart-workdesk-position-table,.hm-orgchart-affiliation-bar,.hm-orgchart-summary-preview').length };
}, kind);
check(state.workdeskPresenters === 0 && state.total?.includes('41') && (kind === 'PositionSummary' ? state.rootClass.includes('hm-orgchart-position-summary') && state.header.includes('소계 (직접)') && state.header.includes('하위 포함') && state.footer.includes('전체 합계') : state.rootClass === 'hm-orgchart-affiliation-summary' && state.affiliationItems === 7), `Independent ${kind}: default presenter and aggregation contract preserved`, state);
}
});
}
} catch (error) { check(false, 'P9 runner completed', { message: error.message, stack: error.stack }); }
finally {
check(errors.length === 0, 'No new browser console or page errors', errors);
const result = { startedAt, completedAt: new Date().toISOString(), origin, baselineMode, sections, widths, browser: browser.version(), results, measurements, errors, pass: results.filter(item => item.status === 'PASS').length, fail: results.filter(item => item.status === 'FAIL').length,
limitations: ['Focused P9 verification only; historical P7/P8 full suites and 2,100 employee tests were not repeated.', 'Shared computed styles demonstrate runtime reuse; CSS design intent approval and original Guard NOT VERIFIED remain separate.', 'StoryPage document vertical scroll is distinct from chart internal scrolling.', 'White text company colors remain approved; Saman and Jangheon Industry contrast remains a recorded accessibility issue.', 'Other browsers, physical devices, screen readers, SSR and deployment remain NOT VERIFIED.'] };
await fs.writeFile(path.join(output, `browser${suffix}.json`), JSON.stringify(result, null, 2));
console.log(JSON.stringify({ pass: result.pass, fail: result.fail, errors: errors.length, output: `test-results/org-chart-p9/browser${suffix}.json` }));
await context.close(); await browser.close(); if (result.fail) process.exitCode = 1;
}