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.
151 lines
17 KiB
JavaScript
151 lines
17 KiB
JavaScript
// 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`)
|