Files
arcrun-collector/progress_wiring_test.go
T
Leo 6c74b97718 feat(collector): workers-ai 路改吃共用契約——免金鑰預設路的卡跟 BYOK 一樣完整(Arcrun#134)
InkStoneCo#44 ④ 只修了 gemma BYOK 路(JSON 契約+wikishape 機械組卡),
免金鑰預設路(多數用戶實際走的)仍吃雲端舊 prompt 的舊格式卡 ⇒ 走預設路
拿到次級知識庫。

修法=契約單一真相源:wikiExtractPrompt(原 gemmaPrompt 改名,因為它已是
兩條路共用)由 daemon 整段帶上雲(request prompt 欄位),雲端只當執行器回
模型原文(response output),解析(parseWikiExtractJSON)與組卡(BuildWikiDoc)
回到本 package 與 gemma 路同一段程式碼——同形不再靠「兩邊要一起改」的叮嚀。

版本歪斜兩向都有路:舊雲端忽略 prompt 回 card ⇒ fallback 走 legacy 落卡
(#60 前綴/不覆蓋保護原封不動,收端 lint 新舊雙軌);舊 daemon 不帶 prompt
⇒ 雲端 legacy 行為不變(對向修法在 Arcrun work/extract-wiki-json-0815)。

測試:+2——①request 的 prompt 必須是 wikiExtractPrompt 本人(不是手抄第二份)
②同原稿+同判斷走兩條路,.wiki/ 產物逐位元組相同(卡+00-INDEX+manifest)。
既有兩則(stub 回 card)自動變成舊雲端 fallback 的守衛。go test ./... 全綠。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 21:26:30 +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>」
// 那行分辨是哪個檔(wikiExtractPrompt 的契約:第一行必須是「# <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)
}
}