// input_test.go - 输入处理管道的单元测试. // // 覆盖场景: // - 文件引用展开(@path 和 files:path 语法) // - 图片检测(.png/.jpg/.gif/.webp) // - URL 检测 // - 斜杠命令解析 // - 空输入检测 // - 路径解析(绝对路径,相对路径,~ 展开) // - 大文件截断 package engine import ( "os" "path/filepath" "strings" "testing" ) // TestProcess_EmptyInput 测试空输入 func TestProcess_EmptyInput(t *testing.T) { proc := NewInputProcessor("/tmp", nil) result, err := proc.Process("") if err != nil { t.Fatalf("处理空输入失败: %v", err) } if !result.IsEmpty { t.Error("空输入 IsEmpty 应为 true") } } // TestProcess_WhitespaceInput 测试纯空白输入 func TestProcess_WhitespaceInput(t *testing.T) { proc := NewInputProcessor("/tmp", nil) result, err := proc.Process(" \n\t ") if err != nil { t.Fatalf("处理失败: %v", err) } if !result.IsEmpty { t.Error("纯空白输入 IsEmpty 应为 true") } } // TestProcess_PlainText 测试普通文本输入 func TestProcess_PlainText(t *testing.T) { proc := NewInputProcessor("/tmp", nil) result, err := proc.Process("hello world") if err != nil { t.Fatalf("处理失败: %v", err) } if result.IsEmpty { t.Error("普通文本 IsEmpty 应为 false") } if result.Text != "hello world" { t.Errorf("文本应为 'hello world',实际: %q", result.Text) } if result.SlashCommand != nil { t.Error("普通文本不应解析为斜杠命令") } } // --- 斜杠命令测试 --- // TestDetectSlashCommand_Simple 测试简单斜杠命令 func TestDetectSlashCommand_Simple(t *testing.T) { proc := NewInputProcessor("/tmp", nil) result, err := proc.Process("/help") if err != nil { t.Fatalf("处理失败: %v", err) } if result.SlashCommand == nil { t.Fatal("应检测到斜杠命令") } if result.SlashCommand.Name != "help" { t.Errorf("命令名应为 'help',实际: %q", result.SlashCommand.Name) } if result.SlashCommand.Args != "" { t.Errorf("参数应为空,实际: %q", result.SlashCommand.Args) } } // TestDetectSlashCommand_WithArgs 测试带参数的斜杠命令 func TestDetectSlashCommand_WithArgs(t *testing.T) { proc := NewInputProcessor("/tmp", nil) result, err := proc.Process("/compact some arguments here") if err != nil { t.Fatalf("处理失败: %v", err) } if result.SlashCommand == nil { t.Fatal("应检测到斜杠命令") } if result.SlashCommand.Name != "compact" { t.Errorf("命令名应为 'compact',实际: %q", result.SlashCommand.Name) } if result.SlashCommand.Args != "some arguments here" { t.Errorf("参数应为 'some arguments here',实际: %q", result.SlashCommand.Args) } } // TestDetectSlashCommand_NotSlashCommand 测试非斜杠命令 func TestDetectSlashCommand_NotSlashCommand(t *testing.T) { proc := NewInputProcessor("/tmp", nil) tests := []string{ "hello /world", // 不是以 / 开头 "/123", // / 后跟数字 "/ space", // / 后跟空格 "/", // 仅有 / "normal text", // 普通文本 "path/to/file.txt", // 路径 } for _, input := range tests { result, err := proc.Process(input) if err != nil { t.Fatalf("处理 %q 失败: %v", input, err) } if result.SlashCommand != nil { t.Errorf("输入 %q 不应解析为斜杠命令,但得到: %+v", input, result.SlashCommand) } } } // TestDetectSlashCommand_Hyphen 测试带连字符的命令名 func TestDetectSlashCommand_Hyphen(t *testing.T) { proc := NewInputProcessor("/tmp", nil) result, err := proc.Process("/review-pr 123") if err != nil { t.Fatalf("处理失败: %v", err) } if result.SlashCommand == nil { t.Fatal("应检测到斜杠命令") } if result.SlashCommand.Name != "review-pr" { t.Errorf("命令名应为 'review-pr',实际: %q", result.SlashCommand.Name) } if result.SlashCommand.Args != "123" { t.Errorf("参数应为 '123',实际: %q", result.SlashCommand.Args) } } // --- URL 检测测试 --- // TestDetectURLs_Single 测试单个 URL func TestDetectURLs_Single(t *testing.T) { urls := detectURLs("请看这个页面 https://example.com/page") if len(urls) != 1 { t.Fatalf("应检测到 1 个 URL,实际: %d", len(urls)) } if urls[0] != "https://example.com/page" { t.Errorf("URL 应为 'https://example.com/page',实际: %q", urls[0]) } } // TestDetectURLs_Multiple 测试多个 URL func TestDetectURLs_Multiple(t *testing.T) { input := "看看 https://a.com 和 http://b.com/path?q=1" urls := detectURLs(input) if len(urls) != 2 { t.Fatalf("应检测到 2 个 URL,实际: %d", len(urls)) } } // TestDetectURLs_Dedup 测试 URL 去重 func TestDetectURLs_Dedup(t *testing.T) { input := "https://example.com 和 https://example.com" urls := detectURLs(input) if len(urls) != 1 { t.Fatalf("重复 URL 应去重,应检测到 1 个,实际: %d", len(urls)) } } // TestDetectURLs_NoURL 测试没有 URL 的文本 func TestDetectURLs_NoURL(t *testing.T) { urls := detectURLs("这段文本没有任何 URL") if len(urls) != 0 { t.Errorf("不应检测到 URL,实际: %v", urls) } } // TestDetectURLs_Nil 测试空文本 func TestDetectURLs_Nil(t *testing.T) { urls := detectURLs("") if len(urls) != 0 { t.Errorf("空文本不应检测到 URL,实际: %v", urls) } } // --- 文件引用展开测试 --- // TestExpandFileReferences_AtSyntax 测试 @path 语法 func TestExpandFileReferences_AtSyntax(t *testing.T) { tmpDir := t.TempDir() testFile := filepath.Join(tmpDir, "test.go") if err := os.WriteFile(testFile, []byte("package main\n\nfunc main() {}\n"), 0644); err != nil { t.Fatalf("创建测试文件失败: %v", err) } proc := NewInputProcessor(tmpDir, nil) result, err := proc.Process("请看这个文件 @test.go") if err != nil { t.Fatalf("处理失败: %v", err) } if !strings.Contains(result.Text, "package main") { t.Errorf("文件内容应被展开,实际: %q", result.Text) } if !strings.Contains(result.Text, "```") { t.Errorf("文件内容应包含代码块标记,实际: %q", result.Text) } if len(result.ReferencedFiles) != 1 { t.Fatalf("应有 1 个引用文件,实际: %d", len(result.ReferencedFiles)) } } // TestExpandFileReferences_FilesSyntax 测试 files:path 语法 func TestExpandFileReferences_FilesSyntax(t *testing.T) { tmpDir := t.TempDir() testFile := filepath.Join(tmpDir, "hello.txt") if err := os.WriteFile(testFile, []byte("Hello World"), 0644); err != nil { t.Fatalf("创建测试文件失败: %v", err) } proc := NewInputProcessor(tmpDir, nil) result, err := proc.Process("请看 files:hello.txt") if err != nil { t.Fatalf("处理失败: %v", err) } if !strings.Contains(result.Text, "Hello World") { t.Errorf("文件内容应被展开,实际: %q", result.Text) } } // TestExpandFileReferences_AbsolutePath 测试绝对路径引用 func TestExpandFileReferences_AbsolutePath(t *testing.T) { tmpDir := t.TempDir() testFile := filepath.Join(tmpDir, "abs.txt") if err := os.WriteFile(testFile, []byte("absolute path content"), 0644); err != nil { t.Fatalf("创建测试文件失败: %v", err) } proc := NewInputProcessor(tmpDir, nil) result, err := proc.Process("请看 @" + testFile) if err != nil { t.Fatalf("处理失败: %v", err) } if !strings.Contains(result.Text, "absolute path content") { t.Errorf("绝对路径文件内容应被展开,实际: %q", result.Text) } } // TestExpandFileReferences_NonexistentFile 测试引用不存在的文件 func TestExpandFileReferences_NonexistentFile(t *testing.T) { proc := NewInputProcessor("/tmp", nil) result, err := proc.Process("请看 @/nonexistent/file.go") if err != nil { t.Fatalf("处理失败: %v", err) } // 不存在的文件应保留原始引用 if !strings.Contains(result.Text, "@/nonexistent/file.go") { t.Errorf("不存在的文件应保留原始引用,实际: %q", result.Text) } } // TestExpandFileReferences_LargeFile 测试大文件截断 func TestExpandFileReferences_LargeFile(t *testing.T) { tmpDir := t.TempDir() testFile := filepath.Join(tmpDir, "large.txt") // 生成超过 200 行的文件 var lines []string for i := 0; i < 300; i++ { lines = append(lines, "line content here") } content := strings.Join(lines, "\n") if err := os.WriteFile(testFile, []byte(content), 0644); err != nil { t.Fatalf("创建测试文件失败: %v", err) } proc := NewInputProcessor(tmpDir, nil) result, err := proc.Process("请看 @large.txt") if err != nil { t.Fatalf("处理失败: %v", err) } if !strings.Contains(result.Text, "truncated") { t.Errorf("大文件应被截断并标注,实际: %q", result.Text) } if !strings.Contains(result.Text, "200") { t.Errorf("截断信息应包含行数限制 200,实际: %q", result.Text) } } // TestExpandFileReferences_NoReferences 测试没有文件引用 func TestExpandFileReferences_NoReferences(t *testing.T) { proc := NewInputProcessor("/tmp", nil) result, err := proc.Process("这段文本没有文件引用") if err != nil { t.Fatalf("处理失败: %v", err) } if result.Text != "这段文本没有文件引用" { t.Errorf("无引用时文本不应改变,实际: %q", result.Text) } if len(result.ReferencedFiles) != 0 { t.Errorf("不应有引用文件,实际: %v", result.ReferencedFiles) } } // TestExpandFileReferences_WithFileCache 测试文件缓存集成 func TestExpandFileReferences_WithFileCache(t *testing.T) { tmpDir := t.TempDir() testFile := filepath.Join(tmpDir, "cached.go") if err := os.WriteFile(testFile, []byte("package cached"), 0644); err != nil { t.Fatalf("创建测试文件失败: %v", err) } cache := NewFileStateCache(100) proc := NewInputProcessor(tmpDir, cache) _, err := proc.Process("看 @cached.go") if err != nil { t.Fatalf("处理失败: %v", err) } // 文件应被记录到缓存 entry, ok := cache.Get(testFile) if !ok { t.Error("引用的文件应被记录到缓存") } if entry.Path != testFile { t.Errorf("缓存条目路径应为 %s,实际: %s", testFile, entry.Path) } } // --- 图片检测测试 --- // TestDetectImages_PNG 测试 PNG 图片检测 func TestDetectImages_PNG(t *testing.T) { tmpDir := t.TempDir() // 创建一个假的 PNG 文件 imgFile := filepath.Join(tmpDir, "test.png") if err := os.WriteFile(imgFile, []byte("fake png data"), 0644); err != nil { t.Fatalf("创建测试文件失败: %v", err) } proc := NewInputProcessor(tmpDir, nil) result, err := proc.Process("看看这张图 @test.png") if err != nil { t.Fatalf("处理失败: %v", err) } if len(result.ContentBlocks) == 0 { t.Fatal("应检测到图片内容块") } if result.ContentBlocks[0].Type != "image" { t.Errorf("内容块类型应为 'image',实际: %s", result.ContentBlocks[0].Type) } src := result.ContentBlocks[0].Source if src == nil { t.Fatal("应填 ContentSource (base64 + MediaType + Data)") } if src.Type != "base64" { t.Errorf("Source.Type 应为 'base64',实际: %q", src.Type) } if src.MediaType != "image/png" { t.Errorf("Source.MediaType 应为 'image/png',实际: %q", src.MediaType) } if src.Data == "" { t.Error("Source.Data 应含 base64 编码内容") } } // TestDetectImages_JPEG 测试 JPEG 图片检测 func TestDetectImages_JPEG(t *testing.T) { tmpDir := t.TempDir() imgFile := filepath.Join(tmpDir, "photo.jpg") if err := os.WriteFile(imgFile, []byte("fake jpg data"), 0644); err != nil { t.Fatalf("创建测试文件失败: %v", err) } proc := NewInputProcessor(tmpDir, nil) result, err := proc.Process("看看 @photo.jpg") if err != nil { t.Fatalf("处理失败: %v", err) } if len(result.ContentBlocks) == 0 { t.Fatal("应检测到图片内容块") } src := result.ContentBlocks[0].Source if src == nil || src.MediaType != "image/jpeg" { t.Errorf("Source.MediaType 应为 'image/jpeg',实际 Source: %+v", src) } } // TestDetectImages_WebP 测试 WebP 图片检测 func TestDetectImages_WebP(t *testing.T) { tmpDir := t.TempDir() imgFile := filepath.Join(tmpDir, "anim.webp") if err := os.WriteFile(imgFile, []byte("fake webp data"), 0644); err != nil { t.Fatalf("创建测试文件失败: %v", err) } proc := NewInputProcessor(tmpDir, nil) result, err := proc.Process("看看 @anim.webp") if err != nil { t.Fatalf("处理失败: %v", err) } if len(result.ContentBlocks) == 0 { t.Fatal("应检测到图片内容块") } src := result.ContentBlocks[0].Source if src == nil || src.MediaType != "image/webp" { t.Errorf("Source.MediaType 应为 'image/webp',实际 Source: %+v", src) } } // TestDetectImages_NonImage 测试非图片文件不会产生图片块 func TestDetectImages_NonImage(t *testing.T) { tmpDir := t.TempDir() testFile := filepath.Join(tmpDir, "code.go") if err := os.WriteFile(testFile, []byte("package main"), 0644); err != nil { t.Fatalf("创建测试文件失败: %v", err) } proc := NewInputProcessor(tmpDir, nil) result, err := proc.Process("看看 @code.go") if err != nil { t.Fatalf("处理失败: %v", err) } if len(result.ContentBlocks) != 0 { t.Errorf("非图片文件不应产生图片块,实际: %d 个", len(result.ContentBlocks)) } } // --- 路径解析测试 --- // TestResolvePath_Absolute 测试绝对路径 func TestResolvePath_Absolute(t *testing.T) { proc := NewInputProcessor("/home/user/project", nil) result := proc.resolvePath("/etc/hosts") if result != "/etc/hosts" { t.Errorf("绝对路径应直接返回,实际: %q", result) } } // TestResolvePath_Relative 测试相对路径 func TestResolvePath_Relative(t *testing.T) { proc := NewInputProcessor("/home/user/project", nil) result := proc.resolvePath("src/main.go") expected := "/home/user/project/src/main.go" if result != expected { t.Errorf("相对路径解析: %q, 期望 %q", result, expected) } } // TestResolvePath_DotDot 测试 ../ 路径越界被拦截 func TestResolvePath_DotDot(t *testing.T) { proc := NewInputProcessor("/home/user/project", nil) // ../other/file.go 解析为 /home/user/other/file.go,超出 cwd 边界 result := proc.resolvePath("../other/file.go") if result != "" { t.Errorf("越界 ../ 路径应返回空字符串,实际: %q", result) } // 子目录内的 ../ 仍然合法(解析后仍在 cwd 内) result2 := proc.resolvePath("sub/../main.go") expected2 := "/home/user/project/main.go" if result2 != expected2 { t.Errorf("cwd 内 ../ 路径解析: %q, 期望 %q", result2, expected2) } } // --- 新建输入处理器测试 --- // TestNewInputProcessor 测试创建输入处理器 func TestNewInputProcessor(t *testing.T) { cache := NewFileStateCache(10) proc := NewInputProcessor("/tmp", cache) if proc == nil { t.Fatal("NewInputProcessor 不应返回 nil") } if proc.cwd != "/tmp" { t.Errorf("cwd 应为 '/tmp',实际: %q", proc.cwd) } if proc.fileCache != cache { t.Error("fileCache 未正确设置") } } // TestReadFileForReference_Nonexistent 测试读取不存在的文件 func TestReadFileForReference_Nonexistent(t *testing.T) { proc := NewInputProcessor("/tmp", nil) result := proc.readFileForReference("/nonexistent/path/file.txt") if result != "" { t.Errorf("不存在的文件应返回空字符串,实际: %q", result) } } // TestReadFileForReference_Normal 测试正常文件读取 func TestReadFileForReference_Normal(t *testing.T) { tmpDir := t.TempDir() testFile := filepath.Join(tmpDir, "normal.txt") if err := os.WriteFile(testFile, []byte("line1\nline2\nline3"), 0644); err != nil { t.Fatalf("创建测试文件失败: %v", err) } proc := NewInputProcessor(tmpDir, nil) result := proc.readFileForReference(testFile) if result != "line1\nline2\nline3" { t.Errorf("文件内容应完整读取,实际: %q", result) } } // TestProcess_ComplexInput 测试复杂的混合输入 func TestProcess_ComplexInput(t *testing.T) { tmpDir := t.TempDir() testFile := filepath.Join(tmpDir, "code.go") if err := os.WriteFile(testFile, []byte("package main"), 0644); err != nil { t.Fatalf("创建测试文件失败: %v", err) } proc := NewInputProcessor(tmpDir, nil) result, err := proc.Process("看看 @code.go 以及 https://example.com 的内容") if err != nil { t.Fatalf("处理失败: %v", err) } // 文件应被展开 if !strings.Contains(result.Text, "package main") { t.Error("文件内容应被展开") } // URL 应被检测到 if len(result.DetectedURLs) != 1 { t.Errorf("应检测到 1 个 URL,实际: %d", len(result.DetectedURLs)) } // 不应是斜杠命令 if result.SlashCommand != nil { t.Error("不应解析为斜杠命令") } }