Files
arcrun-collector/sync_status_test.go
T
Leo 1a8906c0b7 t181 daemon 端:萃取預設走 Workers AI(免金鑰)+托盤可切換 AI 來源
【leo 08-04 列為最優先】「daemon 的 AI 改用 workers AI」
——「這是我的用戶**最大障礙**,造成首輪測試用戶的**好評或惡評**」。

【新增】collector/extract_workersai.go:打自己雲端實例的 /portal/daemon/extract,
那端用 env.AI binding ⇒ **完全不需要任何金鑰**。與 gemma 路架構完全對稱
(leo 的判斷:「不論用 Claude/Gemini/Workers AI…應該是同一件事」——是,
只有「送到哪」不同,讀檔/轉檔/淨化/落卡全共用)。
舊實例回 404 時講人話:「你的知識庫還是舊版 ⇒ 請到 portal 按『立即更新』」。

【預設改為一律 Workers AI】leo 特別交代:
  「default 用 Workers AI,你要用 Gemini 要**特別去選取**,**不管你現在是否有填金鑰**」
  「只要更新版本,就已經 default workers AI 了,除非去一個地方切換」
  「不然我會有很多質疑,**花在解釋為什麼 Gemini 不管用上**」
⇒ 判準是新欄位 ExtractorExplicit(使用者主動選過),**不是「有沒有金鑰」**。
  舊 config 沒這欄=false ⇒ 更新版本後自動走 Workers AI;金鑰留著不動,改選 Gemini 立刻可用。

【托盤=唯一切換處】「AI 設定…」改成引擎選擇:雲端 AI(預設)/Gemini(選配)。
選 Gemini 才顯示金鑰欄,並附「Billing Tier: Unavailable ⇒ 換 Google 帳號」的排難提示
(今天 oscar 撞的那題)。Gemini 不推廣(leo:「特定人告訴他怎麼做就好」),文案只說明不慫恿。

【標籤】leo:「用 workers AI 就**不顯示**,用 Gemini 會顯示 Gemini」
⇒ 預設路徑不佔版面;只有主動選了別的才標出來(延續 t178「標籤必須與實際行為一致」)。

【測試】新增 TestT181DefaultsToWorkersAI 六則,含最關鍵的
**「有金鑰但沒主動選 ⇒ 仍走 workers-ai」**;既有測試補 ExtractorExplicit 以隔離變因
(它們測的是萃取管線,不是預設邏輯)。兩模組全綠。

⚠️ 未送達:要打包 v0.15.5 並部署雲端端點才會到用戶手上。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 15:00:48 +08:00

