221 lines
7.6 KiB
Go
221 lines
7.6 KiB
Go
package collector
|
||
|
||
import (
|
||
"encoding/json"
|
||
"io"
|
||
"net/http"
|
||
"net/http/httptest"
|
||
"os"
|
||
"path/filepath"
|
||
"regexp"
|
||
"strings"
|
||
"sync"
|
||
"testing"
|
||
"time"
|
||
)
|
||
|
||
// TestStatusAndTreeMoveWhileRoundIsStillRunning 守住 `inkstone/arcrun-rag#200` 的主病:
|
||
// **一輪還沒跑完,畫面看的兩份檔就要反映已經送上去的份數。**
|
||
//
|
||
// 2026-09-13 實撞:ISEP 送上雲端 11 份,status.json 從 12:00 起沒寫過、
|
||
// folder-trees.json 的 synced 全是 0 ⇒ 畫面每層 0 / N。
|
||
//
|
||
// 驗法:假雲端在收到**第二份**筆記時(第一份已經送完、這一輪還在跑),
|
||
// 直接去讀磁碟上的 status.json 與 folder-trees.json——那就是小幫手此刻讀得到的東西。
|
||
func TestStatusAndTreeMoveWhileRoundIsStillRunning(t *testing.T) {
|
||
root := t.TempDir()
|
||
for _, n := range []string{"a", "b", "c"} {
|
||
if err := os.WriteFile(filepath.Join(root, n+".md"), []byte("# 原稿 "+n+" 內容"), 0o644); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
}
|
||
|
||
nameRe := regexp.MustCompile(`檔名:([^)]+))`)
|
||
restoreGemma := gemmaStub(t, func(w http.ResponseWriter, r *http.Request) {
|
||
body, _ := io.ReadAll(r.Body)
|
||
name := "a"
|
||
if m := nameRe.FindStringSubmatch(string(body)); m != nil {
|
||
name = m[1]
|
||
}
|
||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||
"candidates": []map[string]any{{
|
||
"content": map[string]any{"parts": []map[string]any{{"text": cardFixture(name, "測試")}}},
|
||
}},
|
||
})
|
||
})
|
||
defer restoreGemma()
|
||
|
||
manifestPath := filepath.Join(t.TempDir(), "m.json")
|
||
statusPath := StatusFilePath(manifestPath)
|
||
treePath := FolderTreeStorePath(manifestPath)
|
||
|
||
var mu sync.Mutex
|
||
cards := 0
|
||
var midStatus SyncStatus
|
||
var midStatusErr error
|
||
var midTree FolderTreeStore
|
||
// 只數**這三份檔自己的**筆記:資料夾總覽卡、目錄索引卡也走同一條收卡路,
|
||
// 而且排在逐檔之前送——數到它們就會在第一份檔都還沒送時拍照。
|
||
docCard := regexp.MustCompile(`"path":"[abc]\.md"`)
|
||
cypher := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
body, _ := io.ReadAll(r.Body)
|
||
if strings.Contains(string(body), `"card_content"`) && docCard.Match(body) {
|
||
mu.Lock()
|
||
cards++
|
||
if cards == 2 {
|
||
midStatus, midStatusErr = LoadSyncStatus(statusPath)
|
||
midTree, _ = LoadFolderTreeStore(treePath)
|
||
}
|
||
mu.Unlock()
|
||
}
|
||
_ = json.NewEncoder(w).Encode(map[string]any{"success": true})
|
||
}))
|
||
defer cypher.Close()
|
||
|
||
cfg := &DirectConfig{
|
||
WatchFolders: []string{root},
|
||
Manifest: manifestPath,
|
||
CypherURL: cypher.URL, Namespace: "demo", APIKey: "demo",
|
||
Library: "kb", Extractor: "gemma", ExtractorExplicit: true, GeminiAPIKey: "k-test",
|
||
CardIngestWF: "rag_ingest_card", MaxRemoved: DefaultMaxRemovedRatio,
|
||
}
|
||
results, _, _ := RunDirectOnce(cfg, false)
|
||
|
||
mu.Lock()
|
||
defer mu.Unlock()
|
||
if cards < 3 {
|
||
t.Fatalf("三份都應該送上去,實際收到 %d 張卡:results=%+v", cards, results)
|
||
}
|
||
|
||
// ── 一輪途中(第一份已送完)──
|
||
if midStatusErr != nil {
|
||
t.Fatalf("🔴 一輪途中讀不到 status.json:%v", midStatusErr)
|
||
}
|
||
if midStatus.InRound == nil {
|
||
t.Fatalf("🔴 一輪途中 status.json 沒有 in_round——畫面講不出做到哪")
|
||
}
|
||
if got := midStatus.InRound.Ingested; got != 1 {
|
||
t.Errorf("一輪途中 in_round.ingested 應為 1(第一份已送完),got %d", got)
|
||
}
|
||
if midStatus.InRound.Folder != root {
|
||
t.Errorf("in_round.folder 應為 %q,got %q", root, midStatus.InRound.Folder)
|
||
}
|
||
if midStatus.InRound.Step != stepIngestCard.Name {
|
||
t.Errorf("正在送第二份時 in_round.step 應為 %q,got %q", stepIngestCard.Name, midStatus.InRound.Step)
|
||
}
|
||
if got := midStatus.FolderProgress[root].Done; got != 1 {
|
||
t.Errorf("🔴 一輪途中 folder_progress 的已同步應為 1,got %d(%+v)", got, midStatus.FolderProgress[root])
|
||
}
|
||
if got := midStatus.Progress.Done; got != 1 {
|
||
t.Errorf("一輪途中首頁總量的已送上去應為 1,got %d", got)
|
||
}
|
||
if got := treeSynced(midTree, root); got != 1 {
|
||
t.Errorf("🔴 一輪途中 folder-trees.json 的已同步應為 1(這就是 leo 看到 0 / N 的那個數字),got %d", got)
|
||
}
|
||
|
||
// ── 收工之後 ──
|
||
st, err := LoadSyncStatus(statusPath)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if st.InRound != nil {
|
||
t.Errorf("收工之後 in_round 應該消失,got %+v", st.InRound)
|
||
}
|
||
if got := st.FolderProgress[root].Done; got != 3 {
|
||
t.Errorf("收工之後已同步應為 3,got %d", got)
|
||
}
|
||
final, err := LoadFolderTreeStore(treePath)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
// 🔴 以前收工合併用的是開工前那棵樹 ⇒ 就算整輪跑完,這裡也還是 0。
|
||
if got := treeSynced(final, root); got != 3 {
|
||
t.Errorf("🔴 收工之後 folder-trees.json 的已同步應為 3,got %d(收工合併還在用開工前那棵樹)", got)
|
||
}
|
||
}
|
||
|
||
func treeSynced(s FolderTreeStore, root string) int {
|
||
t, ok := s.Trees[root]
|
||
if !ok {
|
||
return -1
|
||
}
|
||
n := 0
|
||
for _, node := range t.Nodes {
|
||
n += node.SyncedFiles
|
||
}
|
||
return n
|
||
}
|
||
|
||
// TestWaitingIsWrittenWhileStillWaiting 守住驗收條件③「卡住時寫得出原因」:
|
||
// 「還在等」以前只印到 stdout(collector.log),status.json 一個字都沒有。
|
||
func TestWaitingIsWrittenWhileStillWaiting(t *testing.T) {
|
||
origNotice := stallNoticeEvery
|
||
stallNoticeEvery = 30 * time.Millisecond
|
||
defer func() { stallNoticeEvery = origNotice }()
|
||
|
||
path := filepath.Join(t.TempDir(), "status.json")
|
||
g := newRoundGuard()
|
||
g.announce = nil
|
||
g.beginRound(path, time.Now())
|
||
g.enterFolder("kb.example", "/Users/someone/ISEP")
|
||
|
||
cfg := &DirectConfig{CypherURL: "https://kb.example", guard: g}
|
||
gate := cfg.openGate(stepFolderCard)
|
||
|
||
var st SyncStatus
|
||
deadline := time.Now().Add(3 * time.Second)
|
||
for time.Now().Before(deadline) {
|
||
st, _ = LoadSyncStatus(path)
|
||
if st.InRound != nil && st.InRound.Waiting != nil {
|
||
break
|
||
}
|
||
time.Sleep(10 * time.Millisecond)
|
||
}
|
||
if st.InRound == nil || st.InRound.Waiting == nil {
|
||
gate.release()
|
||
t.Fatalf("🔴 等了一陣子,status.json 仍沒有寫出「卡在哪一步」:%+v", st.InRound)
|
||
}
|
||
if st.InRound.Waiting.Step != stepFolderCard.Name {
|
||
t.Errorf("waiting.step 應為 %q,got %q", stepFolderCard.Name, st.InRound.Waiting.Step)
|
||
}
|
||
if st.InRound.Folder != "/Users/someone/ISEP" || st.InRound.Account != "kb.example" {
|
||
t.Errorf("卡住時要講得出是哪個知識庫的哪個資料夾,got account=%q folder=%q",
|
||
st.InRound.Account, st.InRound.Folder)
|
||
}
|
||
|
||
gate.release()
|
||
st, _ = LoadSyncStatus(path)
|
||
if st.InRound == nil || st.InRound.Waiting != nil {
|
||
t.Errorf("那件事回來之後「還在等」要拿掉,got %+v", st.InRound)
|
||
}
|
||
|
||
// 收工封口:晚到的「還在等」不准把收工寫好的那份蓋回「同步中」。
|
||
g.finishRound()
|
||
if err := SaveSyncStatus(path, SyncStatus{LastSync: "2026-09-14T00:00:00+08:00"}); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
g.noteWaiting(StalledCall{Step: "晚到的", Note: "晚到的"})
|
||
st, _ = LoadSyncStatus(path)
|
||
if st.InRound != nil {
|
||
t.Errorf("🔴 收工之後的途中寫入把 in_round 寫回去了:%+v", st.InRound)
|
||
}
|
||
}
|
||
|
||
// TestLiveWriteNeverWipesUnreadableStatus:狀態檔讀得到卻解不開時,途中寫入一律不動它
|
||
// ——拿零值改一格再寫回去,等於把上一輪所有的數字整份抹掉。
|
||
func TestLiveWriteNeverWipesUnreadableStatus(t *testing.T) {
|
||
path := filepath.Join(t.TempDir(), "status.json")
|
||
garbage := []byte("{ 這不是 JSON")
|
||
if err := os.WriteFile(path, garbage, 0o644); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
g := newRoundGuard()
|
||
g.announce = nil
|
||
g.beginRound(path, time.Now())
|
||
g.fileFinished("/x", "ingested", SyncProgress{Total: 1, Done: 1})
|
||
got, _ := os.ReadFile(path)
|
||
if string(got) != string(garbage) {
|
||
t.Errorf("解不開的狀態檔被覆寫了:%q", got)
|
||
}
|
||
}
|