docs(storybook): make Expanded OrgChart a preview-only page
Deploy Storybook / deploy (push) Successful in 59s

Remove the data, layout, interaction and employee-popup setting panels
from the Expanded OrgChart Story. The chart now renders a fixed sample
(7 companies x 20, TB, all expanded, collapse/zoom/pan on, Drag & Drop
off, HM Dialog) right under the page title. Story ID, OrgChart packages
and public API are unchanged.

Align the Story browser checks (P7, P8, P9, P9.2-P9.5, geometry, popup
parity, hierarchy, HM Story) with the preview: setting-only scenarios
are removed, chart rendering, collapse, Zoom/Pan/Fit and HM Dialog checks
run on the fixed state. Move/Drag & Drop coverage stays in the Vanilla
Core checks (verify-p9-3-core, verify-p9-4-core).

Verified: tsc, test:guard 94/94, node --check on 11 scripts, changed
browser sections PASS. Pre-existing P7 story-menu, P8 Department
long-name, P9 Expanded tb/runner failures remain separate issues.
verify-p9-5-popup-parity was not run (needs the Vanilla example server).
This commit is contained in:
shkim4 committed 2026-10-22 17:51:21 +09:00
1 parent 4a54cfbeb2
commit f4ddd5ac4b
14 files changed
+163 -1134

No files matched your search

+19 -97
View File
@@ -1,4 +1,4 @@
// Focused P9.5 transform geometry and pointer-coordinate probes; no P9.4 replay.
// 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';
@@ -8,20 +8,7 @@ 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);
@@ -37,19 +24,11 @@ async function ready(count) {
}, count, { timeout: 60000 });
await settle();
}
async function setup(type, label) {
// 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 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 ready(140);
await page.locator('.hm-orgchart-viewport').scrollIntoViewIfNeeded(); await settle();
}
async function snapshot(name) {
@@ -84,7 +63,7 @@ async function snapshot(name) {
}
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(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);
}
@@ -107,80 +86,23 @@ 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(() => {});
}
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(), 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 };
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;