Files
arcrun-collector/foldertree_publish_test.go

188 lines
7.1 KiB
Go
Raw Permalink 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.
// foldertree_publish_test.go — 「畫面有沒有跟上」的網(`inkstone/arcrun-rag#153` 第二輪)。
//
// 🔴 這裡測的判準跟 stallguard_test.go **刻意不一樣**,因為第一輪測錯了東西:
// 那一輪的網問的是「**有沒有發出警告**」,而總管 2026-08-28 端到端實測打回來的正是
// 「閘在跑、警告會發,**但主流程仍然沒走下去**」。
//
// stallguard_test.go 端點**不回應**時 → 這一輪要跳過它、要有話說
// foldertree_publish_test 端點**回應但很慢**時 → 畫面要的東西**不准排在後面等**
//
// 為什麼第二種才是使用者真正撞到的(`ARCRUN_TRACE=1` 實測,健康的 youlin 實例):
// 每一發雲端呼叫 24〜33 秒,**沒有一發接近 300 秒** ⇒ 逾時不會觸發、斷路器不會跳、
// 播報也大多不會響(門檻 30 秒)。一輪就這樣安靜地跑十幾分鐘到幾小時,
// 而 `folder-trees.json` 從前**只在整輪的最後**才落地。
// 使用者看到的是「小幫手開著、沒有錯誤、資料夾結構永遠停在上一版」。
package collector
import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"sync/atomic"
"testing"
"time"
)
// slowButHealthyServer**會回應、只是慢**。這是本檔的主角,不是黑洞。
func slowButHealthyServer(t *testing.T, delay time.Duration, hits *int64) *httptest.Server {
t.Helper()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
atomic.AddInt64(hits, 1)
select {
case <-time.After(delay):
case <-r.Context().Done():
return
}
_ = json.NewEncoder(w).Encode(map[string]any{"success": true})
}))
t.Cleanup(srv.Close)
return srv
}
// 🔴 本票第二輪的核心:**雲端每一發都很慢(但都會回來)時,資料夾結構仍然要在
// 這一輪還在跑的時候就已經寫好**。
//
// 這條會抓到的回歸:把樹的落地搬回「整輪最後」——那時它會等到所有雲端呼叫跑完,
// 使用者的畫面就又回到「永遠停在上一版」。
func TestFolderTreeLandsWhileTheRoundIsStillRunning(t *testing.T) {
origFetch := fetchCloudVersion
fetchCloudVersion = func(string) (string, bool) { return "", false }
defer func() { fetchCloudVersion = origFetch }()
// 每一發 400ms——遠低於任何逾時(所以斷路器不會跳,正是實測的形狀),
// 但夠多發就足以把一輪拖長。
var hits int64
slow := slowButHealthyServer(t, 400*time.Millisecond, &hits)
// 多層資料夾+多個檔:讓「雲端呼叫的總時間」明顯大於「算一棵樹的時間」。
root := t.TempDir()
for _, d := range []string{"a", "b", "c", "d", "e", "a/a1", "b/b1"} {
if err := os.MkdirAll(filepath.Join(root, d), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(root, d, "n.md"), []byte("# n\n內容"), 0o644); err != nil {
t.Fatal(err)
}
}
if err := os.WriteFile(filepath.Join(root, "top.md"), []byte("# top\n內容"), 0o644); err != nil {
t.Fatal(err)
}
manifestPath := filepath.Join(t.TempDir(), "m.json")
treePath := FolderTreeStorePath(manifestPath)
cfg := &DirectConfig{
Manifest: manifestPath,
Accounts: []AccountConfig{{CypherURL: slow.URL, Namespace: "n", APIKey: "k",
WatchFolders: []string{root}}},
Library: "kb", MaxRemoved: DefaultMaxRemovedRatio,
CardIngestWF: "rag_ingest_card", IngestWF: "rag_ingest_direct", RemovedWF: "rag_takedown_direct",
ForceSync: true,
}
roundDone := make(chan time.Duration, 1)
start := time.Now()
go func() {
RunDirectOnce(cfg, false)
roundDone <- time.Since(start)
}()
// 一邊等這一輪跑,一邊盯著快照什麼時候出現。
var treeAt time.Duration
var roundAt time.Duration
poll := time.NewTicker(20 * time.Millisecond)
defer poll.Stop()
deadline := time.After(60 * time.Second)
watch:
for {
select {
case roundAt = <-roundDone:
break watch
case <-deadline:
t.Fatalf("🔴 60 秒內這一輪還沒跑完(雲端每一發只有 400ms,這不該發生)")
case <-poll.C:
if treeAt == 0 {
if _, err := os.Stat(treePath); err == nil {
treeAt = time.Since(start)
}
}
}
}
if treeAt == 0 {
t.Fatalf("🔴 整輪跑完(%v)之前,folder-trees.json 一次都沒出現過——"+
"畫面上的資料夾結構會一直停在上一版", roundAt)
}
t.Logf("樹落地於 %v/整輪跑完於 %v/雲端被打了 %d 次", treeAt, roundAt, atomic.LoadInt64(&hits))
// 🔴 判準:樹要在**這一輪還早**的時候就落地,不是跟著整輪一起結束。
// 用比例而不是絕對秒數——CI 機器快慢不同,但「排在雲端佇列前面」這個性質不變。
if treeAt > roundAt/2 {
t.Fatalf("🔴 樹落地於 %v,而整輪 %v——它還是排在雲端呼叫後面等。\n"+
"本票第二輪的病就是這個:不是卡住,是這一輪還沒輪到寫它。", treeAt, roundAt)
}
// 內容也要對:使用者看到的節點數要等於地端真的有幾個資料夾。
store, err := LoadFolderTreeStore(treePath)
if err != nil {
t.Fatalf("讀不回快照:%v", err)
}
tree, ok := store.Trees[root]
if !ok {
t.Fatalf("快照裡沒有這個根:%+v", store.Trees)
}
// 根 a b c d e a/a1 b/b1 8
if tree.TotalNodes != 8 || len(tree.Nodes) != 8 {
t.Fatalf("🔴 節點數不對:TotalNodes=%d len(Nodes)=%dwant 8(根+7 個子資料夾)",
tree.TotalNodes, len(tree.Nodes))
}
}
// 收工那一次的合併語意不准被「提早落地」破壞:
// 已經不看守的根,仍然要在整輪結束時被清掉(不然使用者移除了資料夾還看得到它)。
func TestEarlyPublishStillLetsTheFinalMergeDropUnwatchedRoots(t *testing.T) {
origFetch := fetchCloudVersion
fetchCloudVersion = func(string) (string, bool) { return "", false }
defer func() { fetchCloudVersion = origFetch }()
var hits int64
srv := slowButHealthyServer(t, time.Millisecond, &hits)
root := t.TempDir()
if err := os.WriteFile(filepath.Join(root, "n.md"), []byte("# n\n內容"), 0o644); err != nil {
t.Fatal(err)
}
manifestPath := filepath.Join(t.TempDir(), "m.json")
treePath := FolderTreeStorePath(manifestPath)
// 先塞一棵「已經不看守」的舊樹進快照。
if err := SaveFolderTreeStore(treePath, FolderTreeStore{
UpdatedAt: "2026-08-01T00:00:00Z",
Trees: map[string]FolderTree{"/已經移除的資料夾": sampleTree("/已經移除的資料夾", 1, 1)},
}); err != nil {
t.Fatal(err)
}
cfg := &DirectConfig{
Manifest: manifestPath,
Accounts: []AccountConfig{{CypherURL: srv.URL, Namespace: "n", APIKey: "k",
WatchFolders: []string{root}}},
Library: "kb", MaxRemoved: DefaultMaxRemovedRatio,
CardIngestWF: "rag_ingest_card", IngestWF: "rag_ingest_direct", RemovedWF: "rag_takedown_direct",
ForceSync: true,
}
RunDirectOnce(cfg, false)
store, err := LoadFolderTreeStore(treePath)
if err != nil {
t.Fatal(err)
}
if _, still := store.Trees["/已經移除的資料夾"]; still {
t.Fatalf("🔴 已經不看守的根還留在快照裡——使用者移除完還看得到它:%+v", store.Trees)
}
if _, ok := store.Trees[root]; !ok {
t.Fatalf("正在看守的那棵反而不見了:%+v", store.Trees)
}
}