sync: collector/ 同步自 inkstone/arcrun-rag@c6cf717(桌面小幫手 0.18.38)

This commit is contained in:
2026-08-26 21:04:03 +08:00
parent a1786135d5
commit 4d966b7922
16 changed files with 1545 additions and 22 deletions
+298
View File
@@ -0,0 +1,298 @@
// foldertree_test.go — 資料夾樹(InkStoneCo#44leo 2026-08-17
//
// leo 的驗收情境就是這裡測的東西:
//
// 「指定一個**有子資料夾、且混著支援與不支援檔案**的資料夾 → portal 出現那棵樹,
// 每個節點兩個數字對得上實際檔案數」
// 「指定一個**空資料夾** → 它也要出現」
//
// 🔴 本檔測的是「分子分母對不對得上實際檔案」——那正是這條規格的重點:
// **兩個數字不相等是正常的,但差額必須解釋得了**。所以每個案例都驗不變式
//
// total == synced + pending + unsupported + excluded
//
// 而不是只驗「有送出去」。
package collector
import (
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
// answeredFolderTreePost 讓「假 cypher」認得資料夾樹這個端點:是它就自己回 200 並回報 true,
// 呼叫端 `return` 即可,不要把它算進卡片的統計裡。
//
// 🔴 這支存在的理由是一個實撞(2026-08-17,本分支自己踩的):本套件的假 cypher 幾乎都是
// **萬用 handler**——「收到什麼都當成一次卡片 POST」,然後 `m["page_name"].(string)`。
// #44 之後多了第三個端點(`/portal/daemon/folder-tree`),它的 body **沒有 page_name**
// ⇒ 那些 handler 拿 nil 做型別斷言而 panic。而 panic 發生在 handler 的 goroutine 裡
// ⇒ 測試不是紅,是**掛住到 8 分鐘 timeout**(比紅更難查:看起來像「測試很慢」)。
//
// ⇒ 以後再加第四個 daemon 端點時,凡是「數 POST 幾次/記 page_name」的 stub
// 都要先問過這一句,不要再讓萬用 handler 去猜。
func answeredFolderTreePost(w http.ResponseWriter, r *http.Request) bool {
if !strings.HasSuffix(r.URL.Path, "/portal/daemon/folder-tree") {
return false
}
_ = json.NewEncoder(w).Encode(map[string]any{"success": true})
return true
}
// nodeOf 從一棵樹裡撈出某個路徑的節點(找不到就讓測試自己 Fatal,不回 nil 讓後面 panic)。
func treeNodeOf(t *testing.T, tree FolderTree, path string) FolderNode {
t.Helper()
for _, n := range tree.Nodes {
if n.Path == path {
return n
}
}
t.Fatalf("樹裡沒有節點 %q,實際有:%v", path, nodePaths(tree))
return FolderNode{}
}
func nodePaths(tree FolderTree) []string {
out := make([]string, 0, len(tree.Nodes))
for _, n := range tree.Nodes {
out = append(out, n.Path)
}
return out
}
// 掃一個真實的暫存資料夾,把 Scan() 數出來的分母與 manifest 現況合成樹。
// 刻意走真的 Scan()——分母的判準活在那趟 WalkDir 裡,繞過它就等於測了另一套實作。
func buildTreeFromDisk(t *testing.T, root string) FolderTree {
t.Helper()
m := &Manifest{Root: root, Entries: map[string]*ManifestEntry{}}
plan := PlanIngest(root)
payload, err := Scan(root, m, ScanOptions{Plan: plan})
if err != nil {
t.Fatalf("Scan 失敗:%v", err)
}
return BuildFolderTree(root, "kb", payload.DirStats, m.Entries, payload.AllExcludedDirs, plan, time.Unix(1786900000, 0))
}
// ── leo 的驗收情境①:有子資料夾、混著支援與不支援的檔案 ────────────────────────
func TestFolderTree混合資料夾的兩個數字對得上(t *testing.T) {
root := t.TempDir()
mustWrite(t, filepath.Join(root, "報銷規則.md"), "# 報銷規則\n內容")
mustWrite(t, filepath.Join(root, "docs", "流程.md"), "# 流程\n內容")
mustWrite(t, filepath.Join(root, "docs", "簡報.pptx"), "binary-ish")
mustWrite(t, filepath.Join(root, "docs", "main.go"), "package main")
mustWrite(t, filepath.Join(root, "img", "logo.png"), "PNG")
tree := buildTreeFromDisk(t, root)
// 樹要長得出來(根+docs+img),而且根一定在第一個(畫面靠這個順序)
if len(tree.Nodes) < 3 {
t.Fatalf("節點太少:%v", nodePaths(tree))
}
if tree.Nodes[0].Path != "" {
t.Fatalf("根不在第一個:%v", nodePaths(tree))
}
if tree.DisplayName == "" {
t.Error("根沒有顯示名——使用者認得的是資料夾名字,不是空字串")
}
// 🔴 不變式:每一層的分母必須解釋得完(差額=不支援+不收+還在路上)
for _, n := range tree.Nodes {
if n.Skipped {
continue // 沒走進去 ⇒ 數字全 0 且不是事實,見下一支測試
}
sum := n.SyncedFiles + n.PendingFiles + n.UnsupportedFiles + n.ExcludedFiles
if n.TotalFiles != sum {
t.Errorf("節點 %q 的差額解釋不了:total=%d 但 synced+pending+unsupported+excluded=%d%+v",
n.Path, n.TotalFiles, sum, n)
}
}
// docs 這一層:Finder 裡看得到 3 個檔 ⇒ 分母就是 3(不是「我們收得下的那 1 個」)。
// 這是整條規格的核心:分母是使用者數得出來的那個數字。
docs := treeNodeOf(t, tree, "docs")
if docs.TotalFiles != 3 {
t.Errorf("docs 分母=%d,應為 3.md/.pptx/.go 使用者都看得到):%+v", docs.TotalFiles, docs)
}
// 且「沒上傳的那些」要分得出是哪一類——leo:「不上傳通常是不支援,比如程式碼、不支援的格式」
if docs.UnsupportedFiles+docs.ExcludedFiles == 0 {
t.Errorf("docs 有讀不了/不收的檔,卻一個都沒分類到:%+v", docs)
}
// 每個節點都要有名字與父親,畫面才串得成一棵樹(少一環就變一堆浮著的節點)
for _, n := range tree.Nodes {
if n.Path != "" && n.Parent == "-" {
t.Errorf("非根節點 %q 的 parent 是 '-'(只有根才准)", n.Path)
}
if n.Name == "" {
t.Errorf("節點 %q 沒有名字", n.Path)
}
}
}
// ── leo 的驗收情境②:空資料夾也要出現(arcrun-rag#106)────────────────────────
func TestFolderTree空資料夾照樣有根節點(t *testing.T) {
root := t.TempDir()
tree := buildTreeFromDisk(t, root)
if len(tree.Nodes) != 1 || tree.Nodes[0].Path != "" {
t.Fatalf("空資料夾應恰好有一個根節點:%v", nodePaths(tree))
}
if tree.Nodes[0].TotalFiles != 0 {
t.Errorf("空資料夾的分母應為 0%+v", tree.Nodes[0])
}
// leo:「不能因為地端資料夾內沒東西就當作不存在,如果那是他打算放東西的資料夾呢?」
// ⇒ 樹要送得出去(有 library、有 root),收端才登記得起這個庫。
if tree.Library == "" || tree.Root == "" {
t.Errorf("空資料夾的樹缺 library/root,收端登記不了:%+v", tree)
}
}
// 走進得去但一個檔都沒有的子資料夾,也要有自己的節點
//(leo 的規格是「不管那層有沒有文件,整棵樹都要採」)。
func TestFolderTree空的子資料夾也有節點(t *testing.T) {
root := t.TempDir()
mustWrite(t, filepath.Join(root, "a.md"), "# A")
if err := os.MkdirAll(filepath.Join(root, "打算放東西的地方"), 0o755); err != nil {
t.Fatal(err)
}
tree := buildTreeFromDisk(t, root)
n := treeNodeOf(t, tree, "打算放東西的地方")
if n.TotalFiles != 0 || n.Skipped {
t.Errorf("空的子資料夾應該是「有節點、數字 0、沒被剪掉」:%+v", n)
}
}
// ── 冪等與雜湊 ───────────────────────────────────────────────────────────────
func TestFolderTreeHash不含時間(t *testing.T) {
root := t.TempDir()
mustWrite(t, filepath.Join(root, "a.md"), "# A")
m := &Manifest{Root: root, Entries: map[string]*ManifestEntry{}}
plan := PlanIngest(root)
payload, err := Scan(root, m, ScanOptions{Plan: plan})
if err != nil {
t.Fatal(err)
}
t1 := BuildFolderTree(root, "kb", payload.DirStats, m.Entries, payload.AllExcludedDirs, plan, time.Unix(1000, 0))
t2 := BuildFolderTree(root, "kb", payload.DirStats, m.Entries, payload.AllExcludedDirs, plan, time.Unix(9999, 0))
if t1.Hash() != t2.Hash() {
t.Error("同一份內容在不同時間算出不同雜湊 ⇒ 冪等閘失效,會每輪重送")
}
if t1.Hash() == "" {
t.Error("雜湊算不出來")
}
}
// ── 上傳行為:整棵一次送、成功後節流、失敗退避 ──────────────────────────────
func TestSyncFolderTree整棵一次送(t *testing.T) {
var bodies []map[string]any
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !strings.HasSuffix(r.URL.Path, "/portal/daemon/folder-tree") {
t.Errorf("打錯端點:%s", r.URL.Path)
}
if r.Header.Get("X-Arcrun-API-Key") == "" {
t.Error("沒帶 X-Arcrun-API-Key(收端會 401")
}
raw, _ := io.ReadAll(r.Body)
var m map[string]any
_ = json.Unmarshal(raw, &m)
bodies = append(bodies, m)
_ = json.NewEncoder(w).Encode(map[string]any{"success": true})
}))
defer srv.Close()
root := t.TempDir()
for i := 0; i < 50; i++ { // 遠超過舊版 20 個節點一批的分批門檻
mustWrite(t, filepath.Join(root, "d"+string(rune('a'+i%26))+string(rune('a'+i/26)), "x.md"), "# X")
}
tree := buildTreeFromDisk(t, root)
if len(tree.Nodes) <= 20 {
t.Fatalf("前置沒建出足夠節點:%d", len(tree.Nodes))
}
cfg := &DirectConfig{CypherURL: srv.URL, APIKey: "demo"}
m := &Manifest{Root: root, Entries: map[string]*ManifestEntry{}}
now := time.Unix(1786900000, 0)
res := syncFolderTree(cfg, root, m, tree, false, now)
if res == nil || res.Status != "ingested" {
t.Fatalf("應送達:%+v", res)
}
// 🔴 一次請求送完整棵樹:分批只在「收端每節點一次寫入」的設計下才有意義,
// 而收端改成整棵覆寫後,分批只會留下半棵樹的中間狀態。
if len(bodies) != 1 {
t.Fatalf("應恰好一次請求,實際 %d 次", len(bodies))
}
sent, _ := bodies[0]["nodes"].([]any)
if len(sent) != len(tree.Nodes) {
t.Errorf("送出的節點數 %d ≠ 樹的節點數 %d(有被切掉)", len(sent), len(tree.Nodes))
}
if bodies[0]["sync_token"] == "" || bodies[0]["library"] != "kb" {
t.Errorf("body 缺 sync_token/library%+v", bodies[0])
}
// 內容沒變 → 不重送
if again := syncFolderTree(cfg, root, m, tree, false, now.Add(time.Hour)); again != nil {
t.Errorf("內容沒變不該重送:%+v", again)
}
// 內容變了、但還在最小間隔內 → 也不送(沒有這道閘,初次同步會每 5 秒送一次)
changed := tree
changed.Nodes = append([]FolderNode{{Path: "新", Name: "新", Parent: "", Depth: 1}}, tree.Nodes...)
if soon := syncFolderTree(cfg, root, m, changed, false, now.Add(30*time.Second)); soon != nil {
t.Errorf("最小間隔內不該送:%+v", soon)
}
// 過了最小間隔 → 送
if later := syncFolderTree(cfg, root, m, changed, false, now.Add(folderTreeMinInterval+time.Second)); later == nil {
t.Error("過了最小間隔應該要送")
}
if len(bodies) != 2 {
t.Errorf("應總共兩次請求,實際 %d", len(bodies))
}
}
func TestSyncFolderTree首次不受最小間隔限制(t *testing.T) {
var hits int
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
hits++
_ = json.NewEncoder(w).Encode(map[string]any{"success": true})
}))
defer srv.Close()
root := t.TempDir()
tree := buildTreeFromDisk(t, root) // 空資料夾
m := &Manifest{Root: root, Entries: map[string]*ManifestEntry{}}
// FolderTreeNextSend 是零值(從沒送過)⇒ 就算「現在」小於它也不能被擋
// ——指定資料夾的當下就要看得見它,那正是 #106 的重點。
if res := syncFolderTree(&DirectConfig{CypherURL: srv.URL, APIKey: "demo"}, root, m, tree, false, time.Unix(1, 0)); res == nil || res.Status != "ingested" {
t.Fatalf("首次應直接送出:%+v", res)
}
if hits != 1 {
t.Errorf("應打一次,實際 %d", hits)
}
}
func TestSyncFolderTree失敗退避(t *testing.T) {
root := t.TempDir()
tree := buildTreeFromDisk(t, root)
cfg := &DirectConfig{CypherURL: "https://x.invalid", APIKey: "demo"}
m := &Manifest{Root: root, Entries: map[string]*ManifestEntry{}}
now := time.Unix(1786900000, 0)
res := syncFolderTree(cfg, root, m, tree, false, now)
if res == nil || res.Status != "failed" {
t.Fatalf("應失敗:%+v", res)
}
if m.FolderTreeFailHash == "" || m.FolderTreeNextRetry == 0 {
t.Errorf("失敗沒記退避 ⇒ 雲端一壞就每 5 秒重撞一次:%+v", m)
}
// 退避窗口內同一份內容不重撞
if again := syncFolderTree(cfg, root, m, tree, false, now.Add(time.Minute)); again != nil {
t.Errorf("退避窗口內不該重試:%+v", again)
}
}