package main import ( "encoding/json" "strings" "testing" "arcrun-rag/collector/supervisor" ) // t26:暱稱 > email > 未設定(leo 07-24 拍板:CF 全程隱形,這條邏輯不該提到 cypher/CF)。 func TestConnectionStatusLabel(t *testing.T) { cases := []struct { name string instanceName string email string want string }{ {"暱稱優先於email", "我的書房", "leo21c@gmail.com", "連線中:我的書房"}, {"無暱稱退回email", "", "leo21c@gmail.com", "連線中:leo21c@gmail.com"}, {"兩者皆空顯示未設定", "", "", "連線中:未設定"}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { got := connectionStatusLabel(c.instanceName, c.email) if got != c.want { t.Errorf("connectionStatusLabel(%q, %q) = %q, want %q", c.instanceName, c.email, got, c.want) } }) } } func TestShortCypherHost(t *testing.T) { cases := []struct { name string in string want string }{ {"完整URL取host", "https://arcrun-cypher-executor.someacct.workers.dev/path", "arcrun-cypher-executor.someacct.workers.dev"}, {"空字串回空字串", "", ""}, {"無法解析出host則原樣回傳", "not-a-url", "not-a-url"}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { got := shortCypherHost(c.in) if got != c.want { t.Errorf("shortCypherHost(%q) = %q, want %q", c.in, got, c.want) } }) } } // config 序列化含新欄(email/instance_name)——t26 施工面一驗收項。 func TestDirectConfigJSONRoundTrip(t *testing.T) { c := &directConfig{ CypherURL: "https://example.workers.dev", Namespace: "demo", Email: "leo21c@gmail.com", InstanceName: "我的書房", } data, err := json.Marshal(c) if err != nil { t.Fatalf("marshal 失敗:%v", err) } s := string(data) if !strings.Contains(s, `"email":"leo21c@gmail.com"`) { t.Errorf("序列化結果缺 email 欄:%s", s) } if !strings.Contains(s, `"instance_name":"我的書房"`) { t.Errorf("序列化結果缺 instance_name 欄:%s", s) } var back directConfig if err := json.Unmarshal(data, &back); err != nil { t.Fatalf("unmarshal 失敗:%v", err) } if back.Email != c.Email || back.InstanceName != c.InstanceName { t.Errorf("round-trip 不一致:got email=%q instance_name=%q", back.Email, back.InstanceName) } } // 暱稱空時 config JSON 不應寫出 instance_name 欄(omitempty;對齊面二 installer 端「空就不寫欄位」的約定)。 func TestDirectConfigJSONOmitsEmptyInstanceName(t *testing.T) { c := &directConfig{ CypherURL: "https://example.workers.dev", Namespace: "demo", Email: "leo21c@gmail.com", } data, err := json.Marshal(c) if err != nil { t.Fatalf("marshal 失敗:%v", err) } if strings.Contains(string(data), "instance_name") { t.Errorf("暱稱空時不該序列化出 instance_name 欄:%s", data) } } // ── t86:instanceChanged ───────────────────────────────────────────────────── func TestInstanceChanged(t *testing.T) { cases := []struct { name string oldURL string newURL string want bool }{ { "同 host 同實例改密碼不觸發清空", "https://arcrun-cypher-executor.youlin.workers.dev", "https://arcrun-cypher-executor.youlin.workers.dev", false, }, { "不同 host 換實例觸發清空", "https://arcrun-cypher-executor.youlin.workers.dev", "https://arcrun-cypher-executor.geek6688.workers.dev", true, }, { "舊 URL 空(首次設定)不觸發清空", "", "https://arcrun-cypher-executor.geek6688.workers.dev", false, }, { "新 URL 空(異常值)不觸發清空", "https://arcrun-cypher-executor.youlin.workers.dev", "", false, }, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { got := instanceChanged(c.oldURL, c.newURL) if got != c.want { t.Errorf("instanceChanged(%q, %q) = %v, want %v", c.oldURL, c.newURL, got, c.want) } }) } } // t86:換實例後 applyRemoteConfig 清空資料夾清單 func TestApplyRemoteConfigClearsFoldersOnInstanceSwitch(t *testing.T) { cfg := &directConfig{ CypherURL: "https://arcrun-cypher-executor.youlin.workers.dev", Namespace: "youlin", WatchFolder: "/Users/youlin/KnowledgeBase", WatchFolders: []string{"/Users/youlin/KnowledgeBase", "/Users/youlin/Finance"}, } r := &daemonConfigResp{} r.Config.CypherURL = "https://arcrun-cypher-executor.geek6688.workers.dev" r.Config.Namespace = "geek6688" switched := applyRemoteConfig(cfg, r) if !switched { t.Error("換了不同 host 應回傳 switched=true") } if cfg.WatchFolder != "" { t.Errorf("換實例後 WatchFolder 應清空,got %q", cfg.WatchFolder) } if len(cfg.WatchFolders) != 0 { t.Errorf("換實例後 WatchFolders 應清空,got %v", cfg.WatchFolders) } } // t86:同實例改密碼不清空資料夾清單 func TestApplyRemoteConfigKeepsFoldersOnSameInstance(t *testing.T) { cfg := &directConfig{ CypherURL: "https://arcrun-cypher-executor.youlin.workers.dev", Namespace: "youlin", WatchFolder: "/Users/youlin/KnowledgeBase", WatchFolders: []string{"/Users/youlin/KnowledgeBase"}, } r := &daemonConfigResp{} r.Config.CypherURL = "https://arcrun-cypher-executor.youlin.workers.dev" r.Config.Namespace = "youlin" switched := applyRemoteConfig(cfg, r) if switched { t.Error("同 host 不應回傳 switched=true") } if cfg.WatchFolder == "" || len(cfg.WatchFolders) == 0 { t.Error("同實例不應清空資料夾清單") } } // ── t91/t92:buildStatusLabel 與 syncStatusLabel 邏輯 ────────────────────── // TestBuildStatusLabelNormal:看守中(無 extractor 設定)→ 正常「看守中」文案。 func TestBuildStatusLabelNormal(t *testing.T) { s := supervisor.Status{State: supervisor.StateWatching} got := buildStatusLabel(s, traySyncStatus{ExtractorOK: true}, "") if !strings.HasPrefix(got, "看守中") { t.Errorf("got=%q,應以「看守中」開頭", got) } if strings.Contains(got, "萃取") { t.Errorf("無 extractor 時不應有萃取字眼,got=%q", got) } } // TestBuildStatusLabelExtractorFail:extractor 預檢失敗 → 換成「⚠ 萃取引擎未就緒:」。 func TestBuildStatusLabelExtractorFail(t *testing.T) { s := supervisor.Status{State: supervisor.StateWatching} sync := traySyncStatus{ExtractorOK: false, ExtractorError: "找不到 Claude 指令"} got := buildStatusLabel(s, sync, "claude") if !strings.HasPrefix(got, "⚠ 萃取引擎未就緒:") { t.Errorf("extractor 失敗時應顯示警告,got=%q", got) } if !strings.Contains(got, "找不到 Claude 指令") { t.Errorf("應包含錯誤原因,got=%q", got) } } // TestBuildStatusLabelExtractorOKWithCount:extractor 就緒且有萃取成功數 → 追加「已萃 N 檔」。 func TestBuildStatusLabelExtractorOKWithCount(t *testing.T) { s := supervisor.Status{State: supervisor.StateWatching} sync := traySyncStatus{ExtractorOK: true, ExtractedOK: 5} got := buildStatusLabel(s, sync, "claude") if !strings.Contains(got, "已萃 5 檔") { t.Errorf("should show 已萃 5 檔,got=%q", got) } } // TestBuildStatusLabelStopped:已暫停狀態不受 sync status 影響。 func TestBuildStatusLabelStopped(t *testing.T) { s := supervisor.Status{State: supervisor.StateStopped} sync := traySyncStatus{ExtractorOK: false, ExtractorError: "任何錯誤"} got := buildStatusLabel(s, sync, "claude") if got != "已暫停" { t.Errorf("已暫停狀態應回「已暫停」,got=%q", got) } } // TestSyncNowSignalPath:syncNowSignalPath 回傳以 sync-now 結尾的路徑(t98)。 // tray 寫這個路徑,collector 從 manifest 同目錄讀——路徑語意必須一致。 func TestSyncNowSignalPath(t *testing.T) { p := syncNowSignalPath() if !strings.HasSuffix(p, "sync-now") { t.Errorf("syncNowSignalPath()=%q,應以 'sync-now' 結尾", p) } // 應該住在 appDir() 底下 if !strings.HasPrefix(p, appDir()) { t.Errorf("syncNowSignalPath()=%q 應在 appDir()=%q 底下", p, appDir()) } } // TestDirectConfigPreservesClaudeBin:config JSON round-trip 保留 claude_bin 欄位(t92 回寫驗收)。 func TestDirectConfigPreservesClaudeBin(t *testing.T) { c := &directConfig{ CypherURL: "https://example.workers.dev", Namespace: "demo", ClaudeBin: "/opt/homebrew/bin/claude", } data, err := json.Marshal(c) if err != nil { t.Fatalf("marshal 失敗:%v", err) } if !strings.Contains(string(data), `"claude_bin":"/opt/homebrew/bin/claude"`) { t.Errorf("序列化結果缺 claude_bin 欄:%s", data) } var back directConfig if err := json.Unmarshal(data, &back); err != nil { t.Fatalf("unmarshal 失敗:%v", err) } if back.ClaudeBin != c.ClaudeBin { t.Errorf("round-trip 不一致:got %q, want %q", back.ClaudeBin, c.ClaudeBin) } }