b3f63f40fb
地基(progress.go 的 SyncProgress/ClassifyFailure/BuildFailureBreakdown, eeb35ba)已經算好「總量」與「失敗分類」,但沒人接到畫面上:首頁仍在用 本輪計數(extractedOK)+逐檔白話翻譯(humanizeFailure),導致 leo 08-08 轉述的病——三個數字互相對不起來,使用者無法判斷「還在跑」還是「壞了」。 接線(collector/direct.go): - runDirectOnceRoot 現在也回傳這一根資料夾的 SyncProgress/StuckReasons (rootProgress),RunDirectOnce 跨帳號跨資料夾 Add() 累加成總量。 - G-6.2 的 SkippedDocCount(讀不了的檔,根本沒進 manifest)併進 Unreadable/Total——這是 Progress() 算不到的部分,由呼叫端補齊, 維持不變式 Total == Done+Pending+Stuck+Unreadable。 - 「送不上去」的分類統計=Stuck 的 LastError 原文+Unreadable 重用 convert.go 既有的 ErrUnsupported,一起餵給 BuildFailureBreakdown。 分類判斷全程只經過 progress.go 的 ClassifyFailure 一個接縫, direct.go/app.go/前端都不認得任何分類名稱字串(留給 t214 之後 改資料驅動時只動一個檔)。 - SyncStatus 新增 Progress/FailureBreakdown 兩個欄位,兩者都是每輪從 manifest/掃描結果原地重算的現況快照,不進 CarryForwardActivity—— 斷網或閒置一輪不會被清成 0。 畫面(cmd/arcrun-app/app.go+frontend/src/main.js): - 移除 humanizeFailure/buildFailures/UIFailures 那套逐檔白話翻譯, 改用 UIProgress(Total/Done/Pending/CantSync/Groups);前端 cardProgress 只把後端給的 category/count 陣列原樣印出,不分支、不排序、不認分類名。 - 「送不上去」預設摺疊(<details>),展開只有分類與份數,不逐檔列名、 不解釋、不給解法;細節導向「開啟使用說明」。 - 保留 buildSkipped 的「讀不了的檔」卡片(那是另一件事),但份數已併入 Unreadable。 - 移除「總計」卡片裡用本輪計數 extractedOK 的「份已整理」——上方狀態 時間軸的「上次 N 份」與下方矛盾(1 份 vs 0 份已整理)的病因直接消掉, 改用 cardProgress 的累計「已送上去」。 驗證: - go build ./... 與 go test ./...(collector/cmd/arcrun-app/ cmd/arcrun-tray 三個 module)全綠。 - 新增 collector/progress_wiring_test.go:真跑 RunDirectOnce 湊出 Done/Pending/Stuck/Unreadable 四種狀態同時存在,驗四數字相加等於 總數(leo 驗法①);再跑一輪「什麼都沒發生」驗數字不歸零(驗法③)。 - check-render.sh 視覺機械閘綠(lockup 底板/深色模式)。 殘項(誠實標記,未完成): - 真機驗收(leo 08-08 驗法④:拿 Evan 的情境走一遍)未做,需要 leo 或 封測者在實機驗證。 - 「開啟使用說明」目前連到既有 docs 首頁,尚無 t210 分類對應的 FAQ 頁 (tasks.md 已記為相依項)。 - t213 診斷檔尚未消費這組新欄位(tasks.md 記載該任務等本任務讓路)。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
165 lines
7.3 KiB
Go
165 lines
7.3 KiB
Go
// 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 兜不起來」的病,這裡用一輪同時湊出四種狀態的真實跑法覆現+守住。
|
||
// ③ 斷網/閒置一輪後數字不歸零:Progress/FailureBreakdown 是**現況快照**(每輪原地
|
||
// 重算),不是靠 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 端點:卡片成功送達就回 200(stuck/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 只失敗這一輪
|
||
// (Pending,FailCount=1 < 8)、skip.doc 是不支援的格式(Unreadable,根本沒進
|
||
// manifest)、stuck.md 維持已放棄重試(Stuck,force 讓它再撞一次牆,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 不該是 0:results=%+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 應為 1(ok.md),got %d", p.Done)
|
||
}
|
||
if p.Pending != 1 {
|
||
t.Errorf("Pending 應為 1(pending.md,FailCount=1 < %d),got %d", MaxFailBeforeSkip, p.Pending)
|
||
}
|
||
if p.Stuck < 1 {
|
||
t.Errorf("Stuck 應至少 1(stuck.md 已達 %d 次失敗上限),got %d", MaxFailBeforeSkip, p.Stuck)
|
||
}
|
||
if p.Unreadable != 1 {
|
||
t.Errorf("Unreadable 應為 1(skip.doc,G-6.2 白名單擋下、根本沒進 manifest),got %d", p.Unreadable)
|
||
}
|
||
if p.Total != 4 {
|
||
t.Errorf("Total 應為 4(ok+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)
|
||
}
|
||
|
||
// ── 驗法③:斷網/閒置一輪後數字不歸零(現況快照,不是本輪計數)──
|
||
// 這輪關掉 ForceSync:stuck.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)
|
||
}
|
||
}
|