feat(daemon-beta t1): 多資料夾 config——watch_folders[]+單數相容、manifest 每根一份、多根掃描標 Root、托盤資料層相容;rag-wave1 closed→daemon-beta 實作卷 active

- direct.go: Folders() 正規化(去重保序)/manifestPathFor(單根沿用不丟狀態、多根 sha 尾碼)/RunDirectOnce 多根彙總
- 新測試 4 支全綠(單數相容/多根去重/缺欄驗證/多根 dry-run 標 Root);既有 7 支不動全綠
- tray: config 加 watch_folders 欄(防存檔洗掉)+addWatchFolder 資料層;勾選 UI=task 7
This commit is contained in:
2026-07-24 12:09:40 +08:00
parent 44a9aeddc4
commit b355165180
3 changed files with 236 additions and 32 deletions
+124
View File
@@ -0,0 +1,124 @@
// direct_multi_test.go — daemon-beta task 1 多資料夾 config(單數相容/manifest 分根/多根掃描)。
package main
import (
"encoding/json"
"os"
"path/filepath"
"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() 正規化=一根。
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)
}
// 單根 manifest 沿用原路徑(升級不丟狀態)
if mp := c.manifestPathFor("/tmp/kb"); mp != c.Manifest {
t.Fatalf("單根 manifest 應沿用原路徑,got %s", mp)
}
}
// 只填 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("同路徑尾碼應穩定")
}
}
// 兩欄都空=缺必填。
func TestLoadDirectConfigMissingFolders(t *testing.T) {
dir := t.TempDir()
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.Fatal("兩欄皆空應報缺必填")
}
}
// 多根 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])
}
}