feat(t210): 統計層地基——分母與失敗分類(首頁與診斷檔共用同一組數字)

leo 08-08:「我不要枚舉每個檔案可能的問題和解法,應該是統計的」。
SyncProgress 單位裁定為「份檔案」(地端算不出待同步幾張卡)。
不變式 Total == Done+Pending+Stuck+Unreadable 由測試守著。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-08 12:09:49 +08:00
parent e12f44e84d
commit 1efc8123f1
2 changed files with 248 additions and 0 deletions
+108
View File
@@ -0,0 +1,108 @@
// progress_test.go — t210 統計層。守的是 leo 08-08 定的兩條驗法:
//
// ① 四個數字相加要等於總數(「不准有對不起來的組合」——這整批的起因就是數字對不起來)
// ② 分類收斂:認不出來的一律進「其他」,不准為新錯誤一直長文案
package collector
import "testing"
func mkEntry(content, ingested string, failCount int) *ManifestEntry {
return &ManifestEntry{ContentHash: content, IngestedHash: ingested, FailCount: failCount}
}
// 🔴 leo 08-08 驗法①:相加必須等於總數。
func TestProgress_SumEqualsTotal(t *testing.T) {
m := &Manifest{Entries: map[string]*ManifestEntry{
"done1.md": mkEntry("h1", "h1", 0), // 已完成
"done2.md": mkEntry("h2", "h2", 0), // 已完成
"changed.md": mkEntry("h3new", "h3old", 0), // 改過了,要重送 → 待處理
"fresh.md": mkEntry("h4", "", 0), // 從沒送過 → 待處理
"retrying.md": mkEntry("h5", "", 3), // 退避中,但還沒放棄 → 待處理
"givenup.md": mkEntry("h6", "", MaxFailBeforeSkip), // 已放棄自動重試 → 卡住
}}
p := m.Progress()
if p.Total != 6 {
t.Fatalf("總數應為 6got %d", p.Total)
}
if p.Done != 2 || p.Pending != 3 || p.Stuck != 1 {
t.Fatalf("分佈錯:Done=%d Pending=%d Stuck=%dwant 2/3/1", p.Done, p.Pending, p.Stuck)
}
if sum := p.Done + p.Pending + p.Stuck + p.Unreadable; sum != p.Total {
t.Fatalf("🔴 數字對不起來:%d+%d+%d+%d=%d ≠ 總數 %d\n"+
"(這正是 Evan 2026-08-08 回報的病,不准再發生)",
p.Done, p.Pending, p.Stuck, p.Unreadable, sum, p.Total)
}
}
// 已放棄重試的不可以混進「待處理」——否則使用者會一直等一件永遠不會發生的事。
func TestProgress_GivenUpIsNotPending(t *testing.T) {
m := &Manifest{Entries: map[string]*ManifestEntry{
"givenup.md": mkEntry("h", "", MaxFailBeforeSkip),
}}
p := m.Progress()
if p.Pending != 0 || p.Stuck != 1 {
t.Fatalf("放棄重試的該算 Stuck 不是 PendingPending=%d Stuck=%d", p.Pending, p.Stuck)
}
}
// 多資料夾/多帳號時首頁講的是總量。
func TestProgress_Add(t *testing.T) {
a := SyncProgress{Total: 9000, Done: 101, Pending: 8879, Stuck: 20}
b := SyncProgress{Total: 10, Done: 10}
got := a.Add(b)
if got.Total != 9010 || got.Done != 111 || got.Pending != 8879 || got.Stuck != 20 {
t.Fatalf("累加錯:%+v", got)
}
}
// 分類用的是實撞過的錯誤原文,不是猜的。
func TestClassifyFailure_RealWorldMessages(t *testing.T) {
cases := []struct{ raw, want string }{
{"4006: you have used up your daily free allocation of 10,000 neurons", FailQuotaExhausted},
{"轉檔失敗(a.pdf):檔案裡沒有可抽取的文字", FailNoTextInFile},
{"尚未支援的檔案格式:.pages", FailUnsupportedFormat},
{"上次失敗(第 5 次),5h38m38s 後重試|原因:4006 neurons", FailQuotaExhausted},
{"connection reset by peer", FailOther},
{"", FailOther},
}
for _, c := range cases {
if got := ClassifyFailure(c.raw); got != c.want {
t.Errorf("ClassifyFailure(%q) = %q, want %q", c.raw, got, c.want)
}
}
}
// 統計只出分類與份數;份數 0 的分類不佔畫面。
func TestBuildFailureBreakdown(t *testing.T) {
b := BuildFailureBreakdown([]string{
"4006 neurons", "4006 neurons",
"尚未支援的檔案格式:.pages",
"something we have never seen",
})
if b.Total != 4 {
t.Fatalf("總數應為 4got %d", b.Total)
}
if len(b.Groups) != 3 {
t.Fatalf("應有 3 個非零分類(0 的不出現),got %d%+v", len(b.Groups), b.Groups)
}
// 順序固定,畫面才不會每次重排
want := []FailureGroup{
{FailUnsupportedFormat, 1},
{FailQuotaExhausted, 2},
{FailOther, 1},
}
for i, w := range want {
if b.Groups[i] != w {
t.Fatalf("第 %d 組 = %+v, want %+v(順序須照 FailCategories", i, b.Groups[i], w)
}
}
// 分組份數加起來要等於總數(同「數字對得起來」的不變式)
sum := 0
for _, g := range b.Groups {
sum += g.Count
}
if sum != b.Total {
t.Fatalf("分組加總 %d ≠ 總數 %d", sum, b.Total)
}
}