// 작업실 파일 폴더 규칙(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, previewPngPath } from '../src/gateway/routes/workshop-layout'; describe('targetForFile — 폴더 규칙', () => { const cases: [string, string | null][] = [ // .scad 소스는 SCAD/ (사용자 지시로 STL과 분리), STL 등 모델 파일은 CAD/ ['CAD/print/base_plate.stl', 'CAD/base_plate.stl'], ['CAD/print/scanner-rig.scad', 'SCAD/scanner-rig.scad'], ['CAD/rig.scad', 'SCAD/rig.scad'], ['print/base.stl', 'CAD/base.stl'], ['robot.scad', 'SCAD/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], ['SCAD/a.scad', null], ['견적/x.scad', null], // 사용자가 만든 폴더의 .scad도 존중 ['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', 'SCAD/rig.scad', 'CAD/참고/cam.stl', '견적/a.png', '개요/readme.md', '사진/asm.png', '영상/demo.mp4', '조립설명서/overview.png', ].sort()); assert.equal(fs.readFileSync(path.join(proj, 'SCAD/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/SCAD/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 }); }); }); describe('previewPngPath — STL 렌더 미리보기 저장 위치', () => { test('작업실 프로젝트의 STL이면 그 프로젝트 사진/ 폴더로', () => { assert.equal(previewPngPath('/w', '/w/workshop/proj_a/CAD/base.stl', 'iso_1'), '/w/workshop/proj_a/사진/base_iso_1.png'); assert.equal(previewPngPath('/w', '/w/workshop/proj_a/CAD/참고/cam.stl', 'top_2'), '/w/workshop/proj_a/사진/cam_top_2.png'); }); test('프로젝트 밖(print3d 등)이면 STL 옆에', () => { assert.equal(previewPngPath('/w', '/w/print3d/일회성/x.stl', 'iso_1'), '/w/print3d/일회성/x_iso_1.png'); assert.equal(previewPngPath('/w', '/w/other/y.stl', 'front_3'), '/w/other/y_front_3.png'); }); });