package main import ( "path/filepath" "strings" "testing" ) // 项目/规则文档的生成与套用(不依赖 MySQL 的本地部分)。 // v2 语义:身份文档只按 name 匹配更新,不再从文档落库项目; // 项目落地依赖 paths: 行(applyMachinePaths)或用户手动绑定。 func TestProjectsDocRoundTrip(t *testing.T) { dir := t.TempDir() s, e := OpenStore(filepath.Join(dir, "docs.db")) if e != nil { t.Fatal(e) } defer s.db.Close() a := NewApp() a.store = s doc, e := a.projectsDoc() if e != nil || doc != "[]" { t.Fatalf("empty doc want [], got %q err=%v", doc, e) } // 远端身份文档(v1 旧格式,元素带 path):本机没有这些项目 → 全部进待绑定,不落库。 p1 := filepath.Join(dir, "alpha") p2 := filepath.Join(dir, "beta") remote := `[{"name":"Alpha","path":` + jsonStr(p1) + `,"description":"d1","group":"Work","favorite":true},` + `{"name":"Beta","path":` + jsonStr(p2) + `}]` if e := a.applyProjectsDoc(remote); e != nil { t.Fatal(e) } var n int _ = s.db.QueryRow(`SELECT COUNT(*) FROM projects`).Scan(&n) if n != 0 { t.Fatalf("identity doc must not insert projects, got %d", n) } if pending, _ := a.ListCloudPendingProjects(); len(pending) != 2 { t.Fatalf("expect 2 pending cloud projects, got %d", len(pending)) } // 本机 paths 行到达(同机换库场景)→ 项目落库,随后身份文档补齐分组/收藏。 if e := a.applyMachinePaths(`{"Alpha":` + jsonStr(p1) + `,"Beta":` + jsonStr(p2) + `}`); e != nil { t.Fatal(e) } if e := a.applyProjectsDoc(remote); e != nil { t.Fatal(e) } var gid int64 if e := s.db.QueryRow(`SELECT group_id FROM projects WHERE path=?`, p1).Scan(&gid); e != nil || gid == 1 { t.Fatalf("alpha should be in new group, gid=%d err=%v", gid, e) } var gname string _ = s.db.QueryRow(`SELECT name FROM project_groups WHERE id=?`, gid).Scan(&gname) if gname != "Work" { t.Fatalf("group name want Work, got %q", gname) } var fav int _ = s.db.QueryRow(`SELECT COUNT(*) FROM favorites f JOIN projects p ON p.id=f.project_id WHERE p.path=?`, p1).Scan(&fav) if fav != 1 { t.Fatal("alpha should be favorite") } if pending, _ := a.ListCloudPendingProjects(); len(pending) != 0 { t.Fatalf("pending should be empty after paths applied, got %d", len(pending)) } // 再生成文档应包含两个项目且不带 path;重复套用幂等。 doc, e = a.projectsDoc() if e != nil || !strings.Contains(doc, "Alpha") || !strings.Contains(doc, "Beta") { t.Fatalf("doc missing entries: %q err=%v", doc, e) } if strings.Contains(doc, `"path"`) { t.Fatalf("v2 doc must not contain path: %q", doc) } if e := a.applyProjectsDoc(remote); e != nil { t.Fatal(e) } _ = s.db.QueryRow(`SELECT COUNT(*) FROM projects`).Scan(&n) if n != 2 { t.Fatalf("apply should be idempotent, projects=%d", n) } // 更新:改名(v1 文档按 path 匹配到本地项目)+ 取消收藏;本地多出的项目不受影响(只增改不删)。 remote2 := `[{"name":"Alpha2","path":` + jsonStr(p1) + `,"description":"d2","group":"Work","favorite":false}]` if e := a.applyProjectsDoc(remote2); e != nil { t.Fatal(e) } var name string _ = s.db.QueryRow(`SELECT name FROM projects WHERE path=?`, p1).Scan(&name) if name != "Alpha2" { t.Fatalf("want renamed Alpha2, got %q", name) } _ = s.db.QueryRow(`SELECT COUNT(*) FROM favorites f JOIN projects p ON p.id=f.project_id WHERE p.path=?`, p1).Scan(&fav) if fav != 0 { t.Fatal("favorite should be removed") } _ = s.db.QueryRow(`SELECT COUNT(*) FROM projects`).Scan(&n) if n != 2 { t.Fatalf("pull must not delete local projects, got %d", n) } } func TestRulesDocRoundTrip(t *testing.T) { s, e := OpenStore(filepath.Join(t.TempDir(), "rules.db")) if e != nil { t.Fatal(e) } defer s.db.Close() a := NewApp() a.store = s if _, e := s.AddRule("*.bak", "custom"); e != nil { t.Fatal(e) } doc, e := a.rulesDoc() if e != nil || !strings.Contains(doc, "*.bak") { t.Fatalf("doc should contain custom rule: %q err=%v", doc, e) } var builtinBefore int _ = s.db.QueryRow(`SELECT COUNT(*) FROM exclusion_rules WHERE builtin=1`).Scan(&builtinBefore) // 远端替换自定义部分:*.bak 消失,*.tmp 出现,内置规则数不变。 if e := a.applyRulesDoc(`[{"pattern":"*.tmp","category":"custom"}]`); e != nil { t.Fatal(e) } var nBak, nTmp, builtinAfter int _ = s.db.QueryRow(`SELECT COUNT(*) FROM exclusion_rules WHERE pattern='*.bak'`).Scan(&nBak) _ = s.db.QueryRow(`SELECT COUNT(*) FROM exclusion_rules WHERE pattern='*.tmp' AND builtin=0`).Scan(&nTmp) _ = s.db.QueryRow(`SELECT COUNT(*) FROM exclusion_rules WHERE builtin=1`).Scan(&builtinAfter) if nBak != 0 || nTmp != 1 || builtinAfter != builtinBefore { t.Fatalf("replace failed: bak=%d tmp=%d builtin %d->%d", nBak, nTmp, builtinBefore, builtinAfter) } } // jsonStr 把路径安全编码为 JSON 字符串(Windows 反斜杠)。 func jsonStr(s string) string { b := strings.ReplaceAll(s, `\`, `\\`) return `"` + b + `"` }