// Focused P9.5 transform geometry probes on the preview-only Expanded Story; 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 = []; await fs.mkdir(output, { recursive: true }); 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(); } // Expanded is preview-only (2026-10-22): the fixed 7 companies x 20 TB state is measured; // company filtering, layout switching and Drag & Drop are no longer reachable from the Story. async function setup() { await page.goto(`${origin}/iframe.html?id=orgchart--expanded-org-chart&viewMode=story`); await ready(140); 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 === 140 && s.employeePaths.length === 140, `${label}: all 140 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(); } try { const type = 'tb'; try { await setup(); 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 page.screenshot({ path: path.join(output, 'geometry-tb.png'), fullPage: true }); } catch (error) { check(false, `${type}: focused geometry section completed`, { message: error.message, stack: error.stack }); } } finally { check(errors.length === 0, 'Focused geometry browser console/page errors zero', errors); const result = { startedAt, completedAt: new Date().toISOString(), origin, browser: browser.version(), scope: 'P9.5 preview-only Expanded: 7 companies × 20 staff; TB Pan/Zoom/Fit transforms; 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; }