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

+15 -15
View File
@@ -74,7 +74,7 @@ export function ExpandedExample() {
``` ```
위 native 버튼은 React 계약만 설명하는 최소 예시다. HM 화면에서는 실제 기존 위 native 버튼은 React 계약만 설명하는 최소 예시다. HM 화면에서는 실제 기존
`Button`, `Checkbox`, `Label`, `Select`를 조합한다. Expanded Story가 그 사용 예다. `Button`, `Checkbox`, `Label`, `Select`를 조합한다. Expanded Story는 미리보기 전용이라 이 컨트롤을 포함하지 않는다.
Source·옵션 객체를 안정적으로 유지한다. Core 생성 옵션(layoutMode·TopNode·연결 방향·Zoom 범위)을 Source·옵션 객체를 안정적으로 유지한다. Core 생성 옵션(layoutMode·TopNode·연결 방향·Zoom 범위)을
변경하면 React는 handle을 다시 생성한다. Vanilla는 새 handle을 생성한다. 변경하면 React는 handle을 다시 생성한다. Vanilla는 새 handle을 생성한다.
`autoLayout`·`interactions`·회사 선택·팝업 선택은 기존 인스턴스에서 갱신한다. `autoLayout`·`interactions`·회사 선택·팝업 선택은 기존 인스턴스에서 갱신한다.
@@ -261,11 +261,14 @@ Alt+M도 시작하지 않는다. 전환 시 진행 중 이동·Pointer capture·
이미 완료한 이동을 보존한다. 설정만 바꿀 때 데이터 재요청·재생성·레이아웃 갱신은 없다. 이미 완료한 이동을 보존한다. 설정만 바꿀 때 데이터 재요청·재생성·레이아웃 갱신은 없다.
직원 정보, 그룹 접기·펼치기, Zoom/Pan/Fit은 기존 독립 설정을 따른다. Fixed는 변경하지 않는다. 직원 정보, 그룹 접기·펼치기, Zoom/Pan/Fit은 기존 독립 설정을 따른다. Fixed는 변경하지 않는다.
Expanded Story의 데이터 범위 UI는 제거했고 예시 직원 수20/100/300명은 회사별 기준만 Expanded Story(2026-10-22~)는 조직도만 보여주는 미리보기 전용 화면이다. 데이터 설정
표시한다. 전체 합산 `createOrgChartExampleDataset` 공개 helper 계약은 유지한다. (회사 선택·회사당 직원 수·데이터 적용·초기화), 레이아웃 설정, 인터랙션·직원 정보 설정
회사 선택은 즉시 표시를 갱신하고 숨김·복귀에도 완료한 이동을 유지한다. 직원 수는 기존 UI는 제거했다. 고정 표시 조건은 `createOrgChartExampleDataset`의 회사별 기준 7개 회사×20명=140명,
**데이터 적용**으로 새 예시를 생성하며 임시 이동과 새 Fixture를 섞지 않는다. 초기 설정은 세로형 TB·전체 펼침, 접기·펼치기·Zoom·Pan·Fit 사용, 직원 클릭 HM Dialog, Drag & Drop OFF다.
7개 회사×20명=140명, 선택2개×100명=200명, 선택7개×300명=2,100명이다. 공개 helper·`selectedCompanyIds`·`dragDropEnabled`·`onMove` 계약은 유지하며 다른 화면과
소비 프로젝트는 이 옵션들을 그대로 사용할 수 있다. Story 브라우저 검증(P7·P8·P9·P9.2~P9.5,
hierarchy, HM Story)은 이 고정 미리보기의 렌더링·접기·Zoom/Pan/Fit·HM Dialog만 확인하고,
소속 변경·Drag & Drop ON/OFF 전환·진행 중 취소는 `verify-p9-3-core.mjs`·`verify-p9-4-core.mjs`의 Vanilla Core 검증이 담당한다.
Expanded Story의 외곽 미리보기는 실제 Center Storybook 공통 카드 CSS의 Expanded Story의 외곽 미리보기는 실제 Center Storybook 공통 카드 CSS의
`--sb-center-card-background` / `--sb-center-card-border` / `--sb-center-card-radius`를 `--sb-center-card-background` / `--sb-center-card-border` / `--sb-center-card-radius`를
@@ -293,12 +296,9 @@ Core와 React의 `onMove(result: OrgChartMoveResult)`는 이동 성공·실패
`result.moved`로 성공을 구분하며, React는 성공한 이동 후 열린 HM 직원 Dialog를 닫는다. `result.moved`로 성공을 구분하며, React는 성공한 이동 후 열린 HM 직원 Dialog를 닫는다.
React callback 변경은 최신 committed callback만 갱신하고 Core를 재생성하거나 reload하지 않는다. React callback 변경은 최신 committed callback만 갱신하고 Core를 재생성하거나 reload하지 않는다.
Expanded Story는 모든 회사의 원본 예시를 하나의 세션 source로 유지하고 회사 선택은 소비 화면에서 회사 선택과 소속 변경을 함께 쓸 때는 모든 회사의 원본 예시를 하나의 세션
`selectedCompanyIds`로 처리한다. 회사 숨김·다시 표시·빈 선택은 성공한 소속 변경을 source로 유지하고 회사 선택을 `selectedCompanyIds`로 처리한다. 회사 숨김·다시 표시·빈
보존한다. **데이터 적용**은 현재 draft 조건으로 새 예시 source를 만들고 이동 내용을 선택은 성공한 소속 변경을 보존한다. 새 예시가 필요하면 새 source를 만들며 파일을 다시
초기화한다. **초기화**는 최초 설정과 최초 소속으로 돌아가며 설정값이 이미 최초값이어도 작성하거나 React key로 강제 remount하지 않는다. 직원 합계 등 파생 표시는 성공한 `onMove`의
이동 내용은 되돌린다. 파일을 다시 작성하거나 React key로 강제 remount하지 않는다. `result.data`를 별도 read model로 읽고 source identity는 유지한다. 미리보기 전용 Expanded
Story의 직원 합계와 그룹 선택은 성공한 `onMove`의 `result.data`를 별도 read model로 Story는 회사 선택·Drag & Drop을 노출하지 않으므로 이 세션 흐름을 시연하지 않는다.
읽으며, source identity는 유지한다. 표시 직원 수는 최초 예시 인원이 아닌 현재 조직의
회사 소속을 기준으로 계산하므로 회사 간 이동 직후와 회사 숨김·다시 표시에도 일치한다.
미리보기에는 선택 회사별 현재 직원 수도 표시하며 데이터 설정의 회사당 인원은 예시 생성 기준이다.
@@ -1,4 +1,4 @@
// Actual Expanded Storybook UI: seven companies x three sizes x three layouts. // Actual Expanded Storybook UI: the preview-only fixed state (7 companies x 20, TB).
// Build Storybook first; uses the existing optional Playwright/Chrome runtime. // Build Storybook first; uses the existing optional Playwright/Chrome runtime.
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import fs from 'node:fs/promises'; import fs from 'node:fs/promises';
@@ -23,10 +23,7 @@ const server = http.createServer(async (request, response) => {
}); });
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
let browser, failure; let browser, failure;
// Resume only complete company groups after a test harness interruption. const checks = [];
const savedChecks = process.argv.includes('--resume')
? JSON.parse(await fs.readFile(path.join(output, 'browser-progress.json'), 'utf8')).checks : [];
const checks = savedChecks.filter(check => savedChecks.filter(row => row.company === check.company).length === 9);
const errors = []; const errors = [];
try { try {
browser = await launchChromium({ headless: true }); browser = await launchChromium({ headless: true });
@@ -46,10 +43,6 @@ try {
}, count); }, count);
await settle(); await settle();
}; };
const select = async (label, option) => {
await page.getByRole('combobox', { name: label, exact: true }).click();
await page.getByRole('option', { name: option, exact: true }).click();
};
const snapshot = () => page.locator('.hm-orgchart-host').evaluate(host => { const snapshot = () => page.locator('.hm-orgchart-host').evaluate(host => {
const shadow = host.shadowRoot; const shadow = host.shadowRoot;
const box = element => { const r = element.getBoundingClientRect(); return { x: r.x, y: r.y, width: r.width, height: r.height }; }; const box = element => { const r = element.getBoundingClientRect(); return { x: r.x, y: r.y, width: r.width, height: r.height }; };
@@ -77,64 +70,44 @@ try {
const { viewport: v, contentBounds: f } = await snapshot(); const { viewport: v, contentBounds: f } = await snapshot();
assert.ok(f.x >= v.x - 1 && f.y >= v.y - 1 && f.x + f.width <= v.x + v.width + 1 && f.y + f.height <= v.y + v.height + 1, `complete forest fits viewport: ${JSON.stringify({ viewport: v, forest: f })}`); assert.ok(f.x >= v.x - 1 && f.y >= v.y - 1 && f.x + f.width <= v.x + v.width + 1 && f.y + f.height <= v.y + v.height + 1, `complete forest fits viewport: ${JSON.stringify({ viewport: v, forest: f })}`);
}; };
// Preview-only Expanded Story (2026-10-22): one fixed state, 7 companies x 20 employees in TB.
const count = companies.length * 20;
await page.goto(`http://127.0.0.1:${server.address().port}/iframe.html?id=orgchart--expanded-org-chart&viewMode=story`); await page.goto(`http://127.0.0.1:${server.address().port}/iframe.html?id=orgchart--expanded-org-chart&viewMode=story`);
await ready(140); await ready(count);
let previousCompany; const companyOf = new Map(template.organizations.map(org => [org.id, org.companyId]));
const rendered = await snapshot();
assert.equal(rendered.layout, 'tb');
assert.deepEqual(rendered.nodes.sort((a, b) => a.id.localeCompare(b.id)),
template.organizations.map(org => ({ id: org.id, parentId: org.parentId, type: org.type })).sort((a, b) => a.id.localeCompare(b.id)));
const byId = new Map(rendered.nodes.map(org => [org.id, org]));
for (const team of rendered.nodes.filter(org => org.type === 'team')) {
let ancestor = byId.get(team.parentId);
while (ancestor) { assert.notEqual(ancestor.type, 'cell'); ancestor = byId.get(ancestor.parentId); }
}
const employees = rendered.employees.sort((a, b) => a.id.localeCompare(b.id));
assert.equal(new Set(employees.map(employee => employee.id)).size, count);
assert.ok(employees.every(employee => byId.has(employee.organizationId)));
assert.ok(rendered.upstreamConnectors > 0, 'TB engine CSS connectors are present');
await fit();
await page.screenshot({ path: path.join(output, 'storybook-tb-140.png'), fullPage: true });
for (const company of companies) { for (const company of companies) {
if (checks.filter(check => check.company === company.id).length === 9) continue; assert.equal(employees.filter(employee => companyOf.get(employee.organizationId) === company.id).length, 20, `${company.id}: 20 employees`);
await page.getByRole('button', { name: /회사 선택/ }).click(); const teamId = `${company.id}-team-0`, cellId = `${company.id}-cell-0`;
await page.getByRole('checkbox', { name: previousCompany?.name ?? '전체 선택', exact: true }).uncheck(); const toggle = page.locator(`.hm-orgchart-expanded-toggle[data-organization-id="${teamId}"]`);
await page.getByRole('checkbox', { name: company.name, exact: true }).check(); await toggle.focus(); await toggle.press('Enter'); await ready();
await page.keyboard.press('Escape'); assert.equal(await page.locator(`.node[data-organization-id="${cellId}"]`).count(), 0);
previousCompany = company; assert.ok((await snapshot()).employees.length < count);
for (const count of [20, 100, 300]) { await fit();
await select('회사당 직원 수', `${count}명`); await toggle.focus(); await toggle.press('Enter'); await ready(count);
await page.getByRole('button', { name: '데이터 적용', exact: true }).click(); assert.equal(await page.locator(`.node[data-organization-id="${cellId}"]`).count(), 1);
await ready(count); assert.deepEqual((await snapshot()).employees.sort((a, b) => a.id.localeCompare(b.id)), employees);
const expected = template.organizations.filter(org => org.companyId === company.id).map(org => ({ id: org.id, parentId: org.parentId, type: org.type })).sort((a, b) => a.id.localeCompare(b.id)); await fit();
let employees; checks.push({ company: company.id, employeeCount: 20, layout: 'tb', hierarchy: 'PASS', employeeIdentity: 'PASS', connectors: 'PASS', collapseExpand: 'PASS', fit: 'PASS' });
for (const [type, label] of [['tb', '세로형 TB'], ['lr', '가로형 LR'], ['hybrid', '혼합형 Hybrid']]) { await fs.writeFile(path.join(output, 'browser-progress.json'), JSON.stringify({ checks, errors }, null, 2));
await select('조직도 표시 유형', label);
await ready(count);
const rendered = await snapshot();
assert.equal(rendered.layout, type);
assert.deepEqual(rendered.nodes.sort((a, b) => a.id.localeCompare(b.id)), expected);
const byId = new Map(rendered.nodes.map(org => [org.id, org]));
for (const team of rendered.nodes.filter(org => org.type === 'team')) {
let ancestor = byId.get(team.parentId);
while (ancestor) { assert.notEqual(ancestor.type, 'cell'); ancestor = byId.get(ancestor.parentId); }
}
const roster = rendered.employees.sort((a, b) => a.id.localeCompare(b.id));
assert.equal(new Set(roster.map(employee => employee.id)).size, count);
assert.ok(roster.every(employee => byId.has(employee.organizationId)));
if (employees) assert.deepEqual(roster, employees); else employees = roster;
if (type === 'tb') assert.ok(rendered.upstreamConnectors > 0, 'TB engine CSS connectors are present');
else {
assert.equal(rendered.paths.length, expected.filter(org => org.parentId).length);
assert.ok(rendered.paths.every(p => p.d && !/NaN|Infinity/.test(p.d) && byId.get(p.id)?.parentId === p.parentId), 'measured connectors follow parent IDs and have finite geometry');
}
await fit();
const teamId = `${company.id}-team-0`, cellId = `${company.id}-cell-0`;
const toggle = page.locator(`.hm-orgchart-expanded-toggle[data-organization-id="${teamId}"]`);
await toggle.focus(); await toggle.press('Enter'); await ready();
assert.equal(await page.locator(`.node[data-organization-id="${cellId}"]`).count(), 0);
assert.ok((await snapshot()).employees.length < count);
await fit();
await toggle.focus(); await toggle.press('Enter'); await ready(count);
assert.equal(await page.locator(`.node[data-organization-id="${cellId}"]`).count(), 1);
assert.deepEqual((await snapshot()).employees.sort((a, b) => a.id.localeCompare(b.id)), employees);
await fit();
checks.push({ company: company.id, employeeCount: count, layout: type, hierarchy: 'PASS', employeeIdentity: 'PASS', connectors: 'PASS', collapseExpand: 'PASS', fit: 'PASS' });
await fs.writeFile(path.join(output, 'browser-progress.json'), JSON.stringify({ checks, errors }, null, 2));
if (company.id === 'hanmac' && count === 300) await page.screenshot({ path: path.join(output, `storybook-${type}-300.png`), fullPage: true });
}
}
console.log(`${company.name}: 9 combinations PASS`);
} }
assert.deepEqual(errors, []); assert.deepEqual(errors, []);
assert.equal(await page.getByRole('alert').count(), 0); assert.equal(await page.getByRole('alert').count(), 0);
assert.equal(checks.length, companies.length * 9); assert.equal(checks.length, companies.length);
assert.equal(new Set(checks.map(check => `${check.company}/${check.employeeCount}/${check.layout}`)).size, companies.length * 9);
} catch (error) { } catch (error) {
failure = error.stack; failure = error.stack;
throw error; throw error;
@@ -143,4 +116,4 @@ try {
await browser?.close(); await browser?.close();
await new Promise(resolve => server.close(resolve)); await new Promise(resolve => server.close(resolve));
} }
console.log(`Expanded Storybook: ${checks.length}/63 combinations PASS; ${errors.length} browser errors`); console.log(`Expanded Storybook: ${checks.length}/${companies.length} companies PASS; ${errors.length} browser errors`);
+12 -89
View File
@@ -83,11 +83,6 @@ const endpoint = m => m.cards.every(c => c.endpointError !== null && c.endpointE
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]); }); 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 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 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) { async function hmPopup(name) {
const first = page.locator('.hm-orgchart-employee').first(); const first = page.locator('.hm-orgchart-employee').first();
const id = await first.getAttribute('data-employee-id'); const id = await first.getAttribute('data-employee-id');
@@ -272,60 +267,17 @@ try {
await screenshot('2100-hybrid'); await screenshot('2100-hybrid');
} }
if (run('story')) { if (run('story')) {
// Expanded is preview-only (2026-10-22): the fixed 7 companies x 20 TB sample replaces the
// removed data/scope/company/layout/Depth/popup settings 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 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 ready(140);
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'); check(new Set((await measure()).nodes.map(n => n.company)).size === 7 && (await measure()).layout === 'tb', 'Story preview: seven companies in fixed TB');
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'); 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 rootId = (await measure()).nodes.find(n => !n.parent).id;
const toggle = page.locator(`.hm-orgchart-expanded-toggle[data-organization-id="${rootId}"]`); 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 toggle.focus(); await toggle.press('Enter'); await ready();
await select('직원 정보 팝업', 'Core 기본 팝업'); await ready(); check(await toggle.getAttribute('aria-expanded') === 'false' && await page.locator('.hm-orgchart-employee').count() < 140, 'Story preview: group collapse');
check(await page.locator('.hm-orgchart-employee').count() === collapsed, 'Story popup ownership switch retains independent collapse'); await toggle.focus(); await toggle.press('Enter'); await ready(140);
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]) { for (const width of [390, 768, 1280, 1440]) {
await page.setViewportSize({ width, height: width === 390 ? 844 : 1100 }); await settle(); await page.setViewportSize({ width, height: width === 390 ? 844 : 1100 }); await settle();
await page.getByRole('button', { name: 'Fit to View', exact: true }).click(); await settle(); await page.getByRole('button', { name: 'Fit to View', exact: true }).click(); await settle();
@@ -334,16 +286,6 @@ try {
measurements.push({ name: `Story ${width}px`, value: m }); measurements.push({ name: `Story ${width}px`, value: m });
await screenshot('story-' + width); 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'); check(await page.getByRole('alert').count() === 0, 'Story has no lifecycle error alert');
} }
if (run('react')) { if (run('react')) {
@@ -418,33 +360,14 @@ try {
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] }; 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); 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. // Expanded is preview-only (2026-10-22): its collapse/Zoom/Pan checkboxes and popup selector were removed.
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.goto((process.env.ORGCHART_STORY_ORIGIN ?? 'http://127.0.0.1:6056') + '/iframe.html?id=orgchart--expanded-org-chart&viewMode=story'); await ready(140);
for (const [label, type] of [['세로형 TB', 'tb'], ['가로형 LR', 'lr'], ['혼합형 Hybrid', 'hybrid']]) { await hmPopup('Final tb');
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') || run('options-tail')) {
if (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 page.goto((process.env.ORGCHART_STORY_ORIGIN ?? 'http://127.0.0.1:6056') + '/iframe.html?id=orgchart--expanded-org-chart&viewMode=story'); await ready(140);
} }
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]) { for (const width of [390, 768, 1280, 1440]) {
await page.setViewportSize({ width, height: width === 390 ? 844 : 1100 }); await settle(); 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(); await page.getByRole('button', { name: 'Fit to View', exact: true }).click(); await settle(); const m = await measure();
@@ -523,7 +446,7 @@ try {
} }
if (run('dialog-narrow')) { if (run('dialog-narrow')) {
await page.setViewportSize({ width: 390, height: 844 }); 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.goto((process.env.ORGCHART_STORY_ORIGIN ?? 'http://127.0.0.1:6056') + '/iframe.html?id=orgchart--expanded-org-chart&viewMode=story'); await ready(140);
await page.locator('.hm-orgchart-employee').first().press('Enter'); const d = page.locator('[data-component="HMOrgChartEmployeeDialog"]'); await d.waitFor(); await settle(); 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 bounds = await d.evaluate(d => {
const r = d.getBoundingClientRect(); const r = d.getBoundingClientRect();
+7 -30
View File
@@ -63,7 +63,7 @@ async function ready(expected) {
async function gotoStory(story, width = 1440) { async function gotoStory(story, width = 1440) {
await page.setViewportSize({ width, height: 1100 }); await page.setViewportSize({ width, height: 1100 });
await page.goto(`${origin}/iframe.html?id=${story.id}&viewMode=story`); await page.goto(`${origin}/iframe.html?id=${story.id}&viewMode=story`);
await ready(story.file === 'expanded-org-chart' ? 20 : undefined); await ready(story.file === 'expanded-org-chart' ? 140 : undefined);
} }
async function capture(file, fullPage = true) { await page.screenshot({ path: path.join(output, file + '.png'), fullPage }); } async function capture(file, fullPage = true) { await page.screenshot({ path: path.join(output, file + '.png'), fullPage }); }
async function section(name, work) { async function section(name, work) {
@@ -140,12 +140,6 @@ const fixedHasNoInternalScroll = fixed => fixed.scroll.filter(item => !item.miss
? item.overflowX === 'hidden' && item.overflowY === 'hidden' ? item.overflowX === 'hidden' && item.overflowY === 'hidden'
: item.scrollWidth <= item.clientWidth + 1 && item.scrollHeight <= item.clientHeight + 1 : item.scrollWidth <= item.clientWidth + 1 && item.scrollHeight <= item.clientHeight + 1
)); ));
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();
}
try { try {
await section('manager', async () => { await section('manager', async () => {
const response = await page.request.get(origin + '/index.json'); const response = await page.request.get(origin + '/index.json');
@@ -294,33 +288,19 @@ try {
}); });
await section('smoke', async () => { await section('smoke', async () => {
await gotoStory(stories[3]); await gotoStory(stories[3]);
for (const label of ['데이터 범위', '전체 직원 수', '조직도 표시 유형', '혼합형 시작 Depth', '특정 그룹 세로 배치', '초기 표시 Depth', '직원 정보 팝업']) check(await page.getByRole('combobox', { name: label, exact: true }).count() === 1, `Expanded: ${label} control preserved`); // Expanded is preview-only (2026-10-22): settings, data apply, layout/Depth switches and the
for (const label of ['접기·펼치기 사용', 'Zoom 사용', 'Pan 사용']) check(await page.getByRole('checkbox', { name: label, exact: true }).isChecked(), `Expanded: independent ${label} control enabled`); // independent Zoom/Pan checkboxes were removed; the fixed 7x20 TB preview keeps chart browsing.
check(await page.getByRole('checkbox').count() === 10 && await page.getByRole('button', { name: '데이터 적용', exact: true }).count() === 1 && await page.getByRole('button', { name: '초기화', exact: true }).count() === 1, 'Expanded: seven company selections and apply/reset actions preserved'); check(await page.getByRole('combobox').count() === 0 && await page.getByRole('checkbox').count() === 0 && await page.getByRole('switch').count() === 0, 'Expanded: preview-only page has no setting controls');
await select('전체 직원 수', '100명'); const preview = await autoState();
check((await autoState()).employees === 20, 'Expanded: draft count waits for data apply'); check(preview.type === 'tb' && preview.employees === 140 && preview.zoomEnabled === 'true' && preview.panEnabled === 'true', 'Expanded: fixed TB 140 employee preview with Zoom/Pan enabled', preview);
await page.getByRole('button', { name: '데이터 적용', exact: true }).click(); await ready(100);
check((await autoState()).employees === 100 && (await page.locator('[data-component="OrgChartExampleCounts"]').textContent()).includes('100'), 'Expanded: data apply renders 100 employee records');
for (const [option, type] of [['세로형 TB', 'tb'], ['가로형 LR', 'lr'], ['혼합형 Hybrid', 'hybrid']]) {
await select('조직도 표시 유형', option); await ready(100);
const state = await autoState();
check(state.type === type && state.employees === 100 && autoFits(state), `Expanded: ${type} switch preserves data and Fit`, state);
}
await select('초기 표시 Depth', '2Depth까지'); await ready();
const depths = await page.locator('.node[data-depth]').evaluateAll(nodes => nodes.map(node => Number(node.dataset.depth)));
check(depths.every(depth => depth < 2), 'Expanded: initial Depth control still limits visible groups', depths);
await select('초기 표시 Depth', '전체 펼침'); await ready(100);
const beforeCollapse = await autoState(); const beforeCollapse = await autoState();
const toggle = page.locator('.hm-orgchart-expanded-toggle').first(); const toggle = page.locator('.hm-orgchart-expanded-toggle').first();
const organizationId = await toggle.getAttribute('data-organization-id'); const organizationId = await toggle.getAttribute('data-organization-id');
await toggle.focus(); await toggle.press('Enter'); await ready(); await toggle.focus(); await toggle.press('Enter'); await ready();
const collapsed = await autoState(); const collapsed = await autoState();
check(await toggle.getAttribute('aria-expanded') === 'false' && collapsed.organizations < beforeCollapse.organizations && collapsed.employees < beforeCollapse.employees, 'Expanded: Enter collapses a group and its descendants', { organizationId, before: beforeCollapse, after: collapsed }); check(await toggle.getAttribute('aria-expanded') === 'false' && collapsed.organizations < beforeCollapse.organizations && collapsed.employees < beforeCollapse.employees, 'Expanded: Enter collapses a group and its descendants', { organizationId, before: beforeCollapse, after: collapsed });
await page.locator(`.hm-orgchart-expanded-toggle[data-organization-id="${organizationId}"]`).press('Space'); await ready(100); await page.locator(`.hm-orgchart-expanded-toggle[data-organization-id="${organizationId}"]`).press('Space'); await ready(140);
check(await page.locator(`.hm-orgchart-expanded-toggle[data-organization-id="${organizationId}"]`).getAttribute('aria-expanded') === 'true', 'Expanded: Space expands the same group'); check(await page.locator(`.hm-orgchart-expanded-toggle[data-organization-id="${organizationId}"]`).getAttribute('aria-expanded') === 'true', 'Expanded: Space expands the same group');
await page.getByRole('checkbox', { name: 'Zoom 사용', exact: true }).uncheck(); await ready();
check((await autoState()).zoomEnabled === 'false' && await page.getByRole('button', { name: '확대', exact: true }).isDisabled(), 'Expanded: independent Zoom switch disables only Zoom action');
await page.getByRole('checkbox', { name: 'Zoom 사용', exact: true }).check(); await ready();
await page.getByRole('button', { name: '100% 복원', exact: true }).click(); await page.getByRole('button', { name: '100% 복원', exact: true }).click();
const beforeZoom = await autoState(); const beforeZoom = await autoState();
await page.getByRole('button', { name: '확대', exact: true }).click(); await settle(); await page.getByRole('button', { name: '확대', exact: true }).click(); await settle();
@@ -332,9 +312,6 @@ try {
await page.mouse.move(viewport.x + 10, viewport.y + 10); await page.mouse.down(); await page.mouse.move(viewport.x + 50, viewport.y + 35); await page.mouse.up(); await settle(); await page.mouse.move(viewport.x + 10, viewport.y + 10); await page.mouse.down(); await page.mouse.move(viewport.x + 50, viewport.y + 35); await page.mouse.up(); await settle();
const afterPan = await autoState(); const afterPan = await autoState();
check(afterPan.x !== beforePan.x && afterPan.y !== beforePan.y, 'Expanded: pointer Pan changes both forest coordinates', { before: beforePan, after: afterPan }); check(afterPan.x !== beforePan.x && afterPan.y !== beforePan.y, 'Expanded: pointer Pan changes both forest coordinates', { before: beforePan, after: afterPan });
await page.getByRole('checkbox', { name: 'Pan 사용', exact: true }).uncheck(); await ready();
check((await autoState()).panEnabled === 'false' && (await autoState()).zoomEnabled === 'true', 'Expanded: independent Pan switch leaves Zoom enabled');
await page.getByRole('checkbox', { name: 'Pan 사용', exact: true }).check(); await ready();
await page.getByRole('button', { name: 'Fit to View', exact: true }).click(); await settle(); await page.getByRole('button', { name: 'Fit to View', exact: true }).click(); await settle();
check(autoFits(await autoState()), 'Expanded: toolbar Fit contains complete forest'); check(autoFits(await autoState()), 'Expanded: toolbar Fit contains complete forest');
const employee = page.locator('.hm-orgchart-employee').first(), employeeId = await employee.getAttribute('data-employee-id'); const employee = page.locator('.hm-orgchart-employee').first(), employeeId = await employee.getAttribute('data-employee-id');
+19 -101
View File
@@ -1,18 +1,19 @@
// P9.2 Expanded selection integration only. Historical suites are preserved. // P9.2 Expanded integration only. Historical suites are preserved.
// Expanded is preview-only (2026-10-22): company selection, data scope/count and Apply/Reset
// sections were removed with the Story settings; chart-common checks run on the fixed 7x20 TB state.
import fs from 'node:fs/promises'; import fs from 'node:fs/promises';
import { writeFileSync } from 'node:fs'; import { writeFileSync } from 'node:fs';
import path from 'node:path'; import path from 'node:path';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
import { launchChromium } from '../../scripts/orgchart-browser-runtime.mjs'; import { launchChromium } from '../../scripts/orgchart-browser-runtime.mjs';
import { companyColorPresets, createOrgChartExampleDataset } from '../../packages/org-chart/dist/index.js'; import { companyColorPresets } from '../../packages/org-chart/dist/index.js';
const root = fileURLToPath(new URL('../../', import.meta.url)); const root = fileURLToPath(new URL('../../', import.meta.url));
const output = path.join(root, 'test-results/org-chart-p9-2'); const output = path.join(root, 'test-results/org-chart-p9-2');
const origin = process.env.ORGCHART_STORY_ORIGIN ?? 'http://127.0.0.1:6062'; const origin = process.env.ORGCHART_STORY_ORIGIN ?? 'http://127.0.0.1:6062';
const only = process.argv.slice(2), run = name => !only.length || only.includes(name); const only = process.argv.slice(2), run = name => !only.length || only.includes(name);
const suffix = only.length ? '-' + only.join('-') : ''; const suffix = only.length ? '-' + only.join('-') : '';
const template = JSON.parse(await fs.readFile(path.join(root, 'examples/org-chart/data-expanded-large.json'), 'utf8')); const ids = Object.values(companyColorPresets).map(company => company.companyId), total = ids.length * 20;
const companies = Object.values(companyColorPresets), ids = companies.map(company => company.companyId);
const results = [], measurements = [], errors = [], startedAt = new Date().toISOString(); const results = [], measurements = [], errors = [], startedAt = new Date().toISOString();
await fs.mkdir(output, { recursive: true }); await fs.mkdir(output, { recursive: true });
const persist = () => writeFileSync(path.join(output, `expanded-progress${suffix}.json`), JSON.stringify({ startedAt, results, measurements, errors }, null, 2)); const persist = () => writeFileSync(path.join(output, `expanded-progress${suffix}.json`), JSON.stringify({ startedAt, results, measurements, errors }, null, 2));
@@ -22,9 +23,6 @@ const page = await browser.newPage({ viewport: { width: 1440, height: 1100 } });
page.setDefaultTimeout(15000); page.setDefaultTimeout(15000);
page.on('pageerror', error => { errors.push({ type: 'pageerror', message: error.message }); persist(); }); page.on('pageerror', error => { errors.push({ type: 'pageerror', message: error.message }); persist(); });
page.on('console', message => { if (message.type() === 'error') { errors.push({ type: 'console', message: message.text(), location: message.location() }); persist(); } }); page.on('console', message => { if (message.type() === 'error') { errors.push({ type: 'console', message: message.text(), location: message.location() }); persist(); } });
const trigger = () => page.getByRole('button', { name: /^회사 선택/ });
const panel = () => page.getByRole('dialog', { name: '회사 선택', exact: true });
const checkbox = name => panel().getByRole('checkbox', { name, exact: true });
const settle = () => page.evaluate(async () => { await document.fonts.ready; for (let i = 0; i < 8; i++) await new Promise(requestAnimationFrame); }); const settle = () => page.evaluate(async () => { await document.fonts.ready; for (let i = 0; i < 8; i++) await new Promise(requestAnimationFrame); });
async function ready(count) { async function ready(count) {
await page.waitForFunction(count => { await page.waitForFunction(count => {
@@ -37,19 +35,14 @@ async function ready(count) {
async function goto(size = { width: 1440, height: 1100 }) { async function goto(size = { width: 1440, height: 1100 }) {
await page.setViewportSize(size); await page.setViewportSize(size);
await page.goto(`${origin}/iframe.html?id=orgchart--expanded-org-chart&viewMode=story`); await page.goto(`${origin}/iframe.html?id=orgchart--expanded-org-chart&viewMode=story`);
await trigger().waitFor(); await ready(20); await ready(total);
} }
async function open() { await trigger().click(); await panel().waitFor(); await settle(); }
async function close() { await page.keyboard.press('Escape'); await panel().waitFor({ state: 'hidden' }); await settle(); }
async function select(label, option) { await page.getByRole('combobox', { name: label, exact: true }).click(); await page.getByRole('option', { name: option, exact: true }).click(); await ready(); }
async function apply(count) { await page.getByRole('button', { name: '데이터 적용', exact: true }).click(); await ready(count); }
const expectedTotal = (count, selected) => selected.length ? createOrgChartExampleDataset(template, { scope: 'total', employeeCount: count, companyIds: selected }).employees.length : 0;
async function state() { async function state() {
return page.evaluate(() => { return page.evaluate(() => {
const shadow = document.querySelector('.hm-orgchart-host').shadowRoot, expanded = shadow.querySelector('.hm-orgchart-expanded'); const shadow = document.querySelector('.hm-orgchart-host').shadowRoot, expanded = shadow.querySelector('.hm-orgchart-expanded');
const viewport = shadow.querySelector('.hm-orgchart-viewport'), forest = shadow.querySelector('.hm-orgchart-forest'); const viewport = shadow.querySelector('.hm-orgchart-viewport'), forest = shadow.querySelector('.hm-orgchart-forest');
const rect = element => { if (!element) return null; const r = element.getBoundingClientRect(); return { x: r.x, y: r.y, width: r.width, height: r.height, right: r.right, bottom: r.bottom }; }; const rect = element => { if (!element) return null; const r = element.getBoundingClientRect(); return { x: r.x, y: r.y, width: r.width, height: r.height, right: r.right, bottom: r.bottom }; };
return { countText: document.querySelector('[data-component="OrgChartExampleCounts"]').textContent.trim(), employees: shadow.querySelectorAll('.hm-orgchart-employee').length, return { employees: shadow.querySelectorAll('.hm-orgchart-employee').length,
roots: [...shadow.querySelectorAll('.hm-orgchart-company-tree')].map(element => element.dataset.companyId), type: expanded?.dataset.autoLayout, roots: [...shadow.querySelectorAll('.hm-orgchart-company-tree')].map(element => element.dataset.companyId), type: expanded?.dataset.autoLayout,
empty: forest?.children.length === 0, scale: Number(viewport?.dataset.scale), panX: Number(viewport?.dataset.panX), panY: Number(viewport?.dataset.panY), viewport: rect(viewport), forest: rect(forest), empty: forest?.children.length === 0, scale: Number(viewport?.dataset.scale), panX: Number(viewport?.dataset.panX), panY: Number(viewport?.dataset.panY), viewport: rect(viewport), forest: rect(forest),
documentWidth: document.documentElement.scrollWidth, width: innerWidth, documentWidth: document.documentElement.scrollWidth, width: innerWidth,
@@ -61,72 +54,21 @@ async function state() {
async function snap(name, fullPage = false) { await page.screenshot({ path: path.join(output, `${name}.png`), fullPage }); } async function snap(name, fullPage = false) { await page.screenshot({ path: path.join(output, `${name}.png`), fullPage }); }
async function section(name, action) { if (!run(name)) return; try { await action(); } catch (error) { check(false, `${name}: execution completed`, { message: error.message, stack: error.stack }); await snap(`failure-${name}`, true).catch(() => {}); } } async function section(name, action) { if (!run(name)) return; try { await action(); } catch (error) { check(false, `${name}: execution completed`, { message: error.message, stack: error.stack }); await snap(`failure-${name}`, true).catch(() => {}); } }
await section('dropdown', async () => {
await goto(); await snap('expanded-closed', true);
check((await trigger().innerText()).includes('전체 회사 (7개)') && await page.locator('.hm-orgchart-story-settings').first().getByRole('checkbox').count() === 0, 'Closed: summary and no external company checkboxes');
await trigger().focus(); await trigger().press('Enter'); await panel().waitFor(); await settle();
const structure = await panel().evaluate(element => ({ portal: element.closest('.sb-page') === null, count: element.querySelectorAll('[role="checkbox"]').length, focusedFirst: element.querySelector('[role="checkbox"]') === document.activeElement }));
check(structure.portal && structure.count === 8 && structure.focusedFirst && await checkbox('전체 선택').getAttribute('aria-checked') === 'true', 'Open via keyboard: HM Portal and focus on select-all', structure);
await snap('expanded-open-all');
await page.keyboard.press('Space'); await ready(0);
let s = await state(); measurements.push({ name: 'empty', state: s });
check(await checkbox('전체 선택').getAttribute('aria-checked') === 'false' && (await trigger().innerText()).includes('회사를 선택하세요') && s.employees === 0 && s.countText.includes('0개 회사') && Boolean(s.empty), 'Space select-all off: empty state without error', s);
await page.keyboard.press('Tab');
check(await checkbox('한맥기술').evaluate(element => element === document.activeElement), 'Tab reaches first individual checkbox');
await page.keyboard.press('Space'); await ready(expectedTotal(20, ['hanmac']));
check(await checkbox('한맥기술').getAttribute('aria-checked') === 'true' && await checkbox('전체 선택').getAttribute('aria-checked') === 'mixed' && (await trigger().innerText()).includes('한맥기술'), 'Individual Space: one company summary and indeterminate');
await page.keyboard.press('Tab'); await page.keyboard.press('Space'); await ready(expectedTotal(20, ['hanmac', 'saman']));
check(await checkbox('삼안').getAttribute('aria-checked') === 'true' && (await trigger().innerText()).includes('2개 회사 선택'), 'Multiple keyboard selection: two company summary');
await snap('expanded-open-partial'); await close();
check(await trigger().evaluate(element => element === document.activeElement) && await trigger().getAttribute('aria-expanded') === 'false', 'ESC closes and restores focus');
await open();
check(await checkbox('한맥기술').getAttribute('aria-checked') === 'true' && await checkbox('삼안').getAttribute('aria-checked') === 'true', 'Selection persists after reopen');
await checkbox('한맥기술').click(); await ready(expectedTotal(20, ['saman']));
check((await trigger().innerText()).includes('삼안'), 'Individual deselect removes only that company');
await checkbox('전체 선택').click(); await ready(20);
check(await checkbox('전체 선택').getAttribute('aria-checked') === 'true' && await panel().getByRole('checkbox', { checked: true }).count() === 8, 'Indeterminate select-all selects seven companies');
await snap('expanded-all-selected');
await trigger().click(); await panel().waitFor({ state: 'hidden' }); check(await trigger().getAttribute('aria-expanded') === 'false', 'Trigger click closes');
await open(); await page.locator('.sb-page-title').click(); await panel().waitFor({ state: 'hidden' }); check(true, 'Outside click closes');
});
await section('data', async () => {
await goto(); await open(); await checkbox('전체 선택').click(); await ready(0); await checkbox('한맥기술').click(); await ready(expectedTotal(20, ['hanmac'])); await close();
await select('데이터 범위', '회사별'); await select('회사당 직원 수', '100명'); await apply(100);
let s = await state(); check(s.employees === 100 && s.countText.includes('회사당 100명') && s.countText.includes('합계 100명'), 'Per-company 100: one company produces 100', s.countText);
await open(); await checkbox('삼안').click(); await ready(200); s = await state(); measurements.push({ name: 'per-company-two', state: s });
check(s.employees === 200 && s.countText.includes('2개 회사') && s.countText.includes('합계 200명'), 'Second company immediately produces 200 without Apply', s.countText);
await close(); await snap('expanded-closed-partial', true);
await select('데이터 범위', '전체 회사 합산'); await select('전체 직원 수', '20명');
await open(); await checkbox('삼안').click(); await ready(100); s = await state();
check(s.employees === 100 && s.countText.includes('회사당 100명'), 'Company change preserves pending scope/count Apply boundary', s.countText);
await close(); await apply(expectedTotal(20, ['hanmac'])); s = await state();
check(s.employees === expectedTotal(20, ['hanmac']) && !s.countText.includes('회사당'), 'Total20 preserves P7 weighted partition without redistribution', s.countText);
for (const count of [100, 300]) {
await select('전체 직원 수', `${count}명`); await apply(expectedTotal(count, ['hanmac'])); s = await state();
check(s.employees === expectedTotal(count, ['hanmac']), `Total${count} selected-company partition`, { count: s.employees, expected: expectedTotal(count, ['hanmac']) });
}
await select('데이터 범위', '회사별'); await select('회사당 직원 수', '20명'); await apply(20); check((await state()).employees === 20, 'Per-company20 selection retained');
await select('회사당 직원 수', '300명'); await apply(300); check((await state()).employees === 300, 'Per-company300 selection retained');
await page.getByRole('button', { name: '초기화', exact: true }).click(); await ready(20); check((await trigger().innerText()).includes('전체 회사 (7개)'), 'Reset restores original total20 and all seven');
});
await section('modes', async () => { await section('modes', async () => {
await goto(); await open(); await checkbox('바론컨설턴트').click(); await ready(expectedTotal(20, ids.filter(id => id !== 'baron'))); await close(); await goto(); await snap('expanded-preview', true);
const selected = ids.filter(id => id !== 'baron'), count = expectedTotal(20, selected); const s = await state(); measurements.push({ name: 'tb', state: s });
for (const [option, type] of [['세로형 TB', 'tb'], ['가로형 LR', 'lr'], ['혼합형 Hybrid', 'hybrid']]) { check(s.type === 'tb' && s.employees === total && s.cards.every(card => ids.includes(card.companyId)) && new Set(s.cards.map(card => card.companyId)).size === ids.length, 'tb: fixed seven-company 140 employee preview renders');
if (type !== 'tb') await select('조직도 표시 유형', option); check(s.titles.length > 0 && s.titles.every(title => title.color === 'rgb(255, 255, 255)') && s.cards.every(card => ['top', 'left', 'right'].includes(card.direction)), 'tb: existing white titles and directional cards');
await ready(count); const s = await state(); measurements.push({ name: type, state: s }); check(await page.locator('.hm-orgchart-story-expanded fieldset, .hm-orgchart-story-expanded [role="combobox"], .hm-orgchart-story-expanded [role="switch"]').count() === 0, 'Preview-only: no setting controls outside the chart');
check(s.type === type && s.employees === count && s.cards.every(card => selected.includes(card.companyId)) && new Set(s.cards.map(card => card.companyId)).size === 6, `${type}: chosen companies render and excluded company absent`, s.countText); // The 140 employee forest opens at 100% wider than the viewport; Fit brings the first group into view.
check(s.titles.length > 0 && s.titles.every(title => title.color === 'rgb(255, 255, 255)') && s.cards.every(card => ['top', 'left', 'right'].includes(card.direction)), `${type}: existing white titles and directional cards`); await page.getByRole('button', { name: 'Fit to View', exact: true }).click(); await settle();
}
const toggle = page.locator('.hm-orgchart-expanded-toggle:not(:disabled)').first(); const toggle = page.locator('.hm-orgchart-expanded-toggle:not(:disabled)').first();
const id = await toggle.getAttribute('data-organization-id'); await toggle.click(); await ready(); const id = await toggle.getAttribute('data-organization-id'); await toggle.click(); await ready();
const current = () => page.locator(`.hm-orgchart-expanded-toggle[data-organization-id="${id}"]`); const current = () => page.locator(`.hm-orgchart-expanded-toggle[data-organization-id="${id}"]`);
check(await current().getAttribute('aria-expanded') === 'false', 'Group collapse after selection'); await current().press('Space'); await ready(); check(await current().getAttribute('aria-expanded') === 'true', 'Group expands with Space'); check(await current().getAttribute('aria-expanded') === 'false', 'Group collapse'); await current().press('Space'); await ready(); check(await current().getAttribute('aria-expanded') === 'true', 'Group expands with Space');
const employee = page.locator('.hm-orgchart-employee').first(), employeeId = await employee.getAttribute('data-employee-id'); await employee.click(); const employee = page.locator('.hm-orgchart-employee').first(), employeeId = await employee.getAttribute('data-employee-id'); await employee.click();
const dialog = page.locator('[data-component="HMOrgChartEmployeeDialog"]'); await dialog.waitFor(); const dialog = page.locator('[data-component="HMOrgChartEmployeeDialog"]'); await dialog.waitFor();
check(await dialog.getAttribute('data-employee-id') === employeeId && await page.getByRole('dialog').count() === 1, 'Employee HM Dialog identity after company changes'); check(await dialog.getAttribute('data-employee-id') === employeeId && await page.getByRole('dialog').count() === 1, 'Employee HM Dialog identity');
await page.keyboard.press('Escape'); await dialog.waitFor({ state: 'hidden' }); await settle(); await page.keyboard.press('Escape'); await dialog.waitFor({ state: 'hidden' }); await settle();
check(await page.evaluate(() => document.querySelector('.hm-orgchart-host').shadowRoot.activeElement?.dataset.employeeId) === employeeId, 'Employee Dialog ESC returns card focus'); check(await page.evaluate(() => document.querySelector('.hm-orgchart-host').shadowRoot.activeElement?.dataset.employeeId) === employeeId, 'Employee Dialog ESC returns card focus');
await page.getByRole('button', { name: 'Fit to View', exact: true }).click(); await settle(); const before = await state(); await page.getByRole('button', { name: 'Fit to View', exact: true }).click(); await settle(); const before = await state();
@@ -135,41 +77,17 @@ await section('modes', async () => {
await page.mouse.move(box.x + 8, box.y + box.height - 8); await page.mouse.down(); await page.mouse.move(box.x + 58, box.y + box.height - 38, { steps: 5 }); await page.mouse.up(); await settle(); const pan = await state(); await page.mouse.move(box.x + 8, box.y + box.height - 8); await page.mouse.down(); await page.mouse.move(box.x + 58, box.y + box.height - 38, { steps: 5 }); await page.mouse.up(); await settle(); const pan = await state();
check(Math.abs(pan.panX - panBefore.panX) >= 40 && Math.abs(pan.panY - panBefore.panY) >= 20, 'Pointer Pan remains functional', { before: [panBefore.panX, panBefore.panY], after: [pan.panX, pan.panY] }); check(Math.abs(pan.panX - panBefore.panX) >= 40 && Math.abs(pan.panY - panBefore.panY) >= 20, 'Pointer Pan remains functional', { before: [panBefore.panX, panBefore.panY], after: [pan.panX, pan.panY] });
await page.getByRole('button', { name: 'Fit to View', exact: true }).click(); await settle(); const fit = await state(); await page.getByRole('button', { name: 'Fit to View', exact: true }).click(); await settle(); const fit = await state();
check(fit.forest.x >= fit.viewport.x - 1 && fit.forest.right <= fit.viewport.right + 1 && fit.forest.y >= fit.viewport.y - 1 && fit.forest.bottom <= fit.viewport.bottom + 1, 'Fit contains complete selected forest', fit); check(fit.forest.x >= fit.viewport.x - 1 && fit.forest.right <= fit.viewport.right + 1 && fit.forest.y >= fit.viewport.y - 1 && fit.forest.bottom <= fit.viewport.bottom + 1, 'Fit contains complete forest', fit);
}); });
await section('responsive', async () => { await section('responsive', async () => {
for (const size of [{ width: 1920, height: 1080 }, { width: 768, height: 1024 }, { width: 390, height: 844 }]) { for (const size of [{ width: 1920, height: 1080 }, { width: 768, height: 1024 }, { width: 390, height: 844 }]) {
await goto(size); await open(); await goto(size);
const bounds = await panel().evaluate(element => { const r = element.getBoundingClientRect(); return { x: r.x, right: r.right, y: r.y, bottom: r.bottom, width: r.width, height: r.height, scrollWidth: element.scrollWidth, clientWidth: element.clientWidth, portal: !element.closest('.sb-page'), checkboxesVisible: [...element.querySelectorAll('[role="checkbox"]')].every(child => { const b = child.getBoundingClientRect(); return b.left >= r.left && b.right <= r.right && b.top >= r.top && b.bottom <= r.bottom; }) }; });
check(bounds.portal && bounds.x >= -1 && bounds.right <= size.width + 1 && bounds.y >= -1 && bounds.bottom <= size.height + 1 && bounds.scrollWidth <= bounds.clientWidth + 1 && bounds.checkboxesVisible, `${size.width}: Portal panel and all options fit without clipping`, bounds);
check((await state()).documentWidth <= size.width + 1, `${size.width}: no horizontal page overflow`); check((await state()).documentWidth <= size.width + 1, `${size.width}: no horizontal page overflow`);
await snap(`expanded-open-${size.width}`); await close(); await snap(`expanded-${size.width}`, true);
const summary = await trigger().evaluate(element => { const r = element.getBoundingClientRect(); return { width: r.width, scrollWidth: element.scrollWidth, clientWidth: element.clientWidth, label: element.textContent }; });
check(summary.scrollWidth <= summary.clientWidth + 1, `${size.width}: concise trigger fits control`, summary);
await snap(`expanded-closed-${size.width}`, true);
} }
}); });
// Isolate a corrected empty-forest probe without replaying successful sections.
if (only.includes('empty')) await section('empty', async () => {
await goto(); await trigger().focus(); await trigger().press('Enter'); await panel().waitFor();
await page.keyboard.press('Space'); await ready(0); const s = await state();
check(await checkbox('전체 선택').getAttribute('aria-checked') === 'false' && (await trigger().innerText()).includes('회사를 선택하세요') && s.employees === 0 && s.countText.includes('0개 회사') && s.empty, 'Space select-all off: empty forest without error', s);
await snap('expanded-empty');
});
// Runtime evidence for Radix's public positioning variable, opaque to Guard.
if (only.includes('token')) await section('token', async () => {
await goto(); await open();
const evidence = await panel().evaluate(element => {
const css = getComputedStyle(element);
return { variable: '--radix-popover-content-available-height', actual: css.getPropertyValue('--radix-popover-content-available-height').trim(), maxHeight: css.maxHeight, component: element.dataset.component, width: innerWidth, height: innerHeight };
});
measurements.push({ name: 'Radix runtime variable', evidence });
check(parseFloat(evidence.actual) > 0 && evidence.maxHeight === evidence.actual, 'HM Popover declares Radix available-height variable at runtime', evidence);
});
await section('menu', async () => { await section('menu', async () => {
const index = await (await page.request.get(`${origin}/index.json`)).json(); const index = await (await page.request.get(`${origin}/index.json`)).json();
const entries = Object.values(index.entries).filter(entry => entry.type === 'story' && entry.title.startsWith('OrgChart/') && !(entry.tags ?? []).includes('internal')); const entries = Object.values(index.entries).filter(entry => entry.type === 'story' && entry.title.startsWith('OrgChart/') && !(entry.tags ?? []).includes('internal'));
+8 -273
View File
@@ -1,19 +1,17 @@
// P9.3 browser-state editing only. Exact section names allow failed probes to // P9.3 browser-state checks on the preview-only Expanded Story (2026-10-22). Drag & Drop is fixed
// rerun without replaying previously successful P7-P9.2 or P9.3 checks. // OFF and company/data/layout settings were removed, so Story editing sections were retired;
// move, pointer drag, invalid-move and cross-company coverage stays in verify-p9-3-core.mjs.
import fs from 'node:fs/promises'; import fs from 'node:fs/promises';
import { writeFileSync } from 'node:fs'; import { writeFileSync } from 'node:fs';
import path from 'node:path'; import path from 'node:path';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
import { launchChromium } from '../../scripts/orgchart-browser-runtime.mjs'; import { launchChromium } from '../../scripts/orgchart-browser-runtime.mjs';
import { companyColorPresets } from '../../packages/org-chart/dist/index.js';
const root = fileURLToPath(new URL('../../', import.meta.url)); const root = fileURLToPath(new URL('../../', import.meta.url));
const output = path.join(root, 'test-results/org-chart-p9-3'); const output = path.join(root, 'test-results/org-chart-p9-3');
const origin = process.env.ORGCHART_STORY_ORIGIN ?? 'http://127.0.0.1:6064'; const origin = process.env.ORGCHART_STORY_ORIGIN ?? 'http://127.0.0.1:6064';
const only = process.argv.slice(2), suffix = only.length ? `-${only.join('-')}` : ''; const only = process.argv.slice(2), suffix = only.length ? `-${only.join('-')}` : '';
const results = [], measurements = [], errors = [], startedAt = new Date().toISOString(); const results = [], measurements = [], errors = [], startedAt = new Date().toISOString();
const colors = Object.fromEntries(Object.values(companyColorPresets).map(preset => [preset.companyId,
`rgb(${[1, 3, 5].map(index => parseInt(preset.primary.slice(index, index + 2), 16)).join(', ')})`]));
await fs.mkdir(output, { recursive: true }); await fs.mkdir(output, { recursive: true });
const persist = () => writeFileSync(path.join(output, `browser-progress${suffix}.json`), JSON.stringify({ startedAt, results, measurements, errors }, null, 2)); const persist = () => writeFileSync(path.join(output, `browser-progress${suffix}.json`), JSON.stringify({ startedAt, results, measurements, errors }, null, 2));
const check = (pass, name, detail) => { const check = (pass, name, detail) => {
@@ -27,12 +25,7 @@ page.on('pageerror', error => { errors.push({ type: 'pageerror', message: error.
page.on('console', message => { if (message.type() === 'error') { errors.push({ type: 'console', message: message.text(), location: message.location() }); persist(); } }); page.on('console', message => { if (message.type() === 'error') { errors.push({ type: 'console', message: message.text(), location: message.location() }); persist(); } });
const viewport = () => page.locator('.hm-orgchart-viewport'); const viewport = () => page.locator('.hm-orgchart-viewport');
const employee = id => page.locator(`.hm-orgchart-employee[data-employee-id="${id}"]`); const employee = id => page.locator(`.hm-orgchart-employee[data-employee-id="${id}"]`);
const node = id => page.locator(`.node[data-organization-id="${id}"]`);
const title = id => page.locator(`.hm-orgchart-expanded-toggle[data-organization-id="${id}"]`); const title = id => page.locator(`.hm-orgchart-expanded-toggle[data-organization-id="${id}"]`);
const feedback = () => page.locator('.hm-orgchart-move-feedback');
const companyTrigger = () => page.getByRole('button', { name: /^회사 선택/ });
const companyPanel = () => page.getByRole('dialog', { name: '회사 선택', exact: true });
const companyCheckbox = name => companyPanel().getByRole('checkbox', { name, exact: true });
const dialog = () => page.locator('[data-component="HMOrgChartEmployeeDialog"]'); const dialog = () => page.locator('[data-component="HMOrgChartEmployeeDialog"]');
const settle = () => page.evaluate(async () => { await document.fonts.ready; for (let i = 0; i < 8; i++) await new Promise(requestAnimationFrame); }); const settle = () => page.evaluate(async () => { await document.fonts.ready; for (let i = 0; i < 8; i++) await new Promise(requestAnimationFrame); });
async function ready(count) { async function ready(count) {
@@ -43,26 +36,9 @@ async function ready(count) {
}, count); }, count);
await settle(); await settle();
} }
async function select(label, option) { async function setup() {
await page.getByRole('combobox', { name: label, exact: true }).click();
await page.getByRole('option', { name: option, exact: true }).click(); await ready();
}
async function chooseCompanies(names) {
await companyTrigger().click(); await companyPanel().waitFor();
const all = companyCheckbox('전체 선택');
// The established mixed select-all action selects all first, then none.
for (let i = 0; i < 2 && await all.getAttribute('aria-checked') !== 'false'; i++) { await all.click(); await ready(); }
await ready(0);
for (const name of names) { await companyCheckbox(name).click(); await ready(); }
await page.keyboard.press('Escape'); await companyPanel().waitFor({ state: 'hidden' }); await ready();
}
async function setup({ companies = ['한맥기술'], layout = 'tb' } = {}) {
await page.goto(`${origin}/iframe.html?id=orgchart--expanded-org-chart&viewMode=story`); await page.goto(`${origin}/iframe.html?id=orgchart--expanded-org-chart&viewMode=story`);
await companyTrigger().waitFor(); await ready(20); await ready(140);
await chooseCompanies(companies);
await select('데이터 범위', '회사별');
await page.getByRole('button', { name: '데이터 적용', exact: true }).click(); await ready(companies.length * 20);
if (layout !== 'tb') await select('조직도 표시 유형', layout === 'lr' ? '가로형 LR' : '혼합형 Hybrid');
await viewport().scrollIntoViewIfNeeded(); await page.getByRole('button', { name: 'Fit to View', exact: true }).click(); await settle(); await viewport().scrollIntoViewIfNeeded(); await page.getByRole('button', { name: 'Fit to View', exact: true }).click(); await settle();
} }
async function state() { async function state() {
@@ -88,7 +64,7 @@ async function state() {
drop: [...shadow.querySelectorAll('[data-drop-state]')].map(element => ({ id: element.dataset.organizationId, state: element.dataset.dropState, hover: element.dataset.dropHover })), drop: [...shadow.querySelectorAll('[data-drop-state]')].map(element => ({ id: element.dataset.organizationId, state: element.dataset.dropState, hover: element.dataset.dropHover })),
dragging: viewport.dataset.editDragging, panDragging: viewport.dataset.dragging, activeEmployee: shadow.activeElement?.dataset.employeeId, dragging: viewport.dataset.editDragging, panDragging: viewport.dataset.dragging, activeEmployee: shadow.activeElement?.dataset.employeeId,
activeOrganization: shadow.activeElement?.dataset.organizationId, activeOrganization: shadow.activeElement?.dataset.organizationId,
countText: document.querySelector('[data-component="OrgChartExampleCounts"]')?.textContent.trim(), dialogs: document.querySelectorAll('[data-component="HMOrgChartEmployeeDialog"]').length, dialogs: document.querySelectorAll('[data-component="HMOrgChartEmployeeDialog"]').length,
employeesVisible: cards.length, employeeCount: cards.length, organizationCount: nodes.length, employeesVisible: cards.length, employeeCount: cards.length, organizationCount: nodes.length,
companyRoots: nodes.filter(org => org.parentId === '').map(org => org.companyId), companyRoots: nodes.filter(org => org.parentId === '').map(org => org.companyId),
}; };
@@ -127,97 +103,14 @@ async function centerPair(source, target) {
await pan(dx, dy); await pan(dx, dy);
} }
} }
async function drag(source, target, { inspect = false, screenshot } = {}) {
await centerPair(source, target);
const a = await source.boundingBox(), b = await target.boundingBox();
const before = await state();
await page.mouse.move(a.x + a.width / 2, a.y + a.height / 2); await page.mouse.down();
await page.mouse.move(a.x + a.width / 2 + 8, a.y + a.height / 2, { steps: 2 });
const started = inspect ? await state() : undefined;
await page.mouse.move(b.x + b.width / 2, b.y + b.height / 2, { steps: 14 });
const hover = inspect ? await state() : undefined;
if (screenshot) await snap(screenshot);
await page.mouse.up(); await ready();
return { before, started, hover, pointer: { source: a, target: b }, after: await state() };
}
async function snap(name) { await page.screenshot({ path: path.join(output, `${name}.png`), fullPage: false }); } async function snap(name) { await page.screenshot({ path: path.join(output, `${name}.png`), fullPage: false }); }
async function section(name, action) { async function section(name, action) {
if (only.length && !only.includes(name) && !(name.startsWith('mode-') && only.includes('modes'))) return; if (only.length && !only.includes(name)) return;
try { await action(); } catch (error) { try { await action(); } catch (error) {
check(false, `${name}: execution completed`, { message: error.message, stack: error.stack }); check(false, `${name}: execution completed`, { message: error.message, stack: error.stack });
await page.mouse.up().catch(() => {}); await snap(`failure-${name}`).catch(() => {}); await page.mouse.up().catch(() => {}); await snap(`failure-${name}`).catch(() => {});
} }
} }
function checkConsistency(s, name, total = 20) {
const uniqueEmployees = new Set(s.cards.map(card => card.id)), uniqueOrganizations = new Set(s.nodes.map(org => org.id));
check(uniqueEmployees.size === s.cards.length && uniqueOrganizations.size === s.nodes.length && s.cards.length === total, `${name}: unique cards/groups and total preserved`, { cards: s.cards.length, groups: s.nodes.length, total });
check(s.nodes.every(org => org.parentId === org.domParentId), `${name}: dabeng DOM hierarchy matches current parentId`);
check(s.cards.every(card => card.border[card.direction] === colors[card.companyId]), `${name}: company accent border matches each connector direction`);
check(s.employeePaths.length === s.cards.length && new Set(s.employeePaths.map(path => path.id)).size === s.cards.length
&& s.employeePaths.every(path => path.d && !/NaN|Infinity/.test(path.d) && s.cards.some(card => card.id === path.id && card.organizationId === path.organizationId && card.direction === path.direction)), `${name}: employee connectors refreshed once per card`);
check(s.paths.every(path => path.d && !/NaN|Infinity/.test(path.d) && s.nodes.some(org => org.id === path.id && org.parentId === path.parentId))
&& new Set(s.paths.map(path => path.id)).size === s.paths.length, `${name}: organization connectors use current parents without duplicate paths`);
}
await section('staff', async () => {
await setup();
const result = await drag(employee('virtual-022'), title('hanmac-office-sibling'), { inspect: true, screenshot: 'tb-staff-drop-hover' });
measurements.push({ name: 'TB employee move', ...result });
check(result.started.dragging === 'true' && result.started.drop.some(target => target.state === 'allowed') && result.started.drop.some(target => target.state === 'blocked'), 'Employee threshold starts editing and marks allowed/blocked targets');
check(result.hover.drop.find(target => target.id === 'hanmac-office-sibling')?.hover === 'allowed', 'Employee valid target hover is identified');
check(result.after.cards.find(card => card.id === 'virtual-022')?.organizationId === 'hanmac-office-sibling' && !result.after.cards.some(card => card.id === 'virtual-022' && card.organizationId === 'hanmac-office-0'), 'Employee membership moves to new group once');
check(result.after.feedback.status === 'success' && result.after.dialogs === 0 && result.after.drop.length === 0, 'Employee success announces completion and suppresses click/Dialog');
check(Math.abs(result.before.scale - result.after.scale) < .0001 && Math.abs(result.before.panX - result.hover.panX) < .1 && Math.abs(result.before.panY - result.hover.panY) < .1 && !result.hover.panDragging, 'Card Drag preserves Zoom and does not execute Pan');
const sourceBefore = result.before.nodes.find(org => org.id === 'hanmac-office-0'), sourceAfter = result.after.nodes.find(org => org.id === 'hanmac-office-0');
const targetBefore = result.before.nodes.find(org => org.id === 'hanmac-office-sibling'), targetAfter = result.after.nodes.find(org => org.id === 'hanmac-office-sibling');
check(sourceAfter.direct === sourceBefore.direct - 1 && sourceAfter.inclusive === sourceBefore.inclusive - 1 && targetAfter.direct === targetBefore.direct + 1 && targetAfter.inclusive === targetBefore.inclusive + 1, 'Employee source/destination direct and inclusive counts update');
checkConsistency(result.after, 'TB employee');
await employee('virtual-022').click(); await dialog().waitFor();
const organization = await dialog().locator('dl > div').filter({ has: page.locator('dt', { hasText: '소속 조직' }) }).locator('dd').innerText();
check(organization === '가상 계획실' && await dialog().getAttribute('data-employee-id') === 'virtual-022', 'HM Dialog shows new organization and same employee ID', organization);
await page.keyboard.press('Escape'); await dialog().waitFor({ state: 'hidden' }); await settle();
check((await state()).activeEmployee === 'virtual-022', 'HM Dialog returns focus to moved card');
await snap('tb-staff-moved');
});
await section('groups', async () => {
await setup();
const result = await drag(title('hanmac-office-0'), title('hanmac-office-1'), { inspect: true, screenshot: 'tb-group-drop-hover' });
measurements.push({ name: 'TB group move', ...result });
check(result.after.nodes.find(org => org.id === 'hanmac-office-0')?.parentId === 'hanmac-office-1', 'Group parentId changes to dropped organization');
const descendants = ['hanmac-cell-0', 'hanmac-team-0', 'hanmac-team-sibling'];
check(descendants.every(id => result.after.nodes.find(org => org.id === id)?.parentId === result.before.nodes.find(org => org.id === id)?.parentId)
&& result.after.cards.every(card => result.before.cards.find(before => before.id === card.id)?.organizationId === card.organizationId), 'Group move retains subtree and every employee membership');
const inclusive = result.before.nodes.find(org => org.id === 'hanmac-office-0').inclusive;
check(result.after.nodes.find(org => org.id === 'hanmac-division-0').inclusive === result.before.nodes.find(org => org.id === 'hanmac-division-0').inclusive - inclusive
&& result.after.nodes.find(org => org.id === 'hanmac-division-1').inclusive === result.before.nodes.find(org => org.id === 'hanmac-division-1').inclusive + inclusive, 'Group move updates ancestor inclusive counts');
check(result.after.feedback.status === 'success' && result.after.nodes.find(org => org.id === 'hanmac-office-0').collapsed === 'false' && result.after.dialogs === 0, 'Title Drag suppresses collapse and Dialog');
checkConsistency(result.after, 'TB group'); await snap('tb-group-moved');
await title('hanmac-office-0').click(); await ready(); check(await title('hanmac-office-0').getAttribute('aria-expanded') === 'false', 'Moved group title click still collapses');
await title('hanmac-office-0').press('Space'); await ready(20); check(await title('hanmac-office-0').getAttribute('aria-expanded') === 'true', 'Moved group Space still expands');
});
await section('invalid', async () => {
await setup({ companies: ['한맥기술', '삼안'] });
for (const [name, source, target, reason] of [
['protected leader', employee('virtual-002'), title('hanmac-office-sibling'), '조직장'],
['group descendant cycle', title('hanmac-office-0'), title('hanmac-cell-0'), '순환'],
['group self', title('hanmac-office-0'), title('hanmac-office-0'), '자기 자신'],
['company root', title('hanmac-root'), title('hanmac-office-sibling'), '최상위'],
]) {
const result = await drag(source, target, { inspect: true });
check(relationState(result.before) === relationState(result.after) && result.after.feedback.status === 'failure' && result.after.feedback.text.includes(reason), `${name}: rejects with reason and retains data`, result.after.feedback);
const targetId = await target.getAttribute('data-organization-id');
check(result.hover.drop.find(item => item.id === targetId)?.hover === 'blocked' && result.after.dialogs === 0, `${name}: blocked hover and no Dialog`);
}
await centerPair(employee('virtual-022'), title('hanmac-office-sibling'));
const before = await state(), source = await employee('virtual-022').boundingBox(), box = await viewport().boundingBox();
await page.mouse.move(source.x + source.width / 2, source.y + source.height / 2); await page.mouse.down();
await page.mouse.move(source.x + source.width / 2 + 8, source.y + source.height / 2);
await page.mouse.move(box.x + box.width + 12, box.y + 50, { steps: 12 }); await page.mouse.up(); await ready();
const after = await state(); check(relationState(before) === relationState(after) && after.feedback.status === 'failure' && !after.dragging && after.dialogs === 0, 'Outside viewport release cancels edit without changing data', after.feedback);
await snap('invalid-drop-rejected');
});
await section('pan-click', async () => { await section('pan-click', async () => {
await setup(); await centerPair(employee('virtual-022'), title('hanmac-office-sibling')); await setup(); await centerPair(employee('virtual-022'), title('hanmac-office-sibling'));
@@ -230,167 +123,9 @@ await section('pan-click', async () => {
await dialog().waitFor(); check(!small.dragging && await dialog().getAttribute('data-employee-id') === 'virtual-022', 'Movement below six-pixel threshold remains employee click'); await dialog().waitFor(); check(!small.dragging && await dialog().getAttribute('data-employee-id') === 'virtual-022', 'Movement below six-pixel threshold remains employee click');
await page.keyboard.press('Escape'); await dialog().waitFor({ state: 'hidden' }); await page.keyboard.press('Escape'); await dialog().waitFor({ state: 'hidden' });
await title('hanmac-office-sibling').click(); await ready(); check(await title('hanmac-office-sibling').getAttribute('aria-expanded') === 'false', 'Simple group click collapses'); await title('hanmac-office-sibling').click(); await ready(); check(await title('hanmac-office-sibling').getAttribute('aria-expanded') === 'false', 'Simple group click collapses');
await title('hanmac-office-sibling').click(); await ready(20); check(await title('hanmac-office-sibling').getAttribute('aria-expanded') === 'true', 'Simple group click expands'); await title('hanmac-office-sibling').click(); await ready(140); check(await title('hanmac-office-sibling').getAttribute('aria-expanded') === 'true', 'Simple group click expands');
}); });
await section('zoom', async () => {
await setup();
let current = 'hanmac-office-0';
for (const scale of [1, .65, 1.4]) {
await page.getByRole('button', { name: '100% 복원', exact: true }).click(); await settle();
if (scale !== 1) { const p = await blankPoint(); await page.mouse.move(p.x, p.y); await page.keyboard.down('Control'); await page.mouse.wheel(0, -Math.log(scale) / .002); await page.keyboard.up('Control'); await settle(); }
check(Math.abs((await state()).scale - scale) < .002, `${scale}: actual Ctrl+wheel Zoom reaches requested scale`);
const target = current === 'hanmac-office-0' ? 'hanmac-office-sibling' : 'hanmac-office-0';
const result = await drag(employee('virtual-022'), title(target));
check(result.after.cards.find(card => card.id === 'virtual-022')?.organizationId === target && Math.abs(result.after.scale - scale) < .002 && result.after.dialogs === 0, `${scale}: actual pointer Drop uses transformed screen coordinates`);
measurements.push({ name: `zoom-${scale}`, before: result.before, after: result.after }); current = target;
}
await page.getByRole('button', { name: 'Fit to View', exact: true }).click(); await settle();
await pan(48, 24);
const target = current === 'hanmac-office-0' ? 'hanmac-office-sibling' : 'hanmac-office-0';
const result = await drag(employee('virtual-022'), title(target));
check(result.after.cards.find(card => card.id === 'virtual-022')?.organizationId === target && result.after.scale === result.before.scale, 'Drop after Fit and Pan uses current transformed target');
await snap('zoom-pan-drop-complete');
});
for (const layout of ['lr', 'hybrid']) await section(`mode-${layout}`, async () => {
await setup({ layout });
const staff = await drag(employee('virtual-022'), title('hanmac-office-sibling'));
check(staff.after.layout === layout && staff.after.cards.find(card => card.id === 'virtual-022')?.organizationId === 'hanmac-office-sibling' && staff.after.feedback.status === 'success', `${layout}: actual employee Drag supported`);
const group = await drag(title('hanmac-office-0'), title('hanmac-office-1'), { inspect: true });
check(group.after.nodes.find(org => org.id === 'hanmac-office-0')?.parentId === 'hanmac-office-1' && group.after.feedback.status === 'success', `${layout}: actual group Drag supported`);
check(group.after.paths.some(path => path.id === 'hanmac-office-0' && path.parentId === 'hanmac-office-1'), `${layout}: moved group connector uses new parent`);
checkConsistency(group.after, layout); measurements.push({ name: `${layout}-moves`, staff: staff.after, group: group.after, groupDrag: group });
await snap(`${layout}-group-moved`);
});
await section('retained', async () => {
await setup();
await title('hanmac-office-sibling').click(); await ready();
const moved = await drag(employee('virtual-022'), title('hanmac-office-sibling'));
check(moved.after.feedback.status === 'success' && await title('hanmac-office-sibling').getAttribute('aria-expanded') === 'false' && await employee('virtual-022').count() === 0, 'Drop onto collapsed target updates relationship and retains collapsed view');
await title('hanmac-office-sibling').click(); await ready(20);
check((await state()).cards.find(card => card.id === 'virtual-022')?.organizationId === 'hanmac-office-sibling', 'Expanding target reveals moved employee once');
await title('hanmac-office-0').click(); await ready();
const group = await drag(title('hanmac-office-0'), title('hanmac-office-1'));
check(group.after.nodes.find(org => org.id === 'hanmac-office-0')?.parentId === 'hanmac-office-1' && await title('hanmac-office-0').getAttribute('aria-expanded') === 'false', 'Moving collapsed source group retains its collapsed state');
await title('hanmac-office-0').click(); await ready(20);
check((await state()).nodes.find(org => org.id === 'hanmac-cell-0')?.parentId === 'hanmac-office-0', 'Moved collapsed subtree is restored when source expands');
await chooseCompanies([]); await ready(0); await chooseCompanies(['한맥기술']); await ready(20);
const shown = await state(); check(shown.cards.find(card => card.id === 'virtual-022')?.organizationId === 'hanmac-office-sibling' && shown.nodes.find(org => org.id === 'hanmac-office-0')?.parentId === 'hanmac-office-1', 'Company hide/re-show retains employee and group edits without duplicate data');
checkConsistency(shown, 'Re-shown company');
await page.getByRole('button', { name: '초기화', exact: true }).click(); await ready(20);
const reset = await state(); check(reset.companyRoots.length === 7 && reset.nodes.find(org => org.id === 'hanmac-office-0')?.parentId === 'hanmac-division-0' && reset.feedback.status === 'idle', 'Reset regenerates original virtual data/settings and clears move feedback');
});
// A failed reset assertion can be checked without repeating successful
// collapsed-target/company-selection probes in the retained section.
await section('reset', async () => {
await setup(); await drag(title('hanmac-office-0'), title('hanmac-office-1'));
const edited = await state();
await page.getByRole('button', { name: '초기화', exact: true }).click(); await ready(20);
const reset = await state();
check(edited.nodes.find(org => org.id === 'hanmac-office-0')?.parentId === 'hanmac-office-1'
&& reset.companyRoots.length === 7 && reset.nodes.find(org => org.id === 'hanmac-office-0')?.parentId === 'hanmac-division-0' && reset.feedback.status === 'idle',
'Reset clears actual group edit, restores seven company roots and original membership, and clears feedback', { before: edited.feedback, after: reset.feedback, roots: reset.companyRoots });
});
await section('hybrid-cross-staff', async () => {
await setup({ companies: ['한맥기술', '삼안'], layout: 'hybrid' });
const staff = await drag(employee('virtual-022'), title('saman-office-0'), { inspect: true, screenshot: 'hybrid-cross-company-staff-hover' });
const card = staff.after.cards.find(card => card.id === 'virtual-022');
check(staff.hover.drop.find(target => target.id === 'saman-office-0')?.hover === 'allowed'
&& card?.organizationId === 'saman-office-0' && card.companyId === 'saman' && card.border[card.direction] === colors.saman && staff.after.feedback.status === 'success',
'hybrid: cross-company staff Drag updates organization, company and color', staff.after.feedback);
check(staff.after.nodes.find(org => org.id === 'hanmac-root').inclusive === 19 && staff.after.nodes.find(org => org.id === 'saman-root').inclusive === 21
&& staff.after.cards.length === 40 && new Set(staff.after.cards.map(card => card.id)).size === 40 && staff.after.dialogs === 0,
'hybrid: cross-company staff updates both company totals without duplication/click');
await employee('virtual-022').click(); await dialog().waitFor();
const company = await dialog().locator('dl > div').filter({ has: page.locator('dt', { hasText: '소속 회사' }) }).locator('dd').innerText();
const organization = await dialog().locator('dl > div').filter({ has: page.locator('dt', { hasText: '소속 조직' }) }).locator('dd').innerText();
check(company === '삼안' && organization === '가상 1실', 'Hybrid cross-company HM Dialog shows changed company and organization', { company, organization });
});
await section('cross-company', async () => {
for (const layout of ['tb', 'lr', 'hybrid']) {
await setup({ companies: ['한맥기술', '삼안'], layout });
let initial = await state();
if (layout !== 'hybrid') {
const staff = await drag(employee('virtual-022'), title('saman-office-0'), { inspect: true, screenshot: `${layout}-cross-company-staff-hover` });
const card = staff.after.cards.find(card => card.id === 'virtual-022');
check(staff.hover.drop.find(target => target.id === 'saman-office-0')?.hover === 'allowed'
&& card?.organizationId === 'saman-office-0' && card.companyId === 'saman' && card.border[card.direction] === colors.saman && staff.after.feedback.status === 'success', `${layout}: cross-company staff Drag updates organization, company and color`, staff.after.feedback);
check(staff.after.nodes.find(org => org.id === 'hanmac-root').inclusive === 19 && staff.after.nodes.find(org => org.id === 'saman-root').inclusive === 21
&& staff.after.cards.length === 40 && staff.after.dialogs === 0, `${layout}: cross-company staff updates both company totals without duplication/click`);
if (layout === 'tb') {
await employee('virtual-022').click(); await dialog().waitFor();
const company = await dialog().locator('dl > div').filter({ has: page.locator('dt', { hasText: '소속 회사' }) }).locator('dd').innerText();
const organization = await dialog().locator('dl > div').filter({ has: page.locator('dt', { hasText: '소속 조직' }) }).locator('dd').innerText();
check(company === '삼안' && organization === '가상 1실', 'Cross-company HM Dialog shows changed company and organization', { company, organization });
await page.keyboard.press('Escape'); await dialog().waitFor({ state: 'hidden' }); await settle();
}
initial = staff.after;
}
const subtree = new Set(['hanmac-office-0', 'hanmac-cell-0', 'hanmac-team-0', 'hanmac-team-sibling']);
const transferred = initial.nodes.find(org => org.id === 'hanmac-office-0').inclusive;
const subtreeEmployees = initial.cards.filter(card => subtree.has(card.organizationId)).map(card => card.id);
const group = await drag(title('hanmac-office-0'), title('saman-office-1'), { inspect: true, screenshot: `${layout}-cross-company-group-hover` });
check(group.hover.drop.find(target => target.id === 'saman-office-1')?.hover === 'allowed'
&& group.after.nodes.find(org => org.id === 'hanmac-office-0')?.parentId === 'saman-office-1' && group.after.feedback.status === 'success', `${layout}: cross-company group Drag changes real parent`, group.after.feedback);
check(group.after.nodes.filter(org => subtree.has(org.id)).every(org => org.companyId === 'saman')
&& subtreeEmployees.every(id => { const card = group.after.cards.find(card => card.id === id); return card?.companyId === 'saman' && subtree.has(card.organizationId) && card.border[card.direction] === colors.saman; })
&& group.after.nodes.find(org => org.id === 'hanmac-cell-0')?.parentId === 'hanmac-office-0', `${layout}: entire transferred subtree retains IDs/hierarchy and receives target company colors`);
check(group.after.nodes.find(org => org.id === 'hanmac-root').inclusive === initial.nodes.find(org => org.id === 'hanmac-root').inclusive - transferred
&& group.after.nodes.find(org => org.id === 'saman-root').inclusive === initial.nodes.find(org => org.id === 'saman-root').inclusive + transferred, `${layout}: cross-company group updates both company headcounts`);
checkConsistency(group.after, `${layout} cross-company`, 40);
measurements.push({ name: `${layout} cross-company transfers`, initial, groupDrag: group }); await snap(`${layout}-cross-company-complete`);
if (layout === 'tb') {
const samanCount = group.after.nodes.find(org => org.id === 'saman-root').inclusive;
const hanmacCount = group.after.nodes.find(org => org.id === 'hanmac-root').inclusive;
await chooseCompanies(['삼안']); await ready(samanCount); let shown = await state();
check(shown.cards.length === samanCount && shown.countText.includes(`합계 ${samanCount}명`) && shown.cards.some(card => card.id === 'virtual-022')
&& shown.nodes.find(org => org.id === 'hanmac-office-0')?.parentId === 'saman-office-1', 'Destination-only selection retains transferred employee/subtree and shows current destination headcount', shown.countText);
await chooseCompanies(['한맥기술']); await ready(hanmacCount); shown = await state();
check(shown.cards.length === hanmacCount && shown.countText.includes(`합계 ${hanmacCount}명`) && !shown.cards.some(card => card.id === 'virtual-022')
&& !shown.nodes.some(org => org.id === 'hanmac-office-0'), 'Source-only selection excludes transferred data and shows current source headcount', shown.countText);
await chooseCompanies(['한맥기술', '삼안']); await ready(40); shown = await state();
check(new Set(shown.cards.map(card => card.id)).size === 40 && shown.nodes.find(org => org.id === 'hanmac-office-0')?.parentId === 'saman-office-1', 'Both companies re-show preserves cross-company edits without duplicate cards');
}
}
});
await section('keyboard', async () => {
await setup(); await employee('virtual-022').focus(); await settle();
await page.keyboard.press('Alt+m'); await settle(); const opened = await state();
check(opened.dragging === 'true' && opened.drop.some(target => target.hover === 'allowed'), 'Alt+M starts keyboard movement with an allowed target');
const initialHover = opened.drop.find(target => target.hover)?.id;
await page.keyboard.press('ArrowRight'); await settle(); check((await state()).drop.find(target => target.hover)?.id !== initialHover, 'Keyboard Arrow changes target');
await page.keyboard.press('Escape'); await settle(); const cancelled = await state();
check(!cancelled.dragging && cancelled.feedback.status === 'cancelled' && cancelled.activeEmployee === 'virtual-022' && cancelled.cards.find(card => card.id === 'virtual-022')?.organizationId === 'hanmac-office-0', 'Escape cancels keyboard edit, retains data and returns source focus');
await page.keyboard.press('Alt+m'); await settle();
for (let i = 0; i < 30 && (await state()).drop.find(target => target.hover)?.id !== 'hanmac-office-sibling'; i++) { await page.keyboard.press('ArrowRight'); await settle(); }
check((await state()).drop.find(target => target.hover)?.id === 'hanmac-office-sibling', 'Keyboard selects intended same-company destination');
await page.keyboard.press('Enter'); await ready(20); const completed = await state();
check(completed.cards.find(card => card.id === 'virtual-022')?.organizationId === 'hanmac-office-sibling' && completed.feedback.status === 'success' && completed.dialogs === 0, 'Keyboard Enter commits movement without employee Dialog');
await title('hanmac-office-0').focus(); await settle(); await page.keyboard.press('Alt+m'); await settle();
for (let i = 0; i < 30 && (await state()).drop.find(target => target.hover)?.id !== 'hanmac-office-1'; i++) { await page.keyboard.press('ArrowDown'); await settle(); }
await page.keyboard.press('Enter'); await ready(20);
check((await state()).nodes.find(org => org.id === 'hanmac-office-0')?.parentId === 'hanmac-office-1', 'Keyboard group move changes parent through same policy');
await snap('keyboard-move-complete');
});
await section('fit-cross', async () => {
for (const layout of ['tb', 'lr', 'hybrid']) {
await setup({ companies: ['한맥기술', '삼안'], layout });
await drag(title('hanmac-office-0'), title('saman-office-1'));
await page.getByRole('button', { name: 'Fit to View', exact: true }).click(); await settle();
const fitted = await state();
check(fitted.feedback.status === 'success' && fitted.forest.x >= fitted.viewport.x - 1 && fitted.forest.right <= fitted.viewport.right + 1
&& fitted.forest.y >= fitted.viewport.y - 1 && fitted.forest.bottom <= fitted.viewport.bottom + 1,
`${layout}: Fit contains recalculated forest bounds after cross-company subtree transfer`, { viewport: fitted.viewport, forest: fitted.forest, scale: fitted.scale });
measurements.push({ name: `${layout} post-transfer Fit`, state: fitted }); await snap(`${layout}-cross-company-fit`);
}
});
check(errors.length === 0, 'New console/page errors: zero', errors); check(errors.length === 0, 'New console/page errors: zero', errors);
const result = { startedAt, completedAt: new Date().toISOString(), browser: browser.version(), origin, sections: only, results, measurements, errors, const result = { startedAt, completedAt: new Date().toISOString(), browser: browser.version(), origin, sections: only, results, measurements, errors,
+16 -149
View File
@@ -1,5 +1,7 @@
// Focused P9.4 Storybook deltas. Run named sections to repair only failed probes. // Focused P9.4 Storybook deltas. Run named sections to repair only failed probes.
// Existing P7-P9.3 evidence is preserved and is not replayed by this harness. // Existing P7-P9.3 evidence is preserved and is not replayed by this harness.
// Expanded is preview-only (2026-10-22): company/data/layout settings and the Drag & Drop switch were
// removed, so data/dropdown/on/cancel/modes sections were retired (Core ON/cancel stays in verify-p9-4-core.mjs).
import fs from 'node:fs/promises'; import fs from 'node:fs/promises';
import { writeFileSync } from 'node:fs'; import { writeFileSync } from 'node:fs';
import path from 'node:path'; import path from 'node:path';
@@ -26,11 +28,7 @@ page.on('console', message => { if (message.type() === 'error') { errors.push({
const viewport = () => page.locator('.hm-orgchart-viewport'); const viewport = () => page.locator('.hm-orgchart-viewport');
const employee = id => page.locator(`.hm-orgchart-employee[data-employee-id="${id}"]`); const employee = id => page.locator(`.hm-orgchart-employee[data-employee-id="${id}"]`);
const title = id => page.locator(`.hm-orgchart-expanded-toggle[data-organization-id="${id}"]`); const title = id => page.locator(`.hm-orgchart-expanded-toggle[data-organization-id="${id}"]`);
const companyTrigger = () => page.getByRole('button', { name: /^회사 선택/ });
const companyPanel = () => page.getByRole('dialog', { name: '회사 선택', exact: true });
const companyCheckbox = name => companyPanel().getByRole('checkbox', { name, exact: true });
const dialog = () => page.locator('[data-component="HMOrgChartEmployeeDialog"]'); const dialog = () => page.locator('[data-component="HMOrgChartEmployeeDialog"]');
const editSwitch = () => page.getByRole('switch', { name: /Drag & Drop|조직 이동/ });
const settle = () => page.evaluate(async () => { await document.fonts.ready; for (let i = 0; i < 8; i++) await new Promise(requestAnimationFrame); }); const settle = () => page.evaluate(async () => { await document.fonts.ready; for (let i = 0; i < 8; i++) await new Promise(requestAnimationFrame); });
async function ready(count) { async function ready(count) {
await page.waitForFunction(count => { await page.waitForFunction(count => {
@@ -39,40 +37,10 @@ async function ready(count) {
&& (count === undefined || shadow.querySelectorAll('.hm-orgchart-employee').length === count); && (count === undefined || shadow.querySelectorAll('.hm-orgchart-employee').length === count);
}, count, { timeout: 45000 }); await settle(); }, count, { timeout: 45000 }); await settle();
} }
async function select(label, option) { async function setup() {
await page.getByRole('combobox', { name: label, exact: true }).click();
await page.getByRole('option', { name: option, exact: true }).click(); await ready();
}
async function countPerCompany(count, expected) {
await select('회사당 직원 수', `${count}명`);
const apply = page.getByRole('button', { name: '데이터 적용', exact: true });
if (await apply.count() && await apply.isEnabled()) await apply.click();
await ready(expected);
}
async function chooseCompanies(names) {
await companyTrigger().click(); await companyPanel().waitFor();
const all = companyCheckbox('전체 선택');
for (let i = 0; i < 2 && await all.getAttribute('aria-checked') !== 'false'; i++) { await all.click(); await ready(); }
await ready(0);
for (const name of names) { await companyCheckbox(name).click(); await ready(); }
await page.keyboard.press('Escape'); await companyPanel().waitFor({ state: 'hidden' }); await ready();
}
async function toggle(enabled, { programmatic = false } = {}) {
const control = editSwitch();
if (await control.getAttribute('aria-checked') !== String(enabled)) {
// A live pointer must remain down while an external settings change cancels
// it. DOM click is the same React handler and does not synthesize pointerup.
if (programmatic) await control.evaluate(element => element.click()); else await control.click();
}
await settle();
}
async function setup({ companies = ['한맥기술'], layout = 'tb', enabled = false } = {}) {
await page.setViewportSize({ width: 1920, height: 1700 }); await page.setViewportSize({ width: 1920, height: 1700 });
await page.goto(`${origin}/iframe.html?id=orgchart--expanded-org-chart&viewMode=story`); await page.goto(`${origin}/iframe.html?id=orgchart--expanded-org-chart&viewMode=story`);
await companyTrigger().waitFor(); await ready(140); await ready(140); await viewport().scrollIntoViewIfNeeded();
await chooseCompanies(companies); await ready(companies.length * 20);
if (layout !== 'tb') await select('조직도 표시 유형', layout === 'lr' ? '가로형 LR' : '혼합형 Hybrid');
await toggle(enabled); await viewport().scrollIntoViewIfNeeded();
await page.getByRole('button', { name: 'Fit to View', exact: true }).click(); await settle(); await page.getByRole('button', { name: 'Fit to View', exact: true }).click(); await settle();
} }
async function state() { async function state() {
@@ -96,7 +64,7 @@ async function state() {
drop: [...shadow.querySelectorAll('[data-drop-state]')].map(element => ({ id: element.dataset.organizationId, state: element.dataset.dropState, hover: element.dataset.dropHover })), drop: [...shadow.querySelectorAll('[data-drop-state]')].map(element => ({ id: element.dataset.organizationId, state: element.dataset.dropState, hover: element.dataset.dropHover })),
dragging: viewport.dataset.editDragging, panDragging: viewport.dataset.dragging, activeEmployee: shadow.activeElement?.dataset.employeeId, dragging: viewport.dataset.editDragging, panDragging: viewport.dataset.dragging, activeEmployee: shadow.activeElement?.dataset.employeeId,
activeOrganization: shadow.activeElement?.dataset.organizationId, dragDropEnabled: viewport.dataset.dragDropEnabled, activeOrganization: shadow.activeElement?.dataset.organizationId, dragDropEnabled: viewport.dataset.dragDropEnabled,
countText: document.querySelector('[data-component="OrgChartExampleCounts"]')?.textContent.trim(), dialogs: document.querySelectorAll('[data-component="HMOrgChartEmployeeDialog"]').length, dialogs: document.querySelectorAll('[data-component="HMOrgChartEmployeeDialog"]').length,
companyRoots: nodes.filter(org => org.parentId === '').map(org => org.companyId), documentWidth: document.documentElement.scrollWidth, companyRoots: nodes.filter(org => org.parentId === '').map(org => org.companyId), documentWidth: document.documentElement.scrollWidth,
}; };
}); });
@@ -140,14 +108,6 @@ async function section(name, action) {
if (only.length && !only.includes(name)) return; if (only.length && !only.includes(name)) return;
try { await action(); } catch (error) { check(false, `${name}: execution completed`, { message: error.message, stack: error.stack }); await page.mouse.up().catch(() => {}); await snap(`failure-${name}`, true).catch(() => {}); } try { await action(); } catch (error) { check(false, `${name}: execution completed`, { message: error.message, stack: error.stack }); await page.mouse.up().catch(() => {}); await snap(`failure-${name}`, true).catch(() => {}); }
} }
function counts(s, companies, perCompany, name) {
const grouped = Object.fromEntries(companies.map(id => [id, s.cards.filter(card => card.companyId === id).length]));
check(s.cards.length === companies.length * perCompany && new Set(s.cards.map(card => card.id)).size === s.cards.length
&& new Set(s.nodes.map(org => org.id)).size === s.nodes.length && Object.values(grouped).every(count => count === perCompany), `${name}: unique IDs and exact per-company employee counts`, grouped);
check(s.companyRoots.length === companies.length && s.nodes.filter(org => org.parentId === '').every(org => org.inclusive === perCompany), `${name}: root totals count organizational leaders exactly once`);
check(s.countText?.includes(`합계 ${(companies.length * perCompany).toLocaleString('ko-KR')}명`), `${name}: displayed total matches Employee IDs`, s.countText);
}
await section('department', async () => { await section('department', async () => {
await page.setViewportSize({ width: 1440, height: 1000 }); await page.setViewportSize({ width: 1440, height: 1000 });
await page.goto(`${origin}/iframe.html?id=orgchart--department-colors&viewMode=story`); await ready(); await page.goto(`${origin}/iframe.html?id=orgchart--department-colors&viewMode=story`); await ready();
@@ -250,36 +210,9 @@ await section('department-names', async () => {
await snap('department-names',true); measurements.push({ name:'Department naming correction',current }); await snap('department-names',true); measurements.push({ name:'Department naming correction',current });
}); });
await section('data', async () => {
await setup();
check(await page.getByRole('combobox', { name: '데이터 범위', exact: true }).count() === 0 && !await page.getByText('전체 회사 합산', { exact: true }).count(), 'Expanded removes both data-scope choices');
counts(await state(), ['hanmac'], 20, 'one company x20');
for (const count of [100, 300]) { await countPerCompany(count, count); counts(await state(), ['hanmac'], count, `one company x${count}`); }
await countPerCompany(100, 100); await chooseCompanies(['한맥기술', '삼안']); await ready(200);
counts(await state(), ['hanmac', 'saman'], 100, 'two companies x100'); await snap('expanded-scope-removed', true);
await companyTrigger().click(); await companyPanel().waitFor(); await companyCheckbox('전체 선택').click(); await ready(700);
await page.keyboard.press('Escape'); await companyPanel().waitFor({ state: 'hidden' }); await countPerCompany(300, 2100);
const allIds = ['hanmac','saman','jangheon-industry','jangheon','ptc','halla','baron'];
counts(await state(), allIds, 300, 'seven companies x300'); measurements.push({ name: 'seven x300', state: await state() });
await chooseCompanies([]); await ready(0); const empty = await state();
check(empty.cards.length === 0 && empty.companyRoots.length === 0 && empty.countText.includes('0개 회사') && await page.locator('.hm-orgchart-expanded-empty').count() === 1, 'Zero selected companies renders explicit empty state');
await snap('expanded-empty'); await chooseCompanies(['한맥기술']); await ready(300); counts(await state(), ['hanmac'], 300, 'selection after empty');
});
await section('dropdown', async () => {
await setup(); await chooseCompanies(['한맥기술']); await companyTrigger().focus(); await page.keyboard.press('Enter'); await companyPanel().waitFor();
check(await companyCheckbox('전체 선택').evaluate(element => element === document.activeElement), 'Company dropdown keyboard opening focuses select-all');
await page.keyboard.press('Tab'); await page.keyboard.press('Tab'); await page.keyboard.press('Space'); await ready(40);
check(await companyCheckbox('삼안').getAttribute('aria-checked') === 'true' && await companyCheckbox('전체 선택').getAttribute('aria-checked') === 'mixed', 'Company keyboard Space selects second company and mixed state');
await snap('company-dropdown'); await page.keyboard.press('Escape'); await companyPanel().waitFor({ state: 'hidden' });
check(await companyTrigger().evaluate(element => element === document.activeElement), 'Company ESC closes and restores trigger focus');
await companyTrigger().click(); await companyPanel().waitFor(); await page.locator('.sb-page-title').click(); await companyPanel().waitFor({ state: 'hidden' });
check(true, 'Company outside click closes existing HM Popover');
});
await section('off', async () => { await section('off', async () => {
await setup({ companies: ['한맥기술', '삼안'] }); await setup();
check(await editSwitch().getAttribute('aria-checked') === 'false', 'Drag & Drop defaults OFF'); await snap('drag-drop-off', true); check(await viewport().getAttribute('data-drag-drop-enabled') === 'false', 'Drag & Drop fixed OFF'); await snap('drag-drop-off', true);
for (const [name, source, target] of [ for (const [name, source, target] of [
['employee within company', employee('virtual-022'), title('hanmac-office-sibling')], ['employee within company', employee('virtual-022'), title('hanmac-office-sibling')],
['group within company', title('hanmac-office-0'), title('hanmac-office-1')], ['group within company', title('hanmac-office-0'), title('hanmac-office-1')],
@@ -296,7 +229,7 @@ await section('off', async () => {
await viewport().scrollIntoViewIfNeeded(); const before = await state(); await pan(70, -35); const after = await state(); await viewport().scrollIntoViewIfNeeded(); const before = await state(); await pan(70, -35); const after = await state();
check(Math.abs(after.panX - before.panX - 70) < 1 && Math.abs(after.panY - before.panY + 35) < 1 && relations(before) === relations(after), 'OFF preserves blank-space Pan'); check(Math.abs(after.panX - before.panX - 70) < 1 && Math.abs(after.panY - before.panY + 35) < 1 && relations(before) === relations(after), 'OFF preserves blank-space Pan');
await title('hanmac-office-sibling').click(); await ready(); check(await title('hanmac-office-sibling').getAttribute('aria-expanded') === 'false', 'OFF group click collapses'); await title('hanmac-office-sibling').click(); await ready(); check(await title('hanmac-office-sibling').getAttribute('aria-expanded') === 'false', 'OFF group click collapses');
await title('hanmac-office-sibling').press('Space'); await ready(40); check(await title('hanmac-office-sibling').getAttribute('aria-expanded') === 'true', 'OFF group Space expands'); await title('hanmac-office-sibling').press('Space'); await ready(140); check(await title('hanmac-office-sibling').getAttribute('aria-expanded') === 'true', 'OFF group Space expands');
await employee('virtual-022').click(); await dialog().waitFor(); check(await dialog().getAttribute('data-employee-id') === 'virtual-022', 'OFF employee click opens HM Dialog'); await employee('virtual-022').click(); await dialog().waitFor(); check(await dialog().getAttribute('data-employee-id') === 'virtual-022', 'OFF employee click opens HM Dialog');
await page.keyboard.press('Escape'); await dialog().waitFor({ state: 'hidden' }); await settle(); await page.keyboard.press('Escape'); await dialog().waitFor({ state: 'hidden' }); await settle();
check((await state()).activeEmployee === 'virtual-022', 'OFF Dialog closes and restores employee focus'); check((await state()).activeEmployee === 'virtual-022', 'OFF Dialog closes and restores employee focus');
@@ -306,66 +239,12 @@ await section('off', async () => {
measurements.push({ name: 'OFF browsing', state: fit }); measurements.push({ name: 'OFF browsing', state: fit });
}); });
await section('on', async () => {
await setup({ companies: ['한맥기술', '삼안'], enabled: true }); await snap('drag-drop-on', true);
const inside = await drag(employee('virtual-022'), title('hanmac-office-sibling'));
check(inside.after.cards.find(card => card.id === 'virtual-022')?.organizationId === 'hanmac-office-sibling' && inside.after.feedback.status === 'success', 'ON employee move within company');
const groupInside = await drag(title('hanmac-office-0'), title('hanmac-office-1'));
check(groupInside.after.nodes.find(org => org.id === 'hanmac-office-0')?.parentId === 'hanmac-office-1' && groupInside.after.feedback.status === 'success', 'ON group move within company');
const cross = await drag(employee('virtual-022'), title('saman-office-0'));
const moved = cross.after.cards.find(card => card.id === 'virtual-022');
check(moved?.companyId === 'saman' && moved.organizationId === 'saman-office-0' && moved.border[moved.direction] === 'rgb(240, 131, 0)' && cross.after.feedback.status === 'success', 'ON cross-company employee move updates actual company, organization and approved accent');
const crossGroup = await drag(title('hanmac-office-0'), title('saman-office-1'));
check(crossGroup.after.nodes.find(org => org.id === 'hanmac-office-0')?.parentId === 'saman-office-1'
&& ['hanmac-office-0','hanmac-cell-0','hanmac-team-0','hanmac-team-sibling'].every(id => crossGroup.after.nodes.find(org => org.id === id)?.companyId === 'saman') && crossGroup.after.feedback.status === 'success', 'ON cross-company group move preserves and recolors complete subtree');
check(crossGroup.after.cards.length === 40 && new Set(crossGroup.after.cards.map(card => card.id)).size === 40 && crossGroup.after.nodes.filter(org => org.parentId === '').reduce((sum, org) => sum + org.inclusive, 0) === 40, 'ON cross-company moves preserve unique Employee IDs and aggregate total');
await snap('cross-company-moved', true);
const beforeOff = await state(); await toggle(false); const afterOff = await state();
check(relations(beforeOff) === relations(afterOff) && afterOff.drop.length === 0 && !afterOff.dragging, 'Turning OFF retains completed employee and group moves');
await toggle(true); check(relations(beforeOff) === relations(await state()), 'Turning ON again preserves completed moves without reloading data');
await employee('virtual-022').click(); await dialog().waitFor(); check(await dialog().getAttribute('data-employee-id') === 'virtual-022', 'ON moved employee remains clickable');
await page.keyboard.press('Escape'); await dialog().waitFor({ state: 'hidden' });
await title('hanmac-office-0').click(); await ready(); check(await title('hanmac-office-0').getAttribute('aria-expanded') === 'false', 'ON group click still collapses');
await title('hanmac-office-0').press('Space'); await ready(40); check(await title('hanmac-office-0').getAttribute('aria-expanded') === 'true', 'ON group Space still expands');
await countPerCompany(100, 200); const fresh = await state();
check(fresh.cards.find(card => card.id === 'virtual-022')?.organizationId === 'hanmac-office-0' && fresh.nodes.find(org => org.id === 'hanmac-office-0')?.parentId === 'hanmac-division-0' && fresh.drop.length === 0 && !fresh.dragging, 'Data Apply rebuilds fixture without mixing completed session moves or drag state');
measurements.push({ name: 'ON transfers', inside, groupInside, cross, crossGroup, afterOff });
});
await section('cancel', async () => {
await setup({ enabled: true });
let cancelledDuringPointer;
const pointer = await drag(employee('virtual-022'), title('hanmac-office-sibling'), async () => { await toggle(false, { programmatic: true }); cancelledDuringPointer = await state(); });
check(pointer.hover.dragging === 'true' && pointer.hover.drop.some(target => target.hover === 'allowed'), 'ON pointer edit is active before disable');
check(!cancelledDuringPointer.dragging && cancelledDuringPointer.drop.length === 0 && relations(pointer.before) === relations(pointer.after) && pointer.after.dialogs === 0, 'OFF during pointer move cancels edit/highlights and suppresses release click');
await toggle(true); await employee('virtual-022').focus(); await page.keyboard.press('Alt+m'); await settle(); const opened = await state();
check(opened.dragging === 'true' && opened.drop.some(target => target.hover === 'allowed'), 'ON Alt+M starts keyboard edit');
await toggle(false, { programmatic: true }); await page.keyboard.press('Enter'); await settle(); const cancelled = await state();
check(!cancelled.dragging && cancelled.drop.length === 0 && cancelled.cards.find(card => card.id === 'virtual-022')?.organizationId === 'hanmac-office-0' && cancelled.dialogs === 0, 'OFF cancels active keyboard edit and blocks stale Enter commit');
await toggle(true); await employee('virtual-022').focus(); await page.keyboard.press('Alt+m'); await settle();
for (let i = 0; i < 40 && (await state()).drop.find(target => target.hover)?.id !== 'hanmac-office-sibling'; i++) { await page.keyboard.press('ArrowRight'); await settle(); }
await page.keyboard.press('Enter'); await ready(20); const keyboard = await state();
check(keyboard.cards.find(card => card.id === 'virtual-022')?.organizationId === 'hanmac-office-sibling' && keyboard.feedback.status === 'success' && keyboard.dialogs === 0, 'Re-enabled keyboard move commits through common ON policy');
measurements.push({ name: 'disable cancellation', pointer, cancelledDuringPointer, opened, cancelled, keyboard });
});
await section('modes', async () => {
await setup({ companies: ['한맥기술', '삼안'] });
for (const [layout, option] of [['tb','세로형 TB'],['lr','가로형 LR'],['hybrid','혼합형 Hybrid']]) {
if (layout !== 'tb') await select('조직도 표시 유형', option); await ready(40); const s = await state();
check(s.layout === layout && s.cards.length === 40 && s.cards.every(card => ['top','left','right'].includes(card.direction)), `${layout}: layout and existing directional cards work with default OFF`);
}
});
await section('responsive', async () => { await section('responsive', async () => {
for (const width of [390,768,1280,1440]) { for (const width of [390,768,1280,1440]) {
await page.setViewportSize({ width, height: 1100 }); await page.goto(`${origin}/iframe.html?id=orgchart--expanded-org-chart&viewMode=story`); await companyTrigger().waitFor(); await ready(140); await page.setViewportSize({ width, height: 1100 }); await page.goto(`${origin}/iframe.html?id=orgchart--expanded-org-chart&viewMode=story`); await ready(140);
const metric = await editSwitch().evaluate(element => ({ label: element.getAttribute('aria-label') || element.getAttribute('aria-labelledby') || document.querySelector(`label[for="${element.id}"]`)?.textContent, checked: element.getAttribute('aria-checked'), scrollWidth: document.documentElement.scrollWidth, switchVisible: element.getBoundingClientRect().width > 0 })); const metric = await page.evaluate(() => ({ width: innerWidth, scrollWidth: document.documentElement.scrollWidth }));
check(metric.scrollWidth <= width + 1 && metric.switchVisible && metric.checked === 'false' && Boolean(metric.label), `${width}: OFF switch is named and settings have no horizontal overflow`, metric); check(metric.scrollWidth <= width + 1, `${width}: preview-only Expanded has no horizontal overflow`, metric);
await snap(`expanded-${width}`, true); await companyTrigger().click(); await companyPanel().waitFor(); await snap(`expanded-${width}`, true);
const bounds = await companyPanel().evaluate(element => { const r = element.getBoundingClientRect(); return { x: r.left, right: r.right, y: r.top, bottom: r.bottom, scrollWidth: element.scrollWidth, clientWidth: element.clientWidth }; });
check(bounds.x >= -1 && bounds.right <= width + 1 && bounds.y >= -1 && bounds.bottom <= 1101 && bounds.scrollWidth <= bounds.clientWidth + 1, `${width}: retained company dropdown fits viewport`, bounds);
await page.keyboard.press('Escape'); await companyPanel().waitFor({ state: 'hidden' });
await page.goto(`${origin}/iframe.html?id=orgchart--department-colors&viewMode=story`); await ready(); await page.goto(`${origin}/iframe.html?id=orgchart--department-colors&viewMode=story`); await ready();
const departmentOverflow = await page.evaluate(() => ({ width: innerWidth, scrollWidth: document.documentElement.scrollWidth })); const departmentOverflow = await page.evaluate(() => ({ width: innerWidth, scrollWidth: document.documentElement.scrollWidth }));
check(departmentOverflow.scrollWidth <= width + 1, `${width}: Department preview containers fit viewport`, departmentOverflow); check(departmentOverflow.scrollWidth <= width + 1, `${width}: Department preview containers fit viewport`, departmentOverflow);
@@ -375,13 +254,13 @@ await section('responsive', async () => {
await section('expanded-frame', async () => { await section('expanded-frame', async () => {
// Later outer-frame style correction only. Existing layout/move suites remain // Later outer-frame style correction only. Existing layout/move suites remain
// the evidence for those features; moving a sample below is for fresh capture. // the evidence for those features.
await page.setViewportSize({ width:1440,height:1100 }); await page.setViewportSize({ width:1440,height:1100 });
await page.goto(`${origin}/iframe.html?id=center-graphic-facility--tunnel&viewMode=story`); await page.locator('.tunnel-reference__sample').first().waitFor(); await settle(); await page.goto(`${origin}/iframe.html?id=center-graphic-facility--tunnel&viewMode=story`); await page.locator('.tunnel-reference__sample').first().waitFor(); await settle();
const reference = await page.locator('.tunnel-reference__sample').first().evaluate(element => { const reference = await page.locator('.tunnel-reference__sample').first().evaluate(element => {
const css = getComputedStyle(element); return { backgroundColor:css.backgroundColor,border:css.border,borderRadius:css.borderRadius }; const css = getComputedStyle(element); return { backgroundColor:css.backgroundColor,border:css.border,borderRadius:css.borderRadius };
}); });
await page.goto(`${origin}/iframe.html?id=orgchart--expanded-org-chart&viewMode=story`); await companyTrigger().waitFor(); await ready(140); await page.goto(`${origin}/iframe.html?id=orgchart--expanded-org-chart&viewMode=story`); await ready(140);
const frameSnapshot = () => page.evaluate(() => { const frameSnapshot = () => page.evaluate(() => {
const shadow = document.querySelector('.hm-orgchart-host').shadowRoot, root = shadow.querySelector('.hm-orgchart-expanded'), css = getComputedStyle(root); const shadow = document.querySelector('.hm-orgchart-host').shadowRoot, root = shadow.querySelector('.hm-orgchart-expanded'), css = getComputedStyle(root);
return { frame:{ backgroundColor:css.backgroundColor,border:css.border,borderRadius:css.borderRadius }, return { frame:{ backgroundColor:css.backgroundColor,border:css.border,borderRadius:css.borderRadius },
@@ -396,24 +275,12 @@ await section('expanded-frame', async () => {
check(JSON.stringify(before.frame) === JSON.stringify(reference) && Object.values(before.inputs).every(value => value.length > 0), 'Expanded outer frame matches actual Tunnel shared background, border and radius', { expanded:before.frame,reference,inputs:before.inputs }); check(JSON.stringify(before.frame) === JSON.stringify(reference) && Object.values(before.inputs).every(value => value.length > 0), 'Expanded outer frame matches actual Tunnel shared background, border and radius', { expanded:before.frame,reference,inputs:before.inputs });
const expectedColors = { 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 expectedColors = { 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)' };
check(before.cards.length === 140 && before.cards.every(card => card.background === 'rgb(255, 255, 255)') && before.titles.every(title => title.color === 'rgb(255, 255, 255)' && title.background === expectedColors[title.company]), 'Expanded outer-frame style preserves white employee backgrounds and approved company/white group titles'); check(before.cards.length === 140 && before.cards.every(card => card.background === 'rgb(255, 255, 255)') && before.titles.every(title => title.color === 'rgb(255, 255, 255)' && title.background === expectedColors[title.company]), 'Expanded outer-frame style preserves white employee backgrounds and approved company/white group titles');
check(before.layout === 'tb' && before.editing === 'false' && await editSwitch().getAttribute('aria-checked') === 'false' check(before.layout === 'tb' && before.editing === 'false' && before.pan === 'true' && before.zoom === 'true', 'Expanded frame correction preserves TB, fixed OFF and enabled browsing', before);
&& await page.getByRole('checkbox',{ name:'Pan 사용',exact:true }).getAttribute('aria-checked') === 'true'
&& await page.getByRole('checkbox',{ name:'Zoom 사용',exact:true }).getAttribute('aria-checked') === 'true'
&& await page.getByRole('checkbox',{ name:'접기·펼치기 사용',exact:true }).getAttribute('aria-checked') === 'true', 'Expanded frame correction preserves TB, default OFF and enabled browsing controls');
await snap('expanded-frame-off',true); await snap('expanded-frame-scope-removed',true); await snap('expanded-frame-off',true); await snap('expanded-frame-scope-removed',true);
await companyTrigger().click(); await companyPanel().waitFor(); await snap('expanded-frame-company-dropdown');
await page.keyboard.press('Escape'); await companyPanel().waitFor({ state:'hidden' });
await toggle(true); const on = await frameSnapshot();
check(JSON.stringify(on.frame) === JSON.stringify(before.frame) && JSON.stringify(on.cards) === JSON.stringify(before.cards) && JSON.stringify(on.titles) === JSON.stringify(before.titles), 'ON toggle leaves corrected outer frame and all card/title styling unchanged');
await snap('expanded-frame-on',true);
await page.setViewportSize({ width:390,height:1100 }); await settle(); const narrow = await frameSnapshot(); await page.setViewportSize({ width:390,height:1100 }); await settle(); const narrow = await frameSnapshot();
check(narrow.documentWidth <= 391 && JSON.stringify(narrow.frame) === JSON.stringify(reference), '390px corrected Expanded frame fits page and retains shared style', { width:narrow.width,scrollWidth:narrow.documentWidth,frame:narrow.frame }); check(narrow.documentWidth <= 391 && JSON.stringify(narrow.frame) === JSON.stringify(reference), '390px corrected Expanded frame fits page and retains shared style', { width:narrow.width,scrollWidth:narrow.documentWidth,frame:narrow.frame });
await snap('expanded-frame-390',true); await snap('expanded-frame-390',true);
await setup({ companies:['한맥기술','삼안'],enabled:true }); measurements.push({ name:'Expanded outer-frame correction',reference,before,narrow });
const captureMove = await drag(employee('virtual-022'),title('saman-office-0'));
await page.getByRole('button',{ name:'Fit to View',exact:true }).click(); await settle(); await snap('expanded-frame-company-moved',true);
measurements.push({ name:'Expanded outer-frame correction',reference,before,on,narrow,
captureOnlyMove:{ status:captureMove.after.feedback.status,employee:captureMove.after.cards.find(card => card.id === 'virtual-022'),countText:captureMove.after.countText,note:'Fresh moved sample screenshot; previous P9.4 movement PASS is retained without rerunning its assertions.' } });
}); });
await section('names-mobile', async () => { await section('names-mobile', async () => {
+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 fs from 'node:fs/promises';
import path from 'node:path'; import path from 'node:path';
import { fileURLToPath } from 'node:url'; 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 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 origin = (process.env.ORGCHART_STORY_ORIGIN ?? 'http://127.0.0.1:6064').replace(/\/$/, '');
const startedAt = new Date().toISOString(), checks = [], measurements = [], errors = []; const startedAt = new Date().toISOString(), checks = [], measurements = [], errors = [];
const pointerOnly = process.argv.includes('--pointer-only');
await fs.mkdir(output, { recursive: true }); 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 browser = await launchChromium({ headless: true });
const page = await browser.newPage({ viewport: { width: 1440, height: 1700 } }); const page = await browser.newPage({ viewport: { width: 1440, height: 1700 } });
page.setDefaultTimeout(30000); page.setDefaultTimeout(30000);
@@ -37,19 +24,11 @@ async function ready(count) {
}, count, { timeout: 60000 }); }, count, { timeout: 60000 });
await settle(); 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.goto(`${origin}/iframe.html?id=orgchart--expanded-org-chart&viewMode=story`);
await page.getByRole('button', { name: /^회사 선택/ }).waitFor(); await ready(140); 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(); await page.locator('.hm-orgchart-viewport').scrollIntoViewIfNeeded(); await settle();
} }
async function snapshot(name) { async function snapshot(name) {
@@ -84,7 +63,7 @@ async function snapshot(name) {
} }
function validateEndpoints(s, label) { function validateEndpoints(s, label) {
const paths = [...s.employeePaths, ...s.organizationPaths]; 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 }); 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); 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.locator('.hm-orgchart-viewport').focus();
await page.keyboard.press('ArrowLeft'); await page.keyboard.press('ArrowUp'); await settle(); 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 { try {
for (const [type, label] of [['tb', '세로형 TB'], ['lr', '가로형 LR'], ['hybrid', '혼합형 Hybrid']]) { const type = 'tb';
try { try {
await setup(type, label); await setup();
if (!pointerOnly) { const centered = await snapshot(`${type}-centered`); validateEndpoints(centered, `${type} centered`);
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 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();
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`);
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();
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`);
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) {
await pointerMove(type); check(false, `${type}: focused geometry section completed`, { message: error.message, stack: error.stack });
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 { } finally {
check(errors.length === 0, 'Focused geometry browser console/page errors zero', errors); 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)); 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') })); 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; await browser.close(); if (result.fail) process.exitCode = 1;
@@ -30,10 +30,10 @@ await page.locator(refClose).hover();await settle();reference.hover=await snapsh
await page.mouse.move(0,0);await page.keyboard.press('Tab');await page.locator(refClose).focus();await settle();reference.focus=await snapshot(refClose); await page.mouse.move(0,0);await page.keyboard.press('Tab');await page.locator(refClose).focus();await settle();reference.focus=await snapshot(refClose);
await page.locator('[data-slot="dialog-content"]').screenshot({path:path.join(output,'hm-dialog-reference-popup.png')}); await page.locator('[data-slot="dialog-content"]').screenshot({path:path.join(output,'hm-dialog-reference-popup.png')});
measurements.push({name:'HM Dialog actual public component',...reference}); measurements.push({name:'HM Dialog actual public component',...reference});
// Expanded is preview-only (2026-10-22): no popup selector, so its Core case was removed; Core stays covered by Full/Vanilla.
const cases=[ const cases=[
{name:'storybook-full',url:`${story}/iframe.html?id=orgchart--full-composition&viewMode=story`,kind:'Core native'}, {name:'storybook-full',url:`${story}/iframe.html?id=orgchart--full-composition&viewMode=story`,kind:'Core native'},
{name:'storybook-expanded-hm',url:`${story}/iframe.html?id=orgchart--expanded-org-chart&viewMode=story`,kind:'React HMDesign Dialog'}, {name:'storybook-expanded-hm',url:`${story}/iframe.html?id=orgchart--expanded-org-chart&viewMode=story`,kind:'React HMDesign Dialog'},
{name:'storybook-expanded-core',url:`${story}/iframe.html?id=orgchart--expanded-org-chart&viewMode=story`,kind:'Core native',coreOption:true},
{name:'vanilla-index',url:`${vanilla}/index.html`,kind:'Core native',vanilla:true}, {name:'vanilla-index',url:`${vanilla}/index.html`,kind:'Core native',vanilla:true},
{name:'vanilla-full',url:`${vanilla}/p1.html?mode=readOnly`,kind:'Core native',vanilla:true}, {name:'vanilla-full',url:`${vanilla}/p1.html?mode=readOnly`,kind:'Core native',vanilla:true},
{name:'vanilla-expanded',url:`${vanilla}/expanded.html`,kind:'Core native',vanilla:true}, {name:'vanilla-expanded',url:`${vanilla}/expanded.html`,kind:'Core native',vanilla:true},
@@ -42,7 +42,6 @@ const near=(a,b)=>Math.abs(a-b)<.02,eq=(a,b)=>JSON.stringify(a)===JSON.stringify
for(const width of [1440,390])for(const item of cases){ for(const width of [1440,390])for(const item of cases){
const label=`${item.name} ${width}`;await page.setViewportSize({width,height:900});await page.goto(item.url); const label=`${item.name} ${width}`;await page.setViewportSize({width,height:900});await page.goto(item.url);
await page.waitForFunction(()=>{const root=document.querySelector('.hm-orgchart-host')?.shadowRoot;return root?.querySelector('.hm-orgchart-employee[role="button"]')&&[...root.querySelectorAll('link[rel="stylesheet"]')].every(link=>link.sheet);});await settle(); await page.waitForFunction(()=>{const root=document.querySelector('.hm-orgchart-host')?.shadowRoot;return root?.querySelector('.hm-orgchart-employee[role="button"]')&&[...root.querySelectorAll('link[rel="stylesheet"]')].every(link=>link.sheet);});await settle();
if(item.coreOption){await page.getByRole('combobox',{name:'직원 정보 팝업',exact:true}).click();await page.getByRole('option',{name:'Core 기본 팝업',exact:true}).click();await settle();}
// Use a rendered card inside the horizontally clipped viewport; the Forest // Use a rendered card inside the horizontally clipped viewport; the Forest
// may legitimately extend beyond the viewport without shrinking its cards. // may legitimately extend beyond the viewport without shrinking its cards.
const choice=await page.evaluate(()=>{ const choice=await page.evaluate(()=>{
+13 -46
View File
@@ -52,71 +52,38 @@ async function measure(name) {
} }
const check = (pass, name, detail) => { checks.push({name,status:pass?'PASS':'FAIL',detail}); if (!pass) console.log('FAIL',name,JSON.stringify(detail)); }; const check = (pass, name, detail) => { checks.push({name,status:pass?'PASS':'FAIL',detail}); if (!pass) console.log('FAIL',name,JSON.stringify(detail)); };
const centered = (s, name, vertical = false) => check(Math.abs(s.error.visible.x) < 0.75 && (!vertical || Math.abs(s.error.visible.y) < 0.75), name, s.error); const centered = (s, name, vertical = false) => check(Math.abs(s.error.visible.x) < 0.75 && (!vertical || Math.abs(s.error.visible.y) < 0.75), name, s.error);
async function select(label, option) { // Expanded is preview-only (2026-10-22): company/count/layout combinations are no longer
await page.getByRole('combobox', { name:label, exact:true }).click(); // reachable from the Story, so only the fixed 7x20 TB state, Fit, Pan and widths are measured.
await page.getByRole('option', { name:option, exact:true }).click(); await ready();
}
async function companies(names) {
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);
if (names.length === 7) { await all.click(); await ready(140); await page.keyboard.press('Escape'); await panel.waitFor({state:'hidden'}); return; }
for (const name of names) { await panel.getByRole('checkbox',{name,exact:true}).click(); await ready(); }
await page.keyboard.press('Escape'); await panel.waitFor({state:'hidden'}); await ready();
}
async function count(count, total) {
await select('회사당 직원 수',`${count}명`);
const apply = page.getByRole('button',{name:'데이터 적용',exact:true}); if (await apply.isEnabled()) await apply.click(); await ready(total);
}
async function open() { async function open() {
await page.goto(`${origin}/iframe.html?id=orgchart--expanded-org-chart&viewMode=story`); await page.goto(`${origin}/iframe.html?id=orgchart--expanded-org-chart&viewMode=story`);
await page.getByRole('button',{name:/^회사 선택/}).waitFor(); await ready(140); await ready(140);
} }
async function fit() { await page.getByRole('button',{name:'Fit to View',exact:true}).click(); await settle(); } async function fit() { await page.getByRole('button',{name:'Fit to View',exact:true}).click(); await settle(); }
try { try {
await open(); const initial = await measure('initial-7x20-tb'); await open(); const initial = await measure('initial-7x20-tb');
await page.screenshot({path:path.join(output,`${phase}-initial.png`),fullPage:true}); await page.screenshot({path:path.join(output,`${phase}-initial.png`),fullPage:true});
if (phase === 'after') { centered(initial,'Initial 7-company forest horizontally centered'); check(initial.scale === 1,'Initial oversized forest keeps 100% scale',initial.scale); } if (phase === 'after') { centered(initial,'Initial 7-company forest horizontally centered'); check(initial.scale === 1,'Initial oversized forest keeps 100% scale',initial.scale); }
for (const [companyNames, label] of [[['한맥기술'],'one'],[['한맥기술','삼안'],'two'],[['한맥기술','삼안','장헌산업','장헌','피티씨','한라','바론'],'seven']]) { await fit(); const fitted = await measure('initial-7x20-tb-fit');
if (process.argv[3] && process.argv[3] !== label) continue; if (phase === 'after') {
for (const size of [20,100,300]) { centered(fitted,'7x20/tb: Fit center both axes',true);
for (const [type,labelText] of [['tb','세로형 TB'],['lr','가로형 LR'],['hybrid','혼합형 Hybrid']]) { check(fitted.visible.left >= fitted.content.left+15 && fitted.visible.right <= fitted.content.left+fitted.content.width-15
await open(); await companies(companyNames); await count(size,companyNames.length*size); && fitted.visible.top >= fitted.content.top+15 && fitted.visible.bottom <= fitted.content.top+fitted.content.height-15,'7x20/tb: Fit contains visible tree',fitted.visible);
await select('조직도 표시 유형',labelText);
const s = await measure(`${label}-${size}-${type}-auto`);
if (phase === 'after') { centered(s,`${label}/${size}/${type}: automatic horizontal center`); check(s.scale === 1,`${label}/${size}/${type}: no automatic shrink`,s.scale); }
await fit(); const fitted = await measure(`${label}-${size}-${type}-fit`);
if (phase === 'after') {
centered(fitted,`${label}/${size}/${type}: Fit center both axes`,true);
check(fitted.visible.left >= fitted.content.left+15 && fitted.visible.right <= fitted.content.left+fitted.content.width-15
&& fitted.visible.top >= fitted.content.top+15 && fitted.visible.bottom <= fitted.content.top+fitted.content.height-15,`${label}/${size}/${type}: Fit contains visible tree`,fitted.visible);
}
}
}
} }
if (phase === 'after') { if (phase === 'after') {
// User Pan preservation across resize and deliberate content/layout changes. // User Pan preservation across resize.
await open(); await companies(['한맥기술']); await fit();
const viewport = page.locator('.hm-orgchart-viewport'); await viewport.scrollIntoViewIfNeeded(); await viewport.focus(); const viewport = page.locator('.hm-orgchart-viewport'); await viewport.scrollIntoViewIfNeeded(); await viewport.focus();
await page.keyboard.press('ArrowLeft'); await page.keyboard.press('ArrowUp'); await settle(); await page.keyboard.press('ArrowLeft'); await page.keyboard.press('ArrowUp'); await settle();
const panned = await measure('user-pan'); const panned = await measure('user-pan');
await page.setViewportSize({width:1280,height:1100}); await ready(); const resized = await measure('pan-resize'); await page.setViewportSize({width:1280,height:1100}); await ready(); const resized = await measure('pan-resize');
await companies(['한맥기술','삼안']); const filtered = await measure('pan-company'); check(resized.x===panned.x && resized.y===panned.y && resized.scale===panned.scale,'Manual Pan retained through resize',{before:panned,after:resized});
await count(100,200); const changed = await measure('pan-data');
await select('조직도 표시 유형','가로형 LR'); const switched = await measure('pan-layout');
for (const [name,s] of [['resize',resized],['company selection',filtered],['data',changed],['layout',switched]]) {
check(s.x===panned.x && s.y===panned.y && s.scale===panned.scale,`Manual Pan retained through ${name}`,{before:panned,after:s});
}
await fit(); centered(await measure('fit-after-pan'),'Explicit Fit restores center after user Pan',true); await fit(); centered(await measure('fit-after-pan'),'Explicit Fit restores center after user Pan',true);
for (const width of [390,768,1280,1440]) { for (const width of [390,768,1280,1440]) {
await page.setViewportSize({width,height:1100}); await open(); await companies(['한맥기술']); await page.setViewportSize({width,height:1100}); await open();
centered(await measure(`width-${width}-initial`),`${width}px automatic horizontal center`); centered(await measure(`width-${width}-initial`),`${width}px automatic horizontal center`);
await fit(); centered(await measure(`width-${width}-fit`),`${width}px Fit center`,true); await fit(); centered(await measure(`width-${width}-fit`),`${width}px Fit center`,true);
} }
await page.setViewportSize({width:1440,height:1100}); await open(); await companies(['한맥기술']); await page.setViewportSize({width:1440,height:1100}); await open();
await fit(); await page.screenshot({path:path.join(output,'after-single-fit.png'),fullPage:true}); await fit(); await page.screenshot({path:path.join(output,'after-fit.png'),fullPage:true});
} }
} catch(error) { check(false,'Measurement completed',{message:error.message,stack:error.stack}); } } catch(error) { check(false,'Measurement completed',{message:error.message,stack:error.stack}); }
check(errors.length===0,'Browser console/page errors zero',errors); check(errors.length===0,'Browser console/page errors zero',errors);
+2 -3
View File
@@ -48,7 +48,6 @@ async function ready(requirePage = true) {
} }
async function goto(name, size = sizes[1]) { expectedHosts = name === 'overview' ? 3 : 1; await page.setViewportSize(size); await page.goto(`${origin}/iframe.html?id=${stories[name]}&viewMode=story`); await ready(); } async function goto(name, size = sizes[1]) { expectedHosts = name === 'overview' ? 3 : 1; await page.setViewportSize(size); await page.goto(`${origin}/iframe.html?id=${stories[name]}&viewMode=story`); await ready(); }
async function capture(name, fullPage = true) { await page.screenshot({ path: path.join(output, name + '.png'), fullPage }); } async function capture(name, fullPage = true) { await page.screenshot({ path: path.join(output, name + '.png'), fullPage }); }
async function select(label, option) { await page.getByRole('combobox', { name: label, exact: true }).click(); await page.getByRole('option', { name: option, exact: true }).click(); await ready(); }
async function section(name, work) { if (!run(name)) return; try { await work(); } catch (error) { check(false, `${name}: runner completed`, { message: error.message, stack: error.stack }); } } async function section(name, work) { if (!run(name)) return; try { await work(); } catch (error) { check(false, `${name}: runner completed`, { message: error.message, stack: error.stack }); } }
const equal = (a, b) => JSON.stringify(a) === JSON.stringify(b); const equal = (a, b) => JSON.stringify(a) === JSON.stringify(b);
@@ -188,8 +187,8 @@ try {
await section('expanded', async () => { await section('expanded', async () => {
for (const size of selectedSizes) { for (const size of selectedSizes) {
await goto('expanded', size); await goto('expanded', size);
for (const [option, type] of [['세로형 TB', 'tb'], ['가로형 LR', 'lr'], ['혼합형 Hybrid', 'hybrid']]) { // Expanded is preview-only (2026-10-22): the fixed TB default is the only layout the Story shows.
if (type !== 'tb') await select('조직도 표시 유형', option); { const type = 'tb';
const state = await snapshot(); measurements.push({ name: `Expanded ${type} ${size.width}`, state }); const state = await snapshot(); measurements.push({ name: `Expanded ${type} ${size.width}`, state });
check(state.auto?.type === type && state.auto.employees === 20 && fitted(state) && noHorizontalOverflow(state), `Expanded ${type} ${size.width}: representative layout renders unchanged sample and Fits`, state.auto); check(state.auto?.type === type && state.auto.employees === 20 && fitted(state) && noHorizontalOverflow(state), `Expanded ${type} ${size.width}: representative layout renders unchanged sample and Fits`, state.auto);
check(state.auto?.titles.length > 0 && state.auto.titles.every(title => title.color === '#ffffff' && title.buttonColor === '#ffffff' && title.background === colors[title.company]) && new Set(state.auto.titles.map(title => title.company)).size === 7, `Expanded ${type} ${size.width}: all company title backgrounds and white text preserved`, state.auto?.titles); check(state.auto?.titles.length > 0 && state.auto.titles.every(title => title.color === '#ffffff' && title.buttonColor === '#ffffff' && title.background === colors[title.company]) && new Set(state.auto.titles.map(title => title.company)).size === 7, `Expanded ${type} ${size.width}: all company title backgrounds and white text preserved`, state.auto?.titles);
+2 -12
View File
@@ -35,18 +35,8 @@ try {
await page.screenshot({ path: path.join(evidence, 'storybook-expanded-hm.png') }); await page.screenshot({ path: path.join(evidence, 'storybook-expanded-hm.png') });
await page.keyboard.press('Escape'); await hm.waitFor({ state: 'hidden' }); await page.keyboard.press('Escape'); await hm.waitFor({ state: 'hidden' });
assert.equal(await card.evaluate(e => e.getRootNode().activeElement === e), true); assert.equal(await card.evaluate(e => e.getRootNode().activeElement === e), true);
const trigger = page.getByRole('combobox', { name: '직원 정보 팝업' }); // Expanded is preview-only (2026-10-22): no popup selector. Core popup stays covered by Fixed below.
await trigger.click(); await page.getByRole('option', { name: 'Core 기본 팝업', exact: true }).click(); results.push({ story: id, status: 'PASS', checks: ['Existing default HM Dialog', 'Escape and focus restoration'] });
// Wait for Radix's exiting Select portal/focus restoration before opening
// another modal with the keyboard. External consumers have no Select here.
await page.getByRole('listbox').waitFor({ state: 'detached' });
await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))));
await card.focus(); await page.keyboard.press('Enter');
await core.waitFor({ state: 'visible' });
assert.equal(await hm.count(), 0);
assert.equal(await core.evaluate(e => e.contains(e.getRootNode().activeElement)), true, 'Core modal receives focus after Select closes');
await page.keyboard.press('Escape'); await core.waitFor({ state: 'hidden' });
results.push({ story: id, status: 'PASS', checks: ['Existing default HM Dialog', 'Escape and focus restoration', 'Existing selector switches to Core popup'] });
} else { } else {
await core.waitFor({ state: 'visible' }); assert.equal(await hm.count(), 0); await core.waitFor({ state: 'visible' }); assert.equal(await hm.count(), 0);
await page.screenshot({ path: path.join(evidence, 'storybook-fixed-core.png') }); await page.screenshot({ path: path.join(evidence, 'storybook-fixed-core.png') });
@@ -13,48 +13,3 @@
gap: 0.75rem; gap: 0.75rem;
margin-top: 24px; margin-top: 24px;
} }
.hm-orgchart-story-expanded .hm-orgchart-story-settings {
display: grid;
gap: 0.75rem;
margin: 0;
padding: 0;
border: 0;
min-width: 0;
}
.hm-orgchart-story-expanded .hm-orgchart-story-controls {
align-items: flex-start;
}
.hm-orgchart-story-expanded .hm-orgchart-story-instance {
flex: 1 1 12rem;
}
.hm-orgchart-story-expanded .hm-orgchart-story-popup-setting {
flex: 0 1 24rem;
width: 100%;
}
.hm-orgchart-story-expanded .hm-orgchart-story-companies { margin: 0; }
.hm-orgchart-story-expanded .hm-orgchart-story-company-trigger {
width: 100%;
min-width: 0;
justify-content: space-between;
}
.hm-orgchart-story-expanded .hm-orgchart-story-company-summary {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
text-align: left;
}
/* Popover is portaled outside StoryPage. Only this Story's content layout is
composed here; the HM panel surface, padding and widget states stay intact. */
.hm-orgchart-story-company-popover {
max-height: var(--radix-popover-content-available-height);
overflow-y: auto;
}
.hm-orgchart-story-company-options {
display: grid;
gap: 0.75rem;
margin: 0;
padding: 0;
border: 0;
min-width: 0;
}
+13 -209
View File
@@ -1,115 +1,32 @@
import { StrictMode, useEffect, useId, useMemo, useState } from 'react'; import { StrictMode, useMemo, useState } from 'react';
import type { Meta, StoryObj } from '@storybook/react-vite'; import type { Meta, StoryObj } from '@storybook/react-vite';
import { ChevronDownIcon } from 'lucide-react'; import { OrgChart } from '@hm-design/org-chart-react/hm-dialog';
import {
Button, Checkbox, FilterBar, FilterBarActions, Label,
Popover, PopoverContent, PopoverTrigger, Separator,
Select, SelectContent, SelectItem, SelectTrigger, SelectValue, Switch,
} from '@hm-design/ui';
import { OrgChart, type OrgChartProps } from '@hm-design/org-chart-react/hm-dialog';
import { import {
companyColorPresets, createOrgChartExampleDataset, JsonOrgChartDataSource, companyColorPresets, createOrgChartExampleDataset, JsonOrgChartDataSource,
type CompanyId, type OrgChartAutoLayoutOptions, type OrgChartDataset, type OrgChartExampleSelection, type OrgChartInteractionOptions, type CompanyId, type OrgChartAutoLayoutOptions, type OrgChartDataset, type OrgChartInteractionOptions,
} from '@hm-design/org-chart'; } from '@hm-design/org-chart';
import engineUrl from '@hm-design/org-chart/engine.css?url'; import engineUrl from '@hm-design/org-chart/engine.css?url';
import coreUrl from '@hm-design/org-chart/style.css?url'; import coreUrl from '@hm-design/org-chart/style.css?url';
import { StoryPage, StoryPageHeader, StorySection } from '@/components/story-page'; import { StoryPage, StoryPageHeader } from '@/components/story-page';
import large from '../../examples/org-chart/data-expanded-large.json'; import large from '../../examples/org-chart/data-expanded-large.json';
import './org-chart-stories.css'; import './org-chart-stories.css';
import '../center/center-stories.css'; import '../center/center-stories.css';
import './org-chart-expanded-story.css'; import './org-chart-expanded-story.css';
const template = large as OrgChartDataset; const template = large as OrgChartDataset;
const companies = Object.values(companyColorPresets); const allCompanies = Object.values(companyColorPresets).map(company => company.companyId as CompanyId);
const allCompanies = companies.map(company => company.companyId as CompanyId);
const compositionStylesheets = { engine: engineUrl, core: coreUrl }; const compositionStylesheets = { engine: engineUrl, core: coreUrl };
const topNodes = { ptc: { title: '기술총괄', name: '가상책임자 5' } }; const topNodes = { ptc: { title: '기술총괄', name: '가상책임자 5' } };
type DataSettings = Required<Pick<OrgChartExampleSelection, 'employeeCount' | 'companyIds'>>; // Preview only: fixed sample (7 companies × 20 = 140) with the former default settings.
const initialSettings: DataSettings = { employeeCount: 20, companyIds: allCompanies }; const layout: OrgChartAutoLayoutOptions = { type: 'tb', hybridStartDepth: 2, initialVisibleDepth: 'all' };
const interactions: OrgChartInteractionOptions = { collapse: true, zoom: true, pan: true };
/** Existing Expanded Story only: public FilterBar layout/actions match data /** Existing Expanded Story only: a chart preview without setting panels. */
* conditions. The preview selects samples rather than searching employees. */
function ExpandedPreview() { function ExpandedPreview() {
const id = useId();
const [draft, setDraft] = useState<DataSettings>(initialSettings);
const [applied, setApplied] = useState<DataSettings>(initialSettings);
const [dataRevision, setDataRevision] = useState(0);
const [layout, setLayout] = useState<OrgChartAutoLayoutOptions>({ type: 'tb', hybridStartDepth: 2, initialVisibleDepth: 'all' });
const [popup, setPopup] = useState<OrgChartProps['employeePopup']>('hm-dialog');
const [interactions, setInteractions] = useState<OrgChartInteractionOptions>({ collapse: true, zoom: true, pan: true });
const [dragDropEnabled, setDragDropEnabled] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const dataset = useMemo(() => createOrgChartExampleDataset(template, { const source = useMemo(() => new JsonOrgChartDataSource(createOrgChartExampleDataset(template, {
scope: 'per-company', scope: 'per-company', employeeCount: 20, companyIds: allCompanies,
employeeCount: applied.employeeCount, })), []);
// Company selection is view state. Keep every company in the canonical
// session source so hiding/revealing a company retains successful moves.
companyIds: allCompanies,
}), [applied.employeeCount, dataRevision]);
const source = useMemo(() => new JsonOrgChartDataSource(dataset), [dataset]);
// Core owns edits. This read model updates counts/options without replacing
// dataSource and reloading the unchanged fixture over a successful move.
const [session, setSession] = useState(() => ({ baseline: dataset, current: dataset }));
const currentDataset = session.baseline === dataset ? session.current : dataset;
const organizationCompanies = useMemo(() => {
const byId = new Map(currentDataset.organizations.map(organization => [organization.id, organization]));
const companyById = new Map<string, CompanyId>();
for (const organization of currentDataset.organizations) {
let current = organization;
while (!current.companyId && current.parentId && byId.has(current.parentId)) current = byId.get(current.parentId)!;
if (current.companyId) companyById.set(organization.id, current.companyId as CompanyId);
}
return companyById;
}, [currentDataset]);
const groupChoices = useMemo(() => {
const parents = new Set(currentDataset.organizations.map(organization => organization.parentId));
return currentDataset.organizations.filter(organization => parents.has(organization.id)).map(organization => {
const companyId = organizationCompanies.get(organization.id);
if (!companyId || !applied.companyIds.includes(companyId)) return null;
const company = companies.find(item => item.companyId === companyId);
const prefix = company ? `${company.companyName} · ` : '';
return { id: organization.id, name: organization.name.startsWith(prefix) ? organization.name : `${prefix}${organization.name}` };
}).filter((organization): organization is { id: string; name: string } => organization !== null);
}, [currentDataset, organizationCompanies, applied.companyIds]);
useEffect(() => {
const valid = new Set(groupChoices.map(organization => organization.id));
setLayout(current => {
const previous = current.hybridOrganizationIds ?? [];
const ids = previous.filter(organizationId => valid.has(organizationId));
return ids.length === previous.length ? current : { ...current, hybridOrganizationIds: ids };
});
}, [groupChoices]);
const predicted = draft.companyIds.length * draft.employeeCount;
const displayedTotal = new Set(currentDataset.employees.filter(employee => {
const companyId = organizationCompanies.get(employee.organizationId);
return companyId !== undefined && applied.companyIds.includes(companyId);
}).map(employee => employee.id)).size;
const sessionTotal = new Set(currentDataset.employees.map(employee => employee.id)).size;
const companyEmployeeCounts = useMemo(() => {
const employeeIds = new Map<CompanyId, Set<string>>(allCompanies.map(companyId => [companyId, new Set<string>()]));
for (const employee of currentDataset.employees) {
const companyId = organizationCompanies.get(employee.organizationId);
if (companyId) employeeIds.get(companyId)?.add(employee.id);
}
return new Map([...employeeIds].map(([companyId, ids]) => [companyId, ids.size]));
}, [currentDataset, organizationCompanies]);
const companyCountSummary = applied.companyIds.map(companyId => `${companies.find(company => company.companyId === companyId)!.companyName} ${companyEmployeeCounts.get(companyId) ?? 0}명`).join(' · ');
const pending = draft.employeeCount !== applied.employeeCount
|| draft.companyIds.join() !== applied.companyIds.join();
const selectedCompanyCount = draft.companyIds.length;
const companySummary = selectedCompanyCount === allCompanies.length ? `전체 회사 (${allCompanies.length}개)`
: selectedCompanyCount === 1 ? companies.find(company => company.companyId === draft.companyIds[0])!.companyName
: selectedCompanyCount > 1 ? `${selectedCompanyCount}개 회사 선택` : '회사를 선택하세요';
const allCompaniesChecked = selectedCompanyCount === allCompanies.length ? true
: selectedCompanyCount > 0 ? 'indeterminate' : false;
const changeCompanies = (companyIds: readonly CompanyId[]) => {
const selected = allCompanies.filter(companyId => companyIds.includes(companyId));
setError(null);
// Company filtering is immediate; pending employee-count changes still belong
// to the existing Data Apply action and P7 dataset generation contract.
setDraft(current => ({ ...current, companyIds: selected }));
setApplied(current => ({ ...current, companyIds: selected }));
};
return <StoryPage className="hm-orgchart-story-page hm-orgchart-story-expanded"> return <StoryPage className="hm-orgchart-story-page hm-orgchart-story-expanded">
<StoryPageHeader <StoryPageHeader
@@ -120,122 +37,9 @@ function ExpandedPreview() {
<div className="hm-orgchart-story hm-orgchart-story-lead"> <div className="hm-orgchart-story hm-orgchart-story-lead">
{error ? <p role="alert" className="hm-orgchart-story-note">{error}</p> : null} {error ? <p role="alert" className="hm-orgchart-story-note">{error}</p> : null}
<div className="hm-orgchart-story-preview sb-center"> <div className="hm-orgchart-story-preview sb-center">
<OrgChart dataSource={source} stylesheetUrls={compositionStylesheets} displayMode="readOnly" layoutMode="auto" autoLayout={layout} interactions={interactions} dragDropEnabled={dragDropEnabled} selectedCompanyIds={applied.companyIds} companyTopNodes={topNodes} employeePopup={popup} className="hm-orgchart-story-chart" onMove={result => { if (result.moved) setSession({ baseline: dataset, current: result.data }); }} onLifecycleError={cause => setError(cause instanceof Error ? cause.message : '조직도 설정을 적용할 수 없습니다.')} onStateChange={state => { if (state.status === 'error') setError(state.issues.map(issue => issue.message).join(' / ')); }} /> <OrgChart dataSource={source} stylesheetUrls={compositionStylesheets} displayMode="readOnly" layoutMode="auto" autoLayout={layout} interactions={interactions} dragDropEnabled={false} selectedCompanyIds={allCompanies} companyTopNodes={topNodes} employeePopup="hm-dialog" className="hm-orgchart-story-chart" onLifecycleError={cause => setError(cause instanceof Error ? cause.message : '조직도 설정을 적용할 수 없습니다.')} onStateChange={state => { if (state.status === 'error') setError(state.issues.map(issue => issue.message).join(' / ')); }} />
</div> </div>
</div> </div>
<StorySection title="데이터 설정">
<fieldset className="hm-orgchart-story-settings">
<FilterBar layout="list-extended">
<div className="hm-orgchart-story-instance">
<Label id={`${id}-companies-label`} htmlFor={`${id}-companies`}>회사 선택</Label>
<Popover>
<PopoverTrigger asChild>
<Button id={`${id}-companies`} type="button" variant="cancel" appearance="outline"
className="hm-orgchart-story-company-trigger" aria-labelledby={`${id}-companies-label ${id}-companies-summary`}>
<span id={`${id}-companies-summary`} className="hm-orgchart-story-company-summary">{companySummary}</span>
<ChevronDownIcon aria-hidden="true" />
</Button>
</PopoverTrigger>
<PopoverContent align="start" aria-labelledby={`${id}-companies-label`} data-component="OrgChartCompanySelection" className="hm-orgchart-story-company-popover">
<fieldset className="hm-orgchart-story-company-options">
<legend className="sr-only">표시할 회사 · 복수 선택 가능</legend>
<div className="hm-orgchart-story-company">
<Checkbox id={`${id}-companies-all`} checked={allCompaniesChecked} onCheckedChange={checked => changeCompanies(checked === true ? allCompanies : [])} />
<Label htmlFor={`${id}-companies-all`}>전체 선택</Label>
</div>
<Separator />
{companies.map(company => <div key={company.companyId} className="hm-orgchart-story-company">
<Checkbox id={`${id}-${company.companyId}`} checked={draft.companyIds.includes(company.companyId as CompanyId)} onCheckedChange={checked => {
changeCompanies(checked === true ? [...draft.companyIds, company.companyId as CompanyId] : draft.companyIds.filter(value => value !== company.companyId));
}} />
<Label htmlFor={`${id}-${company.companyId}`}>{company.companyName}</Label>
</div>)}
</fieldset>
</PopoverContent>
</Popover>
</div>
<div className="hm-orgchart-story-instance">
<Label id={`${id}-size-label`} htmlFor={`${id}-size`}>회사당 직원 수</Label>
<Select value={String(draft.employeeCount)} onValueChange={value => setDraft(current => ({ ...current, employeeCount: Number(value) as DataSettings['employeeCount'] }))}>
<SelectTrigger id={`${id}-size`} aria-labelledby={`${id}-size-label`} size="medium"><SelectValue /></SelectTrigger>
<SelectContent>{[20, 100, 300].map(count => <SelectItem key={count} value={String(count)}>{count}명</SelectItem>)}</SelectContent>
</Select>
</div>
<FilterBarActions>
<Button variant="primary" appearance="solid" onClick={() => { setError(null); setApplied({ ...draft, companyIds: [...draft.companyIds] }); setDataRevision(current => current + 1); }}>데이터 적용</Button>
<Button variant="cancel" appearance="outline" onClick={() => { setError(null); setDraft(initialSettings); setApplied(initialSettings); setDataRevision(current => current + 1); }}>초기화</Button>
</FilterBarActions>
</FilterBar>
{/* Not shown: kept only as the data hook that the OrgChart browser checks (P8, P9-2/3/4) read. */}
<p hidden data-component="OrgChartExampleCounts">
표시 중: {applied.companyIds.length}개 회사 · 현재 소속 기준 직원 합계 {displayedTotal.toLocaleString('ko-KR')}명 / 전체 {sessionTotal.toLocaleString('ko-KR')}명
{companyCountSummary ? ` · ${companyCountSummary}` : ''}
</p>
{/* Live feedback only while the employee count differs from the applied data. */}
<p className="hm-orgchart-story-note" role="status" aria-live="polite">
{pending ? `적용 예정: ${draft.companyIds.length}개 회사 · 회사당 ${draft.employeeCount}명 · 총 ${predicted.toLocaleString('ko-KR')}명 · 데이터 적용을 눌러 반영하세요.` : ''}
</p>
</fieldset>
</StorySection>
<StorySection title="레이아웃 설정">
<fieldset className="hm-orgchart-story-settings">
<div className="hm-orgchart-story-controls">
<div className="hm-orgchart-story-instance">
<Label id={`${id}-layout-label`} htmlFor={`${id}-layout`}>조직도 표시 유형</Label>
<Select value={layout.type} onValueChange={value => setLayout(current => ({ ...current, type: value as OrgChartAutoLayoutOptions['type'], hybridOrganizationIds: [] }))}>
<SelectTrigger id={`${id}-layout`} aria-labelledby={`${id}-layout-label`} size="medium"><SelectValue /></SelectTrigger>
<SelectContent><SelectItem value="tb">세로형 TB</SelectItem><SelectItem value="lr">가로형 LR</SelectItem><SelectItem value="hybrid">혼합형 Hybrid</SelectItem></SelectContent>
</Select>
</div>
<div className="hm-orgchart-story-instance">
<Label id={`${id}-hybrid-label`} htmlFor={`${id}-hybrid`}>혼합형 시작 Depth</Label>
<Select value={String(layout.hybridStartDepth)} disabled={layout.type !== 'hybrid'} onValueChange={value => setLayout(current => ({ ...current, hybridStartDepth: Number(value) as 2 | 3 | 4 }))}>
<SelectTrigger id={`${id}-hybrid`} aria-labelledby={`${id}-hybrid-label`} size="medium"><SelectValue /></SelectTrigger>
<SelectContent>{[2, 3, 4].map(depth => <SelectItem key={depth} value={String(depth)}>Depth {depth}부터 세로 배치</SelectItem>)}</SelectContent>
</Select>
</div>
<div className="hm-orgchart-story-instance">
<Label id={`${id}-group-hybrid-label`} htmlFor={`${id}-group-hybrid`}>특정 그룹 세로 배치</Label>
<Select value={layout.hybridOrganizationIds?.[0] ?? 'none'} disabled={layout.type !== 'tb' || !applied.companyIds.length} onValueChange={value => setLayout(current => ({ ...current, hybridOrganizationIds: value === 'none' ? [] : [value] }))}>
<SelectTrigger id={`${id}-group-hybrid`} aria-labelledby={`${id}-group-hybrid-label`} size="medium"><SelectValue /></SelectTrigger>
<SelectContent><SelectItem value="none">사용 안 함</SelectItem>{groupChoices.map(organization => <SelectItem key={organization.id} value={organization.id}>{organization.name}</SelectItem>)}</SelectContent>
</Select>
</div>
<div className="hm-orgchart-story-instance">
<Label id={`${id}-depth-label`} htmlFor={`${id}-depth`}>초기 표시 Depth</Label>
<Select value={String(layout.initialVisibleDepth)} onValueChange={value => setLayout(current => ({ ...current, initialVisibleDepth: value === 'all' ? 'all' : Number(value) as 1 | 2 | 3 }))}>
<SelectTrigger id={`${id}-depth`} aria-labelledby={`${id}-depth-label`} size="medium"><SelectValue /></SelectTrigger>
<SelectContent>{[1, 2, 3].map(depth => <SelectItem key={depth} value={String(depth)}>{depth}Depth까지</SelectItem>)}<SelectItem value="all">전체 펼침</SelectItem></SelectContent>
</Select>
</div>
</div>
</fieldset>
</StorySection>
<StorySection title="인터랙션·직원 정보">
<div className="hm-orgchart-story-settings">
<fieldset className="hm-orgchart-story-companies">
<div className="hm-orgchart-story-company">
<Switch id={`${id}-drag-drop`} checked={dragDropEnabled} onCheckedChange={setDragDropEnabled} />
<Label htmlFor={`${id}-drag-drop`}>Drag &amp; Drop</Label>
</div>
{([
['collapse', '접기·펼치기 사용'], ['zoom', 'Zoom 사용'], ['pan', 'Pan 사용'],
] as const).map(([key, label]) => <div key={key} className="hm-orgchart-story-company">
<Checkbox id={`${id}-interaction-${key}`} checked={interactions[key]} onCheckedChange={checked => setInteractions(current => ({ ...current, [key]: checked === true }))} />
<Label htmlFor={`${id}-interaction-${key}`}>{label}</Label>
</div>)}
</fieldset>
<div className="hm-orgchart-story-controls">
<div className="hm-orgchart-story-instance hm-orgchart-story-popup-setting">
<Label id={`${id}-popup-label`} htmlFor={`${id}-popup`}>직원 정보 팝업</Label>
<Select value={popup} onValueChange={value => setPopup(value as OrgChartProps['employeePopup'])}>
<SelectTrigger id={`${id}-popup`} aria-labelledby={`${id}-popup-label`} size="medium"><SelectValue /></SelectTrigger>
<SelectContent><SelectItem value="hm-dialog">HMDesign Dialog</SelectItem><SelectItem value="core">Core 기본 팝업</SelectItem><SelectItem value="none">사용 안 함</SelectItem></SelectContent>
</Select>
</div>
</div>
</div>
</StorySection>
</StoryPage>; </StoryPage>;
} }