a6ced32d45
封測事故(Evan):daemon 逐檔萃取上傳、每個檔在雲端產生一次工作流執行, 690 個檔在自己的免費 CF 帳號上短時間內衝出 1,070 次寫入,撞上免費上限 1,000, 額度爆掉、只有 8 個檔成功。雲端那一半(紀錄改走資料層 API)已修好, daemon 這一半原本完全沒有節奏——本次補齊四件事: 1. 上傳節奏(direct_pacing.go):單輪最多處理 MaxEventsPerRun 個事件 (預設 25)、每次觸發雲端前節流 700ms;一輪掃到的事件依檔案 mtime 由新到舊排序,今天寫的永遠優先,積壓慢慢消化不擋日常使用。 2. 額度用完講人話(quota.go):偵測到 Workers AI「10,000 neurons」/ 「4006」等已知上游訊號後,換成三句話(今天已整理幾份/可換模型或 升級 Cloudflare/不花錢也沒關係、今天或明天早上 8:00 會自動恢復), 不出現裸露的錯誤碼;同帳號同輪與下一輪都不再繼續撞牆 (quotaState 全域冷卻,跨資料夾/跨程序重啟持續,直到台灣時間 早上 8:00 額度重置)。 3. 斷點續傳:每個事件處理完立刻寫回 manifest(不再等整輪跑完才存一次), process 被殺掉重開只會接著做真正還沒完成的部分;removed 事件另外 用 preScanEntries 快照保護,下架失敗時不會被其他事件的存檔動作 誤標成「已完成」而永遠不再重試。 4. 同內容多格式去重(scan.go):同一批來源轉出的多種格式(如 leo 給的 資料集 27,164 檔=9,045 md+9,044 json+9,043 html,md/json 同檔名 主幹)依檔名主幹分組,只留優先序最高的一份進事件管線,其餘標記在 DuplicateFormats(不吃三倍額度)。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
111 lines
3.9 KiB
Go
111 lines
3.9 KiB
Go
// quota_test.go — 額度用完人話訊息(2026-08-07 pacing task 2)。
|
||
package collector
|
||
|
||
import (
|
||
"strings"
|
||
"testing"
|
||
"time"
|
||
)
|
||
|
||
func TestIsQuotaExhausted(t *testing.T) {
|
||
cases := []struct {
|
||
msg string
|
||
want bool
|
||
}{
|
||
{"本地萃取失敗:雲端萃取失敗(HTTP 502):4006: you have used up your daily free allocation of 10,000 neurons", true},
|
||
{"雲端萃取失敗:exceeded daily free allocation", true},
|
||
{"連不上你的知識庫:dial tcp: connection refused", false},
|
||
{"檔案裡沒有可抽取的文字", false},
|
||
{"雲端萃取失敗(HTTP 500):internal error", false},
|
||
}
|
||
for _, c := range cases {
|
||
if got := isQuotaExhausted(c.msg); got != c.want {
|
||
t.Errorf("isQuotaExhausted(%q)=%v want %v", c.msg, got, c.want)
|
||
}
|
||
}
|
||
}
|
||
|
||
// UTC 午夜重置=台灣時間固定早上 8:00。
|
||
func TestNextQuotaResetTaiwan(t *testing.T) {
|
||
// 2026-08-07 15:00 UTC = 2026-08-07 23:00 台灣 → 下次重置 2026-08-08 00:00 UTC = 08-08 08:00 台灣
|
||
now := time.Date(2026, 8, 7, 15, 0, 0, 0, time.UTC)
|
||
got := nextQuotaResetTaiwan(now)
|
||
want := time.Date(2026, 8, 8, 8, 0, 0, 0, taiwanTZ)
|
||
if !got.Equal(want) {
|
||
t.Fatalf("got %v want %v", got, want)
|
||
}
|
||
}
|
||
|
||
// 三句話缺一不可,且絕不含裸露的錯誤碼(4006/502/HTTP)。
|
||
func TestBuildQuotaNotice_NoRawErrorCode(t *testing.T) {
|
||
now := time.Date(2026, 8, 7, 10, 0, 0, 0, time.UTC)
|
||
resetAt := nextQuotaResetTaiwan(now)
|
||
notice := buildQuotaNotice(now, 42, resetAt)
|
||
|
||
if !strings.Contains(notice.Achievement, "42") {
|
||
t.Errorf("成就句沒帶到份數:%q", notice.Achievement)
|
||
}
|
||
if !strings.Contains(notice.ExitOptions, "升級") || !strings.Contains(notice.ExitOptions, "換") {
|
||
t.Errorf("出口句缺選項:%q", notice.ExitOptions)
|
||
}
|
||
if !strings.Contains(notice.Guarantee, "自動恢復") {
|
||
t.Errorf("保證句沒講自動恢復:%q", notice.Guarantee)
|
||
}
|
||
combined := notice.Combined()
|
||
for _, banned := range []string{"4006", "502", "HTTP", "neurons"} {
|
||
if strings.Contains(combined, banned) {
|
||
t.Errorf("三句話不該出現裸露的錯誤碼 %q:%q", banned, combined)
|
||
}
|
||
}
|
||
}
|
||
|
||
// 「今天」vs「明天」:現在台灣時間若已過午夜還沒到 8 點,重置其實是「今天」。
|
||
func TestQuotaGuaranteeText_TodayVsTomorrow(t *testing.T) {
|
||
// 台灣時間 08-08 02:00(= UTC 08-07 18:00)→ 下次重置 08-08 08:00 台灣 → 同一天 → 「今天」
|
||
now := time.Date(2026, 8, 7, 18, 0, 0, 0, time.UTC)
|
||
resetAt := nextQuotaResetTaiwan(now)
|
||
got := quotaGuaranteeText(now, resetAt)
|
||
if !strings.Contains(got, "今天") {
|
||
t.Fatalf("應該是「今天」:%q(now台灣=%v resetAt台灣=%v)", got, now.In(taiwanTZ), resetAt.In(taiwanTZ))
|
||
}
|
||
|
||
// 台灣時間 08-07 23:00(= UTC 08-07 15:00)→ 下次重置 08-08 08:00 台灣 → 隔天 → 「明天」
|
||
now2 := time.Date(2026, 8, 7, 15, 0, 0, 0, time.UTC)
|
||
resetAt2 := nextQuotaResetTaiwan(now2)
|
||
got2 := quotaGuaranteeText(now2, resetAt2)
|
||
if !strings.Contains(got2, "明天") {
|
||
t.Fatalf("應該是「明天」:%q", got2)
|
||
}
|
||
}
|
||
|
||
func TestQuotaState_MarkHitOnlyFirstTimeSetsCooldown(t *testing.T) {
|
||
qs := "aState{}
|
||
now := time.Date(2026, 8, 7, 10, 0, 0, 0, time.UTC)
|
||
qs.markHit(now, "第一次原因")
|
||
first := qs.CooldownUntil
|
||
|
||
later := now.Add(90 * time.Minute)
|
||
qs.markHit(later, "第二次原因(同一輪另一個檔也撞到)")
|
||
if !qs.CooldownUntil.Equal(first) {
|
||
t.Fatalf("第二次命中不該再往後推遲冷卻時間:first=%v got=%v", first, qs.CooldownUntil)
|
||
}
|
||
if qs.RawReason != "第一次原因" {
|
||
t.Fatalf("RawReason 應保留第一次的:%q", qs.RawReason)
|
||
}
|
||
}
|
||
|
||
func TestQuotaState_InCooldown(t *testing.T) {
|
||
qs := "aState{}
|
||
now := time.Date(2026, 8, 7, 10, 0, 0, 0, time.UTC)
|
||
if qs.inCooldown(now) {
|
||
t.Fatal("從沒命中過不該在冷卻中")
|
||
}
|
||
qs.markHit(now, "x")
|
||
if !qs.inCooldown(now) {
|
||
t.Fatal("剛命中應立刻在冷卻中")
|
||
}
|
||
if qs.inCooldown(qs.CooldownUntil.Add(time.Second)) {
|
||
t.Fatal("過了冷卻時間應該解除")
|
||
}
|
||
}
|