188 lines
16 KiB
JavaScript
188 lines
16 KiB
JavaScript
// 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;
|
||
}
|