Files
Leo 4cbc7ebe11 fix(arcrun-rag#59): 額度卡與雲端連線兩則假訊息改口
leo21c 畫面同時說「今天已經幫你整理了 0 份」與「額度用完,可以換一個模型,
或升級 Cloudflare」——一份都沒成功,額度其實是被同帳號正在做的向量化
(嵌入固定走 Workers AI,與萃取共用同一份每日免費額度)吃光的,換萃取模型
救不了。改法:buildQuotaNotice 在 dailyCount==0(本輪一份都沒吃到額度就先
撞牆)時換一套出口/保證句——不再建議換模型、不再無條件承諾「明天會自動
接著跑」,只留「升級 Cloudflare」這個結構上真的有效的出口;dailyCount>0
(單純量大用完)維持原三句話不動。

同批修另一則真因已被頂層 wiki 鎖定的假訊息:leo 三個帳號 /health 全部 200,
畫面卻顯示「沒連上雲端」——direct.go 寫 status.json 頂層 cloud_check_ok 時
以前只有剛好一個帳號才會填,2+ 帳號(leo 的常態)時恆為 Go 零值 false。
改成不論帳號數,任一帳號連得上就標頂層為 true。

兩者都有回歸測試:對修改前的程式碼跑會 FAIL(多帳號 CloudCheckOK 案例已
用 git stash 實測驗證),修完後 PASS。額度卡文案另外用真正的 frontend/dist
+ 假 window.go 灌 leo21c 現場資料,Claude Browser 實際開起來看過,確認新
文案正確渲染、不再出現「可以換一個模型」、console 無紅字。

未做:兩者都只在原始碼層修好,尚未重打 daemon bundle 出貨(0.18.25 用戶
手上還看不到);頂層 cloud_check_ok 的實際消費端(除已知的舊版 tray)未
完全追出,留給下一輪核實。詳見 system-dev/docs/3-specs/daemon-beta/tasks.md
t216/t217。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 12:28:49 +08:00

177 lines
7.9 KiB
Go
Raw Permalink 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.
// 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("應該是「今天」:%qnow台灣=%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)
}
}
// arcrun-rag#592026-08-10 leo 實查):dailyCount==0 時三句話原本會自相矛盾——
// 「今天已經幫你整理了 0 份」搭「可以換一個模型」,一份都沒成功那額度是誰用掉的?
// 真兇是嵌入(向量化)與萃取共用同一份 Workers AI 額度(不受萃取模型選擇影響)——
// 這支測試釘住:dailyCount==0 時不准再建議換模型,且仍照實講「整理了 0 份」(不假裝有成果)。
func TestBuildQuotaNotice_ZeroDailyCount_NoModelSwitchSuggestion(t *testing.T) {
now := time.Date(2026, 8, 10, 10, 0, 0, 0, time.UTC)
resetAt := nextQuotaResetTaiwan(now)
notice := buildQuotaNotice(now, 0, resetAt)
if !strings.Contains(notice.Achievement, "0") {
t.Errorf("成就句仍要照實講 0 份:%q", notice.Achievement)
}
// 注意:文案裡允許出現「換」這個字(要誠實講「換模型救不了」),
// 禁的是舊版那句**推薦**換模型的措辭「可以換一個模型」。
if strings.Contains(notice.ExitOptions, "可以換一個模型") {
t.Errorf("dailyCount==0 時不該再建議換模型(結構上做不到:嵌入不管選哪個萃取模型都走 "+
"Workers AI):%q", notice.ExitOptions)
}
if !strings.Contains(notice.ExitOptions, "升級") {
t.Errorf("升級 Cloudflare 是這個情境下唯一真的有效的出口,不該被拿掉:%q", notice.ExitOptions)
}
// 三句話合起來仍要能被 ClassifyFailure 歸進額度分類(靠 Achievement 句的
// 「幫你整理了」字樣,不靠 Guarantee 句的「會自動恢復」——這個分支刻意不承諾自動恢復)。
if got := ClassifyFailure(notice.Combined()); got != FailQuotaExhausted {
t.Fatalf("dailyCount==0 的三句話也該歸進額度分類,got %q(訊息:%s)", got, notice.Combined())
}
// 絕不含裸露錯誤碼(沿用既有骨架的紅線)。
for _, banned := range []string{"4006", "502", "HTTP", "neurons"} {
if strings.Contains(notice.Combined(), banned) {
t.Errorf("不該出現裸露的錯誤碼 %q%q", banned, notice.Combined())
}
}
}
// dailyCount>0(單純量大用完,非「額度被別的事吃光」那種矛盾)維持原三句話——
// 這種情境換模型確實能讓萃取那一半少吃一點、多處理幾份,不該被拿掉。
func TestBuildQuotaNotice_PositiveDailyCount_KeepsModelSwitchSuggestion(t *testing.T) {
now := time.Date(2026, 8, 10, 10, 0, 0, 0, time.UTC)
resetAt := nextQuotaResetTaiwan(now)
notice := buildQuotaNotice(now, 105, resetAt)
if !strings.Contains(notice.ExitOptions, "換") || !strings.Contains(notice.ExitOptions, "升級") {
t.Errorf("dailyCount>0(單純量大)應維持原本的三句話,含換模型與升級兩個選項:%q", notice.ExitOptions)
}
if !strings.Contains(notice.Guarantee, "自動恢復") {
t.Errorf("dailyCount>0 時應維持原本「會自動恢復」的保證句:%q", notice.Guarantee)
}
}
func TestQuotaState_MarkHitOnlyFirstTimeSetsCooldown(t *testing.T) {
qs := &quotaState{}
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 := &quotaState{}
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("過了冷卻時間應該解除")
}
}
// P82026-08-09):額度三句話會被存進檔案的失敗欄位(direct.go res.Error),
// 再流進 FailureBreakdown 分類。三句話文案刻意不含「額度」「4006」——
// 若 ClassifyFailure 認不得它,畫面上「今天的 AI 額度用完了」那類反而 0 份、
// 全部掉進「其他」。這支測試釘住「三句話 → 額度分類」這條線。
func TestClassifyFailure_QuotaNoticeCombined(t *testing.T) {
now := time.Date(2026, 8, 9, 3, 0, 0, 0, time.UTC)
notice := buildQuotaNotice(now, 105, nextQuotaResetTaiwan(now))
if got := ClassifyFailure(notice.Combined()); got != FailQuotaExhausted {
t.Fatalf("三句話應歸進額度分類,got %q(訊息:%s)", got, notice.Combined())
}
// 退避訊息會把三句話接在「|原因:」後(direct.go retrySkipReason)——同樣要認得
wrapped := "上次失敗(第 2 次),58m 後重試|原因:" + notice.Combined()
if got := ClassifyFailure(wrapped); got != FailQuotaExhausted {
t.Fatalf("包在退避訊息裡也應歸進額度分類,got %q", got)
}
}