import { loadPlaywright, launchChromium } from '../../scripts/orgchart-browser-runtime.mjs'; import { readFile, writeFile, mkdir } from 'node:fs/promises'; import { createHash } from 'node:crypto'; // Only the eight final card requirements; existing 368-check evidence is untouched. await loadPlaywright(); const output = 'test-results/org-chart-final-check'; await mkdir(output, { recursive: true }); const fixture = JSON.parse(await readFile('examples/org-chart/data-p1.json', 'utf8')); const results = [], measurements = [], errors = []; const check = (ok, name, actual) => results.push({ name, status: ok ? 'PASS' : 'FAIL', actual }); const browser = await launchChromium({ headless: true }); const origin = process.env.ORGCHART_STORY_ORIGIN ?? 'http://127.0.0.1:6031'; const collisionRepair = process.argv.includes('--collision-repair'); async function capture(story, scenario, data) { const page = await browser.newPage({ viewport: { width: 1440, height: 1000 } }); page.on('pageerror', error => errors.push({ story, scenario, message: error.message })); page.on('console', message => { if (message.type() === 'error') errors.push({ story, scenario, message: message.text() }); }); if (data) await page.route('**/examples/org-chart/data-p1.json*', route => route.fulfill({ status: 200, contentType: 'application/javascript', body: `export default ${JSON.stringify(data)};`, })); await page.goto(`${origin}/iframe.html?id=orgchart--${story}&viewMode=story`); await page.waitForFunction(() => { const shadow = document.querySelector('.hm-orgchart-host, .hm-orgchart-component-host')?.shadowRoot; return shadow?.querySelector('[data-card-asset] .hm-orgchart-person-name[data-name-overflow]') && [...shadow.querySelectorAll('link')].every(link => link.sheet); }); await page.evaluate(async () => { await document.fonts.ready; const root = document.querySelector('.hm-orgchart-host, .hm-orgchart-component-host').shadowRoot; await Promise.all([...root.querySelectorAll('img')].map(image => image.decode().catch(() => {}))); }); const cards = await page.evaluate(() => { const shadow = document.querySelector('.hm-orgchart-host, .hm-orgchart-component-host').shadowRoot; const box = element => { const r = element.getBoundingClientRect(); return { x: r.x, y: r.y, right: r.right, bottom: r.bottom, width: r.width, height: r.height }; }; // Collision checks use painted logo pixels, not the transparent container. const logoInk = mark => { const img = mark?.querySelector('img'); if (!img?.naturalWidth) return null; const canvas = document.createElement('canvas'); canvas.width = img.naturalWidth; canvas.height = img.naturalHeight; const ctx = canvas.getContext('2d'); ctx.drawImage(img, 0, 0); const pixels = ctx.getImageData(0, 0, canvas.width, canvas.height).data; let left = canvas.width, right = -1, top = canvas.height, bottom = -1; for (let y = 0; y < canvas.height; y++) for (let x = 0; x < canvas.width; x++) if (pixels[(y * canvas.width + x) * 4 + 3] > 16) { left = Math.min(left, x); right = Math.max(right, x + 1); top = Math.min(top, y); bottom = Math.max(bottom, y + 1); } if (right < 0) return null; const b = box(img), scale = Math.min(b.width / canvas.width, b.height / canvas.height); const x = b.x + (b.width - canvas.width * scale) / 2, y = b.y + (b.height - canvas.height * scale) / 2; return { x: x + left * scale, y: y + top * scale, right: x + right * scale, bottom: y + bottom * scale }; }; return [...shadow.querySelectorAll('[data-card-asset]')].map(card => { const avatar = card.querySelector('.hm-orgchart-avatar'), text = card.querySelector('.hm-orgchart-person-text'); const name = card.querySelector('.hm-orgchart-person-name'), position = card.querySelector('.hm-orgchart-person-role'); const row = card.querySelector('.hm-orgchart-position-row'); const badge = card.querySelector('.hm-orgchart-job-title'), attrs = card.querySelector('.hm-orgchart-person-attributes'); const logo = card.querySelector('.hm-orgchart-affiliation-mark'); const nameStyle = getComputedStyle(name), positionStyle = getComputedStyle(position), cardStyle = getComputedStyle(card); const a = box(avatar), t = box(text), n = box(name), p = box(position), b = badge && box(badge), l = logoInk(logo); const overlap = (u, v) => Math.min(u.right, v.right) - Math.max(u.x, v.x) > 1 && Math.min(u.bottom, v.bottom) - Math.max(u.y, v.y) > 1; const collisions = []; if (overlap(a, t)) collisions.push('avatar/text'); if (b && overlap(b, a)) collisions.push('badge/avatar'); if (b && overlap(b, n)) collisions.push('badge/name'); if (l && overlap(l, a)) collisions.push('logo/avatar'); if (l && overlap(l, n)) collisions.push('logo/name'); if (l && b && overlap(l, b)) collisions.push('logo/badge'); if (l) { const range = document.createRange(); range.selectNodeContents(position); for (const r of range.getClientRects()) if (overlap(l, { x: r.x, y: r.y, right: r.right, bottom: r.bottom })) collisions.push('logo/position'); } const bs = badge && getComputedStyle(badge); return { id: card.dataset.employeeId, size: card.dataset.cardAssetSize, bounds: box(card), avatar: a, columnGap: cardStyle.columnGap, photoTextGap: t.x - a.right, textGap: getComputedStyle(text).rowGap, actualTextGap: n.y - p.bottom, nameColor: nameStyle.color, row: row ? { display: getComputedStyle(row).display, alignItems: getComputedStyle(row).alignItems, gap: getComputedStyle(row).columnGap, nameGap: n.y - box(row).bottom, badgeGap: b ? b.x - p.right : null, badgeMargin: bs?.margin ?? null, children: row.children.length } : null, nameMargin: nameStyle.margin, namePadding: nameStyle.padding, positionMargin: positionStyle.margin, positionPadding: positionStyle.padding, badge: badge ? { text: badge.textContent, fontSize: bs.fontSize, fontWeight: bs.fontWeight, color: bs.color, background: bs.backgroundColor, bounds: b } : null, attributesHeight: box(attrs).height, attributesPosition: getComputedStyle(attrs).position, collisions }; }); }); measurements.push({ story, scenario, cards }); for (const card of cards) { const prefix = `${story}/${scenario}/${card.id}/${card.size}`; if (!collisionRepair) { if (card.size === 'leader') check(card.columnGap === '10px' && Math.abs(card.photoTextGap - 10) < 0.1, `${prefix}: leader gap`, [card.columnGap, card.photoTextGap]); if (card.size === 'employee') { check(card.columnGap === '4px' && Math.abs(card.photoTextGap - 4) < 0.1, `${prefix}: employee gap`, [card.columnGap, card.photoTextGap]); check(card.nameColor === 'rgb(50, 50, 50)', `${prefix}: name color`, card.nameColor); } check(card.textGap === '0px' && Math.abs(card.actualTextGap) < 0.1 && [card.nameMargin, card.namePadding, card.positionMargin, card.positionPadding].every(value => value === '0px'), `${prefix}: text gap`, card.actualTextGap); const person = (data ?? fixture).employees.find(item => item.id === card.id); check(card.badge?.text === person.jobTitle || (!person.jobTitle && !card.badge), `${prefix}: jobTitle-driven badge`, { jobTitle: person.jobTitle, badge: card.badge?.text ?? null }); if (!person.jobTitle) check(card.attributesHeight === 0 && card.attributesPosition === 'absolute', `${prefix}: no empty badge space`, card.attributesHeight); if (card.badge && card.size !== 'representative') check(card.badge.fontSize === (card.size === 'leader' ? '9px' : '8.5px') && card.badge.fontWeight === (card.size === 'leader' ? '700' : '600'), `${prefix}: badge typography`, card.badge); if (card.size !== 'representative') { check(card.row?.display === 'flex' && card.row.alignItems === 'center' && card.row.gap === '4px' && Math.abs(card.row.nameGap) < 0.1, `${prefix}: position/badge row and zero name gap`, card.row); if (card.badge) check(Math.abs(card.row.badgeGap - 4) < 0.1 && card.row.badgeMargin === '0px', `${prefix}: actual position/badge gap and margin`, card.row); else check(card.row.children === 1, `${prefix}: position only without badge`, card.row.children); } const dimensions = card.size === 'employee' ? [108, 52, 24] : card.size === 'leader' ? [146, 68, 32] : [146, 74, 36]; check(card.bounds.width === dimensions[0] && card.bounds.height === dimensions[1] && card.avatar.width === dimensions[2] && card.avatar.height === dimensions[2], `${prefix}: preserved dimensions`, dimensions); } check(card.collisions.length === 0, `${prefix}: related collisions`, card.collisions); } if (!collisionRepair && (scenario === 'normal' || scenario === 'roles')) await page.screenshot({ path: `${output}/${story}-${scenario}.png`, fullPage: true }); await page.close(); return cards; } try { const sample = await capture('department-colors', 'normal'); await capture('full-composition', 'normal'); if (!collisionRepair) { const smallIds = sample.filter(card => card.size === 'employee').slice(0, 2).map(card => card.id); check(smallIds.length === 2, 'two small-card role fixtures available', smallIds); const roles = structuredClone(fixture); smallIds.forEach((id, index) => { roles.employees.find(item => item.id === id).jobTitle = index ? '셀장' : '팀장'; }); const noRole = structuredClone(fixture); const leaderId = sample.find(card => card.size === 'leader').id; noRole.employees.find(item => item.id === leaderId).jobTitle = null; for (const story of ['department-colors', 'full-composition']) { const cards = await capture(story, 'roles', roles); for (const [index, id] of smallIds.entries()) check(cards.some(card => card.id === id && card.size === 'employee' && card.badge?.text === (index ? '셀장' : '팀장')), `${story}: small ${index ? 'cell' : 'team'} badge`, id); const empty = await capture(story, 'no-role', noRole); check(empty.some(card => card.id === leaderId && !card.badge && card.attributesHeight === 0), `${story}: leader without role has no badge/space`, leaderId); } } } finally { await browser.close(); } check(errors.length === 0, 'browser console/runtime errors', errors); const baseline = JSON.parse(await readFile(`${output}/baseline.json`, 'utf8')); const changed = []; for (const [file, hash] of Object.entries(baseline)) { const current = createHash('sha256').update(await readFile(file)).digest('hex'); if (current !== hash) changed.push(file); } if (!collisionRepair) check(changed.length === 3 && changed.includes('packages/org-chart/src/core/employee-card.ts') && changed.includes('packages/org-chart/src/styles/employee-card.css') && changed.includes('packages/org-chart/src/styles/summary-sections.css'), 'preserved SVG/logo/JSON/API/Stories/368 evidence/Guard; only 3 existing files changed', changed); await writeFile(`${output}/${collisionRepair ? 'card-collision-repair' : 'browser'}.json`, JSON.stringify({ results, measurements, errors, changed, fixtureNote: 'Role/no-role scenarios alter only intercepted in-memory browser JSON modules; repository JSON is unchanged.' }, null, 2)); console.log(JSON.stringify({ checks: results.length, pass: results.filter(item => item.status === 'PASS').length, failures: results.filter(item => item.status === 'FAIL'), errors, changed }, null, 2)); if (results.some(item => item.status === 'FAIL')) process.exitCode = 1;