The RomM replacement. - registry: a games media type with ~35 ROM extensions across NES, SNES, GB/GBA, N64, NDS, Genesis/Master System/Game Gear, PC Engine, Neo Geo Pocket, WonderSwan, Atari, Coleco, Intellivision, and PlayStation. - games: platform detection from the file extension, mapping each to an EmulatorJS core. Platforms with no browser core are recognised but marked not-playable so the UI offers a download instead of a broken player. - migration 0005: game_saves keyed by (user, item, slot). Save states are user data, not media — two people playing the same ROM keep their own progress. - save endpoints with a 16 MiB cap and a validated slot name; the upload is read through a LimitReader so an unbounded body is refused rather than buffered. - igdb provider: Twitch client-credentials flow with the token cached for its ~2 month lifetime, Apicalypse query body, and cover URLs normalised from protocol-relative t_thumb to https t_cover_big. - searchTitle: strips "(USA)", "[!]", "(Rev 1)" and similar release tags before searching. "Sonic the Hedgehog (USA)" matched nothing; "Sonic the Hedgehog" matches immediately. Stored titles keep the original. - web: GamePlayer embeds EmulatorJS and adds Load/Save-to-server buttons. Verified live against IGDB: Sonic matched (id 316900) with real cover art; a 4 KB save uploaded, listed, downloaded byte-identical, and deleted.
176 lines
4.9 KiB
Go
176 lines
4.9 KiB
Go
package games
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"io"
|
|
"log/slog"
|
|
"path/filepath"
|
|
"testing"
|
|
|
|
"github.com/mithral/vellum/internal/core"
|
|
"github.com/mithral/vellum/migrations"
|
|
)
|
|
|
|
func testDB(t *testing.T) *sql.DB {
|
|
t.Helper()
|
|
db, err := core.OpenDB(filepath.Join(t.TempDir(), "games.db"))
|
|
if err != nil {
|
|
t.Fatalf("OpenDB: %v", err)
|
|
}
|
|
t.Cleanup(func() { _ = db.Close() })
|
|
log := slog.New(slog.NewTextHandler(io.Discard, nil))
|
|
if err := core.Migrate(context.Background(), db, migrations.FS, log); err != nil {
|
|
t.Fatalf("Migrate: %v", err)
|
|
}
|
|
return db
|
|
}
|
|
|
|
// seedGame creates a user, library, and game item, returning their ids.
|
|
func seedGame(t *testing.T, db *sql.DB) (userID, itemID int64) {
|
|
t.Helper()
|
|
ctx := context.Background()
|
|
|
|
if err := db.QueryRowContext(ctx,
|
|
`INSERT INTO users (username, password_hash) VALUES ('player','x') RETURNING id`).Scan(&userID); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var libID int64
|
|
if err := db.QueryRowContext(ctx,
|
|
`INSERT INTO libraries (name, kind, root_path) VALUES ('Games','games','/roms') RETURNING id`).Scan(&libID); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := db.QueryRowContext(ctx,
|
|
`INSERT INTO media_items (library_id, kind, path, title) VALUES (?, 'games', 'Sonic.md', 'Sonic') RETURNING id`,
|
|
libID).Scan(&itemID); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return userID, itemID
|
|
}
|
|
|
|
func TestPlatformFor(t *testing.T) {
|
|
cases := []struct {
|
|
path string
|
|
platform string
|
|
core string
|
|
ok bool
|
|
}{
|
|
{"Mario.nes", "Nintendo Entertainment System", "nes", true},
|
|
{"Zelda.SFC", "Super Nintendo", "snes", true},
|
|
{"Pokemon.gba", "Game Boy Advance", "gba", true},
|
|
{"Sonic.md", "Sega Genesis", "segaMD", true},
|
|
{"Mario Kart.n64", "Nintendo 64", "n64", true},
|
|
{"Final Fantasy VII.cue", "PlayStation", "psx", true},
|
|
// A platform with no browser core: recognised, but not playable.
|
|
{"Animal Crossing.3ds", "Nintendo 3DS", "", true},
|
|
{"mystery.xyz", "", "", false},
|
|
{"noextension", "", "", false},
|
|
}
|
|
for _, c := range cases {
|
|
p, ok := PlatformFor(c.path)
|
|
if ok != c.ok {
|
|
t.Errorf("PlatformFor(%q) ok = %v, want %v", c.path, ok, c.ok)
|
|
continue
|
|
}
|
|
if !ok {
|
|
continue
|
|
}
|
|
if p.Name != c.platform {
|
|
t.Errorf("PlatformFor(%q).Name = %q, want %q", c.path, p.Name, c.platform)
|
|
}
|
|
if p.Core != c.core {
|
|
t.Errorf("PlatformFor(%q).Core = %q, want %q", c.path, p.Core, c.core)
|
|
}
|
|
if p.Playable() != (c.core != "") {
|
|
t.Errorf("PlatformFor(%q).Playable() = %v", c.path, p.Playable())
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestSaveRoundTrip(t *testing.T) {
|
|
db := testDB(t)
|
|
ctx := context.Background()
|
|
userID, itemID := seedGame(t, db)
|
|
|
|
// Nothing stored yet.
|
|
if _, found, err := GetSave(ctx, db, userID, itemID, "default"); err != nil || found {
|
|
t.Fatalf("expected no save: found=%v err=%v", found, err)
|
|
}
|
|
|
|
state := []byte{0x01, 0x02, 0x03, 0x04}
|
|
if err := PutSave(ctx, db, userID, itemID, "default", state); err != nil {
|
|
t.Fatalf("PutSave: %v", err)
|
|
}
|
|
|
|
got, found, err := GetSave(ctx, db, userID, itemID, "default")
|
|
if err != nil || !found {
|
|
t.Fatalf("GetSave: found=%v err=%v", found, err)
|
|
}
|
|
if len(got) != len(state) || got[0] != 0x01 {
|
|
t.Errorf("save bytes = %v, want %v", got, state)
|
|
}
|
|
|
|
// Overwrite.
|
|
if err := PutSave(ctx, db, userID, itemID, "default", []byte("second")); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
got, _, _ = GetSave(ctx, db, userID, itemID, "default")
|
|
if string(got) != "second" {
|
|
t.Errorf("overwrite failed: %q", got)
|
|
}
|
|
|
|
// A second slot is independent.
|
|
if err := PutSave(ctx, db, userID, itemID, "slot2", []byte("two")); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
saves, err := ListSaves(ctx, db, userID, itemID)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(saves) != 2 {
|
|
t.Errorf("got %d saves, want 2", len(saves))
|
|
}
|
|
|
|
// Deleting one leaves the other.
|
|
if err := DeleteSave(ctx, db, userID, itemID, "default"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
saves, _ = ListSaves(ctx, db, userID, itemID)
|
|
if len(saves) != 1 || saves[0].Slot != "slot2" {
|
|
t.Errorf("after delete: %+v", saves)
|
|
}
|
|
}
|
|
|
|
func TestSavesArePerUser(t *testing.T) {
|
|
db := testDB(t)
|
|
ctx := context.Background()
|
|
userA, itemID := seedGame(t, db)
|
|
|
|
var userB int64
|
|
if err := db.QueryRowContext(ctx,
|
|
`INSERT INTO users (username, password_hash) VALUES ('other','x') RETURNING id`).Scan(&userB); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
if err := PutSave(ctx, db, userA, itemID, "default", []byte("A's progress")); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// The other user sees nothing; saves are not shared.
|
|
if _, found, err := GetSave(ctx, db, userB, itemID, "default"); err != nil || found {
|
|
t.Errorf("user B can see user A's save: found=%v err=%v", found, err)
|
|
}
|
|
}
|
|
|
|
func TestPutSaveRejectsBadInput(t *testing.T) {
|
|
db := testDB(t)
|
|
ctx := context.Background()
|
|
userID, itemID := seedGame(t, db)
|
|
|
|
if err := PutSave(ctx, db, userID, itemID, "default", nil); err == nil {
|
|
t.Error("empty save was accepted")
|
|
}
|
|
if err := PutSave(ctx, db, userID, itemID, "default", make([]byte, MaxSaveBytes+1)); err == nil {
|
|
t.Error("oversized save was accepted")
|
|
}
|
|
}
|