// 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. import fs from 'node:fs/promises'; import { writeFileSync } from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { createHash } from 'node:crypto'; import { launchChromium } from '../../scripts/orgchart-browser-runtime.mjs'; const root = fileURLToPath(new URL('../../', import.meta.url)); const output = path.join(root, 'test-results/org-chart-p9-4'); const origin = (process.env.ORGCHART_STORY_ORIGIN ?? 'http://127.0.0.1:6064').replace(/\/$/, ''); const only = process.argv.slice(2), suffix = only.length ? `-${only.join('-')}` : ''; const results = [], measurements = [], errors = [], startedAt = new Date().toISOString(); 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) => { 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: 1920, height: 1700 } }); page.setDefaultTimeout(20000); 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 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 => { const shadow = document.querySelector('.hm-orgchart-host,.hm-orgchart-component-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, { 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 } = {}) { 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 page.getByRole('button', { name: 'Fit to View', exact: true }).click(); await settle(); } async function state() { return page.evaluate(() => { const shadow = document.querySelector('.hm-orgchart-host').shadowRoot; const viewport = shadow.querySelector('.hm-orgchart-viewport'), expanded = shadow.querySelector('.hm-orgchart-expanded'); const rect = element => { const r = element.getBoundingClientRect(); return { x: r.x, y: r.y, width: r.width, height: r.height, right: r.right, bottom: r.bottom }; }; const nodes = [...shadow.querySelectorAll('.node[data-organization-id]')].map(element => ({ id: element.dataset.organizationId, parentId: element.dataset.parentId, companyId: element.dataset.companyId, direct: Number(element.dataset.directEmployeeCount), inclusive: Number(element.dataset.inclusiveEmployeeCount), collapsed: element.dataset.collapsed, })); const cards = [...shadow.querySelectorAll('.hm-orgchart-employee')].map(element => ({ id: element.dataset.employeeId, organizationId: element.closest('.node').dataset.organizationId, companyId: element.dataset.companyId, direction: element.dataset.connectionDirection, kind: element.dataset.cardKind, border: { top: getComputedStyle(element).borderTopColor, left: getComputedStyle(element).borderLeftColor, right: getComputedStyle(element).borderRightColor }, })); const feedback = shadow.querySelector('.hm-orgchart-move-feedback'); return { layout: expanded?.dataset.autoLayout, scale: Number(viewport.dataset.scale), panX: Number(viewport.dataset.panX), panY: Number(viewport.dataset.panY), viewport: rect(viewport), forest: rect(shadow.querySelector('.hm-orgchart-forest')), nodes, cards, feedback: { status: feedback?.dataset.moveStatus, text: feedback?.textContent }, 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, companyRoots: nodes.filter(org => org.parentId === '').map(org => org.companyId), documentWidth: document.documentElement.scrollWidth, }; }); } const relations = s => JSON.stringify({ nodes: s.nodes.map(({ id, parentId, companyId }) => ({ id, parentId, companyId })), cards: s.cards.map(({ id, organizationId, companyId }) => ({ id, organizationId, companyId })) }); async function blankPoint() { return page.evaluate(() => { const shadow = document.querySelector('.hm-orgchart-host').shadowRoot, viewport = shadow.querySelector('.hm-orgchart-viewport'), box = viewport.getBoundingClientRect(); for (const [dx, dy] of [[8, 8], [8, box.height - 8], [box.width - 8, 8], [box.width - 8, box.height - 8], [box.width / 2, box.height - 8]]) { const point = { x: box.left + dx, y: box.top + dy }, hit = shadow.elementFromPoint(point.x, point.y); if (hit && !hit.closest('button,a,.hm-orgchart-employee,.node')) return point; } throw new Error('No visible blank Pan point found'); }); } async function pan(dx, dy) { const p = await blankPoint(); await page.mouse.move(p.x, p.y); await page.mouse.down(); const v = await viewport().boundingBox(); await page.mouse.move(p.x + Math.sign(v.x + v.width / 2 - p.x) * 8, p.y, { steps: 2 }); await page.mouse.move(p.x + dx, p.y + dy, { steps: 8 }); await page.mouse.up(); await settle(); } async function centerPair(source, target) { await viewport().scrollIntoViewIfNeeded(); await source.focus(); await settle(); for (let pass = 0; pass < 3; pass++) { const a = await source.boundingBox(), b = await target.boundingBox(), v = await viewport().boundingBox(); if (!a || !b || !v) throw new Error('Drag source/target has no geometry'); const left = Math.min(a.x, b.x), right = Math.max(a.x + a.width, b.x + b.width), top = Math.min(a.y, b.y), bottom = Math.max(a.y + a.height, b.y + b.height); if (right - left > v.width - 24 || bottom - top > v.height - 24) throw new Error('Drag pair exceeds viewport at current scale'); const dx = v.x + v.width / 2 - (left + right) / 2, dy = v.y + v.height / 2 - (top + bottom) / 2; if (Math.abs(dx) < 1 && Math.abs(dy) < 1) break; await pan(dx, dy); } } async function drag(source, target, beforeRelease) { await centerPair(source, target); const a = await source.boundingBox(), b = await target.boundingBox(), 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 }); await page.mouse.move(b.x + b.width / 2, b.y + b.height / 2, { steps: 14 }); const hover = await state(); if (beforeRelease) await beforeRelease(); await page.mouse.up(); await ready(); return { before, hover, after: await state() }; } async function snap(name, fullPage = false) { await page.screenshot({ path: path.join(output, `${name}.png`), fullPage }); } 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(); await page.evaluate(async () => { await Promise.all([...document.querySelector('.hm-orgchart-component-host').shadowRoot.querySelectorAll('img')].map(async image => { image.loading = 'eager'; await image.decode(); })); }); const department = await page.evaluate(() => { const shadow = document.querySelector('.hm-orgchart-component-host').shadowRoot, root = shadow.querySelector('.hm-orgchart-department-colors'); const cards = [...root.querySelectorAll('[data-card-asset-size="representative"]')], card = cards[0]; const styles = element => Object.fromEntries(['fontFamily','fontSize','fontWeight','lineHeight','letterSpacing','color'].map(key => [key, getComputedStyle(element)[key]])); const renderedCard = element => ({ id: element?.dataset.employeeId, width: element?.offsetWidth, height: element?.offsetHeight, role: element?.querySelector('.hm-orgchart-person-role')?.textContent, name: element?.querySelector('.hm-orgchart-person-name')?.textContent, badgeCount: element?.querySelectorAll('.hm-orgchart-job-title,.hm-orgchart-status-label').length, background: element?.querySelector('.hm-orgchart-card-background')?.src, photo: element?.querySelector('.hm-orgchart-avatar img')?.src, roleStyle: element ? styles(element.querySelector('.hm-orgchart-person-role')) : {}, nameStyle: element ? styles(element.querySelector('.hm-orgchart-person-name')) : {} }); const rows = [...root.querySelectorAll('.hm-orgchart-color-preview')].map(row => ({ title: row.querySelector('.hm-orgchart-color-department-name')?.textContent, gradient: getComputedStyle(row.querySelector('.hm-orgchart-color-department-name')).backgroundImage, color: getComputedStyle(row.querySelector('.hm-orgchart-color-department-name')).color, cards: [...row.querySelectorAll('.hm-orgchart-employee')].map(card => ({ kind: card.dataset.cardKind, width: card.offsetWidth, height: card.offsetHeight, asset: card.dataset.cardAsset })) })); const unwanted = root.querySelectorAll('.hm-orgchart-color-legend,.hm-orgchart-contrast-note,.hm-orgchart-contrast-comparison,.hm-orgchart-card-caption,.hm-orgchart-color-preview > p').length; const frame = root.querySelector('.tunnel-reference__sample'), stage = frame?.querySelector('.tunnel-reference__stage'); return { representativeCount: cards.length, card: renderedCard(card), rows, unwanted, frameStyle: frame ? Object.fromEntries(['backgroundColor','border','borderRadius','boxShadow','padding','overflow'].map(key => [key, getComputedStyle(frame)[key]])) : {}, stageStyle: stage ? Object.fromEntries(['padding','overflowX','maxWidth'].map(key => [key, getComputedStyle(stage)[key]])) : {}, representativeFirst: Boolean(card && root.firstElementChild?.contains(card)), documentWidth: document.documentElement.scrollWidth }; }); check(department.representativeCount === 1 && department.card.width === 146 && department.card.height === 74, 'Department representative: one shared 146x74 card', department.card); check(department.card.role === '대표이사' && department.card.name === '홍 길 동' && department.card.badgeCount === 0 && /Noto Sans KR/.test(department.card.nameStyle.fontFamily), 'Department representative: approved text, font and zero badges'); check(department.representativeFirst && department.rows.length === 6 && department.rows.every(row => row.cards.length === 2 && row.cards[0].kind === 'leader' && row.cards[0].width === 146 && row.cards[0].height === 68 && row.cards[1].width === 108 && row.cards[1].height === 52), 'Department order: representative, six group titles, large leaders and small employees'); check(department.unwanted === 0, 'Department compact preview retains no legends, contrast/debug prose or duplicate card captions'); await snap('department-representative'); await snap('department-full', true); await page.goto(`${origin}/iframe.html?id=orgchart--full-composition&viewMode=story`); await ready(); const fixedCard = await page.locator('[data-card-asset-size="representative"]').evaluate(element => { const styles = target => Object.fromEntries(['fontFamily','fontSize','fontWeight','lineHeight','letterSpacing','color'].map(key => [key, getComputedStyle(target)[key]])); return { id: element.dataset.employeeId, background: element.querySelector('.hm-orgchart-card-background')?.src, photo: element.querySelector('.hm-orgchart-avatar img')?.src, roleStyle: styles(element.querySelector('.hm-orgchart-person-role')), nameStyle: styles(element.querySelector('.hm-orgchart-person-name')) }; }); check(['id','background','photo','roleStyle','nameStyle'].every(key => JSON.stringify(fixedCard[key]) === JSON.stringify(department.card[key])), 'Department and Fixed representative reuse identical SVG, avatar and typography', { department: department.card, fixed: fixedCard }); const prior = JSON.parse(await fs.readFile(path.join(root, 'test-results/org-chart-p9-1/browser.json'), 'utf8')).snapshots?.department?.['1440']; if (prior?.departments) check(department.rows.every((row, i) => row.gradient === prior.departments[i].headingStyle.backgroundImage && row.color === prior.departments[i].headingStyle.color), 'Department approved group gradients and white titles preserve P9.1 baseline'); else check(false, 'Department historical approved gradient evidence exists', 'Check P9.1 browser.json snapshot structure'); 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(frame => { const stage = frame.querySelector('.tunnel-reference__stage'); return { frameStyle: Object.fromEntries(['backgroundColor','border','borderRadius','boxShadow','padding','overflow'].map(key => [key, getComputedStyle(frame)[key]])), stageStyle: Object.fromEntries(['padding','overflowX','maxWidth'].map(key => [key, getComputedStyle(stage)[key]])) }; }); check(JSON.stringify(reference.frameStyle) === JSON.stringify(department.frameStyle) && JSON.stringify(reference.stageStyle) === JSON.stringify(department.stageStyle), 'Department shares actual Tunnel preview frame, background, border, radius, shadow, padding and overflow', { department: { frameStyle: department.frameStyle, stageStyle: department.stageStyle }, reference }); measurements.push({ name: 'Department', department, fixedCard, reference }); }); await section('department-names', async () => { // User's later naming correction only; keep every prior P9.4 PASS as evidence. const previous = JSON.parse(await fs.readFile(path.join(output, 'browser.json'), 'utf8')).measurements.find(item => item.name === 'Department').department; const oldStyles = JSON.parse(await fs.readFile(path.join(root, 'test-results/org-chart-p9-1/browser.json'), 'utf8')).snapshots.department['1440'].departments; const fixture = JSON.parse(await fs.readFile(path.join(root, 'examples/org-chart/data-p1.json'), 'utf8')); await page.setViewportSize({ width:1440, height:1000 }); await page.goto(`${origin}/iframe.html?id=orgchart--department-colors&viewMode=story`); await ready(); await page.evaluate(async () => { await Promise.all([...document.querySelector('.hm-orgchart-component-host').shadowRoot.querySelectorAll('img')].map(async image => { image.loading = 'eager'; await image.decode(); })); }); const current = await page.evaluate(async () => { const shadow = document.querySelector('.hm-orgchart-component-host').shadowRoot, root = shadow.querySelector('.hm-orgchart-department-colors'); const hash = async data => [...new Uint8Array(await crypto.subtle.digest('SHA-256', data))].map(byte => byte.toString(16).padStart(2,'0')).join(''); const keys = ['fontFamily','fontSize','fontWeight','lineHeight','letterSpacing','color']; const styles = element => Object.fromEntries(keys.map(key => [key,getComputedStyle(element)[key]])); const card = async element => ({ id:element.dataset.employeeId,kind:element.dataset.cardKind,asset:element.dataset.cardAsset,width:element.offsetWidth,height:element.offsetHeight, name:element.querySelector('.hm-orgchart-person-name')?.textContent,role:element.querySelector('.hm-orgchart-person-role')?.textContent, badges:[...element.querySelectorAll('.hm-orgchart-job-title')].map(badge => badge.textContent), roleStyle:styles(element.querySelector('.hm-orgchart-person-role')),nameStyle:styles(element.querySelector('.hm-orgchart-person-name')), assets:await Promise.all([...element.querySelectorAll('img')].map(async image => ({ hash:await hash(await (await fetch(image.currentSrc || image.src)).arrayBuffer()),naturalWidth:image.naturalWidth,naturalHeight:image.naturalHeight,loaded:image.complete && image.naturalWidth > 0 }))) }); return { cards:await Promise.all([...root.querySelectorAll('.hm-orgchart-employee')].map(card)), rows:await Promise.all([...root.querySelectorAll('.hm-orgchart-color-preview')].map(async row => ({ title:row.querySelector('.hm-orgchart-color-department-name').textContent, gradient:getComputedStyle(row.querySelector('.hm-orgchart-color-department-name')).backgroundImage,color:getComputedStyle(row.querySelector('.hm-orgchart-color-department-name')).color, cards:await Promise.all([...row.querySelectorAll('.hm-orgchart-employee')].map(card)) }))) }; }); check(current.cards.length === 13 && current.cards.every(card => card.name === '홍 길 동'), 'Department naming correction: all thirteen displayed names are 홍 길 동', current.cards.map(({ id,name }) => ({ id,name }))); const rolePreserved = current.cards.every(card => { const person = fixture.employees.find(employee => employee.id === card.id); const expectedRole = card.kind === 'topLeader' ? '대표이사' : person?.position.replace(/\s+/gu,''); const expectedBadges = card.kind === 'topLeader' ? [] : person?.jobTitle ? [person.jobTitle] : []; return Boolean(person) && card.role === expectedRole && JSON.stringify(card.badges) === JSON.stringify(expectedBadges); }); check(rolePreserved && new Set(current.cards.map(card => card.id)).size === 13, 'Department naming correction preserves fixture Employee IDs, positions and organizational badges'); check(current.rows.length === previous.rows.length && current.rows.every((row,index) => { const old = previous.rows[index]; return row.title === old.title && row.gradient === old.gradient && row.color === old.color && row.cards.length === old.cards.length && row.cards.every((card,i) => ['kind','width','height','asset'].every(key => card[key] === old.cards[i][key])); }), 'Department naming correction preserves P9.4 group titles, colors, gradients, card sizes and artwork mappings'); const styleKeys = ['fontFamily','fontSize','fontWeight','lineHeight','letterSpacing','color']; check(current.rows.every((row,index) => row.cards.every((card,i) => { const old = oldStyles[index].cards[i]; const role = old.subtree.find(item => item.className === 'hm-orgchart-person-role')?.style, name = old.subtree.find(item => item.className === 'hm-orgchart-person-name')?.style; return styleKeys.every(key => card.roleStyle[key] === role?.[key] && card.nameStyle[key] === name?.[key]) && JSON.stringify(card.assets) === JSON.stringify(old.assets); })), 'Department naming correction preserves approved card SVG/photo hashes and typography from P9.1'); const baseline = JSON.parse(await fs.readFile(path.join(output, 'baseline.json'), 'utf8')); const safe = baseline.files.filter(file => /^examples\/org-chart\/data.*\.json$/.test(file.path) || /^packages\/org-chart\/src\/assets\//.test(file.path) || ['packages/org-chart/src/core/employee-card.ts','packages/org-chart/src/styles/employee-card.css','packages/org-chart/src/core/org-chart-content.ts','packages/org-chart/src/core/render.ts','src/org-chart/org-chart-full-composition.stories.tsx'].includes(file.path)); const changed = []; for (const file of safe) { const hash = createHash('sha256').update(await fs.readFile(path.join(root,file.path))).digest('hex'); if (hash.toUpperCase() !== file.sha256.toUpperCase()) changed.push(file.path); } check(changed.length === 0 && safe.length > 20, 'Department naming correction leaves original fixtures, shared card renderer/styles, Fixed presenter and approved assets unchanged', { checked:safe.length,changed }); const representative = current.cards.find(card => card.kind === 'topLeader'); check(representative.width === previous.card.width && representative.height === previous.card.height && representative.badges.length === 0 && JSON.stringify(representative.nameStyle) === JSON.stringify(previous.card.nameStyle) && JSON.stringify(representative.roleStyle) === JSON.stringify(previous.card.roleStyle), 'Department representative retains 146x74, zero badges and P9.4 typography'); 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); 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')], ['employee across companies', employee('virtual-022'), title('saman-office-0')], ['group across companies', title('hanmac-office-0'), title('saman-office-1')], ]) { const result = await drag(source, target); check(relations(result.before) === relations(result.after) && !result.hover.dragging && result.hover.drop.length === 0 && result.after.drop.length === 0, `OFF blocks ${name} and all Drop highlights`); } for (const source of [employee('virtual-022'), title('hanmac-office-0')]) { await source.focus(); const before = await state(); await page.keyboard.press('Alt+m'); await settle(); const after = await state(); check(!after.dragging && after.drop.length === 0 && relations(after) === relations(before), 'OFF blocks Alt+M on employee/group without highlighting'); } 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 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'); const zoomBefore = await state(); await page.getByRole('button', { name: '확대', exact: true }).click(); await settle(); check((await state()).scale > zoomBefore.scale, 'OFF Zoom remains available'); 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, 'OFF Fit contains selected forest'); 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.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); await snap(`department-${width}`, true); } }); 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. 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); 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 }, inputs:Object.fromEntries(['--hm-orgchart-expanded-preview-background','--hm-orgchart-expanded-preview-border','--hm-orgchart-expanded-preview-radius'].map(name => [name,css.getPropertyValue(name).trim()])), cards:[...shadow.querySelectorAll('.hm-orgchart-employee')].map(card => ({ id:card.dataset.employeeId,background:getComputedStyle(card).backgroundColor,width:card.offsetWidth,height:card.offsetHeight,kind:card.dataset.cardKind })), titles:[...shadow.querySelectorAll('.node[data-company-id] .hm-orgchart-organization-name')].map(title => ({ company:title.closest('.node').dataset.companyId,color:getComputedStyle(title).color,background:getComputedStyle(title).backgroundColor })), layout:root.dataset.autoLayout,width:innerWidth,documentWidth:document.documentElement.scrollWidth, pan:shadow.querySelector('.hm-orgchart-viewport').dataset.panEnabled,zoom:shadow.querySelector('.hm-orgchart-viewport').dataset.zoomEnabled, editing:shadow.querySelector('.hm-orgchart-viewport').dataset.dragDropEnabled }; }); const before = await frameSnapshot(); 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'); 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.' } }); }); await section('names-mobile', async () => { // Only the narrow viewport and current captures are new; all thirteen names, // roles/artwork/typography already passed the department-names section. await page.setViewportSize({ width:1440,height:1000 }); await page.goto(`${origin}/iframe.html?id=orgchart--department-colors&viewMode=story`); await ready(); await page.evaluate(async () => { await Promise.all([...document.querySelector('.hm-orgchart-component-host').shadowRoot.querySelectorAll('img')].map(async image => { image.loading = 'eager'; await image.decode(); })); }); await snap('department-full-current',true); await page.locator('.hm-orgchart-representative-preview').screenshot({ path:path.join(output,'department-representative-current.png') }); await page.setViewportSize({ width:390,height:1100 }); await settle(); const narrow = await page.evaluate(() => { const shadow = document.querySelector('.hm-orgchart-component-host').shadowRoot; const cards = [...shadow.querySelectorAll('.hm-orgchart-employee')].map(card => { const box = card.getBoundingClientRect(); return { id:card.dataset.employeeId,left:box.left,right:box.right,name:card.querySelector('.hm-orgchart-person-name').textContent }; }); return { width:innerWidth,documentWidth:document.documentElement.scrollWidth,cards }; }); check(narrow.documentWidth <= 391 && narrow.cards.every(card => card.left >= -1 && card.right <= 391), '390px corrected Department names/cards stay within page without horizontal overflow', narrow); await snap('department-names-390',true); measurements.push({ name:'Department narrow capture',narrow }); }); 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)), 'Four existing public OrgChart 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, notVerified: ['Physical touch device','Actual screen reader','Design intent approval'], pass: results.filter(item => item.status === 'PASS').length, fail: results.filter(item => item.status === 'FAIL').length }; await fs.writeFile(path.join(output, `browser${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-4/browser${suffix}.json` })); await browser.close(); if (result.fail) process.exitCode = 1;