- web-ui/html/workshop-app.html 147KB → 9KB (구조만) - web-ui/css/workshop-app.css(24KB), web-ui/js/app/workshop-app.js(114KB, classic script라 인라인 onclick 전역 핸들러 그대로) - 분리 내용은 바이트 단위로 원문과 동일함을 검증, 실브라우저 하네스 38개 항목 통과 - vm 저장 로직 테스트는 분리된 JS를 읽도록 변경, 조립 하네스 기본 경로를 폴더 정돈 후 위치로 갱신 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
172 lines
12 KiB
TypeScript
172 lines
12 KiB
TypeScript
// 조립 설명서 UI 실브라우저 검증(수동): npx tsx tests/manual/assembly-ui-check.ts
|
||
// 임시 데이터 디렉토리에 실제 스캐너 .scad를 복사해 "조립" 탭 편집 → 스캔 → 렌더 → 설명서 페이지까지 확인.
|
||
import express from 'express';
|
||
import fs from 'fs';
|
||
import path from 'path';
|
||
import os from 'os';
|
||
import http from 'http';
|
||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'asmui-'));
|
||
process.env.SMALLCLAW_DATA_DIR = tmp;
|
||
const OUT = process.env.SHOT_DIR || '/tmp';
|
||
const SRC_SCAD = process.env.SRC_SCAD || '/srv/homeclaw/.smallclaw/users/papa/workspace/workshop/proj_scan_dental/CAD/scanner-rig.scad';
|
||
(async () => {
|
||
const { registerWorkshopRoutes } = await import('../../src/gateway/routes/routes-workshop');
|
||
const { chromium } = require('playwright-core');
|
||
const app = express();
|
||
app.use(express.json());
|
||
app.get('/api/auth/status', (_q, r) => r.json({ authenticated: true }));
|
||
app.get('/api/chat/sessions/:id', (_q, r) => r.json({ history: [] }));
|
||
app.get('/api/settings/model', (_q, r) => r.json({ primary: 'test-model' }));
|
||
app.get('/api/k2/status', (_q, r) => r.json({ online: false }));
|
||
const isInside = (b: string, t: string) => path.resolve(t).startsWith(path.resolve(b) + path.sep);
|
||
registerWorkshopRoutes(app, () => ({ username: 'tester' }), isInside);
|
||
// 실제 서버의 /api/files/{*filePath}(워크스페이스 파일 서빙)를 흉내
|
||
app.use('/api/files', (req, res, next) => express.static(path.join(tmp, '.smallclaw', 'users', 'tester', 'workspace'))(req, res, next));
|
||
app.use(express.static('/srv/homeclaw/web-ui'));
|
||
const server = http.createServer(app);
|
||
await new Promise<void>(r => server.listen(0, r));
|
||
const base = `http://127.0.0.1:${(server.address() as any).port}`;
|
||
const ws = path.join(tmp, '.smallclaw', 'users', 'tester', 'workspace');
|
||
fs.mkdirSync(path.join(ws, 'workshop/pa/CAD'), { recursive: true });
|
||
fs.copyFileSync(SRC_SCAD, path.join(ws, 'workshop/pa/CAD/rig.scad'));
|
||
await fetch(`${base}/api/workshop/project/pa?force=1`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({
|
||
id: 'pa', name: '치과용 스캐너(시험)', description: '듀얼카메라 DLP 구조광 스캐너 데스크탑 랙', notes: '', phases: [],
|
||
// 사용자가 이미 부품을 하나 넣어 둔 프로젝트 — 부품이 있으면 자동 초안이 끼어들지 않는다(비어 있으면 자동 초안이 채운다)
|
||
assembly: { scad: 'workshop/pa/CAD/rig.scad', parts: [{ id: 'p0', name: '베이스 플레이트', call: 'base_plate()', offset: [0, 0, -60] }], steps: [] },
|
||
parts: [{ id: 'b1', name: '베이스 플레이트', qty: 1, unitPrice: 30000, status: '보유', memo: '', links: [] }] }) });
|
||
|
||
const browser = await chromium.launch({ executablePath: '/usr/bin/google-chrome', args: ['--no-sandbox'] });
|
||
const page = await browser.newPage({ viewport: { width: 1400, height: 1000 } });
|
||
const errors: string[] = [];
|
||
page.on('pageerror', (e: any) => errors.push(e.message));
|
||
page.on('dialog', (d: any) => d.accept());
|
||
await page.addInitScript(() => { try { sessionStorage.setItem('smallclaw_token', 't'); } catch {} });
|
||
const results: string[] = [];
|
||
const check = (n: string, ok: boolean, x = '') => results.push(`${ok ? 'PASS' : 'FAIL'} ${n}${x ? ' — ' + x : ''}`);
|
||
|
||
await page.goto(`${base}/html/workshop-app.html`);
|
||
await page.waitForSelector('#project-select');
|
||
await page.selectOption('#project-select', 'pa');
|
||
await page.click('.rb-tab-btn[data-tab=assembly]');
|
||
await page.waitForSelector('#asm-scad');
|
||
await page.fill('#asm-scad', 'workshop/pa/CAD/rig.scad');
|
||
await page.click('button:has-text("모듈 스캔")');
|
||
await page.waitForSelector('.asm-mods button');
|
||
const modCount = await page.locator('.asm-mods button').count();
|
||
check('스캔: 조립 모듈 목록(part_* 제외)', modCount > 5 && !(await page.locator('.asm-mods').innerText()).includes('part_'), `${modCount}개`);
|
||
|
||
for (const name of ['turntable', 'support_frame', 'head_plate', 'projector']) { // base_plate는 이미 있음
|
||
await page.click(`.asm-mods button:has-text("+ ${name}")`);
|
||
}
|
||
check('부품 5개 추가됨', (await page.locator('.asm-part').count()) === 5);
|
||
// 부품 이름/분해 offset 지정
|
||
const names = ['베이스 플레이트', '턴테이블', '지지 프레임', '헤드 플레이트', '프로젝터'];
|
||
const offs = [-60, 40, 20, 120, 190];
|
||
for (let i = 0; i < 5; i++) {
|
||
const row = page.locator('.asm-part').nth(i);
|
||
await row.locator('input[type=text]').first().fill(names[i]);
|
||
await row.locator('input.off').nth(2).fill(String(offs[i]));
|
||
}
|
||
await page.locator('.asm-part').first().locator('select').selectOption('베이스 플레이트'); // BOM 연결
|
||
// 단계 3개
|
||
const steps = [['베이스와 턴테이블', [0, 1], 'M4x10 볼트 | 4', '2.5mm 육각렌치'], ['기둥 세우기', [2], '', ''], ['헤드와 프로젝터', [3, 4], 'M3x8 볼트 x 8', '드라이버, 니퍼']] as const;
|
||
for (const [title, idxs, fast, tools] of steps) {
|
||
await page.click('#pane-assembly button:has-text("+ 단계 추가")');
|
||
const st = page.locator('.asm-step').last();
|
||
await st.locator('input[type=text]').first().fill(title);
|
||
await st.locator('textarea').first().fill(title + ' 단계 설명입니다.');
|
||
for (const k of idxs) await st.locator('.asm-chip').nth(k).click();
|
||
const st2 = page.locator('.asm-step').last();
|
||
if (fast) await st2.locator('textarea').nth(1).fill(fast);
|
||
if (tools) await st2.locator('input[type=text]').nth(1).fill(tools);
|
||
}
|
||
check('단계 3개 + 부품 배정', (await page.locator('.asm-step').count()) === 3 && (await page.locator('.asm-step').first().locator('.asm-chip.on').count()) === 2);
|
||
await page.screenshot({ path: `${OUT}/asm-1-editor.png`, fullPage: false });
|
||
|
||
await page.click('#asm-render-btn');
|
||
await page.waitForSelector('.asm-imgs figure', { timeout: 120000 });
|
||
await page.waitForTimeout(500);
|
||
const figs = await page.locator('.asm-imgs figure').count();
|
||
check('렌더: 완성+분해+단계 3 = 5장', figs === 5, `${figs}장`);
|
||
check('상태 표시(마지막 렌더)', (await page.locator('#asm-status').innerText()).includes('마지막 렌더'));
|
||
// 명세를 바꾸면 "명세 변경" 표시
|
||
await page.locator('.asm-part').first().locator('input.off').nth(2).fill('-80');
|
||
check('명세 변경 시 경고 표시', (await page.locator('#asm-status').innerText()).includes('명세 변경'));
|
||
await page.screenshot({ path: `${OUT}/asm-2-rendered.png`, fullPage: true });
|
||
|
||
// 작업 탭으로 가져오기
|
||
await page.click('button:has-text("작업 탭으로 가져오기")');
|
||
await page.click('.rb-tab-btn[data-tab=tasks]');
|
||
check('STEP들이 작업 탭 "조립" 단계로', (await page.locator('.rb-phase-name').first().inputValue()) === '조립' && (await page.locator('.rb-task-row').count()) === 3);
|
||
|
||
// 설명서 페이지
|
||
await page.waitForTimeout(1200); // 저장 debounce
|
||
const page2 = await browser.newPage({ viewport: { width: 1000, height: 1300 } });
|
||
page2.on('pageerror', (e: any) => errors.push('manual: ' + e.message));
|
||
await page2.addInitScript(() => { try { sessionStorage.setItem('smallclaw_token', 't'); } catch {} });
|
||
await page2.goto(`${base}/html/assembly-manual.html?projectId=pa`);
|
||
await page2.waitForSelector('.step');
|
||
check('설명서: STEP 3개+부품표 5행', (await page2.locator('.step').count()) === 3 && (await page2.locator('tbody tr').count()) >= 5);
|
||
check('설명서: 체결부품 합계(M4x10=4, M3x8=8)', (await page2.locator('body').innerText()).includes('M3x8 볼트') && (await page2.locator('body').innerText()).includes('8'));
|
||
check('설명서: BOM 상태(보유) 연결 표시', (await page2.locator('.badge.보유').count()) === 1);
|
||
check('설명서: 그림 로드됨', await page2.evaluate(() => [...document.images].every(i => i.complete && i.naturalWidth > 0)));
|
||
await page2.screenshot({ path: `${OUT}/asm-3-manual.png`, fullPage: true });
|
||
await page2.pdf({ path: `${OUT}/asm-manual.pdf`, format: 'A4', printBackground: true });
|
||
|
||
|
||
// ── .scad 자동 연동: 빈 프로젝트의 조립 탭을 열기만 하면 자동 연결 → 초안 → 렌더 ──
|
||
fs.mkdirSync(path.join(ws, 'workshop/pz/CAD'), { recursive: true });
|
||
fs.copyFileSync(SRC_SCAD, path.join(ws, 'workshop/pz/CAD/rig.scad'));
|
||
await fetch(`${base}/api/workshop/project/pz?force=1`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id: 'pz', name: '자동 연동 시험', parts: [], phases: [], notes: '' }) });
|
||
await page.goto(`${base}/html/workshop-app.html`);
|
||
await page.waitForSelector('#project-select');
|
||
await page.selectOption('#project-select', 'pz');
|
||
await page.click('.rb-tab-btn[data-tab=assembly]');
|
||
await page.waitForSelector('.asm-part', { timeout: 60000 });
|
||
check('자동 연결: .scad 경로가 채워짐', (await page.inputValue('#asm-scad')) === 'workshop/pz/CAD/rig.scad');
|
||
const nParts = await page.locator('.asm-part').count();
|
||
check('자동 초안: 부품이 만들어짐(고스트 제외)', nParts === 9, `${nParts}개`);
|
||
check('자동 초안: 제외 안내 표시', (await page.locator('#asm-draft-note').innerText()).includes('제외'));
|
||
await page.waitForSelector('.asm-imgs figure', { timeout: 120000 });
|
||
check('자동 렌더: 그림 생성(완성+분해+단계)', (await page.locator('.asm-imgs figure').count()) >= 5, `${await page.locator('.asm-imgs figure').count()}장`);
|
||
await page.screenshot({ path: `${OUT}/asm-4-auto.png`, fullPage: true });
|
||
|
||
// .scad가 바뀐 뒤 탭을 다시 열면 자동 재렌더
|
||
await page.waitForTimeout(1500);
|
||
const before = await (await fetch(`${base}/api/workshop/assembly/pz`)).json();
|
||
fs.appendFileSync(path.join(ws, 'workshop/pz/CAD/rig.scad'), '\n// changed\n');
|
||
const stale = await (await fetch(`${base}/api/workshop/assembly/pz`)).json();
|
||
check('.scad 변경 → 서버가 stale 판정', stale.stale === true);
|
||
await page.click('.rb-tab-btn[data-tab=parts]');
|
||
await page.click('.rb-tab-btn[data-tab=assembly]');
|
||
for (let i = 0; i < 400; i++) { // 서버 상태를 폴링(최대 ~2분)
|
||
if ((await (await fetch(`${base}/api/workshop/assembly/pz`)).json()).stale === false) break;
|
||
await new Promise(r => setTimeout(r, 300));
|
||
}
|
||
const after = await (await fetch(`${base}/api/workshop/assembly/pz`)).json();
|
||
check('탭을 열면 오래된 그림 자동 재렌더', after.renderedAt > before.renderedAt && after.stale === false);
|
||
|
||
// ── 연결만 돼 있고 비어 있는 프로젝트(중단/수동)도 조립 탭에서 초안이 채워진다 + 빈 명세는 저장되지 않는다 ──
|
||
fs.mkdirSync(path.join(ws, 'workshop/pq/CAD'), { recursive: true });
|
||
fs.copyFileSync(SRC_SCAD, path.join(ws, 'workshop/pq/CAD/rig.scad'));
|
||
await fetch(`${base}/api/workshop/project/pq?force=1`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id: 'pq', name: '연결만 된 프로젝트', parts: [], phases: [], notes: '', assembly: { scad: 'workshop/pq/CAD/rig.scad', parts: [], steps: [] } }) });
|
||
await fetch(`${base}/api/workshop/project/pe?force=1`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id: 'pe', name: 'scad 없는 프로젝트', parts: [], phases: [], notes: '' }) });
|
||
await page.goto(`${base}/html/workshop-app.html`);
|
||
await page.waitForSelector('#project-select');
|
||
await page.selectOption('#project-select', 'pq');
|
||
await page.click('.rb-tab-btn[data-tab=assembly]');
|
||
await page.waitForSelector('.asm-part', { timeout: 60000 });
|
||
check('연결만 된 프로젝트: 초안 자동 생성', (await page.locator('.asm-part').count()) === 9);
|
||
await page.selectOption('#project-select', 'pe');
|
||
await page.waitForTimeout(1500);
|
||
await page.click('.rb-tab-btn[data-tab=assembly]');
|
||
await page.waitForTimeout(1500);
|
||
const pe = (await (await fetch(`${base}/api/workshop/data`)).json()).projects.find((p: any) => p.id === 'pe');
|
||
check('scad 없는 프로젝트: 조립 탭을 열어도 빈 명세가 저장되지 않음', pe.assembly === undefined, JSON.stringify(pe.assembly));
|
||
|
||
console.log(results.join('\n'));
|
||
console.log('페이지 JS 오류:', errors.length ? errors.join('; ') : '없음');
|
||
await browser.close(); server.close();
|
||
fs.rmSync(tmp, { recursive: true, force: true });
|
||
})().catch(e => { console.error('HARNESS ERROR', e); process.exit(1); });
|