Files
HMDesign/examples/org-chart/verify-p9-5-geometry.mjs
T

188 lines
16 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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;
}