184 lines
19 KiB
JavaScript
184 lines
19 KiB
JavaScript
// 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;
|