// Run with Windows Node and an isolated Chrome on CDP 9444; Vite must be on 5173. const fs = require('node:fs'), path = require('node:path'), assert = require('node:assert/strict'); const out = path.resolve(__dirname, '../.verify/quantity-navigation-20261109'); fs.mkdirSync(out, { recursive: true }); const report = { checks: [], errors: [] }; let ws, send; const delay = ms => new Promise(r => setTimeout(r, ms)); (async () => { const target = await (await fetch('http://127.0.0.1:9444/json/new?about:blank', { method: 'PUT' })).json(); ws = new WebSocket(target.webSocketDebuggerUrl); await new Promise((r, j) => { ws.onopen = r; ws.onerror = j; }); let seq = 0; const pending = new Map(); ws.onmessage = ({ data }) => { const m = JSON.parse(data), p = pending.get(m.id); if (p) { clearTimeout(p.timer); pending.delete(m.id); m.error ? p.reject(new Error(JSON.stringify(m.error))) : p.resolve(m.result); } if (m.method === 'Runtime.exceptionThrown') report.errors.push(m.params.exceptionDetails.exception?.description || m.params.exceptionDetails.text); }; send = (method, params = {}) => new Promise((resolve, reject) => { const id = ++seq, timer = setTimeout(() => reject(new Error('Timeout ' + method)), 30000); pending.set(id, { resolve, reject, timer }); ws.send(JSON.stringify({ id, method, params })); }); const ev = async expression => { const r = await send('Runtime.evaluate', { expression, awaitPromise: true, returnByValue: true }); if (r.exceptionDetails) throw new Error(r.exceptionDetails.exception?.description || r.exceptionDetails.text); return r.result.value; }; const until = async expression => { for (let i = 0; i < 120; i++) { if (await ev(expression)) return; await delay(150); } throw new Error('Wait ' + expression); }; const check = (name, value) => { assert.ok(value, name); report.checks.push(name); console.log('PASS', name); }; const click = expression => ev(`(()=>{const e=${expression};if(!e||e.disabled)throw new Error('Missing target');e.click();})()`); const shot = async name => { const r = await send('Page.captureScreenshot', { format: 'png' }); fs.writeFileSync(path.join(out, name + '.png'), Buffer.from(r.data, 'base64')); }; await send('Page.enable'); await send('Runtime.enable'); await send('DOM.enable'); await send('Emulation.setDeviceMetricsOverride', { width: 1600, height: 1000, deviceScaleFactor: 1, mobile: false }); await send('Page.navigate', { url: process.env.PIERZAINER_URL || 'http://127.0.0.1:5173/' }); await until(`typeof window.openSampleProject==='function'`); const fixture = await ev(`(async()=>{ const XLSX=await import('/node_modules/xlsx/xlsx.mjs'),{parseWorkbook}=await import('/src/core/parseWorkbook.ts'); const bytes=await(await fetch('/'+encodeURIComponent('도면추출')+'/samples/'+encodeURIComponent('운곡교P1입력.xlsx'))).arrayBuffer(); const base=parseWorkbook(XLSX.read(bytes)).piers.find(p=>p.name==='P1'); if(!base)throw new Error('Missing sample pier'); const {defaultLevelWidthMm}=await import('/src/core/geometry.ts'); const piers=['직접기초','직접기초','현장타설말뚝'].map((foundationType,i)=>{const input={...base,name:'P'+(i+1),foundationType,type:'T형',columnCount:1,groundEl:30,pileLengthMm:24000,castInPlaceShape:{clayBottomEl:16},columnDiameterMm:2000,footingLengthMm:10400,footingWidthMm:8150,footingHeightMm:2000,blindingMarginMm:100,blindingThicknessMm:100,pileKind:'강관',pileOuterMm:609.6,pileThicknessMm:12,copingH1Mm:1000,copingH2Mm:1500,levelWidthMm:defaultLevelWidthMm('T형',2000)};return{id:input.name,input,defaults:input,draft:input};}); return{schemaVersion:1,project:{name:'병합 검증',bridge:'검증',client:'',author:'',crs:'',memo:''},source:{fileName:'합성 표본',sheets:[]},piers,history:[]}; })()`); const file = path.join(out, 'fixture.pzr.json'); fs.writeFileSync(file, JSON.stringify(fixture)); await ev('window.openSampleProject()'); await click(`document.querySelector('#file-menu summary')`); await click(`document.getElementById('open-project')`); const doc = await send('DOM.getDocument'); const { nodeId } = await send('DOM.querySelector', { nodeId: doc.root.nodeId, selector: '#project-file' }); await send('DOM.setFileInputFiles', { nodeId, files: [file] }); await until(`document.querySelectorAll('#rebar-pier-items button').length===3`); await click(`document.getElementById('nav-rebar')`); check('local rebar screen renders', await ev(`!document.getElementById('view-rebar').hidden&&!!document.querySelector('#rebar-table table')`)); await click(`[...document.querySelectorAll('#rebar-pier-items button')].find(b=>b.textContent.startsWith('P3 ·'))`); await click(`[...document.querySelectorAll('#rebar-tabs button')].find(b=>b.textContent.startsWith('기둥'))`); check('unified cast input and physical rebar preserved', await ev(`!!document.getElementById('cip-apply')&&document.getElementById('rebar-table').textContent.includes('말뚝-P1')`)); await shot('01-cast'); await click(`[...document.querySelectorAll('button')].find(b=>b.textContent==='철근재료표 보기')`); check('materials is the default result tab', await ev(`!document.getElementById('view-quantity').hidden&&document.querySelector('[data-takeoff-mode="materials"]').getAttribute('aria-pressed')==='true'`)); check('local material table preserved', await ev(`document.getElementById('quantity-body').textContent.includes('말뚝-P1')`)); await shot('02-materials'); check('partial results explicitly export as review only', await ev(`!document.getElementById('quantity-export').disabled&&document.getElementById('quantity-export').textContent.includes('검토용')`)); check('piece details expose count and individual lengths', await ev(`!!document.querySelector('#quantity-body details table')&&document.getElementById('quantity-body').textContent.includes('가공 본수')`)); await click(`[...document.querySelectorAll('#quantity-pier-items button')].find(b=>b.textContent.startsWith('P1 ·'))`); check('current regular pier permits export', await ev(`!document.getElementById('quantity-export').disabled`)); await click(`document.querySelector('[data-takeoff-mode="takeoff"]')`); check('selected pier receives automatic rebar weight', await ev(`!document.querySelector('input[data-variable="rebar"]')&&document.querySelector('.takeoff-workspace').textContent.includes('기존 철근재료표')`)); await ev(`(()=>{const e=document.querySelector('select[aria-label="수량산출 대상 교각"]');e.value='P2';e.dispatchEvent(new Event('change'));})()`); check('another pier retains automatic weight despite material filter', await ev(`document.querySelector('select[aria-label="수량산출 대상 교각"]').value==='P2'&&!document.querySelector('input[data-variable="rebar"]')&&document.querySelector('.takeoff-workspace').textContent.includes('기존 철근재료표')`)); check('takeoff displays input, results and preview panels', await ev(`document.querySelectorAll('.tq-dashboard-grid > section, .tq-dashboard-grid > aside').length===3&&!!document.querySelector('.takeoff-excel-preview')`)); await shot('04-takeoff'); await click(`document.getElementById('nav-drawing')`); await until(`!!document.querySelector('#view-drawing svg')`); check('origin drawing screen renders finite geometry', await ev(`!document.getElementById('view-drawing').hidden&&!/NaN|Infinity/.test(document.querySelector('#view-drawing svg').outerHTML)`)); await shot('03-drawing'); await click(`document.getElementById('ga-materials')`); check('drawing shortcut preserves takeoff tab and selected pier', await ev(`!document.getElementById('view-quantity').hidden&&document.querySelector('[data-takeoff-mode="takeoff"]').getAttribute('aria-pressed')==='true'&&document.querySelector('select[aria-label="수량산출 대상 교각"]').value==='P2'`)); check('quantity header alone is highlighted', await ev(`document.getElementById('nav-quantity').getAttribute('aria-current')==='true'&&document.getElementById('nav-rebar').getAttribute('aria-current')==='false'`)); await click(`document.getElementById('nav-rebar')`); await click(`[...document.querySelectorAll('#rebar-pier-items button')].find(b=>b.textContent.startsWith('P1 ·'))`); await click(`document.getElementById('nav-quantity')`); check('header entry restores previous result pier after another screen changes selection', await ev(`document.querySelector('select[aria-label="수량산출 대상 교각"]').value==='P2'`)); await click(`document.querySelector('[data-takeoff-mode="materials"]')`); await click(`document.getElementById('nav-rebar')`); await click(`document.getElementById('rebar-materials')`); check('rebar shortcut preserves materials tab', await ev(`document.querySelector('[data-takeoff-mode="materials"]').getAttribute('aria-pressed')==='true'`)); await click(`[...document.querySelectorAll('#quantity-pier-items button')].find(b=>b.textContent.startsWith('P1 ·'))`); await click(`document.getElementById('nav-plan')`); check('plan remains reachable after drawing', await ev(`!document.getElementById('view-plan').hidden`)); await ev(`(()=>{const e=document.getElementById('edit-copingLengthMm');e.value=Number(e.value)+100;e.dispatchEvent(new Event('change'));})()`); await click(`document.getElementById('nav-rebar')`); await click(`document.getElementById('rebar-materials')`); check('dirty geometry blocks Excel and print with reason', await ev(`document.getElementById('quantity-export').disabled&&document.getElementById('quantity-print').disabled&&document.getElementById('quantity-body').textContent.includes('재생성 필요')`)); for (const width of [1280, 1600, 1920]) { await send('Emulation.setDeviceMetricsOverride', { width, height: 1000, deviceScaleFactor: 1, mobile: false }); await delay(150); check(`one-row header preserved at ${width}`, await ev(`document.querySelector('.top').getBoundingClientRect().height<=65&&document.documentElement.scrollWidth<=innerWidth`)); } check('no runtime exceptions', report.errors.length === 0); report.result = 'PASS'; })().catch(e => { report.result = 'FAIL'; report.failure = e.stack; process.exitCode = 1; }).finally(() => { fs.writeFileSync(path.join(out, 'report.json'), JSON.stringify(report, null, 2)); console.log(JSON.stringify(report)); ws?.close(); });