Files
arcrun-collector/cmd/arcrun-tray/main_test.go
T
Leo 77aa4c5195 feat(t98): 托盤「立刻同步」——訊號檔法零 IPC,跨平台
leo:「無法知道到底同步了沒,可以在 daemon 上加一個立刻同步?」
tray 點擊寫 ~/.arcrun-rag/sync-now+短暫顯示「同步中…」;collector 主迴圈 1 秒輪詢
訊號檔、有就立刻跑一輪刪檔,無則照 PollSec 排程;完成後 status.json 更新自然刷新托盤。
測試:collector+supervisor+tray 三模組全綠(總管親跑)。
(實作=子 CC;驗證+commit=總管)
2026-07-28 15:06:08 +08:00

271 lines
8.8 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.
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 序列化含新欄(emailinstance_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)
}
}
// ── t86instanceChanged ─────────────────────────────────────────────────────
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/t92buildStatusLabel 與 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)
}
}
// TestBuildStatusLabelExtractorFailextractor 預檢失敗 → 換成「⚠ 萃取引擎未就緒:」。
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)
}
}
// TestBuildStatusLabelExtractorOKWithCountextractor 就緒且有萃取成功數 → 追加「已萃 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)
}
}
// TestSyncNowSignalPathsyncNowSignalPath 回傳以 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())
}
}
// TestDirectConfigPreservesClaudeBinconfig 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)
}
}