feat: 프로젝트 공통 지반모델·교대 지반 통합·재료표/수량 진입·옥성교 랜딩 히어로 (11-06~11-09 작업분)

공용 파일(main.ts·index.html·style.css)에서 서로 얽혀 있어 한 커밋으로 묶음.
- 지반: ① 형상 프로젝트 지반모델 관리창(생성·JSON 불러오기·교각별 비교·선택 적용),
  프로젝트 JSON 내장 groundModel 추출, 교대 8행 통합 적용·공통지형 중복격자 제외
- 재료표·수량: 상단 ⑤ 메뉴·배근 바로가기, 결과 교각/탭 기억, 수량 워크북·정합 보정
- 랜딩: 옥성교 6교각 히어로 이미지·투영 강조점·좌→우 광선·지형 라운드/도로 연결
- 검증 스크립트(verify-project-ground/ground-abutments/okseong-user-files/quantity-*/landing-hero 등)
정본: DOC/04_실행/실행_*_2026-11-09_v0.1.00.md

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
This commit is contained in:
ypshimandClaude Haiku 5.5 committed 2026-11-09 18:01:02 +09:00
1 parent b2bebe47eb
commit fc520ec03d
47 files changed
+2848 -149

No files matched your search

+168
View File
@@ -0,0 +1,168 @@
// 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/ground-import-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');
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();
});