// result_store_test.go -- 大结果磁盘持久化的单元测试. // // 覆盖场景: // - 小输出不截断 // - 大输出截断并存储到磁盘 // - 读取存储的结果 // - 清理旧文件 // - 存储目录自动创建 // - 截断函数 package engine import ( "os" "path/filepath" "strings" "testing" "time" ) // TestResultStore_SmallOutput 测试小输出不截断 func TestResultStore_SmallOutput(t *testing.T) { dir := t.TempDir() store := NewResultStore(dir, "test-session") output := "small output" processed, storedPath := store.ProcessResult("tool-1", "Bash", output) if processed != output { t.Errorf("小输出应原样返回, 实际: %q", processed) } if storedPath != "" { t.Errorf("小输出不应存储到磁盘, storedPath: %q", storedPath) } } // TestResultStore_ExactThreshold 测试恰好等于阈值的输出 func TestResultStore_ExactThreshold(t *testing.T) { dir := t.TempDir() store := NewResultStore(dir, "test-session") // 恰好 MaxInlineResultChars 长度的输出 output := strings.Repeat("a", MaxInlineResultChars) processed, storedPath := store.ProcessResult("tool-1", "Bash", output) if processed != output { t.Error("恰好等于阈值的输出应原样返回") } if storedPath != "" { t.Error("恰好等于阈值的输出不应存储") } } // TestResultStore_LargeOutput 测试大输出截断并存储 func TestResultStore_LargeOutput(t *testing.T) { dir := t.TempDir() store := NewResultStore(dir, "test-session") // 超过阈值的输出 output := strings.Repeat("x", MaxInlineResultChars+1000) processed, storedPath := store.ProcessResult("tool-1", "Grep", output) // 处理后的输出应包含截断提示 if !strings.Contains(processed, "Output too large") { t.Error("应包含截断提示") } if !strings.Contains(processed, "Full output saved to:") { t.Error("应包含磁盘路径提示") } if !strings.Contains(processed, "Showing first") { t.Error("应包含预览提示") } // 应有存储路径 if storedPath == "" { t.Fatal("大输出应存储到磁盘") } // 存储路径应包含 session ID 和 tool use ID if !strings.Contains(storedPath, "test-session") { t.Errorf("存储路径应包含 session ID: %q", storedPath) } if !strings.Contains(storedPath, "tool-1") { t.Errorf("存储路径应包含 tool use ID: %q", storedPath) } // 文件应存在 if _, err := os.Stat(storedPath); os.IsNotExist(err) { t.Fatal("存储文件应存在") } // 文件内容应为完整输出 data, err := os.ReadFile(storedPath) if err != nil { t.Fatalf("读取存储文件失败: %v", err) } if string(data) != output { t.Error("存储文件内容应为完整输出") } } // TestResultStore_ReadStoredResult 测试读取存储的结果 func TestResultStore_ReadStoredResult(t *testing.T) { dir := t.TempDir() store := NewResultStore(dir, "test-session") // 存储一个大输出 output := strings.Repeat("y", MaxInlineResultChars+500) _, storedPath := store.ProcessResult("tool-2", "Bash", output) if storedPath == "" { t.Fatal("应有存储路径") } // 读取存储的结果 content, err := store.ReadStoredResult(storedPath) if err != nil { t.Fatalf("读取失败: %v", err) } if content != output { t.Error("读取的内容应与原始输出一致") } } // TestResultStore_ReadStoredResult_NotFound 测试读取不存在的文件 func TestResultStore_ReadStoredResult_NotFound(t *testing.T) { dir := t.TempDir() store := NewResultStore(dir, "test-session") _, err := store.ReadStoredResult("/nonexistent/file.txt") if err == nil { t.Error("读取不存在的文件应报错") } } // TestResultStore_Cleanup 测试清理旧文件 func TestResultStore_Cleanup(t *testing.T) { dir := t.TempDir() store := NewResultStore(dir, "old-session") // 手动创建旧文件 sessionDir := filepath.Join(dir, "old-session") os.MkdirAll(sessionDir, 0755) oldFile := filepath.Join(sessionDir, "old-tool.txt") os.WriteFile(oldFile, []byte("old output"), 0644) // 将文件修改时间设置为 2 小时前 oldTime := time.Now().Add(-2 * time.Hour) os.Chtimes(oldFile, oldTime, oldTime) // 清理 1 小时前的文件 cleaned := store.Cleanup(1 * time.Hour) if cleaned != 1 { t.Errorf("应清理 1 个文件, 实际 %d", cleaned) } // 文件应不存在 if _, err := os.Stat(oldFile); !os.IsNotExist(err) { t.Error("旧文件应被清理") } } // TestResultStore_Cleanup_PreservesNew 测试清理不影响新文件 func TestResultStore_Cleanup_PreservesNew(t *testing.T) { dir := t.TempDir() store := NewResultStore(dir, "test-session") // 创建新文件 sessionDir := filepath.Join(dir, "test-session") os.MkdirAll(sessionDir, 0755) newFile := filepath.Join(sessionDir, "new-tool.txt") os.WriteFile(newFile, []byte("new output"), 0644) // 清理 1 小时前的文件 cleaned := store.Cleanup(1 * time.Hour) if cleaned != 0 { t.Errorf("不应清理新文件, 实际清理了 %d 个", cleaned) } // 文件应仍存在 if _, err := os.Stat(newFile); os.IsNotExist(err) { t.Error("新文件不应被清理") } } // TestResultStore_DefaultSessionID 测试默认会话 ID func TestResultStore_DefaultSessionID(t *testing.T) { dir := t.TempDir() store := NewResultStore(dir, "") // sessionID 不应为空 if store.sessionID == "" { t.Error("默认 sessionID 不应为空") } if !strings.HasPrefix(store.sessionID, "session-") { t.Errorf("默认 sessionID 应以 'session-' 开头, 实际: %q", store.sessionID) } } // TestResultStore_MultipleOutputs 测试多个输出的独立存储 func TestResultStore_MultipleOutputs(t *testing.T) { dir := t.TempDir() store := NewResultStore(dir, "multi-session") output1 := strings.Repeat("a", MaxInlineResultChars+100) output2 := strings.Repeat("b", MaxInlineResultChars+200) _, path1 := store.ProcessResult("tool-1", "Grep", output1) _, path2 := store.ProcessResult("tool-2", "Bash", output2) if path1 == "" || path2 == "" { t.Fatal("两个大输出都应存储") } if path1 == path2 { t.Error("不同工具调用应存储到不同路径") } // 分别读取验证 content1, _ := store.ReadStoredResult(path1) content2, _ := store.ReadStoredResult(path2) if content1 != output1 { t.Error("第一个存储的内容不匹配") } if content2 != output2 { t.Error("第二个存储的内容不匹配") } } // TestTruncateString 测试字符串截断函数 func TestTruncateString(t *testing.T) { tests := []struct { name string input string maxChars int wantLen int }{ {"短字符串", "hello", 10, 5}, {"恰好等于", "hello", 5, 5}, {"需要截断", "hello world", 5, 5}, {"空字符串", "", 5, 0}, } for _, tt := range tests { result := truncateString(tt.input, tt.maxChars) if len(result) != tt.wantLen { t.Errorf("[%s] 截断后长度: 期望 %d, 实际 %d", tt.name, tt.wantLen, len(result)) } } } // TestResultStore_PreviewContent 测试截断预览包含实际内容 func TestResultStore_PreviewContent(t *testing.T) { dir := t.TempDir() store := NewResultStore(dir, "test-session") // 构建一个可辨识的大输出 prefix := "UNIQUE_PREFIX_FOR_TEST:" output := prefix + strings.Repeat("x", MaxInlineResultChars+1000) processed, _ := store.ProcessResult("tool-1", "Bash", output) // 预览应包含原始输出的前缀 if !strings.Contains(processed, prefix) { t.Error("预览应包含原始输出的前缀内容") } }