282 lines
11 KiB
Go
282 lines
11 KiB
Go
package collector
|
||
|
||
// logseq_bak_residue_test.go — `inkstone/arcrun-rag#104` comment 6309(leo 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 graph:pages/journals 是筆記本體,
|
||
// 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×2+journals×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 的普通資料夾(旁邊沒有 pages/journals/config.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/8=37.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))
|
||
}
|
||
}
|