77aa4c5195
leo:「無法知道到底同步了沒,可以在 daemon 上加一個立刻同步?」 tray 點擊寫 ~/.arcrun-rag/sync-now+短暫顯示「同步中…」;collector 主迴圈 1 秒輪詢 訊號檔、有就立刻跑一輪刪檔,無則照 PollSec 排程;完成後 status.json 更新自然刷新托盤。 測試:collector+supervisor+tray 三模組全綠(總管親跑)。 (實作=子 CC;驗證+commit=總管)
271 lines
8.8 KiB
Go
271 lines
8.8 KiB
Go
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)
|
||
}
|
||
}
|