Entries now sit at places 1…max with gaps allowed (#28, nothing at #29, #30). Moving onto an empty place just puts the entry there; onto a taken place, the entries in between shift one step, stopping at the first empty place, so lists without gaps reorder exactly as before. Inserts push down only to the next gap; deleting can close up or leave the place empty. New endpoint POST /api/lists/{id}/places inserts or removes empty places or closes every gap; move and delete reply with every entry's place. The podium shows an empty step for an empty #2 or #3, and a list can't shrink below its last used place. The editor loads every entry (no paging) and applies edits instantly with the same rules (places.js mirrors places.go), queueing saves in the background and reconciling with the server's reply. Type a place and press Enter (↑/↓ move between boxes, Alt+↑/↓ moves the entry), drag, or use the row menu. Empty runs show as rows with Add here and Remove, and a toolbar shows the gap count, Close gaps and a Go to # box. Clicking an entry opens it for editing. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
337 lines
9.2 KiB
Go
337 lines
9.2 KiB
Go
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")
|
|
}
|