4cbc7ebe11
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>
177 lines
7.9 KiB
Go
177 lines
7.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)
|
||
}
|
||
}
|
||
|
||
// arcrun-rag#59(2026-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 := "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("過了冷卻時間應該解除")
|
||
}
|
||
}
|
||
|
||
// P8(2026-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)
|
||
}
|
||
}
|