f903a3f53f
leo 回報 Windows daemon 三症狀,查完 ①③ 同根,根在「雲端控制地端」這個設計。 【①③ 真兇】雲端 extractor_config 是**全租戶共用一把 KV** (arcrun:portal.ts:43 portalTenant = worker 層級變數,不分用戶), 任一處設了 claude → 所有人的 daemon 都收到 claude。沒裝 Claude Code 的機器 FindClaudeBin 失敗 → 每檔萃取 failed、一張卡都沒建;想去 portal 改回 gemini, checkbox 卻恆 disabled(claude_available 恆 false,因 daemon 從未實作 report-capabilities 回報 → daemon_caps KV 永遠空)⇒ 用戶自己解不開。 awindhon 實證:雲端同步成功、Gemini key 有效、零張卡,config.json extractor="claude"。 【leo 裁示】「地端要用什麼模型就在 daemon 上輸入 API Key 設置,而不是雲端設置後 控制地端」「地端先限制 Gemini API Key 配合客戶要求」「雲端就是 Workers AI」。 本次(daemon 端): - addOrUpdateAccount 不再接受雲端下發的 extractor/gemini_api_key/llm_model, 只收連線欄位。t126「每帳號一份萃取設定」照舊保留——t126 修的是「存在哪一層」, 本次改的是「值從哪來」,兩者正交。 - 托盤新增「AI 設定…」:使用者自己填 Gemini API Key,寫本地 config 後立即生效。 - 萃取一律走 gemini:殘留的 extractor:"claude" 正規化為 gemma;claude 路退役。 ⚠️ 這不是「自動偵測有無 claude」(leo 07-27 已否決的 B 案),是整條路先不支援。 - 清掉隨之死亡的 claude_bin 回寫(死代碼=錯誤的環境信號)。 - 托盤單一實例(症狀②):pidfile + 跨平台 processAlive。 mac 之前不多開是借 macOS Launch Services 的巧合,Windows 沒有該層 ⇒ 每點一次多一個 icon。 Unix 用 signal 0(EPERM 也算活著,測試抓到的實際 bug)/Windows 用 OpenProcess+ExitCode。 測試:collector 全綠、tray 全綠。5 個原本用 claude stub 的測試改走**真實 gemma 路** (httptest 替身注入 gemmaBaseURL),不是改斷言充綠;t126②③ 兩案翻轉成 「雲端下發一律被忽略」的回歸守衛;新增 4 案 single-instance。 未送達:本 commit 只到 code,尚未打包出貨;雲端側(刪 portal AI 設定區塊、 extractor 下發、admin/extractor)未動,待部署授權。CP rag-beta 步驟仍為 ◐。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
593 lines
21 KiB
Go
593 lines
21 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)
|
||
}
|
||
}
|
||
|
||
// 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())
|
||
}
|
||
}
|
||
|
||
// ── t101:removeWatchFolder 刪除邏輯 ─────────────────────────────────────────
|
||
|
||
// TestRemoveWatchFolder_removesEntry:移除後 WatchFolders 不含該路徑,WatchFolder 同步更新。
|
||
func TestRemoveWatchFolder_removesEntry(t *testing.T) {
|
||
cfg := &directConfig{
|
||
WatchFolder: "/a",
|
||
WatchFolders: []string{"/a", "/b", "/c"},
|
||
}
|
||
removeWatchFolder(cfg, "/b")
|
||
for _, f := range cfg.WatchFolders {
|
||
if f == "/b" {
|
||
t.Errorf("移除後 WatchFolders 仍含 /b:%v", cfg.WatchFolders)
|
||
}
|
||
}
|
||
if cfg.WatchFolder == "/b" {
|
||
t.Errorf("移除後 WatchFolder 不應是 /b")
|
||
}
|
||
// 移除中間項不影響剩餘項
|
||
if len(cfg.WatchFolders) != 2 {
|
||
t.Errorf("移除一項後應剩 2 項,got %d:%v", len(cfg.WatchFolders), cfg.WatchFolders)
|
||
}
|
||
}
|
||
|
||
// TestRemoveWatchFolder_idempotent:對不在清單中的路徑呼叫,清單保持不變(冪等)。
|
||
func TestRemoveWatchFolder_idempotent(t *testing.T) {
|
||
cfg := &directConfig{
|
||
WatchFolder: "/a",
|
||
WatchFolders: []string{"/a", "/b"},
|
||
}
|
||
removeWatchFolder(cfg, "/nonexistent")
|
||
if len(cfg.WatchFolders) != 2 {
|
||
t.Errorf("重複刪除不存在路徑後清單應不變,got %v", cfg.WatchFolders)
|
||
}
|
||
if cfg.WatchFolder != "/a" {
|
||
t.Errorf("WatchFolder 不應改變,got %q", cfg.WatchFolder)
|
||
}
|
||
}
|
||
|
||
// 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)
|
||
}
|
||
}
|
||
|
||
// ── t104:多帳號同時看守 ──────────────────────────────────────────────────────────
|
||
|
||
// TestAddOrUpdateAccount_NewAccount:空 accounts → 加入新帳號。
|
||
func TestAddOrUpdateAccount_NewAccount(t *testing.T) {
|
||
cfg := &directConfig{}
|
||
r := &daemonConfigResp{}
|
||
r.Config.CypherURL = "https://instance1.workers.dev"
|
||
r.Config.Namespace = "ns1"
|
||
r.Config.Email = "user@a.com"
|
||
r.Config.InstanceName = "Work"
|
||
|
||
isNew := addOrUpdateAccount(cfg, r)
|
||
if !isNew {
|
||
t.Error("新帳號應回 true")
|
||
}
|
||
if len(cfg.Accounts) != 1 {
|
||
t.Fatalf("應新增 1 個帳號,got %d", len(cfg.Accounts))
|
||
}
|
||
if cfg.Accounts[0].Email != "user@a.com" {
|
||
t.Errorf("email 錯:%s", cfg.Accounts[0].Email)
|
||
}
|
||
if cfg.Accounts[0].InstanceName != "Work" {
|
||
t.Errorf("instance_name 錯:%s", cfg.Accounts[0].InstanceName)
|
||
}
|
||
}
|
||
|
||
// TestAddOrUpdateAccount_SameHostUpdates:同 host → 更新連線資訊,WatchFolders 不清空。
|
||
func TestAddOrUpdateAccount_SameHostUpdates(t *testing.T) {
|
||
cfg := &directConfig{
|
||
Accounts: []accountCfg{{
|
||
CypherURL: "https://instance1.workers.dev",
|
||
Namespace: "ns1",
|
||
Email: "old@a.com",
|
||
WatchFolders: []string{"/path/to/folder"},
|
||
}},
|
||
}
|
||
r := &daemonConfigResp{}
|
||
r.Config.CypherURL = "https://instance1.workers.dev"
|
||
r.Config.Namespace = "ns1"
|
||
r.Config.Email = "new@a.com"
|
||
|
||
isNew := addOrUpdateAccount(cfg, r)
|
||
if isNew {
|
||
t.Error("同 host 應回 false(更新,非新增)")
|
||
}
|
||
if len(cfg.Accounts) != 1 {
|
||
t.Fatalf("不應新增帳號,got %d", len(cfg.Accounts))
|
||
}
|
||
if cfg.Accounts[0].Email != "new@a.com" {
|
||
t.Errorf("email 未更新:%s", cfg.Accounts[0].Email)
|
||
}
|
||
if len(cfg.Accounts[0].WatchFolders) == 0 {
|
||
t.Error("同 host 更新不應清空資料夾(t86 退役)")
|
||
}
|
||
}
|
||
|
||
// TestAddOrUpdateAccount_DifferentHostAdds:不同 host → 兩個帳號並存。
|
||
func TestAddOrUpdateAccount_DifferentHostAdds(t *testing.T) {
|
||
cfg := &directConfig{
|
||
Accounts: []accountCfg{{
|
||
CypherURL: "https://instance1.workers.dev",
|
||
Namespace: "ns1",
|
||
}},
|
||
}
|
||
r := &daemonConfigResp{}
|
||
r.Config.CypherURL = "https://instance2.workers.dev"
|
||
r.Config.Namespace = "ns2"
|
||
|
||
isNew := addOrUpdateAccount(cfg, r)
|
||
if !isNew {
|
||
t.Error("不同 host 應回 true(新增)")
|
||
}
|
||
if len(cfg.Accounts) != 2 {
|
||
t.Fatalf("應有 2 個帳號,got %d", len(cfg.Accounts))
|
||
}
|
||
}
|
||
|
||
// TestRemoveAccountWatchFolder_CorrectAccount:刪帳號 A 的資料夾不影響帳號 B(t104+t101)。
|
||
func TestRemoveAccountWatchFolder_CorrectAccount(t *testing.T) {
|
||
cfg := &directConfig{
|
||
Accounts: []accountCfg{
|
||
{CypherURL: "https://a.workers.dev", WatchFolders: []string{"/folder1", "/folder2"}},
|
||
{CypherURL: "https://b.workers.dev", WatchFolders: []string{"/folder3"}},
|
||
},
|
||
}
|
||
removeAccountWatchFolder(cfg, 0, "/folder1")
|
||
if len(cfg.Accounts[0].WatchFolders) != 1 {
|
||
t.Fatalf("account[0] 應剩 1 個資料夾,got %v", cfg.Accounts[0].WatchFolders)
|
||
}
|
||
if cfg.Accounts[0].WatchFolders[0] != "/folder2" {
|
||
t.Errorf("account[0] 剩餘應是 /folder2,got %s", cfg.Accounts[0].WatchFolders[0])
|
||
}
|
||
if len(cfg.Accounts[1].WatchFolders) != 1 || cfg.Accounts[1].WatchFolders[0] != "/folder3" {
|
||
t.Errorf("account[1] 資料夾不應改變,got %v", cfg.Accounts[1].WatchFolders)
|
||
}
|
||
}
|
||
|
||
// TestAccountDisplayName:暱稱 > email > host(t26 延伸)。
|
||
func TestAccountDisplayName(t *testing.T) {
|
||
cases := []struct {
|
||
acc accountCfg
|
||
want string
|
||
}{
|
||
{accountCfg{InstanceName: "書房", Email: "a@b.com", CypherURL: "https://c.dev"}, "書房"},
|
||
{accountCfg{Email: "a@b.com", CypherURL: "https://c.dev"}, "a@b.com"},
|
||
{accountCfg{CypherURL: "https://arcrun-cypher-executor.acct.workers.dev"}, "arcrun-cypher-executor.acct.workers.dev"},
|
||
}
|
||
for _, c := range cases {
|
||
got := accountDisplayName(c.acc)
|
||
if got != c.want {
|
||
t.Errorf("accountDisplayName(%+v) = %q, want %q", c.acc, got, c.want)
|
||
}
|
||
}
|
||
}
|
||
|
||
// TestDirectConfigFolders_MultiAccount:多帳號 Folders() 彙整所有帳號資料夾。
|
||
func TestDirectConfigFolders_MultiAccount(t *testing.T) {
|
||
cfg := &directConfig{
|
||
Accounts: []accountCfg{
|
||
{WatchFolders: []string{"/a", "/b"}},
|
||
{WatchFolders: []string{"/c"}},
|
||
},
|
||
}
|
||
got := cfg.Folders()
|
||
want := []string{"/a", "/b", "/c"}
|
||
if len(got) != len(want) {
|
||
t.Fatalf("Folders() = %v, want %v", got, want)
|
||
}
|
||
for i := range want {
|
||
if got[i] != want[i] {
|
||
t.Errorf("Folders()[%d] = %q, want %q", i, got[i], want[i])
|
||
}
|
||
}
|
||
}
|
||
|
||
// t108:directConfig JSON round-trip 必須保留 GeminiAPIKey/LLMModel/CardIngestWF——
|
||
// 這三欄是 t104 前缺失的;若托盤 loadConfig→saveConfig 吃掉它們,collector 讀到空值就走直送路。
|
||
func TestDirectConfigGeminiFieldsPreserved(t *testing.T) {
|
||
original := &directConfig{
|
||
Extractor: "gemma",
|
||
GeminiAPIKey: "AIzaSy-test-key",
|
||
LLMModel: "gemma-4-31b-it",
|
||
CardIngestWF: "rag_ingest_card",
|
||
}
|
||
data, err := json.Marshal(original)
|
||
if err != nil {
|
||
t.Fatalf("marshal 失敗:%v", err)
|
||
}
|
||
var back directConfig
|
||
if err := json.Unmarshal(data, &back); err != nil {
|
||
t.Fatalf("unmarshal 失敗:%v", err)
|
||
}
|
||
if back.GeminiAPIKey != original.GeminiAPIKey {
|
||
t.Errorf("GeminiAPIKey 消失:got %q", back.GeminiAPIKey)
|
||
}
|
||
if back.LLMModel != original.LLMModel {
|
||
t.Errorf("LLMModel 消失:got %q", back.LLMModel)
|
||
}
|
||
if back.CardIngestWF != original.CardIngestWF {
|
||
t.Errorf("CardIngestWF 消失:got %q", back.CardIngestWF)
|
||
}
|
||
}
|
||
|
||
// t108:addOrUpdateAccount 後機器層 GeminiAPIKey 仍存在(遷移/新增帳號不吃掉機器層欄位)。
|
||
func TestAddOrUpdateAccountPreservesMachineLayer(t *testing.T) {
|
||
cfg := &directConfig{
|
||
Extractor: "gemma",
|
||
GeminiAPIKey: "AIzaSy-existing-key",
|
||
LLMModel: "gemma-4-31b-it",
|
||
CardIngestWF: "rag_ingest_card",
|
||
}
|
||
resp := &daemonConfigResp{Success: true}
|
||
resp.Config.CypherURL = "https://new.workers.dev"
|
||
resp.Config.Namespace = "newns"
|
||
|
||
addOrUpdateAccount(cfg, resp)
|
||
if cfg.GeminiAPIKey != "AIzaSy-existing-key" {
|
||
t.Errorf("GeminiAPIKey 被清空:got %q", cfg.GeminiAPIKey)
|
||
}
|
||
if cfg.LLMModel != "gemma-4-31b-it" {
|
||
t.Errorf("LLMModel 被清空:got %q", cfg.LLMModel)
|
||
}
|
||
if cfg.CardIngestWF != "rag_ingest_card" {
|
||
t.Errorf("CardIngestWF 被清空:got %q", cfg.CardIngestWF)
|
||
}
|
||
}
|
||
|
||
// ── t126:每帳號獨立引擎設定 ──────────────────────────────────────────────────
|
||
|
||
// t126①:accountEngineLabel 依帳號或 defaultExtractor 顯示引擎名(帳號層優先)。
|
||
func TestAccountEngineLabel(t *testing.T) {
|
||
cases := []struct {
|
||
name string
|
||
acc accountCfg
|
||
defaultEx string
|
||
want string
|
||
}{
|
||
{"帳號有gemma", accountCfg{Extractor: "gemma"}, "", " · Gemini"},
|
||
{"帳號有claude", accountCfg{Extractor: "claude"}, "", " · Claude"},
|
||
{"帳號空退回default-gemma", accountCfg{}, "gemma", " · Gemini"},
|
||
{"帳號空退回default-claude", accountCfg{}, "claude", " · Claude"},
|
||
{"兩者皆空無標籤", accountCfg{}, "", ""},
|
||
{"帳號層優先於default", accountCfg{Extractor: "gemma"}, "claude", " · Gemini"},
|
||
}
|
||
for _, c := range cases {
|
||
t.Run(c.name, func(t *testing.T) {
|
||
got := accountEngineLabel(c.acc, c.defaultEx)
|
||
if got != c.want {
|
||
t.Errorf("accountEngineLabel(%+v, %q) = %q, want %q", c.acc, c.defaultEx, got, c.want)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
// t176(翻轉自 t126②):新帳號也一樣——只收連線欄位,雲端下發的 LLM 設定一律不落地。
|
||
// 帳號層留白時,讀取端會繼承機器層(=使用者在「AI 設定…」填的那把)。
|
||
func TestAddOrUpdateAccount_NewAccountIgnoresRemoteLLMSettings(t *testing.T) {
|
||
cfg := &directConfig{
|
||
Extractor: "gemma", // 機器層=使用者自己填的
|
||
GeminiAPIKey: "machine-key",
|
||
}
|
||
resp := &daemonConfigResp{Success: true}
|
||
resp.Config.CypherURL = "https://new.workers.dev"
|
||
resp.Config.Namespace = "ns1"
|
||
resp.Config.Extractor = "claude" // 雲端想下發 claude
|
||
resp.Config.GeminiAPIKey = "cloud-key" // 以及別把金鑰
|
||
resp.Config.LLMModel = "some-cloud-model" //
|
||
|
||
if isNew := addOrUpdateAccount(cfg, resp); !isNew {
|
||
t.Error("應為新帳號")
|
||
}
|
||
if len(cfg.Accounts) == 0 {
|
||
t.Fatal("應新增帳號")
|
||
}
|
||
// 連線欄位要寫進去(這條路徑本來的職責)
|
||
if cfg.Accounts[0].Namespace != "ns1" || cfg.Accounts[0].CypherURL != "https://new.workers.dev" {
|
||
t.Errorf("連線欄位應寫入,got ns=%q url=%q", cfg.Accounts[0].Namespace, cfg.Accounts[0].CypherURL)
|
||
}
|
||
// LLM 欄位一律留白=不吃雲端的
|
||
if cfg.Accounts[0].Extractor != "" {
|
||
t.Errorf("不該吃雲端下發的 extractor,got %q", cfg.Accounts[0].Extractor)
|
||
}
|
||
if cfg.Accounts[0].GeminiAPIKey != "" {
|
||
t.Errorf("不該吃雲端下發的金鑰,got %q", cfg.Accounts[0].GeminiAPIKey)
|
||
}
|
||
if cfg.Accounts[0].LLMModel != "" {
|
||
t.Errorf("不該吃雲端下發的模型,got %q", cfg.Accounts[0].LLMModel)
|
||
}
|
||
// 機器層(使用者自己填的)不受影響
|
||
if cfg.Extractor != "gemma" || cfg.GeminiAPIKey != "machine-key" {
|
||
t.Errorf("機器層不該被雲端動到,got extractor=%q key=%q", cfg.Extractor, cfg.GeminiAPIKey)
|
||
}
|
||
}
|
||
|
||
// t176(翻轉自 t126③):**雲端下發的 LLM 設定一律被忽略**。
|
||
// leo 08-03:「地端要用什麼模型就在 daemon 上輸入 API Key 設置,而不是雲端設置後控制地端」。
|
||
// 這條是回歸守衛——雲端 extractor_config 是全租戶共用一把 KV,任一處設了 claude
|
||
// 會讓所有沒裝 Claude Code 的機器萃取全滅(awindhon 08-03 實證:零張卡)。
|
||
func TestAddOrUpdateAccount_IgnoresRemoteLLMSettings(t *testing.T) {
|
||
cfg := &directConfig{
|
||
Extractor: "gemma", // 機器層=使用者自己在「AI 設定…」填的
|
||
GeminiAPIKey: "my-own-key", //
|
||
Accounts: []accountCfg{{
|
||
CypherURL: "https://inst.workers.dev",
|
||
Namespace: "ns1",
|
||
Extractor: "gemma",
|
||
GeminiAPIKey: "my-own-key",
|
||
}},
|
||
}
|
||
// 雲端試圖下發 claude + 別把金鑰——全部都不該生效
|
||
resp := &daemonConfigResp{Success: true}
|
||
resp.Config.CypherURL = "https://inst.workers.dev"
|
||
resp.Config.Namespace = "ns1"
|
||
resp.Config.Extractor = "claude"
|
||
resp.Config.GeminiAPIKey = "cloud-pushed-key"
|
||
|
||
if isNew := addOrUpdateAccount(cfg, resp); isNew {
|
||
t.Error("同 host 應為更新(回 false)")
|
||
}
|
||
if cfg.Accounts[0].Extractor != "gemma" {
|
||
t.Errorf("雲端下發的 extractor 不該覆蓋本地設定,got %q", cfg.Accounts[0].Extractor)
|
||
}
|
||
if cfg.Accounts[0].GeminiAPIKey != "my-own-key" {
|
||
t.Errorf("雲端下發的金鑰不該覆蓋本地金鑰,got %q", cfg.Accounts[0].GeminiAPIKey)
|
||
}
|
||
if cfg.Extractor != "gemma" || cfg.GeminiAPIKey != "my-own-key" {
|
||
t.Errorf("機器層不該被雲端動到,got extractor=%q key=%q", cfg.Extractor, cfg.GeminiAPIKey)
|
||
}
|
||
// 連線欄位仍要更新(這條路徑本來的職責)
|
||
if cfg.Accounts[0].Namespace != "ns1" {
|
||
t.Errorf("連線欄位仍應更新,got namespace=%q", cfg.Accounts[0].Namespace)
|
||
}
|
||
}
|