Files
arcrun-collector/direct_multi_test.go
T
Leo 133f5add46 t182:老 config 抹除成 Workers AI+雲端探測——leo 實測「無卡」的真因
leo 08-04 實測回報(更新 v0.15.5 後仍顯示 Gemini/丟 PDF 無卡),
查出來不是 Workers AI 不通,是**根本沒走到那條路**。三顆真 bug:

① 抹除只做一半(leo:「就要抹除改成用 Workers AI,如果保持 Gemini
   它不會改掉,那就是失敗的」)
   t181 只在 RunDirectOnce 改頂層記憶體值,但
   - makeAccountSubConfig 會把**帳號層**舊值蓋回來(t126「帳號層優先」)
   - 完全**沒寫回檔案** ⇒ 托盤是另一個行程,讀檔還是念 Gemini
   leo 的 config 正是頂層+兩個帳號各留 "gemma"、無 explicit
   ⇒ 畫面顯示 Gemini、萃取也真的跑 Gemini。
   修:遷移移到 LoadDirectConfig(每次啟動必經),逐層抹除 + 寫回檔案。
   金鑰保留(Gemini 是選配不是廢除)。

② 驗證器擋掉自己的新預設
   `extractor 只能是 claude / gemma` ⇒ config 一旦寫成 workers-ai,
   daemon 直接載入失敗起不來。v0.15.5 已帶著這顆出貨。

③ 托盤標籤照 config 舊字串念(t178 的通則版)
   t178 只把 claude 這**一個**殘留值導向 Gemini,殘留 gemma 一樣脫鉤。
   改成與 direct.go 同一條判準:沒 explicit 就一律念「雲端 AI」。

+ t182 雲端探測(leo 指定設計:「會去掃一次看雲端是否裝好,沒裝好就顯示
  workers AI 還沒通,一旦通了就顯示可用」):ProbeWorkersAI 逐帳號探
  /portal/daemon/extract(送空 text,不燒 LLM 額度),結果寫進 status.json
  由托盤「狀態:」講白話。**不做靜默退回 Gemini**——那會讓用戶永遠
  不知道自己雲端沒更新。

實測證據(非 mock,打真實例):
- youlin 實例萃真卡:3.67s,產出完整知識卡(一句話定義/要點/關鍵實體/關聯)
- 連測三次:3.51s / 3.34s / 3.59s(Gemini 實測 16.87s ⇒ 快約 5 倍)
- 兩個實例都已有 /portal/daemon/extract(皆回 400=route 存在)
- 拿 leo 真 config 複本跑遷移:三層全抹成 workers-ai、金鑰留著、重讀仍是 workers-ai

測試:新增 direct_t182_test.go(抹除全層/寫回磁碟/主動選過不動/合法值/冪等);
既有三則因預設變更而失效的斷言已更新(t108 二進位不出機的契約未放寬,仍綠)。
全套綠。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 16:29:56 +08:00

