Files
arcrun-collector/tidy_test.go
T
Leo 2f33324d3f fix(collector): #60 監看的是筆記庫底下的子資料夾時,保護整個失效
真正的根因不是「vault 判斷漏了子庫」,是**判斷的方向搞反了**:
前兩輪問的都是「監看根**這一層**是不是 vault」,但 daemon 的產物一律落在
監看根底下——這兩件事只有在「監看根 == 庫根」時才等價,而那正好是前兩輪
唯一測過、也唯一不會出事的擺法。

使用者只要把庫底下的某一層加進監看(`KB/docs`、`KB/pages`、Obsidian 庫裡的
某個專案夾——很自然的用法),DetectVaultType 就回 VaultNone,整套保護退回
一般資料夾模式,卡片落在 `<監看根>/system-dev/wiki/cards/`:那個路徑就在
使用者的 graph 裡面,而且看得見,Logseq/Obsidian 每一張卡都收編成一頁。
前綴(第二輪)只擋得住撞名,擋不住「多出一堆機器頁」。

改法:把「這一層是不是庫」與「我寫的東西會不會落進誰的庫」拆成兩個判準。

- vault.go:新增 DetectVaultContext(往上找到最近的庫根)與 VaultDirUnder
  (往下擋:寫入目標會不會踩進子庫)。DetectVaultType 一字未改,繼續與
  install.sh 對齊——往上找用較嚴的判準(logseq/ 要有 config.edn 或
  journals//pages/ 佐證),因為那是替使用者猜、而且一次猜好幾層。
  停在家目錄與檔案系統根,避免 `~/logseq` 這種常見資料夾把整個家目錄判成庫。
- extract.go:cardsRelDirFor 改用 DetectVaultContext。
- safewrite.go:落卡前過 ensureWritable 機械閘——目標踩進子庫就中止,
  不靜靜寫進去。今天不會觸發,它防的是以後新增的寫檔點。
- tidy.go:收拾判準從「有沒有帶標記」擴充成「位置對不對 + 有沒有帶標記」,
  舊版留在看得見位置的卡會被搬進隱藏目錄;MigrateCardNames 每輪自動做,
  使用者不必下任何指令。報告多一個 VaultRoot,說清楚是誰的庫。

leo 派工單上的線索(庫在監看根**底下**)實測不成立:產物一律錨在監看根,
不會落進子庫。但那個「本來就沒破」原本沒有任何機制保證,所以照樣把兩種
格式的子庫情境永久寫進測試,加上 ensureWritable 當第二道保險。

驗證缺口(票上第 6 條):第二輪的足跡測試方向是對的,漏的是**觀測窗**——
snapshotTree 只拍監看根,而災情發生在監看根外面、庫裡面;且 fixture 只有
`root := vault` 一種擺法,測試與被測程式犯了同一個假設,所以永遠是綠的。
vault_subdir_test.go 把快照邊界改成筆記庫,並把「監看根與庫根的關係」升格
成測試維度(庫在上/庫在下/庫就是它/沒有庫 × Logseq/Obsidian)。

全程只用 t.TempDir() 與 mktemp -d;沒碰任何真實筆記庫、沒重啟任何 daemon。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 18:23:49 +08:00

263 lines
9.1 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.
// tidy_test.go — arcrun-rag#60 驗收條件③:已經寫進去的舊產物要有辦法一次認出來/改名。
package collector
import (
"os"
"path/filepath"
"sort"
"strings"
"testing"
)
func TestMarkName_Idempotent(t *testing.T) {
if got := MarkName("status.md"); got != "arcrun-status.md" {
t.Fatalf("MarkName=%q", got)
}
// 冪等:daemon 每輪自動跑、tidy 也可能重跑,不准疊成 arcrun-arcrun-。
twice := MarkName(MarkName("status.md"))
if twice != "arcrun-status.md" {
t.Fatalf("重複加標記疊起來了:%q", twice)
}
if !IsMarked("arcrun-x.md") || IsMarked("x.md") {
t.Fatal("IsMarked 判斷錯")
}
if got := UnmarkName("arcrun-x.md"); got != "x.md" {
t.Fatalf("UnmarkName=%q", got)
}
}
// 舊產物:卡片與備份檔都要被認出來並改名;使用者自己的檔一個都不准動。
func TestTidy_RenamesLegacyCardsOnly(t *testing.T) {
root := t.TempDir()
mustMkdir(t, filepath.Join(root, "logseq"))
// 使用者自己的東西(絕對不准動)
mine := map[string]string{
"journals/2026_08_10.md": "我的日記",
"pages/讀書筆記.md": "我的筆記",
}
for rel, body := range mine {
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 落下的產物(兩個卡片目錄各放一份,加一個備份檔)
legacy := []string{
".arcrun-rag/wiki/cards/2026_08_10.md",
".arcrun-rag/wiki/cards/2026_08_10.md.bak-1723459200000000000",
"system-dev/wiki/cards/會議記錄.md",
}
for _, rel := range legacy {
p := filepath.Join(root, filepath.FromSlash(rel))
mustMkdir(t, filepath.Dir(p))
if err := os.WriteFile(p, []byte("機器寫的"), 0o644); err != nil {
t.Fatal(err)
}
}
// ① dry-run:什麼都不該動
rep, err := Tidy(root, false)
if err != nil {
t.Fatal(err)
}
for _, rel := range legacy {
if _, serr := os.Stat(filepath.Join(root, filepath.FromSlash(rel))); serr != nil {
t.Fatalf("dry-run 竟然動了檔案:%s 不見了", rel)
}
}
// 🔴 第三輪(2026-08-12)改了這裡的期望,這是**刻意的行為變更**:
//
// 本 fixture 的 root 是 vault,卻有一張卡躺在 `system-dev/wiki/cards/`——那是
// **看得見的**目錄,Logseq/Obsidian 會把它當成一頁。第二輪只把它就地改名
// (加上 arcrun- 前綴)就算收拾完,於是「不撞名」達成了、「不要在使用者的
// 筆記庫裡多出機器頁面」沒達成。改名救不了位置。
// ⇒ 現在位置不對的卡一律**搬**進隱藏的 vaultCardsRelDir,動作是 would-move。
// 已經在隱藏目錄、只是少個前綴的,仍然是就地 would-rename(行為不變)。
var previewed []string
for _, it := range rep.Items {
previewed = append(previewed, it.Rel+" → "+it.To+""+it.Action+"")
if it.Action != TidyActionWillRename && it.Action != TidyActionWillMove {
t.Fatalf("dry-run 出現非預期動作:%+v", it)
}
if strings.HasPrefix(it.To, cardsRelDir+"/") {
t.Fatalf("vault 裡的卡片被留在看得見的目錄:%+v", it)
}
}
sort.Strings(previewed)
t.Logf("dry-run 預覽:\n %s", strings.Join(previewed, "\n "))
if len(rep.Items) != len(legacy) {
t.Fatalf("認出來的舊產物數量不對:got %d want %d%+v", len(rep.Items), len(legacy), rep.Items)
}
// ② --apply:真的改名
if _, err := Tidy(root, true); err != nil {
t.Fatal(err)
}
want := []string{
".arcrun-rag/wiki/cards/arcrun-2026_08_10.md",
".arcrun-rag/wiki/cards/arcrun-2026_08_10.md.bak-1723459200000000000",
// 第三輪:這一張本來在 system-dev/wiki/cards/(看得見),現在搬進隱藏目錄。
".arcrun-rag/wiki/cards/arcrun-會議記錄.md",
}
for _, rel := range want {
if _, serr := os.Stat(filepath.Join(root, filepath.FromSlash(rel))); serr != nil {
t.Fatalf("改名後的檔案不存在:%s", rel)
}
}
for _, rel := range legacy {
if _, serr := os.Stat(filepath.Join(root, filepath.FromSlash(rel))); serr == nil {
t.Fatalf("舊名字還在——應該是改名不是複製:%s", rel)
}
}
// 使用者的檔案原封不動
for rel, body := range mine {
data, rerr := os.ReadFile(filepath.Join(root, filepath.FromSlash(rel)))
if rerr != nil {
t.Fatalf("使用者的檔案不見了:%s%v", rel, rerr)
}
if string(data) != body {
t.Fatalf("使用者的檔案被改了:%s → %q", rel, data)
}
}
// ③ 冪等:再跑一次沒有東西可做
rep3, err := Tidy(root, true)
if err != nil {
t.Fatal(err)
}
if len(rep3.Items) != 0 {
t.Fatalf("第二次跑不該再有動作:%+v", rep3.Items)
}
}
// vault 裡的 template 殘留(status.mdmistakes.md 那批)=搬走,不刪。
func TestTidy_VaultTemplateLeftoversAreMovedNotDeleted(t *testing.T) {
root := t.TempDir()
mustMkdir(t, filepath.Join(root, "logseq"))
leftovers := map[string]string{
"system-dev/wiki/status.md": "template 的 status",
"system-dev/wiki/mistakes.md": "template 的 mistakes",
"scripts/sdd-active-check.sh": "#!/bin/sh",
}
for rel, body := range leftovers {
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)
}
}
rep, err := Tidy(root, true)
if err != nil {
t.Fatal(err)
}
moved := 0
for _, it := range rep.Items {
if it.Kind == TidyKindTemplate {
if it.Action != TidyActionMoved {
t.Fatalf("vault 裡的 template 殘留應該被搬走:%+v", it)
}
moved++
}
}
if moved != len(leftovers) {
t.Fatalf("搬走的數量不對:got %d want %d", moved, len(leftovers))
}
for rel, body := range leftovers {
// 原位不該還在
if _, serr := os.Stat(filepath.Join(root, filepath.FromSlash(rel))); serr == nil {
t.Fatalf("原位的殘留還在:%s", rel)
}
// 但**內容必須還找得到**——本指令從不刪東西
p := filepath.Join(root, filepath.FromSlash(legacyTemplateRelDir), filepath.FromSlash(rel))
data, rerr := os.ReadFile(p)
if rerr != nil {
t.Fatalf("搬過去的檔案讀不到(東西被弄丟了?):%s(%v)", p, rerr)
}
if string(data) != body {
t.Fatalf("搬移過程中內容變了:%s", rel)
}
}
}
// 非 vault(很可能是使用者自己的開發 repo)=template 只列出來,一個都不准動。
func TestTidy_PlainFolderTemplateOnlyReported(t *testing.T) {
root := t.TempDir()
rel := "system-dev/wiki/status.md"
p := filepath.Join(root, filepath.FromSlash(rel))
mustMkdir(t, filepath.Dir(p))
if err := os.WriteFile(p, []byte("這是我 repo 裡真正的 status"), 0o644); err != nil {
t.Fatal(err)
}
rep, err := Tidy(root, true) // 即使 --apply 也不准動
if err != nil {
t.Fatal(err)
}
if len(rep.Items) != 1 || rep.Items[0].Action != TidyActionReport {
t.Fatalf("非 vault 的 template 應該只列出不動手:%+v", rep.Items)
}
data, rerr := os.ReadFile(p)
if rerr != nil || string(data) != "這是我 repo 裡真正的 status" {
t.Fatalf("非 vault 的 template 被動了:%v / %q", rerr, data)
}
}
// 目的地已經有同名檔=跳過並回報,絕不覆蓋(safewrite.go 同一條紅線)。
func TestTidy_DoesNotClobberExistingTarget(t *testing.T) {
root := t.TempDir()
cardDir := filepath.Join(root, filepath.FromSlash(cardsRelDir))
mustMkdir(t, cardDir)
if err := os.WriteFile(filepath.Join(cardDir, "x.md"), []byte("舊的"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(cardDir, "arcrun-x.md"), []byte("新的"), 0o644); err != nil {
t.Fatal(err)
}
rep, err := Tidy(root, true)
if err != nil {
t.Fatal(err)
}
if len(rep.Items) != 1 || rep.Items[0].Action != TidyActionSkipped {
t.Fatalf("目的地已存在時應跳過:%+v", rep.Items)
}
if data, _ := os.ReadFile(filepath.Join(cardDir, "arcrun-x.md")); string(data) != "新的" {
t.Fatalf("既有目標被覆蓋了:%q", data)
}
if data, _ := os.ReadFile(filepath.Join(cardDir, "x.md")); string(data) != "舊的" {
t.Fatalf("來源被動了:%q", data)
}
}
// MigrateCardNamesdaemon 每輪自動跑的那個)只碰卡片產物區。
func TestMigrateCardNames_TouchesOnlyCardDirs(t *testing.T) {
root := t.TempDir()
mustMkdir(t, filepath.Join(root, "logseq"))
// 卡片區的舊卡(該改名)
cardDir := filepath.Join(root, filepath.FromSlash(vaultCardsRelDir))
mustMkdir(t, cardDir)
if err := os.WriteFile(filepath.Join(cardDir, "舊卡.md"), []byte("c"), 0o644); err != nil {
t.Fatal(err)
}
// template 殘留(自動路不准碰——那要人確認是不是他自己的 repo)
tplDir := filepath.Join(root, "system-dev", "wiki")
mustMkdir(t, tplDir)
if err := os.WriteFile(filepath.Join(tplDir, "status.md"), []byte("s"), 0o644); err != nil {
t.Fatal(err)
}
if n := MigrateCardNames(root); n != 1 {
t.Fatalf("改名筆數=%dwant 1", n)
}
if _, err := os.Stat(filepath.Join(cardDir, "arcrun-舊卡.md")); err != nil {
t.Fatalf("舊卡沒被改名:%v", err)
}
if _, err := os.Stat(filepath.Join(tplDir, "status.md")); err != nil {
t.Fatalf("自動路不該碰 template 殘留,但它不見了:%v", err)
}
}