규칙(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>
161 lines
8.7 KiB
TypeScript
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 });
|
|
});
|
|
});
|