// Run with Windows Node and an isolated Chrome on CDP 9444; Vite must be on 5173. // ②·③ center workspace: stacked ↔ side-by-side, splitter drag/keyboard, persistence, width fallback. const fs = require('node:fs'), path = require('node:path'), assert = require('node:assert/strict'); const root = path.resolve(__dirname, '..'); const out = path.resolve(root, '.verify/workspace-layout-20261109'); fs.mkdirSync(out, { recursive: true }); const report = { checks: [], errors: [], metrics: {} }; let ws, send; const delay = ms => new Promise(r => setTimeout(r, ms)); let target; (async () => { 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 === 'Page.javascriptDialogOpening') ws.send(JSON.stringify({ id: 900000 + (++seq), method: 'Page.handleJavaScriptDialog', params: { accept: true } })); 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)), 8000); 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, tries = 120) => { for (let i = 0; i < tries; i++) { try { if (await ev(expression)) return; } catch { /* navigating */ } 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 => { await delay(250); const r = await send('Page.captureScreenshot', { format: 'png' }); fs.writeFileSync(path.join(out, name + '.png'), Buffer.from(r.data, 'base64')); }; const viewport = (width, height) => send('Emulation.setDeviceMetricsOverride', { width, height, deviceScaleFactor: 1, mobile: false }); const rects = ws_ => ev(`(()=>{const w=document.getElementById('${ws_}'),r=e=>{const b=e.getBoundingClientRect();return{l:Math.round(b.left),t:Math.round(b.top),r:Math.round(b.right),b:Math.round(b.bottom),w:Math.round(b.width),h:Math.round(b.height)};};const c=document.querySelector('#viewer-block canvas');return{ws:r(w),slot:r(w.querySelector('.viewer-slot')),split:r(w.querySelector('.workspace-splitter')),pane:r(w.querySelector('.workspace-pane')),canvas:c?r(c):null,side:w.classList.contains('layout-side'),note:w.querySelector('.layout-note').textContent,label:w.querySelector('[data-size-label]').textContent,value:w.querySelector('output').textContent};})()`); const drag = async (sel, dx, dy) => { const b = await ev(`(()=>{const r=document.querySelector('${sel}').getBoundingClientRect();return{x:r.left+r.width/2,y:r.top+r.height/2};})()`); await send('Input.dispatchMouseEvent', { type: 'mousePressed', x: b.x, y: b.y, button: 'left', buttons: 1, clickCount: 1 }); for (let i = 1; i <= 5; i++) await send('Input.dispatchMouseEvent', { type: 'mouseMoved', x: b.x + dx * i / 5, y: b.y + dy * i / 5, button: 'left', buttons: 1 }); await send('Input.dispatchMouseEvent', { type: 'mouseReleased', x: b.x + dx, y: b.y + dy, button: 'left', buttons: 0, clickCount: 1 }); await delay(250); }; const key = async (k, code, vk) => { for (const type of ['keyDown', 'keyUp']) await send('Input.dispatchKeyEvent', { type, key: k, code, windowsVirtualKeyCode: vk }); await delay(120); }; await send('Page.enable'); await send('Runtime.enable'); await send('DOM.enable'); await viewport(1600, 1000); await send('Page.navigate', { url: process.env.PIERZAINER_URL || 'http://127.0.0.1:5173/' }); await until(`!!document.querySelector('[data-card="excel"] button') && typeof window.loadBytesToApp === 'function'`); await ev(`Object.keys(localStorage).filter(k=>k.startsWith('pierzainer.workspace-layout')||k==='pierzainer.review-layout').forEach(k=>localStorage.removeItem(k))`); await send('Page.reload'); await delay(2500); await until(`!!document.querySelector('[data-card="excel"] button') && typeof window.loadBytesToApp === 'function'`); await click(`document.querySelector('[data-card="excel"] button')`); await ev(`document.getElementById('project-name').value='배치 검증';document.getElementById('project-name').dispatchEvent(new Event('input'));`); const doc = await send('DOM.getDocument'); const node = await send('DOM.querySelector', { nodeId: doc.root.nodeId, selector: '#file' }); await send('DOM.setFileInputFiles', { nodeId: node.nodeId, files: [path.resolve(root, '../../RAG/운곡교P1입력.xlsx')] }); await until(`!document.getElementById('dlg-apply').disabled`); await click(`document.getElementById('dlg-apply')`); await delay(800); // ② 계획 await click(`document.getElementById('nav-plan')`); await delay(600); let r = await rects('plan-workspace'); check('② default is stacked: model above the input pane', !r.side && r.slot.b <= r.pane.t && r.slot.w === r.pane.w); check('② layout switch shows 상하 pressed', await ev(`document.querySelector('#plan-workspace [data-layout="stack"]').getAttribute('aria-pressed')==='true'`)); await shot('01-plan-stack'); await click(`document.querySelector('#plan-workspace [data-layout="side"]')`); await delay(500); r = await rects('plan-workspace'); report.metrics.planSide = r; check('② 좌우: model left, pane right, same top', r.side && r.slot.r <= r.pane.l && Math.abs(r.slot.t - r.pane.t) <= 1); check('② 좌우 starts near 50 % and shows width label', Math.abs(r.slot.w / r.ws.w - 0.5) < 0.03 && r.label === '모델 폭' && r.value === '50%'); check('② 3D canvas follows the narrower slot', r.canvas && r.slot.w - r.canvas.w >= 0 && r.slot.w - r.canvas.w <= 40 && r.canvas.h > 300); check('② pane scrolls independently', await ev(`(()=>{const p=document.querySelector('#plan-workspace .workspace-pane');return getComputedStyle(p).overflowY==='auto';})()`)); await shot('02-plan-side'); await drag('#plan-workspace .workspace-splitter', -Math.round(r.ws.w * 0.1), 0); r = await rects('plan-workspace'); check('② splitter drag narrows model to about 40 %', r.value === '40%' && Math.abs(r.slot.w / r.ws.w - 0.4) < 0.03); await ev(`document.querySelector('#plan-workspace .workspace-splitter').focus()`); await key('ArrowRight', 'ArrowRight', 39); check('② splitter keyboard step (+2 %)', (await rects('plan-workspace')).value === '42%' && await ev(`document.querySelector('#plan-workspace .workspace-splitter').getAttribute('aria-valuenow')==='42'`)); await send('Input.dispatchMouseEvent', { type: 'mousePressed', x: r.split.l + 6, y: r.split.t + 100, button: 'left', clickCount: 2 }); await send('Input.dispatchMouseEvent', { type: 'mouseReleased', x: r.split.l + 6, y: r.split.t + 100, button: 'left', clickCount: 2 }); await ev(`document.querySelector('#plan-workspace .workspace-splitter').dispatchEvent(new MouseEvent('dblclick',{bubbles:true}))`); await delay(200); check('② double-click resets to 50 %', (await rects('plan-workspace')).value === '50%'); await click(`document.getElementById('plan-model-toggle')`); await delay(300); r = await rects('plan-workspace'); check('② collapsed model: pane takes the full width', r.slot.w === 0 && Math.abs(r.pane.w - r.ws.w) <= 2); await click(`document.getElementById('plan-model-toggle')`); await delay(300); // ③ 배근 stays independent await click(`document.getElementById('nav-rebar')`); await delay(700); r = await rects('review-workspace'); check('③ preference is independent of ② (still stacked, 240 px)', !r.side && r.value === '240 px' && r.slot.b <= r.pane.t); await drag('#review-workspace .workspace-splitter', 0, 100); r = await rects('review-workspace'); check('③ stacked splitter drag grows model height', r.value === '340 px' && Math.abs(r.slot.h - 340) <= 2); await click(`document.querySelector('#review-workspace [data-layout="side"]')`); await delay(500); r = await rects('review-workspace'); report.metrics.rebarSide = r; check('③ 좌우: model left, rebar table right', r.side && r.slot.r <= r.pane.l && r.canvas && r.slot.w - r.canvas.w >= 0 && r.slot.w - r.canvas.w <= 40); await click(`document.getElementById('review-table')`); await delay(300); check('③ 철근표 shortcut still reveals the table in 좌우', await ev(`(()=>{const t=document.activeElement,p=document.querySelector('#review-workspace .workspace-pane > section').getBoundingClientRect();return t.matches('#rebar-table table.rebar-table')&&t.getBoundingClientRect().top>=p.top-2;})()`)); await shot('03-rebar-side'); // persistence await send('Page.reload'); await delay(2500); await until(`typeof window.loadBytesToApp==='function'`); await delay(800); check('preferences persist per stage after reload', await ev(`(()=>{const p=JSON.parse(localStorage.getItem('pierzainer.workspace-layout.plan')),q=JSON.parse(localStorage.getItem('pierzainer.workspace-layout.rebar'));return p.mode==='side'&&p.width===50&&q.mode==='side'&&q.height===340;})()`)); await until(`typeof window.openSampleProject==='function'`); await ev('window.openSampleProject()'); await delay(1500); await click(`document.getElementById('nav-plan')`); await delay(600); check('② restored 좌우 when shown after reload', await ev(`document.getElementById('plan-workspace').classList.contains('layout-side')`)); await click(`document.getElementById('nav-rebar')`); await delay(600); check('③ restored 좌우 and 340 px height after reload', await ev(`document.getElementById('review-workspace').classList.contains('layout-side')&&getComputedStyle(document.getElementById('review-workspace')).getPropertyValue('--review-model-height').trim()==='340px'`)); await click(`document.getElementById('nav-plan')`); await delay(400); // width fallback await viewport(1280, 800); await delay(500); check('1280 px: side request falls back to stacked with a note', await ev(`(()=>{const w=document.getElementById('plan-workspace');return !w.classList.contains('layout-side')&&w.querySelector('.layout-note').textContent.includes('좁아')&&w.querySelector('[data-layout="side"]').getAttribute('aria-pressed')==='true';})()`)); await viewport(1920, 1080); await delay(500); check('1920 px: side-by-side restored automatically', await ev(`document.getElementById('plan-workspace').classList.contains('layout-side')`)); await viewport(1000, 900); await delay(500); check('1000 px (mobile frame): stacked, splitter hidden', await ev(`(()=>{const w=document.getElementById('plan-workspace');return !w.classList.contains('layout-side')&&getComputedStyle(w.querySelector('.workspace-splitter')).display==='none';})()`)); for (const width of [1280, 1600, 1920]) { await viewport(width, 1000); await delay(300); check(`${width} px: no horizontal page scroll`, await ev(`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({ result: report.result, failure: report.failure, errors: report.errors })); if (target?.id) fetch('http://127.0.0.1:9444/json/close/' + target.id).catch(() => {}); ws?.close(); });