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, "english") 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) } }