// filecache_test.go -- 文件状态缓存的单元测试. // // 覆盖场景: // - 基本记录和查询 // - LRU 淘汰策略 // - 文件修改检测 // - 最近文件列表 // - 缓存清空 // - 命中率统计 // - 并发安全 package engine import ( "fmt" "os" "path/filepath" "sync" "testing" ) // TestFileStateCache_RecordAndGet 测试基本的记录和查询功能 func TestFileStateCache_RecordAndGet(t *testing.T) { cache := NewFileStateCache(100) // 创建临时文件用于记录 dir := t.TempDir() filePath := filepath.Join(dir, "test.go") content := []byte("package main\nfunc main() {}\n") os.WriteFile(filePath, content, 0644) // 记录 cache.Record(filePath, content) // 查询 entry, ok := cache.Get(filePath) if !ok { t.Fatal("缓存中应存在该条目") } if entry.Path != filePath { t.Errorf("路径不匹配: 期望 %q, 实际 %q", filePath, entry.Path) } if entry.Size != int64(len(content)) { t.Errorf("大小不匹配: 期望 %d, 实际 %d", len(content), entry.Size) } if entry.LineCount != 2 { t.Errorf("行数不匹配: 期望 2, 实际 %d", entry.LineCount) } if entry.ContentHash == "" { t.Error("内容哈希不应为空") } if len(entry.ContentHash) != 16 { t.Errorf("内容哈希长度应为 16, 实际 %d", len(entry.ContentHash)) } if entry.ReadAt.IsZero() { t.Error("读取时间不应为零值") } if entry.WasModified { t.Error("刚记录的条目不应标记为已修改") } } // TestFileStateCache_GetMiss 测试查询不存在的条目 func TestFileStateCache_GetMiss(t *testing.T) { cache := NewFileStateCache(100) entry, ok := cache.Get("/nonexistent/file.go") if ok { t.Error("不存在的路径不应命中") } if entry != nil { t.Error("未命中时应返回 nil") } } // TestFileStateCache_LRUEviction 测试 LRU 淘汰策略 func TestFileStateCache_LRUEviction(t *testing.T) { cache := NewFileStateCache(3) // 最多 3 个条目 dir := t.TempDir() // 记录 4 个文件,应淘汰第一个 for i := 0; i < 4; i++ { path := filepath.Join(dir, fmt.Sprintf("file%d.go", i)) content := []byte(fmt.Sprintf("package f%d\n", i)) os.WriteFile(path, content, 0644) cache.Record(path, content) } // 第一个文件应被淘汰 _, ok := cache.Get(filepath.Join(dir, "file0.go")) if ok { t.Error("第一个文件应已被淘汰") } // 后三个文件应存在 for i := 1; i < 4; i++ { _, ok := cache.Get(filepath.Join(dir, fmt.Sprintf("file%d.go", i))) if !ok { t.Errorf("file%d.go 应存在于缓存中", i) } } stats := cache.Stats() if stats.Evictions != 1 { t.Errorf("淘汰次数应为 1, 实际 %d", stats.Evictions) } } // TestFileStateCache_LRUAccess 测试访问后更新 LRU 位置 func TestFileStateCache_LRUAccess(t *testing.T) { cache := NewFileStateCache(3) dir := t.TempDir() // 记录 3 个文件 paths := make([]string, 3) for i := 0; i < 3; i++ { paths[i] = filepath.Join(dir, fmt.Sprintf("file%d.go", i)) content := []byte(fmt.Sprintf("package f%d\n", i)) os.WriteFile(paths[i], content, 0644) cache.Record(paths[i], content) } // 访问 file0(使其变为最近使用) cache.Get(paths[0]) // 再添加一个新文件,应淘汰 file1(而不是 file0) newPath := filepath.Join(dir, "file3.go") os.WriteFile(newPath, []byte("package f3\n"), 0644) cache.Record(newPath, []byte("package f3\n")) // file0 应仍在缓存中(因为刚被访问过) if _, ok := cache.Get(paths[0]); !ok { t.Error("file0 应仍在缓存中(最近访问过)") } // file1 应被淘汰 if _, ok := cache.Get(paths[1]); ok { t.Error("file1 应被淘汰") } } // TestFileStateCache_IsModified 测试文件修改检测 func TestFileStateCache_IsModified(t *testing.T) { cache := NewFileStateCache(100) dir := t.TempDir() filePath := filepath.Join(dir, "test.go") content := []byte("package main\n") os.WriteFile(filePath, content, 0644) // 记录文件 cache.Record(filePath, content) // 文件未修改,应返回 false if cache.IsModified(filePath) { t.Error("文件未修改,IsModified 应返回 false") } // 不在缓存中的文件应返回 true(保守策略) if !cache.IsModified("/nonexistent/file.go") { t.Error("不在缓存中的文件应返回 true") } } // TestFileStateCache_RecentFiles 测试最近文件列表 func TestFileStateCache_RecentFiles(t *testing.T) { cache := NewFileStateCache(100) dir := t.TempDir() // 记录 5 个文件 for i := 0; i < 5; i++ { path := filepath.Join(dir, fmt.Sprintf("file%d.go", i)) content := []byte(fmt.Sprintf("package f%d\n", i)) os.WriteFile(path, content, 0644) cache.Record(path, content) } // 获取最近 3 个文件 recent := cache.RecentFiles(3) if len(recent) != 3 { t.Fatalf("期望 3 个文件, 实际 %d", len(recent)) } // 最近的应该是 file4(最后记录的) expected := filepath.Join(dir, "file4.go") if recent[0] != expected { t.Errorf("最近的文件应为 %q, 实际 %q", expected, recent[0]) } // 获取全部文件 all := cache.RecentFiles(0) if len(all) != 5 { t.Errorf("全部文件应为 5, 实际 %d", len(all)) } } // TestFileStateCache_Clear 测试清空缓存 func TestFileStateCache_Clear(t *testing.T) { cache := NewFileStateCache(100) dir := t.TempDir() filePath := filepath.Join(dir, "test.go") content := []byte("package main\n") os.WriteFile(filePath, content, 0644) cache.Record(filePath, content) // 确认存在 if _, ok := cache.Get(filePath); !ok { t.Fatal("清空前应存在") } // 清空 cache.Clear() // 清空后统计数据应归零 stats := cache.Stats() if stats.Entries != 0 { t.Errorf("清空后条目数应为 0, 实际 %d", stats.Entries) } if stats.Hits != 0 || stats.Misses != 0 { t.Error("清空后统计数据应归零") } // 确认不存在(此 Get 会产生一次 miss,但在统计检查之后) if _, ok := cache.Get(filePath); ok { t.Error("清空后不应存在") } } // TestFileStateCache_Stats 测试命中率统计 func TestFileStateCache_Stats(t *testing.T) { cache := NewFileStateCache(100) dir := t.TempDir() filePath := filepath.Join(dir, "test.go") content := []byte("package main\n") os.WriteFile(filePath, content, 0644) cache.Record(filePath, content) // 命中一次 cache.Get(filePath) // 未命中一次 cache.Get("/nonexistent") stats := cache.Stats() if stats.Entries != 1 { t.Errorf("条目数应为 1, 实际 %d", stats.Entries) } if stats.Hits != 1 { t.Errorf("命中次数应为 1, 实际 %d", stats.Hits) } if stats.Misses != 1 { t.Errorf("未命中次数应为 1, 实际 %d", stats.Misses) } if stats.HitRate() != 0.5 { t.Errorf("命中率应为 0.5, 实际 %f", stats.HitRate()) } } // TestFileStateCache_DefaultMaxSize 测试默认最大条目数 func TestFileStateCache_DefaultMaxSize(t *testing.T) { cache := NewFileStateCache(0) stats := cache.Stats() if stats.MaxSize != 1000 { t.Errorf("默认最大条目数应为 1000, 实际 %d", stats.MaxSize) } cache2 := NewFileStateCache(-1) stats2 := cache2.Stats() if stats2.MaxSize != 1000 { t.Errorf("负数应使用默认值 1000, 实际 %d", stats2.MaxSize) } } // TestFileStateCache_LineCount 测试行数统计 func TestFileStateCache_LineCount(t *testing.T) { cache := NewFileStateCache(100) dir := t.TempDir() tests := []struct { name string content string wantLines int }{ {"空文件", "", 0}, {"一行无换行", "hello", 1}, {"一行有换行", "hello\n", 1}, {"三行", "a\nb\nc\n", 3}, {"三行无末尾换行", "a\nb\nc", 3}, } for _, tt := range tests { filePath := filepath.Join(dir, tt.name+".txt") os.WriteFile(filePath, []byte(tt.content), 0644) cache.Record(filePath, []byte(tt.content)) entry, ok := cache.Get(filePath) if !ok { t.Errorf("[%s] 应存在于缓存中", tt.name) continue } if entry.LineCount != tt.wantLines { t.Errorf("[%s] 行数: 期望 %d, 实际 %d", tt.name, tt.wantLines, entry.LineCount) } } } // TestFileStateCache_ContentHash 测试内容哈希一致性 func TestFileStateCache_ContentHash(t *testing.T) { cache := NewFileStateCache(100) dir := t.TempDir() content := []byte("package main\nfunc main() {}\n") // 相同内容应产生相同哈希 path1 := filepath.Join(dir, "file1.go") path2 := filepath.Join(dir, "file2.go") os.WriteFile(path1, content, 0644) os.WriteFile(path2, content, 0644) cache.Record(path1, content) cache.Record(path2, content) entry1, _ := cache.Get(path1) entry2, _ := cache.Get(path2) if entry1.ContentHash != entry2.ContentHash { t.Errorf("相同内容应产生相同哈希: %q vs %q", entry1.ContentHash, entry2.ContentHash) } // 不同内容应产生不同哈希 path3 := filepath.Join(dir, "file3.go") content3 := []byte("package other\n") os.WriteFile(path3, content3, 0644) cache.Record(path3, content3) entry3, _ := cache.Get(path3) if entry1.ContentHash == entry3.ContentHash { t.Error("不同内容应产生不同哈希") } } // TestFileStateCache_RecordOverwrite 测试重复记录覆盖 func TestFileStateCache_RecordOverwrite(t *testing.T) { cache := NewFileStateCache(100) dir := t.TempDir() filePath := filepath.Join(dir, "test.go") // 记录初始内容 content1 := []byte("version 1\n") os.WriteFile(filePath, content1, 0644) cache.Record(filePath, content1) entry1, _ := cache.Get(filePath) hash1 := entry1.ContentHash // 记录更新后的内容 content2 := []byte("version 2\n") os.WriteFile(filePath, content2, 0644) cache.Record(filePath, content2) entry2, _ := cache.Get(filePath) if entry2.ContentHash == hash1 { t.Error("更新后哈希应不同") } if entry2.Size != int64(len(content2)) { t.Errorf("大小应更新为 %d, 实际 %d", len(content2), entry2.Size) } // 缓存条目数应仍为 1 stats := cache.Stats() if stats.Entries != 1 { t.Errorf("重复记录后条目数应为 1, 实际 %d", stats.Entries) } } // TestFileStateCache_ConcurrentAccess 测试并发访问安全性 func TestFileStateCache_ConcurrentAccess(t *testing.T) { cache := NewFileStateCache(100) dir := t.TempDir() var wg sync.WaitGroup // 并发写入 for i := 0; i < 50; i++ { wg.Add(1) go func(n int) { defer wg.Done() path := filepath.Join(dir, fmt.Sprintf("file%d.go", n)) content := []byte(fmt.Sprintf("package f%d\n", n)) os.WriteFile(path, content, 0644) cache.Record(path, content) }(i) } // 并发读取 for i := 0; i < 50; i++ { wg.Add(1) go func(n int) { defer wg.Done() path := filepath.Join(dir, fmt.Sprintf("file%d.go", n)) cache.Get(path) cache.IsModified(path) cache.RecentFiles(5) cache.Stats() }(i) } wg.Wait() // 不 panic 就算通过 stats := cache.Stats() if stats.Entries > 100 { t.Errorf("条目数不应超过 100, 实际 %d", stats.Entries) } } // TestFileStateCache_Peek 测试 Peek 不更新 LRU 顺序,不计入统计 func TestFileStateCache_Peek(t *testing.T) { dir := t.TempDir() cache := NewFileStateCache(3) paths := make([]string, 3) for i := range paths { p := filepath.Join(dir, fmt.Sprintf("f%d.go", i)) os.WriteFile(p, []byte("x"), 0644) cache.Record(p, []byte("x")) paths[i] = p } // LRU 顺序(头→尾): paths[2], paths[1], paths[0] // Peek paths[0](最旧的)-- 不应该更新 LRU 顺序 entry, ok := cache.Peek(paths[0]) if !ok || entry == nil { t.Fatal("Peek: 应命中 paths[0]") } // 插入第 4 个条目,触发淘汰--应该淘汰 paths[0](Peek 不保护它) p3 := filepath.Join(dir, "f3.go") os.WriteFile(p3, []byte("x"), 0644) cache.Record(p3, []byte("x")) if _, ok := cache.Peek(paths[0]); ok { t.Error("Peek 不更新 LRU,paths[0] 应被淘汰") } // Peek 不计入统计(hits/misses 不变) statsBefore := cache.Stats() cache.Peek(paths[1]) // 命中 cache.Peek("nonexistent") // 未命中 statsAfter := cache.Stats() if statsAfter.Hits != statsBefore.Hits || statsAfter.Misses != statsBefore.Misses { t.Error("Peek 不应影响 hits/misses 统计") } } // TestFileStateCache_PeekVsGet_LRUOrder 验证 Get 更新 LRU 而 Peek 不更新 func TestFileStateCache_PeekVsGet_LRUOrder(t *testing.T) { dir := t.TempDir() cache := NewFileStateCache(2) p1 := filepath.Join(dir, "a.go") p2 := filepath.Join(dir, "b.go") os.WriteFile(p1, []byte("a"), 0644) os.WriteFile(p2, []byte("b"), 0644) cache.Record(p1, []byte("a")) // LRU: [p1] cache.Record(p2, []byte("b")) // LRU: [p2, p1](p2 最新) // Peek p1 不改变顺序--p1 仍是最旧的 cache.Peek(p1) // 加入 p3 应淘汰 p1(最旧) p3 := filepath.Join(dir, "c.go") os.WriteFile(p3, []byte("c"), 0644) cache.Record(p3, []byte("c")) if _, ok := cache.Get(p1); ok { t.Error("Peek 不保护 p1,插入 p3 后应淘汰 p1") } if _, ok := cache.Get(p2); !ok { t.Error("p2 应仍在缓存中") } if _, ok := cache.Get(p3); !ok { t.Error("p3 应在缓存中") } } // TestFileStateCache_GetUpdatesLRU 验证 Get 更新 LRU 顺序(保护访问的条目) func TestFileStateCache_GetUpdatesLRU(t *testing.T) { dir := t.TempDir() cache := NewFileStateCache(2) p1 := filepath.Join(dir, "a.go") p2 := filepath.Join(dir, "b.go") os.WriteFile(p1, []byte("a"), 0644) os.WriteFile(p2, []byte("b"), 0644) cache.Record(p1, []byte("a")) // LRU: [p1] cache.Record(p2, []byte("b")) // LRU: [p2, p1] // Get p1 更新顺序:LRU 变为 [p1, p2] cache.Get(p1) // 加入 p3 应淘汰 p2(现在是最旧的) p3 := filepath.Join(dir, "c.go") os.WriteFile(p3, []byte("c"), 0644) cache.Record(p3, []byte("c")) if _, ok := cache.Peek(p2); ok { t.Error("Get(p1) 后 p2 变成最旧,插入 p3 应淘汰 p2") } if _, ok := cache.Peek(p1); !ok { t.Error("p1 经 Get 保护后应仍在缓存中") } } // TestCacheStats_HitRate 测试命中率计算 func TestCacheStats_HitRate(t *testing.T) { // 无请求时命中率为 0 stats := CacheStats{Hits: 0, Misses: 0} if stats.HitRate() != 0 { t.Errorf("无请求时命中率应为 0, 实际 %f", stats.HitRate()) } // 全部命中 stats = CacheStats{Hits: 10, Misses: 0} if stats.HitRate() != 1.0 { t.Errorf("全部命中时命中率应为 1.0, 实际 %f", stats.HitRate()) } // 全部未命中 stats = CacheStats{Hits: 0, Misses: 10} if stats.HitRate() != 0 { t.Errorf("全部未命中时命中率应为 0, 实际 %f", stats.HitRate()) } }