Files
HMDesign/docs/sw/sw-dotum-font-qa/consumer-viewer/viewer-verify-run.mjs
T
shkim4 6504668e85 docs(sw): Dotum default font, Footer design check and real viewer consumer QA
Report docs/sw/sw-dotum-font-2026-10-20.md and evidence docs/sw/sw-dotum-font-qa/: Noto -> Dotum layout diff of 40 SW
Stories x 4 widths, Header hysteresis trace, Footer design items (4 PASS), SW regression runs with baseline failure
classification, and the isolated HMDesignSwTest-viewer run (Chrome 34/34, Firefox 15/15; original project untouched).
Open items: synthesized-bold overflow in fixed Header cells and Footer POLYGON / WIPEOUT labels (design review),
viewer-regression not run, macOS / Linux fallback not verified.
2026-10-20 16:59:55 +09:00

151 lines
17 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Real SW consumer (HMDesignSwTest-viewer, isolated copy) — Dotum font, SW viewer chrome, DWG/DXF load, zoom/pan,
// panels (open/close/Escape/drag/resize/scroll/slider/switch), header menus/More, responsive. Chrome (+ Firefox basic).
// Usage: node viewer-verify-run.mjs <base-url> <out-dir> [chromium|firefox]
import { createRequire } from 'node:module'
import path from 'node:path'
import { mkdir, writeFile } from 'node:fs/promises'
const require = createRequire(path.join('C:/Users/user/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/node_modules', 'index.js'))
const pw = require('playwright')
const { PNG } = require('pngjs')
const [base = 'http://127.0.0.1:5391', outArg = 'viewer-verify-out', engine = 'chromium'] = process.argv.slice(2)
const out = path.resolve(outArg); await mkdir(out, { recursive: true })
const results = []; const errors = []; const missingFavicon = []
const check = (name, pass, detail = '') => { results.push({ name, result: pass ? 'PASS' : 'FAIL', detail }); console.log(`${pass ? 'PASS' : 'FAIL'} ${name}${detail !== '' ? ` — ${typeof detail === 'string' ? detail : JSON.stringify(detail)}` : ''}`) }
const browser = engine === 'firefox' ? await pw.firefox.launch({ headless: true }) : await pw.chromium.launch({ headless: true, executablePath: 'C:/Program Files/Google/Chrome/Application/chrome.exe' })
const newPage = async (width = 1600, height = 1000) => {
const page = await browser.newPage({ viewport: { width, height } })
page.on('pageerror', e => errors.push(`pageerror ${e.message}`))
page.on('console', m => { if (m.type() === 'error' && !/favicon|Failed to load resource/.test(m.text())) errors.push(`console ${m.text()}`) })
page.on('response', r => { if (r.status() >= 400) (r.url().endsWith('/favicon.ico') ? missingFavicon : errors).push(`HTTP ${r.status()} ${r.url()}`) })
return page
}
const status = page => page.evaluate(() => document.querySelector('.hm-sw-viewer-status')?.getAttribute('data-state'))
const waitLoaded = async (page, timeout = 60000) => { await page.waitForFunction(() => { const s = document.querySelector('.hm-sw-viewer-status')?.getAttribute('data-state'); return s && s !== 'loading' && s !== 'idle' }, null, { timeout }).catch(() => {}); await page.waitForTimeout(800); return status(page) }
const canvasStats = async page => { const buf = await page.locator('.viewer-canvas').screenshot(); const png = PNG.sync.read(buf); const seen = new Map(); let n = 0; for (let i = 0; i < png.data.length; i += 16) { const k = (png.data[i] >> 4) << 8 | (png.data[i + 1] >> 4) << 4 | png.data[i + 2] >> 4; seen.set(k, (seen.get(k) ?? 0) + 1); n++ } const top = Math.max(...seen.values()); return { colors: seen.size, nonBg: +(1 - top / n).toFixed(4), buf } }
const diffRatio = (a, b) => { const A = PNG.sync.read(a), B = PNG.sync.read(b); if (A.width !== B.width || A.height !== B.height) return 1; let d = 0; for (let i = 0; i < A.data.length; i += 4) if (Math.abs(A.data[i] - B.data[i]) + Math.abs(A.data[i + 1] - B.data[i + 1]) + Math.abs(A.data[i + 2] - B.data[i + 2]) > 30) d++; return d / (A.data.length / 4) }
const fontsOf = (page, sel) => page.evaluate(sel => { const m = {}; for (const r of document.querySelectorAll(sel)) for (const e of [r, ...r.querySelectorAll('*')]) { if (e.closest('svg')) continue; if (![...e.childNodes].some(n => n.nodeType === 3 && n.textContent.trim()) && !/^(BUTTON|INPUT)$/.test(e.tagName)) continue; const f = getComputedStyle(e).fontFamily.split(',')[0].replace(/"/g, ''); m[f] = (m[f] ?? 0) + 1 } return m }, sel)
const page = await newPage()
await page.goto(base); await page.waitForSelector('.hm-sw-viewer-header'); await page.evaluate(() => document.fonts.ready); await page.waitForTimeout(500)
// --- Font / theme
const headerFonts = await fontsOf(page, '.hm-sw-viewer-header')
check('Font: SWViewerHeader text = Dotum (SW default)', Object.keys(headerFonts).length > 0 && Object.keys(headerFonts).every(f => f === 'Dotum' || f === 'ui-monospace'), headerFonts)
if (engine === 'chromium') {
const cdp = await page.context().newCDPSession(page); await cdp.send('DOM.enable'); await cdp.send('CSS.enable')
const { root } = await cdp.send('DOM.getDocument', { depth: -1 }); const { nodeIds } = await cdp.send('DOM.querySelectorAll', { nodeId: root.nodeId, selector: '.hm-sw-viewer-header *' })
const agg = {}; for (const id of nodeIds) { const { fonts } = await cdp.send('CSS.getPlatformFontsForNode', { nodeId: id }); for (const f of fonts) agg[f.familyName] = (agg[f.familyName] ?? 0) + f.glyphCount }
check('Font: rendered platform font in header = Dotum (installed, no fallback)', Object.keys(agg).length > 0 && Object.keys(agg).every(f => f === 'Dotum'), agg)
}
const tokens = await page.evaluate(() => { const s = getComputedStyle(document.querySelector('.hm-sw-viewer-header')); return { font: s.getPropertyValue('--hm-sw-header-font-family').trim(), bg: s.getPropertyValue('--hm-sw-viewer-header-background').trim() || s.backgroundColor } })
check('Theme: SW token --hm-sw-header-font-family = Dotum stack', tokens.font.startsWith('Dotum'), tokens)
const webFont = await page.evaluate(() => getComputedStyle(document.documentElement).getPropertyValue('--hm-font-family-base').trim())
check('Web UI token --hm-font-family-base unchanged (Noto Sans KR)', webFont.startsWith('"Noto Sans KR"'), webFont)
await page.screenshot({ path: path.join(out, `${engine}-01-initial.png`) })
// --- DXF / DWG loading via the app's own ?model= path and the header sample menu
const loads = {}
for (const [label, url] of [['simple.dxf', '/samples/simple.dxf'], ['BasicSample.dwg', '/samples/BasicSample.dwg'], ['civil.dwg', '/samples/civil.dwg']]) {
const p = await newPage()
const t0 = Date.now(); await p.goto(`${base}/?model=${encodeURIComponent(url)}`); await p.waitForSelector('.hm-sw-viewer-header')
const st = await waitLoaded(p, 120000); const stats = await canvasStats(p)
const name = await p.evaluate(() => document.querySelector('.hm-sw-viewer-header__document-name')?.textContent?.trim() ?? '')
const tabs = await p.locator('.hm-sw-viewer-header__tab').count()
loads[label] = { status: st, ms: Date.now() - t0, colors: stats.colors, nonBg: stats.nonBg, name, tabs }
check(`Load ${label}: status ready + drawing pixels`, st && !/error/i.test(st) && stats.nonBg > 0.002, loads[label])
await p.screenshot({ path: path.join(out, `${engine}-02-load-${label}.png`) })
await p.close()
}
// --- Sample menu (header dropdown) + Escape, then interactions on civil.dwg
await page.goto(`${base}/?model=${encodeURIComponent('/samples/civil.dwg')}`); await waitLoaded(page, 120000)
const before = await canvasStats(page)
// zoom (wheel) and pan (drag) on the canvas
const box = await page.locator('.viewer-canvas').boundingBox()
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2); await page.mouse.wheel(0, -600); await page.waitForTimeout(600)
const zoomed = await canvasStats(page)
check('Viewer: wheel zoom changes the drawing', diffRatio(before.buf, zoomed.buf) > 0.01, diffRatio(before.buf, zoomed.buf).toFixed(3))
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2); await page.mouse.down({ button: 'middle' }); await page.mouse.move(box.x + box.width / 2 + 150, box.y + box.height / 2 + 80, { steps: 8 }); await page.mouse.up({ button: 'middle' }); await page.waitForTimeout(400)
let panned = await canvasStats(page)
if (diffRatio(zoomed.buf, panned.buf) <= 0.01) { await page.mouse.move(box.x + 300, box.y + 300); await page.mouse.down(); await page.mouse.move(box.x + 450, box.y + 380, { steps: 8 }); await page.mouse.up(); await page.waitForTimeout(400); panned = await canvasStats(page) }
check('Viewer: drag pan changes the view', diffRatio(zoomed.buf, panned.buf) > 0.01, diffRatio(zoomed.buf, panned.buf).toFixed(3))
await page.keyboard.press('f'); await page.waitForTimeout(500)
const fitted = await canvasStats(page)
check('Viewer: F (fit) restores the full view', diffRatio(before.buf, fitted.buf) < diffRatio(before.buf, panned.buf), `${diffRatio(before.buf, fitted.buf).toFixed(3)} < ${diffRatio(before.buf, panned.buf).toFixed(3)}`)
// space tabs (model / layouts)
const tabCount = await page.locator('.hm-sw-viewer-header__tab').count()
if (tabCount > 1) { const other = page.locator('.hm-sw-viewer-header__tab:not([data-state="active"])').first(); const label = (await other.textContent()).trim(); await other.click(); await page.waitForTimeout(1500); const s2 = await canvasStats(page); const active = await page.locator('.hm-sw-viewer-header__tab[data-state="active"]').textContent(); check('Header: space tab switch (inactive → active) re-renders the canvas', active.trim() === label && diffRatio(fitted.buf, s2.buf) > 0.01, `${tabCount} tabs, now ${active.trim()}`); await page.locator('.hm-sw-viewer-header__tab:not([data-state="active"])').first().click(); await page.waitForTimeout(1000) }
else check('Header: space tabs (only one space in civil.dwg)', true, `${tabCount} tab`)
// Header menu: 샘플 dropdown
await page.getByRole('button', { name: /샘플/ }).first().click()
const menuOpen = await page.locator('.hm-sw-viewer-menu').count()
const menuFonts = await fontsOf(page, '.hm-sw-viewer-menu')
check('Header: 샘플 dropdown opens (SWViewerMenu) with Dotum', menuOpen > 0 && Object.keys(menuFonts).every(f => f === 'Dotum'), menuFonts)
await page.screenshot({ path: path.join(out, `${engine}-03-sample-menu.png`) })
await page.keyboard.press('Escape'); await page.waitForTimeout(200)
check('Header: Escape closes the dropdown', (await page.locator('.hm-sw-viewer-menu').count()) === 0)
// load simple.dxf from the menu
await page.getByRole('button', { name: /샘플/ }).first().click(); await page.getByRole('menuitem', { name: 'simple.dxf' }).click()
const st2 = await waitLoaded(page)
const name2 = await page.evaluate(() => document.querySelector('.hm-sw-viewer-header__document-name')?.textContent?.trim())
check('Header: sample menu loads simple.dxf', /simple\.dxf/.test(name2 ?? '') && !/error/i.test(st2 ?? ''), `${name2} / ${st2}`)
// 2.5D switch
const s25 = page.getByRole('switch', { name: '2.5D' }).first()
if (await s25.count()) { const a = await canvasStats(page); await s25.click(); await page.waitForTimeout(800); const b = await canvasStats(page); check('Viewer: 2.5D switch toggles and re-renders', (await s25.getAttribute('aria-checked')) === 'true' && diffRatio(a.buf, b.buf) > 0.002, diffRatio(a.buf, b.buf).toFixed(4)); await s25.click(); await page.waitForTimeout(500) }
// Panels: 설정 (options) — open, fonts, slider, switch, drag, resize, scroll, Escape/close
await page.getByRole('button', { name: '설정' }).first().click(); await page.waitForTimeout(300)
const panel = page.locator('.hm-sw-viewer-panel').filter({ hasText: '옵션' }).first()
check('Panel: 설정 opens SWViewerPanel', await panel.isVisible())
const pf = await fontsOf(page, '.hm-sw-viewer-panel')
check('Panel: text = Dotum (program list cells keep their monospace)', Object.keys(pf).every(f => f === 'Dotum' || f === 'ui-monospace'), pf)
const slider = panel.locator('input[type="range"]').nth(1)
const v0 = await slider.inputValue(); await slider.focus(); await page.keyboard.press('ArrowRight'); await page.keyboard.press('ArrowRight'); await page.waitForTimeout(150)
const v1 = await slider.inputValue(); const num = await panel.locator('input[type="number"]').nth(1).inputValue()
check('Panel: SWViewerSlider changes value and syncs the number input', Number(v1) > Number(v0) && Number(num) === Number(v1), `${v0} → ${v1}, input ${num}`)
const sw = panel.getByRole('switch').nth(1); const c0 = await sw.getAttribute('aria-checked'); await sw.click(); const c1 = await sw.getAttribute('aria-checked'); await sw.click()
check('Panel: SWViewerSwitch toggles (그리드)', c0 !== c1, `${c0} → ${c1}`)
const pb0 = await panel.boundingBox(); const title = panel.locator('.hm-sw-property__title').first()
const tb = await title.boundingBox(); await page.mouse.move(tb.x + 40, tb.y + tb.height / 2); await page.mouse.down(); await page.mouse.move(tb.x - 160, tb.y + 120, { steps: 10 }); await page.mouse.up(); await page.waitForTimeout(200)
const pb1 = await panel.boundingBox()
check('Panel: title drag moves the panel', Math.abs(pb1.x - pb0.x) > 100 && Math.abs(pb1.y - pb0.y) > 60, `(${Math.round(pb0.x)},${Math.round(pb0.y)}) → (${Math.round(pb1.x)},${Math.round(pb1.y)})`)
const grip = panel.locator('.hm-sw-viewer-panel__resize')
if (await grip.count()) { const gb = await grip.boundingBox(); await page.mouse.move(gb.x + gb.width / 2, gb.y + gb.height / 2); await page.mouse.down(); await page.mouse.move(gb.x + 80, gb.y - 160, { steps: 10 }); await page.mouse.up(); await page.waitForTimeout(200); const pb2 = await panel.boundingBox(); check('Panel: resize grip changes size', Math.round(pb2.width - pb1.width) > 40 && Math.round(pb1.height - pb2.height) > 80, `${Math.round(pb1.width)}×${Math.round(pb1.height)} → ${Math.round(pb2.width)}×${Math.round(pb2.height)}`) }
else check('Panel: resize grip (not resizable in this program)', true, 'resizable=false')
const body = panel.locator('.hm-sw-property__body')
const sc = await body.evaluate(e => { const before = e.scrollTop; e.scrollTop = 9999; return { scrollable: e.scrollHeight > e.clientHeight, before, after: e.scrollTop, overflowX: e.scrollWidth > e.clientWidth + 1 } })
check('Panel: body scrolls vertically, no horizontal overflow', sc.scrollable && sc.after > sc.before && !sc.overflowX, sc)
const clipped = await panel.evaluate(p => [...p.querySelectorAll('*')].filter(e => { const own = [...e.childNodes].some(n => n.nodeType === 3 && n.textContent.trim()); if (!own) return false; const cs = getComputedStyle(e); if (cs.overflowX === 'visible' && cs.textOverflow !== 'ellipsis') return false; const r = document.createRange(); r.selectNodeContents(e); return r.getBoundingClientRect().width > e.clientWidth - parseFloat(cs.paddingLeft) - parseFloat(cs.paddingRight) + 0.5 }).map(e => e.textContent.trim().slice(0, 30)))
check('Panel: no clipped text after Dotum', clipped.length === 0, clipped)
await page.screenshot({ path: path.join(out, `${engine}-04-options-panel.png`) })
await panel.locator('input[type="number"]').first().focus(); await page.keyboard.press('Escape'); await page.waitForTimeout(200)
const afterEsc = await panel.isVisible().catch(() => false)
if (afterEsc) { await panel.getByRole('button', { name: /닫기/ }).first().click(); await page.waitForTimeout(200) }
check(`Panel: closes (${afterEsc ? 'close button — Escape is not wired by the program for non-modal panels' : 'Escape'})`, !(await panel.isVisible().catch(() => false)))
// 레이어 / 속성 panels + 정보(modal) Escape
await page.getByRole('button', { name: '레이어' }).first().click(); await page.waitForTimeout(300)
const lp = page.locator('.hm-sw-viewer-panel').filter({ hasText: '레이어' }).first()
check('Panel: 레이어 opens with layer rows', (await lp.isVisible()) && (await lp.locator('li').count()) > 0, `${await lp.locator('li').count()} rows`)
await page.getByRole('button', { name: '레이어' }).first().click(); await page.waitForTimeout(200)
await page.getByRole('button', { name: '정보 및 라이선스' }).click(); await page.waitForTimeout(300)
const about = page.locator('.hm-sw-viewer-panel[data-modal]').first()
const aboutOpen = await about.isVisible().catch(() => false)
await page.keyboard.press('Escape'); await page.waitForTimeout(200)
check('Panel: 정보 (modal) opens and Escape closes it', aboutOpen && !(await about.isVisible().catch(() => false)), `open=${aboutOpen}`)
// Responsive header: More at narrow widths, no horizontal overflow
for (const w of [1920, 1180, 900, 560, 375]) {
await page.setViewportSize({ width: w, height: 900 }); await page.waitForTimeout(400)
const r = await page.evaluate(() => { const h = document.querySelector('.hm-sw-viewer-header'); return { overflow: h.scrollWidth > h.clientWidth + 1, doc: document.documentElement.scrollWidth > innerWidth + 1, more: !!document.querySelector('.hm-sw-viewer-header__more') } })
check(`Responsive ${w}: header/page no horizontal overflow${r.more ? ' (More shown)' : ''}`, !r.overflow && !r.doc, r)
if (w === 900 || w === 375) {
await page.screenshot({ path: path.join(out, `${engine}-05-${w}.png`) })
if (r.more) { await page.locator('.hm-sw-viewer-header__more').click(); await page.waitForTimeout(200); const items = await page.locator('.hm-sw-viewer-menu [role="menuitem"], .hm-sw-viewer-menu [role="menuitemcheckbox"], .hm-sw-viewer-menu button').count(); check(`Responsive ${w}: More opens with collapsed items`, items > 0, `${items} items`); await page.screenshot({ path: path.join(out, `${engine}-06-more-${w}.png`) }); await page.keyboard.press('Escape') }
}
}
check('Console / page / HTTP errors: none (favicon.ico 404 = program has no icon, pre-existing)', errors.length === 0, { errors: errors.slice(0, 6), favicon404: missingFavicon.length })
await browser.close()
await writeFile(path.join(out, `${engine}-result.json`), JSON.stringify({ base, engine, loads, results, errors }, null, 2))
console.log(`${results.filter(r => r.result === 'PASS').length}/${results.length} PASS`)