From f4ddd5ac4bd596d5f75643c84cc7b927c534ce0b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=88=98=ED=98=84?= Date: Thu, 22 Oct 2026 17:51:21 +0900 Subject: [PATCH] docs(storybook): make Expanded OrgChart a preview-only page 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). --- docs/org-chart/expanded-usage.md | 30 +- .../verify-hierarchy-stories.mjs | 99 +++--- examples/org-chart/verify-p7.mjs | 101 +------ examples/org-chart/verify-p8.mjs | 37 +-- examples/org-chart/verify-p9-2.mjs | 120 ++------ examples/org-chart/verify-p9-3.mjs | 281 +----------------- examples/org-chart/verify-p9-4.mjs | 165 +--------- examples/org-chart/verify-p9-5-geometry.mjs | 116 ++------ .../org-chart/verify-p9-5-popup-parity.mjs | 3 +- examples/org-chart/verify-p9-5.mjs | 59 +--- examples/org-chart/verify-p9.mjs | 5 +- scripts/verify-orgchart-hm-story.mjs | 14 +- src/org-chart/org-chart-expanded-story.css | 45 --- src/org-chart/org-chart-expanded.stories.tsx | 222 +------------- 14 files changed, 163 insertions(+), 1134 deletions(-) diff --git a/docs/org-chart/expanded-usage.md b/docs/org-chart/expanded-usage.md index 62d3742..c1ab0eb 100644 --- a/docs/org-chart/expanded-usage.md +++ b/docs/org-chart/expanded-usage.md @@ -74,7 +74,7 @@ export function ExpandedExample() { ``` 위 native 버튼은 React 계약만 설명하는 최소 예시다. HM 화면에서는 실제 기존 -`Button`, `Checkbox`, `Label`, `Select`를 조합한다. Expanded Story가 그 사용 예다. +`Button`, `Checkbox`, `Label`, `Select`를 조합한다. Expanded Story는 미리보기 전용이라 이 컨트롤을 포함하지 않는다. Source·옵션 객체를 안정적으로 유지한다. Core 생성 옵션(layoutMode·TopNode·연결 방향·Zoom 범위)을 변경하면 React는 handle을 다시 생성한다. Vanilla는 새 handle을 생성한다. `autoLayout`·`interactions`·회사 선택·팝업 선택은 기존 인스턴스에서 갱신한다. @@ -261,11 +261,14 @@ Alt+M도 시작하지 않는다. 전환 시 진행 중 이동·Pointer capture· 이미 완료한 이동을 보존한다. 설정만 바꿀 때 데이터 재요청·재생성·레이아웃 갱신은 없다. 직원 정보, 그룹 접기·펼치기, Zoom/Pan/Fit은 기존 독립 설정을 따른다. Fixed는 변경하지 않는다. -Expanded Story의 데이터 범위 UI는 제거했고 예시 직원 수20/100/300명은 회사별 기준만 -표시한다. 전체 합산 `createOrgChartExampleDataset` 공개 helper 계약은 유지한다. -회사 선택은 즉시 표시를 갱신하고 숨김·복귀에도 완료한 이동을 유지한다. 직원 수는 기존 -**데이터 적용**으로 새 예시를 생성하며 임시 이동과 새 Fixture를 섞지 않는다. 초기 설정은 -7개 회사×20명=140명, 선택2개×100명=200명, 선택7개×300명=2,100명이다. +Expanded Story(2026-10-22~)는 조직도만 보여주는 미리보기 전용 화면이다. 데이터 설정 +(회사 선택·회사당 직원 수·데이터 적용·초기화), 레이아웃 설정, 인터랙션·직원 정보 설정 +UI는 제거했다. 고정 표시 조건은 `createOrgChartExampleDataset`의 회사별 기준 7개 회사×20명=140명, +세로형 TB·전체 펼침, 접기·펼치기·Zoom·Pan·Fit 사용, 직원 클릭 HM Dialog, Drag & Drop OFF다. +공개 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의 `--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를 닫는다. React callback 변경은 최신 committed callback만 갱신하고 Core를 재생성하거나 reload하지 않는다. -Expanded Story는 모든 회사의 원본 예시를 하나의 세션 source로 유지하고 회사 선택은 -`selectedCompanyIds`로 처리한다. 회사 숨김·다시 표시·빈 선택은 성공한 소속 변경을 -보존한다. **데이터 적용**은 현재 draft 조건으로 새 예시 source를 만들고 이동 내용을 -초기화한다. **초기화**는 최초 설정과 최초 소속으로 돌아가며 설정값이 이미 최초값이어도 -이동 내용은 되돌린다. 파일을 다시 작성하거나 React key로 강제 remount하지 않는다. -Story의 직원 합계와 그룹 선택은 성공한 `onMove`의 `result.data`를 별도 read model로 -읽으며, source identity는 유지한다. 표시 직원 수는 최초 예시 인원이 아닌 현재 조직의 -회사 소속을 기준으로 계산하므로 회사 간 이동 직후와 회사 숨김·다시 표시에도 일치한다. -미리보기에는 선택 회사별 현재 직원 수도 표시하며 데이터 설정의 회사당 인원은 예시 생성 기준이다. +소비 화면에서 회사 선택과 소속 변경을 함께 쓸 때는 모든 회사의 원본 예시를 하나의 세션 +source로 유지하고 회사 선택을 `selectedCompanyIds`로 처리한다. 회사 숨김·다시 표시·빈 +선택은 성공한 소속 변경을 보존한다. 새 예시가 필요하면 새 source를 만들며 파일을 다시 +작성하거나 React key로 강제 remount하지 않는다. 직원 합계 등 파생 표시는 성공한 `onMove`의 +`result.data`를 별도 read model로 읽고 source identity는 유지한다. 미리보기 전용 Expanded +Story는 회사 선택·Drag & Drop을 노출하지 않으므로 이 세션 흐름을 시연하지 않는다. diff --git a/examples/org-chart-react/verify-hierarchy-stories.mjs b/examples/org-chart-react/verify-hierarchy-stories.mjs index 4e98d39..6db47a1 100644 --- a/examples/org-chart-react/verify-hierarchy-stories.mjs +++ b/examples/org-chart-react/verify-hierarchy-stories.mjs @@ -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. import assert from 'node:assert/strict'; 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)); let browser, failure; -// Resume only complete company groups after a test harness interruption. -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 checks = []; const errors = []; try { browser = await launchChromium({ headless: true }); @@ -46,10 +43,6 @@ try { }, count); 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 shadow = host.shadowRoot; 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(); 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 ready(140); - let previousCompany; + await ready(count); + 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) { - if (checks.filter(check => check.company === company.id).length === 9) continue; - await page.getByRole('button', { name: /회사 선택/ }).click(); - await page.getByRole('checkbox', { name: previousCompany?.name ?? '전체 선택', exact: true }).uncheck(); - await page.getByRole('checkbox', { name: company.name, exact: true }).check(); - await page.keyboard.press('Escape'); - previousCompany = company; - for (const count of [20, 100, 300]) { - await select('회사당 직원 수', `${count}명`); - await page.getByRole('button', { name: '데이터 적용', exact: true }).click(); - await ready(count); - 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)); - let employees; - for (const [type, label] of [['tb', '세로형 TB'], ['lr', '가로형 LR'], ['hybrid', '혼합형 Hybrid']]) { - 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.equal(employees.filter(employee => companyOf.get(employee.organizationId) === company.id).length, 20, `${company.id}: 20 employees`); + 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: 20, layout: 'tb', 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)); } assert.deepEqual(errors, []); assert.equal(await page.getByRole('alert').count(), 0); - assert.equal(checks.length, companies.length * 9); - assert.equal(new Set(checks.map(check => `${check.company}/${check.employeeCount}/${check.layout}`)).size, companies.length * 9); + assert.equal(checks.length, companies.length); } catch (error) { failure = error.stack; throw error; @@ -143,4 +116,4 @@ try { await browser?.close(); 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`); diff --git a/examples/org-chart/verify-p7.mjs b/examples/org-chart/verify-p7.mjs index b4c4ee7..3cba969 100644 --- a/examples/org-chart/verify-p7.mjs +++ b/examples/org-chart/verify-p7.mjs @@ -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]); }); 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 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) { const first = page.locator('.hm-orgchart-employee').first(); const id = await first.getAttribute('data-employee-id'); @@ -272,60 +267,17 @@ try { await screenshot('2100-hybrid'); } 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 ready(20); - 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'); - 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 ready(140); + check(new Set((await measure()).nodes.map(n => n.company)).size === 7 && (await measure()).layout === 'tb', 'Story preview: seven companies in fixed TB'); 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 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 select('직원 정보 팝업', 'Core 기본 팝업'); await ready(); - check(await page.locator('.hm-orgchart-employee').count() === collapsed, 'Story popup ownership switch retains independent collapse'); - 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); + await toggle.focus(); await toggle.press('Enter'); await ready(); + check(await toggle.getAttribute('aria-expanded') === 'false' && await page.locator('.hm-orgchart-employee').count() < 140, 'Story preview: group collapse'); + await toggle.focus(); await toggle.press('Enter'); await ready(140); for (const width of [390, 768, 1280, 1440]) { await page.setViewportSize({ width, height: width === 390 ? 844 : 1100 }); 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 }); 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'); } 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] }; }); 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. - 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); - for (const [label, type] of [['세로형 TB', 'tb'], ['가로형 LR', 'lr'], ['혼합형 Hybrid', 'hybrid']]) { - 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); + // 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(140); + await hmPopup('Final tb'); } if (run('options') || 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]) { 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(); @@ -523,7 +446,7 @@ try { } if (run('dialog-narrow')) { 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(); const bounds = await d.evaluate(d => { const r = d.getBoundingClientRect(); diff --git a/examples/org-chart/verify-p8.mjs b/examples/org-chart/verify-p8.mjs index c5b807d..2b468e0 100644 --- a/examples/org-chart/verify-p8.mjs +++ b/examples/org-chart/verify-p8.mjs @@ -63,7 +63,7 @@ async function ready(expected) { async function gotoStory(story, width = 1440) { await page.setViewportSize({ width, height: 1100 }); 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 section(name, work) { @@ -140,12 +140,6 @@ const fixedHasNoInternalScroll = fixed => fixed.scroll.filter(item => !item.miss ? item.overflowX === 'hidden' && item.overflowY === 'hidden' : 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 { await section('manager', async () => { const response = await page.request.get(origin + '/index.json'); @@ -294,33 +288,19 @@ try { }); await section('smoke', async () => { 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`); - for (const label of ['접기·펼치기 사용', 'Zoom 사용', 'Pan 사용']) check(await page.getByRole('checkbox', { name: label, exact: true }).isChecked(), `Expanded: independent ${label} control enabled`); - 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'); - await select('전체 직원 수', '100명'); - check((await autoState()).employees === 20, 'Expanded: draft count waits for data apply'); - 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); + // Expanded is preview-only (2026-10-22): settings, data apply, layout/Depth switches and the + // independent Zoom/Pan checkboxes were removed; the fixed 7x20 TB preview keeps chart browsing. + 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'); + const preview = await autoState(); + check(preview.type === 'tb' && preview.employees === 140 && preview.zoomEnabled === 'true' && preview.panEnabled === 'true', 'Expanded: fixed TB 140 employee preview with Zoom/Pan enabled', preview); const beforeCollapse = await autoState(); const toggle = page.locator('.hm-orgchart-expanded-toggle').first(); const organizationId = await toggle.getAttribute('data-organization-id'); await toggle.focus(); await toggle.press('Enter'); await ready(); 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 }); - 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'); - 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(); const beforeZoom = await autoState(); 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(); const afterPan = await autoState(); 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(); 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'); diff --git a/examples/org-chart/verify-p9-2.mjs b/examples/org-chart/verify-p9-2.mjs index 3a278c6..ee6fe8f 100644 --- a/examples/org-chart/verify-p9-2.mjs +++ b/examples/org-chart/verify-p9-2.mjs @@ -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 { writeFileSync } from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; 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 output = path.join(root, 'test-results/org-chart-p9-2'); 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 suffix = only.length ? '-' + only.join('-') : ''; -const template = JSON.parse(await fs.readFile(path.join(root, 'examples/org-chart/data-expanded-large.json'), 'utf8')); -const companies = Object.values(companyColorPresets), ids = companies.map(company => company.companyId); +const ids = Object.values(companyColorPresets).map(company => company.companyId), total = ids.length * 20; const results = [], measurements = [], errors = [], startedAt = new Date().toISOString(); await fs.mkdir(output, { recursive: true }); 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.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(); } }); -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); }); async function ready(count) { await page.waitForFunction(count => { @@ -37,19 +35,14 @@ async function ready(count) { async function goto(size = { width: 1440, height: 1100 }) { await page.setViewportSize(size); 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() { return page.evaluate(() => { 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 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, 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, @@ -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 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 goto(); await open(); await checkbox('바론컨설턴트').click(); await ready(expectedTotal(20, ids.filter(id => id !== 'baron'))); await close(); - const selected = ids.filter(id => id !== 'baron'), count = expectedTotal(20, selected); - for (const [option, type] of [['세로형 TB', 'tb'], ['가로형 LR', 'lr'], ['혼합형 Hybrid', 'hybrid']]) { - if (type !== 'tb') await select('조직도 표시 유형', option); - await ready(count); const s = await state(); measurements.push({ name: type, state: s }); - 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); - 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 goto(); await snap('expanded-preview', true); + const s = await state(); measurements.push({ name: 'tb', state: s }); + 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'); + 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'); + 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'); + // The 140 employee forest opens at 100% wider than the viewport; Fit brings the first group into view. + 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 id = await toggle.getAttribute('data-organization-id'); await toggle.click(); await ready(); 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 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(); 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(); @@ -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(); 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(); - 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 () => { for (const size of [{ width: 1920, height: 1080 }, { width: 768, height: 1024 }, { width: 390, height: 844 }]) { - await goto(size); await open(); - 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); + await goto(size); check((await state()).documentWidth <= size.width + 1, `${size.width}: no horizontal page overflow`); - await snap(`expanded-open-${size.width}`); await close(); - 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); + await snap(`expanded-${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 () => { 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')); diff --git a/examples/org-chart/verify-p9-3.mjs b/examples/org-chart/verify-p9-3.mjs index 5d1c557..69d8934 100644 --- a/examples/org-chart/verify-p9-3.mjs +++ b/examples/org-chart/verify-p9-3.mjs @@ -1,19 +1,17 @@ -// P9.3 browser-state editing only. Exact section names allow failed probes to -// rerun without replaying previously successful P7-P9.2 or P9.3 checks. +// P9.3 browser-state checks on the preview-only Expanded Story (2026-10-22). Drag & Drop is fixed +// 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 { writeFileSync } from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; 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 output = path.join(root, 'test-results/org-chart-p9-3'); 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 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 }); const persist = () => writeFileSync(path.join(output, `browser-progress${suffix}.json`), JSON.stringify({ startedAt, results, measurements, errors }, null, 2)); 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(); } }); const viewport = () => page.locator('.hm-orgchart-viewport'); 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 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 settle = () => page.evaluate(async () => { await document.fonts.ready; for (let i = 0; i < 8; i++) await new Promise(requestAnimationFrame); }); async function ready(count) { @@ -43,26 +36,9 @@ async function ready(count) { }, count); 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 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' } = {}) { +async function setup() { await page.goto(`${origin}/iframe.html?id=orgchart--expanded-org-chart&viewMode=story`); - await companyTrigger().waitFor(); await ready(20); - 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 ready(140); await viewport().scrollIntoViewIfNeeded(); await page.getByRole('button', { name: 'Fit to View', exact: true }).click(); await settle(); } 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 })), dragging: viewport.dataset.editDragging, panDragging: viewport.dataset.dragging, activeEmployee: shadow.activeElement?.dataset.employeeId, 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, companyRoots: nodes.filter(org => org.parentId === '').map(org => org.companyId), }; @@ -127,97 +103,14 @@ async function centerPair(source, target) { 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 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) { check(false, `${name}: execution completed`, { message: error.message, stack: error.stack }); 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 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 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(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); const result = { startedAt, completedAt: new Date().toISOString(), browser: browser.version(), origin, sections: only, results, measurements, errors, diff --git a/examples/org-chart/verify-p9-4.mjs b/examples/org-chart/verify-p9-4.mjs index f73ef34..49412c0 100644 --- a/examples/org-chart/verify-p9-4.mjs +++ b/examples/org-chart/verify-p9-4.mjs @@ -1,5 +1,7 @@ // 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. +// 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 { writeFileSync } from 'node:fs'; 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 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 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 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); }); async function ready(count) { await page.waitForFunction(count => { @@ -39,40 +37,10 @@ async function ready(count) { && (count === undefined || shadow.querySelectorAll('.hm-orgchart-employee').length === count); }, count, { timeout: 45000 }); 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 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 } = {}) { +async function setup() { await page.setViewportSize({ width: 1920, height: 1700 }); await page.goto(`${origin}/iframe.html?id=orgchart--expanded-org-chart&viewMode=story`); - await companyTrigger().waitFor(); await ready(140); - await chooseCompanies(companies); await ready(companies.length * 20); - if (layout !== 'tb') await select('조직도 표시 유형', layout === 'lr' ? '가로형 LR' : '혼합형 Hybrid'); - await toggle(enabled); await viewport().scrollIntoViewIfNeeded(); + await ready(140); await viewport().scrollIntoViewIfNeeded(); await page.getByRole('button', { name: 'Fit to View', exact: true }).click(); await settle(); } 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 })), dragging: viewport.dataset.editDragging, panDragging: viewport.dataset.dragging, activeEmployee: shadow.activeElement?.dataset.employeeId, 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, }; }); @@ -140,14 +108,6 @@ async function section(name, action) { 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(() => {}); } } -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 page.setViewportSize({ width: 1440, height: 1000 }); 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 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 setup({ companies: ['한맥기술', '삼안'] }); - check(await editSwitch().getAttribute('aria-checked') === 'false', 'Drag & Drop defaults OFF'); await snap('drag-drop-off', true); + await setup(); + 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 [ ['employee within company', employee('virtual-022'), title('hanmac-office-sibling')], ['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(); 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').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 page.keyboard.press('Escape'); await dialog().waitFor({ state: 'hidden' }); await settle(); 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 }); }); -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 () => { 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); - 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 })); - 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); - await snap(`expanded-${width}`, true); await companyTrigger().click(); await companyPanel().waitFor(); - 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.setViewportSize({ width, height: 1100 }); await page.goto(`${origin}/iframe.html?id=orgchart--expanded-org-chart&viewMode=story`); await ready(140); + const metric = await page.evaluate(() => ({ width: innerWidth, scrollWidth: document.documentElement.scrollWidth })); + check(metric.scrollWidth <= width + 1, `${width}: preview-only Expanded has no horizontal overflow`, metric); + await snap(`expanded-${width}`, true); 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 })); 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 () => { // 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.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 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 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 }, @@ -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 }); 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.layout === 'tb' && before.editing === 'false' && await editSwitch().getAttribute('aria-checked') === 'false' - && 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'); + 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 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(); 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 setup({ companies:['한맥기술','삼안'],enabled:true }); - 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.' } }); + measurements.push({ name:'Expanded outer-frame correction',reference,before,narrow }); }); await section('names-mobile', async () => { diff --git a/examples/org-chart/verify-p9-5-geometry.mjs b/examples/org-chart/verify-p9-5-geometry.mjs index a8b40bb..c966cec 100644 --- a/examples/org-chart/verify-p9-5-geometry.mjs +++ b/examples/org-chart/verify-p9-5-geometry.mjs @@ -1,4 +1,4 @@ -// Focused P9.5 transform geometry and pointer-coordinate probes; no P9.4 replay. +// Focused P9.5 transform geometry probes on the preview-only Expanded Story; no P9.4 replay. import fs from 'node:fs/promises'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -8,20 +8,7 @@ const root = fileURLToPath(new URL('../../', import.meta.url)); const output = path.join(root, 'test-results/org-chart-p9-5'); const origin = (process.env.ORGCHART_STORY_ORIGIN ?? 'http://127.0.0.1:6064').replace(/\/$/, ''); const startedAt = new Date().toISOString(), checks = [], measurements = [], errors = []; -const pointerOnly = process.argv.includes('--pointer-only'); await fs.mkdir(output, { recursive: true }); -if (pointerOnly) { - const initial = JSON.parse(await fs.readFile(path.join(output, 'geometry-initial.json'), 'utf8')); - measurements.push(...initial.measurements); - for (const c of initial.checks) { - if (c.name === 'Focused geometry browser console/page errors zero') continue; - if (c.name.includes(': pointer move starts at fitted scale')) continue; - if (c.status === 'PASS') checks.push(c); - else if (c.name.endsWith(': connector endpoints reach intended targets')) { - checks.push({ ...c, status: c.detail.maxLocalError < 0.75 ? 'PASS' : 'FAIL', detail: { ...c.detail, toleranceLocalPx: 0.75, reason: 'Integer offset geometry and fractional CSS widths differ by up to 0.5 local px; evaluated from preserved initial measurements without browser replay.' } }); - } - } -} const browser = await launchChromium({ headless: true }); const page = await browser.newPage({ viewport: { width: 1440, height: 1700 } }); page.setDefaultTimeout(30000); @@ -37,19 +24,11 @@ async function ready(count) { }, count, { timeout: 60000 }); await settle(); } -async function setup(type, label) { +// Expanded is preview-only (2026-10-22): the fixed 7 companies x 20 TB state is measured; +// company filtering, layout switching and Drag & Drop are no longer reachable from the Story. +async function setup() { await page.goto(`${origin}/iframe.html?id=orgchart--expanded-org-chart&viewMode=story`); - await page.getByRole('button', { name: /^회사 선택/ }).waitFor(); await ready(140); - await page.getByRole('button', { name: /^회사 선택/ }).click(); - const panel = page.getByRole('dialog', { name: '회사 선택', exact: true }); await panel.waitFor(); - const all = panel.getByRole('checkbox', { name: '전체 선택', exact: true }); - for (let i = 0; i < 2 && await all.getAttribute('aria-checked') !== 'false'; i++) { await all.click(); await ready(); } - await ready(0); await panel.getByRole('checkbox', { name: '한맥기술', exact: true }).click(); await ready(20); - await page.keyboard.press('Escape'); await panel.waitFor({ state: 'hidden' }); - if (type !== 'tb') { - await page.getByRole('combobox', { name: '조직도 표시 유형', exact: true }).click(); - await page.getByRole('option', { name: label, exact: true }).click(); await ready(20); - } + await ready(140); await page.locator('.hm-orgchart-viewport').scrollIntoViewIfNeeded(); await settle(); } async function snapshot(name) { @@ -84,7 +63,7 @@ async function snapshot(name) { } function validateEndpoints(s, label) { const paths = [...s.employeePaths, ...s.organizationPaths]; - check(s.cards.length === 20 && s.employeePaths.length === 20, `${label}: all 20 cards retain employee connectors`, { cards: s.cards.length, employeePaths: s.employeePaths.length }); + check(s.cards.length === 140 && s.employeePaths.length === 140, `${label}: all 140 cards retain employee connectors`, { cards: s.cards.length, employeePaths: s.employeePaths.length }); check(paths.every(p => p.error < 0.75), `${label}: connector endpoints reach intended targets`, { paths: paths.length, organizationPaths: s.organizationPaths.length, maxLocalError: Math.max(0, ...paths.map(p => p.error)), toleranceLocalPx: 0.75 }); if (s.layout !== 'tb') check(s.organizationPaths.length > 0, `${label}: organization routes are measured`, s.organizationPaths.length); } @@ -107,80 +86,23 @@ async function pan() { await page.locator('.hm-orgchart-viewport').focus(); await page.keyboard.press('ArrowLeft'); await page.keyboard.press('ArrowUp'); await settle(); } -async function pointerMove(type) { - const toggle = page.getByRole('switch', { name: /Drag & Drop|조직 이동/ }); - if (await toggle.getAttribute('aria-checked') !== 'true') await toggle.click(); - await page.getByRole('button', { name: 'Fit to View', exact: true }).click(); await settle(); - const fitted = await snapshot(`${type}-drag-fit`); await pan(); - const panned = await snapshot(`${type}-drag-pan`); - check(panned.x !== fitted.x && panned.y !== fitted.y && panned.scale === fitted.scale, `${type}: pointer move starts at fitted scale with nonzero manual Pan`, { fitted: { x: fitted.x, y: fitted.y, scale: fitted.scale }, panned: { x: panned.x, y: panned.y, scale: panned.scale } }); - const pair = await page.evaluate(() => { - const shadow = document.querySelector('.hm-orgchart-host').shadowRoot, viewport = shadow.querySelector('.hm-orgchart-viewport').getBoundingClientRect(); - const box = element => { const r = element.getBoundingClientRect(); return { left: r.left, top: r.top, right: r.right, bottom: r.bottom, x: r.left + r.width / 2, y: r.top + r.height / 2 }; }; - const inside = r => r.left > viewport.left + 4 && r.right < viewport.right - 4 && r.top > viewport.top + 4 && r.bottom < viewport.bottom - 4 - && r.left > 4 && r.right < innerWidth - 4 && r.top > 4 && r.bottom < innerHeight - 4; - const sources = [...shadow.querySelectorAll('.hm-orgchart-employees .hm-orgchart-employee')].map(element => ({ element, rect: box(element) })).filter(item => inside(item.rect)); - sources.sort((a, b) => Math.hypot(a.rect.x - (viewport.left + viewport.right) / 2, a.rect.y - (viewport.top + viewport.bottom) / 2) - Math.hypot(b.rect.x - (viewport.left + viewport.right) / 2, b.rect.y - (viewport.top + viewport.bottom) / 2)); - for (const source of sources) { - const sourceOrg = source.element.closest('.node').dataset.organizationId; - const targets = [...shadow.querySelectorAll('.hm-orgchart-expanded-toggle')].filter(element => element.dataset.organizationId !== sourceOrg && element.closest('.node').dataset.parentId !== '').map(element => ({ element, rect: box(element) })).filter(item => inside(item.rect)); - targets.sort((a, b) => Math.hypot(a.rect.x - source.rect.x, a.rect.y - source.rect.y) - Math.hypot(b.rect.x - source.rect.x, b.rect.y - source.rect.y)); - if (targets.length) return { employeeId: source.element.dataset.employeeId, beforeOrganizationId: sourceOrg, targetOrganizationId: targets[0].element.dataset.organizationId, source: source.rect, target: targets[0].rect }; - } - throw new Error('No visible employee/other-organization pointer pair after Fit/Pan'); - }); - // Native pointer focus may reveal/scroll a source. Settle that before taking - // client coordinates, and verify both endpoints through live shadow hit tests. - await page.locator(`.hm-orgchart-employee[data-employee-id="${pair.employeeId}"]`).focus(); await settle(); - const actualPair = await page.evaluate(pair => { - const shadow = document.querySelector('.hm-orgchart-host').shadowRoot; - const employee = [...shadow.querySelectorAll('.hm-orgchart-employee')].find(item => item.dataset.employeeId === pair.employeeId); - const target = [...shadow.querySelectorAll('.hm-orgchart-expanded-toggle')].find(item => item.dataset.organizationId === pair.targetOrganizationId); - const center = element => { const r = element.getBoundingClientRect(); return { x: r.left + r.width / 2, y: r.top + r.height / 2 }; }; - const sourcePoint = center(employee), targetPoint = center(target); - return { ...pair, source: sourcePoint, target: targetPoint, sourceHit: shadow.elementFromPoint(sourcePoint.x, sourcePoint.y)?.closest('.hm-orgchart-employee')?.dataset.employeeId, targetHit: shadow.elementFromPoint(targetPoint.x, targetPoint.y)?.closest('.node[data-organization-id]')?.dataset.organizationId }; - }, pair); - check(actualPair.sourceHit === pair.employeeId && actualPair.targetHit === pair.targetOrganizationId, `${type}: focused pointer endpoints are visible and hit intended DOM targets`, actualPair); - await page.mouse.move(actualPair.source.x, actualPair.source.y); await page.mouse.down(); - await page.mouse.move(actualPair.source.x + 8, actualPair.source.y, { steps: 2 }); - await page.mouse.move(actualPair.target.x, actualPair.target.y, { steps: 14 }); - const hover = await page.evaluate(() => { - const shadow = document.querySelector('.hm-orgchart-host').shadowRoot; - return { dragging: shadow.querySelector('.hm-orgchart-viewport').dataset.editDragging, target: shadow.querySelector('[data-drop-hover="allowed"]')?.dataset.organizationId, feedback: shadow.querySelector('.hm-orgchart-move-feedback').textContent }; - }); - check(hover.dragging === 'true' && hover.target === pair.targetOrganizationId, `${type}: actual pointer hit testing highlights intended transformed target`, { pair, hover }); - await page.mouse.up(); - await page.waitForFunction(({ employeeId, targetOrganizationId }) => { - const shadow = document.querySelector('.hm-orgchart-host').shadowRoot; - const card = [...shadow.querySelectorAll('.hm-orgchart-employee')].find(card => card.dataset.employeeId === employeeId); - return card?.closest('.node').dataset.organizationId === targetOrganizationId && shadow.querySelector('.hm-orgchart-move-feedback').dataset.moveStatus === 'success'; - }, pair); await ready(20); - const moved = await snapshot(`${type}-after-pointer-move`); - check(moved.cards.find(card => card.id === pair.employeeId)?.organizationId === pair.targetOrganizationId, `${type}: pointer release moves employee to intended organization`, pair); - validateEndpoints(moved, `${type} after pointer move`); -} try { - for (const [type, label] of [['tb', '세로형 TB'], ['lr', '가로형 LR'], ['hybrid', '혼합형 Hybrid']]) { - try { - await setup(type, label); - if (!pointerOnly) { - const centered = await snapshot(`${type}-centered`); validateEndpoints(centered, `${type} centered`); - await pan(); const panned = await snapshot(`${type}-panned`); compareGeometry(centered, panned, `${type} Pan`); validateEndpoints(panned, `${type} panned`); - await page.getByRole('button', { name: '확대', exact: true }).click(); await settle(); - const zoomed = await snapshot(`${type}-zoomed`); compareGeometry(centered, zoomed, `${type} Zoom`); validateEndpoints(zoomed, `${type} zoomed`); - await page.getByRole('button', { name: 'Fit to View', exact: true }).click(); await settle(); - const fitted = await snapshot(`${type}-fitted`); compareGeometry(centered, fitted, `${type} Fit`); validateEndpoints(fitted, `${type} fitted`); - } - await pointerMove(type); - if (type === 'hybrid') await page.screenshot({ path: path.join(output, 'geometry-pointer-hybrid.png'), fullPage: true }); - } catch (error) { - check(false, `${type}: focused geometry section completed`, { message: error.message, stack: error.stack }); - await page.mouse.up().catch(() => {}); - } + const type = 'tb'; + try { + await setup(); + const centered = await snapshot(`${type}-centered`); validateEndpoints(centered, `${type} centered`); + await pan(); const panned = await snapshot(`${type}-panned`); compareGeometry(centered, panned, `${type} Pan`); validateEndpoints(panned, `${type} panned`); + await page.getByRole('button', { name: '확대', exact: true }).click(); await settle(); + const zoomed = await snapshot(`${type}-zoomed`); compareGeometry(centered, zoomed, `${type} Zoom`); validateEndpoints(zoomed, `${type} zoomed`); + await page.getByRole('button', { name: 'Fit to View', exact: true }).click(); await settle(); + const fitted = await snapshot(`${type}-fitted`); compareGeometry(centered, fitted, `${type} Fit`); validateEndpoints(fitted, `${type} fitted`); + await page.screenshot({ path: path.join(output, 'geometry-tb.png'), fullPage: true }); + } catch (error) { + check(false, `${type}: focused geometry section completed`, { message: error.message, stack: error.stack }); } } finally { check(errors.length === 0, 'Focused geometry browser console/page errors zero', errors); - const result = { startedAt, completedAt: new Date().toISOString(), origin, browser: browser.version(), repairedPointerOnly: pointerOnly, initialEvidence: pointerOnly ? 'geometry-initial.json' : undefined, scope: 'P9.5 only: 1 company × 20 staff; TB/LR/Hybrid transforms and one actual employee pointer move per mode; existing TB organization pseudo-elements unchanged', measurements, checks, errors, pass: checks.filter(c => c.status === 'PASS').length, fail: checks.filter(c => c.status === 'FAIL').length }; + const result = { startedAt, completedAt: new Date().toISOString(), origin, browser: browser.version(), scope: 'P9.5 preview-only Expanded: 7 companies × 20 staff; TB Pan/Zoom/Fit transforms; existing TB organization pseudo-elements unchanged', measurements, checks, errors, pass: checks.filter(c => c.status === 'PASS').length, fail: checks.filter(c => c.status === 'FAIL').length }; await fs.writeFile(path.join(output, 'geometry.json'), JSON.stringify(result, null, 2)); console.log(JSON.stringify({ pass: result.pass, fail: result.fail, measurements: measurements.length, output: path.join(output, 'geometry.json') })); await browser.close(); if (result.fail) process.exitCode = 1; diff --git a/examples/org-chart/verify-p9-5-popup-parity.mjs b/examples/org-chart/verify-p9-5-popup-parity.mjs index 996b201..ac8b4d2 100644 --- a/examples/org-chart/verify-p9-5-popup-parity.mjs +++ b/examples/org-chart/verify-p9-5-popup-parity.mjs @@ -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.locator('[data-slot="dialog-content"]').screenshot({path:path.join(output,'hm-dialog-reference-popup.png')}); 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=[ {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-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-full',url:`${vanilla}/p1.html?mode=readOnly`,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){ 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(); - 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 // may legitimately extend beyond the viewport without shrinking its cards. const choice=await page.evaluate(()=>{ diff --git a/examples/org-chart/verify-p9-5.mjs b/examples/org-chart/verify-p9-5.mjs index 7fb4470..d2b64ca 100644 --- a/examples/org-chart/verify-p9-5.mjs +++ b/examples/org-chart/verify-p9-5.mjs @@ -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 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) { - await page.getByRole('combobox', { name:label, exact:true }).click(); - 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); -} +// Expanded is preview-only (2026-10-22): company/count/layout combinations are no longer +// reachable from the Story, so only the fixed 7x20 TB state, Fit, Pan and widths are measured. async function open() { 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(); } try { await open(); const initial = await measure('initial-7x20-tb'); 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); } - for (const [companyNames, label] of [[['한맥기술'],'one'],[['한맥기술','삼안'],'two'],[['한맥기술','삼안','장헌산업','장헌','피티씨','한라','바론'],'seven']]) { - if (process.argv[3] && process.argv[3] !== label) continue; - for (const size of [20,100,300]) { - for (const [type,labelText] of [['tb','세로형 TB'],['lr','가로형 LR'],['hybrid','혼합형 Hybrid']]) { - await open(); await companies(companyNames); await count(size,companyNames.length*size); - 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); - } - } - } + await fit(); const fitted = await measure('initial-7x20-tb-fit'); + if (phase === 'after') { + centered(fitted,'7x20/tb: 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,'7x20/tb: Fit contains visible tree',fitted.visible); } if (phase === 'after') { - // User Pan preservation across resize and deliberate content/layout changes. - await open(); await companies(['한맥기술']); await fit(); + // User Pan preservation across resize. 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(); const panned = await measure('user-pan'); await page.setViewportSize({width:1280,height:1100}); await ready(); const resized = await measure('pan-resize'); - await companies(['한맥기술','삼안']); const filtered = await measure('pan-company'); - 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}); - } + check(resized.x===panned.x && resized.y===panned.y && resized.scale===panned.scale,'Manual Pan retained through resize',{before:panned,after:resized}); await fit(); centered(await measure('fit-after-pan'),'Explicit Fit restores center after user Pan',true); 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`); 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 fit(); await page.screenshot({path:path.join(output,'after-single-fit.png'),fullPage:true}); + await page.setViewportSize({width:1440,height:1100}); await open(); + 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}); } check(errors.length===0,'Browser console/page errors zero',errors); diff --git a/examples/org-chart/verify-p9.mjs b/examples/org-chart/verify-p9.mjs index 040a4d1..2369c66 100644 --- a/examples/org-chart/verify-p9.mjs +++ b/examples/org-chart/verify-p9.mjs @@ -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 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 }); } } const equal = (a, b) => JSON.stringify(a) === JSON.stringify(b); @@ -188,8 +187,8 @@ try { await section('expanded', async () => { for (const size of selectedSizes) { await goto('expanded', size); - for (const [option, type] of [['세로형 TB', 'tb'], ['가로형 LR', 'lr'], ['혼합형 Hybrid', 'hybrid']]) { - if (type !== 'tb') await select('조직도 표시 유형', option); + // Expanded is preview-only (2026-10-22): the fixed TB default is the only layout the Story shows. + { const type = 'tb'; 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?.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); diff --git a/scripts/verify-orgchart-hm-story.mjs b/scripts/verify-orgchart-hm-story.mjs index 9e028f3..8e41565 100644 --- a/scripts/verify-orgchart-hm-story.mjs +++ b/scripts/verify-orgchart-hm-story.mjs @@ -35,18 +35,8 @@ try { await page.screenshot({ path: path.join(evidence, 'storybook-expanded-hm.png') }); await page.keyboard.press('Escape'); await hm.waitFor({ state: 'hidden' }); assert.equal(await card.evaluate(e => e.getRootNode().activeElement === e), true); - const trigger = page.getByRole('combobox', { name: '직원 정보 팝업' }); - await trigger.click(); await page.getByRole('option', { name: 'Core 기본 팝업', exact: true }).click(); - // 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'] }); + // Expanded is preview-only (2026-10-22): no popup selector. Core popup stays covered by Fixed below. + results.push({ story: id, status: 'PASS', checks: ['Existing default HM Dialog', 'Escape and focus restoration'] }); } else { await core.waitFor({ state: 'visible' }); assert.equal(await hm.count(), 0); await page.screenshot({ path: path.join(evidence, 'storybook-fixed-core.png') }); diff --git a/src/org-chart/org-chart-expanded-story.css b/src/org-chart/org-chart-expanded-story.css index 6b7e646..1eb3195 100644 --- a/src/org-chart/org-chart-expanded-story.css +++ b/src/org-chart/org-chart-expanded-story.css @@ -13,48 +13,3 @@ gap: 0.75rem; 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; -} diff --git a/src/org-chart/org-chart-expanded.stories.tsx b/src/org-chart/org-chart-expanded.stories.tsx index c5ffac6..3528d2a 100644 --- a/src/org-chart/org-chart-expanded.stories.tsx +++ b/src/org-chart/org-chart-expanded.stories.tsx @@ -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 { ChevronDownIcon } from 'lucide-react'; -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 { OrgChart } from '@hm-design/org-chart-react/hm-dialog'; import { 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'; import engineUrl from '@hm-design/org-chart/engine.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 './org-chart-stories.css'; import '../center/center-stories.css'; import './org-chart-expanded-story.css'; const template = large as OrgChartDataset; -const companies = Object.values(companyColorPresets); -const allCompanies = companies.map(company => company.companyId as CompanyId); +const allCompanies = Object.values(companyColorPresets).map(company => company.companyId as CompanyId); const compositionStylesheets = { engine: engineUrl, core: coreUrl }; const topNodes = { ptc: { title: '기술총괄', name: '가상책임자 5' } }; -type DataSettings = Required>; -const initialSettings: DataSettings = { employeeCount: 20, companyIds: allCompanies }; +// Preview only: fixed sample (7 companies × 20 = 140) with the former default settings. +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 - * conditions. The preview selects samples rather than searching employees. */ +/** Existing Expanded Story only: a chart preview without setting panels. */ function ExpandedPreview() { - const id = useId(); - const [draft, setDraft] = useState(initialSettings); - const [applied, setApplied] = useState(initialSettings); - const [dataRevision, setDataRevision] = useState(0); - const [layout, setLayout] = useState({ type: 'tb', hybridStartDepth: 2, initialVisibleDepth: 'all' }); - const [popup, setPopup] = useState('hm-dialog'); - const [interactions, setInteractions] = useState({ collapse: true, zoom: true, pan: true }); - const [dragDropEnabled, setDragDropEnabled] = useState(false); const [error, setError] = useState(null); - const dataset = useMemo(() => createOrgChartExampleDataset(template, { - scope: 'per-company', - 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(); - 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>(allCompanies.map(companyId => [companyId, new Set()])); - 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 })); - }; + const source = useMemo(() => new JsonOrgChartDataSource(createOrgChartExampleDataset(template, { + scope: 'per-company', employeeCount: 20, companyIds: allCompanies, + })), []); return {error ?

