Files
arcrun-collector/direct_quota_test.go
T
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

165 lines
6.0 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.
// direct_quota_test.go — 額度用完要說人話並降速(2026-08-07 task 2)端到端驗證。
//
// 模擬 wiki mistakes.md 2026-08-06 記錄的真實上游狀況:Workers AI 每日免費額度
// 10,000 neurons 用完,HTTP 502,訊息含「4006: you have used up your daily free
// allocation of 10,000 neurons」。驗收:畫面(status.json)出現三句話,
// 沒有裸露的錯誤碼;且同一輪/下一輪不再繼續撞同一面牆。
package collector
import (
"encoding/json"
"net/http"
"net/http/httptest"
"path/filepath"
"strings"
"testing"
"time"
)
// quotaExhaustedServer 對任何 /portal/daemon/extract 請求都回上游額度用完的原始錯誤。
// calls 只計「真的萃取」請求(body.text 非空)——ProbeWorkersAI 每輪固定會探測一次
// (送空 text,不燒 LLM 額度,見 probe_workersai.go),不是本測試要驗的「有沒有繼續撞牆」。
func quotaExhaustedServer(t *testing.T, calls *int) *httptest.Server {
t.Helper()
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var body struct {
Text string `json:"text"`
}
_ = json.NewDecoder(r.Body).Decode(&body)
if strings.TrimSpace(body.Text) != "" {
*calls++
}
w.WriteHeader(http.StatusBadGateway) // 實據:HTTP 502
_ = json.NewEncoder(w).Encode(map[string]string{
"error": "4006: you have used up your daily free allocation of 10,000 neurons",
})
}))
}
func TestDirect_QuotaExhausted_ShowsHumanMessageNoRawCode(t *testing.T) {
root := t.TempDir()
writeFile(t, root, "a.md", "內容 A", baseTime)
writeFile(t, root, "b.md", "內容 B", baseTime.Add(time.Minute))
var calls int
srv := quotaExhaustedServer(t, &calls)
defer srv.Close()
manifest := filepath.Join(t.TempDir(), "m.json")
cfg := &DirectConfig{
WatchFolders: []string{root},
Manifest: manifest,
CypherURL: srv.URL, Namespace: "demo", APIKey: "demo",
Library: "kb", Extractor: "workers-ai", ExtractorExplicit: true,
MaxRemoved: DefaultMaxRemovedRatio,
}
results, exit, _ := RunDirectOnce(cfg, false)
if exit != 1 {
t.Fatalf("兩份都該萃取失敗,exit 應為 1got %d", exit)
}
if len(results) != 2 {
t.Fatalf("results=%+v", results)
}
banned := []string{"4006", "502", "HTTP", "neurons"}
for _, r := range results {
for _, b := range banned {
if strings.Contains(r.Error, b) {
t.Errorf("結果不該出現裸露的錯誤碼 %q%q", b, r.Error)
}
}
// arcrun-rag#59:這支測試裡兩份檔案全部失敗、dailyCount 全程是 0——
// 正是 leo 實查那個「一份都沒成功、額度卻用完」的情境,buildQuotaNotice
// 改口不再承諾「自動恢復」(那是做不到的承諾)、也不再建議換模型
// (結構上做不到:嵌入不管選哪個萃取模型都走 Workers AI),只留「升級」這個真出口。
for _, want := range []string{"今天已經幫你整理了", "升級 Cloudflare"} {
if !strings.Contains(r.Error, want) {
t.Errorf("缺三句話之一 %q%q", want, r.Error)
}
}
if strings.Contains(r.Error, "可以換一個模型") {
t.Errorf("dailyCount==0(這輪一份都沒成功)不該再建議換模型:%q", r.Error)
}
}
// status.json 也要是人話,同樣不准有裸碼
st, err := LoadSyncStatus(StatusFilePath(manifest))
if err != nil {
t.Fatalf("讀 status.json 失敗:%v", err)
}
if st.ExtractorOK {
t.Error("額度冷卻中,ExtractorOK 應為 false")
}
for _, b := range banned {
if strings.Contains(st.ExtractorError, b) {
t.Errorf("status.ExtractorError 不該有裸碼 %q%q", b, st.ExtractorError)
}
}
acc, ok := st.AccountDetails[instanceHostOf(srv.URL)]
if !ok {
t.Fatal("找不到帳號的 status")
}
if acc.QuotaMessage == nil {
t.Fatal("應該有 QuotaMessage")
}
if acc.QuotaCooldownUntil == "" {
t.Fatal("應該記下冷卻到什麼時候")
}
for _, b := range banned {
combined := acc.QuotaMessage.Combined()
if strings.Contains(combined, b) {
t.Errorf("QuotaMessage 不該有裸碼 %q%q", b, combined)
}
}
// 只有第一次真的打了 API(第二份的 xerr 判斷不需要——一偵測到就進冷卻,
// 第二個事件被上面的 qs.inCooldown 擋下,不再觸網)。
if calls != 1 {
t.Fatalf("偵測到額度用完後不該繼續撞牆,got %d 次呼叫", calls)
}
}
// 同一帳號、下一輪(新的 RunDirectOnce 呼叫,模擬下一次輪詢):冷卻還沒過,
// 一次網路呼叫都不該再打——這是「不要一次湧上去、也不要一直撞牆」的核心。
func TestDirect_QuotaExhausted_NextRunSkipsWithoutNetworkCall(t *testing.T) {
root := t.TempDir()
writeFile(t, root, "a.md", "內容 A", baseTime)
var calls int
srv := quotaExhaustedServer(t, &calls)
defer srv.Close()
cfg := &DirectConfig{
WatchFolders: []string{root},
Manifest: filepath.Join(t.TempDir(), "m.json"),
CypherURL: srv.URL, Namespace: "demo", APIKey: "demo",
Library: "kb", Extractor: "workers-ai", ExtractorExplicit: true,
MaxRemoved: DefaultMaxRemovedRatio,
}
if _, exit, _ := RunDirectOnce(cfg, false); exit != 1 {
t.Fatal("第一輪應該偵測到額度用完")
}
if calls != 1 {
t.Fatalf("第一輪應該只打 1 次,got %d", calls)
}
results2, exit2, _ := RunDirectOnce(cfg, false)
if exit2 != 0 {
t.Fatalf("冷卻中的 skip 不是失敗,exit 應為 0got %d results=%+v", exit2, results2)
}
if calls != 1 {
t.Fatalf("冷卻中不該再打任何一次 APIgot %d 次呼叫", calls)
}
if len(results2) != 1 || results2[0].Status != "skipped" {
t.Fatalf("results2=%+v", results2)
}
// arcrun-rag#59:這支測試同樣是 dailyCount==0(單一檔案、全程沒有成功過),
// 跳過訊息改口成「升級 Cloudflare」這個真出口,不再是「自動恢復」的舊三句話。
if !strings.Contains(results2[0].Error, "今天已經幫你整理了") ||
!strings.Contains(results2[0].Error, "升級 Cloudflare") {
t.Errorf("跳過訊息也該是三句話(新版):%q", results2[0].Error)
}
}