package search import ( "path/filepath" "strings" "testing" "github.com/aisim/kb-cli/internal/graph" "github.com/aisim/kb-cli/internal/index" ) func TestSearch(t *testing.T) { // 创建临时数据库 tmpDir := t.TempDir() dbPath := filepath.Join(tmpDir, "test.db") store, err := index.Open(dbPath) if err != nil { t.Fatalf("Open failed: %v", err) } defer store.Close() // 插入测试数据 nodes := []*graph.Node{ { ID: 1, Path: "FAQ/充装类/001-test.md", Title: "充装问题排查", Section: "FAQ", Tags: []string{"充装"}, Content: "关于智能枪充装问题的排查方法", }, { ID: 2, Path: "知识/002-config.md", Title: "充装规格配置", Section: "知识", Tags: []string{"配置"}, Content: "充装规格配置说明", }, } for _, n := range nodes { _, err := store.InsertNode(n) if err != nil { t.Fatalf("InsertNode failed: %v", err) } } // 创建 FTS 索引 if err := store.CreateFTS(); err != nil { t.Fatalf("CreateFTS failed: %v", err) } if err := store.PopulateFTS(); err != nil { t.Fatalf("PopulateFTS failed: %v", err) } // 测试搜索 results, err := Search(store, []string{"充装"}, SearchOptions{}) if err != nil { t.Fatalf("Search failed: %v", err) } if len(results) == 0 { t.Error("Expected results, got none") } // 验证结果包含关键词 found := false for _, r := range results { if strings.Contains(r.Title, "充装") || strings.Contains(r.Path, "充装") { found = true break } } if !found { t.Error("Expected results to contain '充装'") } } func TestCJKLikeChannel(t *testing.T) { // CJK LIKE 通道:多字符中文词 FTS MATCH 匹配不到(unicode61 整串 token), // 必须走 LIKE 才能命中 content/title tmpDir := t.TempDir() dbPath := filepath.Join(tmpDir, "test.db") store, err := index.Open(dbPath) if err != nil { t.Fatalf("Open failed: %v", err) } defer store.Close() nodes := []*graph.Node{ { // content 含"补气",title 不含(靠 LIKE content 通道命中) Path: "FAQ/001-a.md", Title: "设备故障排查", Section: "FAQ", Content: "电子秤补气失败时的排查步骤", }, { // title 含"补气"(title LIKE 命中,应排前) Path: "FAQ/002-b.md", Title: "电子秤补气失败", Section: "FAQ", Content: "补气失败的处理方法", }, { // 不含"补气"(不应出现在结果中) Path: "FAQ/003-c.md", Title: "阀门漏气处理", Section: "FAQ", Content: "阀门漏气的原因和处理", }, } for _, n := range nodes { if err := store.UpsertNode(n, 100, 1, "x"); err != nil { t.Fatalf("UpsertNode failed: %v", err) } } if err := store.CreateFTS(); err != nil { t.Fatalf("CreateFTS failed: %v", err) } if err := store.PopulateFTS(); err != nil { t.Fatalf("PopulateFTS failed: %v", err) } results, err := Search(store, []string{"补气"}, SearchOptions{}) if err != nil { t.Fatalf("Search failed: %v", err) } if len(results) < 2 { t.Fatalf("Expected 2 results (content 命中 + title 命中), got %d", len(results)) } // 两个含"补气"的节点都应在结果中 if results[0].Path != "FAQ/002-b.md" && results[1].Path != "FAQ/002-b.md" { t.Error("title 含'补气'的节点应出现在结果中") } if results[0].Path != "FAQ/001-a.md" && results[1].Path != "FAQ/001-a.md" { t.Error("content 含'补气'的节点应出现在结果中") } // 不含"补气"的节点不应出现 for _, r := range results { if r.Path == "FAQ/003-c.md" { t.Error("不含关键词的节点不应出现在结果中") } } // title 命中者应排前(title 计权高于 content) if results[0].Path != "FAQ/002-b.md" { t.Errorf("title 命中者应排第一, got %q", results[0].Path) } } // TestSearchLongCJKBigram 回归(审查遗留):单复合 CJK 词「电子秤补气失败」 // 在内容中不连续出现时,整串 LIKE 匹配不到,search 必须对长 CJK 词做 bigram // 展开后检索才能命中(RED 证据:未下沉前该查询 0 结果)。 func TestSearchLongCJKBigram(t *testing.T) { tmpDir := t.TempDir() dbPath := filepath.Join(tmpDir, "test.db") store, err := index.Open(dbPath) if err != nil { t.Fatalf("Open failed: %v", err) } defer store.Close() // content 含「电子秤补气」「补气失败」片段,但不含完整的连续「电子秤补气失败」 n := &graph.Node{ ID: 1, Path: "FAQ/称重/001-test.md", Title: "称重故障排查", Section: "FAQ", Content: "电子秤补气 时先检查阀门,补气失败后断电重启。", } if err := store.UpsertNode(n, 100, 1, "x"); err != nil { t.Fatalf("UpsertNode failed: %v", err) } if err := store.CreateFTS(); err != nil { t.Fatalf("CreateFTS failed: %v", err) } if err := store.PopulateFTS(); err != nil { t.Fatalf("PopulateFTS failed: %v", err) } results, err := Search(store, []string{"电子秤补气失败"}, SearchOptions{}) if err != nil { t.Fatalf("Search failed: %v", err) } if len(results) != 1 { t.Fatalf("expected 1 result (bigram 展开命中), got %d", len(results)) } if results[0].Path != "FAQ/称重/001-test.md" { t.Errorf("expected hit FAQ/称重/001-test.md, got %q", results[0].Path) } } func TestIsGenericWord(t *testing.T) { tests := []struct { word string expected bool }{ {"问题", true}, {"故障", true}, {"充装", false}, {"智能枪", false}, } for _, tt := range tests { result := IsGenericWord(tt.word) if result != tt.expected { t.Errorf("IsGenericWord(%q) = %v, want %v", tt.word, result, tt.expected) } } }