Files
arcrun-collector/logseq_bak_residue_test.go
T

282 lines
11 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.
package collector
// logseq_bak_residue_test.go — `inkstone/arcrun-rag#104` comment 6309leo 2026-09-05「bak 是不要的」)。
//
// 病:Logseq 每次編輯都往 `logseq/bak/` 留一份備份,daemon 把每一份都當新知識收進 `kb`
// ——leo21c 的 KB 4,193 份文件裡 3,620 份是這種殘影,而且雲端的資料夾卡也跟著長出
// 「資料夾:KB/logseq/bak/pages/…」上千張。08-13 早判過是垃圾,D82 重灌後回歸。
//
// 三個環,缺一個殘影都清不掉:
// ① 不再收:`logseq/` 是 Logseq 自己的資料夾,整棵跳過,理由看得見
// ② 已收的要下架:策略不再收的檔照發 removed,**不受**大量刪除防呆管(86% 會被擋一輩子)
// ③ 資料夾卡也要撤:資料夾從樹上消失=那張卡排進待撤清單
import (
"os"
"path/filepath"
"strings"
"testing"
"time"
)
// makeLogseqGraph 造一個有佐證的 Logseq graphpagesjournals 是筆記本體,
// logseq/ 底下是設定與備份。回傳根與 bak 檔的相對路徑。
func makeLogseqGraph(t *testing.T, withConfig bool) (root string, bakFiles []string) {
t.Helper()
root = t.TempDir()
writeFile(t, root, "pages/index.md", "- n8n 本機安裝指南\n", baseTime)
writeFile(t, root, "pages/擋風防雨車.md", "- Adiva D1 價格 20 萬\n", baseTime)
writeFile(t, root, "journals/2026_09_04.md", "- 今天評估 Adiva D1\n", baseTime)
if withConfig {
writeFile(t, root, "logseq/config.edn", "{:meta/version 1}\n", baseTime)
}
for _, rel := range []string{
"logseq/bak/pages/index/2026-09-01T12_08_05.467Z.Desktop.md",
"logseq/bak/pages/index/2026-09-01T13_07_56.194Z.Desktop.md",
"logseq/bak/pages/擋風防雨車/2026-09-04T01_00_00.000Z.Desktop.md",
"logseq/bak/journals/2026_09_04/2026-09-04T02_00_00.000Z.Desktop.md",
"logseq/version-files/base/pages/index.md",
} {
writeFile(t, root, rel, "- 舊版殘影 "+rel+"\n", baseTime)
bakFiles = append(bakFiles, rel)
}
return root, bakFiles
}
// ① Logseq graph 底下的 logseq/ 整棵不收,而且理由要講得出來。
func TestPlanIngest_Logseq自己的bak資料夾不收且理由看得見(t *testing.T) {
root, bakFiles := makeLogseqGraph(t, true)
payload, plan := scanWithPlan(t, root)
if plan.Mode != IngestAll {
t.Fatalf("筆記庫應該是 all 模式,實得 %s", plan.Mode)
}
got := eventPaths(payload)
for _, p := range got {
if strings.HasPrefix(p, "logseq/") {
t.Fatalf("收到了 Logseq 自己的檔:%s(全部:%v", p, got)
}
}
if len(got) != 3 {
t.Fatalf("筆記本體應該收 3 份(pages×2journals×1),實得 %d%v", len(got), got)
}
var why string
for _, d := range payload.AllExcludedDirs {
if d.Path == "logseq" {
why = d.Reason
}
}
if why == "" {
t.Fatalf("logseq/ 被跳過了,卻沒有列在排除清單裡——使用者會以為東西不見了:%+v", payload.AllExcludedDirs)
}
if !strings.Contains(why, "Logseq") || !strings.Contains(why, "bak") {
t.Fatalf("理由要講得出這是 Logseq 的備份:%q", why)
}
// 樹上不該有 logseq/bak 這一層的節點——它整棵沒走進去,不是「走進去但不收」。
for rel := range payload.DirStats {
if strings.HasPrefix(rel, "logseq") {
t.Fatalf("logseq/ 底下不該生出節點:%s", rel)
}
}
_ = bakFiles
}
// ①' 一個碰巧叫 logseq 的普通資料夾(旁邊沒有 pagesjournalsconfig.edn)照收——
// 漏判只是多收一個資料夾,誤判是把使用者的東西弄不見。
func TestPlanIngest_碰巧叫logseq的普通資料夾照收(t *testing.T) {
root := t.TempDir()
writeFile(t, root, "筆記.md", "- 一般筆記\n", baseTime)
writeFile(t, root, "logseq/匯出/教學.md", "- 從 Logseq 匯出的教學\n", baseTime)
payload, _ := scanWithPlan(t, root)
got := eventPaths(payload)
if len(got) != 2 || got[0] != "logseq/匯出/教學.md" {
t.Fatalf("沒有佐證的 logseq 資料夾應該照收,實得 %v", got)
}
}
// ② 已經收過的殘影:規則補上之後要下架,而且不受大量刪除防呆管。
//
// 情境照 leo21c 的形狀:先用「看不出是 Logseq graph」的狀態把 bak 收進去(模擬舊版 daemon),
// 再補上 config.edn(佐證齊了)——3,620/4,193=86% 的檔在同一輪從現況消失。
func TestScan_策略不再收的檔照下架且不觸發大量刪除防呆(t *testing.T) {
root, bakFiles := makeLogseqGraph(t, false)
// pages/journals 在,logseq/ 沒有 config.edn ⇒ 佐證仍成立(journals/ 或 pages/ 存在就算)。
// 所以要模擬「舊版把 bak 收進去」得直接造 manifest,不能靠舊判準。
m := newTestManifest()
first := mustScan(t, root, m)
for _, rel := range bakFiles {
abs := filepath.Join(root, filepath.FromSlash(rel))
h, err := hashFile(abs)
if err != nil {
t.Fatal(err)
}
m.Entries[rel] = &ManifestEntry{ContentHash: h, Size: 1, Mtime: baseTime.Unix()}
}
markIngested(m)
if n := len(m.Entries); n != 3+len(bakFiles) {
t.Fatalf("前置:manifest 應有 %d 份,實得 %d(第一輪事件 %d)", 3+len(bakFiles), n, len(first.Events))
}
p := mustScan(t, root, m)
removed := eventsOfType(p, "removed")
if len(removed) != len(bakFiles) {
t.Fatalf("殘影 %d 份都要下架,實得 removed %d%+v", len(bakFiles), len(removed), removed)
}
for _, ev := range removed {
if !strings.HasPrefix(ev.Path, "logseq/") {
t.Fatalf("下架的不該是筆記本體:%s", ev.Path)
}
}
for _, w := range p.Warnings {
if w.Code == "mass_delete_guard" {
t.Fatalf("這不是資料夾未掛載,是我們自己不收了——防呆不該響:%+v", w)
}
}
var retired *Warning
for i := range p.Warnings {
if p.Warnings[i].Code == "plan_retired" {
retired = &p.Warnings[i]
}
}
if retired == nil {
t.Fatalf("下架了 %d 份卻沒告訴使用者為什麼(票上的紅線:不要讓用戶猜):%+v", len(removed), p.Warnings)
}
if retired.RemovedCount != len(bakFiles) || !strings.Contains(retired.Message, "Logseq") {
t.Fatalf("警告要說出份數與理由:%+v", *retired)
}
for _, rel := range bakFiles {
if _, still := m.Entries[rel]; still {
t.Fatalf("殘影 %s 不該還留在 manifest(下架成功後 direct.go 才刪;Scan 這一層先拿掉)", rel)
}
}
if len(m.Entries) != 3 {
t.Fatalf("筆記本體 3 份要留著,實得 %d", len(m.Entries))
}
// 殘影的內容雜湊不准跟新檔配成 renamed——那會讓殘影換個名字留在雲端。
writeFile(t, root, "pages/新頁.md", "- 舊版殘影 "+bakFiles[0]+"\n", baseTime)
p2 := mustScan(t, root, m)
if n := len(eventsOfType(p2, "renamed")); n != 0 {
t.Fatalf("殘影不該被當成搬走的檔:%+v", p2.Events)
}
}
// ②' 同一輪裡「策略不收」與「真的不見」要各管各的:前者照下架,後者仍受防呆管。
func TestScan_策略不再收與真的不見同時發生時各管各的(t *testing.T) {
root, bakFiles := makeLogseqGraph(t, false)
m := newTestManifest()
mustScan(t, root, m)
for _, rel := range bakFiles {
h, err := hashFile(filepath.Join(root, filepath.FromSlash(rel)))
if err != nil {
t.Fatal(err)
}
m.Entries[rel] = &ManifestEntry{ContentHash: h, Size: 1, Mtime: baseTime.Unix()}
}
markIngested(m)
// 筆記本體 3 份全部消失(像資料夾沒掛載)。防呆的分母是整份 manifest(3+5=8),
// 3/837.5%——刻意把門檻設在 30%,讓「真的不見的那 3 份」單獨就超過門檻,
// 而殘影那 5 份不管多少都不該被算進去。
for _, rel := range []string{"pages/index.md", "pages/擋風防雨車.md", "journals/2026_09_04.md"} {
if err := os.Remove(filepath.Join(root, filepath.FromSlash(rel))); err != nil {
t.Fatal(err)
}
}
p, err := Scan(root, m, ScanOptions{MaxRemovedRatio: 0.3})
if err != nil {
t.Fatal(err)
}
removed := eventsOfType(p, "removed")
if len(removed) != len(bakFiles) {
t.Fatalf("只有殘影該下架(%d),真的不見的 3 份要被防呆壓住;實得 %+v", len(bakFiles), removed)
}
codes := map[string]bool{}
for _, w := range p.Warnings {
codes[w.Code] = true
}
if !codes["mass_delete_guard"] || !codes["plan_retired"] {
t.Fatalf("兩種警告都要有:%+v", p.Warnings)
}
for _, rel := range []string{"pages/index.md", "pages/擋風防雨車.md", "journals/2026_09_04.md"} {
if _, kept := m.Entries[rel]; !kept {
t.Fatalf("防呆觸發時真的不見的 %s 要留在 manifest 等下輪重評", rel)
}
}
}
// ③ 資料夾從樹上消失 ⇒ 雲端那張資料夾卡排進待撤清單(以前只刪本機記帳,雲端永遠留著)。
func TestFolderCard_消失的資料夾卡要排進待撤清單(t *testing.T) {
root := "/x/KB"
lib := "kb"
stale := "logseq/bak/pages/index"
m := &Manifest{
Entries: ent("journals/2026_09_04.md"),
FolderCardHashes: map[string]string{
"journals": "sha256:old",
stale: "sha256:old",
},
}
cfg := &DirectConfig{Library: lib}
syncFolderCards(cfg, root, m, true, true /*dryRun*/, time.Now())
wantPath := folderCardPath(lib, stale)
page, queued := m.PendingTakedowns[wantPath]
if !queued {
t.Fatalf("消失的資料夾卡沒排進待撤清單:%+v", m.PendingTakedowns)
}
if page != "資料夾:KB/"+stale {
t.Fatalf("撤的頁名要跟當初送上去的一樣:%q", page)
}
if _, still := m.FolderCardHashes[stale]; still {
t.Fatal("本機記帳也要清掉")
}
if _, wrong := m.PendingTakedowns[folderCardPath(lib, "journals")]; wrong {
t.Fatal("還在的資料夾不該被撤")
}
// 整棵樹都不收了(一張卡都沒有)更要撤——這一段不能被「沒卡就提早返回」擋住。
m2 := &Manifest{
Entries: map[string]*ManifestEntry{},
FolderCardHashes: map[string]string{stale: "sha256:old"},
}
syncFolderCards(cfg, root, m2, true, true, time.Now())
if _, queued := m2.PendingTakedowns[wantPath]; !queued {
t.Fatalf("樹空了也要撤:%+v", m2.PendingTakedowns)
}
}
// ③' 待撤清單套單輪上限:上千張資料夾卡不能一輪撤完(會撞單輪等待上限),沒撤到的下輪接著撤。
func TestDrainPendingTakedowns_套單輪上限且順序固定(t *testing.T) {
m := &Manifest{PendingTakedowns: map[string]string{}}
for _, k := range []string{"c", "a", "b", "d", "e"} {
m.QueueTakedown(k+".md", k)
}
cfg := &DirectConfig{}
res, _ := drainPendingTakedowns(cfg, m, "/x", "renamed_takedown", "", func() {}, true, func() {}, 2)
var planned []string
var info string
for _, r := range res {
if r.Status == "planned" {
planned = append(planned, r.Path)
}
if r.Type == "info" {
info = r.Error
}
}
if len(planned) != 2 || planned[0] != "a.md" || planned[1] != "b.md" {
t.Fatalf("上限 2 且要照固定順序:%v", planned)
}
if !strings.Contains(info, "3") {
t.Fatalf("要告訴使用者還剩幾筆:%q", info)
}
if len(m.PendingTakedowns) != 5 {
t.Fatal("dry-run 不該動清單")
}
// 0=不限(移除資料夾那條路維持原行為)。
res0, _ := drainPendingTakedowns(cfg, m, "/x", "folder_takedown", "", func() {}, true, func() {}, 0)
if len(res0) != 5 {
t.Fatalf("0 應該不限,實得 %d", len(res0))
}
}