Files
scrabble-game/backend/internal/game/helpers_test.go
T
Ilia Denisov ecb21bd218
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 17s
CI / ui (pull_request) Has been skipped
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 2m18s
perf(backend): cut evaluate's DB round-trips; load the game in one query
EvaluatePlay (the hottest gameplay call, fired on every tile placement) now uses
the warm live-game cache directly: an active game stays cached (mutated in place
across moves, evicted only on finish), so the cached engine game and its immutable
seat list answer the membership check and the score with no DB read. The cold path
(eviction / first load) still loads and validates via the store. The seat list is
cached alongside the engine game for the membership fast path.

GetGame also folds its two round-trips (game, then seats) into one LEFT JOIN,
preserving the contract (same Game, a seatless game still returns empty seats, seat
order kept) — one round-trip for every remaining caller.

Measured at 500 players: evaluate p99 halves (200 -> 100 ms) and the per-op query
count drops. It does NOT cut postgres CPU — that is write-bound (per-move CommitMove
plus draft upserts and journal replays), the cheap indexed GetGame reads were never
its bottleneck, and postgres runs with headroom (~1.5 of 2 cores). So this is a
latency / query-volume optimization, not a DB-CPU one.

Regression cover: a non-player evaluate against a warm game asserts the cached-seat
membership path; the integration suite exercises GetGame's join across every game op.
2026-06-21 20:47:13 +02:00

140 lines
3.5 KiB
Go

package game
import (
"sync"
"testing"
"time"
"github.com/google/uuid"
"scrabble/backend/internal/engine"
)
func TestPayloadPlayRoundTrip(t *testing.T) {
rec := engine.MoveRecord{
Action: engine.ActionPlay, Dir: engine.Vertical, MainRow: 3, MainCol: 4,
Tiles: []engine.TileRecord{{Row: 3, Col: 4, Letter: "q", Blank: true}, {Row: 4, Col: 4, Letter: "i"}},
Words: []string{"qi"},
}
s, err := buildPayload(rec, []string{"q", "i", "?"}, nil).marshal()
if err != nil {
t.Fatalf("marshal: %v", err)
}
p, err := parsePayload(s)
if err != nil {
t.Fatalf("parse: %v", err)
}
if p.direction() != engine.Vertical || p.MainRow != 3 || p.MainCol != 4 {
t.Errorf("dir/anchor = %v/(%d,%d)", p.direction(), p.MainRow, p.MainCol)
}
tiles := p.tileRecords()
if len(tiles) != 2 || tiles[0].Letter != "q" || !tiles[0].Blank || tiles[1].Letter != "i" {
t.Errorf("tiles = %+v", tiles)
}
if len(p.Rack) != 3 || p.Rack[2] != "?" {
t.Errorf("rack = %v", p.Rack)
}
}
func TestPayloadExchangeRoundTrip(t *testing.T) {
rec := engine.MoveRecord{Action: engine.ActionExchange, Count: 2}
s, err := buildPayload(rec, []string{"a", "b", "c"}, []string{"a", "b"}).marshal()
if err != nil {
t.Fatalf("marshal: %v", err)
}
p, err := parsePayload(s)
if err != nil {
t.Fatalf("parse: %v", err)
}
if len(p.Exchanged) != 2 || p.Exchanged[0] != "a" {
t.Errorf("exchanged = %v", p.Exchanged)
}
if len(p.Tiles) != 0 || p.Dir != "" {
t.Errorf("exchange payload carried play fields: %+v", p)
}
}
func TestHintsRemaining(t *testing.T) {
cases := []struct{ allowance, used, wallet, want int }{
{1, 0, 3, 4},
{1, 1, 3, 3},
{1, 2, 3, 3}, // used past allowance clamps to 0
{0, 0, 5, 5},
{2, 1, 0, 1},
}
for _, c := range cases {
if got := hintsRemaining(c.allowance, c.used, c.wallet); got != c.want {
t.Errorf("hintsRemaining(%d,%d,%d) = %d, want %d", c.allowance, c.used, c.wallet, got, c.want)
}
}
}
func TestAllowedTimeout(t *testing.T) {
if !allowedTimeout(24 * time.Hour) {
t.Error("24h must be allowed")
}
if !allowedTimeout(5 * time.Minute) {
t.Error("5m must be allowed")
}
if allowedTimeout(7 * time.Minute) {
t.Error("7m must not be allowed")
}
if allowedTimeout(0) {
t.Error("zero must not be allowed")
}
}
func TestNormalizeWord(t *testing.T) {
if got := normalizeWord(" CaT \n"); got != "cat" {
t.Errorf("normalizeWord = %q, want cat", got)
}
}
func TestGameCacheEviction(t *testing.T) {
cur := time.Unix(1_700_000_000, 0)
cache := newGameCache(time.Hour, func() time.Time { return cur })
id := uuid.New()
cache.put(id, nil, "scrabble_en", nil)
if _, _, ok := cache.get(id); !ok {
t.Fatal("game must be resident after put")
}
cur = cur.Add(30 * time.Minute)
cache.get(id) // refresh idle timer
cur = cur.Add(90 * time.Minute)
if n := cache.sweep(); n != 1 {
t.Errorf("sweep evicted %d, want 1", n)
}
if _, _, ok := cache.get(id); ok {
t.Error("game must be evicted after idle TTL")
}
if cache.size() != 0 {
t.Errorf("cache size = %d, want 0", cache.size())
}
}
func TestKeyedMutexSerializes(t *testing.T) {
km := newKeyedMutex()
id := uuid.New()
var counter int
var wg sync.WaitGroup
for i := 0; i < 200; i++ {
wg.Add(1)
go func() {
defer wg.Done()
unlock := km.lock(id)
counter++ // serialised; -race would flag a missing lock
unlock()
}()
}
wg.Wait()
if counter != 200 {
t.Errorf("counter = %d, want 200", counter)
}
km.mu.Lock()
left := len(km.locks)
km.mu.Unlock()
if left != 0 {
t.Errorf("lock map not cleaned up: %d entries left", left)
}
}