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:
1 parent
b2bebe47eb
commit
fc520ec03d
47 files changed
+2848
-149
No files matched your search
@@ -0,0 +1,64 @@
|
||||
// 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 preview=process.argv.includes('--horizontal-preview');
|
||||
const lowerPreview=process.argv.includes('--lower-preview');
|
||||
const out = path.resolve(__dirname, lowerPreview?'../.verify/landing-lower-20261109':process.argv.includes('--all')?'../.verify/landing-hero-20261109':'../.verify/landing-horizontal-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 projectPath=process.env.OKSEONG_PROJECT||path.resolve(__dirname,'../.verify/ground-abutments-20261109/saved.pzr.json');
|
||||
const projectText=fs.readFileSync(projectPath,'utf8');
|
||||
const hash=require('node:crypto').createHash('sha256').update(projectText).digest('hex');
|
||||
await ev(`window.__heroProject=JSON.parse(${JSON.stringify(projectText)})`);
|
||||
const variants=lowerPreview ? [['lower-33',.65,0,30,true,true],['lower-27',.5,0,30,true,true],['lower-19',.35,0,30,true,true],['lower-27-cutaway',.5,0,30,true,true,8]] : preview ? [['horizontal',.85,0,30,true],['horizontal-smooth',.85,0,30,true,true]] : process.argv.includes('--all') ? [['low',.35,0],['balanced',.55,.12],['high',.8,-.12],['trim30',.55,.12,30],['trim20',.55,.12,20],['refined',.85,.12,30,true]] : [['horizontal-smooth',.85,0,30,true,true]];
|
||||
for(const [name,elevation,along,margin,refined,smooth,cutaway] of variants){
|
||||
const result=await ev(`(async()=>{const {renderLandingHero}=await import('/scripts/render-landing-hero.ts');return renderLandingHero(window.__heroProject,${elevation},${along},${margin},${refined},${smooth},${cutaway});})()`);
|
||||
for(const type of ['png','webp'])fs.writeFileSync(path.join(out,name+'.'+type),Buffer.from(result[type].split(',')[1],'base64'));
|
||||
fs.writeFileSync(path.join(out,name+'.json'),JSON.stringify({...result.metadata,sourceSha256:hash},null,2));
|
||||
check(name+' includes six piers and two abutments',result.metadata.piers===6&&result.metadata.abutments===2&&result.metadata.points.length===8);
|
||||
check(name+' all projected markers stay inside image',result.metadata.points.every(p=>p.x>0&&p.x<result.metadata.width&&p.y>0&&p.y<result.metadata.height));
|
||||
check(name+' light samples actual terrain densely',result.metadata.terrainFlow.filter(Boolean).length>100&&result.metadata.terrainFlow.every(p=>!p||Math.abs(p.world[1]-p.el-.12)<.0001));
|
||||
if(refined)check('both road extensions contain continuous terrain-supported sections',result.metadata.roads.length===2&&result.metadata.roads.every(r=>r.missingSections===0&&r.validSections===81&&r.triangles>0));
|
||||
if(smooth){
|
||||
check('outline trims real pixels without filling missing terrain',result.metadata.outline.addedOutsideSourcePixels===0&&result.metadata.outline.retainedTerrainRatio>.8&&result.metadata.outline.retainedTerrainRatio<.99);
|
||||
check('smooth outline preserves all eight structure tops',await ev(`(()=>{const c=document.createElement('canvas');c.width=${result.metadata.width*2};c.height=${result.metadata.height*2};return new Promise(resolve=>{const i=new Image();i.onload=()=>{const ctx=c.getContext('2d');ctx.drawImage(i,0,0);resolve(${JSON.stringify(result.metadata.points)}.every(p=>{let n=0;for(let x=-8;x<=8;x++)for(let y=0;y<30;y++)n+=ctx.getImageData(Math.round(p.x*2+x),Math.round(p.y*2+y),1,1).data[3]>0;return n>15;}));};i.src=${JSON.stringify(result.png)};});})()`));
|
||||
}
|
||||
}
|
||||
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();
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
"""Create a review sheet from native Three.js captures; validate local structure crops."""
|
||||
import json
|
||||
from pathlib import Path
|
||||
from PIL import Image, ImageChops, ImageDraw, ImageFont
|
||||
|
||||
root = Path(__file__).resolve().parents[1]
|
||||
out = root / '.verify/landing-horizontal-20261109'
|
||||
metadata = json.loads((out / 'horizontal-smooth.json').read_text())
|
||||
raw = Image.open(out / 'horizontal.png').convert('RGBA')
|
||||
smooth = Image.open(out / 'horizontal-smooth.png').convert('RGBA')
|
||||
checks = []
|
||||
for point in metadata['points']:
|
||||
x, y = point['x'] * 2, point['y'] * 2
|
||||
box = (int(x - 48), int(y - 8), int(x + 48), int(y + 120))
|
||||
assert ImageChops.difference(raw.crop(box), smooth.crop(box)).getbbox() is None, point['name']
|
||||
checks.append(point['name'] + ' local structure pixels unchanged')
|
||||
assert metadata['along'] == 0
|
||||
assert metadata['outline']['addedOutsideSourcePixels'] == 0
|
||||
checks.extend(['horizontal camera has zero along-axis component', 'no pixels added outside source'])
|
||||
|
||||
sheet = Image.new('RGB', (1600, 1050), '#f5f8fb')
|
||||
draw = ImageDraw.Draw(sheet)
|
||||
font = ImageFont.truetype('/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf', 23)
|
||||
small = ImageFont.truetype('/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf', 17)
|
||||
previous = out / 'before-horizontal/okseong-model-hero.png'
|
||||
panels = [
|
||||
('PREVIOUS / diagonal view, rough survey boundary', previous if previous.exists() else root / '랜딩페이지/assets/okseong-model-hero.png'),
|
||||
('PROPOSAL / horizontal orthographic view, inward curved boundary', out / 'horizontal-smooth.png'),
|
||||
]
|
||||
for i, (label, path) in enumerate(panels):
|
||||
y = 25 + i * 510
|
||||
draw.rounded_rectangle((20, y, 1580, y + 480), radius=20, fill='white', outline='#dce4ed')
|
||||
draw.text((45, y + 20), label, font=font, fill='#19334d')
|
||||
image = Image.open(path).convert('RGBA')
|
||||
image = image.crop(image.getbbox())
|
||||
image.thumbnail((1500, 380), Image.Resampling.LANCZOS)
|
||||
sheet.paste(image, ((1600 - image.width) // 2, y + 80 + (380 - image.height) // 2), image)
|
||||
if i == 1:
|
||||
draw.text((45, y + 445), '6 piers + 2 abutments preserved / original survey unchanged / preview only', font=small, fill='#52667a')
|
||||
sheet.save(out / 'comparison.png')
|
||||
(out / 'review-report.json').write_text(json.dumps({'result': 'PASS', 'checks': checks}, indent=2))
|
||||
print(f'PASS {len(checks)} review checks; {out / "comparison.png"}')
|
||||
@@ -0,0 +1,57 @@
|
||||
import * as THREE from 'three';
|
||||
|
||||
/** Rounded capture footprint; the underlying survey grid remains untouched. */
|
||||
export function roundedTerrainPlanes(axis: THREE.Vector3, start: number, end: number, halfWidth: number, radius: number) {
|
||||
const lateral = new THREE.Vector3(-axis.z, 0, axis.x);
|
||||
const corners = [[end-radius,halfWidth-radius,0],[start+radius,halfWidth-radius,90],[start+radius,-halfWidth+radius,180],[end-radius,-halfWidth+radius,270]];
|
||||
const points: [number,number][]=[];
|
||||
for(const [s,t,angle] of corners) for(let i=0;i<=8;i++) {
|
||||
const a=(angle+i*90/8)*Math.PI/180;
|
||||
points.push([s+radius*Math.cos(a),t+radius*Math.sin(a)]);
|
||||
}
|
||||
return points.map((p,i)=>{
|
||||
const q=points[(i+1)%points.length],ds=q[0]-p[0],dt=q[1]-p[1];
|
||||
const normal=axis.clone().multiplyScalar(-dt).addScaledVector(lateral,ds).normalize();
|
||||
const origin=axis.clone().multiplyScalar(p[0]).addScaledVector(lateral,p[1]);
|
||||
return new THREE.Plane(normal,-normal.dot(origin));
|
||||
});
|
||||
}
|
||||
|
||||
/** Landing-only road ribbons, joining the photographed road to ground at each abutment. */
|
||||
export function buildRoadExtensions(terrain: THREE.Mesh, anchors: {name:string;kind:string;point:THREE.Vector3}[], axis: THREE.Vector3, top: number) {
|
||||
const piers=anchors.filter(a=>a.kind==='pier').toSorted((a,b)=>a.point.dot(axis)-b.point.dot(axis));
|
||||
const abuts=anchors.filter(a=>a.kind==='abutment').toSorted((a,b)=>a.point.dot(axis)-b.point.dot(axis));
|
||||
if(piers.length<2||abuts.length!==2)throw new Error('Road extensions require two abutments and at least two piers');
|
||||
const group=new THREE.Group();group.name='landing-road-extensions';
|
||||
const ray=new THREE.Raycaster(),down=new THREE.Vector3(0,-1,0),widthM=8;
|
||||
const sample=(x:number,z:number)=>{
|
||||
ray.set(new THREE.Vector3(x,top+100,z),down);
|
||||
return ray.intersectObject(terrain,false)[0]?.point;
|
||||
};
|
||||
const descriptions=[];
|
||||
const ends=[{name:'A1–P1',points:[abuts[0].point,piers[0].point,piers[1].point],from:0,to:.58},
|
||||
{name:'P6–A2',points:[piers.at(-2)!.point,piers.at(-1)!.point,abuts[1].point],from:.42,to:1}];
|
||||
for(const end of ends){
|
||||
const curve=new THREE.CatmullRomCurve3(end.points.map(p=>new THREE.Vector3(p.x,0,p.z)),false,'catmullrom',.2);
|
||||
const steps=80,positions:number[]=[],colors:number[]=[],indices:number[]=[];
|
||||
let previous=-1,validSections=0,missingSections=0;
|
||||
for(let i=0;i<=steps;i++){
|
||||
const t=i/steps,u=end.from+(end.to-end.from)*t,p=curve.getPoint(u),tangent=curve.getTangent(u).normalize();
|
||||
const cross=new THREE.Vector3(-tangent.z,0,tangent.x);
|
||||
const offsets=Array.from({length:17},(_,j)=>-widthM/2+widthM*j/16);
|
||||
const hits=offsets.map(o=>sample(p.x+cross.x*o,p.z+cross.z*o));
|
||||
if(hits.some(h=>!h)){previous=-1;missingSections++;continue;}
|
||||
const first=positions.length/3;
|
||||
const blend=end.from===0?1-THREE.MathUtils.smoothstep(t,.72,1):THREE.MathUtils.smoothstep(t,0,.28);
|
||||
const color=new THREE.Color('#dcd8c7').multiplyScalar(1+Math.sin(i*2.33)*.018);
|
||||
hits.forEach((hit,j)=>{positions.push(hit!.x,hit!.y+.15,hit!.z);colors.push(color.r,color.g,color.b,blend*(j===0||j===offsets.length-1?0:.96));});
|
||||
if(previous>=0)for(let j=0;j<offsets.length-1;j++)indices.push(previous+j,first+j,previous+j+1,previous+j+1,first+j,first+j+1);
|
||||
previous=first;validSections++;
|
||||
}
|
||||
const geometry=new THREE.BufferGeometry();geometry.setAttribute('position',new THREE.Float32BufferAttribute(positions,3));geometry.setAttribute('color',new THREE.Float32BufferAttribute(colors,4));geometry.setIndex(indices);geometry.computeVertexNormals();
|
||||
const mesh=new THREE.Mesh(geometry,new THREE.MeshBasicMaterial({vertexColors:true,side:THREE.DoubleSide,transparent:true,depthWrite:false,polygonOffset:true,polygonOffsetFactor:-2,polygonOffsetUnits:-2}));
|
||||
mesh.name=end.name;mesh.renderOrder=2;group.add(mesh);
|
||||
descriptions.push({name:end.name,widthM,validSections,missingSections,triangles:indices.length/3,visualizationOnly:true});
|
||||
}
|
||||
return {group,descriptions};
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
/** Capture-only silhouette trimming. Samples real alpha; never fills missing survey data. */
|
||||
export function smoothLandingOutline(source: HTMLCanvasElement, terrain: HTMLCanvasElement, preserveExposedStructures = false) {
|
||||
const width=source.width,height=source.height,scale=width/2172;
|
||||
const alpha=terrain.getContext('2d')!.getImageData(0,0,width,height).data;
|
||||
const tops:number[]=[],bottoms:number[]=[];
|
||||
for(let x=0;x<width;x++) {
|
||||
let top=height,bottom=-1;
|
||||
for(let y=0;y<height;y++)if(alpha[(y*width+x)*4+3]>240){top=Math.min(top,y);bottom=y;}
|
||||
tops.push(top);bottoms.push(bottom);
|
||||
}
|
||||
const support=tops.map((top,x)=>bottoms[x]-top>60*scale?x:-1).filter(x=>x>=0);
|
||||
if(!support.length)throw new Error('No continuous terrain silhouette');
|
||||
const left=support[0]+Math.ceil(12*scale),right=support.at(-1)!-Math.ceil(12*scale);
|
||||
const inset=Math.ceil(4*scale),radius=Math.ceil(18*scale),average=Math.ceil(8*scale);
|
||||
const eroded=(values:number[],upper:boolean)=>values.map((_,x)=>{
|
||||
const local=values.slice(Math.max(0,x-radius-average),Math.min(width,x+radius+average+1));
|
||||
return (upper?Math.max(...local):Math.min(...local))+(upper?inset:-inset);
|
||||
});
|
||||
const upper=eroded(tops,true),lower=eroded(bottoms,false);
|
||||
const mean=(values:number[],x:number)=>{
|
||||
const samples=values.slice(Math.max(0,x-average),Math.min(width,x+average+1));
|
||||
return samples.reduce((a,b)=>a+b,0)/samples.length;
|
||||
};
|
||||
const points:[number,number][]=[];
|
||||
const cap=Math.ceil(24*scale),step=Math.ceil(12*scale);
|
||||
for(let x=left+cap;x<=right-cap;x+=step)points.push([x,mean(upper,x)]);
|
||||
points.push([right-cap,mean(upper,right-cap)],[right,mean(upper,right-cap)+cap],[right,mean(lower,right-cap)-cap],[right-cap,mean(lower,right-cap)]);
|
||||
for(let x=right-cap-step;x>=left+cap;x-=step)points.push([x,mean(lower,x)]);
|
||||
points.push([left+cap,mean(lower,left+cap)],[left,mean(lower,left+cap)-cap],[left,mean(upper,left+cap)+cap],[left+cap,mean(upper,left+cap)]);
|
||||
const mid=(a:[number,number],b:[number,number])=>[(a[0]+b[0])/2,(a[1]+b[1])/2];
|
||||
const first=mid(points.at(-1)!,points[0]);
|
||||
let path=`M${first[0]} ${first[1]}`;
|
||||
points.forEach((p,i)=>{const next=mid(p,points[(i+1)%points.length]);path+=`Q${p[0]} ${p[1]} ${next[0]} ${next[1]}`;});path+='Z';
|
||||
const canvas=document.createElement('canvas');canvas.width=width;canvas.height=height;
|
||||
const ctx=canvas.getContext('2d')!;ctx.drawImage(source,0,0);
|
||||
const original=ctx.getImageData(0,0,width,height).data;
|
||||
ctx.globalCompositeOperation='destination-in';ctx.fill(new Path2D(path));
|
||||
const outputImage=ctx.getImageData(0,0,width,height),output=outputImage.data;
|
||||
// A cutaway may expose piles beyond the terrain silhouette. Preserve their rendered pixels.
|
||||
if(preserveExposedStructures){
|
||||
for(let i=0;i<alpha.length;i+=4)if(alpha[i+3]===0&&original[i+3]>0)output.set(original.subarray(i,i+4),i);
|
||||
ctx.putImageData(outputImage,0,0);
|
||||
}
|
||||
// Compare to terrain alpha only: smoothing should remove its rough border, not invent pixels.
|
||||
let before=0,after=0,addedPixels=0;
|
||||
for(let i=3;i<alpha.length;i+=4){if(alpha[i]>240){before++;if(output[i]>240)after++;}if(original[i]===0&&output[i]>0)addedPixels++;}
|
||||
return {canvas,metadata:{method:'inward-alpha-envelope-quadratic',path,retainedTerrainRatio:after/before,addedOutsideSourcePixels:addedPixels}};
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
"""Promote one rendered camera variant; preserve the previous landing image."""
|
||||
import json
|
||||
import shutil
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
root = Path(__file__).resolve().parents[1]
|
||||
assets = root / '랜딩페이지/assets'
|
||||
variant = sys.argv[1] if len(sys.argv) > 1 else 'horizontal-smooth'
|
||||
if variant not in ('balanced', 'low', 'high', 'trim30', 'trim20', 'refined', 'horizontal-smooth'):
|
||||
raise SystemExit('Unknown camera variant')
|
||||
source = root / ('.verify/landing-horizontal-20261109' if variant == 'horizontal-smooth' else '.verify/landing-hero-20261109')
|
||||
metadata = json.loads((source / f'{variant}.json').read_text())
|
||||
if variant == 'horizontal-smooth':
|
||||
assert metadata['along'] == 0 and metadata['outline']['addedOutsideSourcePixels'] == 0
|
||||
assert metadata['piers'] == 6 and metadata['abutments'] == 2
|
||||
# Full curve control points remain in the capture audit, outside the service bundle.
|
||||
metadata['outline'].pop('path')
|
||||
backup = source / ('before-horizontal' if variant == 'horizontal-smooth' else 'before-refined' if variant == 'refined' else 'before-trim30')
|
||||
backup.mkdir(exist_ok=True)
|
||||
for name in ('okseong-model-hero.png', 'okseong-model-hero.webp', 'okseong-hero-capture.ts'):
|
||||
if (assets / name).exists() and not (backup / name).exists():
|
||||
shutil.copyfile(assets / name, backup / name)
|
||||
for kind in ('png', 'webp'):
|
||||
shutil.copyfile(source / f'{variant}.{kind}', assets / f'okseong-model-hero.{kind}')
|
||||
metadata['terrainFlow'] = [None if p is None else {'x': p['x'], 'y': p['y']} for p in metadata['terrainFlow']]
|
||||
(assets / 'okseong-hero-capture.ts').write_text(
|
||||
'// Generated by scripts/promote-landing-hero.py from actual model projection.\n'
|
||||
'export const heroCapture = ' + json.dumps(metadata, ensure_ascii=False, indent=2) + ' as const;\n'
|
||||
)
|
||||
@@ -0,0 +1,123 @@
|
||||
import * as THREE from 'three';
|
||||
import type { ProjectFile } from '../src/core/project';
|
||||
import { buildPier } from '../src/core/geometry';
|
||||
import { buildAllPiers } from '../src/view/all-piers';
|
||||
import { buildTerrainMapGroup, disposeTerrainMapGroup } from '../src/view/terrain-map';
|
||||
import { buildAbut } from '../src/core/abut/geometry';
|
||||
import { toAbutInput } from '../src/core/abut/parse';
|
||||
import { buildMembers } from '../src/core/abut/members';
|
||||
import { buildWings } from '../src/core/abut/wings';
|
||||
import { buildAbutGroup } from '../src/view/abut-scene';
|
||||
import { abutSoles, resolveBearingRule } from '../src/core/abut/bearing-table';
|
||||
import { bridgePairs } from '../src/core/bridge-pairs';
|
||||
import { buildRoadExtensions, roundedTerrainPlanes } from './landing-ground-treatment';
|
||||
import { smoothLandingOutline } from './landing-smooth-outline';
|
||||
|
||||
/** Actual structure/terrain capture with optional landing-only road extensions. */
|
||||
export async function renderLandingHero(project: ProjectFile, elevation: number, along = 0, endMarginM?: number, refined = false, smoothOutline = false, cutawayFrontM?: number) {
|
||||
const width = 2172, height = refined ? 870 : 724, aspect=width/height;
|
||||
const renderer = new THREE.WebGLRenderer({ alpha: true, antialias: true, preserveDrawingBuffer: true });
|
||||
renderer.setPixelRatio(2); renderer.setSize(width, height); renderer.setClearColor(0xffffff, 0);
|
||||
renderer.localClippingEnabled = true;
|
||||
const scene = new THREE.Scene();
|
||||
scene.add(new THREE.HemisphereLight(0xffffff, 0x7c8a9a, 2.1));
|
||||
const sun = new THREE.DirectionalLight(0xffffff, 2.4); sun.position.set(-100, 250, 150); scene.add(sun);
|
||||
const pairs=bridgePairs(project.piers.map(p=>p.input),project.abutments??[]);
|
||||
const entries = project.piers.map(p => ({ name: p.input.name, model: buildPier(p.input,{pairs}) }));
|
||||
const pivot = entries[0].model.pivot, grid = project.groundModel?.terrainGrid;
|
||||
const all = buildAllPiers(entries, grid); scene.add(all.piers); if (all.terrain) scene.add(all.terrain);
|
||||
const anchors = entries.map(e => ({ name: e.name, kind: 'pier', point: new THREE.Vector3(e.model.pivot.e - pivot.e, e.model.copingRefTop + 1, -(e.model.pivot.n - pivot.n)) }));
|
||||
for (const rec of project.abutments ?? []) {
|
||||
const model = buildAbut(toAbutInput(rec));
|
||||
const members = buildMembers(model, { bottomEl: rec.foundationBottomEl, pileLengthMm: rec.pileLengthMm }, rec.shape);
|
||||
const soles = abutSoles(model, resolveBearingRule(rec.bearingRule), rec.bearingHeightMm, {
|
||||
pairs,slopeI:rec.slopeI,
|
||||
inputAzimuthDeg: Object.fromEntries(rec.bearings.filter(b => b.azimuthDeg !== undefined).map(b => [String(b.no), b.azimuthDeg!])),
|
||||
});
|
||||
scene.add(buildAbutGroup(model, members, buildWings(model, members, rec.wings, rec.wingDims), pivot, rec.groundEl, grid, rec.slab, soles, undefined, !!all.terrain));
|
||||
anchors.push({ name: rec.name, kind: 'abutment', point: new THREE.Vector3(rec.ip2.e-pivot.e, Math.max(members.parapetTopStartEl, members.parapetTopEndEl)+1, -(rec.ip2.n-pivot.n)) });
|
||||
}
|
||||
const layers = project.groundModel?.mapLayers ?? [];
|
||||
await Promise.all(layers.map(l => new Promise<void>((resolve, reject) => { const i = new Image(); i.onload = () => resolve(); i.onerror = reject; i.src = l.image; })));
|
||||
let mapError = false;
|
||||
const maps = buildTerrainMapGroup(layers, pivot, 0, 'satellite', () => { mapError = true; }, grid); scene.add(maps);
|
||||
const deadline=performance.now()+15000;
|
||||
while (!maps.children.every(m => {
|
||||
const image=((m as THREE.Mesh).material as THREE.MeshBasicMaterial).map!.image as HTMLImageElement|null;
|
||||
return image?.complete && image.naturalWidth>0;
|
||||
})) {
|
||||
if(mapError || performance.now()>deadline) throw new Error('Map texture loading failed or timed out');
|
||||
await new Promise(resolve=>setTimeout(resolve,20));
|
||||
}
|
||||
if (mapError) throw new Error('Map texture loading failed');
|
||||
scene.updateMatrixWorld(true);
|
||||
const box = new THREE.Box3().setFromObject(scene), target = box.getCenter(new THREE.Vector3());
|
||||
const axis = new THREE.Vector3(entries.at(-1)!.model.pivot.e-pivot.e, 0, -(entries.at(-1)!.model.pivot.n-pivot.n)).normalize();
|
||||
if (endMarginM !== undefined) {
|
||||
const ends=anchors.filter(a=>a.kind==='abutment').map(a=>a.point.dot(axis));
|
||||
if(ends.length!==2)throw new Error('End margin requires both abutments');
|
||||
const planes=refined ? roundedTerrainPlanes(axis,Math.min(...ends)-endMarginM,Math.max(...ends)+endMarginM,56,18) : [new THREE.Plane(axis.clone(),-Math.min(...ends)+endMarginM),new THREE.Plane(axis.clone().negate(),Math.max(...ends)+endMarginM)];
|
||||
if(all.terrain)(all.terrain.material as THREE.Material).clippingPlanes=planes;
|
||||
maps.children.forEach(m=>{((m as THREE.Mesh).material as THREE.Material).clippingPlanes=planes;});
|
||||
}
|
||||
const road=refined&&all.terrain ? buildRoadExtensions(all.terrain,anchors,axis,box.max.y) : null;
|
||||
if(road)scene.add(road.group);
|
||||
if(cutawayFrontM!==undefined){
|
||||
const front=new THREE.Vector3(-axis.z,0,axis.x);
|
||||
const plane=new THREE.Plane(front.negate(),cutawayFrontM);
|
||||
for(const mesh of [all.terrain,...maps.children])if(mesh){
|
||||
const material=(mesh as THREE.Mesh).material as THREE.Material;
|
||||
material.clippingPlanes=[...(material.clippingPlanes??[]),plane];
|
||||
}
|
||||
}
|
||||
scene.updateMatrixWorld(true);
|
||||
const direction = new THREE.Vector3(-axis.z, elevation, axis.x).addScaledVector(axis, along).normalize();
|
||||
const camera = new THREE.OrthographicCamera(-1, 1, 1, -1, .1, 10000);
|
||||
camera.position.copy(target).addScaledVector(direction, 2000); camera.lookAt(target); camera.updateMatrixWorld(true);
|
||||
const bounds = new THREE.Box3();
|
||||
scene.traverse(obj => {
|
||||
if (!(obj instanceof THREE.Mesh)) return;
|
||||
for (let p: THREE.Object3D | null = obj; p; p=p.parent) if (!p.visible) return;
|
||||
const pos = obj.geometry.getAttribute('position');
|
||||
for (let i=0;i<pos.count;i++) {
|
||||
const point=new THREE.Vector3().fromBufferAttribute(pos,i).applyMatrix4(obj.matrixWorld);
|
||||
const material: THREE.Material=Array.isArray(obj.material)?obj.material[0]:obj.material;
|
||||
if(material.clippingPlanes?.some(p=>p.distanceToPoint(point)<0))continue;
|
||||
bounds.expandByPoint(point.applyMatrix4(camera.matrixWorldInverse));
|
||||
}
|
||||
});
|
||||
const w = Math.max(bounds.max.x-bounds.min.x, (bounds.max.y-bounds.min.y)*aspect)*1.12, h=w/aspect;
|
||||
const cx=(bounds.max.x+bounds.min.x)/2, cy=(bounds.max.y+bounds.min.y)/2;
|
||||
camera.left=cx-w/2; camera.right=cx+w/2; camera.top=cy+h/2; camera.bottom=cy-h/2; camera.updateProjectionMatrix();
|
||||
let terrainCanvas:HTMLCanvasElement|undefined;
|
||||
if(smoothOutline){
|
||||
const hidden=scene.children.filter(o=>o!==all.terrain&&o!==maps&&o!==road?.group&&!(o instanceof THREE.Light)&&o.visible);
|
||||
hidden.forEach(o=>{o.visible=false;});renderer.render(scene,camera);
|
||||
terrainCanvas=document.createElement('canvas');terrainCanvas.width=renderer.domElement.width;terrainCanvas.height=renderer.domElement.height;
|
||||
terrainCanvas.getContext('2d')!.drawImage(renderer.domElement,0,0);
|
||||
hidden.forEach(o=>{o.visible=true;});
|
||||
}
|
||||
renderer.render(scene, camera);
|
||||
const points=anchors.map(a => { const p=a.point.clone().project(camera); return { name:a.name,kind:a.kind,x:+((p.x+1)*width/2).toFixed(2),y:+((1-p.y)*height/2).toFixed(2) }; }).sort((a,b)=>a.x-b.x);
|
||||
const ordered=anchors.toSorted((a,b)=>a.point.clone().project(camera).x-b.point.clone().project(camera).x);
|
||||
const ray=new THREE.Raycaster(), down=new THREE.Vector3(0,-1,0), spacing=(grid?.step??5)/2;
|
||||
const terrainFlow: ({x:number;y:number;el:number;world:[number,number,number]}|null)[]=[];
|
||||
if(!all.terrain)throw new Error('Terrain mesh is required for surface-following light');
|
||||
for(let i=0;i<ordered.length-1;i++){
|
||||
const from=ordered[i].point,to=ordered[i+1].point;
|
||||
const steps=Math.ceil(Math.hypot(to.x-from.x,to.z-from.z)/spacing);
|
||||
for(let j=i===0?0:1;j<=steps;j++){
|
||||
const p=from.clone().lerp(to,j/steps);p.y=box.max.y+100;
|
||||
ray.set(p,down);const hit=ray.intersectObject(all.terrain,false)[0];
|
||||
if(!hit){terrainFlow.push(null);continue;}
|
||||
const surface=hit.point.clone();surface.y+=.12;const projected=surface.clone().project(camera);
|
||||
terrainFlow.push({x:+((projected.x+1)*width/2).toFixed(3),y:+((1-projected.y)*height/2).toFixed(3),el:+hit.point.y.toFixed(5),world:surface.toArray()});
|
||||
}
|
||||
}
|
||||
const smooth=terrainCanvas?smoothLandingOutline(renderer.domElement,terrainCanvas,cutawayFrontM!==undefined):undefined;
|
||||
const output=smooth?.canvas??renderer.domElement;
|
||||
const result={png:output.toDataURL('image/png'),webp:output.toDataURL('image/webp',.94),metadata:{width,height,elevation,along,...(cutawayFrontM!==undefined?{cutawayFrontM}:{}),...(smooth?{outline:smooth.metadata}:{}),...(endMarginM!==undefined?{endMarginM}:{}),...(refined?{cornerRadiusM:18,terrainHalfWidthM:56,roads:road?.descriptions}:{}),points,terrainFlow,camera:{position:camera.position.toArray(),target:target.toArray(),left:camera.left,right:camera.right,top:camera.top,bottom:camera.bottom},piers:entries.length,abutments:project.abutments?.length??0}};
|
||||
disposeTerrainMapGroup(maps);
|
||||
scene.traverse(o=>{if(o instanceof THREE.Mesh){o.geometry.dispose();for(const m of Array.isArray(o.material)?o.material:[o.material])m.dispose();}});
|
||||
renderer.dispose();renderer.forceContextLoss();return result;
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { readFileSync, mkdirSync, writeFileSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import * as XLSX from 'xlsx';
|
||||
import * as THREE from 'three';
|
||||
import { it, expect } from 'vitest';
|
||||
import { parseGroundModel } from '../src/core/ground-model';
|
||||
import { parseWorkbook } from '../src/core/parseWorkbook';
|
||||
import { buildPier } from '../src/core/geometry';
|
||||
import { parseAbutments, toAbutInput } from '../src/core/abut/parse';
|
||||
import { buildAbut } from '../src/core/abut/geometry';
|
||||
import { buildMembers } from '../src/core/abut/members';
|
||||
import { buildWings } from '../src/core/abut/wings';
|
||||
import { buildAbutGroup } from '../src/view/abut-scene';
|
||||
import { terrainGeometry } from '../src/view/terrain';
|
||||
import { terrainMapGeometry } from '../src/view/terrain-map';
|
||||
|
||||
it('records terrain and map placement against the actual Okseong project', () => {
|
||||
const folder = '/mnt/c/Users/User/Downloads/NAVER WORKS';
|
||||
const ground = parseGroundModel(readFileSync(path.join(folder, '옥성교 지형.json'), 'utf8'));
|
||||
const wb = XLSX.read(readFileSync(path.join(folder, '옥성교 데이터_261106.xlsx')), { type: 'buffer' });
|
||||
const piers = parseWorkbook(wb).piers;
|
||||
const abuts = parseAbutments(wb, piers.map(p => p.name)).abutments;
|
||||
const pivot = buildPier(piers[0]).pivot, grid = ground.terrainGrid!;
|
||||
const terrain = terrainGeometry(grid, pivot), pos = terrain.getAttribute('position');
|
||||
const layers = ground.mapLayers!.map(layer => {
|
||||
const g = terrainMapGeometry(layer, pivot, 0, grid);
|
||||
return { id: layer.id, corners: layer.survey_corners, vertices: g.getAttribute('position').count, triangles: g.getIndex()!.count / 3 };
|
||||
});
|
||||
const duplicates = abuts.map(rec => {
|
||||
const model = buildAbut(toAbutInput(rec));
|
||||
const members = buildMembers(model, { bottomEl: rec.foundationBottomEl, pileLengthMm: rec.pileLengthMm }, rec.shape);
|
||||
const root = buildAbutGroup(model, members, buildWings(model, members, rec.wings, rec.wingDims), pivot, rec.groundEl, grid, rec.slab);
|
||||
const shared = buildAbutGroup(model, members, buildWings(model, members, rec.wings, rec.wingDims), pivot, rec.groundEl, grid, rec.slab, undefined, undefined, true);
|
||||
expect(shared.getObjectByName('ground')).toBeUndefined();
|
||||
root.updateMatrixWorld(true);
|
||||
const meshes: object[] = [];
|
||||
root.getObjectByName('ground')?.traverse(obj => {
|
||||
if (!(obj instanceof THREE.Mesh)) return;
|
||||
const vertices = obj.geometry.getAttribute('position');
|
||||
let maxDisplacement = 0;
|
||||
if (vertices.count === pos.count) for (let i = 0; i < vertices.count; i++) {
|
||||
const actual = new THREE.Vector3().fromBufferAttribute(vertices, i).applyMatrix4(obj.matrixWorld);
|
||||
const expected = new THREE.Vector3().fromBufferAttribute(pos, i);
|
||||
maxDisplacement = Math.max(maxDisplacement, actual.distanceTo(expected));
|
||||
}
|
||||
meshes.push({ vertices: vertices.count, triangles: obj.geometry.getIndex()?.count! / 3, sameGrid: vertices.count === pos.count, maxDisplacementM: maxDisplacement });
|
||||
});
|
||||
const gx = (rec.ip2.n - grid.origin[0]) / grid.step, gy = (rec.ip2.e - grid.origin[1]) / grid.step;
|
||||
const i = Math.floor(gx), j = Math.floor(gy);
|
||||
const inside = i >= 0 && j >= 0 && i + 1 < grid.nx && j + 1 < grid.ny;
|
||||
const heights = inside ? [grid.z[j * grid.nx + i], grid.z[j * grid.nx + i + 1], grid.z[(j + 1) * grid.nx + i], grid.z[(j + 1) * grid.nx + i + 1]] : [];
|
||||
const valid = heights.length === 4 && heights.every(h => h !== null);
|
||||
const u = gx - i, v = gy - j;
|
||||
const sampledAtIp2 = valid ? heights[0]! * (1-u) * (1-v) + heights[1]! * u * (1-v) + heights[2]! * (1-u) * v + heights[3]! * u * v : null;
|
||||
return { abutment: rec.name, excelGroundEl: rec.groundEl, ip2: rec.ip2, modelEntry: ground.piers.some(p => p.id === rec.name), ip2GridSample: sampledAtIp2, meshes };
|
||||
});
|
||||
const values = grid.z.filter((v): v is number => v !== null);
|
||||
const report = { grid: { origin: grid.origin, step: grid.step, nx: grid.nx, ny: grid.ny, valid: values.length, total: grid.z.length, min: Math.min(...values), max: Math.max(...values) }, terrain: { vertices: pos.count, triangles: terrain.getIndex()!.count / 3 }, layers, duplicates };
|
||||
const out = path.resolve('.verify/terrain-review-20261109'); mkdirSync(out, { recursive: true });
|
||||
writeFileSync(path.join(out, 'report.json'), JSON.stringify(report, null, 2));
|
||||
console.log(JSON.stringify(report));
|
||||
expect(layers.every(layer => layer.triangles === report.terrain.triangles)).toBe(true);
|
||||
expect(duplicates.every(d => d.meshes.every(m => (m as { maxDisplacementM: number }).maxDisplacementM < 0.001))).toBe(true);
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
"""Apply the project ground manager integration to the existing application."""
|
||||
from pathlib import Path
|
||||
|
||||
root = Path(__file__).resolve().parents[1]
|
||||
p = root / 'src/main.ts'
|
||||
s = p.read_text()
|
||||
s = "import { createProjectGroundManager } from './view/project-ground';\nimport { applyProjectGround, groundInputChanged } from './core/project-ground';\nimport { registerGroundImporter } from '../pierzainer_ground/ground-link';\n" + s
|
||||
s = s.replace('let groundModel: GroundModelFile | null = null;', 'let groundModel: GroundModelFile | null = null;\nlet groundProjectVersion = 0;\nlet groundImportSerial = 0;')
|
||||
marker = 'function el<K extends keyof HTMLElementTagNameMap>'
|
||||
insert = '''async function importProjectGround(text: string, name: string): Promise<void> {
|
||||
const version = groundProjectVersion, serial = ++groundImportSerial;
|
||||
const imported = parseGroundModel(text);
|
||||
await Promise.all((imported.mapLayers ?? []).map(decodeGroundMapImage));
|
||||
if (version !== groundProjectVersion || serial !== groundImportSerial) throw new Error('프로젝트 또는 불러올 모델이 변경되어 이전 결과를 적용하지 않았습니다.');
|
||||
groundModel = imported;
|
||||
groundMapMode = imported.mapLayers?.length ? 'satellite' : 'none';
|
||||
setGroundMapMode(groundMapMode);
|
||||
state.visible.ground = true;
|
||||
setupScene(); renderAll();
|
||||
setStatus(`${name} 지반모델을 프로젝트 공통으로 불러왔습니다. 설계 입력은 비교 후 적용하세요.`);
|
||||
}
|
||||
registerGroundImporter(importProjectGround);
|
||||
const projectGround = createProjectGroundManager({
|
||||
snapshot: () => ({ model: groundModel, piers: state.piers }),
|
||||
importModel: importProjectGround,
|
||||
apply: (names, fields) => {
|
||||
if (!groundModel) return;
|
||||
for (const p of state.piers) if (names.has(p.input.name)) p.draft = applyProjectGround(p.draft, groundModel, fields);
|
||||
renderAll();
|
||||
},
|
||||
regenerate: () => {
|
||||
state.piers = state.piers.map(p => groundInputChanged(p.input, p.draft) ? regenerateEntry(p) : p);
|
||||
setupScene(); renderAll();
|
||||
setStatus('지반 입력이 변경된 교각을 다시 생성했습니다. 생성 상태와 알림을 확인하세요.');
|
||||
},
|
||||
});
|
||||
function resetProjectGround(): void {
|
||||
groundProjectVersion++; groundImportSerial++;
|
||||
projectGround.reset();
|
||||
window.dispatchEvent(new Event('pierzainer:project-changed'));
|
||||
}
|
||||
|
||||
'''
|
||||
assert marker in s
|
||||
s = s.replace(marker, insert + marker, 1)
|
||||
s = s.replace(' updatePartToggles();\n $<HTMLButtonElement>', ' updatePartToggles();\n projectGround.refresh();\n $<HTMLButtonElement>', 1)
|
||||
start = s.index(' // ② 지반 모델 (승인 2026-10-23):')
|
||||
end = s.index(' if (!groundModel) {', start)
|
||||
s = s[:start] + " wrap.appendChild(el('p', '프로젝트 공통 지반모델은 ① 형상의 [프로젝트 지반모델]에서 생성·불러오기·선택 적용합니다.', 'muted small'));\n\n" + s[end:]
|
||||
s = s.replace(' * The ground model import and the 지반고 apply stay at the bottom, as before.', ' * Project model generation/import is managed from ①; this card retains local inputs.')
|
||||
s = s.replace(' state.project = { ...parsed.project };', ' resetProjectGround();\n state.project = { ...parsed.project };', 1)
|
||||
marker = " state.project = { ...state.project, name, bridge: $<HTMLInputElement>('bridge-name').value.trim() };"
|
||||
assert marker in s
|
||||
s = s.replace(marker, " resetProjectGround();\n groundModel = null;\n groundMapMode = 'none';\n setGroundMapMode(groundMapMode);\n" + marker, 1)
|
||||
p.write_text(s)
|
||||
@@ -0,0 +1,95 @@
|
||||
// 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-abutments-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 selectFile=async(selector,files)=>{
|
||||
const doc=await send('DOM.getDocument'),{nodeId}=await send('DOM.querySelector',{nodeId:doc.root.nodeId,selector});
|
||||
await send('DOM.setFileInputFiles',{nodeId,files});
|
||||
};
|
||||
const folder='C:\\Users\\User\\Downloads\\NAVER WORKS';
|
||||
await ev(`window.openSampleProject()`);
|
||||
await selectFile('#file',[path.join(folder,'옥성교 데이터_261106.xlsx')]);
|
||||
await until(`document.getElementById('file-name').textContent.includes('선택된 파일: 옥성교 데이터_261106.xlsx')`);
|
||||
await ev(`(()=>{const e=document.getElementById('project-name');e.value='교대 지반 검증';e.dispatchEvent(new Event('input'));document.getElementById('import-dialog').showModal();})()`);
|
||||
await click(`document.getElementById('dlg-apply')`);
|
||||
await click(`document.getElementById('nav-shape')`);
|
||||
await click(`document.getElementById('project-ground-open')`);
|
||||
check('manager lists six piers and both abutments before model import',await ev(`document.querySelectorAll('[data-ground-pier]').length===8&&document.querySelectorAll('[data-ground-kind="교대"]').length===2`));
|
||||
await selectFile('#ground-import',[path.join(folder,'옥성교 지형.json')]);
|
||||
await until(`document.getElementById('project-ground-status').textContent.includes('8/8')`);
|
||||
check('both abutments show IP2 grid source and finite comparison elevations',await ev(`[...document.querySelectorAll('[data-ground-kind="교대"]')].every(e=>e.closest('tr').textContent.includes('IP2 격자 보간')&&!e.disabled)`));
|
||||
const values=()=>ev(`[...document.querySelectorAll('.project-ground-table tbody tr')].map(r=>({name:r.children[1].textContent,el:Number(r.children[2].textContent),model:Number(r.children[3].textContent)}))`);
|
||||
const before=await values();
|
||||
check('import alone preserves original abutment inputs',before.find(r=>r.name==='A1').el===11.24&&before.find(r=>r.name==='A2').el===14.43);
|
||||
await click(`document.querySelector('[data-ground-pier="A1"]')`);
|
||||
await click(`document.getElementById('project-ground-apply')`);
|
||||
const one=await values();
|
||||
check('only selected A1 changes; A2 and P1 remain unchanged',one.find(r=>r.name==='A1').el===11.789&&one.find(r=>r.name==='A2').el===14.43&&one.find(r=>r.name==='P1').el===before.find(r=>r.name==='P1').el);
|
||||
await click(`document.getElementById('project-ground-select-all')`);
|
||||
check('select all includes both types',await ev(`document.querySelectorAll('[data-ground-pier]:checked').length===8`));
|
||||
await click(`document.getElementById('project-ground-apply')`);
|
||||
const applied=await values();
|
||||
check('A2 receives IP2 interpolation',applied.find(r=>r.name==='A2').el===14.61);
|
||||
check('pier regeneration remains explicit',await ev(`!document.getElementById('project-ground-regenerate').disabled`));
|
||||
await click(`document.getElementById('project-ground-regenerate')`);
|
||||
check('pending pier changes regenerate',await ev(`document.getElementById('project-ground-regenerate').disabled`));
|
||||
await click(`document.getElementById('project-ground-el')`);
|
||||
await click(`document.getElementById('project-ground-layers')`);
|
||||
check('missing abutment soil layers cannot be applied',await ev(`[...document.querySelectorAll('[data-ground-kind="교대"]')].every(e=>e.disabled)`));
|
||||
await click(`document.getElementById('project-ground-el')`);
|
||||
await click(`document.getElementById('project-ground-layers')`);
|
||||
await shot('01-unified-ground');
|
||||
await click(`document.querySelector('#project-ground-dialog .viewer-head button')`);
|
||||
await shot('02-shared-terrain');
|
||||
await ev(`(()=>{const original=URL.createObjectURL;window.__saved=null;URL.createObjectURL=function(blob){blob.text().then(text=>{try{window.__saved=JSON.parse(text);}catch{}});return original.call(this,blob);};})()`);
|
||||
await send('Browser.setDownloadBehavior',{behavior:'deny'});
|
||||
await click(`document.getElementById('save-project')`);
|
||||
await until(`document.getElementById('save-dialog').open`);
|
||||
await click(`document.getElementById('sv-save')`);
|
||||
await until(`!!window.__saved?.abutments`);
|
||||
const saved=await ev(`window.__saved`);
|
||||
check('saved project retains both abutment elevations and Excel source',Math.abs(saved.abutments.find(a=>a.name==='A1').groundEl-11.7886759049651)<1e-6&&Math.abs(saved.abutments.find(a=>a.name==='A2').groundEl-14.610314099767017)<1e-6&&saved.source.fileName==='옥성교 데이터_261106.xlsx');
|
||||
const savedPath=path.join(out,'saved.pzr.json');fs.writeFileSync(savedPath,JSON.stringify(saved));
|
||||
await selectFile('#project-file',[savedPath]);
|
||||
await click(`document.getElementById('project-ground-open')`);
|
||||
check('reopening project restores abutment inputs', (await values()).filter(r=>r.name==='A1'||r.name==='A2').every(r=>Math.abs(r.el-r.model)<0.001));
|
||||
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();
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
// 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/landing-horizontal-20261109/applied-browser');
|
||||
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'`);
|
||||
await until(`document.querySelector('.landing-promo-image')?.complete&&document.querySelector('.landing-promo-image').naturalWidth>0`);
|
||||
await delay(1800);
|
||||
check('landing uses the new high-resolution WebP',await ev(`document.querySelector('.landing-promo-image').src.includes('okseong-model-hero.webp')&&document.querySelector('.landing-promo-image').naturalWidth===4344`));
|
||||
check('eight projected markers include both abutments',await ev(`document.querySelectorAll('.landing-pier-beacon').length===8&&!!document.querySelector('[data-structure="A1"]')&&!!document.querySelector('[data-structure="A2"]')`));
|
||||
check('30m end margin is active',await ev(`(async()=>{const {heroCapture}=await import('/'+encodeURIComponent('랜딩페이지')+'/assets/okseong-hero-capture.ts');return heroCapture.endMarginM===30;})()`));
|
||||
check('taller frame and rounded terrain are active',await ev(`(async()=>{const {heroCapture}=await import('/'+encodeURIComponent('랜딩페이지')+'/assets/okseong-hero-capture.ts');return heroCapture.height===870&&heroCapture.cornerRadiusM===18&&document.querySelector('.landing-promo-image').naturalHeight===1740;})()`));
|
||||
check('horizontal view and smooth inward outline are applied',await ev(`(async()=>{const {heroCapture}=await import('/'+encodeURIComponent('랜딩페이지')+'/assets/okseong-hero-capture.ts');return heroCapture.along===0&&heroCapture.outline.method==='inward-alpha-envelope-quadratic'&&heroCapture.outline.addedOutsideSourcePixels===0&&heroCapture.outline.retainedTerrainRatio>.9;})()`));
|
||||
const expectedHash=require('node:crypto').createHash('sha256').update(fs.readFileSync(path.resolve(__dirname,'../.verify/landing-horizontal-20261109/horizontal-smooth.webp'))).digest('hex');
|
||||
check('served landing image matches the reviewed horizontal capture',await ev(`(async()=>{const bytes=await(await fetch(document.querySelector('.landing-promo-image').src)).arrayBuffer();const hash=[...new Uint8Array(await crypto.subtle.digest('SHA-256',bytes))].map(n=>n.toString(16).padStart(2,'0')).join('');return hash===${JSON.stringify(expectedHash)};})()`));
|
||||
check('both road extensions follow valid terrain sections',await ev(`(async()=>{const {heroCapture}=await import('/'+encodeURIComponent('랜딩페이지')+'/assets/okseong-hero-capture.ts');return heroCapture.roads.length===2&&heroCapture.roads.every(r=>r.missingSections===0&&r.validSections===81&&r.visualizationOnly);})()`));
|
||||
check('decorative frame includes both abutments',await ev(`(()=>{const box=document.querySelector('.landing-hud-bracket').getBBox(),a=document.querySelector('[data-structure="A1"]').transform.baseVal.getItem(0).matrix,b=document.querySelector('[data-structure="A2"]').transform.baseVal.getItem(0).matrix;return box.x<a.e&&box.x+box.width>b.e;})()`));
|
||||
check('restored light follows projected structures from left to right',await ev(`(()=>{const path=document.getElementById('landing-alignment-path'),first=path.getPointAtLength(0),last=path.getPointAtLength(path.getTotalLength()),a=document.querySelector('[data-structure="A1"]').transform.baseVal.getItem(0).matrix,b=document.querySelector('[data-structure="A2"]').transform.baseVal.getItem(0).matrix;return first.x<last.x&&Math.abs(first.x-a.e)<.1&&Math.abs(last.x-b.e)<.1;})()`));
|
||||
check('light follows dense terrain samples rather than coping elevations',await ev(`(async()=>{const {heroCapture}=await import('/'+encodeURIComponent('랜딩페이지')+'/assets/okseong-hero-capture.ts');const path=document.getElementById('landing-alignment-path'),samples=heroCapture.terrainFlow.filter(Boolean),first=path.getPointAtLength(0),a=document.querySelector('[data-structure="A1"]').transform.baseVal.getItem(0).matrix;return samples.length>100&&(path.getAttribute('d').match(/L/g)||[]).length>100&&Math.abs(first.y-samples[0].y)<.1&&Math.abs(first.y-a.f)>10;})()`));
|
||||
check('vertical scan, moving grid, and rising sparks are removed',await ev(`!document.querySelector('.landing-scan-mask,.landing-scan-beam,.landing-terrain-grid,.landing-pier-spark')&&![...document.querySelectorAll('.landing-hero *')].some(e=>e.getAnimations().some(a=>['landing-scan-pass','landing-beacon-spark','landing-grid-breathe'].includes(a.animationName)))`));
|
||||
check('traveling light is white',await ev(`[...document.querySelectorAll('#landing-flow-fade stop')].every(e=>getComputedStyle(e).stopColor==='rgb(255, 255, 255)')&&getComputedStyle(document.querySelector('.landing-energy-track')).stroke==='rgb(255, 255, 255)'`));
|
||||
const offset=async time=>ev(`(()=>{const e=document.querySelector('.landing-energy-flow'),a=e.getAnimations()[0];if(!a)throw new Error('Missing flow animation');a.pause();a.currentTime=${time};return parseFloat(getComputedStyle(e).strokeDashoffset);})()`);
|
||||
const left=await offset(3500),right=await offset(5000);
|
||||
check('light actually advances along the path over time',right<left);
|
||||
await shot('light-moving');
|
||||
await ev(`document.querySelector('.landing-energy-flow').getAnimations()[0].play()`);
|
||||
check('asset has transparent background and opaque terrain',await ev(`(()=>{const c=document.createElement('canvas');c.width=100;c.height=34;const ctx=c.getContext('2d');ctx.drawImage(document.querySelector('.landing-promo-image'),0,0,100,34);const data=ctx.getImageData(0,0,100,34).data;let lo=255,hi=0;for(let i=3;i<data.length;i+=4){lo=Math.min(lo,data[i]);hi=Math.max(hi,data[i]);}return lo===0&&hi===255;})()`));
|
||||
await send('Emulation.setEmulatedMedia',{features:[{name:'prefers-reduced-motion',value:'reduce'}]});
|
||||
for(const [width,height] of [[1920,1080],[1600,900],[1280,800],[390,844]]){
|
||||
await send('Emulation.setDeviceMetricsOverride',{width,height,deviceScaleFactor:1,mobile:width<760});
|
||||
await delay(200);
|
||||
await shot(`${width}-landing`);
|
||||
report.layout=await ev(`[...document.querySelectorAll('.landing-cards .landing-primary')].map(e=>({text:e.textContent,rect:e.getBoundingClientRect().toJSON()}))`);
|
||||
check(`no horizontal overflow at ${width}`,await ev(`document.documentElement.scrollWidth<=innerWidth`));
|
||||
check(`all entry actions remain visible at ${width}`,await ev(`[...document.querySelectorAll('.landing-cards .landing-primary')].every(e=>{const r=e.getBoundingClientRect(),hit=document.elementFromPoint(r.x+r.width/2,r.y+r.height/2);return r.width>0&&r.height>0&&r.left>=0&&r.right<=innerWidth+1&&r.top>=0&&r.bottom<=innerHeight&&!!hit&&(hit===e||e.contains(hit));})`));
|
||||
check(`image and projected markers align at ${width}`,await ev(`(()=>{const img=document.querySelector('.landing-promo-image').getBoundingClientRect(),svg=document.querySelector('.landing-model-motion'),view=svg.viewBox.baseVal;return [...document.querySelectorAll('.landing-pier-beacon')].every(g=>{const t=g.transform.baseVal.getItem(0).matrix,p=svg.createSVGPoint();p.x=t.e;p.y=t.f;const actual=p.matrixTransform(svg.getScreenCTM());return Math.abs(actual.x-(img.left+t.e/view.width*img.width))<2&&Math.abs(actual.y-(img.top+t.f/view.height*img.height))<2;});})()`));
|
||||
check(`first-visit guide does not cover structures at ${width}`,await ev(`(()=>{const hint=document.querySelector('.landing-guide-hint');if(hint.hidden)return true;const r=hint.getBoundingClientRect(),svg=document.querySelector('.landing-model-motion');return [...document.querySelectorAll('.landing-pier-beacon')].every(g=>{const m=g.transform.baseVal.getItem(0).matrix,p=svg.createSVGPoint();p.x=m.e;p.y=m.f;const q=p.matrixTransform(svg.getScreenCTM());return q.x<r.left||q.x>r.right||q.y<r.top||q.y>r.bottom;});})()`));
|
||||
await shot(`${width}-landing`);
|
||||
}
|
||||
check('reduced motion stops beacon animation',await ev(`getComputedStyle(document.querySelector('.landing-pier-pulse')).animationName==='none'`));
|
||||
check('reduced motion stops light travel',await ev(`getComputedStyle(document.querySelector('.landing-energy-flow')).animationName==='none'`));
|
||||
await send('Emulation.setDeviceMetricsOverride',{width:1600,height:1000,deviceScaleFactor:1,mobile:false});
|
||||
await click(`document.querySelector('[data-card="sample"] .landing-primary')`);
|
||||
check('design library still opens',await ev(`document.querySelector('.sample-library-popup').open`));
|
||||
await click(`document.querySelector('.sample-library-close')`);
|
||||
await click(`document.querySelector('[data-card="excel"] .landing-primary')`);
|
||||
check('Excel entry still opens',await ev(`document.getElementById('import-dialog').open`));
|
||||
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();
|
||||
});
|
||||
@@ -0,0 +1,200 @@
|
||||
// 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();
|
||||
});
|
||||
@@ -0,0 +1,138 @@
|
||||
// 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/project-ground-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==='모델 없음'`));
|
||||
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();
|
||||
});
|
||||
@@ -0,0 +1,97 @@
|
||||
// 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-generation-20261106');
|
||||
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: '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('기존 철근재료표')`));
|
||||
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 opens materials rather than takeoff', await ev(`!document.getElementById('view-quantity').hidden&&document.querySelector('[data-takeoff-mode="materials"]').getAttribute('aria-pressed')==='true'`));
|
||||
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();
|
||||
});
|
||||
@@ -0,0 +1,109 @@
|
||||
// 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();
|
||||
});
|
||||
@@ -0,0 +1,120 @@
|
||||
// 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/takeoff-alignment-20261106/browser');
|
||||
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('Browser.setDownloadBehavior', {behavior:'deny'}); // Request transport is mocked below; native Excel is verified separately.
|
||||
await send('Emulation.setDeviceMetricsOverride', { width: 1600, height: 1000, deviceScaleFactor: 1, mobile: false });
|
||||
await send('Page.navigate', { 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:[]};
|
||||
})()`);
|
||||
for(const pier of fixture.piers){
|
||||
const settings={values:{r20:0,r21:2.8,r22:0,r23:0},reviewed:true};
|
||||
for(const key of ['input','defaults','draft'])pier[key]={...pier[key],quantityTakeoff:settings};
|
||||
}
|
||||
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('기존 철근재료표')`));
|
||||
check('selected and all Excel exports are separate', await ev(`document.querySelector('[data-takeoff-export="selected"]').textContent==='선택 교각 Excel'&&document.querySelector('[data-takeoff-export="all"]').textContent==='전체 교각 Excel'`));
|
||||
await click(`document.querySelector('button[aria-label="수량산출 재계산"]')`);
|
||||
await ev(`(()=>{
|
||||
const original=window.fetch;window.__takeoffRequests=[];
|
||||
window.fetch=(url,options)=>String(url).includes('/__pierzainer/quantity/export')?new Promise(resolve=>{window.__takeoffRequests.push(JSON.parse(options.body));window.__finishTakeoff=()=>resolve(new Response(new Uint8Array([80,75]),{status:200}));}):original(url,options);
|
||||
})()`);
|
||||
await click(`document.querySelector('[data-takeoff-export="selected"]')`);
|
||||
await until(`window.__takeoffRequests.length===1`);
|
||||
check('selected request carries one pier and model solplate mode', await ev(`window.__takeoffRequests[0].piers.length===1&&window.__takeoffRequests[0].piers[0].pier==='P1'&&window.__takeoffRequests[0].piers[0].solplateMode==='model'`));
|
||||
await ev(`(()=>{const e=document.querySelector('input[data-variable="r21"]');e.value='4';e.dispatchEvent(new Event('change'));})()`);
|
||||
check('rerender during export keeps both export buttons locked', await ev(`[...document.querySelectorAll('[data-takeoff-export]')].every(b=>b.disabled)`));
|
||||
await ev(`window.__finishTakeoff()`);
|
||||
await until(`!document.querySelector('[data-takeoff-export="selected"]').disabled`);
|
||||
check('async export preserves edited input and displays snapshot warning', await ev(`document.querySelector('input[data-variable="r21"]').value==='4'&&document.querySelector('.takeoff-workspace [role="status"]').textContent.includes('입력은 보존')`));
|
||||
const requests=await ev(`window.__takeoffRequests.length`);
|
||||
await click(`document.querySelector('[data-takeoff-export="all"]')`);
|
||||
await delay(200);
|
||||
check('all export validates other incomplete piers before requesting server', await ev(`window.__takeoffRequests.length===${requests}&&document.querySelector('.takeoff-workspace [role="status"]').textContent.includes('P3')`));
|
||||
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('기존 철근재료표')`));
|
||||
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 opens materials rather than takeoff', await ev(`!document.getElementById('view-quantity').hidden&&document.querySelector('[data-takeoff-mode="materials"]').getAttribute('aria-pressed')==='true'`));
|
||||
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();
|
||||
});
|
||||
Reference in new issue
Block a user