Add alternative names and cover art for soundtracks
Alternative names (#32): - PUT /api/v1/soundtracks/{uuid}/alternative-names replaces a soundtrack's alternative names (trimmed, deduplicated, without the soundtrack's own name). - alternative_names is in the soundtrack and statistics responses. - /find matches them too and shows which name matched. Covers (#12), stored in COVERS_PATH and off when it is not set: - GET, PUT (upload) and DELETE /api/v1/soundtracks/{uuid}/cover, and PUT .../cover/source to pick a SteamGridDB game or AniList anime by ID. Uploaded and picked covers are never replaced automatically. - With STEAMGRIDDB_API_KEY, a background job searches SteamGridDB, then AniList, by name, alternative names and the name without bracketed parts, at startup, after a sync and after names change. Search results are ranked by matching words, so "Civilization IV" gets "Sid Meier's Civilization IV". - cover_modified (Unix seconds) is in the soundtrack and statistics responses, so clients can cache covers. The new columns are added to migration 000006, which is not released yet. Closes #12, closes #32 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
f79f51ea4c
commit
bb8de5548d
39 files changed
+2418
-99
No files matched your search
@@ -0,0 +1,425 @@
|
||||
package backend
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"image"
|
||||
"image/png"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"music-server/internal/db/repository"
|
||||
"music-server/internal/testutil/fakedb"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func pngBytes(t *testing.T) []byte {
|
||||
t.Helper()
|
||||
var buf bytes.Buffer
|
||||
require.NoError(t, png.Encode(&buf, image.NewRGBA(image.Rect(0, 0, 2, 3))))
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
// coverAPIs fakes SteamGridDB, AniList and the image host.
|
||||
type coverAPIs struct {
|
||||
t *testing.T
|
||||
server *httptest.Server
|
||||
// games maps search terms to SteamGridDB game IDs; 404 has no grids.
|
||||
games map[string]int
|
||||
// anime maps search terms to AniList titles; the ID is always 42.
|
||||
anime map[string]string
|
||||
// failSteamGridDB makes SteamGridDB answer 500.
|
||||
failSteamGridDB bool
|
||||
|
||||
mu sync.Mutex
|
||||
searches []string
|
||||
}
|
||||
|
||||
func newCoverAPIs(t *testing.T) *coverAPIs {
|
||||
a := &coverAPIs{t: t, games: map[string]int{}, anime: map[string]string{}}
|
||||
image := pngBytes(t)
|
||||
mux := http.NewServeMux()
|
||||
reply := func(w http.ResponseWriter, data any) {
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"success": true, "data": data})
|
||||
}
|
||||
mux.HandleFunc("GET /sgdb/search/autocomplete/{term}", func(w http.ResponseWriter, r *http.Request) {
|
||||
a.record("sgdb:" + r.PathValue("term"))
|
||||
if a.failSteamGridDB {
|
||||
http.Error(w, "down", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
assert.Equal(t, "Bearer key", r.Header.Get("Authorization"))
|
||||
if id, ok := a.games[r.PathValue("term")]; ok {
|
||||
reply(w, []map[string]any{{"id": id, "name": r.PathValue("term")}})
|
||||
return
|
||||
}
|
||||
reply(w, []any{})
|
||||
})
|
||||
mux.HandleFunc("GET /sgdb/grids/game/{id}", func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "600x900", r.URL.Query().Get("dimensions"))
|
||||
if r.PathValue("id") == "404" {
|
||||
reply(w, []any{})
|
||||
return
|
||||
}
|
||||
reply(w, []map[string]any{{"id": 1, "url": a.server.URL + "/img/grid-" + r.PathValue("id") + ".png"}})
|
||||
})
|
||||
mux.HandleFunc("POST /anilist", func(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Variables struct {
|
||||
Search string `json:"search"`
|
||||
ID int `json:"id"`
|
||||
} `json:"variables"`
|
||||
}
|
||||
require.NoError(t, json.NewDecoder(r.Body).Decode(&req))
|
||||
a.record("anilist:" + req.Variables.Search)
|
||||
title, ok := a.anime[req.Variables.Search]
|
||||
if req.Variables.ID == 42 {
|
||||
title, ok = "By ID", true
|
||||
}
|
||||
if !ok {
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"data": map[string]any{"Media": nil}})
|
||||
return
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"data": map[string]any{"Media": map[string]any{
|
||||
"id": 42, "title": map[string]any{"romaji": title},
|
||||
"coverImage": map[string]any{"extraLarge": a.server.URL + "/img/anime.png"},
|
||||
}}})
|
||||
})
|
||||
mux.HandleFunc("GET /img/{name}", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.PathValue("name") == "text.png" {
|
||||
_, _ = w.Write([]byte("not an image"))
|
||||
return
|
||||
}
|
||||
_, _ = w.Write(image)
|
||||
})
|
||||
a.server = httptest.NewServer(mux)
|
||||
t.Cleanup(a.server.Close)
|
||||
return a
|
||||
}
|
||||
|
||||
func (a *coverAPIs) record(search string) {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
a.searches = append(a.searches, search)
|
||||
}
|
||||
|
||||
func (a *coverAPIs) searched() []string {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
return append([]string(nil), a.searches...)
|
||||
}
|
||||
|
||||
func (a *coverAPIs) options(withKey bool) CoverOptions {
|
||||
opts := CoverOptions{SteamGridDBURL: a.server.URL + "/sgdb", AniListURL: a.server.URL + "/anilist"}
|
||||
if withKey {
|
||||
opts.SteamGridDBKey = "key"
|
||||
}
|
||||
return opts
|
||||
}
|
||||
|
||||
func newCovers(t *testing.T, store *fakedb.Store, opts CoverOptions) *CoverService {
|
||||
t.Helper()
|
||||
c, err := NewCoverService(store, filepath.Join(t.TempDir(), "covers"), opts)
|
||||
require.NoError(t, err)
|
||||
c.interval = 0
|
||||
return c
|
||||
}
|
||||
|
||||
func coverOf(t *testing.T, store *fakedb.Store, name string) repository.Soundtrack {
|
||||
t.Helper()
|
||||
st, ok := store.Soundtrack(name)
|
||||
require.True(t, ok)
|
||||
return st
|
||||
}
|
||||
|
||||
func TestCoverSearchFindsCovers(t *testing.T) {
|
||||
apis := newCoverAPIs(t)
|
||||
apis.games["Chrono Trigger"] = 1
|
||||
apis.games["FF7"] = 7
|
||||
apis.games["Lemmings"] = 3
|
||||
apis.anime["Cowboy Bebop"] = "Cowboy Bebop"
|
||||
apis.anime["Final Fantasy VII"] = "Final Fantasy VII: Advent Children"
|
||||
store := fakedb.New()
|
||||
store.AddSoundtrack(repository.Soundtrack{SoundtrackName: "Chrono Trigger"})
|
||||
ff := store.AddSoundtrack(repository.Soundtrack{SoundtrackName: "Final Fantasy VII", AlternativeNames: []string{"FF7"}})
|
||||
store.AddSoundtrack(repository.Soundtrack{SoundtrackName: "Lemmings (SNES + Mega Drive)"})
|
||||
store.AddSoundtrack(repository.Soundtrack{SoundtrackName: "Cowboy Bebop"})
|
||||
store.AddSoundtrack(repository.Soundtrack{SoundtrackName: "Unknown"})
|
||||
c := newCovers(t, store, apis.options(true))
|
||||
|
||||
require.NoError(t, c.searchMissing(context.Background()))
|
||||
|
||||
chrono := coverOf(t, store, "Chrono Trigger")
|
||||
require.NotNil(t, chrono.CoverFile)
|
||||
assert.Equal(t, chrono.Uuid.String()+".png", *chrono.CoverFile)
|
||||
assert.Equal(t, "steamgriddb", *chrono.CoverSource)
|
||||
assert.Equal(t, "1", *chrono.CoverExternalID)
|
||||
assert.False(t, chrono.CoverManual)
|
||||
assert.True(t, chrono.CoverModified.Valid)
|
||||
assert.FileExists(t, filepath.Join(c.dir, *chrono.CoverFile))
|
||||
|
||||
assert.Equal(t, "7", *coverOf(t, store, "Final Fantasy VII").CoverExternalID, "found by its alternative name")
|
||||
assert.Equal(t, "3", *coverOf(t, store, "Lemmings (SNES + Mega Drive)").CoverExternalID, "found without the part in brackets")
|
||||
bebop := coverOf(t, store, "Cowboy Bebop")
|
||||
assert.Equal(t, "anilist", *bebop.CoverSource, "AniList when SteamGridDB has nothing")
|
||||
|
||||
unknown := coverOf(t, store, "Unknown")
|
||||
assert.Nil(t, unknown.CoverFile)
|
||||
assert.True(t, unknown.CoverChecked.Valid, "marked as searched")
|
||||
assert.NotContains(t, apis.searched(), "anilist:FF7", "SteamGridDB found it first")
|
||||
_ = ff
|
||||
|
||||
// Searched soundtracks are not searched again until the recheck time.
|
||||
before := len(apis.searched())
|
||||
require.NoError(t, c.searchMissing(context.Background()))
|
||||
assert.Len(t, apis.searched(), before)
|
||||
}
|
||||
|
||||
func TestCoverSearchAniListNeedsAnExactTitle(t *testing.T) {
|
||||
apis := newCoverAPIs(t)
|
||||
apis.anime["Final Fantasy VII"] = "Final Fantasy VII: Advent Children"
|
||||
store := fakedb.New()
|
||||
store.AddSoundtrack(repository.Soundtrack{SoundtrackName: "Final Fantasy VII"})
|
||||
c := newCovers(t, store, apis.options(true))
|
||||
|
||||
require.NoError(t, c.searchMissing(context.Background()))
|
||||
|
||||
assert.Nil(t, coverOf(t, store, "Final Fantasy VII").CoverFile, "a near match is not used")
|
||||
}
|
||||
|
||||
func TestCoverSearchStopsWhenAnAPIFails(t *testing.T) {
|
||||
apis := newCoverAPIs(t)
|
||||
apis.failSteamGridDB = true
|
||||
store := fakedb.New()
|
||||
store.AddSoundtrack(repository.Soundtrack{SoundtrackName: "A"})
|
||||
store.AddSoundtrack(repository.Soundtrack{SoundtrackName: "B"})
|
||||
c := newCovers(t, store, apis.options(true))
|
||||
|
||||
err := c.searchMissing(context.Background())
|
||||
assert.ErrorIs(t, err, ErrUpstream)
|
||||
|
||||
assert.Equal(t, []string{"sgdb:A"}, apis.searched(), "the other soundtracks are not tried")
|
||||
assert.False(t, coverOf(t, store, "A").CoverChecked.Valid, "searched again next time")
|
||||
}
|
||||
|
||||
func TestCoverSearchSkipsManualCovers(t *testing.T) {
|
||||
apis := newCoverAPIs(t)
|
||||
apis.games["Chrono Trigger"] = 1
|
||||
store := fakedb.New()
|
||||
st := store.AddSoundtrack(repository.Soundtrack{SoundtrackName: "Chrono Trigger"})
|
||||
c := newCovers(t, store, apis.options(true))
|
||||
require.NoError(t, c.Upload(context.Background(), st.Uuid, pngBytes(t)))
|
||||
require.NoError(t, store.ClearCover(context.Background(), uuid.Nil)) // no-op
|
||||
|
||||
require.NoError(t, c.searchMissing(context.Background()))
|
||||
|
||||
assert.Empty(t, apis.searched())
|
||||
assert.Equal(t, SourceUpload, *coverOf(t, store, "Chrono Trigger").CoverSource)
|
||||
|
||||
// A cover set by hand while the job was searching is kept.
|
||||
stored, err := c.storeFound(context.Background(), st.Uuid, pngBytes(t), "steamgriddb", "1")
|
||||
require.NoError(t, err)
|
||||
assert.False(t, stored)
|
||||
assert.Equal(t, SourceUpload, *coverOf(t, store, "Chrono Trigger").CoverSource)
|
||||
}
|
||||
|
||||
func TestCoverSearchAgainAfterRecheckTime(t *testing.T) {
|
||||
apis := newCoverAPIs(t)
|
||||
store := fakedb.New()
|
||||
store.AddSoundtrack(repository.Soundtrack{SoundtrackName: "Old",
|
||||
CoverChecked: pgtype.Timestamptz{Time: time.Now().Add(-coverRecheckAfter - time.Hour), Valid: true}})
|
||||
store.AddSoundtrack(repository.Soundtrack{SoundtrackName: "Recent",
|
||||
CoverChecked: pgtype.Timestamptz{Time: time.Now().Add(-time.Hour), Valid: true}})
|
||||
c := newCovers(t, store, apis.options(true))
|
||||
|
||||
require.NoError(t, c.searchMissing(context.Background()))
|
||||
|
||||
assert.Contains(t, apis.searched(), "sgdb:Old")
|
||||
assert.NotContains(t, apis.searched(), "sgdb:Recent")
|
||||
}
|
||||
|
||||
func TestCoverUpload(t *testing.T) {
|
||||
store := fakedb.New()
|
||||
st := store.AddSoundtrack(repository.Soundtrack{SoundtrackName: "Game"})
|
||||
c := newCovers(t, store, CoverOptions{})
|
||||
ctx := context.Background()
|
||||
jpeg := append([]byte{0xFF, 0xD8, 0xFF, 0xE0}, make([]byte, 100)...)
|
||||
|
||||
require.NoError(t, c.Upload(ctx, st.Uuid, jpeg))
|
||||
got := coverOf(t, store, "Game")
|
||||
assert.Equal(t, st.Uuid.String()+".jpg", *got.CoverFile)
|
||||
assert.True(t, got.CoverManual)
|
||||
assert.Nil(t, got.CoverExternalID)
|
||||
path, err := c.Path(ctx, st.Uuid)
|
||||
require.NoError(t, err)
|
||||
data, _ := os.ReadFile(path)
|
||||
assert.Equal(t, jpeg, data)
|
||||
|
||||
// A PNG replaces the JPEG, and its old file is removed.
|
||||
require.NoError(t, c.Upload(ctx, st.Uuid, pngBytes(t)))
|
||||
assert.Equal(t, st.Uuid.String()+".png", *coverOf(t, store, "Game").CoverFile)
|
||||
assert.NoFileExists(t, filepath.Join(c.dir, st.Uuid.String()+".jpg"))
|
||||
|
||||
assert.ErrorIs(t, c.Upload(ctx, st.Uuid, []byte("<svg></svg>")), ErrInvalidImage)
|
||||
assert.ErrorIs(t, c.Upload(ctx, st.Uuid, append(pngBytes(t), make([]byte, MaxCoverBytes)...)), ErrInvalidImage)
|
||||
assert.ErrorIs(t, c.Upload(ctx, uuid.New(), pngBytes(t)), ErrNotFound)
|
||||
entries, _ := os.ReadDir(c.dir)
|
||||
assert.Len(t, entries, 1, "no temporary files are left behind")
|
||||
}
|
||||
|
||||
func TestCoverFromSource(t *testing.T) {
|
||||
apis := newCoverAPIs(t)
|
||||
store := fakedb.New()
|
||||
st := store.AddSoundtrack(repository.Soundtrack{SoundtrackName: "Game"})
|
||||
ctx := context.Background()
|
||||
|
||||
c := newCovers(t, store, apis.options(true))
|
||||
require.NoError(t, c.FromSource(ctx, st.Uuid, "steamgriddb", "5"))
|
||||
got := coverOf(t, store, "Game")
|
||||
assert.Equal(t, "steamgriddb", *got.CoverSource)
|
||||
assert.Equal(t, "5", *got.CoverExternalID)
|
||||
assert.True(t, got.CoverManual, "a picked cover is never replaced by the job")
|
||||
|
||||
require.NoError(t, c.FromSource(ctx, st.Uuid, "anilist", "42"))
|
||||
assert.Equal(t, "anilist", *coverOf(t, store, "Game").CoverSource)
|
||||
|
||||
assert.ErrorIs(t, c.FromSource(ctx, st.Uuid, "steamgriddb", "404"), ErrNotFound, "no grids")
|
||||
assert.ErrorIs(t, c.FromSource(ctx, st.Uuid, "steamgriddb", "abc"), ErrNotFound)
|
||||
assert.ErrorIs(t, c.FromSource(ctx, st.Uuid, "anilist", "7"), ErrNotFound)
|
||||
assert.ErrorIs(t, c.FromSource(ctx, st.Uuid, "igdb", "1"), ErrInvalidName)
|
||||
assert.ErrorIs(t, c.FromSource(ctx, uuid.New(), "anilist", "42"), ErrNotFound)
|
||||
|
||||
withoutKey := newCovers(t, store, apis.options(false))
|
||||
assert.ErrorIs(t, withoutKey.FromSource(ctx, st.Uuid, "steamgriddb", "5"), ErrInvalidName)
|
||||
require.NoError(t, withoutKey.FromSource(ctx, st.Uuid, "anilist", "42"), "AniList needs no key")
|
||||
}
|
||||
|
||||
func TestCoverDownloadMustBeAnImage(t *testing.T) {
|
||||
apis := newCoverAPIs(t)
|
||||
c := newCovers(t, fakedb.New(), apis.options(true))
|
||||
_, err := c.download(context.Background(), apis.server.URL+"/img/text.png")
|
||||
assert.ErrorIs(t, err, ErrUpstream)
|
||||
}
|
||||
|
||||
func TestCoverRemove(t *testing.T) {
|
||||
store := fakedb.New()
|
||||
st := store.AddSoundtrack(repository.Soundtrack{SoundtrackName: "Game"})
|
||||
c := newCovers(t, store, CoverOptions{})
|
||||
ctx := context.Background()
|
||||
require.NoError(t, c.Upload(ctx, st.Uuid, pngBytes(t)))
|
||||
|
||||
require.NoError(t, c.Remove(ctx, st.Uuid))
|
||||
|
||||
got := coverOf(t, store, "Game")
|
||||
assert.Nil(t, got.CoverFile)
|
||||
assert.False(t, got.CoverManual)
|
||||
assert.False(t, got.CoverChecked.Valid, "the job searches for it again")
|
||||
assert.NoFileExists(t, filepath.Join(c.dir, st.Uuid.String()+".png"))
|
||||
_, err := c.Path(ctx, st.Uuid)
|
||||
assert.ErrorIs(t, err, ErrNotFound)
|
||||
assert.Len(t, c.trigger, 1, "the job is asked to search")
|
||||
}
|
||||
|
||||
func TestCoverRemoveOrphans(t *testing.T) {
|
||||
store := fakedb.New()
|
||||
st := store.AddSoundtrack(repository.Soundtrack{SoundtrackName: "Game"})
|
||||
c := newCovers(t, store, CoverOptions{})
|
||||
require.NoError(t, c.Upload(context.Background(), st.Uuid, pngBytes(t)))
|
||||
writeFile(t, filepath.Join(c.dir, uuid.NewString()+".png"), "left over after a reset")
|
||||
|
||||
c.removeOrphans(context.Background())
|
||||
|
||||
entries, _ := os.ReadDir(c.dir)
|
||||
require.Len(t, entries, 1)
|
||||
assert.Equal(t, st.Uuid.String()+".png", entries[0].Name())
|
||||
}
|
||||
|
||||
func TestCoverRunSearchesUntilStopped(t *testing.T) {
|
||||
apis := newCoverAPIs(t)
|
||||
apis.games["Game"] = 1
|
||||
store := fakedb.New()
|
||||
store.AddSoundtrack(repository.Soundtrack{SoundtrackName: "Game"})
|
||||
c := newCovers(t, store, apis.options(true))
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
done := make(chan struct{})
|
||||
go func() { c.Run(ctx); close(done) }()
|
||||
|
||||
assert.Eventually(t, func() bool { return coverOf(t, store, "Game").CoverFile != nil }, time.Second, 10*time.Millisecond,
|
||||
"searches when started")
|
||||
store.AddSoundtrack(repository.Soundtrack{SoundtrackName: "Game"}) // same name, new soundtrack
|
||||
c.Trigger()
|
||||
assert.Eventually(t, func() bool { return strings.Count(strings.Join(apis.searched(), ","), "sgdb:Game") == 2 },
|
||||
time.Second, 10*time.Millisecond, "searches again when triggered")
|
||||
|
||||
cancel()
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("Run did not stop")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCoverRunWithoutKeyDoesNothing(t *testing.T) {
|
||||
c := newCovers(t, fakedb.New(), CoverOptions{})
|
||||
assert.False(t, c.SearchesAutomatically())
|
||||
done := make(chan struct{})
|
||||
go func() { c.Run(context.Background()); close(done) }()
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("Run should return at once without a SteamGridDB key")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCoverSearchNames(t *testing.T) {
|
||||
assert.Equal(t, []string{"Lemmings (SNES + Mega Drive)", "LEM", "lemmings"},
|
||||
coverSearchNames("Lemmings (SNES + Mega Drive)", []string{"LEM", "lemmings"}))
|
||||
assert.Equal(t, []string{"Chrono Trigger"}, coverSearchNames("Chrono Trigger", nil))
|
||||
assert.Equal(t, []string{"Resident Evil 2 [Remake]", "Resident Evil 2"}, coverSearchNames("Resident Evil 2 [Remake]", nil))
|
||||
}
|
||||
|
||||
func TestNormalizeTitle(t *testing.T) {
|
||||
assert.Equal(t, normalizeTitle("Cowboy Bebop: The Movie"), normalizeTitle("cowboy bebop the movie"))
|
||||
assert.Equal(t, "ファイナルファンタジーvii", normalizeTitle("ファイナルファンタジーVII"))
|
||||
}
|
||||
|
||||
func TestBestGame(t *testing.T) {
|
||||
games := func(names ...string) []steamGridDBGame {
|
||||
result := make([]steamGridDBGame, len(names))
|
||||
for i, name := range names {
|
||||
result[i] = steamGridDBGame{ID: i + 1, Name: name}
|
||||
}
|
||||
return result
|
||||
}
|
||||
tests := []struct {
|
||||
search string
|
||||
games []steamGridDBGame
|
||||
want string
|
||||
}{
|
||||
// Real SteamGridDB results, in their order.
|
||||
{"Civilization IV", games("Sid Meier's Civilization IV: Colonization", "Sid Meier's Civilization IV",
|
||||
"Sid Meier's Civilization IV: Warlords", "Galactic Civilizations IV"), "Sid Meier's Civilization IV"},
|
||||
{"Lemmings 3D", games("3D Lemmings Winterland", "3D Lemmings", "Lemmings"), "3D Lemmings"},
|
||||
{"Octopath Traveler 2", games("Octopath Traveler II", "Octopath Traveler"), "Octopath Traveler II"},
|
||||
{"Joe & Mac", games("Joe & Mac: Caveman Ninja", "Joe & Mac Returns"), "Joe & Mac: Caveman Ninja"},
|
||||
{"Ys (Chronicles)", games("Ys I & II Chronicles", "Ys Origin"), "Ys I & II Chronicles"},
|
||||
// No result has every word: SteamGridDB's first result.
|
||||
{"Resident Evil 2 (Remake)", games("Resident Evil 2", "Resident Evil 2: 1-Shot Demo"), "Resident Evil 2"},
|
||||
{"Pokémon HeartGold & SoulSilver", games("Pokémon HeartGold Version", "Pokémon SoulSilver Version"), "Pokémon HeartGold Version"},
|
||||
{"Kingdom Hearts", games("Kingdom Hearts II", "Kingdom Hearts"), "Kingdom Hearts"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
assert.Equal(t, tt.want, bestGame(tt.search, tt.games).Name, tt.search)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user