Files
arcrun-collector/sync_status_test.go

316 lines
12 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.
// sync_status_test.go — t91/t92 狀態檔與 fallback 路徑邏輯單元測試。
package collector
import (
"os"
"path/filepath"
"runtime"
"strings"
"testing"
"time"
)
// ── 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 應有白話說明")
}
}
// TestCarryForwardActivity 鎖住「做完的證據不會被下一輪抹掉」(2026-08-05 leo 實撞的 status 假象)。
//
// 症狀:拖檔進資料夾 → 萃取上傳都跑完了,但首頁自始至終顯示「等待中」、綠燈沒動。
// 真兇:ExtractedOK 是本輪計數,每輪覆寫整份 status.json ⇒ 有產出那輪寫下 N,
// 十幾秒後沒事做的那輪把它蓋成 0,使用者永遠看不到自己剛做完的事。
func TestCarryForwardActivity(t *testing.T) {
t.Run("本輪有產出→記本輪", func(t *testing.T) {
st := SyncStatus{LastSync: "2026-08-05T17:18:34+08:00", ExtractedOK: 3, ExtractFailed: 1}
CarryForwardActivity(SyncStatus{}, &st)
if st.LastActivityAt != st.LastSync || st.LastActivityOK != 3 || st.LastActivityFailed != 1 {
t.Fatalf("有產出的一輪應記成 last_activity,得到 %+v", st)
}
})
t.Run("本輪沒事做→沿用上一輪,不歸零", func(t *testing.T) {
prev := SyncStatus{LastActivityAt: "2026-08-05T17:18:34+08:00", LastActivityOK: 3, LastActivityFailed: 1}
st := SyncStatus{LastSync: "2026-08-05T17:36:38+08:00"} // 空轉的一輪
CarryForwardActivity(prev, &st)
if st.LastActivityOK != 3 || st.LastActivityFailed != 1 {
t.Fatalf("空轉的一輪不該抹掉上次成果,得到 OK=%d Failed=%d", st.LastActivityOK, st.LastActivityFailed)
}
if st.LastActivityAt != prev.LastActivityAt {
t.Fatalf("時間也要沿用上一輪:want %q got %q", prev.LastActivityAt, st.LastActivityAt)
}
})
t.Run("只有失敗也算有做事", func(t *testing.T) {
st := SyncStatus{LastSync: "2026-08-05T18:00:00+08:00", ExtractFailed: 2}
CarryForwardActivity(SyncStatus{}, &st)
if st.LastActivityFailed != 2 || st.LastActivityAt == "" {
t.Fatalf("全失敗的一輪也要留痕(否則使用者不知道剛剛出過事),得到 %+v", st)
}
})
t.Run("從沒跑過→全空,不編造", func(t *testing.T) {
st := SyncStatus{LastSync: "2026-08-05T18:00:00+08:00"}
CarryForwardActivity(SyncStatus{}, &st)
if st.LastActivityAt != "" || st.LastActivityOK != 0 || st.LastActivityFailed != 0 {
t.Fatalf("沒有任何歷史時應保持空值,得到 %+v", st)
}
})
}
// 🔴 產生訊息的那一端,和決定「這則訊息要不要上畫面」的那一端,必須永遠對得上。
//
// 2026-08-28 第三輪差點實撞:我改了斷路器的措辭,判準沒跟著改 ⇒ 那 9 個被跳過的檔
// 會**連一句原因都沒有地從畫面消失**。不會報錯、不會有紅字,就是安靜地不見。
// 這條網把兩端綁在一起:斷路器講的每一種話,都要能被 explainsWhySkipped 認得。
func TestSkipMessagesAlwaysExplainThemselves(t *testing.T) {
g := newRoundGuard()
g.announce = func(StalledCall) {}
// ① 從沒回應過的帳號(措辭:沒有回應)
first := g.strike("dead.example", stepIngestCard, 60*time.Second)
// ② 連續第二次 ⇒ 跳閘,這句會被貼在每一個被跳過的檔上
tripped := g.strike("dead.example", stepIngestCard, 60*time.Second)
// ③ 回應過、只是慢的帳號(措辭:回得太慢)
g.succeeded("slow.example")
slow := g.strike("slow.example", stepIngestCard, 60*time.Second)
for name, msg := range map[string]string{
"第一次逾時": first,
"跳閘(貼在被跳過的檔上)": tripped,
"回得太慢": slow,
} {
if msg == "" {
t.Fatalf("%s:訊息是空的", name)
}
if !explainsWhySkipped(msg) {
t.Fatalf("🔴 %s 的訊息不會被 status.json 收進失敗清單 ⇒ "+
"畫面上那些檔會沒有任何原因地消失。\n訊息:%s", name, msg)
}
// 產品文案:不准漏出內部語彙
for _, banned := range []string{"HTTP", "context", "timeout", "goroutine", "token"} {
if strings.Contains(msg, banned) {
t.Fatalf("🔴 %s 漏出內部語彙「%s」:%s", name, banned, msg)
}
}
}
// 反面:一句不講原因的話,不該被收進去(不然畫面會被噪音塞滿)
if explainsWhySkipped("跳過") {
t.Fatal("不講原因的訊息不該被當成「講得出原因」")
}
}