Files
homeclaw/tests/scad-conventions.test.ts
T
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

116 lines
6.2 KiB
TypeScript

// 조립 설명서 호환 .scad 작성 규칙(scad-conventions.ts) — lint, 경로 보정, write_scad 통합.
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 { lintScadAssembly, normalizeWorkshopScadPath, formatLint, SCAD_ASSEMBLY_RULES, SCAD_RULES_SHORT } from '../src/tools/scad-conventions';
import { writeScadTool } from '../src/tools/stl-cad';
const GOOD = `
H = 30;
// 베이스 판
module base(){ cube([40,40,5]); }
// 기둥
module post(x){ translate([x,0,5]) cube([5,5,H]); }
module asm_base(){ base(); }
module asm_post_l(){ post(0); }
module asm_post_r(){ post(35); }
module ref_floor(){ %cube([50,50,1]); }
PART = "all";
if (PART == "all") {
asm_base();
asm_post_l();
asm_post_r();
ref_floor();
} else if (PART == "base") base();
`;
const NO_BLOCK = `module a(){cube(1);} module b(){cube(2);} module c(){cube(3);}\ntranslate([0,0,1]) a();\nb();\nc();`;
const hasTools = spawnSync('openscad', ['--version']).status === 0;
describe('lintScadAssembly', () => {
test('규칙을 지킨 파일은 위반 없음(ref_ 고스트/else if 부품 분기 허용)', () => {
const l = lintScadAssembly(GOOD);
assert.equal(l.applicable, true);
assert.deepEqual(l.issues, []);
assert.equal(formatLint(l), '');
});
test('모듈이 3개 미만인 단순 파일은 대상이 아님', () => {
assert.deepEqual(lintScadAssembly('module a(){cube(1);}\nmodule b(){cube(2);}\na(); b();'), { applicable: false, issues: [] });
});
test('조립 블록이 없으면 위반', () => {
const l = lintScadAssembly(NO_BLOCK);
assert.equal(l.applicable, true);
assert.equal(l.issues.length, 1);
assert.match(l.issues[0], /조립 블록이 없습니다/);
assert.match(formatLint(l), /조립 설명서 호환 규칙 위반/);
});
test('블록 안에 변환/형상/반복문이 직접 있으면 위반', () => {
for (const inner of ['translate([0,0,5]) asm_a();', 'asm_a(); cylinder(r=1,h=2);', 'for (i=[0:2]) asm_a();', 'color("red") asm_a();', 'asm_a(); asm_b(); %asm_c();']) {
const src = `module asm_a(){cube(1);} module asm_b(){cube(2);} module asm_c(){cube(3);}\nPART="all";\nif (PART == "all") { ${inner} asm_b(); }`;
const l = lintScadAssembly(src);
assert.ok(l.issues.some(i => /모듈 호출이 아닌 코드/.test(i)), `"${inner}" → ${JSON.stringify(l.issues)}`);
}
});
test('블록의 부품이 1개뿐이면 위반(전체를 한 모듈로 부르는 구조)', () => {
const src = 'module p(){cube(1);} module q(){cube(2);} module robot(){p();q();}\nPART="all";\nif (PART == "all") { robot(); }';
const l = lintScadAssembly(src);
assert.ok(l.issues.some(i => /부품이 1개 이하/.test(i)), JSON.stringify(l.issues));
});
test('"assembly" 별칭 블록도 인정', () => {
assert.deepEqual(lintScadAssembly(GOOD.replace('PART == "all"', 'PART == "assembly"')).issues, []);
});
});
describe('normalizeWorkshopScadPath — 프로젝트 안의 새 .scad는 SCAD/ 로', () => {
const cases: [string, string, boolean][] = [
['workshop/p1/rig.scad', 'workshop/p1/SCAD/rig.scad', true],
['workshop/p1/CAD/rig.scad', 'workshop/p1/SCAD/rig.scad', true],
['workshop/p1/print/rig.scad', 'workshop/p1/SCAD/rig.scad', true],
['workshop/p1/SCAD/rig.scad', 'workshop/p1/SCAD/rig.scad', false],
['workshop/p1/실험/rig.scad', 'workshop/p1/실험/rig.scad', false], // 사용자가 만든 폴더는 존중
['rig.scad', 'rig.scad', false], // 프로젝트 밖은 그대로
['print3d/x/rig.scad', 'print3d/x/rig.scad', false],
['./workshop/p1/CAD/rig.scad', 'workshop/p1/SCAD/rig.scad', true],
];
for (const [from, to, moved] of cases) test(`${from} → ${to}`, () => assert.deepEqual(normalizeWorkshopScadPath(from), { path: to, moved }));
});
test('규칙 문구에 핵심 요소가 들어 있다(프롬프트/도구 설명이 이 상수를 그대로 씀)', () => {
for (const kw of ['asm_', 'PART == "all"', 'ref_', 'SCAD/']) assert.ok(SCAD_ASSEMBLY_RULES.includes(kw), kw);
for (const kw of ['asm_', 'PART == "all"', 'ref_']) assert.ok(SCAD_RULES_SHORT.includes(kw), kw);
});
describe('write_scad 통합(실제 OpenSCAD)', { skip: !hasTools && 'openscad 없음' }, () => {
const ws = () => fs.mkdtempSync(path.join(os.tmpdir(), 'wscad-'));
test('CAD/에 쓰려 해도 SCAD/에 저장 + 이동 안내 + 규칙 준수 파일은 위반 경고 없음', async () => {
const dir = ws();
const r = await writeScadTool.execute({ scad_path: 'workshop/p1/CAD/rig.scad', content: GOOD, _workspacePath: dir });
assert.equal(r.success, true, r.error);
assert.ok(fs.existsSync(path.join(dir, 'workshop/p1/SCAD/rig.scad')));
assert.ok(!fs.existsSync(path.join(dir, 'workshop/p1/CAD/rig.scad')));
assert.match(r.stdout || '', /폴더 규칙에 따라 "workshop\/p1\/CAD\/rig\.scad" 대신 "workshop\/p1\/SCAD\/rig\.scad"/);
assert.doesNotMatch(r.stdout || '', /조립 설명서 호환 규칙 위반/);
fs.rmSync(dir, { recursive: true, force: true });
});
test('조립 블록이 없는 조립체는 저장은 되되 위반 안내가 붙어 모델이 고치게 한다', async () => {
const dir = ws();
const r = await writeScadTool.execute({ scad_path: 'workshop/p1/SCAD/rig.scad', content: NO_BLOCK, _workspacePath: dir });
assert.equal(r.success, true, r.error);
assert.doesNotMatch(r.stdout || '', /폴더 규칙에 따라/);
assert.match(r.stdout || '', /조립 설명서 호환 규칙 위반/);
assert.match(r.stdout || '', /조립 블록이 없습니다/);
assert.ok(fs.existsSync(path.join(dir, 'workshop/p1/SCAD/rig.scad')));
fs.rmSync(dir, { recursive: true, force: true });
});
test('프로젝트 밖 경로는 옛 동작 그대로(보정·위반 안내 없음, 단순 파일)', async () => {
const dir = ws();
const r = await writeScadTool.execute({ scad_path: 'notes/simple.scad', content: 'cube(5);', _workspacePath: dir });
assert.equal(r.success, true, r.error);
assert.ok(fs.existsSync(path.join(dir, 'notes/simple.scad')));
assert.doesNotMatch(r.stdout || '', /폴더 규칙|규칙 위반/);
fs.rmSync(dir, { recursive: true, force: true });
});
});