Files
arcrun-collector/progress_wiring_test.go
T
Leo 12c2d41886 feat(collector): daemon 產出改成規範形 wiki 卡——1 文件卡+N 原子卡落 .wiki/(InkStoneCo#44 ④)
LLM 只回 JSON 判斷,格式/落點/連結閉合/索引/manifest 全由 wikishape.go 機械組裝:
- 卡形=frontmatter(tags/gloss/created/updated)+← 上層+摘要/重點/實體(帶類型)/
  關聯(內文知識關係/卡片關係/出處)——差距表 #6
- 落點 <節點>/.wiki/、檔名=H1(.wiki 隱藏目錄自身即機器標記,machinemark 例外③)——#7
- 00-INDEX 機械維護:每行五樣照抄 frontmatter;每份原稿必列——#8
- 萃取端就切 1 hub+N 原子卡(gemma 路 prompt 改 JSON 契約)——#9
- 無可萃概念標「空」+理由,上索引不產卡——#10
機械保證對著 wiki-lint.py 寫:斷連結拆殼、index 式句改寫、三元組恰三項、
雙向邊自動補反向、佔用不覆蓋、原稿永不動;同名概念跨文件先消歧(merge 歸第⑤環)。
scan/convert 白名單補 .feature/.yaml/.yml/.org/.rst(規範洞 6);
lint.go 新舊格式雙軌(workers-ai 雲端 prompt 屬 matrix/arcrun 核心,待開票);
direct.go 只送文件卡上雲(takedown 配對鍵不變)、下架收走 .wiki 產物。

實測:真資料夾+真 Gemini → 2 節點 12 卡,wiki-lint 17/17 exit 0,
原稿 sha256 前後不變;go test collector+supervisor+arcrun-app 全綠(新增 9 案)。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 21:03:45 +08:00

165 lines
7.3 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.
// progress_wiring_test.go — t210:把 progress.go 的地基(Progress()ClassifyFailure
// BuildFailureBreakdown,已在 progress_test.go 單獨驗過)跟 RunDirectOnce 的接線也測一遍。
//
// 驗的是 leo 08-08 驗法①③:
//
// ① 四個數字(Done/Pending/Stuck/Unreadable)相加要等於 Total——這正是 Evan 封測
// 回報「9000/101/20 兜不起來」的病,這裡用一輪同時湊出四種狀態的真實跑法覆現+守住。
// ③ 斷網/閒置一輪後數字不歸零:ProgressFailureBreakdown 是**現況快照**(每輪原地
// 重算),不是靠 CarryForwardActivity 才不歸零——這裡故意驗一輪「什麼都沒發生」
// (已放棄重試的跳過、還在退避窗內的跳過、已完成的沒有事件)之後數字原封不動。
package collector
import (
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
)
func TestT210ProgressWiring(t *testing.T) {
root := t.TempDir()
write := func(name, content string) {
if err := os.WriteFile(filepath.Join(root, name), []byte(content), 0o644); err != nil {
t.Fatal(err)
}
}
// 雲端版本探測與畫面無關,stub 掉避免真實網路呼叫拖慢測試(同 direct_multi_test.go 手法)。
origFetch := fetchCloudVersion
fetchCloudVersion = func(string) (string, bool) { return "", false }
defer func() { fetchCloudVersion = origFetch }()
// Gemini 替身:pageName 是 "stuck" 或 "pending" 的一律萃取失敗(模擬「本地萃取失敗」
// 這一種無法同步的成因),其餘("ok")成功萃出一張最簡卡片。用 prompt 裡「# <pageName>」
// 那行分辨是哪個檔(gemmaPrompt 的契約:第一行必須是「# <pageName>」)。
restoreGemma := gemmaStub(t, func(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
switch {
case strings.Contains(string(body), "檔名:stuck"), strings.Contains(string(body), "檔名:pending"):
w.WriteHeader(http.StatusInternalServerError)
_, _ = w.Write([]byte("上游炸了(測試用)"))
default:
_ = json.NewEncoder(w).Encode(map[string]any{
"candidates": []map[string]any{{
"content": map[string]any{"parts": []map[string]any{{"text": cardFixture("ok", "測試")}}},
}},
})
}
})
defer restoreGemma()
// 雲端 ingest 端點:卡片成功送達就回 200stuck/pending 根本不會萃出卡,不會打到這裡)。
cypher := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_ = json.NewEncoder(w).Encode(map[string]any{"success": true})
}))
defer cypher.Close()
manifestPath := filepath.Join(t.TempDir(), "m.json")
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,
// ForceSync:測試用真實時間跑不完 1m→5m→…→6h 的退避階梯,force 讓每輪都真的嘗試,
// 只影響「要不要重試」,不影響 FailCount 怎麼記——跟正式情境「使用者按立刻同步」是
// 同一條路徑,不是另開後門。
ForceSync: true,
}
// 先讓 stuck.md 連續失敗到 MaxFailBeforeSkip(8)次,成為「已放棄自動重試」。
write("stuck.md", "# 原稿 stuck 內容")
for i := 0; i < MaxFailBeforeSkip; i++ {
if _, exit, _ := RunDirectOnce(cfg, false); exit == 0 {
t.Fatalf("第 %d 輪應該失敗(exit!=0),卻是 0", i+1)
}
}
// 同一輪裡湊齊四種狀態:ok.md 會成功(Done)、pending.md 只失敗這一輪
// PendingFailCount=1 < 8)、skip.doc 是不支援的格式(Unreadable,根本沒進
// manifest)、stuck.md 維持已放棄重試(Stuckforce 讓它再撞一次牆,FailCount 繼續
// 往上加但不影響「已達上限」這個判斷)。
write("ok.md", "# 原稿 ok 內容")
write("pending.md", "# 原稿 pending 內容")
write("skip.doc", "舊版 Word,還不支援")
results, exit, _ := RunDirectOnce(cfg, false)
if exit == 0 {
t.Fatalf("這輪 stuck/pending 都會失敗,exit 不該是 0results=%+v", results)
}
st, err := LoadSyncStatus(StatusFilePath(manifestPath))
if err != nil {
t.Fatalf("讀 status.json 失敗:%v", err)
}
// ── 驗法①:四個數字相加等於總數(Evan 封測的病:9000/101/20 兜不起來)──
p := st.Progress
t.Logf("round1 Progress=%+v FailureBreakdown=%+v", p, st.FailureBreakdown)
if sum := p.Done + p.Pending + p.Stuck + p.Unreadable; sum != p.Total {
t.Fatalf("🔴 數字對不起來:%d+%d+%d+%d=%d ≠ 總數 %d\n完整 Progress=%+v",
p.Done, p.Pending, p.Stuck, p.Unreadable, sum, p.Total, p)
}
if p.Done != 1 {
t.Errorf("Done 應為 1ok.md),got %d", p.Done)
}
if p.Pending != 1 {
t.Errorf("Pending 應為 1pending.mdFailCount=1 < %d),got %d", MaxFailBeforeSkip, p.Pending)
}
if p.Stuck < 1 {
t.Errorf("Stuck 應至少 1stuck.md 已達 %d 次失敗上限),got %d", MaxFailBeforeSkip, p.Stuck)
}
if p.Unreadable != 1 {
t.Errorf("Unreadable 應為 1skip.docG-6.2 白名單擋下、根本沒進 manifest),got %d", p.Unreadable)
}
if p.Total != 4 {
t.Errorf("Total 應為 4ok+pending+stuck+skip),got %d%+v", p.Total, p)
}
// FailureBreakdown 的分組加總要等於「送不上去」(Stuck+Unreadable),
// 且只有分類與份數——不含檔名、不含解法(呼叫端/前端都不該認得分類名稱,
// 這裡只是照 collector 已經分好的結果核對總量,不重新判斷)。
wantCantSync := p.Stuck + p.Unreadable
if st.FailureBreakdown.Total != wantCantSync {
t.Fatalf("FailureBreakdown.Total=%d,應等於 Stuck+Unreadable=%d", st.FailureBreakdown.Total, wantCantSync)
}
groupSum := 0
for _, g := range st.FailureBreakdown.Groups {
groupSum += g.Count
}
if groupSum != wantCantSync {
t.Fatalf("FailureBreakdown.Groups 加總=%d,應等於 %d%+v", groupSum, wantCantSync, st.FailureBreakdown.Groups)
}
// Unreadable 那一份必然分類成「格式不支援」(G-6.2 白名單擋下的定義就是這樣)。
foundUnsupported := false
for _, g := range st.FailureBreakdown.Groups {
if g.Category == FailUnsupportedFormat {
foundUnsupported = true
}
}
if !foundUnsupported {
t.Errorf("skip.doc 應該被分類進「%s」,實際分組:%+v", FailUnsupportedFormat, st.FailureBreakdown.Groups)
}
// ── 驗法③:斷網/閒置一輪後數字不歸零(現況快照,不是本輪計數)──
// 這輪關掉 ForceSyncstuck.md 已達上限、pending.md 還在退避窗內、ok.md 內容沒變,
// 全部不會真的觸發任何萃取/上傳動作——模擬「斷網一輪」或「什麼都沒發生的一輪」。
// exit 值不重要(上一輪的失敗紀錄可能還在別的欄位),重點是 Progress 有沒有被清空。
cfg.ForceSync = false
RunDirectOnce(cfg, false)
st2, err := LoadSyncStatus(StatusFilePath(manifestPath))
if err != nil {
t.Fatalf("讀第二次 status.json 失敗:%v", err)
}
t.Logf("round2(idle) Progress=%+v FailureBreakdown=%+v", st2.Progress, st2.FailureBreakdown)
if st2.Progress != p {
t.Fatalf("🔴 閒置一輪後數字變了(不該歸零/不該亂動):before=%+v after=%+v", p, st2.Progress)
}
}