/** * WorkspaceService 测试(v0.7.2 覆盖补齐 —— 此前零测试) * * 锁定工作空间生命周期契约: * 1. initialize 创建目录结构(logs/.metona)与必需文件(SOUL.md/MEMORY.md) * 2. MEMORY.md 模板元数据头 + 5 分区结构 * 3. appendMemory 分区追加语义(含未知 section 兜底) * 4. validateMemoryFormat 自动修正(缺元数据头/缺分区) * 5. updateMemoryTimestamp / reload(外部编辑同步) */ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, existsSync, rmSync } from 'fs'; import { join } from 'path'; import { tmpdir } from 'os'; import { WorkspaceService } from '../workspace.service'; let wsRoot: string; beforeEach(() => { wsRoot = mkdtempSync(join(tmpdir(), 'metona-ws-')); }); afterEach(() => { try { rmSync(wsRoot, { recursive: true, force: true }); } catch { /* ignore */ } }); describe('WorkspaceService — initialize', () => { it('创建工作空间目录结构 + 两个必需文件 + 自动目录', () => { const wsPath = join(wsRoot, 'ws'); const svc = new WorkspaceService(wsPath); const info = svc.initialize(); expect(info.isValid).toBe(true); expect(info.path).toBe(wsPath); expect(existsSync(join(wsPath, 'SOUL.md'))).toBe(true); expect(existsSync(join(wsPath, 'MEMORY.md'))).toBe(true); expect(existsSync(join(wsPath, 'logs'))).toBe(true); expect(existsSync(join(wsPath, '.metona'))).toBe(true); // 本次启动自动创建的文件计入 missingFiles expect(info.missingFiles).toEqual(['SOUL.md', 'MEMORY.md']); }); it('已有完整文件的工作空间:missingFiles 为空且内容不被覆盖', () => { const wsPath = join(wsRoot, 'ws'); mkdirSync(wsPath, { recursive: true }); writeFileSync(join(wsPath, 'SOUL.md'), '# 自定义人格', 'utf-8'); writeFileSync( join(wsPath, 'MEMORY.md'), '# MEMORY.md\n> 创建时间: x\n> 最后更新: y\n> 工作空间: z\n', 'utf-8', ); const svc = new WorkspaceService(wsPath); const info = svc.initialize(); expect(info.missingFiles).toEqual([]); expect(svc.getFiles().soul).toBe('# 自定义人格'); }); it('MEMORY.md 模板包含元数据头与核心分区', () => { const wsPath = join(wsRoot, 'ws'); const svc = new WorkspaceService(wsPath); svc.initialize(); const content = readFileSync(join(wsPath, 'MEMORY.md'), 'utf-8'); expect(content).toContain('# MEMORY.md'); expect(content).toContain('> 创建时间:'); expect(content).toContain('> 最后更新:'); expect(content).toContain('> 工作空间:'); for (const section of [ '## 用户偏好', '## 项目上下文', '## 重要决策', '## 待办事项', '## 已知问题', ]) { expect(content).toContain(section); } }); }); describe('WorkspaceService — appendMemory', () => { it('追加到指定 section 末尾(不侵入下一分区)', () => { const wsPath = join(wsRoot, 'ws'); const svc = new WorkspaceService(wsPath); svc.initialize(); svc.appendMemory('用户偏好', '偏好深色主题'); svc.appendMemory('用户偏好', '偏好简洁回复'); const content = readFileSync(join(wsPath, 'MEMORY.md'), 'utf-8'); const sectionStart = content.indexOf('## 用户偏好'); const sectionEnd = content.indexOf('## 项目上下文'); const section = content.slice(sectionStart, sectionEnd); expect(section).toContain('- 偏好深色主题'); expect(section).toContain('- 偏好简洁回复'); }); it('未知 section 追加到文件末尾并自动创建分区头', () => { const wsPath = join(wsRoot, 'ws'); const svc = new WorkspaceService(wsPath); svc.initialize(); svc.appendMemory('自定义分区', '条目内容'); const content = readFileSync(join(wsPath, 'MEMORY.md'), 'utf-8'); expect(content).toContain('## 自定义分区'); expect(content).toContain('- 条目内容'); }); }); describe('WorkspaceService — validateMemoryFormat 自动修正', () => { it('缺失元数据头与核心分区被自动补齐,内容不丢失', () => { const wsPath = join(wsRoot, 'ws'); mkdirSync(wsPath, { recursive: true }); writeFileSync(join(wsPath, 'MEMORY.md'), '# MEMORY.md\n- 用户留下的重要内容', 'utf-8'); const svc = new WorkspaceService(wsPath); expect(svc.validateMemoryFormat()).toBe(false); // false = 有修正 const content = readFileSync(join(wsPath, 'MEMORY.md'), 'utf-8'); expect(content).toContain('> 创建时间:'); expect(content).toContain('> 工作空间:'); for (const section of ['## 用户偏好', '## 项目上下文', '## 重要决策']) { expect(content).toContain(section); } // 原内容保留 expect(content).toContain('- 用户留下的重要内容'); }); it('格式完整时返回 true 且不触发写盘', () => { const wsPath = join(wsRoot, 'ws'); const svc = new WorkspaceService(wsPath); svc.initialize(); expect(svc.validateMemoryFormat()).toBe(true); }); }); describe('WorkspaceService — 时间戳与 reload', () => { it('updateMemoryTimestamp 更新"最后更新"行', () => { const wsPath = join(wsRoot, 'ws'); const svc = new WorkspaceService(wsPath); svc.initialize(); svc.updateMemoryTimestamp(); const content = svc.getFiles().memory; expect(content).toMatch(/> 最后更新: \d{4}-\d{2}-\d{2}T/); }); it('reload 同步外部编辑(用户手动改文件后)', () => { const wsPath = join(wsRoot, 'ws'); const svc = new WorkspaceService(wsPath); svc.initialize(); writeFileSync(join(wsPath, 'SOUL.md'), '# 外部修改后的人格', 'utf-8'); const files = svc.reload(); expect(files.soul).toBe('# 外部修改后的人格'); }); it('getFiles 返回副本(外部修改引用不影响内部状态)', () => { const wsPath = join(wsRoot, 'ws'); const svc = new WorkspaceService(wsPath); svc.initialize(); const files = svc.getFiles(); files.soul = 'tampered'; expect(svc.getFiles().soul).not.toBe('tampered'); }); }); describe('WorkspaceService — appendMemory 分区定位(v0.7.2 根治)', () => { it('中文分区(用户偏好)条目追加到分区末尾而非文件末尾(v0.7.2 缺陷回归)', () => { const wsPath = join(wsRoot, 'ws'); const svc = new WorkspaceService(wsPath); svc.initialize(); svc.appendMemory('用户偏好', '第一条'); svc.appendMemory('项目上下文', '项目第一条'); const content = readFileSync(join(wsPath, 'MEMORY.md'), 'utf-8'); const userSection = content.slice( content.indexOf('## 用户偏好'), content.indexOf('## 项目上下文'), ); // 项目上下文条目不应出现在用户偏好分区内(旧 bug 会创建重复分区头) expect(userSection).toContain('- 第一条'); expect(userSection).not.toContain('项目第一条'); // 项目分区只有一份(没有重复的 ## 项目上下文) expect(content.match(/## 项目上下文/g)).toHaveLength(1); }); it('已存在分区:多条条目顺序追加(后写在后)', () => { const wsPath = join(wsRoot, 'ws'); const svc = new WorkspaceService(wsPath); svc.initialize(); svc.appendMemory('用户偏好', 'A'); svc.appendMemory('用户偏好', 'B'); svc.appendMemory('用户偏好', 'C'); const content = readFileSync(join(wsPath, 'MEMORY.md'), 'utf-8'); const userSection = content.slice( content.indexOf('## 用户偏好'), content.indexOf('## 项目上下文'), ); expect(userSection.indexOf('- A')).toBeLessThan(userSection.indexOf('- B')); expect(userSection.indexOf('- B')).toBeLessThan(userSection.indexOf('- C')); }); it('新分区(不存在)追加到文件末尾并创建分区头', () => { const wsPath = join(wsRoot, 'ws'); const svc = new WorkspaceService(wsPath); svc.initialize(); svc.appendMemory('新增分区', '新内容'); const content = readFileSync(join(wsPath, 'MEMORY.md'), 'utf-8'); // 新分区位于文件末尾 expect(content.trimEnd().endsWith('- 新内容')).toBe(true); expect(content.match(/## 新增分区/g)).toHaveLength(1); }); it('分区名含正则元字符(括号)时不被误转义破坏', () => { const wsPath = join(wsRoot, 'ws'); const svc = new WorkspaceService(wsPath); svc.initialize(); svc.appendMemory('用户偏好(移动端)', '移动端条目'); const content = readFileSync(join(wsPath, 'MEMORY.md'), 'utf-8'); expect(content).toContain('## 用户偏好(移动端)'); expect(content).toContain('- 移动端条目'); }); it('前缀相似分区互不误匹配("用户偏好" vs "用户偏好(旧)")', () => { const wsPath = join(wsRoot, 'ws'); mkdirSync(wsPath, { recursive: true }); writeFileSync( join(wsPath, 'MEMORY.md'), '# MEMORY.md\n> 创建时间: x\n> 最后更新: y\n> 工作空间: z\n\n## 用户偏好(旧)\n- 旧条目\n\n## 项目上下文\n', 'utf-8', ); const svc = new WorkspaceService(wsPath); svc.appendMemory('用户偏好', '新条目'); const content = readFileSync(join(wsPath, 'MEMORY.md'), 'utf-8'); // 新条目追加到"用户偏好"分区(在(旧)分区后创建独立分区) const mainSection = content.slice(content.indexOf('## 用户偏好\n')); expect(mainSection).toContain('- 新条目'); expect(content).toContain('## 用户偏好\n'); }); it('appendMemory 更新"最后更新"时间戳行', () => { const wsPath = join(wsRoot, 'ws'); const svc = new WorkspaceService(wsPath); svc.initialize(); svc.appendMemory('用户偏好', 'x'); const content = readFileSync(join(wsPath, 'MEMORY.md'), 'utf-8'); expect(content).toMatch(/> 最后更新: \d{4}-\d{2}-\d{2}T/); }); it('MEMORY.md 不存在时 appendMemory 静默返回(不抛错)', () => { const wsPath = join(wsRoot, 'ws'); const svc = new WorkspaceService(wsPath); expect(() => svc.appendMemory('用户偏好', 'x')).not.toThrow(); }); it('条目内容含换行与 markdown 语法原样保留', () => { const wsPath = join(wsRoot, 'ws'); const svc = new WorkspaceService(wsPath); svc.initialize(); svc.appendMemory('项目上下文', '第一行\n第二行 `code`'); const content = readFileSync(join(wsPath, 'MEMORY.md'), 'utf-8'); expect(content).toContain('- 第一行\n第二行 `code`'); }); }); describe('WorkspaceService — validateMemoryFormat 自愈矩阵', () => { const setup = (content: string): { wsPath: string; svc: WorkspaceService } => { const wsPath = join(wsRoot, `ws_${Math.random().toString(36).slice(2, 8)}`); mkdirSync(wsPath, { recursive: true }); writeFileSync(join(wsPath, 'MEMORY.md'), content, 'utf-8'); return { wsPath, svc: new WorkspaceService(wsPath) }; }; it('缺失"创建时间"元数据 → 自动补全并返回 false', () => { const { wsPath, svc } = setup('# MEMORY.md\n> 最后更新: x\n> 工作空间: y\n'); expect(svc.validateMemoryFormat()).toBe(false); const content = readFileSync(join(wsPath, 'MEMORY.md'), 'utf-8'); expect(content).toContain('> 创建时间:'); }); it('缺失"最后更新"元数据 → 自动补全', () => { const { wsPath, svc } = setup('# MEMORY.md\n> 创建时间: x\n> 工作空间: y\n'); expect(svc.validateMemoryFormat()).toBe(false); const content = readFileSync(join(wsPath, 'MEMORY.md'), 'utf-8'); expect(content).toContain('> 最后更新:'); }); it('缺失"工作空间"元数据 → 自动补全为当前工作空间路径', () => { const { wsPath, svc } = setup('# MEMORY.md\n> 创建时间: x\n> 最后更新: y\n'); expect(svc.validateMemoryFormat()).toBe(false); const content = readFileSync(join(wsPath, 'MEMORY.md'), 'utf-8'); expect(content).toContain(`> 工作空间: ${wsPath}`); }); it('缺失核心分区(用户偏好/项目上下文/重要决策)→ 自动补齐全部', () => { const { wsPath, svc } = setup('# MEMORY.md\n> 创建时间: x\n> 最后更新: y\n> 工作空间: z\n'); expect(svc.validateMemoryFormat()).toBe(false); const content = readFileSync(join(wsPath, 'MEMORY.md'), 'utf-8'); for (const section of ['## 用户偏好', '## 项目上下文', '## 重要决策']) { expect(content).toContain(section); } }); it('缺"# MEMORY"标题时元数据插入到文件开头(三条元数据均在原内容之前)', () => { const { wsPath, svc } = setup('- 无标题内容\n'); expect(svc.validateMemoryFormat()).toBe(false); const content = readFileSync(join(wsPath, 'MEMORY.md'), 'utf-8'); // 元数据插入在开头 expect(content.indexOf('> 创建时间:')).toBeLessThan(content.indexOf('- 无标题内容')); expect(content.indexOf('> 最后更新:')).toBeLessThan(content.indexOf('- 无标题内容')); expect(content.indexOf('> 工作空间:')).toBeLessThan(content.indexOf('- 无标题内容')); expect(content).toContain('- 无标题内容'); }); it('完整文件(含标题+元数据+核心分区)返回 true 且不改写', () => { const { wsPath, svc } = setup( '# MEMORY.md\n> 创建时间: x\n> 最后更新: y\n> 工作空间: z\n\n## 用户偏好\n\n## 项目上下文\n\n## 重要决策\n', ); const before = readFileSync(join(wsPath, 'MEMORY.md'), 'utf-8'); expect(svc.validateMemoryFormat()).toBe(true); const after = readFileSync(join(wsPath, 'MEMORY.md'), 'utf-8'); expect(after).toBe(before); }); it('MEMORY.md 不存在时 validateMemoryFormat 返回 false 不抛错', () => { const wsPath = join(wsRoot, 'ws_nofile'); const svc = new WorkspaceService(wsPath); expect(svc.validateMemoryFormat()).toBe(false); }); it('自愈后 getFiles().memory 同步为修正后的内容', () => { const { svc } = setup('# MEMORY.md\n> 创建时间: x\n> 最后更新: y\n> 工作空间: z\n'); svc.validateMemoryFormat(); expect(svc.getFiles().memory).toContain('## 重要决策'); }); it('重复调用自愈幂等:修正后再次验证返回 true', () => { const { svc } = setup('# MEMORY.md\n> 创建时间: x\n> 最后更新: y\n> 工作空间: z\n'); expect(svc.validateMemoryFormat()).toBe(false); expect(svc.validateMemoryFormat()).toBe(true); }); }); describe('WorkspaceService — initialize 与路径边界', () => { it('嵌套目录路径自动创建(多级不存在目录)', () => { const wsPath = join(wsRoot, 'a', 'b', 'c'); const svc = new WorkspaceService(wsPath); const info = svc.initialize(); expect(info.isValid).toBe(true); expect(existsSync(join(wsPath, 'SOUL.md'))).toBe(true); }); it('getPath 返回构造时传入的路径', () => { const wsPath = join(wsRoot, 'ws_path'); const svc = new WorkspaceService(wsPath); expect(svc.getPath()).toBe(wsPath); }); it('initialize 重复调用幂等(不抛错、内容不丢失)', () => { const wsPath = join(wsRoot, 'ws_reinit'); const svc = new WorkspaceService(wsPath); svc.initialize(); writeFileSync(join(wsPath, 'SOUL.md'), '# 自定义人格', 'utf-8'); svc.initialize(); expect(svc.getFiles().soul).toBe('# 自定义人格'); }); it('SOUL.md 自动创建内容包含灵魂定义标题', () => { const wsPath = join(wsRoot, 'ws_soul'); const svc = new WorkspaceService(wsPath); svc.initialize(); const content = readFileSync(join(wsPath, 'SOUL.md'), 'utf-8'); expect(content).toContain('# SOUL.md'); expect(content).toContain('身份、性格和核心价值观'); }); it('MEMORY.md 模板的工作空间路径占位符被替换', () => { const wsPath = join(wsRoot, 'ws_placeholder'); const svc = new WorkspaceService(wsPath); svc.initialize(); const content = readFileSync(join(wsPath, 'MEMORY.md'), 'utf-8'); expect(content).toContain(`> 工作空间: ${wsPath}`); expect(content).not.toContain('__WORKSPACE_PATH__'); }); it('initialize 返回 files 快照(不可变副本)', () => { const wsPath = join(wsRoot, 'ws_snapshot'); const svc = new WorkspaceService(wsPath); const info = svc.initialize(); info.files.soul = 'mutated'; expect(svc.getFiles().soul).not.toBe('mutated'); }); it('reload 同步 MEMORY.md 外部修改并触发格式校验', () => { const wsPath = join(wsRoot, 'ws_reload'); const svc = new WorkspaceService(wsPath); svc.initialize(); writeFileSync(join(wsPath, 'MEMORY.md'), '> 创建时间: x\n', 'utf-8'); svc.reload(); // reload 后内存内容与磁盘一致(自愈补全在 validateMemoryFormat 中写回) expect(svc.getFiles().memory).toContain('> 创建时间: x'); }); it('updateMemoryTimestamp 对不存在的 MEMORY.md 静默返回', () => { const wsPath = join(wsRoot, 'ws_nots'); const svc = new WorkspaceService(wsPath); expect(() => svc.updateMemoryTimestamp()).not.toThrow(); }); });