Files
homeclaw/tests/workshop-layout.test.ts
T
kimandClaude Sonnet 5 ba193eec3c feat: 작업실 파일 폴더 규칙 통일 — STL을 .scad와 같은 폴더에, 프로젝트별 제각각 구조 정돈
규칙(workshop-layout.ts 한 곳에 정의, 도구·스크립트가 공유):
  workshop/<id>/CAD/(.scad+STL 같은 폴더) · CAD/참고/(비인쇄 참고 모델) · CAD/다운로드/ · 출력/ · 사진/ · 영상/ · 조립설명서/
  print3d/K2웹슬라이서/ · 모델검색/ · 일회성/ · profiles/
인쇄용 STL은 .scad 옆에 두되 참고용은 CAD/참고/로 분리 유지(09-22 "참고용을 인쇄 대상으로 착각" 방지)

- 이동 스크립트 scripts/migrate-workshop-layout.ts: 기본 dry-run, --apply(이동만·덮어쓰기 없음·충돌은 건너뛰고 보고),
  --undo <manifest>. .scad 경로 참조(조립 명세)와 문서 문구(CAD/print→CAD 등) 갱신, 빈 폴더만 정리. 멱등
- 도구가 같은 규칙으로 저장: print3d_model(모델→CAD/다운로드, G-code→출력, 프로젝트 없으면 print3d/일회성),
  model_download(CAD/다운로드 또는 print3d/모델검색), K2 웹 슬라이서(print3d/K2웹슬라이서). 옛 print3d/<프로젝트이름>/ 제거
- 채팅 프롬프트: STL은 .scad와 같은 폴더(옛 09-22 CAD/print 하위폴더 규칙 폐지), 프로젝트 폴더 규칙 안내
- 파일 탭: 권장 폴더를 비어 있어도 업로드 대상으로 표시하고 규칙 순서로 정렬
- 테스트 34건(규칙 표, 멱등, 충돌 미덮어쓰기, apply→undo 왕복 무손실, saveDirs, print3d 계획)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-25 09:37:55 +09:00

161 lines
8.7 KiB
TypeScript

