Files
arcrun-collector/vault_subdir_test.go
T
Leo 12c2d41886 feat(collector): daemon 產出改成規範形 wiki 卡——1 文件卡+N 原子卡落 .wiki/(InkStoneCo#44 ④)
LLM 只回 JSON 判斷,格式/落點/連結閉合/索引/manifest 全由 wikishape.go 機械組裝:
- 卡形=frontmatter(tags/gloss/created/updated)+← 上層+摘要/重點/實體(帶類型)/
  關聯(內文知識關係/卡片關係/出處)——差距表 #6
- 落點 <節點>/.wiki/、檔名=H1(.wiki 隱藏目錄自身即機器標記,machinemark 例外③)——#7
- 00-INDEX 機械維護:每行五樣照抄 frontmatter;每份原稿必列——#8
- 萃取端就切 1 hub+N 原子卡(gemma 路 prompt 改 JSON 契約)——#9
- 無可萃概念標「空」+理由,上索引不產卡——#10
機械保證對著 wiki-lint.py 寫:斷連結拆殼、index 式句改寫、三元組恰三項、
雙向邊自動補反向、佔用不覆蓋、原稿永不動;同名概念跨文件先消歧(merge 歸第⑤環)。
scan/convert 白名單補 .feature/.yaml/.yml/.org/.rst(規範洞 6);
lint.go 新舊格式雙軌(workers-ai 雲端 prompt 屬 matrix/arcrun 核心,待開票);
direct.go 只送文件卡上雲(takedown 配對鍵不變)、下架收走 .wiki 產物。

實測:真資料夾+真 Gemini → 2 節點 12 卡,wiki-lint 17/17 exit 0,
原稿 sha256 前後不變;go test collector+supervisor+arcrun-app 全綠(新增 9 案)。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 21:03:45 +08:00

531 lines
22 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.
// vault_subdir_test.go — arcrun-rag#60 第三輪(2026-08-12)。
//
// ══════════════════════════════════════════════════════════════════════════
// 前兩輪的驗法為什麼沒抓到這一個(票上第 6 條驗收,必答)
// ══════════════════════════════════════════════════════════════════════════
//
// 第二輪已經做對了一件很重要的事:把驗法從「點測某個函式回傳什麼」換成
// **足跡測試**(跑之前 walk 一次、跑之後 walk 一次,比對多出什麼、動到什麼)。
// 那張網對「寫檔點的數量」免疫——第三輪這個洞不是它漏抓,是**它看不到**。
//
// 漏的是另外兩件事,兩件都不是「網目太粗」,是**網架錯了地方**:
//
// ① 觀測窗 == 監看根。snapshotTree(root) 只 walk 監看根底下。
// 而這一輪的災情發生在**監看根的外面、vault 的裡面**:監看根是
// `KB/docs` 時,卡片落在 `KB/docs/system-dev/wiki/cards/`——在監看根裡面沒錯,
// 但它同時也在 `KB` 這個 Logseq graph 裡面,而 Logseq 是**遞迴索引整個 graph** 的。
// 要看見「使用者的筆記庫多了幾頁」,快照的邊界必須是**筆記庫**,不是監看根。
// ⇒ 本檔的 fixture 一律回傳 vault 根,快照也一律拍 vault 根。
//
// ② fixture 只有一種擺法。前兩輪每一支測試都是 `root := vault`(監看根就是庫根),
// 而那正好是唯一不會出事的擺法。判準函式只看一層,fixture 也只擺一層——
// **測試與被測程式犯了同一個假設**,所以它永遠是綠的。
// ⇒ 本檔把「監看根與庫根的關係」升格成測試維度,四種都擺:
// 庫在上面/庫在下面/庫就是它/完全沒有庫;每種再乘 Logseq 與 Obsidian。
//
// 一句話:**第二輪問「daemon 多寫了什麼」,第三輪問「使用者的筆記庫多了什麼」。**
// 後者才是 leo 打開 Logseq 時真正看到的東西。
package collector
import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"sort"
"strings"
"testing"
)
// ─────────────────────────────────────────────────────────────────────────────
// 判準層:DetectVaultContext / VaultDirUnder
// ─────────────────────────────────────────────────────────────────────────────
// makeLogseqVault 造一個像樣的 Logseq graph(有 logseq/config.edn + journals/ + pages/)。
func makeLogseqVault(t *testing.T, dir string) {
t.Helper()
mustMkdir(t, filepath.Join(dir, "logseq"))
mustMkdir(t, filepath.Join(dir, "journals"))
mustMkdir(t, filepath.Join(dir, "pages"))
if err := os.WriteFile(filepath.Join(dir, "logseq", "config.edn"),
[]byte("{:default-home {:page \"contents\"}}"), 0o644); err != nil {
t.Fatal(err)
}
}
func makeObsidianVault(t *testing.T, dir string) {
t.Helper()
mustMkdir(t, filepath.Join(dir, ".obsidian"))
if err := os.WriteFile(filepath.Join(dir, ".obsidian", "app.json"), []byte("{}"), 0o644); err != nil {
t.Fatal(err)
}
}
// 監看根就是庫根=前兩輪處理好的那條路,不能被這次改動弄壞。
func TestDetectVaultContext_SelfIsVault(t *testing.T) {
for _, tc := range []struct {
name string
make func(*testing.T, string)
want VaultType
}{
{"logseq", makeLogseqVault, VaultLogseq},
{"obsidian", makeObsidianVault, VaultObsidian},
} {
t.Run(tc.name, func(t *testing.T) {
root := t.TempDir()
tc.make(t, root)
ctx := DetectVaultContext(root)
if ctx.Type != tc.want || !ctx.Self || ctx.Root != root {
t.Fatalf("ctx=%+vwant Type=%q Self=true Root=%q", ctx, tc.want, root)
}
})
}
}
// 🔴 第三輪的核心:庫在**上面**,監看根是它底下的子資料夾。
func TestDetectVaultContext_RootInsideVault(t *testing.T) {
for _, tc := range []struct {
name string
make func(*testing.T, string)
want VaultType
}{
{"logseq", makeLogseqVault, VaultLogseq},
{"obsidian", makeObsidianVault, VaultObsidian},
} {
t.Run(tc.name, func(t *testing.T) {
base := t.TempDir()
vault := filepath.Join(base, "KB")
mustMkdir(t, vault)
tc.make(t, vault)
// 一層、兩層都要認得——使用者可能監看 `KB/docs`,也可能監看 `KB/專案/A`。
for _, sub := range []string{"docs", filepath.Join("專案", "A")} {
watch := filepath.Join(vault, sub)
mustMkdir(t, watch)
ctx := DetectVaultContext(watch)
if ctx.Type != tc.want {
t.Fatalf("%sctx.Type=%qwant %q(監看根在 vault 裡卻沒被認出來)", sub, ctx.Type, tc.want)
}
if ctx.Self {
t.Fatalf("%sSelf 應為 false(庫根是上層的 %s", sub, vault)
}
if ctx.Root != vault {
t.Fatalf("%sctx.Root=%qwant %q", sub, ctx.Root, vault)
}
if got := cardsRelDirFor(watch); got != vaultCardsRelDir {
t.Fatalf("%s:卡片會落在 %q——那是可見目錄,筆記軟體會把它當成頁面", sub, got)
}
}
})
}
}
// 回歸:沒有任何庫的普通資料夾,行為一個字都不能變。
func TestDetectVaultContext_PlainFolder_Unchanged(t *testing.T) {
base := t.TempDir()
watch := filepath.Join(base, "報告", "2026")
mustMkdir(t, watch)
if ctx := DetectVaultContext(watch); ctx.InVault() {
t.Fatalf("普通資料夾被誤判成在 vault 裡:%+v", ctx)
}
if got := cardsRelDirFor(watch); got != cardsRelDir {
t.Fatalf("cardsRelDirFor=%qwant %q(一般資料夾行為必須零改變)", got, cardsRelDir)
}
}
// 往上找要有佐證:光有一個叫 logseq 的資料夾不算數。
//
// 為什麼這條重要:`~/logseq`、專案裡放匯出檔的 `logseq/` 都很常見。沒有這條,
// 那些人底下**每一個**監看根都會突然改變行為(卡片全部變隱藏)。
func TestDetectVaultContext_BareLogseqDirUpstairs_NeedsCorroboration(t *testing.T) {
base := t.TempDir()
fake := filepath.Join(base, "某個專案")
mustMkdir(t, filepath.Join(fake, "logseq")) // 只有名字,沒有 config.edn/journals/pages
watch := filepath.Join(fake, "sub")
mustMkdir(t, watch)
if ctx := DetectVaultContext(watch); ctx.InVault() {
t.Fatalf("只有一個叫 logseq 的空資料夾就被判成筆記庫:%+v", ctx)
}
// 補上 config.edn 之後就該認得了(真的 graph 一定有)。
if err := os.WriteFile(filepath.Join(fake, "logseq", "config.edn"), []byte("{}"), 0o644); err != nil {
t.Fatal(err)
}
if ctx := DetectVaultContext(watch); ctx.Type != VaultLogseq {
t.Fatalf("有 config.edn 了還是沒認出來:%+v", ctx)
}
}
// 監看根**自己**這一層仍走 install.sh 的寬鬆判準(兩邊必須同一個答案,vault.go 開頭的鐵律)。
func TestDetectVaultContext_SelfKeepsInstallShRule(t *testing.T) {
root := t.TempDir()
mustMkdir(t, filepath.Join(root, "logseq")) // 只有 logseq/,沒有佐證
if got := DetectVaultType(root); got != VaultLogseq {
t.Fatalf("DetectVaultType=%qwant logseq(這一層的判準要與 install.sh 逐字一致)", got)
}
if ctx := DetectVaultContext(root); ctx.Type != VaultLogseq || !ctx.Self {
t.Fatalf("ctx=%+vwant logseq/Self(自己這一層不該套往上找的嚴格判準)", ctx)
}
}
// VaultDirUnder=往下擋:目標路徑中途踩進子筆記庫要被認出來。
func TestVaultDirUnder(t *testing.T) {
root := t.TempDir()
sub := filepath.Join(root, "KB")
makeLogseqVault(t, sub)
if dir, vt := VaultDirUnder(root, filepath.Join(sub, "pages", "x.md")); vt != VaultLogseq || dir != sub {
t.Fatalf("VaultDirUnder=(%q,%q)want (%q,logseq)", dir, vt, sub)
}
// 正常落卡路徑(錨在監看根)不該命中。
safe := filepath.Join(root, filepath.FromSlash(cardsRelDir), "arcrun-x.md")
if dir, vt := VaultDirUnder(root, safe); vt != VaultNone {
t.Fatalf("正常落卡路徑被誤擋:(%q,%q)", dir, vt)
}
}
// safeWriteCard 真的擋得住(機械閘,不是只有註解)。
func TestSafeWriteCard_RefusesToWriteIntoSubVault(t *testing.T) {
root := t.TempDir()
sub := filepath.Join(root, "KB")
makeLogseqVault(t, sub)
dest := filepath.Join(sub, "pages", "arcrun-偷渡.md")
err := safeWriteCard(root, dest, []byte("# 偷渡"))
if err == nil {
t.Fatal("寫進子筆記庫竟然成功了——機械閘沒有作用")
}
if !strings.Contains(err.Error(), "筆記庫") {
t.Fatalf("錯誤訊息看不出原因:%v", err)
}
if _, serr := os.Stat(dest); serr == nil {
t.Fatal("擋下來了卻還是留下了檔案")
}
}
// ─────────────────────────────────────────────────────────────────────────────
// 足跡層:跑一輪真的同步,用**筆記庫**當觀測窗(不是監看根)
// ─────────────────────────────────────────────────────────────────────────────
// runSyncOnce 跑一輪 direct 同步(全程假伺服器,不出網、不碰任何真實筆記庫)。
func runSyncOnce(t *testing.T, watch string) {
t.Helper()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_ = json.NewEncoder(w).Encode(map[string]any{"success": true})
}))
defer srv.Close()
defer gemmaEchoStub(t)()
cfg := &DirectConfig{
WatchFolders: []string{watch},
Manifest: filepath.Join(t.TempDir(), "m.json"),
CypherURL: srv.URL, Namespace: "demo", APIKey: "demo",
Library: "kb", Extractor: "gemma", ExtractorExplicit: true, GeminiAPIKey: "k-test",
CardIngestWF: "rag_ingest_card", MaxRemoved: DefaultMaxRemovedRatio,
}
results, exit, _ := RunDirectOnce(cfg, false)
if exit != 0 {
t.Fatalf("同步失敗 exit=%d results=%+v", exit, results)
}
}
// assertVaultUntouched 是本檔的共用判準,四條一起驗(缺一條這一票就還沒修好):
//
// ① 使用者原本的檔案,一個位元、一個 mtime 都不能變,也不能消失
// ② 新出現的檔案,basename 一律帶 MachineMark(誰寫的,一眼看得出來)
// ③ 新出現的檔案,一律落在隱藏路徑(`/.` 開頭的段)——筆記軟體不會收編成頁面
// ④ 東西照樣要產出(沒產出=把功能關掉,那是紅線不是修好)
func assertVaultUntouched(t *testing.T, vaultDir string, before, after map[string]footprintEntry) []string {
t.Helper()
for rel, want := range before {
got, still := after[rel]
if !still {
t.Fatalf("使用者的檔案不見了:%s", rel)
}
if got.hash != want.hash || got.size != want.size {
t.Fatalf("使用者的檔案被改了:%ssize %d→%d", rel, want.size, got.size)
}
if got.mtime != want.mtime {
t.Fatalf("使用者的檔案 mtime 被動過:%s", rel)
}
}
var added []string
for rel := range after {
if _, existed := before[rel]; !existed {
added = append(added, rel)
}
}
sort.Strings(added)
t.Logf("筆記庫裡新增的檔案(%d 個):%v", len(added), added)
for _, rel := range added {
if !IsMachineOwnedRel(rel) {
t.Fatalf("筆記庫裡多了一個沒帶 %q 標記的檔:%s", MachineMark, rel)
}
if !isHiddenRel(rel) {
t.Fatalf("筆記庫裡多了一個**看得見**的機器產物:%s"+
"Logseq/Obsidian 會遞迴索引整個庫,這就是使用者看到的新頁面)", rel)
}
}
return added
}
// isHiddenRel:路徑上任何一段以 . 開頭=筆記軟體與 daemon 的 scan.go 都不會掃它。
func isHiddenRel(rel string) bool {
for _, seg := range strings.Split(filepath.ToSlash(rel), "/") {
if strings.HasPrefix(seg, ".") {
return true
}
}
return false
}
// 🔴 主戲:庫在上面(監看根是 vault 的子資料夾)。Logseq 與 Obsidian 各驗一次。
//
// 修之前這裡會落下 `docs/system-dev/wiki/cards/arcrun-*.md`——在使用者的 graph 裡、
// 而且看得見,Logseq 每一張都會變成一頁。
func TestVaultFootprint_WatchRootInsideVault(t *testing.T) {
for _, tc := range []struct {
name string
make func(*testing.T, string)
}{
{"logseq", makeLogseqVault},
{"obsidian", makeObsidianVault},
} {
t.Run(tc.name, func(t *testing.T) {
base := t.TempDir()
vault := filepath.Join(base, "我的筆記庫")
watch := filepath.Join(vault, "docs")
mustMkdir(t, watch)
tc.make(t, vault)
// 使用者自己的東西(含日期檔——那是最會撞名的一類)。
writeFixture(t, vault, map[string]string{
"journals/2026_08_12.md": "- 我自己寫的日記,不准被動到",
"pages/讀書筆記.md": "- 我自己的頁面",
// 🔴 同名情境(票上驗收條件③):故意在庫裡先擺一個「跟機器產物會撞名」的檔。
// 機器萃 docs/會議記錄.md 會產出名為 `arcrun-會議記錄.md` 的卡,
// 這裡先把那個名字佔走,看它會不會被覆蓋。
"pages/arcrun-會議記錄.md": "- 這個檔名是我先取的,機器不准碰",
"docs/會議記錄.md": "# 要被萃取的原稿",
"docs/2026_08_12.md": "# 用日期當檔名的原稿",
})
before := snapshotTree(t, vault)
logTree(t, tc.name+"・跑之前(整個筆記庫)", before)
runSyncOnce(t, watch)
after := snapshotTree(t, vault)
logTree(t, tc.name+"・跑之後(整個筆記庫)", after)
added := assertVaultUntouched(t, vault, before, after)
if len(added) == 0 {
t.Fatal("這一輪什麼都沒產出——紅線是「東西照樣產出、但不撞進使用者的命名空間」")
}
// 產物一律落在兩個隱藏目錄底下(都在監看根裡):
// `docs/.arcrun-rag/`=工作區(.gitignore 等)
// `docs/.wiki/`wiki 產物(InkStoneCo#44 ④ 起:卡片+00-INDEXmanifest
workspacePrefix := filepath.ToSlash(filepath.Join("docs", workspaceRelDir)) + "/"
wikiPrefix := filepath.ToSlash(filepath.Join("docs", wikiRelDir)) + "/"
for _, rel := range added {
if !strings.HasPrefix(rel, workspacePrefix) && !strings.HasPrefix(rel, wikiPrefix) {
t.Fatalf("產物 %s 不在預期的隱藏目錄(%s 或 %s)底下", rel, workspacePrefix, wikiPrefix)
}
if strings.HasSuffix(rel, ".md") && !strings.HasPrefix(rel, wikiPrefix) {
t.Fatalf("卡片 %s 不在預期的隱藏 wiki 區 %s 底下", rel, wikiPrefix)
}
}
})
}
}
// 🔴 leo 派工單上的那條線索:庫在**下面**(監看根是普通資料夾,底下某一層是 vault)。
//
// 這一支的結論是「本來就沒破」——產物一律錨在監看根,不會落進子庫。
// 但它必須永久留在測試裡:那個「本來就沒破」目前**沒有任何機制保證**,
// 只要有人加一個「把卡寫在原稿旁邊」的寫檔點就會破,而那是很自然的想法。
// 這支測試 safeWriteCard 的機械閘是它的兩道保險。
func TestVaultFootprint_SubVaultBelowWatchRoot(t *testing.T) {
for _, tc := range []struct {
name string
make func(*testing.T, string)
}{
{"logseq", makeLogseqVault},
{"obsidian", makeObsidianVault},
} {
t.Run(tc.name, func(t *testing.T) {
watch := t.TempDir()
vault := filepath.Join(watch, "我的筆記庫")
mustMkdir(t, vault)
tc.make(t, vault)
writeFixture(t, watch, map[string]string{
"我的筆記庫/journals/2026_08_12.md": "- 我自己寫的日記,不准被動到",
"我的筆記庫/pages/讀書筆記.md": "- 我自己的頁面",
// 同名情境:庫裡先擺一個會跟機器產物撞名的檔。
"我的筆記庫/pages/arcrun-讀書筆記.md": "- 這個名字是我先取的",
"我的筆記庫/會議記錄.md": "# 庫裡的原稿",
"外面的報告.md": "# 監看根上的原稿",
})
beforeVault := snapshotTree(t, vault)
beforeAll := snapshotTree(t, watch)
logTree(t, tc.name+"・跑之前(子筆記庫)", beforeVault)
runSyncOnce(t, watch)
afterVault := snapshotTree(t, vault)
afterAll := snapshotTree(t, watch)
logTree(t, tc.name+"・跑之後(子筆記庫)", afterVault)
logTree(t, tc.name+"・跑之後(整個監看根)", afterAll)
// 子庫裡:一個檔都不准多、一個檔都不准變。
for rel, want := range beforeVault {
got, still := afterVault[rel]
if !still {
t.Fatalf("子筆記庫裡的檔案不見了:%s", rel)
}
if got.hash != want.hash || got.mtime != want.mtime {
t.Fatalf("子筆記庫裡的檔案被動過:%s", rel)
}
}
for rel := range afterVault {
if _, existed := beforeVault[rel]; !existed {
t.Fatalf("子筆記庫裡多出了機器產物:%sLogseq/Obsidian 會把它當成一頁)", rel)
}
}
// 但東西照樣要產出——落在監看根上,不在子庫裡。
produced := 0
for rel := range afterAll {
if _, existed := beforeAll[rel]; existed {
continue
}
produced++
if !IsMachineOwnedRel(rel) {
t.Fatalf("監看根上多了一個沒帶標記的檔:%s", rel)
}
}
if produced == 0 {
t.Fatal("什麼都沒產出——那是把功能關掉,不是修好")
}
})
}
}
// 回歸:監看根**本身**就是庫(前兩輪修好的那條路)不能被這次改動弄壞。
func TestVaultFootprint_WatchRootIsVault_StillProtected(t *testing.T) {
for _, tc := range []struct {
name string
make func(*testing.T, string)
}{
{"logseq", makeLogseqVault},
{"obsidian", makeObsidianVault},
} {
t.Run(tc.name, func(t *testing.T) {
vault := t.TempDir()
tc.make(t, vault)
writeFixture(t, vault, map[string]string{
"journals/2026_08_12.md": "- 我的日記",
"會議記錄.md": "# 原稿",
})
before := snapshotTree(t, vault)
runSyncOnce(t, vault)
after := snapshotTree(t, vault)
logTree(t, tc.name+"・監看根就是庫・跑之後", after)
if added := assertVaultUntouched(t, vault, before, after); len(added) == 0 {
t.Fatal("什麼都沒產出")
}
})
}
}
// writeFixture 依 rel→內容 造檔(rel 用斜線分隔)。
func writeFixture(t *testing.T, root string, files map[string]string) {
t.Helper()
for rel, body := range files {
p := filepath.Join(root, filepath.FromSlash(rel))
mustMkdir(t, filepath.Dir(p))
if err := os.WriteFile(p, []byte(body), 0o644); err != nil {
t.Fatal(err)
}
}
}
// ─────────────────────────────────────────────────────────────────────────────
// 收拾層:已經寫進去的那一批(票上「額外要處理的」)
// ─────────────────────────────────────────────────────────────────────────────
// 舊版 daemon 在「vault 子資料夾」上留下的可見卡片,下一輪要能自己搬進隱藏目錄。
// 使用者什麼都不必做——紅線:「不准要使用者去設定什麼開關才能保護自己的筆記」。
func TestMigrateCardNames_RelocatesVisibleCardsInsideVault(t *testing.T) {
base := t.TempDir()
vault := filepath.Join(base, "KB")
watch := filepath.Join(vault, "docs")
mustMkdir(t, watch)
makeLogseqVault(t, vault)
// 舊版留下的兩種:帶標記但位置錯(第二輪的產物)、以及完全沒標記(第一輪的產物)。
writeFixture(t, watch, map[string]string{
"system-dev/wiki/cards/arcrun-會議記錄.md": "# 會議記錄",
"system-dev/wiki/cards/2026_08_12.md": "# 2026_08_12",
})
t.Logf("收拾前:%v", relList(t, watch))
mig := MigrateCardNames(watch)
got := relList(t, watch)
t.Logf("收拾後(搬了 %d 筆):%v", mig.Moved, got)
if mig.Moved != 2 {
t.Fatalf("搬了 %d 筆,want 2", mig.Moved)
}
for _, rel := range got {
if !isHiddenRel(rel) {
t.Fatalf("還有看得見的機器產物留在筆記庫裡:%s", rel)
}
if !IsMarked(filepath.Base(rel)) {
t.Fatalf("還有沒帶標記的產物:%s", rel)
}
}
// 冪等:再跑一次不該有任何動作。
if again := MigrateCardNames(watch); again.Moved != 0 {
t.Fatalf("第二次跑又搬了 %d 筆,應為 0(冪等)", again.Moved)
}
}
// tidy 的預覽要說清楚是誰的庫,且**從不刪檔**、目標已存在就不覆蓋。
func TestTidy_ReportsVaultRootForSubdirWatch(t *testing.T) {
base := t.TempDir()
vault := filepath.Join(base, "KB")
watch := filepath.Join(vault, "docs")
mustMkdir(t, watch)
makeLogseqVault(t, vault)
writeFixture(t, watch, map[string]string{
"system-dev/wiki/cards/arcrun-會議記錄.md": "# 會議記錄",
})
rep, err := Tidy(watch, false)
if err != nil {
t.Fatal(err)
}
if rep.VaultType != VaultLogseq || rep.VaultRoot != vault {
t.Fatalf("報告沒認出上層筆記庫:VaultType=%q VaultRoot=%qwant logseq / %s",
rep.VaultType, rep.VaultRoot, vault)
}
if len(rep.Items) != 1 || rep.Items[0].Action != TidyActionWillMove {
t.Fatalf("預覽內容不對:%+v", rep.Items)
}
t.Logf("tidy 預覽:%+v", rep.Items)
}
func relList(t *testing.T, root string) []string {
t.Helper()
var out []string
for rel := range snapshotTree(t, root) {
out = append(out, rel)
}
sort.Strings(out)
return out
}