// filewrite_test.go -- FileWrite 工具的单元测试. // // 覆盖场景: // - 创建新文件 // - 覆盖已有文件 // - 自动创建父目录 // - 空文件路径报错 // - 验证写入内容正确 package builtin import ( "context" "encoding/json" "os" "path/filepath" "strings" "testing" "git.flytoex.net/yuanwei/flyto-agent/pkg/tools" ) // TestFileWriteTool_CreateNew 测试创建新文件 func TestFileWriteTool_CreateNew(t *testing.T) { tool := NewFileWriteTool() dir := t.TempDir() filePath := filepath.Join(dir, "new.txt") input, _ := json.Marshal(fileWriteInput{ FilePath: filePath, Content: "hello world", }) result, err := tool.Execute(context.Background(), input, nil) if err != nil { t.Fatalf("执行失败: %v", err) } if result.IsError { t.Fatalf("不应标记为错误: %s", result.Output) } if !strings.Contains(result.Output, "Created") { t.Errorf("新文件应提示 'Created': %s", result.Output) } data, _ := os.ReadFile(filePath) if string(data) != "hello world" { t.Errorf("文件内容不匹配, 实际: %q", string(data)) } } // TestFileWriteTool_Overwrite 测试覆盖已有文件 func TestFileWriteTool_Overwrite(t *testing.T) { tool := NewFileWriteTool() dir := t.TempDir() filePath := filepath.Join(dir, "existing.txt") os.WriteFile(filePath, []byte("old content"), 0644) input, _ := json.Marshal(fileWriteInput{ FilePath: filePath, Content: "new content", }) result, err := tool.Execute(context.Background(), input, nil) if err != nil { t.Fatalf("执行失败: %v", err) } if result.IsError { t.Fatalf("不应标记为错误: %s", result.Output) } if !strings.Contains(result.Output, "Wrote") { t.Errorf("覆盖文件应提示 'Wrote': %s", result.Output) } data, _ := os.ReadFile(filePath) if string(data) != "new content" { t.Errorf("文件内容不匹配, 实际: %q", string(data)) } } // TestFileWriteTool_AutoCreateDirs 测试自动创建父目录 func TestFileWriteTool_AutoCreateDirs(t *testing.T) { tool := NewFileWriteTool() dir := t.TempDir() filePath := filepath.Join(dir, "a", "b", "c", "deep.txt") input, _ := json.Marshal(fileWriteInput{ FilePath: filePath, Content: "deep content", }) result, err := tool.Execute(context.Background(), input, nil) if err != nil { t.Fatalf("执行失败: %v", err) } if result.IsError { t.Fatalf("不应标记为错误: %s", result.Output) } data, _ := os.ReadFile(filePath) if string(data) != "deep content" { t.Errorf("文件内容不匹配, 实际: %q", string(data)) } } // TestFileWriteTool_EmptyFilePath 测试空文件路径 func TestFileWriteTool_EmptyFilePath(t *testing.T) { tool := NewFileWriteTool() input, _ := json.Marshal(fileWriteInput{ FilePath: "", Content: "content", }) result, err := tool.Execute(context.Background(), input, nil) if err != nil { t.Fatalf("不应返回 Go error: %v", err) } if !result.IsError { t.Error("空路径应报错") } if !strings.Contains(result.Output, "file_path is required") { t.Errorf("错误信息不匹配: %s", result.Output) } } // TestFileWriteTool_EmptyContent 测试写入空内容 func TestFileWriteTool_EmptyContent(t *testing.T) { tool := NewFileWriteTool() dir := t.TempDir() filePath := filepath.Join(dir, "empty.txt") input, _ := json.Marshal(fileWriteInput{ FilePath: filePath, Content: "", }) result, err := tool.Execute(context.Background(), input, nil) if err != nil { t.Fatalf("执行失败: %v", err) } if result.IsError { t.Fatalf("写入空内容不应报错: %s", result.Output) } data, _ := os.ReadFile(filePath) if len(data) != 0 { t.Errorf("文件应为空, 实际长度: %d", len(data)) } } // TestFileWriteTool_ByteCount 测试输出包含字节数 func TestFileWriteTool_ByteCount(t *testing.T) { tool := NewFileWriteTool() dir := t.TempDir() filePath := filepath.Join(dir, "count.txt") content := "12345" input, _ := json.Marshal(fileWriteInput{ FilePath: filePath, Content: content, }) result, err := tool.Execute(context.Background(), input, nil) if err != nil { t.Fatalf("执行失败: %v", err) } if !strings.Contains(result.Output, "5 bytes") { t.Errorf("应包含字节数 '5 bytes': %s", result.Output) } } // TestFileWriteTool_Metadata 测试工具元数据 func TestFileWriteTool_Metadata(t *testing.T) { tool := NewFileWriteTool() meta := tool.Metadata() if meta.ConcurrencySafe { t.Error("FileWrite 不应标记为 ConcurrencySafe") } if meta.ReadOnly { t.Error("FileWrite 不应标记为 ReadOnly") } if tool.Name() != "Write" { t.Errorf("期望名称 'Write', 实际 %q", tool.Name()) } } // ───────────────────────────────────────────────────────────────────── // ToolCapability 协议测试 // ───────────────────────────────────────────────────────────────────── // TestFileWriteTool_Capability 测试 FileWrite 的能力声明 func TestFileWriteTool_Capability(t *testing.T) { tool := NewFileWriteTool() cap := tool.Capability() if !cap.DryRun { t.Error("FileWrite 应该支持 DryRun") } if !cap.Reversible { t.Error("FileWrite 应该支持 Reversible") } if cap.UndoMethod != "tool" { t.Errorf("UndoMethod 应该是 'tool',实际: %q", cap.UndoMethod) } } // TestFileWriteTool_DryRun_NewFile 测试 DryRun 新建文件 func TestFileWriteTool_DryRun_NewFile(t *testing.T) { tool := NewFileWriteTool() dir := t.TempDir() filePath := filepath.Join(dir, "newfile.txt") input, _ := json.Marshal(fileWriteInput{ FilePath: filePath, Content: "new content", }) result, err := tool.DryRun(context.Background(), input) if err != nil { t.Fatalf("DryRun 失败: %v", err) } if !strings.Contains(result.Preview, "create") { t.Errorf("新文件的 DryRun 应包含 'create': %q", result.Preview) } // 文件不应被创建 if _, err := os.Stat(filePath); !os.IsNotExist(err) { t.Error("DryRun 不应实际创建文件") } } // TestFileWriteTool_DryRun_OverwriteFile 测试 DryRun 覆盖文件 func TestFileWriteTool_DryRun_OverwriteFile(t *testing.T) { tool := NewFileWriteTool() dir := t.TempDir() filePath := filepath.Join(dir, "existing.txt") os.WriteFile(filePath, []byte("old"), 0644) input, _ := json.Marshal(fileWriteInput{ FilePath: filePath, Content: "new content", }) result, err := tool.DryRun(context.Background(), input) if err != nil { t.Fatalf("DryRun 失败: %v", err) } if !strings.Contains(result.Preview, "overwrite") { t.Errorf("已存在文件的 DryRun 应包含 'overwrite': %q", result.Preview) } // 文件内容不应被修改 data, _ := os.ReadFile(filePath) if string(data) != "old" { t.Error("DryRun 不应修改文件内容") } } // TestFileWriteTool_GenerateUndo 测试 FileWrite 的撤销信息生成 func TestFileWriteTool_GenerateUndo(t *testing.T) { tool := NewFileWriteTool() input, _ := json.Marshal(fileWriteInput{ FilePath: "/tmp/test.txt", Content: "new content", }) undo, err := tool.GenerateUndo(context.Background(), input, &tools.Result{Output: "ok"}) if err != nil { t.Fatalf("GenerateUndo 失败: %v", err) } if undo.ToolName != "Write" { t.Errorf("撤销工具应该是 'Write',实际: %q", undo.ToolName) } if undo.Input["file_path"] != "/tmp/test.txt" { t.Errorf("撤销输入应包含文件路径: %v", undo.Input) } } // TestFileWriteTool_WithFileHistory 测试 FileWrite 配合文件历史 func TestFileWriteTool_WithFileHistory(t *testing.T) { dir := t.TempDir() filePath := filepath.Join(dir, "test.txt") os.WriteFile(filePath, []byte("old content"), 0644) history := &mockWriteFileHistory{} tool := NewFileWriteToolWithHistory(history) tool.SetMessageID("msg-1") input, _ := json.Marshal(fileWriteInput{ FilePath: filePath, Content: "new content", }) result, err := tool.Execute(context.Background(), input, nil) if err != nil { t.Fatalf("执行失败: %v", err) } if result.IsError { t.Fatalf("不应标记为错误: %s", result.Output) } // 验证文件历史被调用 if len(history.calls) != 1 { t.Errorf("文件历史应该被调用 1 次,实际: %d", len(history.calls)) } if history.calls[0].method != "BeforeWrite" { t.Errorf("应该调用 BeforeWrite,实际: %q", history.calls[0].method) } } // Mock FileHistoryRecorder for filewrite tests type writeHistoryCall struct { method string filePath string messageID string } type mockWriteFileHistory struct { calls []writeHistoryCall } func (m *mockWriteFileHistory) BeforeEdit(filePath string, messageID string) error { m.calls = append(m.calls, writeHistoryCall{"BeforeEdit", filePath, messageID}) return nil } func (m *mockWriteFileHistory) BeforeWrite(filePath string, messageID string) error { m.calls = append(m.calls, writeHistoryCall{"BeforeWrite", filePath, messageID}) return nil }