// inventory_test.go — 結構先行(InkStoneCo#43):資料夾總覽卡的單元與整合測試。 package collector import ( "encoding/json" "fmt" "io" "net/http" "net/http/httptest" "os" "path/filepath" "strings" "testing" "time" ) // splitInventory 把 results 拆成(總覽卡, 其餘)。既有測試斷言「幾個檔案事件」時 // 用 rest;要驗結構先行本身時用 inv。 // splitInventory 把「每輪都會做的結構回報」從「檔案事件」裡分出來。 // // 有兩種結構回報,都不是檔案事件、都與 LLM 無關、都在額度閘之外: // - `inventory`=資料夾總覽**知識卡**(給檢索用的 markdown,走 rag_ingest_card) // - `folder_tree`=portal 畫面用的**結構化樹**(InkStoneCo#44,走 /portal/daemon/folder-tree) // // 🔴 2026-08-17:`folder_tree` 這一類是後加的,而本函式當時沒跟著加 ⇒ 五支既有測試 // 突然把「樹的回報」當成一個檔案事件,紅了。**這正是「新增一種結果型別」時最容易漏的一環**: // 分類器只認得舊的那一種,新的就靜靜地落進 rest。以後再加第三種,記得改這裡。 func splitInventory(results []DirectResult) (inv, rest []DirectResult) { for _, r := range results { switch r.Type { case "inventory": inv = append(inv, r) case "folder_tree": // 樹不算總覽卡(回傳值只有兩個,刻意不擴成三個——呼叫端在意的是 // 「哪些是檔案事件」,樹跟總覽卡一樣都屬於「不是檔案事件」那一堆)。 // 要專門驗樹的測試自己從 results 撈 Type=="folder_tree"。 default: rest = append(rest, r) } } return inv, rest } // folderTreeResults 撈出本輪的資料夾樹回報(專門驗樹的測試用)。 func folderTreeResults(results []DirectResult) []DirectResult { var out []DirectResult for _, r := range results { if r.Type == "folder_tree" { out = append(out, r) } } return out } func invEntries(paths map[string]int64) map[string]*ManifestEntry { m := map[string]*ManifestEntry{} for p, mt := range paths { m[p] = &ManifestEntry{ContentHash: "sha256:x", Size: 1, Mtime: mt} } return m } // 基本結構:四段齊全、逐檔點名、格式統計、目錄 part_of 三元組。 func TestBuildInventoryCard_基本結構(t *testing.T) { day := time.Date(2026, 8, 10, 12, 0, 0, 0, time.Local) entries := invEntries(map[string]int64{ "報銷規則.md": day.Unix(), "docs/流程.md": day.Add(24 * time.Hour).Unix(), "docs/簡報.pptx": day.Add(48 * time.Hour).Unix(), }) page, card := BuildInventoryCard("/Users/x/我的筆記", entries, "kb") if page != "資料夾總覽:我的筆記" { t.Fatalf("page=%q", page) } for _, want := range []string{ "# 資料夾總覽:我的筆記", "## 一句話定義", "這個資料夾目前有 3 份文件", "md 2 份", "pptx 1 份", "## 最近改了什麼", "- 2026-08-12 docs/簡報.pptx", // 最新在最上面 "## 檔案清單", "- docs/流程.md(2026-08-11)", "- 報銷規則.md(2026-08-10)", "## 關聯", "- docs " + strings.Repeat(">", 2) + " part_of " + strings.Repeat(">", 2) + " 資料夾總覽:我的筆記", } { if !strings.Contains(card, want) { t.Errorf("卡片缺 %q\n----\n%s", want, card) } } // 最近改動的排序:簡報(08-12)要出現在 流程(08-11)之前 if strings.Index(card, "docs/簡報.pptx") > strings.Index(card, "docs/流程.md") { t.Error("「最近改了什麼」未按 mtime 新到舊排序") } } // 確定性:同一份輸入永遠同一份輸出(冪等雜湊的前提)。 func TestBuildInventoryCard_確定性(t *testing.T) { entries := invEntries(map[string]int64{"a.md": 100, "b/c.md": 200, "b/d.pdf": 200}) _, c1 := BuildInventoryCard("/x/root", entries, "kb") for i := 0; i < 10; i++ { _, c2 := BuildInventoryCard("/x/root", entries, "kb") if c1 != c2 { t.Fatal("同一份輸入產出不同卡片(map 迭代順序洩漏)") } } } // 巨量資料夾:不逐檔點名(改列各目錄份數),關聯不超上限——守雲端 subrequest 天花板。 func TestBuildInventoryCard_巨量只列摘要(t *testing.T) { entries := map[string]*ManifestEntry{} for d := 0; d < 40; d++ { for f := 0; f < 10; f++ { p := fmt.Sprintf("dir%02d/f%02d.md", d, f) entries[p] = &ManifestEntry{Mtime: int64(1000 + d + f)} } } _, card := BuildInventoryCard("/x/big", entries, "kb") if !strings.Contains(card, "共 400 份,檔案較多") { t.Error("巨量模式應改列摘要") } if strings.Count(card, "- dir")-strings.Count(card, "- dir00 ") > maxInventoryDirLines+maxInventoryRels { t.Error("目錄行數超出上限") } sep := " " + strings.Repeat(">", 2) + " " if n := strings.Count(card, sep+"part_of"+sep); n > maxInventoryRels { t.Errorf("關聯 %d 條超過上限 %d(會撞雲端 subrequest 上限)", n, maxInventoryRels) } if !strings.Contains(card, "其餘") { t.Error("被截斷的目錄應誠實說有其餘幾個(不安靜略過)") } } // 整合:總覽卡必須先於任何萃取/收卡 POST 送達(結構不跟 LLM 排同一條隊), // 且第二輪(無變動)不重送=冪等。 func TestDirectInventory_先於萃取且冪等(t *testing.T) { root := t.TempDir() if err := os.WriteFile(filepath.Join(root, "規則.md"), []byte("# 內容"), 0o644); err != nil { t.Fatal(err) } var order []string // 按抵達順序記 page_name srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // InkStoneCo#44:資料夾樹不是卡片(沒有 page_name),不能算進「送了幾張卡」。 if answeredFolderTreePost(w, r) { return } body, _ := io.ReadAll(r.Body) var m map[string]any _ = json.Unmarshal(body, &m) pn, _ := m["page_name"].(string) order = append(order, pn) _ = json.NewEncoder(w).Encode(map[string]any{"success": true}) })) defer srv.Close() defer gemmaCardStub(t, cardFixture("規則", "kb"))() cfg := &DirectConfig{ WatchFolders: []string{root}, Manifest: filepath.Join(t.TempDir(), "m.json"), CypherURL: srv.URL, Namespace: "demo", APIKey: "demo", Library: "kb", Extractor: "gemma", ExtractorExplicit: true, GeminiAPIKey: "k-test", CardIngestWF: "rag_ingest_card", MaxRemoved: DefaultMaxRemovedRatio, } results, exit, _ := RunDirectOnce(cfg, false) if exit != 0 { t.Fatalf("exit=%d results=%+v", exit, results) } if len(order) != 2 { t.Fatalf("應恰好兩個 POST(總覽卡+內容卡),got %v", order) } if !strings.HasPrefix(order[0], "資料夾總覽:") { t.Fatalf("總覽卡必須先送(結構先行),實際順序:%v", order) } if order[1] != "規則" { t.Fatalf("內容卡未送達:%v", order) } inv, _ := splitInventory(results) if len(inv) != 1 || inv[0].Status != "ingested" { t.Fatalf("inv=%+v", inv) } // 第二輪:什麼都沒變 → 總覽不重送、也沒有檔案事件 results2, _, _ := RunDirectOnce(cfg, false) if len(results2) != 0 || len(order) != 2 { t.Fatalf("無變動輪不該重送:results2=%+v order=%v", results2, order) } // 新增一個檔 → 總覽跟著更新(清單多一筆)+新檔照常萃取。 // (附帶驗證:同一天內改同一個檔,總覽內容在日粒度下不變=不重送,是冪等的正確行為) if err := os.WriteFile(filepath.Join(root, "新頁.md"), []byte("# 新內容"), 0o644); err != nil { t.Fatal(err) } if _, exit3, _ := RunDirectOnce(cfg, false); exit3 != 0 { t.Fatal("第三輪失敗") } if len(order) != 4 || !strings.HasPrefix(order[2], "資料夾總覽:") { t.Fatalf("變動後總覽應更新且仍先送:%v", order) } } // 整合:萃取整條路壞掉(連 Gemini 都連不上)時,總覽卡照樣送達—— // 這正是「不必等 LLM、不必等額度」要保的那條路。 func TestDirectInventory_萃取壞掉照樣送達(t *testing.T) { root := t.TempDir() if err := os.WriteFile(filepath.Join(root, "a.md"), []byte("x"), 0o644); err != nil { t.Fatal(err) } var invPosted int srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { body, _ := io.ReadAll(r.Body) if strings.Contains(string(body), "資料夾總覽") { invPosted++ } _ = json.NewEncoder(w).Encode(map[string]any{"success": true}) })) defer srv.Close() // Gemini 替身回 500=萃取全滅 defer gemmaStub(t, func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusInternalServerError) _, _ = w.Write([]byte(`{"error":"boom"}`)) })() cfg := &DirectConfig{ WatchFolders: []string{root}, Manifest: filepath.Join(t.TempDir(), "m.json"), CypherURL: srv.URL, Namespace: "demo", APIKey: "demo", Extractor: "gemma", ExtractorExplicit: true, GeminiAPIKey: "k-test", MaxRemoved: DefaultMaxRemovedRatio, } results, exit, _ := RunDirectOnce(cfg, false) if exit != 1 { t.Fatalf("萃取失敗 exit 應為 1,got %d", exit) } if invPosted != 1 { t.Fatalf("萃取壞掉時總覽卡應照樣送達,got %d", invPosted) } inv, rest := splitInventory(results) if len(inv) != 1 || inv[0].Status != "ingested" { t.Fatalf("inv=%+v", inv) } if len(rest) != 1 || rest[0].Status != "failed" { t.Fatalf("rest=%+v", rest) } } // 失敗退避:總覽送失敗後,同一份內容在退避窗口內不重撞(積壓輪每 5 秒都有事件, // 沒有這道就是 t195 的 1387 輪重演);內容變了立即可再試。 func TestSyncInventory_失敗退避(t *testing.T) { root := t.TempDir() m := &Manifest{Entries: invEntries(map[string]int64{"a.md": 100})} cfg := &DirectConfig{CypherURL: "https://x.invalid", Namespace: "demo", APIKey: "demo", Library: "kb"} now := time.Date(2026, 8, 15, 10, 0, 0, 0, time.UTC) res := syncInventory(cfg, root, m, true, false, now) if res == nil || res.Status != "failed" { t.Fatalf("res=%+v", res) } if m.InventoryFailHash == "" || m.InventoryNextRetry == 0 { t.Fatal("失敗應記退避") } // 同內容、退避窗口內 → 不重試 if res2 := syncInventory(cfg, root, m, true, false, now.Add(time.Minute)); res2 != nil { t.Fatalf("退避窗口內不該重試:%+v", res2) } // 退避窗口過了 → 重試 if res3 := syncInventory(cfg, root, m, true, false, now.Add(inventoryRetryDelay+time.Minute)); res3 == nil { t.Fatal("退避過期後應重試") } // 內容變了 → 立即可再試 m.Entries["b.md"] = &ManifestEntry{Mtime: 200} if res4 := syncInventory(cfg, root, m, true, false, now.Add(time.Minute)); res4 == nil { t.Fatal("內容變了應立即重試(新卡不受舊卡退避拖累)") } }