package store import ( "context" "errors" "fmt" "os" "path/filepath" "strings" "testing" ) func newTestStore(t *testing.T) *Store { t.Helper() s, err := Open(t.TempDir()) if err != nil { t.Fatal(err) } t.Cleanup(func() { s.Close() }) return s } func titles(t *testing.T, s *Store, listID int64) string { t.Helper() items, err := s.Items(context.Background(), listID, 0, 0) if err != nil { t.Fatal(err) } var out []string for i, it := range items { if it.Rank != i+1 { t.Fatalf("rank gap: item %q has rank %d at position %d", it.Title, it.Rank, i+1) } out = append(out, it.Title) } return strings.Join(out, ",") } func TestRanking(t *testing.T) { ctx := context.Background() s := newTestStore(t) l, err := s.CreateList(ctx, List{Title: "Top 5 Movies", Category: "movie", MaxItems: 5, Visible: true}) if err != nil { t.Fatal(err) } if l.Slug != "movies" { t.Errorf("slug = %q, want movies", l.Slug) } ids := map[string]int64{} for _, title := range []string{"A", "B", "C", "D"} { it, err := s.AddItem(ctx, Item{ListID: l.ID, Title: title}, 0) if err != nil { t.Fatal(err) } ids[title] = it.ID } if got := titles(t, s, l.ID); got != "A,B,C,D" { t.Fatalf("append: %s", got) } e, err := s.AddItem(ctx, Item{ListID: l.ID, Title: "E"}, 2) if err != nil { t.Fatal(err) } ids["E"] = e.ID if got := titles(t, s, l.ID); got != "A,E,B,C,D" { t.Fatalf("insert at 2: %s", got) } var ve ValidationError if _, err := s.AddItem(ctx, Item{ListID: l.ID, Title: "F"}, 0); !errors.As(err, &ve) { t.Fatalf("expected full-list error, got %v", err) } steps := []struct { id string rank int want string }{ {"A", 5, "E,B,C,D,A"}, {"D", 1, "D,E,B,C,A"}, {"B", 5, "D,E,C,A,B"}, {"C", 1, "C,D,E,A,B"}, } for _, st := range steps { if _, err := s.MoveItem(ctx, ids[st.id], st.rank); err != nil { t.Fatal(err) } if got := titles(t, s, l.ID); got != st.want { t.Fatalf("move %s to %d: got %s, want %s", st.id, st.rank, got, st.want) } } for _, bad := range []int{0, 6} { if _, err := s.MoveItem(ctx, ids["A"], bad); !errors.As(err, &ve) { t.Fatalf("move to %d: expected an error, got %v", bad, err) } } if _, err := s.DeleteItem(ctx, ids["E"], true); err != nil { t.Fatal(err) } if got := titles(t, s, l.ID); got != "C,D,A,B" { t.Fatalf("delete: %s", got) } if _, err := s.UpdateList(ctx, List{ID: l.ID, Title: "x", Category: "movie", MaxItems: 3}); !errors.As(err, &ve) { t.Fatalf("expected shrink error, got %v", err) } if _, err := s.UpdateList(ctx, List{ID: l.ID, Title: "x", Category: "tv", MaxItems: 5}); !errors.As(err, &ve) { t.Fatalf("expected category change error, got %v", err) } } func TestListLimits(t *testing.T) { ctx := context.Background() s := newTestStore(t) for _, n := range []int{0, MaxListSize + 1} { if _, err := s.CreateList(ctx, List{Title: "x", Category: "game", MaxItems: n}); err == nil { t.Errorf("size %d accepted", n) } } if _, err := s.CreateList(ctx, List{Title: "x", Category: "game", MaxItems: MaxListSize}); err != nil { t.Errorf("max size rejected: %v", err) } if _, err := s.CreateList(ctx, List{Title: "x", Category: "podcast", MaxItems: 5}); err == nil { t.Error("unknown category accepted") } // Second list in a category gets a unique slug. l, err := s.CreateList(ctx, List{Title: "y", Category: "game", MaxItems: 5}) if err != nil { t.Fatal(err) } if l.Slug != "video-games-2" { t.Errorf("slug = %q", l.Slug) } } func BenchmarkMoveInLargeList(b *testing.B) { ctx := context.Background() s, err := Open(b.TempDir()) if err != nil { b.Fatal(err) } defer s.Close() l, _ := s.CreateList(ctx, List{Title: "big", Category: "song", MaxItems: 5000}) var first int64 for i := 0; i < 5000; i++ { it, err := s.AddItem(ctx, Item{ListID: l.ID, Title: fmt.Sprint(i)}, 0) if err != nil { b.Fatal(err) } if i == 0 { first = it.ID } } b.ResetTimer() for i := 0; i < b.N; i++ { s.MoveItem(ctx, first, 5000-(i%2)*4999) } } func TestCustomList(t *testing.T) { ctx := context.Background() s := newTestStore(t) l, err := s.CreateList(ctx, List{Title: "Favorite Sports Teams", Category: "custom", MaxItems: 10, Art: "poster"}) if err != nil { t.Fatal(err) } if l.Slug != "favorite-sports-teams" { t.Errorf("custom slug = %q, want it from the title", l.Slug) } if l.Aspect() != "poster" { t.Errorf("aspect = %q, want the list's own choice", l.Aspect()) } l.Art = "sideways" if l, err = s.UpdateList(ctx, l); err != nil { t.Fatal(err) } if l.Art != "" || l.Aspect() != "square" { t.Errorf("invalid art should fall back to the category default, got art=%q aspect=%q", l.Art, l.Aspect()) } m, _ := s.CreateList(ctx, List{Title: "Top 10 Movies", Category: "movie", MaxItems: 10}) if m.Aspect() != "poster" { t.Errorf("movie aspect = %q", m.Aspect()) } } func TestAdoptLegacyDB(t *testing.T) { dir := t.TempDir() s, err := Open(dir) if err != nil { t.Fatal(err) } if _, err := s.CreateList(context.Background(), List{Title: "Keep me", Category: "movie", MaxItems: 5}); err != nil { t.Fatal(err) } s.Close() // Pretend this data dir predates the rename. for _, suffix := range []string{"", "-wal", "-shm"} { os.Rename(filepath.Join(dir, "canon.db"+suffix), filepath.Join(dir, "topdeck.db"+suffix)) } s, err = Open(dir) if err != nil { t.Fatal(err) } defer s.Close() lists, err := s.Lists(context.Background(), true) if err != nil || len(lists) != 1 || lists[0].Title != "Keep me" { t.Fatalf("legacy data not adopted: %v %v", lists, err) } if _, err := os.Stat(filepath.Join(dir, "topdeck.db")); !os.IsNotExist(err) { t.Errorf("topdeck.db should have been renamed") } } // layout shows a list's places, "-" for an empty one: "A,-,B". func layout(t *testing.T, s *Store, listID int64) string { t.Helper() items, err := s.Items(context.Background(), listID, 0, 0) if err != nil { t.Fatal(err) } var out []string next := 1 for _, it := range items { for ; next < it.Rank; next++ { out = append(out, "-") } out = append(out, it.Title) next = it.Rank + 1 } return strings.Join(out, ",") } func TestPlaces(t *testing.T) { ctx := context.Background() s := newTestStore(t) l, err := s.CreateList(ctx, List{Title: "Games", Category: "game", MaxItems: 8}) if err != nil { t.Fatal(err) } ids := map[string]int64{} add := func(title string, rank int) { t.Helper() it, err := s.AddItem(ctx, Item{ListID: l.ID, Title: title}, rank) if err != nil { t.Fatal(err) } ids[title] = it.ID } check := func(what, want string) { t.Helper() if got := layout(t, s, l.ID); got != want { t.Fatalf("%s: got %s, want %s", what, got, want) } } add("A", 0) add("B", 0) add("C", 4) // an empty place check("add at an empty place", "A,B,-,C") add("D", 0) check("append goes after the last", "A,B,-,C,D") add("E", 1) check("insert pushes down to the gap only", "E,A,B,C,D") mustMove := func(title string, to int, want string) { t.Helper() if _, err := s.MoveItem(ctx, ids[title], to); err != nil { t.Fatal(err) } check(fmt.Sprintf("move %s to %d", title, to), want) } mustMove("D", 7, "E,A,B,C,-,-,D") // onto an empty place mustMove("E", 2, "A,E,B,C,-,-,D") // ordinary move down mustMove("C", 1, "C,A,E,B,-,-,D") // ordinary move up mustMove("B", 7, "C,A,E,-,-,D,B") // onto a taken place: D steps up into the gap mustMove("D", 2, "C,D,A,E,-,-,B") // move up: shifts stop where D left mustMove("A", 6, "C,D,-,E,-,A,B") // empty place: A's old place stays empty if _, err := s.InsertPlace(ctx, l.ID, 2); err != nil { t.Fatal(err) } check("insert an empty place", "C,-,D,E,-,A,B") // D moves into the gap; E stays if _, err := s.RemovePlace(ctx, l.ID, 2, 1); err != nil { t.Fatal(err) } check("remove an empty place", "C,D,E,-,A,B") var ve ValidationError if _, err := s.RemovePlace(ctx, l.ID, 1, 1); !errors.As(err, &ve) { t.Fatalf("removing a taken place: %v", err) } if _, err := s.DeleteItem(ctx, ids["E"], false); err != nil { t.Fatal(err) } check("delete, keep the place", "C,D,-,-,A,B") if _, err := s.DeleteItem(ctx, ids["C"], true); err != nil { t.Fatal(err) } check("delete, close up to the gap", "D,-,-,-,A,B") if _, err := s.RemovePlace(ctx, l.ID, 2, 2); err != nil { t.Fatal(err) } check("remove two empty places", "D,-,A,B") if _, err := s.InsertPlace(ctx, l.ID, 2); err != nil { t.Fatal(err) } if _, err := s.InsertPlace(ctx, l.ID, 2); err != nil { t.Fatal(err) } check("insert into a gap does nothing to the entries", "D,-,A,B") if _, err := s.InsertPlace(ctx, l.ID, 3); err != nil { // above A at #3 t.Fatal(err) } if _, err := s.InsertPlace(ctx, l.ID, 4); err != nil { // above A, now at #4 t.Fatal(err) } check("insert above an entry", "D,-,-,-,A,B") if _, err := s.UpdateList(ctx, List{ID: l.ID, Title: "Games", Category: "game", MaxItems: 5}); !errors.As(err, &ve) { t.Fatalf("shrinking below the last place: %v", err) } add("F", 6) add("G", 0) check("fill to the end", "D,-,-,-,A,F,B,G") add("H", 0) check("append into the first gap once the end is reached", "D,H,-,-,A,F,B,G") if _, err := s.AddItem(ctx, Item{ListID: l.ID, Title: "J"}, 5); !errors.As(err, &ve) { t.Fatalf("no room to push down: %v", err) } if _, err := s.CloseGaps(ctx, l.ID); err != nil { t.Fatal(err) } check("close gaps", "D,H,A,F,B,G") }