448 lines
46 KiB
JavaScript
448 lines
46 KiB
JavaScript
// 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;
|