// 작업실 파일 폴더 규칙(workshop-layout.ts) + 이동 스크립트(apply/undo) 테스트.
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 { targetForFile, planProjectMoves, pruneEmptyDirs, renameTextPaths, saveDirs, planPrint3dMoves } from '../src/gateway/routes/workshop-layout';
describe('targetForFile — 폴더 규칙', () => {
const cases: [string, string | null][] = [
// 인쇄용 STL은 .scad와 같은 폴더(CAD/)
['CAD/print/base_plate.stl', 'CAD/base_plate.stl'],
['CAD/print/scanner-rig.scad', 'CAD/scanner-rig.scad'],
['print/base.stl', 'CAD/base.stl'],
['robot.scad', 'CAD/robot.scad'],
['robot.stl', 'CAD/robot.stl'],
// 참고용은 분리 유지(09-22 "인쇄 대상으로 착각" 방지)
['CAD/reference/cam_left.stl', 'CAD/참고/cam_left.stl'],
['reference/x.stl', 'CAD/참고/x.stl'],
// 받아 온 모델 묶음은 자기 폴더 유지, stls/ 접두어만 제거
['stls/SO101/Leader/a.stl', 'CAD/SO101/Leader/a.stl'],
// 이미지/영상/G-code
['CAD/assembly.png', '사진/assembly.png'],
['CAD/reference/assembly.png', '사진/assembly.png'],
['iso.png', '사진/iso.png'],
['robot_2min.mp4', '영상/robot_2min.mp4'],
['out.gcode', '출력/out.gcode'],
// 이미 규칙에 맞음 / 손대지 않는 것
['CAD/base.stl', null],
['CAD/참고/x.stl', null],
['CAD/SO101/Leader/a.stl', null],
['CAD/다운로드/m.stl', null],
['사진/a.png', null],
['영상/a.mp4', null],
['출력/a.gcode', null],
['조립설명서/step-01.png', null],
['개요/readme.md', null],
['.hidden/a.stl', null],
['메모.txt', null],
['data.csv', null],
// 사용자가 직접 만든 폴더는 존중
['견적/부품도.png', null],
['참고자료/a.stl', null],
];
for (const [from, to] of cases) test(`${from} → ${to ?? '(그대로)'}`, () => assert.equal(targetForFile(from), to));
test('멱등: 결과를 다시 넣으면 그대로', () => {
for (const [, to] of cases) if (to) assert.equal(targetForFile(to), null, to);
});
});
describe('문서 문구 갱신', () => {
test('옛 폴더 이름을 새 이름으로', () => {
assert.equal(renameTextPaths('CAD/print/ 와 CAD/reference/ 그리고 CAD/print, CAD/reference, CAD/렌더'), 'CAD/ 와 CAD/참고/ 그리고 CAD, CAD/참고, 사진');
});
});
function tmpWorkspace(): string {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'layout-'));
const ws = path.join(dir, '.smallclaw', 'users', 'tester', 'workspace');
const put = (rel: string, body = 'x') => { const p = path.join(ws, 'workshop', 'p1', rel); fs.mkdirSync(path.dirname(p), { recursive: true }); fs.writeFileSync(p, body); };
put('CAD/print/rig.scad', 'scad-1'); put('CAD/print/a.stl', 'stl-a'); put('CAD/reference/cam.stl', 'ref'); put('CAD/reference/asm.png', 'png');
put('demo.mp4', 'v'); put('stls/SO/x.stl', 'x'); put('개요/readme.md', 'r'); put('조립설명서/overview.png', 'o'); put('견적/a.png', 'keep');
fs.mkdirSync(path.join(ws, '.smallclaw'), { recursive: true });
fs.writeFileSync(path.join(ws, '.smallclaw', 'workshop.json'), JSON.stringify({
activeProjectId: 'p1',
projects: [{ id: 'p1', name: 'P', parts: [], phases: [], notes: 'CAD/print/ 에 있음', description: 'CAD/reference/ 는 참고용',
assembly: { scad: 'workshop/p1/CAD/print/rig.scad', view: { rx: 60, rz: 35 }, explode: 1, autoRender: true, parts: [], steps: [] } }],
}));
return dir;
}
const tree = (root: string, base = ''): string[] => fs.readdirSync(path.join(root, base), { withFileTypes: true }).flatMap(e => {
const rel = base ? `${base}/${e.name}` : e.name;
return e.isDirectory() ? tree(root, rel) : [rel];
}).sort();
describe('planProjectMoves / 이동 스크립트', () => {
test('충돌은 덮어쓰지 않고 건너뛴다', () => {
const dir = tmpWorkspace();
const ws = path.join(dir, '.smallclaw/users/tester/workspace');
fs.writeFileSync(path.join(ws, 'workshop/p1/CAD/a.stl'), 'existing'); // CAD/print/a.stl 의 도착지에 이미 존재
const { moves, conflicts } = planProjectMoves(ws, 'p1');
assert.ok(conflicts.some(c => c.from === 'CAD/print/a.stl'));
assert.ok(!moves.some(m => m.from === 'CAD/print/a.stl'));
fs.rmSync(dir, { recursive: true, force: true });
});
test('pruneEmptyDirs: 빈 폴더만 지운다', () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'prune-'));
fs.mkdirSync(path.join(dir, 'a/b'), { recursive: true }); fs.mkdirSync(path.join(dir, 'c'), { recursive: true }); fs.writeFileSync(path.join(dir, 'c/f.txt'), 'x');
const removed = pruneEmptyDirs(dir);
assert.deepEqual(removed.sort(), ['a', 'a/b']);
assert.ok(fs.existsSync(path.join(dir, 'c/f.txt')));
fs.rmSync(dir, { recursive: true, force: true });
});
test('dry-run은 아무것도 안 바꾸고, --apply로 이동+경로 갱신, --undo로 원복(내용 보존)', () => {
const dir = tmpWorkspace();
const ws = path.join(dir, '.smallclaw/users/tester/workspace');
const proj = path.join(ws, 'workshop/p1');
const before = tree(proj);
const env = { ...process.env, SMALLCLAW_DATA_DIR: dir };
const run = (...a: string[]) => spawnSync('npx', ['tsx', 'scripts/migrate-workshop-layout.ts', '--user', 'tester', ...a], { cwd: path.join(__dirname, '..'), env, encoding: 'utf-8' });
const dry = run();
assert.match(dry.stdout, /dry-run/);
assert.deepEqual(tree(proj), before, 'dry-run이 파일을 바꿈');
const app = run('--apply');
assert.equal(app.status, 0, app.stderr);
assert.deepEqual(tree(proj), [
'CAD/SO/x.stl', 'CAD/a.stl', 'CAD/rig.scad', 'CAD/참고/cam.stl', '견적/a.png', '개요/readme.md', '사진/asm.png', '영상/demo.mp4', '조립설명서/overview.png',
].sort());
assert.equal(fs.readFileSync(path.join(proj, 'CAD/rig.scad'), 'utf-8'), 'scad-1'); // 내용 그대로
const data = JSON.parse(fs.readFileSync(path.join(ws, '.smallclaw/workshop.json'), 'utf-8'));
assert.equal(data.projects[0].assembly.scad, 'workshop/p1/CAD/rig.scad');
assert.equal(data.projects[0].notes, 'CAD/ 에 있음');
assert.equal(fs.existsSync(path.join(proj, 'CAD/print')), false, '빈 폴더 정리');
assert.equal(fs.existsSync(path.join(proj, 'stls')), false);
const again = run('--apply'); // 멱등: 두 번째는 이동할 게 없다
assert.match(again.stdout, /이동 0개/);
const manifest = fs.readdirSync(path.join(ws, '.smallclaw')).find(f => f.startsWith('workshop-layout-migration-'))!;
const undo = run('--undo', path.join(ws, '.smallclaw', manifest));
assert.match(undo.stdout, /되돌림/);
assert.deepEqual(tree(proj), before);
assert.equal(JSON.parse(fs.readFileSync(path.join(ws, '.smallclaw/workshop.json'), 'utf-8')).projects[0].assembly.scad, 'workshop/p1/CAD/print/rig.scad');
fs.rmSync(dir, { recursive: true, force: true });
});
});
describe('저장 폴더 결정(saveDirs) / print3d 정돈', () => {
test('프로젝트가 있으면 모델→CAD/다운로드, G-code→출력 / 없으면 print3d 하위', () => {
const p = saveDirs('/w', 'proj_a');
assert.equal(p.modelDir, '/w/workshop/proj_a/CAD/다운로드');
assert.equal(p.outDir, '/w/workshop/proj_a/출력');
assert.equal(p.downloadDir, '/w/workshop/proj_a/CAD/다운로드');
const n = saveDirs('/w');
assert.equal(n.modelDir, '/w/print3d/일회성');
assert.equal(n.outDir, '/w/print3d/일회성');
assert.equal(n.downloadDir, '/w/print3d/모델검색');
});
test('planPrint3dMoves: k2ui_*→K2웹슬라이서, model-search→모델검색, 루트 잔여→일회성, k2_config.ini→profiles, 기존 하위폴더/프로파일은 그대로', () => {
const ws = fs.mkdtempSync(path.join(os.tmpdir(), 'p3d-'));
const put = (rel: string) => { const f = path.join(ws, rel); fs.mkdirSync(path.dirname(f), { recursive: true }); fs.writeFileSync(f, 'x'); };
put('print3d/k2ui_123.stl'); put('print3d/k2ui_123.gcode'); put('print3d/loose.stl'); put('print3d/model-search/a.stl');
put('print3d/esp32_camera/case.stl'); put('print3d/profiles/__current.ini'); put('k2_config.ini');
const m = planPrint3dMoves(ws).map(x => `${x.from} → ${x.to}`).sort();
assert.deepEqual(m, [
'k2_config.ini → print3d/profiles/k2_config.ini',
'print3d/k2ui_123.gcode → print3d/K2웹슬라이서/k2ui_123.gcode',
'print3d/k2ui_123.stl → print3d/K2웹슬라이서/k2ui_123.stl',
'print3d/loose.stl → print3d/일회성/loose.stl',
'print3d/model-search/a.stl → print3d/모델검색/a.stl',
].sort());
fs.rmSync(ws, { recursive: true, force: true });
});
});