Files
arcrun-collector/live_status_test.go
T

221 lines
7.6 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 應為 %qgot %q", root, midStatus.InRound.Folder)
}
if midStatus.InRound.Step != stepIngestCard.Name {
t.Errorf("正在送第二份時 in_round.step 應為 %qgot %q", stepIngestCard.Name, midStatus.InRound.Step)
}
if got := midStatus.FolderProgress[root].Done; got != 1 {
t.Errorf("🔴 一輪途中 folder_progress 的已同步應為 1got %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("收工之後已同步應為 3got %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 守住驗收條件③「卡住時寫得出原因」:
// 「還在等」以前只印到 stdoutcollector.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 應為 %qgot %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)
}
}