// 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. 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) => { 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(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 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) { 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 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' } = {}) { 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 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, domParentId: element.closest('.hierarchy')?.parentElement?.closest('.hierarchy')?.querySelector(':scope > .node')?.dataset.organizationId ?? '', 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 paths = [...shadow.querySelectorAll('.hm-orgchart-organization-connectors path')].map(path => ({ id: path.dataset.organizationId, parentId: path.dataset.parentId, d: path.getAttribute('d') })); const employeePaths = [...shadow.querySelectorAll('.hm-orgchart-employee-connectors path')].map(path => ({ id: path.dataset.employeeId, organizationId: path.closest('.node').dataset.organizationId, direction: path.dataset.direction, d: path.getAttribute('d') })); 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, paths, employeePaths, 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, countText: document.querySelector('[data-component="OrgChartExampleCounts"]')?.textContent.trim(), 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), }; }); } const relationState = s => JSON.stringify({ nodes: s.nodes, cards: s.cards.map(({ id, organizationId, direction }) => ({ id, organizationId, direction })) }); 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(); // Cross the six-pixel Pan threshold inside the viewport before the requested // motion. A large first upward step from its top edge never reaches the // viewport's pointermove listener and therefore cannot establish capture. 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 or target has no geometry'); const left = Math.min(a.x, b.x), right = Math.max(a.x + a.width, b.x + b.width); const 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: ${JSON.stringify({ a, b, v })}`); 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, { 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; 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')); const before = await state(); await pan(85, -45); const after = await state(); check(Math.abs(after.panX - before.panX - 85) < 1 && Math.abs(after.panY - before.panY + 45) < 1 && after.scale === before.scale && relationState(after) === relationState(before), 'Blank-space pointer Pan preserves Zoom and every relationship'); await centerPair(employee('virtual-022'), title('hanmac-office-sibling')); const box = await employee('virtual-022').boundingBox(); await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2); await page.mouse.down(); await page.mouse.move(box.x + box.width / 2 + 2, box.y + box.height / 2 + 1); const small = await state(); await page.mouse.up(); 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 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, notVerified: ['Physical touch device', 'Actual screen reader'], pass: results.filter(item => item.status === 'PASS').length, fail: results.filter(item => item.status === 'FAIL').length }; await fs.writeFile(path.join(output, `browser-editing${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-3/browser-editing${suffix}.json` })); await browser.close(); if (result.fail) process.exitCode = 1;