430 lines
14 KiB
Go
430 lines
14 KiB
Go
// cleanup_test.go — 斷連清理的迴歸網(arcrun-rag#138)。
|
||
//
|
||
// 這支測的重點**不是「有沒有刪掉」**,是「**有沒有刪到不該刪的**」。
|
||
// 每一個 case 都對應一種「差一點就誤刪使用者知識」的擺法。
|
||
package collector
|
||
|
||
import (
|
||
"encoding/json"
|
||
"os"
|
||
"path/filepath"
|
||
"sort"
|
||
"strings"
|
||
"testing"
|
||
)
|
||
|
||
func cuWriteFile(t *testing.T, path, body string) {
|
||
t.Helper()
|
||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
}
|
||
|
||
// makeWiki 造一個「長得跟 daemon 產的一模一樣」的 .wiki 目錄。
|
||
func makeWiki(t *testing.T, dir string, cards ...string) {
|
||
t.Helper()
|
||
cuWriteFile(t, filepath.Join(dir, ".gitignore"), wikiIgnoreBody)
|
||
cuWriteFile(t, filepath.Join(dir, "00-INDEX.md"), "# 00-INDEX\n\n## 文件\n")
|
||
for _, c := range cards {
|
||
cuWriteFile(t, filepath.Join(dir, c), "# "+strings.TrimSuffix(c, ".md")+"\n")
|
||
}
|
||
}
|
||
|
||
// makeRootManifest 寫出監看根那一份 `.wiki/manifest.json`(帳本)。
|
||
// nodeCards=節點 →「相對監看根」的卡片路徑,跟 daemon 實際寫的形狀一致
|
||
// (2026-08-24 對照 `pms` 現場的真檔驗過:帳本涵蓋每一張卡,只有 00-INDEX.md 不在裡面)。
|
||
func makeRootManifest(t *testing.T, root string, nodeCards map[string][]string) {
|
||
t.Helper()
|
||
m := &wikiManifest{Version: 1}
|
||
for node, cards := range nodeCards {
|
||
m.Docs = append(m.Docs, wikiDoc{
|
||
Node: nodeKeyOf(node), Path: "來源.md", DocID: "doc-" + node + "-1",
|
||
Status: "extracted", Cards: cards,
|
||
})
|
||
}
|
||
data, err := json.MarshalIndent(m, "", " ")
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
cuWriteFile(t, filepath.Join(root, wikiRelDir, "manifest.json"), string(data)+"\n")
|
||
}
|
||
|
||
func makeWorkspace(t *testing.T, root string) {
|
||
t.Helper()
|
||
cuWriteFile(t, filepath.Join(root, workspaceRelDir, ".gitignore"), workspaceIgnoreBody)
|
||
cuWriteFile(t, filepath.Join(root, workspaceRelDir, "wiki", "cards", "arcrun-資料夾總覽-x.md"), "# x\n")
|
||
}
|
||
|
||
func relsOf(items []CleanupItem) []string {
|
||
out := make([]string, 0, len(items))
|
||
for _, it := range items {
|
||
out = append(out, it.Rel)
|
||
}
|
||
sort.Strings(out)
|
||
return out
|
||
}
|
||
|
||
func keepRels(keeps []CleanupKeep) []string {
|
||
out := make([]string, 0, len(keeps))
|
||
for _, k := range keeps {
|
||
out = append(out, k.Rel)
|
||
}
|
||
sort.Strings(out)
|
||
return out
|
||
}
|
||
|
||
// snapshotTree 記下整棵樹每個檔的相對路徑與內容雜湊——用來證明「使用者的檔一個都沒動」。
|
||
func cuSnapshotTree(t *testing.T, root string) map[string]string {
|
||
t.Helper()
|
||
out := map[string]string{}
|
||
err := filepath.Walk(root, func(p string, info os.FileInfo, err error) error {
|
||
if err != nil || info.IsDir() {
|
||
return nil
|
||
}
|
||
rel, _ := filepath.Rel(root, p)
|
||
data, rerr := os.ReadFile(p)
|
||
if rerr != nil {
|
||
return nil
|
||
}
|
||
out[filepath.ToSlash(rel)] = string(data)
|
||
return nil
|
||
})
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
return out
|
||
}
|
||
|
||
// ── ① 基本盤:多層巢狀的 .wiki 與工作區全部收掉,使用者的原稿一個不少 ──
|
||
|
||
func TestCleanupRemovesNestedProductsKeepsUserFiles(t *testing.T) {
|
||
root := t.TempDir()
|
||
// 使用者的東西
|
||
cuWriteFile(t, filepath.Join(root, "README.md"), "我的說明\n")
|
||
cuWriteFile(t, filepath.Join(root, "docs", "設計.md"), "我的設計\n")
|
||
cuWriteFile(t, filepath.Join(root, "docs", "deep", "更深的.md"), "我的深層檔\n")
|
||
// daemon 的東西(三層)
|
||
makeWorkspace(t, root)
|
||
makeWiki(t, filepath.Join(root, wikiRelDir), "根卡.md")
|
||
makeWiki(t, filepath.Join(root, "docs", wikiRelDir), "設計卡.md")
|
||
makeWiki(t, filepath.Join(root, "docs", "deep", wikiRelDir), "深卡.md")
|
||
makeRootManifest(t, root, map[string][]string{
|
||
"": {".wiki/根卡.md"},
|
||
"docs": {"docs/.wiki/設計卡.md"},
|
||
"docs/deep": {"docs/deep/.wiki/深卡.md"},
|
||
})
|
||
|
||
before := cuSnapshotTree(t, root)
|
||
|
||
plan, res, err := ApplyCleanup(root, nil)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if len(res.Failed) != 0 {
|
||
t.Fatalf("不該有刪不掉的:%+v", res.Failed)
|
||
}
|
||
want := []string{".arcrun-rag", ".wiki", "docs/.wiki", "docs/deep/.wiki"}
|
||
if got := relsOf(plan.Remove); !equalStrings(got, want) {
|
||
t.Fatalf("要刪的清單不對\n got=%v\nwant=%v", got, want)
|
||
}
|
||
|
||
after := cuSnapshotTree(t, root)
|
||
// 使用者的檔:逐一比對「內容一字不差」。
|
||
for _, rel := range []string{"README.md", "docs/設計.md", "docs/deep/更深的.md"} {
|
||
if after[rel] != before[rel] {
|
||
t.Fatalf("使用者的檔被動到了:%s(before=%q after=%q)", rel, before[rel], after[rel])
|
||
}
|
||
}
|
||
// 剩下的檔**只能是**使用者那三個。
|
||
if len(after) != 3 {
|
||
t.Fatalf("清完之後應該只剩使用者的 3 個檔,實際剩 %d:%v", len(after), after)
|
||
}
|
||
for _, hidden := range []string{".wiki", ".arcrun-rag", "docs/.wiki", "docs/deep/.wiki"} {
|
||
if _, err := os.Stat(filepath.Join(root, filepath.FromSlash(hidden))); !os.IsNotExist(err) {
|
||
t.Fatalf("%s 沒被刪掉", hidden)
|
||
}
|
||
}
|
||
}
|
||
|
||
// ── ② 陌生鄰居:.wiki 裡有一個不是我們的檔 ⇒ 整個目錄留下,只刪認得的 ──
|
||
|
||
func TestCleanupKeepsWikiDirWithStrangerFile(t *testing.T) {
|
||
root := t.TempDir()
|
||
wiki := filepath.Join(root, wikiRelDir)
|
||
makeWiki(t, wiki, "卡.md")
|
||
makeRootManifest(t, root, map[string][]string{"": {".wiki/卡.md"}})
|
||
cuWriteFile(t, filepath.Join(wiki, "我自己寫的.md"), "這是我的\n")
|
||
|
||
plan, res, err := ApplyCleanup(root, nil)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
_ = res
|
||
for _, it := range plan.Remove {
|
||
if it.Kind == CleanupKindWikiDir {
|
||
t.Fatalf("有陌生鄰居時不准整個目錄刪掉:%+v", it)
|
||
}
|
||
}
|
||
if _, err := os.Stat(filepath.Join(wiki, "我自己寫的.md")); err != nil {
|
||
t.Fatalf("使用者自己的檔被刪了:%v", err)
|
||
}
|
||
if _, err := os.Stat(wiki); err != nil {
|
||
t.Fatalf(".wiki 目錄不該被刪:%v", err)
|
||
}
|
||
// 帳本以外的檔(00-INDEX 與 .gitignore 是我們的)該刪掉。
|
||
if _, err := os.Stat(filepath.Join(wiki, "00-INDEX.md")); !os.IsNotExist(err) {
|
||
t.Fatalf("00-INDEX.md 應該被刪掉")
|
||
}
|
||
if !contains(keepRels(plan.Keep), ".wiki/我自己寫的.md") {
|
||
t.Fatalf("留下的東西要出現在 Keep 清單上讓使用者看到:%v", keepRels(plan.Keep))
|
||
}
|
||
}
|
||
|
||
// ── ③ 沒有我們宣告的 .wiki(使用者自己的同名目錄)⇒ 一個檔都不碰 ──
|
||
|
||
func TestCleanupIgnoresForeignWikiDir(t *testing.T) {
|
||
root := t.TempDir()
|
||
wiki := filepath.Join(root, wikiRelDir)
|
||
cuWriteFile(t, filepath.Join(wiki, "我的筆記.md"), "我的\n")
|
||
cuWriteFile(t, filepath.Join(wiki, "00-INDEX.md"), "# 00-INDEX\n") // 連名字都一樣也不行
|
||
|
||
before := cuSnapshotTree(t, root)
|
||
plan, _, err := ApplyCleanup(root, nil)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if len(plan.Remove) != 0 {
|
||
t.Fatalf("沒有我們的宣告就一個都不准刪:%v", relsOf(plan.Remove))
|
||
}
|
||
if got := cuSnapshotTree(t, root); len(got) != len(before) {
|
||
t.Fatalf("樹被動到了:before=%v after=%v", before, got)
|
||
}
|
||
}
|
||
|
||
// ── ④ .gitignore 被使用者改過(後面加了自己的規則)⇒ 不算我們的宣告 ──
|
||
|
||
func TestCleanupRequiresExactIgnoreBody(t *testing.T) {
|
||
root := t.TempDir()
|
||
wiki := filepath.Join(root, wikiRelDir)
|
||
makeWiki(t, wiki, "卡.md")
|
||
cuWriteFile(t, filepath.Join(wiki, ".gitignore"), wikiIgnoreBody+"\n!我要收版控的.md\n")
|
||
|
||
plan, err := PlanCleanup(root, nil)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if len(plan.Remove) != 0 {
|
||
t.Fatalf(".gitignore 被改過就不該認:%v", relsOf(plan.Remove))
|
||
}
|
||
}
|
||
|
||
// ── ⑤ 工作區裡有 legacy-template 收容處 ⇒ 收容處留著,其餘刪 ──
|
||
|
||
func TestCleanupKeepsLegacyTemplateQuarantine(t *testing.T) {
|
||
root := t.TempDir()
|
||
makeWorkspace(t, root)
|
||
quar := filepath.Join(root, legacyTemplateRelDir, "system-dev", "wiki", "status.md")
|
||
cuWriteFile(t, quar, "使用者的舊 status\n")
|
||
|
||
plan, res, err := ApplyCleanup(root, nil)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if len(res.Failed) != 0 {
|
||
t.Fatalf("不該有失敗:%+v", res.Failed)
|
||
}
|
||
if _, err := os.Stat(quar); err != nil {
|
||
t.Fatalf("收容處裡的檔被刪了——那是從使用者資料夾搬進來的:%v", err)
|
||
}
|
||
if _, err := os.Stat(filepath.Join(root, workspaceRelDir, "wiki")); !os.IsNotExist(err) {
|
||
t.Fatalf("工作區裡我們自己的東西該刪掉")
|
||
}
|
||
if !contains(keepRels(plan.Keep), ".arcrun-rag/legacy-template") {
|
||
t.Fatalf("收容處要出現在 Keep 清單:%v", keepRels(plan.Keep))
|
||
}
|
||
}
|
||
|
||
// ── ⑥ 另一個還在看守的巢狀根 ⇒ 它底下的東西一個都不碰(2026-08-24 pms 現場擺法)──
|
||
|
||
func TestCleanupSkipsOtherWatchedRoot(t *testing.T) {
|
||
root := t.TempDir()
|
||
inner := filepath.Join(root, "legacy")
|
||
makeWorkspace(t, root)
|
||
makeWiki(t, filepath.Join(root, wikiRelDir), "外卡.md")
|
||
makeRootManifest(t, root, map[string][]string{"": {".wiki/外卡.md"}})
|
||
makeWorkspace(t, inner)
|
||
makeWiki(t, filepath.Join(inner, wikiRelDir), "內卡.md")
|
||
makeWiki(t, filepath.Join(inner, "sub", wikiRelDir), "更內卡.md")
|
||
makeRootManifest(t, inner, map[string][]string{
|
||
"": {".wiki/內卡.md"},
|
||
"sub": {"sub/.wiki/更內卡.md"},
|
||
})
|
||
|
||
plan, res, err := ApplyCleanup(root, []string{inner})
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if len(res.Failed) != 0 {
|
||
t.Fatalf("不該有失敗:%+v", res.Failed)
|
||
}
|
||
for _, rel := range relsOf(plan.Remove) {
|
||
if strings.HasPrefix(rel, "legacy/") {
|
||
t.Fatalf("動到另一個還在看守的根:%s", rel)
|
||
}
|
||
}
|
||
for _, p := range []string{
|
||
filepath.Join(inner, workspaceRelDir, ".gitignore"),
|
||
filepath.Join(inner, wikiRelDir, "內卡.md"),
|
||
filepath.Join(inner, "sub", wikiRelDir, "更內卡.md"),
|
||
} {
|
||
if _, err := os.Stat(p); err != nil {
|
||
t.Fatalf("還在看守的資料夾底下的東西被刪了:%s(%v)", p, err)
|
||
}
|
||
}
|
||
if _, err := os.Stat(filepath.Join(root, wikiRelDir)); !os.IsNotExist(err) {
|
||
t.Fatalf("外層自己的 .wiki 該刪掉")
|
||
}
|
||
}
|
||
|
||
// ── ⑦ 看得見的卡片產物區:只刪帶 arcrun- 標記的檔,目錄與別人的卡不動 ──
|
||
|
||
func TestCleanupMarkedCardsOnly(t *testing.T) {
|
||
root := t.TempDir()
|
||
cards := filepath.Join(root, filepath.FromSlash(cardsRelDir))
|
||
cuWriteFile(t, filepath.Join(cards, "arcrun-機器卡.md"), "# 機器卡\n")
|
||
cuWriteFile(t, filepath.Join(cards, "leo自己的卡.md"), "# 我的\n")
|
||
cuWriteFile(t, filepath.Join(root, "system-dev", "wiki", "status.md"), "我的 status\n")
|
||
|
||
_, res, err := ApplyCleanup(root, nil)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if len(res.Failed) != 0 {
|
||
t.Fatalf("不該有失敗:%+v", res.Failed)
|
||
}
|
||
if _, err := os.Stat(filepath.Join(cards, "arcrun-機器卡.md")); !os.IsNotExist(err) {
|
||
t.Fatalf("帶標記的卡該刪掉")
|
||
}
|
||
for _, p := range []string{
|
||
filepath.Join(cards, "leo自己的卡.md"),
|
||
filepath.Join(root, "system-dev", "wiki", "status.md"),
|
||
cards,
|
||
} {
|
||
if _, err := os.Stat(p); err != nil {
|
||
t.Fatalf("不該動的東西被動了:%s(%v)", p, err)
|
||
}
|
||
}
|
||
}
|
||
|
||
// ── ⑧ symlink 不跟隨、不刪(指出去的連結會刪到監看根以外)──
|
||
|
||
func TestCleanupNeverFollowsSymlink(t *testing.T) {
|
||
root := t.TempDir()
|
||
outside := t.TempDir()
|
||
cuWriteFile(t, filepath.Join(outside, "外面的重要檔.md"), "別碰\n")
|
||
makeWiki(t, filepath.Join(root, wikiRelDir), "卡.md")
|
||
if err := os.Symlink(outside, filepath.Join(root, "捷徑")); err != nil {
|
||
t.Skipf("這台建不了 symlink:%v", err)
|
||
}
|
||
// 連 .wiki 自己是條捷徑的情況也擋
|
||
link2 := filepath.Join(root, "sub", wikiRelDir)
|
||
if err := os.MkdirAll(filepath.Join(root, "sub"), 0o755); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if err := os.Symlink(outside, link2); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
|
||
plan, res, err := ApplyCleanup(root, nil)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if len(res.Failed) != 0 {
|
||
t.Fatalf("不該有失敗:%+v", res.Failed)
|
||
}
|
||
for _, rel := range relsOf(plan.Remove) {
|
||
if strings.HasPrefix(rel, "捷徑") || strings.HasPrefix(rel, "sub/") {
|
||
t.Fatalf("動到 symlink:%s", rel)
|
||
}
|
||
}
|
||
if _, err := os.Stat(filepath.Join(outside, "外面的重要檔.md")); err != nil {
|
||
t.Fatalf("監看根以外的檔被刪了:%v", err)
|
||
}
|
||
}
|
||
|
||
// ── ⑨ PlanCleanup 保證不寫檔(「先看清單」的前提就是看清單這件事本身無副作用)──
|
||
|
||
func TestPlanCleanupWritesNothing(t *testing.T) {
|
||
root := t.TempDir()
|
||
cuWriteFile(t, filepath.Join(root, "我的.md"), "x\n")
|
||
makeWorkspace(t, root)
|
||
makeWiki(t, filepath.Join(root, wikiRelDir), "卡.md")
|
||
makeWiki(t, filepath.Join(root, "docs", wikiRelDir), "卡2.md")
|
||
|
||
before := cuSnapshotTree(t, root)
|
||
plan, err := PlanCleanup(root, nil)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if len(plan.Remove) == 0 {
|
||
t.Fatal("這份擺法應該找得到東西")
|
||
}
|
||
after := cuSnapshotTree(t, root)
|
||
if len(before) != len(after) {
|
||
t.Fatalf("PlanCleanup 動了檔案:before=%d after=%d", len(before), len(after))
|
||
}
|
||
for k, v := range before {
|
||
if after[k] != v {
|
||
t.Fatalf("PlanCleanup 改了 %s", k)
|
||
}
|
||
}
|
||
// 每一筆都要說得出依據——沒依據的東西不准進清單。
|
||
for _, it := range plan.Remove {
|
||
if strings.TrimSpace(it.Evidence) == "" {
|
||
t.Fatalf("這一筆沒有依據:%+v", it)
|
||
}
|
||
}
|
||
}
|
||
|
||
// ── ⑩ 帳本認卡:卡名不帶標記、也不是 00-INDEX,靠 manifest.json 認出來 ──
|
||
|
||
func TestCleanupAccountsCardsViaManifest(t *testing.T) {
|
||
root := t.TempDir()
|
||
wiki := filepath.Join(root, wikiRelDir)
|
||
cuWriteFile(t, filepath.Join(wiki, "手動卡.md"), "# 手動卡\n")
|
||
cuWriteFile(t, filepath.Join(wiki, "manifest.json"), `{"version":1,"docs":[
|
||
{"node":"(根)","path":"a.md","doc_id":"abc","status":"extracted","cards":[".wiki/手動卡.md"]}]}`)
|
||
// 故意不放 .gitignore ⇒ 只能靠帳本認人
|
||
plan, err := PlanCleanup(root, nil)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if got := relsOf(plan.Remove); !equalStrings(got, []string{".wiki"}) {
|
||
t.Fatalf("帳本上的卡+帳本自己=全部認得,應該整個目錄刪掉,實際 %v(keep=%v)", got, keepRels(plan.Keep))
|
||
}
|
||
}
|
||
|
||
func equalStrings(a, b []string) bool {
|
||
if len(a) != len(b) {
|
||
return false
|
||
}
|
||
for i := range a {
|
||
if a[i] != b[i] {
|
||
return false
|
||
}
|
||
}
|
||
return true
|
||
}
|
||
|
||
func contains(ss []string, want string) bool {
|
||
for _, s := range ss {
|
||
if s == want {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|