Files
homeclaw/tests/workshop-assembly-routes.test.ts
kimandClaude Sonnet 5 8677a090d5 feat: 작업실 "조립 설명서" — .scad에서 단계별 조립도·분해도·부품표를 자동 생성
- 명세(project.assembly): 부품(call=조립 모듈 호출, 분해 offset, BOM 연결, 수량) + 조립 단계(부품/체결부품/공구/설명).
  프로젝트 데이터라 이력·충돌감지·복제가 그대로 적용된다
- 렌더러: 원본 .scad를 수정하지 않고 `use <>` wrapper로 부품을 골라 OpenSCAD ortho 렌더. 안쪽 color가 우선이라
  색을 못 덮는 제약을 "전체 렌더 vs 부품 뺀 렌더"의 픽셀 차이로 해결 → 이번 단계 부품만 원색+주황 외곽선,
  나머지는 옅게, 부품 번호 풍선은 그 부품이 실제 보이는 영역에 배치(가림 처리 정확). 모든 그림 같은 카메라
  (bbox 기반 fit, 투영식 실측 검증), 공통 여백 크롭으로 단계 간 축척 유지
- call 검증: 모듈 1개 호출만(따옴표/세미콜론/중괄호/주석/import·surface 등 금지) + .scad 안에 있는 module만
- 대시보드 "조립" 탭: .scad 스캔→부품 추가, 분해 offset, 단계 편집, 그림 렌더, 작업 탭으로 가져오기
- 설명서 페이지 assembly-manual.html: 완성/분해도, 부품표(BOM 구매상태), 체결부품·공구 합계, STEP 카드, A4 인쇄/PDF
- 채팅 도구: scan_scad / get_assembly / set_assembly / render_assembly ("조립 설명서 만들어줘" → AI가 초안)
- 테스트: 순수함수·실제 OpenSCAD/PIL 렌더(가장자리 잘림 없음 검증)·라우트 HTTP 통합·실브라우저 하네스

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-24 23:03:43 +09:00

74 lines
4.6 KiB
TypeScript

// 조립 설명서 라우트 통합(HTTP) — 임시 데이터 디렉토리 + 가짜 세션.
import test from 'node:test';
import assert from 'node:assert/strict';
import express from 'express';
import fs from 'fs';
import path from 'path';
import os from 'os';
import http from 'http';
import { spawnSync } from 'child_process';
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'asmint-'));
process.env.SMALLCLAW_DATA_DIR = tmp;
const hasTools = spawnSync('openscad', ['--version']).status === 0 && spawnSync('python3', ['-c', 'import PIL, numpy']).status === 0;
test('조립 설명서 라우트: 저장→스캔→렌더→stale', { skip: !hasTools && 'openscad/PIL 없음' }, async () => {
const { registerWorkshopRoutes } = await import('../src/gateway/routes/routes-workshop');
const app = express();
app.use(express.json());
const isInside = (b: string, t: string) => path.resolve(t).startsWith(path.resolve(b) + path.sep);
registerWorkshopRoutes(app, () => ({ username: 'tester' }), isInside);
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 j = async (m: string, u: string, body?: any) => {
const r = await fetch(base + u, { method: m, headers: { 'Content-Type': 'application/json' }, body: body ? JSON.stringify(body) : undefined });
return { status: r.status, body: (r.headers.get('content-type') || '').includes('json') ? await r.json() : null };
};
const ws = path.join(tmp, '.smallclaw', 'users', 'tester', 'workspace');
fs.mkdirSync(path.join(ws, 'workshop/pa/CAD'), { recursive: true });
const scadPath = path.join(ws, 'workshop/pa/CAD/m.scad');
fs.writeFileSync(scadPath, 'module base(){ color("gray") cube([40,40,5]); }\nmodule top(){ color("red") translate([0,0,10]) cube([40,40,4]); }\nmodule part_x(){ cube(1); }\n');
try {
const assembly = { scad: 'workshop/pa/CAD/m.scad', view: { rx: 60, rz: 35 }, explode: 1,
parts: [{ id: 'b', name: '베이스', call: 'base()', offset: [0, 0, -20] }, { id: 't', name: '상판', call: 'top()', offset: [0, 0, 30] }],
steps: [{ title: '베이스', partIds: ['b'] }, { title: '상판', partIds: ['t'], fasteners: [{ name: 'M3', qty: 4 }] }] };
let r = await j('PUT', '/api/workshop/project/pa', { id: 'pa', name: '조립 시험', parts: [], phases: [], notes: '', assembly });
assert.equal(r.status, 200);
// 조회: 아직 렌더 전
r = await j('GET', '/api/workshop/assembly/pa');
assert.equal(r.status, 200);
assert.equal(r.body.rendered, false);
assert.equal(r.body.project.assembly.parts.length, 2);
// 스캔/scad 후보
r = await j('GET', '/api/workshop/assembly-scan?path=' + encodeURIComponent('workshop/pa/CAD/m.scad'));
assert.deepEqual(r.body.modules.map((m: any) => m.name), ['base', 'top', 'part_x']);
assert.equal((await j('GET', '/api/workshop/assembly-scan?path=' + encodeURIComponent('../../etc/passwd'))).status, 400);
r = await j('GET', '/api/workshop/assembly-scad-files?projectId=pa');
assert.deepEqual(r.body.files, ['workshop/pa/CAD/m.scad']);
// 렌더
r = await j('POST', '/api/workshop/assembly/pa/render');
assert.equal(r.status, 200, JSON.stringify(r.body));
assert.equal(r.body.rendered, true);
assert.equal(r.body.stale, false);
assert.equal(r.body.images.steps.length, 2);
assert.match(r.body.images.overview, /^\/api\/files\/workshop\/pa\/%EC%A1%B0%EB%A6%BD%EC%84%A4%EB%AA%85%EC%84%9C\/overview\.png\?v=\d+$/);
// 그림 파일이 파일 탭(기존 /api/workshop/files)에도 보인다
r = await j('GET', '/api/workshop/files?projectId=pa');
assert.ok(r.body.files.some((f: any) => f.relPath === '조립설명서/step-01.png'));
// 모델 수정 → stale
fs.appendFileSync(scadPath, '// changed\n');
r = await j('GET', '/api/workshop/assembly/pa');
assert.equal(r.body.stale, true);
// 잘못된 명세는 렌더 422, 명세 없는 프로젝트는 400
await j('PUT', '/api/workshop/project/pa?force=1', { id: 'pa', name: '조립 시험', parts: [], phases: [], notes: '', assembly: { ...assembly, parts: [{ id: 'b', name: 'x', call: 'nope()' }] } });
assert.equal((await j('POST', '/api/workshop/assembly/pa/render')).status, 422);
await j('PUT', '/api/workshop/project/pb', { id: 'pb', name: '명세 없음', parts: [], phases: [], notes: '' });
assert.equal((await j('POST', '/api/workshop/assembly/pb/render')).status, 400);
assert.equal((await j('GET', '/api/workshop/assembly/nope')).status, 404);
} finally {
await new Promise(r => server.close(r));
fs.rmSync(tmp, { recursive: true, force: true });
}
});