Files
homeclaw/tests/workshop-review-fixes.test.ts
T
kimandClaude Sonnet 5 d235ee94f3 refactor: 작업실 핵심 스크립트를 기능별 파일로 분리 (core/parts/tasks/files/chat/init)
- workshop-app.js(83KB) → workshop-core.js(30KB: 인증·이스케이프·상태/저장/충돌/폴링·탭·지연 로더·모달/이력),
  workshop-parts.js(17KB), workshop-tasks.js(6KB: 작업+메모·개요), workshop-files.js(13KB: 파일·슬라이서),
  workshop-chat.js(18KB: 채팅·사진 첨부·리사이즈·모델 pill), workshop-init.js(1KB)
- 원래 한 파일의 섹션 순서 그대로 로드(core→parts→tasks→files→chat→init)해 로딩 순서 문제를 만들지 않음.
  escHtml/safeUrl은 여러 모듈이 쓰므로 core로 이동
- 무손실 검증: 분리 전후 코드 줄 비교(사라진 코드 줄 0, 함수 선언 118개 동일), 실브라우저 45개 항목 + 실서버 읽기 전용 확인
- vm 저장 로직 테스트는 workshop-core.js를 읽도록 변경

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

563 lines
31 KiB
TypeScript

// 작업실 앱 전수 검토(2026-09-24)에서 발견·수정한 결함들의 회귀 테스트.
// 서버측(저장소/도구/업로드 파일명/가드 정규식) + 프런트엔드 저장 로직(vm 시뮬레이션).
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 vm from 'vm';
import {
loadWorkshop, saveWorkshop, workshopPath, matchByName, normalizeProjectInput, WorkshopData,
} from '../src/gateway/routes/workshop-storage';
import { decodeUploadFilename } from '../src/gateway/routes/routes-workshop';
import { workshopProjectTool, sessionProjectId } from '../src/tools/workshop-project';
import { claimsWorkshopSaved, isWorkshopMutationRequest } from '../src/gateway/guards/prompt-gates';
function tmpWorkspace(data?: WorkshopData): string {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ws-test-'));
if (data) saveWorkshop(dir, data);
return dir;
}
const proj = (id: string, name: string, extra: any = {}) => ({ id, name, parts: [], phases: [], notes: '', ...extra });
const run = (ws: string, args: any) => workshopProjectTool.execute({ ...args, _workspacePath: ws });
describe('헛보고 가드 정규식 — 한글 종결형', () => {
test('반말/평서 종결도 클레임으로 잡는다(옛 \\b 버그)', () => {
for (const s of ['저장했습니다.', '저장했어요', '저장했어.', '저장했다.', '추가했다', '수정됐어', '메모에 저장했어'])
assert.equal(claimsWorkshopSaved(s), true, s);
});
test('질문형·수식형·미래형은 클레임이 아니다', () => {
for (const s of ['저장했너?', '저장했어?', '저장했던 메모', '저장했다는 얘기', '저장하겠습니다', '저장했다가 지웠다'])
assert.equal(claimsWorkshopSaved(s), false, s);
});
test('명사 없는 후속 확인 질문도 저장 요청 계열로 본다', () => {
assert.equal(isWorkshopMutationRequest('저장 했니?'), true);
assert.equal(isWorkshopMutationRequest('등록됐어?'), true);
assert.equal(isWorkshopMutationRequest('오늘 날씨 어때'), false);
});
});
describe('업로드 파일명 디코딩 — % 포함 이름이 프로세스를 죽이지 않는다', () => {
test('리터럴 %', () => {
assert.equal(decodeUploadFilename('100%.stl'), '100%.stl');
assert.equal(decodeUploadFilename('50%_infill.stl'), '50%_infill.stl');
});
test('RFC5987 퍼센트 인코딩과 평문', () => {
assert.equal(decodeUploadFilename('%ED%95%9C%EA%B8%80.stl'), '한글.stl');
assert.equal(decodeUploadFilename('a b.stl'), 'a b.stl');
});
});
describe('저장소', () => {
test('matchByName: 정확일치 우선, 없으면 부분일치', () => {
const items = ['모터', '모터드라이버', '서보'];
assert.deepEqual(matchByName(items, '모터', s => s), [{ index: 0 }]);
assert.deepEqual(matchByName(items, '드라이버', s => s), [{ index: 1 }]);
assert.deepEqual(matchByName(items, '모', s => s).length, 2);
assert.deepEqual(matchByName(items, '없음', s => s), []);
});
test('손상된 workshop.json은 시드로 대체하되 원본을 .corrupt로 보존', () => {
const ws = tmpWorkspace();
fs.mkdirSync(path.dirname(workshopPath(ws)), { recursive: true });
fs.writeFileSync(workshopPath(ws), '{"projects":[{"id":"x","name":"소중한 데이터"', 'utf-8');
const data = loadWorkshop(ws);
assert.equal(data.projects[0].id, 'robot');
const dir = path.dirname(workshopPath(ws));
const backups = fs.readdirSync(dir).filter(f => f.includes('.corrupt-'));
assert.equal(backups.length, 1);
assert.ok(fs.readFileSync(path.join(dir, backups[0]), 'utf-8').includes('소중한 데이터'));
});
test('normalizeProjectInput: 누락 필드 보정 + 잘못된 타입/id 거부·교체', () => {
const ok = normalizeProjectInput({ id: 'p', name: 'A', parts: [{ id: "x'); alert(1);//", name: 'n' }], phases: [{ id: 'ph1', name: 's' }] }, 'p');
assert.ok(ok.ok);
if (ok.ok) {
assert.equal(ok.project.notes, '');
assert.deepEqual(ok.project.phases[0].tasks, []);
assert.deepEqual(ok.project.parts[0].links, []);
assert.match(ok.project.parts[0].id, /^p_[a-z0-9]+$/); // 위험한 id는 교체
assert.equal(ok.project.phases[0].id, 'ph1');
}
assert.equal(normalizeProjectInput({ id: 'p', name: 'A', parts: [], phases: [], notes: 5 }, 'p').ok, false);
assert.equal(normalizeProjectInput({ id: 'p', name: 'A', parts: [], phases: [{ tasks: 'x' }] }, 'p').ok, false);
assert.equal(normalizeProjectInput({ id: 'other', name: 'A', parts: [], phases: [] }, 'p').ok, false);
});
});
describe('workshop_project 도구', () => {
test('정확한 이름이 있으면 부분일치 모호 오류 없이 그 부품을 수정', async () => {
const ws = tmpWorkspace({
activeProjectId: 'a',
projects: [proj('a', 'A', { parts: [
{ id: 'p1', name: '모터', qty: 1, unitPrice: 100, status: '검토중', memo: '', links: [] },
{ id: 'p2', name: '모터드라이버', qty: 1, unitPrice: 200, status: '검토중', memo: '', links: [] },
] })],
});
const r = await run(ws, { action: 'update_part', part_name: '모터', qty: 4 });
assert.equal(r.success, true, r.error);
const d = loadWorkshop(ws);
assert.equal(d.projects[0].parts[0].qty, 4);
assert.equal(d.projects[0].parts[1].qty, 1);
});
test('project_name 없으면 세션(ws_<id>)의 프로젝트가 대상 — 전역 활성 포인터 무시', async () => {
const ws = tmpWorkspace({ activeProjectId: 'a', projects: [proj('a', 'A'), proj('b', 'B')] });
assert.equal(sessionProjectId('ws_b'), 'b');
const r = await run(ws, { action: 'add_note', note_text: 'B에 쓰는 메모', _sessionId: 'ws_b' });
assert.equal(r.success, true);
const d = loadWorkshop(ws);
assert.equal(d.projects[1].notes, 'B에 쓰는 메모');
assert.equal(d.projects[0].notes, '');
// 세션 정보가 없으면 옛 동작(활성 프로젝트)
await run(ws, { action: 'add_note', note_text: 'A 메모' });
assert.equal(loadWorkshop(ws).projects[0].notes, 'A 메모');
});
test('create_project 같은 이름 중복 생성 안 함(전환만)', async () => {
const ws = tmpWorkspace({ activeProjectId: 'a', projects: [proj('a', '로봇팔'), proj('b', '기타')] });
const r = await run(ws, { action: 'create_project', project_name: '로봇팔' });
assert.equal(r.success, true);
const d = loadWorkshop(ws);
assert.equal(d.projects.length, 2);
assert.equal(d.activeProjectId, 'a');
});
test('add_part/add_note/add_task/add_phase 중복은 추가하지 않고 성공(가드 재시도가 중복을 못 만든다)', async () => {
const ws = tmpWorkspace({ activeProjectId: 'a', projects: [proj('a', 'A', { phases: [{ id: 'ph', name: '준비', tasks: [] }] })] });
for (let i = 0; i < 2; i++) {
assert.equal((await run(ws, { action: 'add_part', name: '608ZZ', qty: 4 })).success, true);
assert.equal((await run(ws, { action: 'add_note', note_text: '메모 한 줄' })).success, true);
assert.equal((await run(ws, { action: 'add_task', phase_name: '준비', task_text: '출력' })).success, true);
assert.equal((await run(ws, { action: 'add_phase', new_phase_name: '조립' })).success, true);
}
// 중복 응답이 모델의 반복 호출을 유발하지 않도록 "이미 충족됨" 안내가 붙는다(09-25 LOOP WARN 실측)
const dup = await run(ws, { action: 'add_task', phase_name: '준비', task_text: '출력' });
assert.match(dup.stdout || '', /같은 호출을 반복하지 말고/);
const p = loadWorkshop(ws).projects[0];
assert.equal(p.parts.length, 1);
assert.equal(p.notes, '메모 한 줄');
assert.equal(p.phases.length, 2);
assert.equal(p.phases[0].tasks.length, 1);
});
});
// ── 프런트엔드 저장 로직(workshop-core.js 상태/저장 구간을 vm으로 실행) ──────────────
describe('workshop-core.js 저장 로직', () => {
const html = fs.readFileSync(path.join(__dirname, '../web-ui/js/app/workshop-core.js'), 'utf-8').split('\n'); // 2026-09-25 핵심 스크립트(기능별 분리 후)
const start = html.findIndex(l => l.startsWith('function escAttr'));
const from = html.findIndex(l => l.startsWith('let workshopData='));
const to = html.findIndex(l => l.startsWith('// ── Tabs'));
const escHtmlLine = html.find(l => l.startsWith('function escHtml('))!;
function boot(server: any, putDelayMs = 0) {
const puts: any[] = [];
let clock = 1000;
const banner: any = { style: {}, innerHTML: '' };
const el: any = { textContent: '', value: '', classList: { contains: () => false }, innerHTML: '', style: {}, disabled: false };
const ctx: any = {
// 서버와 같은 낙관적 락: 클라이언트가 본 updatedAt이 현재 값과 다르면(force 아님) 409.
fetch: async (url: string, opt: any = {}) => {
if (url === '/api/workshop/data') return { ok: true, status: 200, json: async () => JSON.parse(JSON.stringify(server)) };
const m = url.match(/^\/api\/workshop\/project\/([^?]+)(\?force=1)?$/);
if (m && opt.method === 'PUT') {
if (putDelayMs) await new Promise(r => setTimeout(r, putDelayMs)); // 네트워크 지연 흉내 — 겹치는 저장이 끼어들 틈
const p = JSON.parse(opt.body);
const i = server.projects.findIndex((x: any) => x.id === p.id);
const cur = server.projects[i];
if (cur && cur.updatedAt && (Number(p.updatedAt) || 0) !== cur.updatedAt && !m[2]) {
return { ok: false, status: 409, json: async () => ({ error: 'conflict', server: JSON.parse(JSON.stringify(cur)) }) };
}
puts.push(p);
p.updatedAt = ++clock;
if (i >= 0) server.projects[i] = p; else server.projects.push(p);
return { ok: true, status: 200, json: async () => ({ success: true, updatedAt: p.updatedAt }) };
}
return { ok: true, status: 200, json: async () => ({}) };
},
document: { getElementById: (id: string) => (id === 'conflict-banner' ? banner : el), activeElement: null }, window: { addEventListener() {} },
setTimeout, clearTimeout, JSON, console, escHtml: undefined,
authH: () => ({}), renderParts() {}, renderPhases() {}, updateBudget() {}, syncBudgetInput() {}, refreshAssemblyPane() {}, refreshChatForProject() {}, clearStlSelection() {},
loadFiles() {}, expandedParts: new Set(), stlSelection: new Set(), alert() {},
};
vm.createContext(ctx);
vm.runInContext([escHtmlLine, ...html.slice(start, start + 1), ...html.slice(from, to),
`this.api={loadWorkshopData,softRefreshWorkshop,saveProject,resolveConflict,setNotes:(v)=>{notes=v;scheduleSave();},setBudget:(v)=>{budget=v;scheduleSave();},
saveProject,get conflict(){return conflict},get local(){return {notes,budget,parts}},escAttr};`].join('\n'), ctx);
return { api: ctx.api, puts, banner, tick: () => ++clock };
}
const mkServer = () => ({ projects: [proj('p1', 'A')], activeProjectId: 'p1' });
test('대시보드 편집 후 채팅 도구가 서버에 쓴 내용이 새로고침에 덮이지 않는다(핵심 회귀)', async () => {
const server: any = mkServer();
const { api } = boot(server);
await api.loadWorkshopData();
api.setNotes('사용자 메모');
await new Promise(r => setTimeout(r, 900)); // debounce 발동 → 저장
server.projects[0].notes += '\n[채팅 도구가 추가한 줄]';
await api.softRefreshWorkshop();
assert.equal(server.projects[0].notes, '사용자 메모\n[채팅 도구가 추가한 줄]');
});
test('바뀐 게 없으면 PUT하지 않는다', async () => {
const server: any = mkServer();
const { api, puts } = boot(server);
await api.loadWorkshopData();
await api.softRefreshWorkshop();
assert.equal(puts.length, 0);
});
test('저장 대기 중인 편집은 새로고침 전에 먼저 flush된다', async () => {
const server: any = mkServer();
const { api } = boot(server);
await api.loadWorkshopData();
api.setNotes('쓰는 중'); // 600ms 타이머 대기 상태
await api.softRefreshWorkshop();
assert.equal(server.projects[0].notes, '쓰는 중');
});
test('escAttr는 텍스트 노드용 < > & 도 이스케이프(XSS)', () => {
const { api } = boot(mkServer());
assert.equal(api.escAttr('<img src=x onerror=alert(1)>'), '&lt;img src=x onerror=alert(1)&gt;');
assert.equal(api.escAttr('A&B "q"'), 'A&amp;B &quot;q&quot;');
assert.equal(api.escAttr(0), '0');
});
test('다른 곳이 먼저 고쳤으면 저장을 보류하고 충돌 배너를 띄운다(조용히 덮지 않음)', async () => {
const server: any = mkServer();
const { api, banner, puts } = boot(server);
await api.loadWorkshopData();
server.projects[0].notes = '채팅 도구가 쓴 내용'; server.projects[0].updatedAt = 5000; // 내가 모르는 사이 서버가 바뀜
api.setNotes('내가 쓴 내용');
await new Promise(r => setTimeout(r, 900));
assert.equal(puts.length, 0);
assert.equal(server.projects[0].notes, '채팅 도구가 쓴 내용');
assert.ok(api.conflict);
assert.equal(banner.style.display, 'flex');
assert.match(banner.innerHTML, /덮어쓰기/);
});
test('충돌 해결 — 내 변경으로 덮어쓰기(force)', async () => {
const server: any = mkServer();
const { api, puts } = boot(server);
await api.loadWorkshopData();
server.projects[0].notes = '서버쪽'; server.projects[0].updatedAt = 5000;
api.setNotes('내 것');
await new Promise(r => setTimeout(r, 900));
await api.resolveConflict('mine');
assert.equal(server.projects[0].notes, '내 것');
assert.equal(api.conflict, null);
assert.equal(puts.length, 1);
});
test('충돌 해결 — 서버 버전 채택(내 변경 버림)', async () => {
const server: any = mkServer();
const { api } = boot(server);
await api.loadWorkshopData();
server.projects[0].notes = '서버쪽'; server.projects[0].updatedAt = 5000;
api.setNotes('내 것');
await new Promise(r => setTimeout(r, 900));
await api.resolveConflict('server');
assert.equal(api.conflict, null);
assert.equal(api.local.notes, '서버쪽');
assert.equal(server.projects[0].notes, '서버쪽');
});
test('저장이 동시에 두 번 불려도(자동 흐름의 flush + debounce 타이머) 가짜 충돌이 생기지 않는다', async () => {
const server: any = mkServer();
const { api, puts } = boot(server, 30);
await api.loadWorkshopData();
api.setNotes('첫 편집');
// debounce 타이머 저장과 flushNow(=saveProject 직접 호출)가 겹치는 상황
const [a, b] = [api.saveProject(), api.saveProject()];
await Promise.all([a, b]);
assert.equal(api.conflict, null, '자기 자신의 저장과 충돌로 오판');
assert.equal(server.projects[0].notes, '첫 편집');
assert.equal(puts.length, 1, '두 번째 저장은 무변경이라 PUT하지 않아야 함');
api.setNotes('두 번째 편집');
await Promise.all([api.saveProject(), api.saveProject(), api.saveProject()]);
assert.equal(api.conflict, null);
assert.equal(server.projects[0].notes, '두 번째 편집');
});
test('409여도 서버 내용이 내 내용과 같으면(내 이전 저장의 버전) 충돌 배너를 띄우지 않는다', async () => {
const server: any = mkServer();
const { api } = boot(server);
await api.loadWorkshopData();
api.setNotes('같은 내용');
await new Promise(r => setTimeout(r, 900)); // 정상 저장(서버 updatedAt 갱신)
server.projects[0].updatedAt = 9999; // 버전만 어긋남(내용은 동일)
api.setNotes('같은 내용 '); // 클라가 다시 dirty가 되도록 잠깐 바꿨다가
api.setNotes('같은 내용'); // 원래 내용으로 복귀
await api.saveProject();
assert.equal(api.conflict, null);
});
test('폴링 갱신: 저장 대기/충돌 중엔 서버 값을 안 끌어오고, 조용할 땐 반영한다', async () => {
const server: any = mkServer();
const { api } = boot(server);
await api.loadWorkshopData();
server.projects[0].notes = '다른 탭이 쓴 내용'; server.projects[0].updatedAt = 7000;
await api.softRefreshWorkshop({ poll: true }); // 조용함 → 반영
assert.equal(api.local.notes, '다른 탭이 쓴 내용');
api.setNotes('타이핑 중'); // 저장 대기 중
server.projects[0].notes = '또 다른 변경'; server.projects[0].updatedAt = 8000;
await api.softRefreshWorkshop({ poll: true }); // 대기 중 → 건드리지 않음
assert.equal(api.local.notes, '타이핑 중');
});
test('목표 예산 변경도 저장되고 스냅샷 기반 dirty에 잡힌다', async () => {
const server: any = mkServer();
const { api } = boot(server);
await api.loadWorkshopData();
api.setBudget(300000);
await new Promise(r => setTimeout(r, 900));
assert.equal(server.projects[0].budget, 300000);
});
});
// ══ 업그레이드(2026-09-24): 이력/버전/도구 확장/출력 감시 ═════════════════════════════
import {
recordHistory, listHistory, readHistory, restoreFromHistory, listDeletedProjects, duplicateProject, projectProgress,
projectToReadme, HISTORY_KEEP,
} from '../src/gateway/routes/workshop-storage';
import { judgePrintJob, applyPrintResult } from '../src/gateway/workshop-print-watch';
describe('변경 이력과 버전(updatedAt)', () => {
test('저장하면 바뀐 프로젝트만 updatedAt이 오르고, 안 바뀐 프로젝트는 그대로', () => {
const ws = tmpWorkspace({ activeProjectId: 'a', projects: [proj('a', 'A'), proj('b', 'B')] });
const v1 = loadWorkshop(ws);
const [aV, bV] = [v1.projects[0].updatedAt!, v1.projects[1].updatedAt!];
assert.ok(aV > 0 && bV > 0);
v1.projects[0].notes = '바뀜';
saveWorkshop(ws, v1);
const v2 = loadWorkshop(ws);
assert.ok(v2.projects[0].updatedAt! > aV);
assert.equal(v2.projects[1].updatedAt, bV);
saveWorkshop(ws, v2); // 무변경 저장
assert.equal(loadWorkshop(ws).projects[0].updatedAt, v2.projects[0].updatedAt);
});
test('바뀌기 전 상태가 이력에 남고, 45초 안의 연속 저장은 하나로 합친다', () => {
const ws = tmpWorkspace({ activeProjectId: 'a', projects: [proj('a', 'A', { notes: '원본' })] });
for (const n of ['수정1', '수정2', '수정3']) {
const d = loadWorkshop(ws); d.projects[0].notes = n; saveWorkshop(ws, d);
}
const hist = listHistory(ws, 'a');
assert.equal(hist.length, 1);
assert.equal(readHistory(ws, 'a', hist[0].ts)!.notes, '원본'); // 구간 시작 상태
});
test('이력은 프로젝트당 HISTORY_KEEP개까지만', () => {
const ws = tmpWorkspace({ activeProjectId: 'a', projects: [proj('a', 'A')] });
for (let i = 0; i < HISTORY_KEEP + 5; i++) recordHistory(ws, proj('a', 'A', { notes: String(i) }) as any, true);
assert.equal(listHistory(ws, 'a').length, HISTORY_KEEP);
});
test('이력 복원: 되돌리기 직전 상태도 이력에 남는다(되돌리기 취소 가능)', () => {
const ws = tmpWorkspace({ activeProjectId: 'a', projects: [proj('a', 'A', { notes: '소중한 메모' })] });
let d = loadWorkshop(ws); d.projects[0].notes = ''; saveWorkshop(ws, d); // 실수로 비움
const ts = listHistory(ws, 'a')[0].ts;
d = loadWorkshop(ws);
assert.ok(restoreFromHistory(ws, d, 'a', ts));
saveWorkshop(ws, d);
assert.equal(loadWorkshop(ws).projects[0].notes, '소중한 메모');
assert.ok(listHistory(ws, 'a').length >= 2); // 복원 직전(빈 메모) 상태도 보존
});
test('삭제된 프로젝트는 이력으로 복구 후보에 오른다', () => {
const ws = tmpWorkspace({ activeProjectId: 'a', projects: [proj('a', 'A'), proj('b', '지운 프로젝트', { notes: '중요' })] });
const d = loadWorkshop(ws);
recordHistory(ws, d.projects[1], true);
d.projects = d.projects.filter(p => p.id !== 'b');
saveWorkshop(ws, d);
const del = listDeletedProjects(ws, loadWorkshop(ws));
assert.equal(del.length, 1);
assert.equal(del[0].name, '지운 프로젝트');
});
});
describe('복제 · 진행률 · 개요 문서', () => {
const rich = () => proj('a', '원본', {
budget: 500000, archived: true, updatedAt: 5,
parts: [{ id: 'p1', name: '모터', qty: 2, unitPrice: 1000, status: '주문완료', memo: '', links: [{ id: 'l1', label: '쿠팡', url: 'https://x' }], priceCheck: { min: 1, median: 2, count: 3, at: 4 } }],
phases: [{ id: 'ph1', name: '준비', tasks: [{ id: 't1', text: '출력', done: true, due: '2026-10-01' }, { id: 't2', text: '조립', done: false }] }],
});
test('duplicateProject: 새 id·진행상태 초기화·구조 유지', () => {
const c = duplicateProject(rich() as any, 'proj_new');
assert.equal(c.id, 'proj_new');
assert.equal(c.name, '원본 (복제)');
assert.equal(c.archived, false);
assert.equal(c.budget, 500000);
assert.equal(c.parts[0].status, '검토중');
assert.equal(c.parts[0].priceCheck, undefined);
assert.notEqual(c.parts[0].id, 'p1');
assert.notEqual(c.parts[0].links[0].id, 'l1');
assert.equal(c.phases[0].tasks.every(t => !t.done), true);
assert.equal(c.phases[0].tasks[0].due, '2026-10-01');
});
test('projectProgress와 get 출력(진행률·목표예산·기한)', () => {
const p: any = rich();
assert.deepEqual(projectProgress(p), { done: 1, total: 2, pct: 50 });
const md = projectToReadme(p);
assert.ok(md.includes('작업 진행: 1/2 (50%)'));
assert.ok(md.includes('목표 예산: 500,000원'));
assert.ok(md.includes('(기한 2026-10-01)'));
});
test('normalizeProjectInput: budget/기한 검증', () => {
assert.equal(normalizeProjectInput({ id: 'p', name: 'A', parts: [], phases: [], budget: -1 }, 'p').ok, false);
const r = normalizeProjectInput({ id: 'p', name: 'A', parts: [], phases: [{ id: 'x', name: 's', tasks: [{ id: 't', text: 'a', done: false, due: '내일' }] }], budget: '1000' }, 'p');
assert.ok(r.ok);
if (r.ok) { assert.equal(r.project.budget, 1000); assert.equal(r.project.phases[0].tasks[0].due, undefined); }
});
});
describe('workshop_project 새 액션', () => {
const base = () => tmpWorkspace({
activeProjectId: 'a',
projects: [proj('a', '로봇', {
notes: '옛 메모 1\n옛 메모 2',
phases: [{ id: 'ph1', name: '준비', tasks: [{ id: 't1', text: '베이스 출력', done: false }, { id: 't2', text: '조립', done: false }] }, { id: 'ph2', name: '검증', tasks: [] }],
}), proj('b', '다른')],
});
const load = (ws: string) => loadWorkshop(ws);
test('set_notes는 메모 전체를 교체하고 get으로 읽은 내용을 정리할 수 있다', async () => {
const ws = base();
assert.equal((await run(ws, { action: 'set_notes', notes_text: '| a | b |\n|---|---|\n| 1 | 2 |' })).success, true);
assert.equal(load(ws).projects[0].notes, '| a | b |\n|---|---|\n| 1 | 2 |');
assert.equal((await run(ws, { action: 'set_notes' })).success, false); // 파라미터 누락은 조용히 비우지 않는다
assert.ok(load(ws).projects[0].notes.length > 0);
});
test('update_task/delete_task/rename_phase/delete_phase', async () => {
const ws = base();
assert.equal((await run(ws, { action: 'update_task', task_text: '베이스', new_text: '베이스 플레이트 출력', due: '2026-10-05' })).success, true);
let t = load(ws).projects[0].phases[0].tasks[0];
assert.equal(t.text, '베이스 플레이트 출력'); assert.equal(t.due, '2026-10-05');
assert.equal((await run(ws, { action: 'update_task', task_text: '베이스', due: '내일' })).success, false);
await run(ws, { action: 'update_task', task_text: '베이스', due: '' });
assert.equal(load(ws).projects[0].phases[0].tasks[0].due, undefined);
assert.equal((await run(ws, { action: 'delete_task', task_text: '조립' })).success, true);
assert.equal(load(ws).projects[0].phases[0].tasks.length, 1);
await run(ws, { action: 'rename_phase', phase_name: '검증', new_phase_name: '테스트' });
assert.equal(load(ws).projects[0].phases[1].name, '테스트');
await run(ws, { action: 'delete_phase', phase_name: '테스트' });
assert.equal(load(ws).projects[0].phases.length, 1);
});
test('add_parts 일괄 추가(중복은 건너뜀)', async () => {
const ws = base();
const r = await run(ws, { action: 'add_parts', parts: [{ name: '608ZZ', qty: 4, unit_price: 500 }, { name: 'M3 나사', qty: 20 }, { name: '608zz' }] });
assert.equal(r.success, true, r.error);
const parts = load(ws).projects[0].parts;
assert.equal(parts.length, 2);
assert.match(r.stdout || '', /건너뜀: 608zz/);
});
test('set_budget · rename_project · archive · duplicate', async () => {
const ws = base();
await run(ws, { action: 'set_budget', budget: 300000 });
assert.equal(load(ws).projects[0].budget, 300000);
assert.equal((await run(ws, { action: 'rename_project', new_project_name: '다른' })).success, false); // 이름 충돌
await run(ws, { action: 'rename_project', new_project_name: '로봇팔' });
assert.equal(load(ws).projects[0].name, '로봇팔');
await run(ws, { action: 'archive_project', project_name: '로봇팔' });
let d = load(ws);
assert.equal(d.projects[0].archived, true);
assert.equal(d.activeProjectId, 'b'); // 활성이던 프로젝트를 보관하면 다른 프로젝트로 전환
await run(ws, { action: 'unarchive_project', project_name: '로봇팔' });
assert.equal(load(ws).projects[0].archived, false);
await run(ws, { action: 'duplicate_project', project_name: '로봇팔' });
d = load(ws);
assert.equal(d.projects.length, 3);
assert.equal(d.projects[2].name, '로봇팔 (복제)');
});
test('delete_project는 confirm=true 필수, 마지막 프로젝트 삭제 불가, 이력에서 복구 가능', async () => {
const ws = base();
assert.equal((await run(ws, { action: 'delete_project', project_name: '다른' })).success, false);
assert.equal(load(ws).projects.length, 2);
assert.equal((await run(ws, { action: 'delete_project', project_name: '다른', confirm: true })).success, true);
assert.equal(load(ws).projects.length, 1);
assert.equal(listDeletedProjects(ws, load(ws))[0]?.name, '다른');
assert.equal((await run(ws, { action: 'delete_project', project_name: '로봇', confirm: true })).success, false);
});
test('list_history/restore_history: 메모를 실수로 교체해도 되돌릴 수 있다', async () => {
const ws = base();
await run(ws, { action: 'set_notes', notes_text: '' });
const listed = await run(ws, { action: 'list_history' });
const ts = Number(/ts=(\d+)/.exec(listed.stdout || '')?.[1]);
assert.ok(ts > 0, listed.stdout);
assert.equal((await run(ws, { action: 'restore_history', ts })).success, true);
assert.equal(load(ws).projects[0].notes, '옛 메모 1\n옛 메모 2');
assert.equal((await run(ws, { action: 'restore_history', ts: 1 })).success, false);
});
test('add_task 기한 검증, list_projects 진행률/보관 표시', async () => {
const ws = base();
assert.equal((await run(ws, { action: 'add_task', phase_name: '준비', task_text: '검수', due: '2026-11-01' })).success, true);
assert.equal((await run(ws, { action: 'add_task', phase_name: '준비', task_text: '검수2', due: '11월' })).success, false);
await run(ws, { action: 'archive_project', project_name: '다른' });
const l = await run(ws, { action: 'list_projects' });
assert.match(l.stdout || '', /로봇.*작업 0\/3/);
assert.match(l.stdout || '', /다른 \[보관됨\]/);
});
});
describe('K2 출력 감시', () => {
const job = { workspace: '/x', projectId: 'a', filename: 'print3d_1.gcode', startedAt: 1_000_000 };
const st = (state: string, filename = 'print3d_1.gcode') => ({ state, filename, printDurationSec: 600 });
test('judgePrintJob 판정표', () => {
const t = job.startedAt + 60_000;
assert.equal(judgePrintJob(job, st('complete'), t, true), 'complete');
assert.equal(judgePrintJob(job, st('complete', 'gcodes/print3d_1.gcode'), t, true), 'complete'); // 경로 접두어 무시
assert.equal(judgePrintJob(job, st('cancelled'), t, true), 'failed');
assert.equal(judgePrintJob(job, st('error'), t, true), 'failed');
assert.equal(judgePrintJob(job, st('printing'), t, true), 'keep');
assert.equal(judgePrintJob(job, st('paused'), t, true), 'keep');
assert.equal(judgePrintJob(job, null, t, true), 'keep'); // 프린터 일시 무응답
assert.equal(judgePrintJob(job, st('complete', 'other.gcode'), t, true), 'keep'); // 다른 파일의 완료는 무관
assert.equal(judgePrintJob(job, st('standby', ''), job.startedAt + 4 * 3600_000, false), 'drop'); // 시작이 안 된 job
assert.equal(judgePrintJob(job, st('standby', ''), job.startedAt + 4 * 3600_000, true), 'keep'); // 출력 도중 대기는 유지
assert.equal(judgePrintJob(job, st('printing'), job.startedAt + 73 * 3600_000, true), 'drop'); // 너무 오래됨
});
test('applyPrintResult: 작업 자동 완료 + 메모, 모호하면 완료 처리 건너뜀', () => {
const ws = tmpWorkspace({ activeProjectId: 'a', projects: [proj('a', 'A', {
phases: [{ id: 'ph', name: '출력', tasks: [{ id: 't1', text: '베이스 출력', done: false }, { id: 't2', text: '헤드 출력', done: false }] }],
})] });
const j = { workspace: ws, projectId: 'a', filename: 'x.gcode', taskText: '베이스', startedAt: 1 };
const summary = applyPrintResult(j, 'complete', 3700);
assert.match(summary, /출력 완료: x\.gcode/);
const p = loadWorkshop(ws).projects[0];
assert.equal(p.phases[0].tasks[0].done, true);
assert.equal(p.phases[0].tasks[1].done, false);
assert.match(p.notes, /🖨️ 출력 완료: x\.gcode \(소요 1시간 \d+분\)/);
applyPrintResult({ ...j, taskText: '출력' }, 'complete', 0); // "출력"은 둘 다 일치 → 건너뜀
assert.equal(loadWorkshop(ws).projects[0].phases[0].tasks[1].done, false);
applyPrintResult(j, 'failed', 0);
assert.match(loadWorkshop(ws).projects[0].notes, /⚠️ 출력 실패\/취소: x\.gcode/);
});
});
describe('workshop_project view_image (사진 첨부 채팅)', () => {
test('프로젝트 사진을 찾아 /api/files 마크다운으로 돌려준다(모델이 직접 보게 됨)', async () => {
const ws = tmpWorkspace({ activeProjectId: 'a', projects: [proj('a', 'A')] });
const dir = path.join(ws, 'workshop', 'a', '사진');
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(path.join(dir, 'motor.jpg'), 'x');
fs.writeFileSync(path.join(dir, 'motor2.jpg'), 'x');
fs.writeFileSync(path.join(dir, 'note.txt'), 'x');
const r = await run(ws, { action: 'view_image', file_name: '사진/motor.jpg' });
assert.equal(r.success, true, r.error);
assert.match(r.stdout || '', /!\[motor\.jpg\]\(\/api\/files\/workshop\/a\/%EC%82%AC%EC%A7%84\/motor\.jpg\)/);
// 파일명만 줘도, 정확히 일치하는 쪽이 우선(motor vs motor2)
assert.equal((await run(ws, { action: 'view_image', file_name: 'motor.jpg' })).success, true);
assert.equal((await run(ws, { action: 'view_image', file_name: 'motor' })).success, false); // 모호(motor.jpg/motor2.jpg)
assert.equal((await run(ws, { action: 'view_image', file_name: '없음.png' })).success, false);
assert.equal((await run(ws, { action: 'view_image' })).success, false);
});
});