Files
code-utils/database_test.go
2026-08-14 07:51:46 +08:00

386 lines
10 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 main
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestStoreProjectAndSnapshot(t *testing.T) {
s, e := OpenStore(filepath.Join(t.TempDir(), "test.db"))
if e != nil {
t.Fatal(e)
}
defer s.db.Close()
dir := t.TempDir()
p, e := s.SaveProject(0, ProjectInput{Name: "demo", Path: dir})
if e != nil {
t.Fatal(e)
}
if _, e = s.SaveProject(0, ProjectInput{Name: "duplicate", Path: dir}); e == nil {
t.Fatal("duplicate path accepted")
}
e = s.ReplaceScan(p.ID, []LanguageStat{{Name: "Go", Files: 1, Code: 2, Comments: 1, Blanks: 1}}, []FileEntry{{Path: "main.go", Name: "main.go", Extension: ".go", Size: 20}})
if e != nil {
t.Fatal(e)
}
got, e := s.GetProject(p.ID)
if e != nil {
t.Fatal(e)
}
if got.Stats.TotalLines != 4 || got.Stats.FileCount != 1 {
t.Fatalf("unexpected stats: %#v", got.Stats)
}
}
func TestAISummaryRoundTrip(t *testing.T) {
s, e := OpenStore(filepath.Join(t.TempDir(), "test.db"))
if e != nil {
t.Fatal(e)
}
defer s.db.Close()
p, e := s.SaveProject(0, ProjectInput{Name: "demo", Path: t.TempDir()})
if e != nil {
t.Fatal(e)
}
if e = s.SaveAISummary(p.ID, "project", "spark", "第一版介绍"); e != nil {
t.Fatal(e)
}
// 覆盖更新(同项目同模块只保留最新一份)
if e = s.SaveAISummary(p.ID, "project", "deepseek", "第二版介绍"); e != nil {
t.Fatal(e)
}
if e = s.SaveAISummary(p.ID, "git", "spark", "Git 介绍"); e != nil {
t.Fatal(e)
}
got, e := s.GetAISummaries(p.ID)
if e != nil || len(got) != 2 {
t.Fatalf("expected 2 summaries, got %d (%v)", len(got), e)
}
for _, x := range got {
switch x.Kind {
case "project":
if x.Content != "第二版介绍" || x.Provider != "deepseek" {
t.Fatalf("project summary mismatch: %#v", x)
}
case "git":
if x.Content != "Git 介绍" || x.Provider != "spark" {
t.Fatalf("git summary mismatch: %#v", x)
}
}
}
}
func TestAISummaryCleanup(t *testing.T) {
if got := stripFence("```markdown\n介绍正文\n- 列表\n```"); got != "介绍正文\n- 列表" {
t.Fatalf("stripFence: %q", got)
}
if got := stripFence("普通文本"); got != "普通文本" {
t.Fatalf("stripFence should keep plain text: %q", got)
}
if got := stripFence("```\n# 标题\n正文没闭合"); got != "# 标题\n正文没闭合" {
t.Fatalf("stripFence should drop unclosed opening fence: %q", got)
}
if got := dedentCommon(" 第一行\n 第二行"); got != "第一行\n第二行" {
t.Fatalf("dedentCommon: %q", got)
}
if got := dedentCommon("第一行\n 嵌套保留"); got != "第一行\n 嵌套保留" {
t.Fatalf("dedentCommon should keep relative indent: %q", got)
}
}
func TestNormalizePath(t *testing.T) {
if _, e := normalizePath(filepath.Join(t.TempDir(), "missing")); e == nil {
t.Fatal("missing directory accepted")
}
d := t.TempDir()
p, e := normalizePath(d)
if e != nil {
t.Fatal(e)
}
if _, e = os.Stat(p); e != nil {
t.Fatal(e)
}
}
func TestDuplicateProjectReturnsStableCode(t *testing.T) {
s, e := OpenStore(filepath.Join(t.TempDir(), "test.db"))
if e != nil {
t.Fatal(e)
}
defer s.db.Close()
dir := t.TempDir()
if _, e = s.SaveProject(0, ProjectInput{Name: "one", Path: dir}); e != nil {
t.Fatal(e)
}
if _, e = s.SaveProject(0, ProjectInput{Name: "two", Path: dir}); e == nil || !strings.HasPrefix(e.Error(), "PROJECT_PATH_DUPLICATE") {
t.Fatalf("unexpected error: %v", e)
}
}
func TestProjectGroupsFilterProjectsAndDashboard(t *testing.T) {
s, e := OpenStore(filepath.Join(t.TempDir(), "groups.db"))
if e != nil {
t.Fatal(e)
}
defer s.db.Close()
g, e := s.SaveProjectGroup(0, "Backend")
if e != nil {
t.Fatal(e)
}
dir1 := t.TempDir()
dir2 := t.TempDir()
p1, e := s.SaveProject(0, ProjectInput{Name: "api", Path: dir1, GroupID: g.ID})
if e != nil {
t.Fatal(e)
}
if _, e = s.SaveProject(0, ProjectInput{Name: "web", Path: dir2}); e != nil {
t.Fatal(e)
}
if e = s.ReplaceScan(p1.ID, []LanguageStat{{Name: "Go", Files: 2, Code: 10, Comments: 1, Blanks: 1}}, nil); e != nil {
t.Fatal(e)
}
projects, e := s.ListProjects(g.ID)
if e != nil {
t.Fatal(e)
}
if len(projects) != 1 || projects[0].GroupID != g.ID || projects[0].GroupName != "Backend" {
t.Fatalf("unexpected grouped projects: %#v", projects)
}
d, e := s.Dashboard(g.ID)
if e != nil {
t.Fatal(e)
}
if d.Projects != 1 || d.TotalLines != 12 {
t.Fatalf("unexpected dashboard: %#v", d)
}
}
func TestDeleteProjectGroupMovesProjectsToDefault(t *testing.T) {
s, e := OpenStore(filepath.Join(t.TempDir(), "groups.db"))
if e != nil {
t.Fatal(e)
}
defer s.db.Close()
g, e := s.SaveProjectGroup(0, "Temp")
if e != nil {
t.Fatal(e)
}
p, e := s.SaveProject(0, ProjectInput{Name: "demo", Path: t.TempDir(), GroupID: g.ID})
if e != nil {
t.Fatal(e)
}
if e = s.DeleteProjectGroup(g.ID); e != nil {
t.Fatal(e)
}
got, e := s.GetProject(p.ID)
if e != nil {
t.Fatal(e)
}
if got.GroupID != 1 {
t.Fatalf("groupId=%d want 1", got.GroupID)
}
}
func TestSettingsReturnsActiveDatabasePath(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "active.db")
s, e := OpenStore(dbPath)
if e != nil {
t.Fatal(e)
}
defer s.db.Close()
settings, e := s.Settings()
if e != nil {
t.Fatal(e)
}
if settings.DatabasePath != dbPath {
t.Fatalf("databasePath=%q want %q", settings.DatabasePath, dbPath)
}
}
func TestGlassOpacityPersistsAndValidates(t *testing.T) {
s, e := OpenStore(filepath.Join(t.TempDir(), "settings.db"))
if e != nil {
t.Fatal(e)
}
defer s.db.Close()
if e = s.SaveSettings(AppSettings{Theme: "dark", Locale: "zh-CN", GitScope: "current", AutoRefresh: true, GlassOpacity: 30}); e != nil {
t.Fatal(e)
}
got, e := s.Settings()
if e != nil {
t.Fatal(e)
}
if got.GlassOpacity != 30 {
t.Fatalf("glassOpacity=%d", got.GlassOpacity)
}
if e = s.SaveSettings(AppSettings{GlassOpacity: 99}); e != nil {
t.Fatal(e)
}
got, e = s.Settings()
if e != nil {
t.Fatal(e)
}
if got.GlassOpacity != 55 {
t.Fatalf("invalid opacity did not reset: %d", got.GlassOpacity)
}
}
func TestLoadingStylePersistsAndValidates(t *testing.T) {
s, e := OpenStore(filepath.Join(t.TempDir(), "settings.db"))
if e != nil {
t.Fatal(e)
}
defer s.db.Close()
if e = s.SaveSettings(AppSettings{Theme: "dark", Locale: "zh-CN", GitScope: "current", AutoRefresh: true, GlassOpacity: 55, LoadingStyle: "fullscreen-grid"}); e != nil {
t.Fatal(e)
}
got, e := s.Settings()
if e != nil {
t.Fatal(e)
}
if got.LoadingStyle != "fullscreen-grid" {
t.Fatalf("loadingStyle=%q", got.LoadingStyle)
}
if e = s.SaveSettings(AppSettings{GlassOpacity: 55, LoadingStyle: "floating"}); e != nil {
t.Fatal(e)
}
got, e = s.Settings()
if e != nil {
t.Fatal(e)
}
if got.LoadingStyle != "fullscreen-orbit" {
t.Fatalf("invalid loadingStyle did not reset: %q", got.LoadingStyle)
}
}
func TestMigrateSkippedWhenVersionCurrent(t *testing.T) {
db := filepath.Join(t.TempDir(), "versioned.db")
s, e := OpenStore(db)
if e != nil {
t.Fatal(e)
}
// 删除一张迁移会创建的表:若重开时迁移被跳过,该表应保持缺失。
if _, e = s.db.Exec(`DROP TABLE git_hotspots`); e != nil {
t.Fatal(e)
}
s.db.Close()
s, e = OpenStore(db)
if e != nil {
t.Fatal(e)
}
defer s.db.Close()
var n int
_ = s.db.QueryRow(`SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='git_hotspots'`).Scan(&n)
if n != 0 {
t.Fatal("migration ran again despite current schema version")
}
// 回退版本号后重开,迁移应重新执行并补回该表。
if _, e = s.db.Exec(`DELETE FROM schema_migrations`); e != nil {
t.Fatal(e)
}
s.db.Close()
s, e = OpenStore(db)
if e != nil {
t.Fatal(e)
}
defer s.db.Close()
_ = s.db.QueryRow(`SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='git_hotspots'`).Scan(&n)
if n != 1 {
t.Fatal("migration did not run after version reset")
}
}
func TestLegacyLoadingStyleDefaultMigratesOnce(t *testing.T) {
db := filepath.Join(t.TempDir(), "settings.db")
s, e := OpenStore(db)
if e != nil {
t.Fatal(e)
}
if _, e = s.db.Exec(`UPDATE settings SET value='bar' WHERE key='loadingStyle'`); e != nil {
t.Fatal(e)
}
// 模拟旧版本数据库:回退已记录的结构版本,重开时才会执行一次迁移转换。
if _, e = s.db.Exec(`DELETE FROM schema_migrations`); e != nil {
t.Fatal(e)
}
s.db.Close()
s, e = OpenStore(db)
if e != nil {
t.Fatal(e)
}
got, e := s.Settings()
if e != nil {
t.Fatal(e)
}
if got.LoadingStyle != "fullscreen-orbit" {
t.Fatalf("legacy loadingStyle=%q", got.LoadingStyle)
}
if e = s.SaveSettings(AppSettings{GlassOpacity: 55, LoadingStyle: "bar"}); e != nil {
t.Fatal(e)
}
s.db.Close()
s, e = OpenStore(db)
if e != nil {
t.Fatal(e)
}
defer s.db.Close()
got, e = s.Settings()
if e != nil {
t.Fatal(e)
}
if got.LoadingStyle != "bar" {
t.Fatalf("explicit loadingStyle=%q", got.LoadingStyle)
}
}
func TestMultiNoteCRUD(t *testing.T) {
s, e := OpenStore(filepath.Join(t.TempDir(), "notes.db"))
if e != nil {
t.Fatal(e)
}
defer s.db.Close()
a, e := s.SaveNoteByID(0, "第一条")
if e != nil || a.ID == 0 || a.UUID == "" {
t.Fatalf("create note: %#v %v", a, e)
}
b, e := s.SaveNoteByID(0, "第二条")
if e != nil || b.ID == a.ID {
t.Fatalf("second note: %#v %v", b, e)
}
notes, e := s.ListNotes(0)
if e != nil || len(notes) != 2 {
t.Fatalf("list notes: %d %v", len(notes), e)
}
if notes[0].ID != b.ID {
t.Fatalf("latest first, got %#v", notes[0])
}
// GetNote 返回最近更新的一条SaveNote 兼容入口更新它
if n, _ := s.GetNote(); n.ID != b.ID {
t.Fatalf("GetNote should return latest, got %d", n.ID)
}
if a2, e := s.SaveNoteByID(a.ID, "第一条改"); e != nil || a2.Content != "第一条改" {
t.Fatalf("update note: %#v %v", a2, e)
}
// 更新过的 a 变为最近一条
if n, _ := s.GetNote(); n.ID != a.ID {
t.Fatalf("GetNote should follow update, got %d", n.ID)
}
if _, e := s.SaveNoteByID(99999, "missing"); e == nil || e.Error() != "NOTE_NOT_FOUND" {
t.Fatalf("expect NOTE_NOT_FOUND, got %v", e)
}
if e := s.DeleteNote(b.ID); e != nil {
t.Fatal(e)
}
if notes, _ = s.ListNotes(0); len(notes) != 1 || notes[0].ID != a.ID {
t.Fatalf("after delete: %#v", notes)
}
// 软删行仍在表中且 dirty供同步推送
var deleted, dirty int
_ = s.db.QueryRow(`SELECT deleted,dirty FROM notes WHERE id=?`, b.ID).Scan(&deleted, &dirty)
if deleted != 1 || dirty != 1 {
t.Fatalf("soft delete flags: deleted=%d dirty=%d", deleted, dirty)
}
}