228 lines
7.7 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.
// sync_status_test.go — t91/t92 狀態檔與 fallback 路徑邏輯單元測試。
package main
import (
"os"
"path/filepath"
"runtime"
"testing"
)
// ── FindClaudeBin fallbackt92)──────────────────────────────────────────────
// TestFindClaudeBinPathHithint 在 PATH 能找到時,直接回傳,不進 fallback。
func TestFindClaudeBinPathHit(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("此測試僅在 Unix 執行")
}
dir := t.TempDir()
bin := filepath.Join(dir, "claude")
if err := os.WriteFile(bin, []byte("#!/bin/sh\necho fake"), 0o755); err != nil {
t.Fatal(err)
}
t.Setenv("PATH", dir+":"+os.Getenv("PATH"))
orig := claudeFallbackPaths
claudeFallbackPaths = nil
defer func() { claudeFallbackPaths = orig }()
found, err := FindClaudeBin("")
if err != nil {
t.Fatalf("PATH 有 claude 卻找不到:%v", err)
}
if found != bin {
t.Errorf("found=%q want=%q", found, bin)
}
}
// TestFindClaudeBinFallbackPATH 找不到時,依序掃 claudeFallbackPaths 並命中。
// 模擬 Finder 啟動的 GUI app PATH 不含 /opt/homebrew/bin 的情境(t92 根因)。
func TestFindClaudeBinFallback(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("此測試僅在 Unix 執行")
}
dir := t.TempDir()
bin := filepath.Join(dir, "claude")
if err := os.WriteFile(bin, []byte("#!/bin/sh\necho fake"), 0o755); err != nil {
t.Fatal(err)
}
orig := claudeFallbackPaths
// 清單:第一個不存在(跳過)、第二個是 bin(命中)
claudeFallbackPaths = []string{filepath.Join(dir, "no-such"), bin}
defer func() { claudeFallbackPaths = orig }()
found, err := FindClaudeBin("/definitely/not/there")
if err != nil {
t.Fatalf("fallback 應找到 %sgot err=%v", bin, err)
}
if found != bin {
t.Errorf("found=%q want=%q", found, bin)
}
}
// TestFindClaudeBinAllMissPATH 和所有 fallback 都找不到 → 回誠實錯誤。
func TestFindClaudeBinAllMiss(t *testing.T) {
orig := claudeFallbackPaths
claudeFallbackPaths = []string{"/does/not/exist/claude"}
defer func() { claudeFallbackPaths = orig }()
_, err := FindClaudeBin("/also/does/not/exist")
if err == nil {
t.Fatal("全部找不到應回錯誤")
}
}
// TestFindClaudeBinNotExecutablefallback 路徑存在但不可執行,不應採用。
func TestFindClaudeBinNotExecutable(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("此測試僅在 Unix 執行")
}
dir := t.TempDir()
notExec := filepath.Join(dir, "claude")
if err := os.WriteFile(notExec, []byte("not exec"), 0o644); err != nil { // 0644 無執行位
t.Fatal(err)
}
orig := claudeFallbackPaths
claudeFallbackPaths = []string{notExec}
defer func() { claudeFallbackPaths = orig }()
_, err := FindClaudeBin("/no/such")
if err == nil {
t.Fatal("不可執行的檔不應被當成可用 claude")
}
}
// ── SyncStatus 狀態檔寫入/讀取(t91)────────────────────────────────────────
// TestSyncStatusRoundTrip:寫進去的結構讀出來完全相同。
func TestSyncStatusRoundTrip(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "status.json")
want := SyncStatus{
LastSync: "2026-07-28T10:00:00Z",
ExtractedOK: 3,
ExtractFailed: 1,
Failures: []ExtractFail{
{Path: "重要文件.md", Error: "找不到 Claude 指令"},
},
ExtractorOK: false,
ExtractorError: "找不到 Claude 指令",
}
if err := SaveSyncStatus(path, want); err != nil {
t.Fatalf("SaveSyncStatus 失敗:%v", err)
}
got, err := LoadSyncStatus(path)
if err != nil {
t.Fatalf("LoadSyncStatus 失敗:%v", err)
}
if got.LastSync != want.LastSync {
t.Errorf("LastSync=%q want=%q", got.LastSync, want.LastSync)
}
if got.ExtractedOK != want.ExtractedOK || got.ExtractFailed != want.ExtractFailed {
t.Errorf("counts: ok=%d fail=%d, want ok=%d fail=%d",
got.ExtractedOK, got.ExtractFailed, want.ExtractedOK, want.ExtractFailed)
}
if len(got.Failures) != 1 || got.Failures[0].Path != "重要文件.md" {
t.Errorf("Failures=%+v", got.Failures)
}
if got.ExtractorOK || got.ExtractorError != want.ExtractorError {
t.Errorf("extractor: ok=%v err=%q", got.ExtractorOK, got.ExtractorError)
}
}
// TestLoadSyncStatusMissingFile:檔不存在回零值+error,不 panic。
func TestLoadSyncStatusMissingFile(t *testing.T) {
_, err := LoadSyncStatus(filepath.Join(t.TempDir(), "no-status.json"))
if err == nil {
t.Fatal("不存在的檔應回 error")
}
}
// TestStatusFilePath:依 manifest 路徑回傳同目錄 status.json。
func TestStatusFilePath(t *testing.T) {
got := StatusFilePath("/home/leo/.arcrun-rag/manifest.json")
want := "/home/leo/.arcrun-rag/status.json"
if got != want {
t.Errorf("StatusFilePath=%q want=%q", got, want)
}
}
// TestSyncNowSignalPath:依 manifest 路徑回傳同目錄 sync-nowt98)。
func TestSyncNowSignalPath(t *testing.T) {
got := SyncNowSignalPath("/home/leo/.arcrun-rag/manifest.json")
want := "/home/leo/.arcrun-rag/sync-now"
if got != want {
t.Errorf("SyncNowSignalPath=%q want=%q", got, want)
}
}
// ── RunDirectOnce 寫狀態檔(t91 整合)────────────────────────────────────────
// TestRunDirectOnceWritesStatusRunDirectOnce 完成後應在 manifest 同目錄寫出 status.json。
// 使用空資料夾(無事件)確保不觸發 HTTP,只驗 extractor 預檢結果與 LastSync。
func TestRunDirectOnceWritesStatus(t *testing.T) {
// 空資料夾 → 零事件 → 無 HTTP 呼叫
root := t.TempDir()
manifestDir := t.TempDir()
// t176extractor 寫 "claude" 也會被正規化成 gemmaclaude 路已不支援),
// 故預檢看的是「Gemini 金鑰有沒有填」——這裡填了,ExtractorOK 應為 true。
cfg := &DirectConfig{
WatchFolders: []string{root},
Manifest: filepath.Join(manifestDir, "manifest.json"),
CypherURL: "https://unused.example", Namespace: "demo",
Extractor: "claude", ExtractorExplicit: true, GeminiAPIKey: "k-test",
MaxRemoved: DefaultMaxRemovedRatio,
}
RunDirectOnce(cfg, false)
statusPath := StatusFilePath(cfg.Manifest)
if _, err := os.Stat(statusPath); err != nil {
t.Fatalf("RunDirectOnce 後應存在 status.json%v", err)
}
st, err := LoadSyncStatus(statusPath)
if err != nil {
t.Fatalf("LoadSyncStatus 失敗:%v", err)
}
if !st.ExtractorOK {
t.Errorf("可用 stub claude 時 ExtractorOK 應為 trueExtractorError=%q", st.ExtractorError)
}
if st.LastSync == "" {
t.Error("LastSync 應非空")
}
// 空資料夾 → 零事件
if st.ExtractedOK != 0 || st.ExtractFailed != 0 {
t.Errorf("空資料夾應零計數,got ok=%d fail=%d", st.ExtractedOK, st.ExtractFailed)
}
}
// TestRunDirectOnceWritesStatusExtractorFail:找不到 claude 時 status 應記 ExtractorOK=false。
func TestRunDirectOnceWritesStatusExtractorFail(t *testing.T) {
orig := claudeFallbackPaths
claudeFallbackPaths = []string{} // 空 fallback,確保找不到
defer func() { claudeFallbackPaths = orig }()
root := t.TempDir()
manifestDir := t.TempDir()
cfg := &DirectConfig{
WatchFolders: []string{root},
Manifest: filepath.Join(manifestDir, "manifest.json"),
CypherURL: "https://unused.example", Namespace: "demo",
Extractor: "claude", ExtractorExplicit: true, ClaudeBin: "/no/such/claude",
MaxRemoved: DefaultMaxRemovedRatio,
}
RunDirectOnce(cfg, false)
st, err := LoadSyncStatus(StatusFilePath(cfg.Manifest))
if err != nil {
t.Fatalf("LoadSyncStatus 失敗:%v", err)
}
if st.ExtractorOK {
t.Error("找不到 claude 時 ExtractorOK 應為 false")
}
if st.ExtractorError == "" {
t.Error("ExtractorError 應有白話說明")
}
}