546 lines
52 KiB
JavaScript
546 lines
52 KiB
JavaScript
// 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;
|
||
}
|