Files
homeclaw/tests/manual/assembly-ui-check.ts
T
kimandClaude Sonnet 5 6119be592d refactor: 작업실 HTML에서 CSS·JS를 별도 파일로 분리 — 내용 변경 없음
- 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>
2026-09-25 09:45:28 +09:00

172 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 조립 설명서 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); });