docs(storybook): make Expanded OrgChart a preview-only page
Deploy Storybook / deploy (push) Successful in 59s
Deploy Storybook / deploy (push) Successful in 59s
Remove the data, layout, interaction and employee-popup setting panels from the Expanded OrgChart Story. The chart now renders a fixed sample (7 companies x 20, TB, all expanded, collapse/zoom/pan on, Drag & Drop off, HM Dialog) right under the page title. Story ID, OrgChart packages and public API are unchanged. Align the Story browser checks (P7, P8, P9, P9.2-P9.5, geometry, popup parity, hierarchy, HM Story) with the preview: setting-only scenarios are removed, chart rendering, collapse, Zoom/Pan/Fit and HM Dialog checks run on the fixed state. Move/Drag & Drop coverage stays in the Vanilla Core checks (verify-p9-3-core, verify-p9-4-core). Verified: tsc, test:guard 94/94, node --check on 11 scripts, changed browser sections PASS. Pre-existing P7 story-menu, P8 Department long-name, P9 Expanded tb/runner failures remain separate issues. verify-p9-5-popup-parity was not run (needs the Vanilla example server).
This commit is contained in:
1 parent
4a54cfbeb2
commit
f4ddd5ac4b
14 files changed
+163
-1134
No files matched your search
@@ -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`);
|
||||
@@ -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();
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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'));
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(()=>{
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user