788 lines
26 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.
// direct_multi_test.go — daemon-beta task 1 多資料夾 config(單數相容/manifest 分根/多根掃描)。
package main
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
)
func writeDirectConfig(t *testing.T, dir string, cfg map[string]any) string {
t.Helper()
data, _ := json.Marshal(cfg)
p := filepath.Join(dir, "config.json")
if err := os.WriteFile(p, data, 0o600); err != nil {
t.Fatal(err)
}
return p
}
// 單數舊制照舊可載入;Folders() 正規化=一根。
// t86b:單根也分實例,manifest 路徑含 instanceHost 尾碼,不再沿用 cfg.Manifest 原名。
func TestLoadDirectConfigSingularCompat(t *testing.T) {
dir := t.TempDir()
p := writeDirectConfig(t, dir, map[string]any{
"watch_folder": "/tmp/kb", "manifest": filepath.Join(dir, "m.json"),
"cypher_url": "https://x.example", "namespace": "demo",
})
c, err := LoadDirectConfig(p)
if err != nil {
t.Fatal(err)
}
if got := c.Folders(); len(got) != 1 || got[0] != "/tmp/kb" {
t.Fatalf("Folders()=%v", got)
}
// t86b:單根也含實例尾碼,不再沿用裸 cfg.Manifest
mp := c.manifestPathFor("/tmp/kb")
if mp == c.Manifest {
t.Fatalf("t86b:單根 manifest 應含實例尾碼,不應沿用 cfg.Manifest 原路徑,got %s", mp)
}
// 穩定性:同路徑同 config 重算一致
if mp != c.manifestPathFor("/tmp/kb") {
t.Fatal("manifestPathFor 應穩定")
}
}
// 只填 watch_folders 也合法;單數與清單並存時去重保序。
func TestLoadDirectConfigMulti(t *testing.T) {
dir := t.TempDir()
p := writeDirectConfig(t, dir, map[string]any{
"watch_folder": "/tmp/a", "watch_folders": []string{"/tmp/b", "/tmp/a", "/tmp/c"},
"manifest": filepath.Join(dir, "m.json"),
"cypher_url": "https://x.example", "namespace": "demo",
})
c, err := LoadDirectConfig(p)
if err != nil {
t.Fatal(err)
}
got := c.Folders()
want := []string{"/tmp/a", "/tmp/b", "/tmp/c"}
if len(got) != len(want) {
t.Fatalf("Folders()=%v want %v", got, want)
}
for i := range want {
if got[i] != want[i] {
t.Fatalf("Folders()[%d]=%s want %s", i, got[i], want[i])
}
}
// 多根=每根 manifest 各一份、彼此不同、且尾碼對同一路徑穩定
m1 := c.manifestPathFor("/tmp/a")
m2 := c.manifestPathFor("/tmp/b")
if m1 == m2 || m1 == c.Manifest {
t.Fatalf("多根 manifest 應分根:%s vs %s", m1, m2)
}
if m1 != c.manifestPathFor("/tmp/a") {
t.Fatal("同路徑尾碼應穩定")
}
}
// t104cypher_url 有值但無 watch_folders → 合法(遷移後空帳號,等待用戶加資料夾)。
// 真正缺必填:無 accounts 且無 cypher_url。
func TestLoadDirectConfigMissingFolders(t *testing.T) {
dir := t.TempDir()
// 有 cypher_url 無 watch_folders → 合法
p := writeDirectConfig(t, dir, map[string]any{
"manifest": filepath.Join(dir, "m.json"), "cypher_url": "https://x.example", "namespace": "demo",
})
if _, err := LoadDirectConfig(p); err != nil {
t.Fatalf("t104: 有 cypher_url 無 watch_folders 應合法,got: %v", err)
}
// 無 cypher_url 無 accounts → 缺必填
p2 := writeDirectConfig(t, dir, map[string]any{
"manifest": filepath.Join(dir, "m.json"),
})
if _, err := LoadDirectConfig(p2); err == nil {
t.Fatal("無帳號且無 cypher_url 時應報缺必填")
}
}
// 多根 dry-run:各根的檔案都被掃到、事件標 Root、manifest 各自獨立。
func TestRunDirectOnceMultiRootDryRun(t *testing.T) {
base := t.TempDir()
rootA := filepath.Join(base, "rootA")
rootB := filepath.Join(base, "rootB")
for _, d := range []string{rootA, rootB} {
if err := os.MkdirAll(d, 0o755); err != nil {
t.Fatal(err)
}
}
if err := os.WriteFile(filepath.Join(rootA, "a.md"), []byte("# A"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(rootB, "b.md"), []byte("# B"), 0o644); err != nil {
t.Fatal(err)
}
cfg := &DirectConfig{
WatchFolders: []string{rootA, rootB},
Manifest: filepath.Join(base, "manifest.json"),
CypherURL: "https://x.example", Namespace: "demo",
MaxRemoved: DefaultMaxRemovedRatio,
}
results, exit, _ := RunDirectOnce(cfg, true)
if exit != 0 {
t.Fatalf("dry-run 不應失敗:%+v", results)
}
byRoot := map[string][]string{}
for _, r := range results {
if r.Status != "planned" {
t.Fatalf("dry-run 事件應為 planned%+v", r)
}
byRoot[r.Root] = append(byRoot[r.Root], r.Path)
}
if len(byRoot[rootA]) != 1 || byRoot[rootA][0] != "a.md" {
t.Fatalf("rootA 事件錯:%v", byRoot[rootA])
}
if len(byRoot[rootB]) != 1 || byRoot[rootB][0] != "b.md" {
t.Fatalf("rootB 事件錯:%v", byRoot[rootB])
}
}
// t39config 的 `~/` 由 daemon 端展開——安裝器成功頁下載的 config.json 用
// `~/.arcrun-rag/manifest.json` 這種人看的寫法,Go 不展開波浪號=會去找字面上叫 "~" 的資料夾。
func TestLoadDirectConfigExpandsHome(t *testing.T) {
home, err := os.UserHomeDir()
if err != nil || home == "" {
t.Skip("拿不到家目錄,跳過")
}
dir := t.TempDir()
cfgPath := filepath.Join(dir, "config.json")
body := `{
"watch_folders": ["~/Documents/知識庫", "/tmp/絕對路徑不動"],
"manifest": "~/.arcrun-rag/manifest.json",
"cypher_url": "https://cypher.example.workers.dev",
"namespace": "ns1"
}`
if err := os.WriteFile(cfgPath, []byte(body), 0o644); err != nil {
t.Fatal(err)
}
cfg, err := LoadDirectConfig(cfgPath)
if err != nil {
t.Fatalf("LoadDirectConfig: %v", err)
}
wantManifest := filepath.Join(home, ".arcrun-rag/manifest.json")
if cfg.Manifest != wantManifest {
t.Errorf("manifest 未展開:got %q want %q", cfg.Manifest, wantManifest)
}
folders := cfg.Folders()
wantFolder := filepath.Join(home, "Documents/知識庫")
if folders[0] != wantFolder {
t.Errorf("watch_folders[0] 未展開:got %q want %q", folders[0], wantFolder)
}
if folders[1] != "/tmp/絕對路徑不動" {
t.Errorf("絕對路徑不該被動:got %q", folders[1])
}
}
// ── t86bmanifest 分實例 ────────────────────────────────────────────────────
// ① 同資料夾、不同 instanceHost → 不同 manifest 路徑。
func TestManifestPathDifferentInstances(t *testing.T) {
dir := t.TempDir()
cfgA := &DirectConfig{
WatchFolder: "/tmp/kb",
Manifest: filepath.Join(dir, "m.json"),
CypherURL: "https://instanceA.example.workers.dev",
Namespace: "demo",
}
cfgB := &DirectConfig{
WatchFolder: "/tmp/kb",
Manifest: filepath.Join(dir, "m.json"),
CypherURL: "https://instanceB.example.workers.dev",
Namespace: "demo",
}
pA := cfgA.manifestPathFor("/tmp/kb")
pB := cfgB.manifestPathFor("/tmp/kb")
if pA == pB {
t.Fatalf("① 不同實例相同資料夾應產生不同 manifest 路徑,got same: %s", pA)
}
}
// ② 同 instance 同夾路徑 → 穩定且含尾碼。
func TestManifestPathStable(t *testing.T) {
dir := t.TempDir()
cfg := &DirectConfig{
WatchFolder: "/tmp/kb",
Manifest: filepath.Join(dir, "m.json"),
CypherURL: "https://instance.workers.dev",
Namespace: "demo",
}
p1 := cfg.manifestPathFor("/tmp/kb")
p2 := cfg.manifestPathFor("/tmp/kb")
if p1 != p2 {
t.Fatalf("② 相同 instance+路徑應穩定,got %s vs %s", p1, p2)
}
if p1 == cfg.Manifest {
t.Fatal("② manifestPathFor 應含實例尾碼,不應沿用 cfg.Manifest 原路徑")
}
}
// ③ 遷移:舊單根名(cfg.Manifest)存在、新名不存在 → rename 後讀得到既有 entries。
func TestManifestMigrateSingleRoot(t *testing.T) {
dir := t.TempDir()
cfg := &DirectConfig{
WatchFolder: dir,
Manifest: filepath.Join(dir, "manifest.json"),
CypherURL: "https://instance.workers.dev",
Namespace: "demo",
MaxRemoved: DefaultMaxRemovedRatio,
}
absRoot, _ := filepath.Abs(dir)
// 在舊路徑(cfg.Manifest)寫一份有資料的 manifest
old := &Manifest{
FolderID: "old-folder-id",
Root: absRoot,
Entries: map[string]*ManifestEntry{"doc.md": {ContentHash: "sha256:aabb", IngestedHash: "sha256:aabb", Size: 42}},
}
if err := old.Save(cfg.Manifest); err != nil {
t.Fatal(err)
}
newPath := cfg.manifestPathFor(absRoot)
if newPath == cfg.Manifest {
t.Fatal("前提失效:新路徑應與舊路徑不同")
}
if _, err := os.Stat(newPath); err == nil {
t.Fatal("新路徑不應已存在")
}
cfg.migrateManifestIfNeeded(absRoot, newPath)
if _, err := os.Stat(cfg.Manifest); err == nil {
t.Fatal("③ 遷移後舊路徑應已消失")
}
m, err := LoadManifest(newPath, absRoot)
if err != nil {
t.Fatalf("③ 遷移後讀取新路徑失敗:%v", err)
}
if _, ok := m.Entries["doc.md"]; !ok {
t.Fatal("③ 遷移後 entries 應完整保留")
}
}
// ③ 遷移:舊多根名(sha256(absRoot only))存在 → rename 到新路徑(sha256(host+absRoot))。
func TestManifestMigrateMultiRootOldFormula(t *testing.T) {
dir := t.TempDir()
cfg := &DirectConfig{
WatchFolders: []string{dir},
Manifest: filepath.Join(dir, "manifest.json"),
CypherURL: "https://instance.workers.dev",
Namespace: "demo",
MaxRemoved: DefaultMaxRemovedRatio,
}
absRoot, _ := filepath.Abs(dir)
// 計算舊多根路徑(只含路徑雜湊,無 instanceHost
sum := sha256.Sum256([]byte(absRoot))
ext := filepath.Ext(cfg.Manifest)
oldMultiPath := strings.TrimSuffix(cfg.Manifest, ext) + "-" + hex.EncodeToString(sum[:4]) + ext
old := &Manifest{
FolderID: "multi-old-id",
Root: absRoot,
Entries: map[string]*ManifestEntry{"wiki.md": {ContentHash: "sha256:cc00"}},
}
if err := old.Save(oldMultiPath); err != nil {
t.Fatal(err)
}
newPath := cfg.manifestPathFor(absRoot)
if newPath == oldMultiPath {
t.Fatal("前提失效:新路徑應與舊多根路徑不同(instanceHost 加進去了)")
}
cfg.migrateManifestIfNeeded(absRoot, newPath)
if _, err := os.Stat(oldMultiPath); err == nil {
t.Fatal("③ 多根遷移後舊路徑應已消失")
}
m, err := LoadManifest(newPath, absRoot)
if err != nil {
t.Fatalf("③ 多根遷移後讀取失敗:%v", err)
}
if _, ok := m.Entries["wiki.md"]; !ok {
t.Fatal("③ 多根遷移後 entries 應保留")
}
}
// ④ 冪等:新名已存在 → 再跑 migrateManifestIfNeeded 不覆蓋新名內容。
func TestManifestMigrateIdempotent(t *testing.T) {
dir := t.TempDir()
cfg := &DirectConfig{
WatchFolder: dir,
Manifest: filepath.Join(dir, "manifest.json"),
CypherURL: "https://instance.workers.dev",
Namespace: "demo",
MaxRemoved: DefaultMaxRemovedRatio,
}
absRoot, _ := filepath.Abs(dir)
newPath := cfg.manifestPathFor(absRoot)
// 直接在新路徑寫一份 manifest
fresh := &Manifest{
FolderID: "fresh-id",
Root: absRoot,
Entries: map[string]*ManifestEntry{"keep.md": {ContentHash: "sha256:dd11"}},
}
if err := fresh.Save(newPath); err != nil {
t.Fatal(err)
}
// 也在舊路徑留一份(確認冪等時不被搬來蓋掉新名)
stale := &Manifest{
FolderID: "stale-id",
Root: absRoot,
Entries: map[string]*ManifestEntry{"old.md": {ContentHash: "sha256:ee22"}},
}
if err := stale.Save(cfg.Manifest); err != nil {
t.Fatal(err)
}
// 跑兩次
cfg.migrateManifestIfNeeded(absRoot, newPath)
cfg.migrateManifestIfNeeded(absRoot, newPath)
m, err := LoadManifest(newPath, absRoot)
if err != nil {
t.Fatalf("④ 冪等後讀取失敗:%v", err)
}
if _, ok := m.Entries["keep.md"]; !ok {
t.Fatal("④ 冪等後新路徑內容不應被蓋掉")
}
if _, ok := m.Entries["old.md"]; ok {
t.Fatal("④ 冪等後新路徑不應出現舊路徑的 entries")
}
}
// ── t104:多帳號同時看守 ──────────────────────────────────────────────────────────
// ①遷移:舊格式(頂層 cypher_url + watch_folders)→ LoadDirectConfig → accounts[0]。
func TestMigrateOldConfigToAccounts(t *testing.T) {
dir := t.TempDir()
p := writeDirectConfig(t, dir, map[string]any{
"watch_folders": []string{"/tmp/a", "/tmp/b"},
"manifest": filepath.Join(dir, "m.json"),
"cypher_url": "https://instance.example.workers.dev",
"namespace": "ns1",
"email": "user@example.com",
"instance_name": "My Library",
})
cfg, err := LoadDirectConfig(p)
if err != nil {
t.Fatalf("LoadDirectConfig: %v", err)
}
if len(cfg.Accounts) != 1 {
t.Fatalf("① 應自動建 accounts[0]got %d accounts", len(cfg.Accounts))
}
acc := cfg.Accounts[0]
if acc.CypherURL != "https://instance.example.workers.dev" {
t.Errorf("accounts[0].cypher_url 錯:%s", acc.CypherURL)
}
if acc.Namespace != "ns1" {
t.Errorf("accounts[0].namespace 錯:%s", acc.Namespace)
}
if acc.Email != "user@example.com" {
t.Errorf("accounts[0].email 錯:%s", acc.Email)
}
if acc.InstanceName != "My Library" {
t.Errorf("accounts[0].instance_name 錯:%s", acc.InstanceName)
}
if len(acc.WatchFolders) != 2 {
t.Errorf("accounts[0].watch_folders 應含 2 根,got %v", acc.WatchFolders)
}
}
// ①冪等:已有 accounts 時不再遷移(accounts 數量不增加)。
func TestMigrateAlreadyHasAccountsIsIdempotent(t *testing.T) {
dir := t.TempDir()
p := writeDirectConfig(t, dir, map[string]any{
"accounts": []map[string]any{
{"cypher_url": "https://a.example", "namespace": "nsA", "watch_folders": []string{"/tmp/a"}},
{"cypher_url": "https://b.example", "namespace": "nsB", "watch_folders": []string{"/tmp/b"}},
},
"manifest": filepath.Join(dir, "m.json"),
})
cfg, err := LoadDirectConfig(p)
if err != nil {
t.Fatalf("LoadDirectConfig: %v", err)
}
if len(cfg.Accounts) != 2 {
t.Fatalf("① 已有 2 個 accounts 時不應再新增,got %d", len(cfg.Accounts))
}
}
// ②雙帳號同輪同步互不干擾:各帳號事件打到各自的 fake server,結果標正確的 Account host。
func TestRunDirectOnceMultiAccount(t *testing.T) {
var serverACalled, serverBCalled int
serverA := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
serverACalled++
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"ok":true}`))
}))
defer serverA.Close()
serverB := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
serverBCalled++
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"ok":true}`))
}))
defer serverB.Close()
base := t.TempDir()
rootA := filepath.Join(base, "rootA")
rootB := filepath.Join(base, "rootB")
for _, d := range []string{rootA, rootB} {
if err := os.MkdirAll(d, 0o755); err != nil {
t.Fatal(err)
}
}
if err := os.WriteFile(filepath.Join(rootA, "a.md"), []byte("# A"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(rootB, "b.md"), []byte("# B"), 0o644); err != nil {
t.Fatal(err)
}
orig := fetchCloudVersion
fetchCloudVersion = func(string) (string, bool) { return "2026-07-28+stub", true }
defer func() { fetchCloudVersion = orig }()
cfg := &DirectConfig{
Manifest: filepath.Join(base, "manifest.json"),
Accounts: []AccountConfig{
{CypherURL: serverA.URL, Namespace: "nsA", WatchFolders: []string{rootA}},
{CypherURL: serverB.URL, Namespace: "nsB", WatchFolders: []string{rootB}},
},
MaxRemoved: DefaultMaxRemovedRatio,
// t181:本測驗的是「雙帳號各自送到自己的實例」,不是萃取。
// 預設已改成 workers-ai(會打雲端萃取端點),為隔離變因這裡明確走舊制直送
// Extractor 空+ExtractorExplicit=true ⇒ 不被預設覆蓋)。
ExtractorExplicit: true,
}
results, exit, _ := RunDirectOnce(cfg, false)
if exit != 0 {
t.Fatalf("② 雙帳號同步應成功,exit=%dresults=%+v", exit, results)
}
hostA := instanceHostOf(serverA.URL)
hostB := instanceHostOf(serverB.URL)
var countA, countB int
for _, r := range results {
if r.Account == hostA {
countA++
}
if r.Account == hostB {
countB++
}
}
if countA == 0 {
t.Errorf("② account A 結果應標 host %sgot results: %+v", hostA, results)
}
if countB == 0 {
t.Errorf("② account B 結果應標 host %sgot results: %+v", hostB, results)
}
// 各自的 fake server 收到請求(a.md → serverAb.md → serverB
if serverACalled == 0 {
t.Error("② serverA 未收到請求")
}
if serverBCalled == 0 {
t.Error("② serverB 未收到請求")
}
}
// ③一帳號 HTTP 失敗不擋另一帳號。
func TestRunDirectOnceOneAccountFailDoesNotBlock(t *testing.T) {
serverFail := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
_, _ = w.Write([]byte(`{"error":"internal"}`))
}))
defer serverFail.Close()
var serverOKCalled bool
serverOK := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
serverOKCalled = true
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"ok":true}`))
}))
defer serverOK.Close()
base := t.TempDir()
rootFail := filepath.Join(base, "rootFail")
rootOK := filepath.Join(base, "rootOK")
for _, d := range []string{rootFail, rootOK} {
if err := os.MkdirAll(d, 0o755); err != nil {
t.Fatal(err)
}
}
_ = os.WriteFile(filepath.Join(rootFail, "f.md"), []byte("# F"), 0o644)
_ = os.WriteFile(filepath.Join(rootOK, "ok.md"), []byte("# OK"), 0o644)
orig := fetchCloudVersion
fetchCloudVersion = func(string) (string, bool) { return "", false }
defer func() { fetchCloudVersion = orig }()
cfg := &DirectConfig{
Manifest: filepath.Join(base, "manifest.json"),
Accounts: []AccountConfig{
{CypherURL: serverFail.URL, Namespace: "nsFail", WatchFolders: []string{rootFail}},
{CypherURL: serverOK.URL, Namespace: "nsOK", WatchFolders: []string{rootOK}},
},
MaxRemoved: DefaultMaxRemovedRatio,
}
_, exit, _ := RunDirectOnce(cfg, false)
if exit != 1 {
t.Errorf("③ 帳號失敗時 exit 應為 1got %d", exit)
}
if !serverOKCalled {
t.Error("③ 帳號 A 失敗不應擋住帳號 B 同步")
}
}
// ④托盤分組資料結構:accountDisplayName 與 removeAccountWatchFolder 正確作用於指定帳號。
// (純資料結構測試,不依賴 fyne GUI)
func TestAccountDataStructureFunctions(t *testing.T) {
type localAccCfg struct {
InstanceName string
Email string
CypherURL string
WatchFolders []string
}
// 用 makeAccountSubConfig 驗證 per-account sub-config 繼承機器層級欄位
parent := &DirectConfig{
Manifest: "/tmp/m.json",
PollSec: 10,
Extractor: "claude", ExtractorExplicit: true,
Accounts: []AccountConfig{
{CypherURL: "https://a.example", Namespace: "nsA", WatchFolders: []string{"/folder1"}},
},
Library: "kb",
IngestWF: "rag_ingest_direct",
RemovedWF: "rag_takedown_direct",
CardIngestWF: "rag_ingest_card",
LLMModel: "gemma-4-31b-it",
}
sub := parent.makeAccountSubConfig(parent.Accounts[0])
if sub.CypherURL != "https://a.example" {
t.Errorf("makeAccountSubConfig CypherURL 錯:%s", sub.CypherURL)
}
if sub.PollSec != 10 {
t.Errorf("makeAccountSubConfig 機器層級 PollSec 應繼承:%d", sub.PollSec)
}
if sub.Extractor != "claude" {
t.Errorf("makeAccountSubConfig 機器層級 Extractor 應繼承:%s", sub.Extractor)
}
if len(sub.Accounts) != 0 {
t.Errorf("makeAccountSubConfig Accounts 應清空(避免遞迴),got %d", len(sub.Accounts))
}
}
// ⑤t101 刪除在多帳號下作用於正確帳號:帳號 A 刪 folder1 不影響帳號 B。
func TestRunDirectOnceAccountResultsTagged(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodGet {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{}`))
return
}
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"ok":true}`))
}))
defer server.Close()
orig := fetchCloudVersion
fetchCloudVersion = func(string) (string, bool) { return "", false }
defer func() { fetchCloudVersion = orig }()
base := t.TempDir()
rootA := filepath.Join(base, "rA")
rootB := filepath.Join(base, "rB")
for _, d := range []string{rootA, rootB} {
_ = os.MkdirAll(d, 0o755)
}
_ = os.WriteFile(filepath.Join(rootA, "a.md"), []byte("# A"), 0o644)
_ = os.WriteFile(filepath.Join(rootB, "b.md"), []byte("# B"), 0o644)
// 同一個 server,兩個帳號(用不同 namespace 區分)
cfg := &DirectConfig{
Manifest: filepath.Join(base, "manifest.json"),
Accounts: []AccountConfig{
{CypherURL: server.URL, Namespace: "nsA", WatchFolders: []string{rootA}},
{CypherURL: server.URL + "/", Namespace: "nsB", WatchFolders: []string{rootB}},
},
MaxRemoved: DefaultMaxRemovedRatio,
}
results, _, _ := RunDirectOnce(cfg, true) // dry-run:驗結果標籤
if len(results) == 0 {
t.Fatal("⑤ dry-run 應有計畫事件")
}
for _, r := range results {
if r.Account == "" {
t.Errorf("⑤ 結果應標 Account hostgot %+v", r)
}
}
}
// ── t126:每帳號獨立萃取設定 ──────────────────────────────────────────────────
// t126①:帳號層 Extractor/GeminiAPIKey/LLMModel 有值時優先覆蓋機器層。
func TestMakeAccountSubConfigExtractorOverride(t *testing.T) {
parent := &DirectConfig{
Manifest: "/tmp/m.json",
Extractor: "claude", ExtractorExplicit: true,
GeminiAPIKey: "machine-key",
LLMModel: "model-machine",
PollSec: 5,
MaxRemoved: DefaultMaxRemovedRatio,
}
acc := AccountConfig{
CypherURL: "https://a.example",
Namespace: "ns",
WatchFolders: []string{"/tmp"},
Extractor: "gemma", // 帳號層覆蓋(AccountConfig 無 ExtractorExplicit,那是機器層旗標)
GeminiAPIKey: "account-key", // 帳號層覆蓋
LLMModel: "model-acc", // 帳號層覆蓋
}
sub := parent.makeAccountSubConfig(acc)
if sub.Extractor != "gemma" {
t.Errorf("① 帳號層 Extractor 應優先,got %q", sub.Extractor)
}
if sub.GeminiAPIKey != "account-key" {
t.Errorf("① 帳號層 GeminiAPIKey 應優先,got %q", sub.GeminiAPIKey)
}
if sub.LLMModel != "model-acc" {
t.Errorf("① 帳號層 LLMModel 應優先,got %q", sub.LLMModel)
}
}
// t126②:帳號層空字串不算「有值」,應繼承機器層。
func TestMakeAccountSubConfigExtractorFallback(t *testing.T) {
parent := &DirectConfig{
Manifest: "/tmp/m.json",
Extractor: "gemma", ExtractorExplicit: true,
GeminiAPIKey: "machine-key",
LLMModel: "model-machine",
PollSec: 5,
MaxRemoved: DefaultMaxRemovedRatio,
}
acc := AccountConfig{
CypherURL: "https://a.example",
Namespace: "ns",
WatchFolders: []string{"/tmp"},
// Extractor/GeminiAPIKey/LLMModel 全空 → 繼承機器層
}
sub := parent.makeAccountSubConfig(acc)
if sub.Extractor != "gemma" {
t.Errorf("② 帳號層空時應繼承機器層 Extractorgot %q", sub.Extractor)
}
if sub.GeminiAPIKey != "machine-key" {
t.Errorf("② 帳號層空時應繼承機器層 GeminiAPIKeygot %q", sub.GeminiAPIKey)
}
if sub.LLMModel != "model-machine" {
t.Errorf("② 帳號層空時應繼承機器層 LLMModelgot %q", sub.LLMModel)
}
}
// t126③ 遷移:機器層金鑰複製到沒有金鑰的帳號;已有金鑰的帳號不覆蓋。
func TestLoadDirectConfigMigratesCopyKeyToAccounts(t *testing.T) {
dir := t.TempDir()
p := writeDirectConfig(t, dir, map[string]any{
"manifest": filepath.Join(dir, "m.json"),
"extractor": "gemma",
"extractor_explicit": true, // t182:主動選過 ⇒ 不被抹成 workers-ai
"gemini_api_key": "top-level-key",
"llm_model": "model-x",
"accounts": []map[string]any{
{
"cypher_url": "https://a.example",
"namespace": "nsA",
"watch_folders": []string{"/tmp/a"},
// 沒有 gemini_api_key → 應複製頂層
},
{
"cypher_url": "https://b.example",
"namespace": "nsB",
"watch_folders": []string{"/tmp/b"},
"gemini_api_key": "b-own-key", // 已有自己的 key → 不覆蓋
},
},
})
cfg, err := LoadDirectConfig(p)
if err != nil {
t.Fatalf("LoadDirectConfig: %v", err)
}
// account A:無 key → 應複製頂層
if cfg.Accounts[0].GeminiAPIKey != "top-level-key" {
t.Errorf("③ account A 應複製頂層 keygot %q", cfg.Accounts[0].GeminiAPIKey)
}
// t182:加 extractor_explicit(見下方 writeDirectConfig)=使用者主動選過 Gemini
// ⇒ 不觸發 workers-ai 抹除,t126 的「複製頂層 extractor」行為照舊可測。
if cfg.Accounts[0].Extractor != "gemma" {
t.Errorf("③ account A 應複製頂層 extractorgot %q", cfg.Accounts[0].Extractor)
}
if cfg.Accounts[0].LLMModel != "model-x" {
t.Errorf("③ account A 應複製頂層 llm_modelgot %q", cfg.Accounts[0].LLMModel)
}
// account B:已有自己的 key → 不覆蓋
if cfg.Accounts[1].GeminiAPIKey != "b-own-key" {
t.Errorf("③ account B 已有 key 不應被頂層覆蓋,got %q", cfg.Accounts[1].GeminiAPIKey)
}
// 頂層保留(複製非搬移)
if cfg.GeminiAPIKey != "top-level-key" {
t.Errorf("③ 頂層 key 應保留(複製非搬移),got %q", cfg.GeminiAPIKey)
}
}
// t126④ 冪等:帳號已有 extractor/key 時再跑遷移不覆蓋。
func TestLoadDirectConfigMigrationIdempotent(t *testing.T) {
dir := t.TempDir()
p := writeDirectConfig(t, dir, map[string]any{
"manifest": filepath.Join(dir, "m.json"),
"extractor": "gemma",
"extractor_explicit": true, // t182:主動選過 ⇒ 不被抹成 workers-ai
"gemini_api_key": "top-key",
"accounts": []map[string]any{
{
"cypher_url": "https://a.example",
"namespace": "nsA",
"watch_folders": []string{"/tmp/a"},
"extractor": "claude", // 帳號已有 → 不應被頂層 "gemma" 覆蓋
"gemini_api_key": "own-key", // 帳號已有 → 不應被頂層 key 覆蓋
},
},
})
cfg, err := LoadDirectConfig(p)
if err != nil {
t.Fatalf("LoadDirectConfig: %v", err)
}
if cfg.Accounts[0].Extractor != "claude" {
t.Errorf("④ 帳號已有 extractor 不應被頂層覆蓋,got %q", cfg.Accounts[0].Extractor)
}
if cfg.Accounts[0].GeminiAPIKey != "own-key" {
t.Errorf("④ 帳號已有 key 不應被頂層覆蓋,got %q", cfg.Accounts[0].GeminiAPIKey)
}
}
func TestExpandHomeEdgeCases(t *testing.T) {
home, err := os.UserHomeDir()
if err != nil || home == "" {
t.Skip("拿不到家目錄,跳過")
}
cases := map[string]string{
"~": home,
"~/a/b": filepath.Join(home, "a/b"),
"/abs/path": "/abs/path",
"relative/dir": "relative/dir",
"": "",
"~notme/x": "~notme/x", // 只認 `~/``~user` 形式不猜(Go 無法解析他人家目錄)
}
for in, want := range cases {
if got := expandHome(in); got != want {
t.Errorf("expandHome(%q)%qwant %q", in, got, want)
}
}
}