Files
homeclaw/tests/workshop-assembly.test.ts
kimandClaude Sonnet 5 cedf881f2d feat: 조립 설명서 호환 .scad 작성 규칙을 코드에 하드코딩 (규칙 문구·lint·SCAD/ 경로 보정)
배경: 테스트 프로젝트 로봇팔은 부품이 로컬 좌표 모듈이고 조립 자세를 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>
2026-09-25 11:18:29 +09:00

379 lines
22 KiB
TypeScript
Raw Permalink 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.
// 조립 설명서(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);
});
});