배경: 테스트 프로젝트 로봇팔은 부품이 로컬 좌표 모듈이고 조립 자세를 robot_arm() 하나가 translate/rotate로 만드는 구조라, 조립 초안이 전체+하위 모듈을 중복으로 잡고 하위 모듈이 원점에 그려져 단계 강조가 안 보였다. 규칙(src/tools/scad-conventions.ts): 1) 부품마다 조립 자세 모듈 asm_<이름>()(모듈 안에서 translate/rotate) 2) 파일 끝 조립 블록 PART == "all" — 모듈 호출만, 조립 순서대로 3) 참고용 형상은 ref_ 접두어(조립 설명서에서 제외) 4) 모듈 위 주석 한 줄 = 한국어 부품 이름 5) 새 .scad는 SCAD/, STL은 CAD/ 적용 지점: - 작업실 채팅 컨텍스트에 규칙 전문, write_scad 도구 설명(모델이 보는 정의 2곳)에 요약 - write_scad가 저장 직후 lint(조립 블록 없음 / 블록 안에 변환·형상·반복문 / 부품 1개뿐)를 응답에 덧붙여 모델이 스스로 고치게 함 - 프로젝트 안의 새 .scad를 SCAD/ 로 자동 보정(CAD/·루트·print/ → SCAD/, 사용자 폴더는 존중)하고 이동 사실을 안내 파서: 조립 블록 이름 별칭(all/assembly/full/whole/complete), ref_ 고스트 접두어, 한 줄에 압축된 module 인식, getAssemblyRegion 분리 초안 실패/경고 문구에 "채팅에 조립 블록을 만들어줘" 안내 테스트 24건 추가(lint 표, 경로 보정, 별칭, write_scad 실제 OpenSCAD 통합) Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
379 lines
22 KiB
TypeScript
379 lines
22 KiB
TypeScript
// 조립 설명서(2026-09-24): 명세 검증/정규화/스캔/카메라 맞춤 + 실제 OpenSCAD·PIL 렌더 통합 + 채팅 도구 액션.
|
||
import { test, describe } from 'node:test';
|
||
import assert from 'node:assert/strict';
|
||
import fs from 'fs';
|
||
import os from 'os';
|
||
import path from 'path';
|
||
import { spawnSync } from 'child_process';
|
||
import {
|
||
parseCall, normalizeAssembly, scanScadModules, buildWrapperScad, fitCamera, stepPartSets, unassignedParts, assemblyHash, ASSEMBLY_LIMITS,
|
||
} from '../src/gateway/routes/workshop-assembly';
|
||
import { renderAssemblyManual, resolveScadPath, readManifest, currentAssemblyHash, draftAssembly, autoRenderForScad } from '../src/tools/workshop-assembly-render';
|
||
import { extractAssemblyCalls, stripComments, splitTopLevelArgs, moduleLabel, disambiguateNames } from '../src/gateway/routes/workshop-assembly-draft';
|
||
import { workshopProjectTool } from '../src/tools/workshop-project';
|
||
import { saveWorkshop, loadWorkshop, duplicateProject, normalizeProjectInput, projectToReadme } from '../src/gateway/routes/workshop-storage';
|
||
|
||
const SCAD = `
|
||
module base(){ color("gray") cube([40,40,5]); }
|
||
module post(x){ color("blue") translate([x,0,5]) cube([5,5,30]); }
|
||
module top(){ color("red") translate([0,0,35]) cube([40,40,4]); }
|
||
module part_base(){ cube([40,40,5]); }
|
||
// module commented_out(){}
|
||
`;
|
||
const hasTools = spawnSync('openscad', ['--version']).status === 0 && spawnSync('python3', ['-c', 'import PIL, numpy']).status === 0;
|
||
|
||
function tmpWs(): string {
|
||
const ws = fs.mkdtempSync(path.join(os.tmpdir(), 'asm-'));
|
||
fs.mkdirSync(path.join(ws, 'workshop/p1/CAD'), { recursive: true });
|
||
fs.writeFileSync(path.join(ws, 'workshop/p1/CAD/m.scad'), SCAD);
|
||
saveWorkshop(ws, { activeProjectId: 'p1', projects: [{ id: 'p1', name: 'P', parts: [{ id: 'b1', name: '베이스 판', qty: 1, unitPrice: 0, status: '보유', memo: '', links: [] }], phases: [], notes: '' }] });
|
||
return ws;
|
||
}
|
||
const spec = () => ({
|
||
scad: 'workshop/p1/CAD/m.scad', view: { rx: 60, rz: 35 }, explode: 1,
|
||
parts: [
|
||
{ id: 'base', name: '베이스', call: 'base()', offset: [0, 0, -30], bom: '베이스 판' },
|
||
{ id: 'p1', name: '기둥 A', call: 'post(0)', offset: [-20, 0, 10] },
|
||
{ id: 'p2', name: '기둥 B', call: 'post(35)', offset: [20, 0, 10] },
|
||
{ id: 'top', name: '상판', call: 'top()', offset: [0, 0, 60] },
|
||
],
|
||
steps: [
|
||
{ title: '베이스', desc: '바닥에 놓는다', partIds: ['base'], fasteners: [{ name: 'M3 나사', qty: 4 }], tools: ['드라이버'] },
|
||
{ title: '기둥', partIds: ['p1', 'p2'] },
|
||
{ title: '상판', partIds: ['top'] },
|
||
],
|
||
});
|
||
|
||
describe('call 검증', () => {
|
||
test('정상 호출', () => {
|
||
for (const c of ['base()', 'camera(80)', 'camera(-80 / 2)', 'post( x = 3, y = [1,2] )', 'm(1+2*3)']) assert.equal(parseCall(c).ok, true, c);
|
||
});
|
||
test('임의 코드/파일 읽기/다중 문장/문자열은 거부', () => {
|
||
for (const c of ['import("/etc/passwd")', 'base(); cube(1)', 'base() { cube(1); }', 'x("a")', "x('a')", 'surface(file=1)', 'a(import(1))', 'text(3)', 'use(1)', 'base', '', 'a()//x', 'a(/*x*/)', 'a(\\)'])
|
||
assert.equal(parseCall(c).ok, false, c);
|
||
});
|
||
});
|
||
|
||
describe('명세 정규화', () => {
|
||
test('id 정리, partIds는 존재하는 부품만, 중복 제거, 숫자 범위 제한', () => {
|
||
const r = normalizeAssembly({ ...spec(), steps: [{ title: '', partIds: ['base', 'base', 'nope'] }], explode: 99, view: { rx: 500, rz: 'x' } });
|
||
assert.ok(r.ok);
|
||
if (r.ok) {
|
||
assert.deepEqual(r.assembly.steps[0].partIds, ['base']);
|
||
assert.equal(r.assembly.steps[0].title, '단계 1');
|
||
assert.equal(r.assembly.explode, 5);
|
||
assert.equal(r.assembly.view.rx, 90);
|
||
assert.equal(r.assembly.view.rz, 35);
|
||
}
|
||
});
|
||
test('strict는 잘못된 call 거부, lenient(대시보드 저장)는 미완성 행을 보존', () => {
|
||
const bad = { ...spec(), parts: [{ id: 'x', name: '', call: '' }] };
|
||
assert.equal(normalizeAssembly(bad).ok, false);
|
||
const r = normalizeAssembly(bad, false);
|
||
assert.ok(r.ok);
|
||
});
|
||
test('scad 경로: 절대경로/.. /.scad 아님 거부', () => {
|
||
for (const s of ['/etc/x.scad', '../x.scad', 'a/../../x.scad', 'a/b.txt']) assert.equal(normalizeAssembly({ ...spec(), scad: s }).ok, false, s);
|
||
});
|
||
test('한도 초과 거부', () => {
|
||
assert.equal(normalizeAssembly({ ...spec(), parts: Array.from({ length: ASSEMBLY_LIMITS.parts + 1 }, (_, i) => ({ id: `a${i}`, call: 'base()' })) }).ok, false);
|
||
});
|
||
test('프로젝트 PUT 정규화(normalizeProjectInput)에 assembly가 통합되고 없으면 제거', () => {
|
||
const base = { id: 'p', name: 'A', parts: [], phases: [] };
|
||
const withA = normalizeProjectInput({ ...base, assembly: spec() }, 'p');
|
||
assert.ok(withA.ok && withA.project.assembly && withA.project.assembly.parts.length === 4);
|
||
const without = normalizeProjectInput({ ...base }, 'p');
|
||
assert.ok(without.ok && !('assembly' in without.project));
|
||
// 미완성 행이 있어도 프로젝트 저장이 막히지 않는다
|
||
assert.ok(normalizeProjectInput({ ...base, assembly: { scad: '', parts: [{ call: '' }], steps: [] } }, 'p').ok);
|
||
});
|
||
});
|
||
|
||
describe('스캔/wrapper/카메라/단계 계산', () => {
|
||
test('scanScadModules: 모듈·매개변수·필수인자·프린트용 구분, 주석 처리된 module 제외', () => {
|
||
const mods = scanScadModules(SCAD);
|
||
assert.deepEqual(mods.map(m => m.name), ['base', 'post', 'top', 'part_base']);
|
||
const post = mods.find(m => m.name === 'post')!;
|
||
assert.deepEqual(post.requiredParams, ['x']);
|
||
assert.equal(mods.find(m => m.name === 'part_base')!.isPrintPart, true);
|
||
});
|
||
test('buildWrapperScad: use + 호출, offset이 있으면 translate', () => {
|
||
const w = buildWrapperScad('/a/b c/m.scad', [{ call: 'base()' }, { call: 'post(3)', offset: [1, 2, 3] }]);
|
||
assert.equal(w, 'use </a/b c/m.scad>\nbase();\ntranslate([1, 2, 3]) post(3);\n');
|
||
});
|
||
test('fitCamera: 카메라 문자열 형식과 큰 모델일수록 거리 증가', () => {
|
||
const small = fitCamera({ min: [0, 0, 0], max: [10, 10, 10] }, { rx: 60, rz: 35 }, { w: 1000, h: 750 });
|
||
const big = fitCamera({ min: [0, 0, 0], max: [100, 100, 100] }, { rx: 60, rz: 35 }, { w: 1000, h: 750 });
|
||
assert.match(small.camera, /^5,5,5,60,0,35,[\d.]+$/);
|
||
assert.ok(parseFloat(big.camera.split(',')[6]) > parseFloat(small.camera.split(',')[6]) * 5);
|
||
assert.ok(small.scale > big.scale);
|
||
});
|
||
test('stepPartSets: 그때까지 붙은 부품 누적, 중복 배정은 처음 단계에서만 새 부품', () => {
|
||
const a = normalizeAssembly({ ...spec(), steps: [{ partIds: ['base'] }, { partIds: ['base', 'p1'] }, { partIds: [] }] });
|
||
assert.ok(a.ok);
|
||
if (a.ok) {
|
||
const sets = stepPartSets(a.assembly);
|
||
assert.deepEqual(sets.map(s => s.added.map(p => p.id)), [['base'], ['p1'], []]);
|
||
assert.deepEqual(sets[2].before.map(p => p.id), ['base', 'p1']);
|
||
assert.deepEqual(unassignedParts(a.assembly).map(p => p.id), ['p2', 'top']);
|
||
}
|
||
});
|
||
test('assemblyHash: scad 내용이나 명세가 바뀌면 달라진다', () => {
|
||
const a = (normalizeAssembly(spec()) as any).assembly;
|
||
const h1 = assemblyHash(a, SCAD);
|
||
assert.equal(assemblyHash(a, SCAD), h1);
|
||
assert.notEqual(assemblyHash(a, SCAD + ' '), h1);
|
||
assert.notEqual(assemblyHash({ ...a, view: { rx: 30, rz: 35 } }, SCAD), h1);
|
||
});
|
||
test('duplicateProject: 조립 명세 id 재발급 + 단계의 partIds 매핑', () => {
|
||
const a = (normalizeAssembly(spec()) as any).assembly;
|
||
const copy = duplicateProject({ id: 'x', name: 'X', parts: [], phases: [], notes: '', assembly: a } as any, 'y');
|
||
const ids = new Set(copy.assembly!.parts.map(p => p.id));
|
||
assert.equal(ids.size, 4);
|
||
assert.ok(copy.assembly!.parts.every(p => !a.parts.some((o: any) => o.id === p.id)));
|
||
assert.ok(copy.assembly!.steps.every(s => s.partIds.every(id => ids.has(id))));
|
||
assert.deepEqual(copy.assembly!.steps.map(s => s.partIds.length), [1, 2, 1]);
|
||
});
|
||
test('resolveScadPath: 워크스페이스 밖/비-scad 거부', () => {
|
||
const ws = tmpWs();
|
||
assert.equal(resolveScadPath(ws, 'workshop/p1/CAD/m.scad').ok, true);
|
||
assert.equal(resolveScadPath(ws, '../etc/passwd').ok, false);
|
||
assert.equal(resolveScadPath(ws, 'workshop/p1/CAD/none.scad').ok, false);
|
||
assert.equal(resolveScadPath(ws, 'workshop/p1/CAD').ok, false);
|
||
});
|
||
});
|
||
|
||
describe('렌더 통합(OpenSCAD+PIL)', { skip: !hasTools && 'openscad/PIL 없음' }, () => {
|
||
const stat = (png: string) => {
|
||
const r = spawnSync('python3', ['-c', `
|
||
import sys, numpy as np
|
||
from PIL import Image
|
||
a = np.asarray(Image.open(sys.argv[1]).convert('RGB')).astype(int)
|
||
H, W = a.shape[:2]
|
||
orange = int(((abs(a[...,0]-245)<6)&(abs(a[...,1]-158)<6)&(abs(a[...,2]-11)<6)).sum())
|
||
edge = np.concatenate([a[0], a[-1], a[:,0], a[:,-1]])
|
||
print(W, H, orange, int((edge.sum(axis=1) < 740).sum()))
|
||
`, png], { encoding: 'utf-8' });
|
||
const [w, h, orange, edgeDark] = r.stdout.trim().split(' ').map(Number);
|
||
return { w, h, orange, edgeDark };
|
||
};
|
||
|
||
test('전체 파이프라인: 완성/분해/단계 그림 생성, 풍선(주황) 존재, 가장자리에 모델이 안 잘림, 해시/manifest', async () => {
|
||
const ws = tmpWs();
|
||
const a = (normalizeAssembly(spec()) as any).assembly;
|
||
const r = await renderAssemblyManual(ws, 'p1', a);
|
||
assert.ok(r.ok, JSON.stringify(r));
|
||
if (!r.ok) return;
|
||
const dir = path.join(ws, 'workshop/p1/조립설명서');
|
||
assert.deepEqual(fs.readdirSync(dir).sort(), ['exploded.png', 'manifest.json', 'overview.png', 'step-01.png', 'step-02.png', 'step-03.png']);
|
||
for (const f of ['overview.png', 'exploded.png', 'step-01.png', 'step-02.png', 'step-03.png']) {
|
||
const s = stat(path.join(dir, f));
|
||
assert.ok(s.w <= 1000 && s.h <= 750 && s.w > 100 && s.h > 100, `${f}: ${s.w}x${s.h}`);
|
||
assert.ok(s.orange > 100, `${f}: 풍선/외곽선 주황 픽셀 ${s.orange}`);
|
||
assert.equal(s.edgeDark, 0, `${f}: 모델이 프레임 가장자리에 닿음(잘림)`);
|
||
}
|
||
const m = readManifest(ws, 'p1')!;
|
||
assert.equal(m.hash, r.hash);
|
||
assert.equal(currentAssemblyHash(ws, a), r.hash);
|
||
// scad 수정 → 해시 달라짐(stale 판정 근거)
|
||
fs.appendFileSync(path.join(ws, 'workshop/p1/CAD/m.scad'), '\n// changed\n');
|
||
assert.notEqual(currentAssemblyHash(ws, a), r.hash);
|
||
assert.equal(fs.readdirSync(path.join(ws, '.smallclaw/workshop-assembly-tmp')).length, 0, '임시 파일 정리');
|
||
});
|
||
|
||
test('단계를 줄여 다시 렌더하면 옛 step 그림 삭제, 없는 module/빈 명세는 명확한 오류', async () => {
|
||
const ws = tmpWs();
|
||
const a = (normalizeAssembly(spec()) as any).assembly;
|
||
await renderAssemblyManual(ws, 'p1', a);
|
||
await renderAssemblyManual(ws, 'p1', { ...a, steps: a.steps.slice(0, 1) });
|
||
assert.deepEqual(fs.readdirSync(path.join(ws, 'workshop/p1/조립설명서')).filter(f => f.startsWith('step-')), ['step-01.png']);
|
||
const bad = await renderAssemblyManual(ws, 'p1', { ...a, parts: [{ ...a.parts[0], call: 'nope()' }] });
|
||
assert.equal(bad.ok, false);
|
||
assert.match((bad as any).error, /module nope/);
|
||
assert.match((bad as any).error, /m\.scad/); // 어느 파일인지
|
||
assert.match((bad as any).error, /자동 초안으로 부품을 다시 만들거나/); // 해결 방법 안내
|
||
assert.equal((await renderAssemblyManual(ws, 'p1', { ...a, parts: [] })).ok, false);
|
||
assert.equal((await renderAssemblyManual(ws, 'p1', { ...a, scad: 'workshop/p1/CAD/nope.scad' })).ok, false);
|
||
});
|
||
});
|
||
|
||
describe('workshop_project 조립 액션', () => {
|
||
const run = (ws: string, args: any) => workshopProjectTool.execute({ ...args, _workspacePath: ws });
|
||
test('scan_scad → set_assembly → get_assembly (+ 잘못된 입력 피드백)', async () => {
|
||
const ws = tmpWs();
|
||
const scan = await run(ws, { action: 'scan_scad', scad_path: 'workshop/p1/CAD/m.scad' });
|
||
assert.equal(scan.success, true);
|
||
assert.match(scan.stdout || '', /- post\(x\) ← 필수 인자 x/);
|
||
assert.match(scan.stdout || '', /part_base/);
|
||
assert.equal((await run(ws, { action: 'scan_scad', scad_path: '../x.scad' })).success, false);
|
||
|
||
assert.equal((await run(ws, { action: 'set_assembly' })).success, false);
|
||
assert.equal((await run(ws, { action: 'set_assembly', assembly: { ...spec(), parts: [{ id: 'a', name: 'x', call: 'import("/etc/passwd")' }] } })).success, false);
|
||
const ok = await run(ws, { action: 'set_assembly', assembly: { ...spec(), parts: [...spec().parts, { id: 'z', name: '유령', call: 'ghost()' }] } });
|
||
assert.equal(ok.success, true, ok.error);
|
||
assert.match(ok.stdout || '', /module ghost이\(가\) 없음/);
|
||
assert.match(ok.stdout || '', /어느 단계에도 없는 부품: 유령/);
|
||
const got = await run(ws, { action: 'get_assembly' });
|
||
assert.match(got.stdout || '', /부품 5, 단계 3/);
|
||
assert.match(projectToReadme(loadWorkshop(ws).projects[0]), /조립 설명서: 부품 5개 · 단계 3개/);
|
||
});
|
||
|
||
test('render_assembly: 이미지 마크다운 반환(비전 임베드용) + 명세 없으면 오류', { skip: !hasTools && 'openscad/PIL 없음' }, async () => {
|
||
const ws = tmpWs();
|
||
assert.equal((await run(ws, { action: 'render_assembly' })).success, false);
|
||
await run(ws, { action: 'set_assembly', assembly: spec() });
|
||
const r = await run(ws, { action: 'render_assembly' });
|
||
assert.equal(r.success, true, r.error);
|
||
assert.match(r.stdout || '', /!\[조립 완성\]\(\/api\/files\/workshop\/p1\/%EC%A1%B0%EB%A6%BD%EC%84%A4%EB%AA%85%EC%84%9C\/overview\.png\)/);
|
||
assert.match(r.stdout || '', /!\[분해도\]/);
|
||
});
|
||
});
|
||
|
||
// ══ .scad 자동 연동(2026-09-25) ══════════════════════════════════════════════════
|
||
const RIG = `
|
||
OFF = 30; // 카메라 오프셋
|
||
H = 20 + 5;
|
||
// 베이스 판 — 설명 주석
|
||
module base(){ color("gray") cube([60,60,5]); }
|
||
module post(x, y){ color("blue") translate([x,y,5]) cube([5,5,H]); }
|
||
// 상판
|
||
module top(){ color("red") translate([0,0,H+5]) cube([60,60,4]); }
|
||
module cam(sx){ color("green") translate([sx+30,30,H+9]) cube([6,6,6]); }
|
||
module ghost_cone(){ %cylinder(h=10,d=5); }
|
||
module part_base(){ cube([60,60,5]); }
|
||
PART = "all";
|
||
if (PART == "all") {
|
||
base();
|
||
post(0, 0);
|
||
post(55, 55);
|
||
top();
|
||
cam(+OFF); // 변수 인자
|
||
cam(-OFF);
|
||
ghost_cone();
|
||
%base(); // 고스트 수식자 — 건너뜀
|
||
*top(); // 비활성 — 건너뜀
|
||
translate([1,1,1]) post(1, 1); // 변환이 붙은 호출 — 건너뜀
|
||
} else if (PART == "base") part_base();
|
||
`;
|
||
|
||
describe('자동 초안: 파싱', () => {
|
||
test('stripComments: 주석 제거, 문자열 안 // 는 보존, 줄 수 유지', () => {
|
||
const t = stripComments('a(); // x\nb("http://y"); /* c\nd */ e();');
|
||
assert.ok(t.includes('a();') && !t.includes('// x') && t.includes('"http://y"') && !t.includes('c'));
|
||
assert.equal(t.split('\n').length, 'a(); // x\nb("http://y"); /* c\nd */ e();'.split('\n').length);
|
||
});
|
||
test('splitTopLevelArgs: 괄호/대괄호 안 쉼표는 나누지 않음', () => {
|
||
assert.deepEqual(splitTopLevelArgs('a, [1,2], f(3,4), x=[5,6]'), ['a', '[1,2]', 'f(3,4)', 'x=[5,6]']);
|
||
assert.deepEqual(splitTopLevelArgs(''), []);
|
||
});
|
||
test('extractAssemblyCalls: all-블록의 호출만, 수식자/변환/모듈 정의 본문/else 분기 제외, 고스트 표시', () => {
|
||
const r = extractAssemblyCalls(RIG);
|
||
assert.equal(r.region, 'all-block');
|
||
assert.deepEqual(r.calls.map(c => c.raw), ['base()', 'post(0, 0)', 'post(55, 55)', 'top()', 'cam(+OFF)', 'cam(-OFF)', 'ghost_cone()']);
|
||
assert.deepEqual(r.calls.filter(c => c.ghost).map(c => c.module), ['ghost_cone']);
|
||
assert.deepEqual(r.calls[4].args, ['+OFF']);
|
||
});
|
||
test('조립 블록 별칭(PART == "assembly" 등)과 ref_ 접두어 고스트를 인식', () => {
|
||
const src = 'module asm_a(){cube(1);}\nmodule asm_b(){cube(2);}\nmodule ref_floor(){cube(3);}\nPART = "assembly";\nif (PART == "all" || PART == "assembly") {\n asm_a();\n asm_b();\n ref_floor();\n}\nelse if (PART == "a") asm_a();';
|
||
const r = extractAssemblyCalls(src);
|
||
assert.equal(r.region, 'all-block');
|
||
assert.deepEqual(r.calls.map(c => [c.module, c.ghost]), [['asm_a', false], ['asm_b', false], ['ref_floor', true]]);
|
||
for (const name of ['assembly', 'full', 'whole', 'complete']) {
|
||
const r2 = extractAssemblyCalls(`module a(){cube(1);}\nif (PART == "${name}") {\n a();\n}`);
|
||
assert.equal(r2.region, 'all-block', name);
|
||
}
|
||
});
|
||
test('조립 블록이 없으면 최상위 호출 전체(region=top-level), 이름 붙은 ghost 인자도 고스트', () => {
|
||
const r = extractAssemblyCalls('module a(x){cube(x);}\nmodule arm(ang, isGhost=false){cube(1);}\na(3);\narm(10);\narm(20, isGhost=true);');
|
||
assert.equal(r.region, 'top-level');
|
||
assert.deepEqual(r.calls.map(c => [c.raw, c.ghost]), [['a(3)', false], ['arm(10)', false], ['arm(20, isGhost=true)', true]]);
|
||
});
|
||
test('moduleLabel: 바로 위 주석의 첫 문장, 없거나 긴 문장이면 모듈 이름', () => {
|
||
assert.equal(moduleLabel(RIG, 'base'), '베이스 판');
|
||
assert.equal(moduleLabel(RIG, 'top'), '상판');
|
||
assert.equal(moduleLabel(RIG, 'post'), 'post');
|
||
assert.equal(moduleLabel(RIG, 'ghost_cone'), 'ghost cone');
|
||
});
|
||
test('disambiguateNames: 같은 이름은 인자로 구분', () => {
|
||
const items = [{ name: 'cam', call: 'cam(30)', module: 'cam' }, { name: 'cam', call: 'cam(-30)', module: 'cam' }, { name: '상판', call: 'top()', module: 'top' }];
|
||
disambiguateNames(items);
|
||
assert.deepEqual(items.map(i => i.name), ['cam (30)', 'cam (-30)', '상판']);
|
||
});
|
||
});
|
||
|
||
describe('자동 초안: 실행(OpenSCAD)', { skip: !hasTools && 'openscad/PIL 없음' }, () => {
|
||
test('변수 인자를 값으로 풀고, 고스트 제외, 호출 순서대로 단계 구성, 같은 모듈은 한 단계', async () => {
|
||
const ws = tmpWs();
|
||
fs.writeFileSync(path.join(ws, 'workshop/p1/CAD/rig.scad'), RIG);
|
||
const r = await draftAssembly(ws, 'workshop/p1/CAD/rig.scad');
|
||
assert.ok(r.ok, JSON.stringify(r));
|
||
if (!r.ok) return;
|
||
assert.equal(r.region, 'all-block');
|
||
assert.deepEqual(r.assembly.parts.map(p => p.call), ['base()', 'post(0, 0)', 'post(55, 55)', 'top()', 'cam(30)', 'cam(-30)']); // +OFF → 30
|
||
assert.deepEqual(r.excluded, ['ghost_cone()']);
|
||
assert.deepEqual(r.assembly.steps.map(s => s.partIds.length), [1, 2, 1, 2]); // base / post×2 / top / cam×2 (같은 모듈은 첫 등장 위치에 한 단계)
|
||
});
|
||
});
|
||
|
||
describe('자동 초안 → 렌더 → .scad 변경 시 자동 재렌더', { skip: !hasTools && 'openscad/PIL 없음' }, () => {
|
||
const waitFor = async (cond: () => boolean, ms = 60000) => { const t0 = Date.now(); while (!cond()) { if (Date.now() - t0 > ms) return false; await new Promise(r => setTimeout(r, 300)); } return true; };
|
||
|
||
test('초안이 곧바로 렌더 가능하고, write_scad 같은 변경 → autoRenderForScad가 그림을 갱신(autoRender=false면 안 함)', async () => {
|
||
const ws = tmpWs();
|
||
const scad = path.join(ws, 'workshop/p1/CAD/rig.scad');
|
||
fs.writeFileSync(scad, RIG);
|
||
const d = await draftAssembly(ws, 'workshop/p1/CAD/rig.scad');
|
||
assert.ok(d.ok);
|
||
if (!d.ok) return;
|
||
const data = loadWorkshop(ws);
|
||
data.projects[0].assembly = d.assembly;
|
||
saveWorkshop(ws, data);
|
||
const r1 = await renderAssemblyManual(ws, 'p1', d.assembly);
|
||
assert.ok(r1.ok, JSON.stringify(r1));
|
||
if (!r1.ok) return;
|
||
assert.equal(readManifest(ws, 'p1')!.hash, r1.hash);
|
||
|
||
// 1) autoRender=false → 갱신 안 함
|
||
const off = loadWorkshop(ws); off.projects[0].assembly!.autoRender = false; saveWorkshop(ws, off);
|
||
fs.appendFileSync(scad, '\n// edit 1\n');
|
||
autoRenderForScad(ws, scad);
|
||
await new Promise(r => setTimeout(r, 1500));
|
||
assert.equal(readManifest(ws, 'p1')!.hash, r1.hash, 'autoRender=false인데 갱신됨');
|
||
|
||
// 2) autoRender=true → manifest 해시가 바뀔 때까지 갱신
|
||
const on = loadWorkshop(ws); on.projects[0].assembly!.autoRender = true; saveWorkshop(ws, on);
|
||
autoRenderForScad(ws, scad);
|
||
autoRenderForScad(ws, scad); // 실행 중 재호출은 합쳐진다(동시 렌더 없음)
|
||
assert.ok(await waitFor(() => readManifest(ws, 'p1')!.hash !== r1.hash), '자동 재렌더가 안 됨');
|
||
const m = readManifest(ws, 'p1')!;
|
||
assert.equal(m.hash, currentAssemblyHash(ws, loadWorkshop(ws).projects[0].assembly!));
|
||
|
||
// 3) 다른 파일이 바뀌면 무관, 렌더한 적 없는 프로젝트는 건드리지 않는다
|
||
const other = path.join(ws, 'workshop/p1/CAD/other.scad');
|
||
fs.writeFileSync(other, 'cube(1);');
|
||
const before = readManifest(ws, 'p1')!.renderedAt;
|
||
autoRenderForScad(ws, other);
|
||
await new Promise(r => setTimeout(r, 1000));
|
||
assert.equal(readManifest(ws, 'p1')!.renderedAt, before);
|
||
});
|
||
});
|
||
|
||
describe('workshop_project draft_assembly', { skip: !hasTools && 'openscad/PIL 없음' }, () => {
|
||
const run = (ws: string, args: any) => workshopProjectTool.execute({ ...args, _workspacePath: ws });
|
||
test('초안만 보여주기(저장 안 함) → apply=true면 저장', async () => {
|
||
const ws = tmpWs();
|
||
fs.writeFileSync(path.join(ws, 'workshop/p1/CAD/rig.scad'), RIG);
|
||
const a = await run(ws, { action: 'draft_assembly', scad_path: 'workshop/p1/CAD/rig.scad' });
|
||
assert.equal(a.success, true, a.error);
|
||
assert.match(a.stdout || '', /자동 초안: 부품 6개, 단계 4개/);
|
||
assert.match(a.stdout || '', /제외\(투명\/참고용 추정\): ghost_cone\(\)/);
|
||
assert.match(a.stdout || '', /저장되지 않음/);
|
||
assert.equal(loadWorkshop(ws).projects[0].assembly, undefined);
|
||
const b = await run(ws, { action: 'draft_assembly', scad_path: 'workshop/p1/CAD/rig.scad', apply: true });
|
||
assert.equal(b.success, true);
|
||
assert.equal(loadWorkshop(ws).projects[0].assembly!.parts.length, 6);
|
||
assert.equal(loadWorkshop(ws).projects[0].assembly!.autoRender, true);
|
||
assert.equal((await run(ws, { action: 'draft_assembly', scad_path: 'workshop/p1/CAD/none.scad' })).success, false);
|
||
});
|
||
});
|