// 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/okseong-user-files-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`); const selectFile = async (selector, files) => { const doc = await send('DOM.getDocument'); const {nodeId} = await send('DOM.querySelector', {nodeId:doc.root.nodeId, selector}); await send('DOM.setFileInputFiles', {nodeId, files}); }; await click(`document.getElementById('nav-shape')`); check('shape has a visible project ground entry', await ev(`document.getElementById('project-ground-open').getBoundingClientRect().height>0`)); await click(`document.getElementById('project-ground-open')`); check('manager opens without per-pier controls', await ev(`document.getElementById('project-ground-dialog').open&&document.querySelectorAll('#ground-import').length===1`)); const modelFile=path.join(out,'ground.json'); const raw={schema:2,generated:'2026-11-09',piers:[{id:'P1',ground:{center:29,excel:30},soil:{layers:[{name:'암반',bottom_el:10}]}},{id:'P2',ground:{center:null,excel:30}}]}; fs.writeFileSync(modelFile,JSON.stringify(raw)); await selectFile('#ground-import',[modelFile]); await until(`document.getElementById('project-ground-info').textContent.includes('2026-11-09')`); check('matching, out-of-range and missing piers are distinguished',await ev(`document.getElementById('project-ground-dialog').textContent.includes('지형 범위 밖')&&document.getElementById('project-ground-dialog').textContent.includes('모델에 없음')&&document.querySelector('[data-ground-pier="P2"]').disabled&&document.querySelector('[data-ground-pier="P3"]').disabled`)); check('import alone keeps current design input',await ev(`document.querySelector('.project-ground-table tbody tr').children[2].textContent==='30.000'`)); await click(`document.querySelector('[data-ground-pier="P1"]')`); await click(`document.getElementById('project-ground-apply')`); check('selected ground input applies and requires regeneration',await ev(`document.querySelector('.project-ground-table tbody tr').children[2].textContent==='29.000'&&!document.getElementById('project-ground-regenerate').disabled`)); await click(`document.getElementById('project-ground-regenerate')`); check('regeneration consumes ground changes',await ev(`document.getElementById('project-ground-regenerate').disabled&&document.querySelector('.project-ground-table tbody tr').textContent.includes('지반고 일치')`)); await click(`document.getElementById('project-ground-el')`); await click(`document.getElementById('project-ground-layers')`); await click(`document.getElementById('project-ground-apply')`); check('layers can be applied separately',await ev(`!document.getElementById('project-ground-regenerate').disabled&&document.querySelector('.project-ground-table tbody tr').children[2].textContent==='29.000'`)); await click(`document.getElementById('project-ground-regenerate')`); await shot('01-project-ground'); await click(`document.querySelector('#project-ground-dialog .viewer-head button')`); await click(`document.getElementById('nav-plan')`); await click(`[...document.querySelectorAll('#members button')].find(b=>b.textContent.includes('지반'))`); check('per-pier ground card has no model generation or file picker',await ev(`!document.getElementById('view-plan').textContent.includes('지반 모델 파일 불러오기 · 지반 생성 프로그램')&&document.querySelectorAll('#view-plan input[type=file]').length===0`)); await ev(`(()=>{window.__savedGround=null;const original=URL.createObjectURL;URL.createObjectURL=function(blob){blob.text().then(text=>{try{window.__savedGround=JSON.parse(text);}catch{}});return original.call(this,blob);};})()`); await click(`document.getElementById('save-project')`); await until(`!!window.__savedGround?.groundModel`); const saved=await ev(`window.__savedGround`); check('project saves shared model once and applied inputs',saved.groundModel.piers.length===2&&saved.piers[0].input.groundEl===29&&saved.piers[0].input.strataRows[0].name==='암반'&&saved.piers[1].input.groundEl===30); const savedFile=path.join(out,'saved.pzr.json');fs.writeFileSync(savedFile,JSON.stringify(saved)); await selectFile('#project-file',[savedFile]); await until(`document.getElementById('project-ground-status').textContent.includes('불러옴')`); check('project reopen restores ground model',await ev(`document.getElementById('project-ground-status').textContent.includes('2/3')`)); // Mock the external engine; deliver an in-flight result after switching projects. await ev(`(()=>{const original=window.fetch;window.__releaseGround=null;window.__groundReady=false;window.fetch=async(url,init)=>{const path=String(url);if(path.includes('/ground-link/prepare'))return new Response(JSON.stringify({drawing:'test.dxf',excelName:'test.xlsx',excelSelected:true}));if(path.includes('/ground-link/upload'))return new Response(JSON.stringify({file:'test.xlsx'}));if(path.includes('/ground-link/launch'))return new Response(JSON.stringify({id:'mock'}));if(path.includes('/ground-link/result')){window.__groundReady=true;return new Promise(resolve=>window.__releaseGround=()=>resolve(new Response(JSON.stringify({ready:true,state:'done',filename:'old.json',text:${JSON.stringify(JSON.stringify({...raw,generated:'mock-generated'}))}}))));}return original(url,init);};})()`); const excel=path.join(out,'source.xlsx');fs.writeFileSync(excel,'mock'); const startGeneration = async () => { await ev(`window.__groundReady=false`); await click(`document.getElementById('nav-shape')`); await click(`document.getElementById('project-ground-open')`); await click(`[...document.querySelectorAll('#project-ground-dialog button')].find(b=>b.textContent==='지반모델 생성')`); await until(`document.querySelector('.ground-popup [data-run]')&&!document.querySelector('.ground-popup [data-run]').disabled`); await selectFile('#ground-excel-file',[excel]); await click(`document.querySelector('.ground-popup [data-run]')`); await until(`window.__groundReady`); }; await startGeneration(); await click(`document.querySelector('.ground-popup [data-close]')`); await click(`document.querySelector('#project-ground-dialog .viewer-head button')`); await click(`document.getElementById('nav-rebar')`); await ev(`window.__releaseGround()`); await until(`document.querySelector('.ground-popup [data-status]').textContent.includes('생성 완료')`); await click(`document.getElementById('nav-shape')`); await click(`document.getElementById('project-ground-open')`); check('generated model imports after leaving both manager and ground card',await ev(`document.getElementById('project-ground-info').textContent.includes('mock-generated')`)); await click(`document.querySelector('#project-ground-dialog .viewer-head button')`); await startGeneration(); await selectFile('#project-file',[file]); await until(`document.getElementById('project-ground-status').textContent==='모델 없음'`); await ev(`window.__releaseGround()`); await delay(300); check('late generated result does not contaminate another project',await ev(`document.getElementById('project-ground-status').textContent==='모델 없음'`)); check('project switch closes manager and generator',await ev(`!document.getElementById('project-ground-dialog').open&&!document.querySelector('.ground-popup').open`)); await selectFile('#project-file',[savedFile]); await until(`document.getElementById('project-ground-status').textContent.includes('불러옴')`); await ev(`(async()=>{const bytes=await(await fetch('/'+encodeURIComponent('도면추출')+'/samples/'+encodeURIComponent('운곡교P1입력.xlsx'))).arrayBuffer();window.loadBytesToApp(new Uint8Array(bytes),'새 프로젝트.xlsx');const name=document.getElementById('project-name');name.value='새 프로젝트';name.dispatchEvent(new Event('input'));document.getElementById('import-dialog').showModal();})()`); await until(`document.getElementById('import-dialog').open&&!document.getElementById('dlg-apply').disabled`); await click(`document.getElementById('dlg-apply')`); await until(`document.getElementById('project-ground-status').textContent==='모델 없음'`); check('new Excel import clears previous project ground model',await ev(`document.getElementById('project-ground-status').textContent==='모델 없음'`)); // Real generated JSON and the adjacent settings file reproduce the reported failure. const actualFolder=path.resolve(__dirname,'../pierzainer_ground/data/output/drawing_01_옥성교_종평면도'); const actualModel=path.join(actualFolder,'ground_model.json'); const expected=JSON.parse(fs.readFileSync(actualModel,'utf8')); const six={...fixture,piers:Array.from({length:6},(_,i)=>{ const input={...fixture.piers[0].input,name:'P'+(i+1)}; return{id:input.name,input,defaults:input,draft:input}; })}; const sixFile=path.join(out,'six-piers.pzr.json');fs.writeFileSync(sixFile,JSON.stringify(six)); await selectFile('#project-file',[sixFile]); await until(`document.querySelectorAll('#pier-items button').length===6`); await click(`document.getElementById('nav-shape')`); await click(`document.getElementById('project-ground-open')`); await selectFile('#ground-import',[path.join(actualFolder,'generation_settings.json')]); await until(`document.getElementById('project-ground-dialog').textContent.includes('지반 생성 설정 파일')`); check('settings file reports correct result filenames and blocks application',await ev(`document.getElementById('project-ground-apply').disabled&&document.getElementById('project-ground-status').textContent==='모델 없음'&&document.getElementById('project-ground-dialog').textContent.includes('ground_model_3d_with_maps.json')`)); await selectFile('#ground-import',[actualModel]); await until(`document.getElementById('project-ground-status').textContent.includes('6/6')`); check('real Okseong JSON imports all six piers',await ev(`document.querySelectorAll('[data-ground-pier]:not(:disabled)').length===6`)); await click(`[...document.querySelectorAll('#project-ground-dialog button')].find(b=>b.textContent==='적용 가능한 교각 선택')`); check('select all enables application',await ev(`document.querySelectorAll('[data-ground-pier]:checked').length===6&&!document.getElementById('project-ground-apply').disabled`)); await click(`document.getElementById('project-ground-apply')`); const applied=await ev(`[...document.querySelectorAll('.project-ground-table tbody tr')].map(row=>Number(row.children[2].textContent))`); check('all six design elevations match real JSON',applied.every((el,i)=>el===expected.piers[i].ground.center)); await click(`document.getElementById('project-ground-regenerate')`); check('all six applied elevations regenerate',await ev(`document.getElementById('project-ground-regenerate').disabled`)); await selectFile('#ground-import',[path.join(actualFolder,'generation_settings.json')]); await until(`document.getElementById('project-ground-dialog').textContent.includes('지반 생성 설정 파일')`); check('invalid replacement preserves the loaded project ground model',await ev(`document.getElementById('project-ground-status').textContent.includes('6/6')`)); await shot('02-real-okseong'); await click(`document.querySelector('#project-ground-dialog .viewer-head button')`); const userFolder=process.env.OKSEONG_FILES||'C:\\Users\\User\\Downloads\\NAVER WORKS'; const userExcel=path.join(userFolder,'옥성교 데이터_261106.xlsx'),userJson=path.join(userFolder,'옥성교 지형.json'); await selectFile('#file',[userExcel]); await until(`document.getElementById('file-name').textContent.includes('선택된 파일: 옥성교 데이터_261106.xlsx')&&!document.getElementById('dlg-apply').disabled`); await ev(`document.getElementById('import-dialog').showModal()`); await click(`document.getElementById('dlg-apply')`); await until(`document.getElementById('project-ground-status').textContent==='모델 없음'`); const before=await ev(`[...document.querySelectorAll('#inputs-all tbody tr')].map(row=>row.textContent)`); const projectBefore=await ev(`document.getElementById('project-line').textContent`); await click(`document.getElementById('nav-shape')`); await click(`document.getElementById('project-ground-open')`); await selectFile('#ground-import',[userJson]); await until(`document.getElementById('project-ground-status').textContent.includes('6/6')`); check('user JSON imports six elevations plus terrain and two maps',await ev(`document.getElementById('project-ground-info').textContent.includes('지도 2개')&&document.getElementById('project-ground-info').textContent.includes('지형 있음')`)); check('importing embedded model preserves user Excel inputs and project',await ev(`JSON.stringify([...document.querySelectorAll('#inputs-all tbody tr')].map(row=>row.textContent))`)===JSON.stringify(before)&&await ev(`document.getElementById('project-line').textContent`)===projectBefore); await click(`[...document.querySelectorAll('#project-ground-dialog button')].find(b=>b.textContent==='적용 가능한 교각 선택')`); await click(`document.getElementById('project-ground-apply')`); const userExpected=JSON.parse(fs.readFileSync(userJson,'utf8')).groundModel; const userApplied=await ev(`[...document.querySelectorAll('.project-ground-table tbody tr')].map(row=>Number(row.children[2].textContent))`); check('user Excel accepts all six elevations from embedded ground model',userApplied.length===6&&userApplied.every((el,i)=>el===userExpected.piers[i].centerEl)); await click(`document.getElementById('project-ground-regenerate')`); check('user project regenerates after ground application',await ev(`document.getElementById('project-ground-regenerate').disabled`)); await shot('03-user-files-applied'); await click(`document.querySelector('#project-ground-dialog .viewer-head button')`); await send('Browser.setDownloadBehavior',{behavior:'deny'}); await ev(`window.__savedGround=null`); await click(`document.getElementById('save-project')`); await until(`document.getElementById('save-dialog').open`); await click(`document.getElementById('sv-save')`); await until(`window.__savedGround?.source?.fileName==='옥성교 데이터_261106.xlsx'`); check('saved result retains user Excel source and applied embedded terrain',await ev(`window.__savedGround.piers.length===6&&window.__savedGround.piers[0].input.groundEl===18.011&&window.__savedGround.groundModel.mapLayers.length===2`)); 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(); });