{error}

: null}
- { 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(' / ')); }} /> + setError(cause instanceof Error ? cause.message : '조직도 설정을 적용할 수 없습니다.')} onStateChange={state => { if (state.status === 'error') setError(state.issues.map(issue => issue.message).join(' / ')); }} />
- -
- -
- - - - - - -
- 표시할 회사 · 복수 선택 가능 -
- changeCompanies(checked === true ? allCompanies : [])} /> - -
- - {companies.map(company =>
- { - changeCompanies(checked === true ? [...draft.companyIds, company.companyId as CompanyId] : draft.companyIds.filter(value => value !== company.companyId)); - }} /> - -
)} -
-
-
-
-
- - -
- - - - -
- {/* Not shown: kept only as the data hook that the OrgChart browser checks (P8, P9-2/3/4) read. */} - - {/* Live feedback only while the employee count differs from the applied data. */} -

- {pending ? `적용 예정: ${draft.companyIds.length}개 회사 · 회사당 ${draft.employeeCount}명 · 총 ${predicted.toLocaleString('ko-KR')}명 · 데이터 적용을 눌러 반영하세요.` : ''} -

-
-
- -
-
-
- - -
-
- - -
-
- - -
-
- - -
-
-
-
- -
-
-
- - -
- {([ - ['collapse', '접기·펼치기 사용'], ['zoom', 'Zoom 사용'], ['pan', 'Pan 사용'], - ] as const).map(([key, label]) =>
- setInteractions(current => ({ ...current, [key]: checked === true }))} /> - -
)} -
-
-
- - -
-
-
-
; }