Files
arcrun-collector/progress_wiring_test.go
T
Leo b3f63f40fb feat(t210): 首頁改統計,不再逐檔解釋——Evan「9000/101/20 兜不起來」的病
地基(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>
2026-08-08 12:45:12 +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)
}
}