Files
arcrun-collector/sync_status_test.go
T
Leo 6c9d74d588 fix(t91+t92): 萃取狀態可見+Finder 啟動 PATH 修正——背景執行不可見的兩題同根一起解
leo 07-28 實測根因:Finder 起的 GUI app 只有最小 PATH(無 /opt/homebrew/bin)
→ claude 靜默找不到→整輪萃取失敗,托盤卻顯示「看守中」。leo:「我怎麼知道它有萃?」
- FindClaudeBin:LookPath 失敗後掃 4 個常見絕對路徑,找到回寫 config claude_bin
- CheckExtractor 預檢+每輪寫 ~/.arcrun-rag/status.json(ok/fail 計數+失敗清單)
- 托盤:引擎未就緒→「⚠ 萃取引擎未就緒:<白話原因>」;失敗>0→可點開明細;正常→「已萃 N 檔」
測試:collector+supervisor+tray 三模組 go test 全綠(總管親跑)。
(實作=子 CC;驗證+commit=總管)
2026-07-28 12:41:41 +08:00

227 lines
7.4 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)
}
}
// ── RunDirectOnce 寫狀態檔(t91 整合)────────────────────────────────────────
// TestRunDirectOnceWritesStatusRunDirectOnce 完成後應在 manifest 同目錄寫出 status.json。
// 使用空資料夾(無事件)確保不觸發 HTTP,只驗 extractor 預檢結果與 LastSync。
func TestRunDirectOnceWritesStatus(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("stub 腳本測試僅跑 Unix")
}
// 建可執行的假 claude stub
stubDir := t.TempDir()
stub := filepath.Join(stubDir, "claude")
if err := os.WriteFile(stub, []byte("#!/bin/sh\necho ok"), 0o755); err != nil {
t.Fatal(err)
}
// 空資料夾 → 零事件 → 無 HTTP 呼叫
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", ClaudeBin: stub,
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", 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 應有白話說明")
}
}