feat(view): ②·③ 모델과 입력창/철근표를 상하·좌우로 전환하고 분할선으로 크기 조절
- 중앙 도구줄 '상하 | 좌우' 전환(② 도구줄 신설), 분할선 드래그·키보드(←→↑↓, Shift×5, Home/End)·더블클릭 기본값 - 상하: 모델 높이 180–560 px, 좌우: 모델 폭 30–70 %(모델 왼쪽), 모델 접기 시 창 전체 사용 - 중앙 열 900 px 미만·1100 px 이하 화면은 자동 상하 + 안내, 넓어지면 복귀 - 단계별 표시 설정을 localStorage에 저장(기존 pierzainer.review-layout 이관), 프로젝트 파일 미포함 - 오른쪽 창 묶음(.workspace-pane) 도입에 맞춰 기존 검증 스크립트 선택자 갱신 - 단위시험 4개, verify-workspace-layout.cjs Chrome 27개 검사 Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
ef596650cc
commit
7d36c4b64a
8 files changed
+389
-44
No files matched your search
@@ -0,0 +1,135 @@
|
||||
// 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();
|
||||
});
|
||||
Reference in new issue
Block a user