// P9.2 Expanded selection integration only. Historical suites are preserved. 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'; 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 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)); const check = (pass, name, detail) => { results.push({ name, status: pass ? 'PASS' : 'FAIL', ...(detail === undefined ? {} : { detail }) }); persist(); if (!pass) console.log('FAIL', name, JSON.stringify(detail)); }; const browser = await launchChromium({ headless: true }); 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 => { const shadow = document.querySelector('.hm-orgchart-host')?.shadowRoot; return shadow && !shadow.querySelector('[aria-busy="true"]') && [...shadow.querySelectorAll('link[rel="stylesheet"]')].every(link => link.sheet) && (count === undefined || shadow.querySelectorAll('.hm-orgchart-employee').length === count); }, count); await settle(); } 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); } 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, 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, cards: [...shadow.querySelectorAll('.hm-orgchart-employee')].map(element => ({ id: element.dataset.employeeId, companyId: element.dataset.companyId, direction: element.dataset.connectionDirection })), titles: [...shadow.querySelectorAll('.node[data-company-id] .hm-orgchart-organization-name')].map(element => ({ companyId: element.closest('.node').dataset.companyId, color: getComputedStyle(element).color })), autoControlsInFixed: Boolean(shadow.querySelector('.hm-orgchart-stage .hm-orgchart-expanded-toggle')) }; }); } 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`); } 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'); 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'); 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(); await page.getByRole('button', { name: '확대', exact: true }).click(); await settle(); const zoom = await state(); check(zoom.scale > before.scale, 'Zoom remains functional'); const viewport = page.locator('.hm-orgchart-viewport'); await viewport.scrollIntoViewIfNeeded(); const box = await viewport.boundingBox(); const panBefore = await state(); await page.mouse.move(box.x + 8, box.y + box.height - 8); await page.mouse.down(); await page.mouse.move(box.x + 58, box.y + box.height - 38, { steps: 5 }); await page.mouse.up(); await settle(); const pan = await state(); check(Math.abs(pan.panX - panBefore.panX) >= 40 && Math.abs(pan.panY - panBefore.panY) >= 20, 'Pointer Pan remains functional', { before: [panBefore.panX, panBefore.panY], after: [pan.panX, pan.panY] }); 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); }); 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); 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); } }); // 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')); const expected = ['orgchart--logo-personnel-summary', 'orgchart--department-colors', 'orgchart--full-composition', 'orgchart--expanded-org-chart']; check(entries.length === 4 && entries.every(entry => expected.includes(entry.id)), 'Existing four public Story IDs retained', entries.map(entry => ({ id: entry.id, title: entry.title }))); }); 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, pass: results.filter(item => item.status === 'PASS').length, fail: results.filter(item => item.status === 'FAIL').length }; await fs.writeFile(path.join(output, `browser-expanded${suffix}.json`), JSON.stringify(result, null, 2)); console.log(JSON.stringify({ pass: result.pass, fail: result.fail, errors: errors.length, output: `test-results/org-chart-p9-2/browser-expanded${suffix}.json` })); await browser.close(); if (result.fail) process.exitCode = 1;