Files
HMDesign/examples/org-chart/verify-p5-23.mjs
T

225 lines
24 KiB
JavaScript

import { loadPlaywright, launchChromium } from '../../scripts/orgchart-browser-runtime.mjs';
import fs from 'node:fs/promises';
import http from 'node:http';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const root = fileURLToPath(new URL('../../', import.meta.url));
const output = path.join(root, 'test-results/org-chart-p5-23');
await loadPlaywright();
const story = process.env.ORGCHART_STORY_ORIGIN ?? 'http://127.0.0.1:6054';
const html = process.env.ORGCHART_HTML_ORIGIN ?? 'http://127.0.0.1:4189';
const fixtures = Object.fromEntries(await Promise.all(['small', 'medium', 'large'].map(async size => [size, JSON.parse(await fs.readFile(path.join(root, `examples/org-chart/data-expanded-${size}.json`), 'utf8'))])));
const fixed = JSON.parse(await fs.readFile(path.join(root, 'examples/org-chart/data-p1.json'), 'utf8'));
const results = [], errors = [], timings = [], measurements = [];
const check = (pass, name, detail) => { results.push({ name, status: pass ? 'PASS' : 'FAIL', detail }); if (!pass) console.log('FAIL', name, JSON.stringify(detail)); };
const mime = { '.js': 'text/javascript', '.css': 'text/css', '.ttf': 'font/ttf', '.json': 'application/json', '.png': 'image/png' };
const server = http.createServer(async (request, response) => {
try {
const pathname = decodeURIComponent(new URL(request.url, 'http://localhost').pathname);
if (pathname === '/shell') return response.writeHead(200, { 'Content-Type': 'text/html' }).end('<!doctype html><html><head><link rel="icon" href="data:,"><style>@font-face{font-family:"Noto Sans KR";src:url(/public/fonts/NotoSansKR-variable.ttf);font-weight:100 900}body{margin:0}#chart{width:100%}</style></head><body><main id="chart"></main></body></html>');
const file = path.resolve(root, '.' + pathname);
if (path.relative(root, file).startsWith('..')) return response.writeHead(403).end();
response.writeHead(200, { 'Content-Type': mime[path.extname(file)] ?? 'application/octet-stream' }).end(await fs.readFile(file));
} catch { response.writeHead(404).end(); }
});
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
const origin = `http://127.0.0.1:${server.address().port}`;
await fs.mkdir(output, { recursive: true });
const browser = await launchChromium({ headless: true });
const page = await browser.newPage({ viewport: { width: 1440, height: 1000 } });
page.on('pageerror', error => errors.push(error.message));
page.on('console', message => { if (message.type() === 'error') errors.push(message.text()); });
const settle = () => page.evaluate(async () => { await document.fonts.ready; await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(() => requestAnimationFrame(resolve)))); });
async function ready(count) {
await page.waitForFunction(n => {
const s = document.querySelector('.hm-orgchart-host')?.shadowRoot;
return s?.querySelectorAll('.hm-orgchart-employee').length === n && s.querySelectorAll('link').length > 0 && [...s.querySelectorAll('link')].every(link => link.sheet);
}, count); await settle();
}
async function mount(data, options = {}, baseline = false) {
await page.goto(origin + '/shell');
const ms = await page.evaluate(async ({ origin, data, options, baseline }) => {
const start = performance.now();
const base = origin + (baseline ? '/test-results/org-chart-p5-23/baseline-dist' : '/packages/org-chart/dist');
const mod = await import(base + '/index.js');
window.handle = mod.createOrgChart({ container: document.getElementById('chart'), dataSource: new mod.JsonOrgChartDataSource(data), stylesheetUrls: { engine: base + '/engine.css', core: base + '/style.css' }, ...options });
await window.handle.mount();
await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)));
return performance.now() - start;
}, { origin, data, options, baseline });
await settle(); return ms;
}
async function measure() {
return page.evaluate(() => {
const s = document.querySelector('.hm-orgchart-host').shadowRoot;
const rect = el => { const r = el.getBoundingClientRect(); return { x:r.x,y:r.y,width:r.width,height:r.height,right:r.right,bottom:r.bottom,cx:r.x+r.width/2 }; };
const nodes = [...s.querySelectorAll('.node[data-organization-id]')].map(n => ({ id:n.dataset.organizationId, parentId:n.dataset.parentId, company:n.dataset.companyId, depth:+n.dataset.depth, rect:rect(n), article:rect(n.querySelector('article')), subtree:rect(n.parentElement), domParent:n.parentElement.parentElement.closest('.hierarchy')?.querySelector(':scope > .node')?.dataset.organizationId ?? '', color:getComputedStyle(n.querySelector('h3')).backgroundColor, ink:getComputedStyle(n.querySelector('h3')).color }));
const cards = [...s.querySelectorAll('.hm-orgchart-employee')].map(c => {
const css = getComputedStyle(c), svg = c.closest('article')?.querySelector('svg.hm-orgchart-employee-connectors');
const line = svg && [...svg.querySelectorAll('path')].find(p => p.dataset.employeeId === c.dataset.employeeId);
const direction = c.dataset.connectionDirection, box = rect(c), scale = +s.querySelector('.hm-orgchart-viewport').dataset.scale || 1;
const end = line ? { x:svg.getBoundingClientRect().x + +line.dataset.endX * scale, y:svg.getBoundingClientRect().y + +line.dataset.endY * scale } : null;
const target = direction==='left' ? {x:box.x,y:box.y+box.height/2} : direction==='right' ? {x:box.right,y:box.y+box.height/2} : {x:box.cx,y:box.y};
return { id:c.dataset.employeeId, kind:c.dataset.cardKind, direction, company:c.dataset.companyId, box, border:[css.borderTopColor,css.borderRightColor,css.borderBottomColor,css.borderLeftColor], shadow:css.boxShadow, name:c.querySelector('.hm-orgchart-person-name')?.textContent, role:c.querySelector('.hm-orgchart-person-role')?.textContent, asset:c.dataset.cardAsset, endpointError:end?Math.hypot(end.x-target.x,end.y-target.y):null };
});
const collisions = [];
for (let i=0;i<cards.length;i++) for (const b of cards.slice(i+1)) {
const a=cards[i]; if (Math.min(a.box.right,b.box.right)-Math.max(a.box.x,b.box.x)>0.5 && Math.min(a.box.bottom,b.box.bottom)-Math.max(a.box.y,b.box.y)>0.5) collisions.push([a.id,b.id]);
}
const viewport = s.querySelector('.hm-orgchart-viewport'), forest=s.querySelector('.hm-orgchart-forest');
return { nodes, cards, collisions, scale:+viewport.dataset.scale, forest:rect(forest), viewport:rect(viewport), viewportState:{x:+viewport.dataset.panX,y:+viewport.dataset.panY}, doc:{width:document.documentElement.clientWidth,scrollWidth:document.documentElement.scrollWidth,height:document.documentElement.clientHeight,scrollHeight:document.documentElement.scrollHeight}, controls:s.querySelectorAll('.hm-orgchart-expanded-toolbar button').length };
});
}
async function hierarchy(name, data) {
const m = await measure(); measurements.push({ name, value:m });
check(m.nodes.length===data.organizations.length, name+' all organizations');
check(m.cards.length===data.employees.length && new Set(m.cards.map(c=>c.id)).size===data.employees.length, name+' unique employees exactly once');
check(m.collisions.length===0,name+' no card overlap',m.collisions);
check(m.nodes.every(n=>n.parentId===n.domParent),name+' DOM ancestry matches source');
check(m.nodes.every(n=>!n.parentId || n.rect.y > m.nodes.find(p=>p.id===n.parentId).article.bottom),name+' children below own parent');
check(m.nodes.every(n=>m.nodes.filter(p=>p.depth===n.depth).every(p=>Math.abs(p.rect.y-n.rect.y)<1)),name+' same depth horizontal alignment');
check(m.cards.every(c=>c.endpointError!==null && c.endpointError<1),name+' actual connector endpoints match cards',m.cards.filter(c=>c.endpointError===null||c.endpointError>=1));
check(m.cards.every(c=>c.border.filter((color,index)=>index!==({top:0,right:1,left:3}[c.direction])).every(color=>color===c.border[2]) && c.border[{top:0,right:1,left:3}[c.direction]]!==c.border[2]),name+' only incoming border side accented');
check(m.doc.scrollWidth<=m.doc.width+1,name+' no document horizontal overflow');
return m;
}
async function popup(id) {
const card = page.locator(`.hm-orgchart-employee[data-employee-id="${id}"]`).first();
await card.focus(); await card.press('Enter'); await page.locator('dialog[open]').waitFor();
const detail = await page.evaluate(()=> {const s=document.querySelector('.hm-orgchart-host').shadowRoot,d=s.querySelector('dialog');return {focused:d.contains(s.activeElement),transformed:!!d.closest('.hm-orgchart-forest'),text:d.textContent};});
check(detail.focused && !detail.transformed,'Popup focus inside; outside transformed forest',detail);
await page.keyboard.press('Escape');
check(await page.evaluate(id=>document.querySelector('.hm-orgchart-host').shadowRoot.activeElement?.dataset.employeeId===id,id),'Popup Escape restores card focus');
}
async function timedToggle(id, key='Enter') {
const button=page.locator(`.hm-orgchart-expanded-toggle[data-organization-id="${id}"]`);
await button.focus();
await page.evaluate(()=>{window.toggleStart=performance.now();});
await button.press(key); await settle();
const ms=await page.evaluate(()=>performance.now()-window.toggleStart);
timings.push({action:'toggle '+id,ms}); return ms;
}
try {
if (!process.env.ORGCHART_VERIFY_ONLY) {
await mount(fixed,{displayMode:'readOnly'},true); const before=await measure();
await mount(fixed,{displayMode:'readOnly'}); const after=await measure();
const fixedSig=m=>m.cards.map(c=>({id:c.id,kind:c.kind,asset:c.asset,name:c.name,role:c.role,box:c.box,border:c.border}));
check(JSON.stringify(fixedSig(before))===JSON.stringify(fixedSig(after)),'Fixed baseline/current exact card geometry, SVG, typography, color');
check(after.controls===0,'Fixed no Auto controls'); await popup(after.cards[0].id);
await page.screenshot({path:path.join(output,'fixed-1440.png'),fullPage:true});
for(const [size,data] of Object.entries(fixtures)) {
const ms=await mount(data,{layoutMode:'auto',displayMode:'readOnly',employeeConnections:Object.fromEntries(data.organizations.map((org,index)=>[org.id,['top','left','right'][index%3]]))});
timings.push({action:`${size} initial mount (module/styles + 2 animation frames)`,ms});
await ready(data.employees.length); await hierarchy('HTML '+size,data);
await page.screenshot({path:path.join(output,`html-${size}-fit.png`),fullPage:true});
}
const data=fixtures.large;
await timedToggle('hanmac-office-0');
check(await page.locator('[data-organization-id="hanmac-office-0"].hm-orgchart-expanded-toggle').getAttribute('aria-expanded')==='false','Enter collapses group');
await timedToggle('hanmac-division-0','Space'); await timedToggle('hanmac-division-0','Space');
check(await page.locator('[data-organization-id="hanmac-office-0"].hm-orgchart-expanded-toggle').getAttribute('aria-expanded')==='false','Parent re-open preserves child collapse');
check(await page.evaluate(()=>document.querySelector('.hm-orgchart-host').shadowRoot.activeElement?.dataset.organizationId==='hanmac-division-0'),'Toggle preserves initiating focus');
await timedToggle('hanmac-office-0'); await ready(300);
const selectMs=await page.evaluate(async()=>{const t=performance.now();await window.handle.setSelectedCompanies(['hanmac']);await new Promise(r=>requestAnimationFrame(()=>requestAnimationFrame(r)));return performance.now()-t;});timings.push({action:'300 single company selection + layout',ms:selectMs});
let m=await measure();check(new Set(m.nodes.map(n=>n.company)).size===1 && m.nodes.every(n=>n.company==='hanmac'),'Single company independent root');
check(await page.evaluate(()=>window.handle.getState().summary.raw.total)===300,'Company filter preserves immutable source total');
await page.evaluate(async()=>window.handle.setSelectedCompanies(['hanmac','ptc']));await settle();m=await measure();check(new Set(m.nodes.map(n=>n.company)).size===2,'Multiple companies without synthetic root');
await page.evaluate(async()=>window.handle.setSelectedCompanies([]));await settle();check(await page.locator('.hm-orgchart-expanded-empty').count()===1 && await page.locator('.node[data-organization-id]').count()===0,'Explicit empty selection');
await page.evaluate(async()=>window.handle.setSelectedCompanies());await ready(300);
await page.getByRole('button',{name:'100% 복원',exact:true}).click();let zoom=await measure();check(zoom.scale===1,'100% resets scale');
await page.getByRole('button',{name:'확대',exact:true}).click();check((await measure()).scale>1,'Zoom in button');
await page.getByRole('button',{name:'축소',exact:true}).click();check(Math.abs((await measure()).scale-1)<0.0001,'Zoom out button');
const area=page.locator('.hm-orgchart-viewport'),box=await area.boundingBox();
await page.mouse.move(box.x+box.width-50,box.y+50);const state=(await measure()).viewportState;
await page.mouse.down();await page.mouse.move(box.x+box.width-150,box.y+120,{steps:8});await page.mouse.up();
const moved=(await measure()).viewportState;check(moved.x!==state.x&&moved.y!==state.y,'Mouse drag pans forest');check(await page.locator('dialog[open]').count()===0,'Drag does not open popup');
await page.evaluate(()=> {window.handle.zoomTo(999);});check((await measure()).scale===3,'Zoom clamps maximum');
await page.evaluate(()=> {window.handle.zoomTo(0);});check((await measure()).scale===0.05,'Zoom clamps minimum');
await page.getByRole('button',{name:'Fit to View',exact:true}).click();zoom=await measure();check(zoom.forest.width<=zoom.viewport.width+1&&zoom.forest.height<=zoom.viewport.height+1,'Fit contains current forest');
await popup('virtual-001');
const beforeWheel=(await measure()).scale;
await page.mouse.move(box.x+box.width/2,box.y+box.height/2);await page.mouse.wheel(0,-150);await settle();check((await measure()).scale===beforeWheel,'Ordinary wheel does not zoom or consume scroll');
await page.keyboard.down('Control');await page.mouse.wheel(0,-150);await page.keyboard.up('Control');await settle();check((await measure()).scale>beforeWheel,'Ctrl wheel zooms');
await mount(data,{layoutMode:'auto',companyTopNodes:{hanmac:{title:'최고기술책임자',name:'가상리더',organizationName:'가상 기술회사'},saman:{display:'titleOnly'},halla:{display:'hidden'},baron:{employeeId:null}}});
check(await page.locator('[data-employee-id="virtual-001"] .hm-orgchart-person-role').textContent()==='최고기술책임자','Custom top title uses data/presentation, not representative constant');
check(await page.locator('.hm-orgchart-top-node-title').count()===1,'Top titleOnly mode');
check(await page.locator('.node[data-organization-id="halla-root"] .hm-orgchart-employee').count()===0,'Hidden top card mode');
check(await page.locator('.node[data-organization-id="baron-root"]').count()===1,'No top employee retains original organization root');
}
if (process.env.ORGCHART_VERIFY_ONLY !== 'extras') {
const data=fixtures.large;
await page.goto(html+'/expanded.html');await ready(20);await page.selectOption('#dataset','large');await ready(300);const htmlValue=await hierarchy('Real HTML large',data);
await page.goto(story+'/iframe.html?id=orgchart--expanded-org-chart&viewMode=story');await ready(20);
await page.getByRole('combobox',{name:'가상 데이터'}).click();await page.getByRole('option',{name:'Large · 300명',exact:true}).click();await ready(300);
const react=await hierarchy('React large',data);
const signature=m=>m.cards.map(c=>({id:c.id,kind:c.kind,role:c.role,name:c.name,company:c.company,direction:c.direction,border:c.border,width:Math.round(c.box.width/m.scale),height:Math.round(c.box.height/m.scale)}));
check(JSON.stringify(signature(htmlValue))===JSON.stringify(signature(react)),'Actual HTML/React shared card geometry/content/color/direction');
const index=await (await page.request.get(story+'/index.json')).json();check(Object.values(index.entries).filter(e=>e.type==='story'&&e.title==='OrgChart'&&!e.tags?.includes('internal')).length===4,'Four existing user Storybook menus retained');
await page.getByRole('button',{name:'선택 해제',exact:true}).click();await page.locator('.hm-orgchart-expanded-empty').waitFor();await page.getByRole('checkbox',{name:'한맥기술',exact:true}).click();await settle();check((await measure()).nodes.every(n=>n.company==='hanmac'),'React single selection');
await page.getByRole('checkbox',{name:'피티씨',exact:true}).click();await settle();check(new Set((await measure()).nodes.map(n=>n.company)).size===2,'React multiple selection');
await page.getByRole('button',{name:'전체 회사',exact:true}).click();await ready(300);
await page.screenshot({path:path.join(output,'storybook-large-1440.png'),fullPage:true});
for(const width of [390,768,1280,1440]) {
await page.setViewportSize({width,height:1000});await settle();const m=await measure();measurements.push({name:'React viewport '+width,value:m});
check(m.doc.scrollWidth<=m.doc.width+1,'React '+width+' no page overflow',m.doc);check(m.controls===4,'React '+width+' zoom toolbar accessible');
await page.screenshot({path:path.join(output,`storybook-${width}.png`),fullPage:true});
}
}
if (!process.env.ORGCHART_VERIFY_ONLY || process.env.ORGCHART_VERIFY_ONLY === 'extras') {
const data=fixtures.large;
const directions=Object.fromEntries(data.organizations.map((org,i)=>[org.id,['top','left','right'][i%3]]));
await mount(data,{layoutMode:'auto',employeeConnections:directions,organizationLinks:{'hanmac-root':'#company-detail'}});
const expected={hanmac:'#CB2E28',saman:'#F08300','jangheon-industry':'#4580A6',jangheon:'#1D418D',ptc:'#84338E',halla:'#0068B7',baron:'#0C367F'};
const rgb=hex=>`rgb(${[1,3,5].map(i=>parseInt(hex.slice(i,i+2),16)).join(', ')})`;
const colors=await measure();
check(colors.nodes.every(n=>n.color===rgb(expected[n.company])),'All seven group header preset colors exact');
check(colors.cards.every(c=>c.border[{top:0,right:1,left:3}[c.direction]]===rgb(expected[c.company])),'All seven employee border preset colors exact');
check(new Set(colors.cards.map(c=>c.direction)).size===3,'Real top/left/right connector examples present');
const duration=await page.evaluate(async()=>{const t=performance.now();document.querySelector('.hm-orgchart-host').shadowRoot.querySelector('.hm-orgchart-expanded-toggle[data-organization-id="hanmac-office-0"]').click();await new Promise(r=>requestAnimationFrame(()=>requestAnimationFrame(r)));return performance.now()-t;});
timings.push({action:'300 group collapse (native click + 2 frames)',ms:duration});
await page.evaluate(()=>document.querySelector('.hm-orgchart-host').shadowRoot.querySelector('.hm-orgchart-expanded-toggle[data-organization-id="hanmac-office-0"]').click());await ready(300);
const zoomPan=await page.evaluate(async()=>{const t=performance.now();window.handle.zoomTo(1.2);for(let i=0;i<60;i++)window.handle.panBy(1,-1);await new Promise(r=>requestAnimationFrame(r));return performance.now()-t;});
timings.push({action:'300 zoom + 60 pan updates + frame',ms:zoomPan});
await page.evaluate(()=>window.handle.fitToView());await settle();
const drag=await page.locator('.hm-orgchart-employee[data-employee-id="virtual-001"]').boundingBox();
await page.mouse.move(drag.x+drag.width/2,drag.y+drag.height/2);await page.mouse.down();await page.mouse.move(drag.x+drag.width/2+30,drag.y+drag.height/2+30,{steps:5});await page.mouse.up();
check(await page.locator('dialog[open]').count()===0,'Dragging an employee card suppresses popup click');
const touch=await page.evaluate(()=>{const s=document.querySelector('.hm-orgchart-host').shadowRoot,v=s.querySelector('.hm-orgchart-viewport'),before=window.handle.getViewportState();v.setPointerCapture=()=>{};v.hasPointerCapture=()=>false;for(const [type,x,y] of [['pointerdown',50,50],['pointermove',100,100],['pointerup',100,100]])v.dispatchEvent(new PointerEvent(type,{pointerId:42,pointerType:'touch',isPrimary:true,button:0,clientX:x,clientY:y,bubbles:true}));const after=window.handle.getViewportState();return {before,after};});
check(touch.after.x-touch.before.x===50&&touch.after.y-touch.before.y===50,'Touch pointer event route pans (synthetic; real device unverified)',touch);
await page.locator('.hm-orgchart-expanded-link').click();check(await page.locator('.node[data-organization-id]').count()===data.organizations.length,'Separate group page link does not collapse');
await page.evaluate(async()=>window.handle.setSelectedCompanies(['hanmac']));await settle();
await timedToggle('hanmac-office-0');await page.evaluate(async()=>window.handle.setSelectedCompanies(['saman']));await settle();
check(await page.locator('.hm-orgchart-expanded-toggle[aria-expanded="false"]').count()===0,'Other company remains independently expanded');
await page.evaluate(async()=>window.handle.setSelectedCompanies(['hanmac']));await settle();
check(await page.locator('.hm-orgchart-expanded-toggle[data-organization-id="hanmac-office-0"]').getAttribute('aria-expanded')==='false','Company re-selection preserves its collapse state');
const updated=structuredClone(data);updated.organizations.push({id:'hanmac-empty-leaf',companyId:'hanmac',parentId:'hanmac-root',name:'가상 빈 조직',type:'team',leaderId:null,displayOrder:5});
await page.evaluate(async d=>window.handle.update(d),updated);await settle();
check(await page.locator('.hm-orgchart-expanded-toggle[data-organization-id="hanmac-empty-leaf"]').isDisabled(),'Empty group title is disabled');
await page.goto(story+'/iframe.html?id=orgchart--expanded-org-chart&viewMode=story');await ready(20);
await page.getByRole('button',{name:'선택 해제',exact:true}).click();await page.getByRole('checkbox',{name:'한맥기술',exact:true}).click();await settle();
await page.getByRole('button',{name:'100% 복원',exact:true}).click();
await page.screenshot({path:path.join(output,'storybook-small-hanmac-100.png'),fullPage:true});
await page.locator('.hm-orgchart-expanded-toggle').first().focus();await page.locator('.hm-orgchart-expanded-toggle').first().press('Enter');await settle();
check(await page.locator('.hm-orgchart-expanded-toggle').first().getAttribute('aria-expanded')==='false','React group keyboard collapse uses same Core');
await page.locator('.hm-orgchart-expanded-toggle').first().press('Enter');await settle();
await page.getByRole('checkbox',{name:'삼안',exact:true}).click();await settle();
await page.getByRole('combobox',{name:'가상 데이터'}).click();await page.getByRole('option',{name:'Medium · 100명',exact:true}).click();await ready(100);
check((await measure()).cards.length===100,'React fixture switch restores all 100 employees after selections');
}
} catch(error) {check(false,'Suite completed',{message:error.message,stack:error.stack});}
finally {
check(errors.length===0,'No new browser console/runtime errors',errors);
await browser.close();await new Promise(resolve=>server.close(resolve));
const summary={total:results.length,pass:results.filter(r=>r.status==='PASS').length,fail:results.filter(r=>r.status==='FAIL').length};
const file=path.join(output,'browser.json');
if(process.env.ORGCHART_VERIFY_ONLY) {
const previous=JSON.parse(await fs.readFile(file,'utf8'));
const names=new Set(results.map(result=>result.name));
results.unshift(...previous.results.filter(result=>result.name!=='Suite completed' && !names.has(result.name)));
timings.unshift(...previous.timings);measurements.unshift(...previous.measurements);
summary.total=results.length;summary.pass=results.filter(r=>r.status==='PASS').length;summary.fail=results.filter(r=>r.status==='FAIL').length;
}
await fs.writeFile(file,JSON.stringify({scope:'P5-2/P5-3 affected paths',summary,results,timings,measurements,errors,notVerified:['Reference SVG design match','Real touch device, screen reader and other browsers','Historical full regression suite']},null,2));
console.log(JSON.stringify({summary,timings}));if(summary.fail)process.exitCode=1;
}