// file_history_test.go -- 文件历史系统的单元测试. // // 覆盖场景: // - 备份创建(内容寻址去重) // - 编辑前备份 // - 写入前备份(文件存在/不存在两种情况) // - 按消息回滚 // - CanRollback 查询 // - maxSnapshots 上限 // - 同一消息多次备份同一文件只保留第一次 package engine import ( "context" "os" "path/filepath" "testing" "time" ) // ───────────────────────────────────────────────────────────────────── // 测试辅助 // ───────────────────────────────────────────────────────────────────── func newTestFileHistory(t *testing.T) (*FileHistory, string) { t.Helper() backupDir := filepath.Join(t.TempDir(), "history") fh := NewFileHistoryWithDir(backupDir, &NoopObserver{}) return fh, backupDir } // ───────────────────────────────────────────────────────────────────── // 基础测试 // ───────────────────────────────────────────────────────────────────── func TestFileHistory_BeforeEdit_BackupCreated(t *testing.T) { fh, backupDir := newTestFileHistory(t) // 创建测试文件 dir := t.TempDir() filePath := filepath.Join(dir, "test.txt") os.WriteFile(filePath, []byte("original content"), 0644) // 备份 err := fh.BeforeEdit(filePath, "msg-1") if err != nil { t.Fatalf("BeforeEdit 失败: %v", err) } // 验证备份目录已创建 entries, err := os.ReadDir(backupDir) if err != nil { t.Fatalf("读取备份目录失败: %v", err) } if len(entries) != 1 { t.Errorf("应该有 1 个备份文件,实际: %d", len(entries)) } // 验证快照计数 if fh.SnapshotCount() != 1 { t.Errorf("应该有 1 个快照,实际: %d", fh.SnapshotCount()) } } func TestFileHistory_BeforeEdit_FileNotFound(t *testing.T) { fh, _ := newTestFileHistory(t) err := fh.BeforeEdit("/nonexistent/file.txt", "msg-1") if err == nil { t.Error("对不存在的文件调用 BeforeEdit 应该返回错误") } } func TestFileHistory_BeforeWrite_ExistingFile(t *testing.T) { fh, backupDir := newTestFileHistory(t) dir := t.TempDir() filePath := filepath.Join(dir, "existing.txt") os.WriteFile(filePath, []byte("old content"), 0644) err := fh.BeforeWrite(filePath, "msg-1") if err != nil { t.Fatalf("BeforeWrite 失败: %v", err) } // 验证备份已创建 entries, _ := os.ReadDir(backupDir) if len(entries) != 1 { t.Errorf("应该有 1 个备份文件,实际: %d", len(entries)) } } func TestFileHistory_BeforeWrite_NewFile(t *testing.T) { fh, backupDir := newTestFileHistory(t) dir := t.TempDir() filePath := filepath.Join(dir, "newfile.txt") err := fh.BeforeWrite(filePath, "msg-1") if err != nil { t.Fatalf("BeforeWrite 对新文件不应失败: %v", err) } // 新文件不应创建备份文件(OriginalExists=false) entries, _ := os.ReadDir(backupDir) if len(entries) != 0 { t.Errorf("新文件不应创建备份文件,实际: %d", len(entries)) } // 但快照应该有记录 if fh.SnapshotCount() != 1 { t.Errorf("应该有 1 个快照,实际: %d", fh.SnapshotCount()) } } // ───────────────────────────────────────────────────────────────────── // 内容寻址去重测试 // ───────────────────────────────────────────────────────────────────── func TestFileHistory_ContentAddressedDedup(t *testing.T) { fh, backupDir := newTestFileHistory(t) dir := t.TempDir() file1 := filepath.Join(dir, "a.txt") file2 := filepath.Join(dir, "b.txt") // 两个文件内容相同 os.WriteFile(file1, []byte("same content"), 0644) os.WriteFile(file2, []byte("same content"), 0644) fh.BeforeEdit(file1, "msg-1") fh.BeforeEdit(file2, "msg-2") // 内容相同,只应有 1 个备份文件 entries, _ := os.ReadDir(backupDir) if len(entries) != 1 { t.Errorf("内容相同的文件应该共享 1 个备份,实际: %d", len(entries)) } } func TestFileHistory_DifferentContentCreatesDifferentBackups(t *testing.T) { fh, backupDir := newTestFileHistory(t) dir := t.TempDir() file1 := filepath.Join(dir, "a.txt") file2 := filepath.Join(dir, "b.txt") os.WriteFile(file1, []byte("content A"), 0644) os.WriteFile(file2, []byte("content B"), 0644) fh.BeforeEdit(file1, "msg-1") fh.BeforeEdit(file2, "msg-2") entries, _ := os.ReadDir(backupDir) if len(entries) != 2 { t.Errorf("不同内容应该创建 2 个备份,实际: %d", len(entries)) } } // ───────────────────────────────────────────────────────────────────── // 回滚测试 // ───────────────────────────────────────────────────────────────────── func TestFileHistory_Rollback_RestoreContent(t *testing.T) { fh, _ := newTestFileHistory(t) dir := t.TempDir() filePath := filepath.Join(dir, "test.txt") os.WriteFile(filePath, []byte("original"), 0644) // 备份 fh.BeforeEdit(filePath, "msg-1") // 模拟编辑 os.WriteFile(filePath, []byte("modified"), 0644) // 验证文件已被修改 data, _ := os.ReadFile(filePath) if string(data) != "modified" { t.Fatalf("文件应该已被修改") } // 回滚 err := fh.Rollback("msg-1") if err != nil { t.Fatalf("回滚失败: %v", err) } // 验证恢复 data, _ = os.ReadFile(filePath) if string(data) != "original" { t.Errorf("回滚后内容应该恢复,实际: %q", string(data)) } } func TestFileHistory_Rollback_DeleteNewFile(t *testing.T) { fh, _ := newTestFileHistory(t) dir := t.TempDir() filePath := filepath.Join(dir, "newfile.txt") // 备份(文件不存在) fh.BeforeWrite(filePath, "msg-1") // 模拟创建文件 os.WriteFile(filePath, []byte("new content"), 0644) // 验证文件已创建 if _, err := os.Stat(filePath); os.IsNotExist(err) { t.Fatal("文件应该已创建") } // 回滚 err := fh.Rollback("msg-1") if err != nil { t.Fatalf("回滚失败: %v", err) } // 验证文件已删除 if _, err := os.Stat(filePath); !os.IsNotExist(err) { t.Error("回滚新建文件后,文件应该被删除") } } func TestFileHistory_Rollback_MultipleFiles(t *testing.T) { fh, _ := newTestFileHistory(t) dir := t.TempDir() file1 := filepath.Join(dir, "a.txt") file2 := filepath.Join(dir, "b.txt") os.WriteFile(file1, []byte("content A"), 0644) os.WriteFile(file2, []byte("content B"), 0644) // 同一消息修改两个文件 fh.BeforeEdit(file1, "msg-1") fh.BeforeEdit(file2, "msg-1") // 模拟修改 os.WriteFile(file1, []byte("modified A"), 0644) os.WriteFile(file2, []byte("modified B"), 0644) // 回滚 err := fh.Rollback("msg-1") if err != nil { t.Fatalf("回滚失败: %v", err) } data1, _ := os.ReadFile(file1) data2, _ := os.ReadFile(file2) if string(data1) != "content A" { t.Errorf("a.txt 应该恢复,实际: %q", string(data1)) } if string(data2) != "content B" { t.Errorf("b.txt 应该恢复,实际: %q", string(data2)) } } func TestFileHistory_Rollback_NotFound(t *testing.T) { fh, _ := newTestFileHistory(t) err := fh.Rollback("nonexistent-msg") if err == nil { t.Error("回滚不存在的消息应该返回错误") } } // ───────────────────────────────────────────────────────────────────── // CanRollback 测试 // ───────────────────────────────────────────────────────────────────── func TestFileHistory_CanRollback(t *testing.T) { fh, _ := newTestFileHistory(t) dir := t.TempDir() filePath := filepath.Join(dir, "test.txt") os.WriteFile(filePath, []byte("content"), 0644) fh.BeforeEdit(filePath, "msg-1") can, files := fh.CanRollback("msg-1") if !can { t.Error("msg-1 应该可以回滚") } if len(files) != 1 { t.Errorf("应该有 1 个可回滚文件,实际: %d", len(files)) } can, _ = fh.CanRollback("msg-999") if can { t.Error("不存在的消息不应该可以回滚") } } // ───────────────────────────────────────────────────────────────────── // maxSnapshots 测试 // ───────────────────────────────────────────────────────────────────── func TestFileHistory_MaxSnapshots(t *testing.T) { fh, _ := newTestFileHistory(t) fh.maxSnapshots = 3 // 设置小的上限方便测试 dir := t.TempDir() for i := 0; i < 5; i++ { filePath := filepath.Join(dir, "test.txt") os.WriteFile(filePath, []byte("content"), 0644) fh.BeforeEdit(filePath, "msg-"+string(rune('a'+i))) } // 应该只保留最近 3 个快照 if fh.SnapshotCount() != 3 { t.Errorf("应该保留 3 个快照,实际: %d", fh.SnapshotCount()) } // 最旧的 msg-a 和 msg-b 应该已被淘汰 can, _ := fh.CanRollback("msg-a") if can { t.Error("msg-a 应该已被淘汰") } } // ───────────────────────────────────────────────────────────────────── // 同一消息重复备份测试 // ───────────────────────────────────────────────────────────────────── func TestFileHistory_SameMessageSameFile_OnlyFirstBackup(t *testing.T) { fh, _ := newTestFileHistory(t) dir := t.TempDir() filePath := filepath.Join(dir, "test.txt") os.WriteFile(filePath, []byte("version 1"), 0644) // 第一次备份 fh.BeforeEdit(filePath, "msg-1") // 修改文件 os.WriteFile(filePath, []byte("version 2"), 0644) // 第二次备份(同一消息同一文件) fh.BeforeEdit(filePath, "msg-1") // 回滚应该恢复到 version 1(第一次备份时的内容) os.WriteFile(filePath, []byte("version 3"), 0644) fh.Rollback("msg-1") data, _ := os.ReadFile(filePath) if string(data) != "version 1" { t.Errorf("应该恢复到 version 1,实际: %q", string(data)) } } // ───────────────────────────────────────────────────────────────────── // INF-2 Backup 接口测试 // ───────────────────────────────────────────────────────────────────── // TestFileHistory_Backup_ExistingFile 验证 Backup 对已存在文件正确备份. func TestFileHistory_Backup_ExistingFile(t *testing.T) { fh, backupDir := newTestFileHistory(t) ctx := context.Background() dir := t.TempDir() filePath := filepath.Join(dir, "memory.md") os.WriteFile(filePath, []byte("original content"), 0644) // 调用 Backup 接口 if err := fh.Backup(ctx, filePath); err != nil { t.Fatalf("Backup 失败: %v", err) } // 应该创建了备份文件 entries, err := os.ReadDir(backupDir) if err != nil { t.Fatalf("读取备份目录失败: %v", err) } if len(entries) != 1 { t.Errorf("应该有 1 个备份文件,实际: %d", len(entries)) } // 快照应该存在(归入 "memory-autosave" 组) can, files := fh.CanRollback("memory-autosave") if !can { t.Error("应该可以回滚 memory-autosave 组") } found := false for _, f := range files { if f == filePath { found = true } } if !found { t.Errorf("Backup 的文件 %s 应在可回滚列表中", filePath) } } // TestFileHistory_Backup_NonExistentFile 验证 Backup 对不存在的文件不报错. func TestFileHistory_Backup_NonExistentFile(t *testing.T) { fh, backupDir := newTestFileHistory(t) ctx := context.Background() filePath := filepath.Join(t.TempDir(), "nonexistent.md") // 文件不存在时 Backup 应该成功(BeforeWrite 静默记录 OriginalExists=false) if err := fh.Backup(ctx, filePath); err != nil { t.Fatalf("Backup 不存在的文件不应报错: %v", err) } // 不应该创建备份文件(原文件不存在,无内容可备份) entries, _ := os.ReadDir(backupDir) if len(entries) != 0 { t.Errorf("不存在的文件不应创建备份,实际: %d 个文件", len(entries)) } } // TestFileHistory_Backup_MultipleCallsSameGroup 验证多次 Backup 同一文件归入同一组. func TestFileHistory_Backup_MultipleCallsSameGroup(t *testing.T) { fh, _ := newTestFileHistory(t) ctx := context.Background() dir := t.TempDir() file1 := filepath.Join(dir, "mem1.md") file2 := filepath.Join(dir, "mem2.md") os.WriteFile(file1, []byte("mem1 content"), 0644) os.WriteFile(file2, []byte("mem2 content"), 0644) fh.Backup(ctx, file1) fh.Backup(ctx, file2) // 两次 Backup 应该归入同一个 "memory-autosave" 快照组 can, files := fh.CanRollback("memory-autosave") if !can { t.Fatal("应该可以回滚 memory-autosave 组") } if len(files) != 2 { t.Errorf("两个文件应该在同一个快照组中,实际: %d", len(files)) } } // ───────────────────────────────────────────────────────────────────── // INF-2 RetentionPolicy / Prune 测试 // ───────────────────────────────────────────────────────────────────── // createBackupFiles 在备份目录下创建指定数量的备份文件(用于测试 Prune). func createBackupFiles(t *testing.T, backupDir string, count int) []string { t.Helper() if err := os.MkdirAll(backupDir, 0755); err != nil { t.Fatalf("创建备份目录失败: %v", err) } var paths []string for i := 0; i < count; i++ { name := filepath.Join(backupDir, "backup"+string(rune('a'+i))) if err := os.WriteFile(name, []byte("content"), 0644); err != nil { t.Fatalf("创建备份文件失败: %v", err) } paths = append(paths, name) // 用 Chtimes 设置不同的 mtime,确保排序稳定 mtime := time.Now().Add(time.Duration(i) * time.Second) os.Chtimes(name, mtime, mtime) } return paths } // TestRetentionPolicy_MaxVersions 验证 MaxVersions 按全局文件数清理. func TestRetentionPolicy_MaxVersions(t *testing.T) { fh, backupDir := newTestFileHistory(t) ctx := context.Background() // 创建 10 个备份文件 createBackupFiles(t, backupDir, 10) policy := RetentionPolicy{MaxVersions: 5} if err := fh.Prune(ctx, policy); err != nil { t.Fatalf("Prune 失败: %v", err) } entries, err := os.ReadDir(backupDir) if err != nil { t.Fatalf("读取目录失败: %v", err) } // 应该只保留 5 个(最新的) if len(entries) != 5 { t.Errorf("MaxVersions=5 后应保留 5 个文件,实际: %d", len(entries)) } } // TestRetentionPolicy_MaxAgeDays 验证 MaxAgeDays 按时间清理. func TestRetentionPolicy_MaxAgeDays(t *testing.T) { fh, backupDir := newTestFileHistory(t) ctx := context.Background() if err := os.MkdirAll(backupDir, 0755); err != nil { t.Fatalf("创建备份目录失败: %v", err) } // 创建一个 "旧" 文件(mtime = 40 天前) oldPath := filepath.Join(backupDir, "old_backup") os.WriteFile(oldPath, []byte("old"), 0644) oldTime := time.Now().AddDate(0, 0, -40) os.Chtimes(oldPath, oldTime, oldTime) // 创建一个 "新" 文件(mtime = 今天) newPath := filepath.Join(backupDir, "new_backup") os.WriteFile(newPath, []byte("new"), 0644) policy := RetentionPolicy{MaxAgeDays: 30} if err := fh.Prune(ctx, policy); err != nil { t.Fatalf("Prune 失败: %v", err) } // 旧文件应该被删除 if _, err := os.Stat(oldPath); !os.IsNotExist(err) { t.Error("40 天前的旧文件应该被 MaxAgeDays=30 删除") } // 新文件应该保留 if _, err := os.Stat(newPath); os.IsNotExist(err) { t.Error("新文件不应该被删除") } } // TestRetentionPolicy_NoPrune 验证策略全为 0 时不做任何清理. func TestRetentionPolicy_NoPrune(t *testing.T) { fh, backupDir := newTestFileHistory(t) ctx := context.Background() createBackupFiles(t, backupDir, 5) policy := RetentionPolicy{} // MaxAgeDays=0, MaxVersions=0 if err := fh.Prune(ctx, policy); err != nil { t.Fatalf("Prune 失败: %v", err) } entries, _ := os.ReadDir(backupDir) if len(entries) != 5 { t.Errorf("策略全为 0 不应删除任何文件,实际: %d", len(entries)) } } // TestRetentionPolicy_EmptyDir 验证目录不存在时 Prune 不报错. func TestRetentionPolicy_EmptyDir(t *testing.T) { fh, _ := newTestFileHistory(t) // 不创建 backupDir 下的文件(目录本身也不存在) nonExistDir := filepath.Join(t.TempDir(), "nonexistent") fh.baseDir = nonExistDir ctx := context.Background() policy := RetentionPolicy{MaxAgeDays: 30, MaxVersions: 50} if err := fh.Prune(ctx, policy); err != nil { t.Fatalf("目录不存在时 Prune 不应报错: %v", err) } } // TestRetentionPolicy_ContextCancel 验证 context 取消时 Prune 提前退出. func TestRetentionPolicy_ContextCancel(t *testing.T) { fh, backupDir := newTestFileHistory(t) createBackupFiles(t, backupDir, 10) ctx, cancel := context.WithCancel(context.Background()) cancel() // 立即取消 policy := RetentionPolicy{MaxVersions: 5} err := fh.Prune(ctx, policy) // context 已取消,应返回 context.Canceled if err == nil { // Prune 可能在检查 ctx.Done 前就完成了(10个文件处理很快), // 这是正常的竞态--不当作测试失败 t.Log("注意:context 取消前 Prune 已完成(文件数少,处理快)") } } // TestDefaultRetentionPolicy 验证默认保留策略的字段值. func TestDefaultRetentionPolicy(t *testing.T) { if DefaultRetentionPolicy.MaxAgeDays != 30 { t.Errorf("DefaultRetentionPolicy.MaxAgeDays = %d, 期望 30", DefaultRetentionPolicy.MaxAgeDays) } if DefaultRetentionPolicy.MaxVersions != 50 { t.Errorf("DefaultRetentionPolicy.MaxVersions = %d, 期望 50", DefaultRetentionPolicy.MaxVersions) } } // TestRetentionPolicy_BothConditions 验证两个条件 OR 逻辑都生效. func TestRetentionPolicy_BothConditions(t *testing.T) { fh, backupDir := newTestFileHistory(t) ctx := context.Background() if err := os.MkdirAll(backupDir, 0755); err != nil { t.Fatalf("创建备份目录失败: %v", err) } // 创建 8 个文件,其中 3 个 mtime 设为 35 天前(超过 MaxAgeDays=30) for i := 0; i < 8; i++ { name := filepath.Join(backupDir, "bk"+string(rune('a'+i))) os.WriteFile(name, []byte("content"), 0644) var mtime time.Time if i < 3 { mtime = time.Now().AddDate(0, 0, -35) // 超过 30 天 } else { mtime = time.Now().Add(time.Duration(i) * time.Second) // 新文件 } os.Chtimes(name, mtime, mtime) } // MaxAgeDays=30 删除 3 个旧文件;MaxVersions=3 再从剩余 5 个中删到 3 个 policy := RetentionPolicy{MaxAgeDays: 30, MaxVersions: 3} if err := fh.Prune(ctx, policy); err != nil { t.Fatalf("Prune 失败: %v", err) } entries, _ := os.ReadDir(backupDir) // 阶段一:删 3 个旧文件 → 剩 5 个 // 阶段二:MaxVersions=3,删 2 个 → 剩 3 个 if len(entries) != 3 { t.Errorf("两阶段清理后应保留 3 个文件,实际: %d", len(entries)) } }