10412fee8e
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Failing after 12s
CI / ui (pull_request) Successful in 30s
CI / gate (pull_request) Failing after 1s
CI / deploy (pull_request) Has been skipped
- Resign on the opponent's turn: engine ResignSeat(seat) resigns a specific seat (not just toMove); game.Resign bypasses the turn check and forfeits the actor's seat. - Quick-match cancel was a UI no-op (only stopped polling): add the full path (REST /lobby/cancel -> gateway lobby.cancel -> client) and clear the matchmaker's pending result on Cancel, so a cancelled search is dequeued (no 'already queued', no later robot-substituted game). NewGame dequeues on cancel and on abandon. - Lobby win/loss: result.ts ranked by score, so a 0-0 resignation read as a win. The winner now takes rank 1 and the viewer is placed from rank 2 — matching the game-detail screen. - Friend request to a robot: robots no longer block requests; the request stays pending and expires (friendRequestTTL), mirroring a human who ignores it. - Nudge cooldown: ErrNudgeTooSoon now maps to a distinct nudge_too_soon code with a correct message; the chat nudge button disables during the hourly cooldown; the nudge note reads 'Waiting for your move!' (button keeps the Nudge action label). Tests: engine/service off-turn resign, matchmaker cancel-clears-result, friend-to-robot inttest, result.ts 0-0 resignation, nudge_too_soon mapping.
631 lines
20 KiB
Go
631 lines
20 KiB
Go
//go:build integration
|
||
|
||
package inttest
|
||
|
||
import (
|
||
"context"
|
||
"database/sql"
|
||
"errors"
|
||
"sync"
|
||
"testing"
|
||
"time"
|
||
|
||
"github.com/google/uuid"
|
||
"go.uber.org/zap"
|
||
|
||
"scrabble/backend/internal/account"
|
||
"scrabble/backend/internal/engine"
|
||
"scrabble/backend/internal/game"
|
||
)
|
||
|
||
// newGameService builds a game service over the shared pool and registry.
|
||
func newGameService() *game.Service {
|
||
return game.NewService(
|
||
game.NewStore(testDB),
|
||
account.NewStore(testDB),
|
||
testRegistry,
|
||
game.Config{
|
||
DictDir: dictDir(),
|
||
DictVersion: testDictVersion,
|
||
TimeoutSweepInterval: time.Minute,
|
||
CacheTTL: time.Hour,
|
||
},
|
||
zap.NewNop(),
|
||
)
|
||
}
|
||
|
||
// provisionAccount creates a fresh durable account and returns its id.
|
||
func provisionAccount(t *testing.T) uuid.UUID {
|
||
t.Helper()
|
||
acc, err := account.NewStore(testDB).ProvisionByIdentity(context.Background(), account.KindTelegram, "tg-"+uuid.NewString())
|
||
if err != nil {
|
||
t.Fatalf("provision account: %v", err)
|
||
}
|
||
return acc.ID
|
||
}
|
||
|
||
// openingSeed returns a seed whose fresh two-player English opening rack has a
|
||
// legal move, so a greedy mirror can drive a game.
|
||
func openingSeed(t *testing.T) int64 {
|
||
t.Helper()
|
||
for seed := int64(1); seed <= 200; seed++ {
|
||
g, err := engine.New(testRegistry, engine.Options{Variant: engine.VariantEnglish, Version: testDictVersion, Players: 2, Seed: seed})
|
||
if err != nil {
|
||
t.Fatalf("engine new: %v", err)
|
||
}
|
||
if _, ok := g.HintView(); ok {
|
||
return seed
|
||
}
|
||
}
|
||
t.Fatal("no opening seed found")
|
||
return 0
|
||
}
|
||
|
||
// newMirror builds a parallel engine game with the same seed, used to compute
|
||
// legal moves to feed the service under test.
|
||
func newMirror(t *testing.T, seed int64, players int) *engine.Game {
|
||
t.Helper()
|
||
g, err := engine.New(testRegistry, engine.Options{Variant: engine.VariantEnglish, Version: testDictVersion, Players: players, Seed: seed})
|
||
if err != nil {
|
||
t.Fatalf("mirror new: %v", err)
|
||
}
|
||
return g
|
||
}
|
||
|
||
// readStats reads an account's statistics row.
|
||
func readStats(t *testing.T, id uuid.UUID) (wins, losses, draws, maxGame, maxWord int, found bool) {
|
||
t.Helper()
|
||
row := testDB.QueryRowContext(context.Background(),
|
||
`SELECT wins, losses, draws, max_game_points, max_word_points FROM backend.account_stats WHERE account_id = $1`, id)
|
||
if err := row.Scan(&wins, &losses, &draws, &maxGame, &maxWord); err != nil {
|
||
if errors.Is(err, sql.ErrNoRows) {
|
||
return 0, 0, 0, 0, 0, false
|
||
}
|
||
t.Fatalf("read stats: %v", err)
|
||
}
|
||
return wins, losses, draws, maxGame, maxWord, true
|
||
}
|
||
|
||
// TestListForAccount checks the lobby "my games" query: it returns exactly the
|
||
// games the account is seated in (each with its seats), and nothing for an outsider.
|
||
func TestListForAccount(t *testing.T) {
|
||
ctx := context.Background()
|
||
svc := newGameService()
|
||
me, opp := provisionAccount(t), provisionAccount(t)
|
||
|
||
g1, err := svc.Create(ctx, game.CreateParams{
|
||
Variant: engine.VariantEnglish, Seats: []uuid.UUID{me, opp}, TurnTimeout: 24 * time.Hour, Seed: 1,
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("create g1: %v", err)
|
||
}
|
||
g2, err := svc.Create(ctx, game.CreateParams{
|
||
Variant: engine.VariantEnglish, Seats: []uuid.UUID{opp, me}, TurnTimeout: 24 * time.Hour, Seed: 2,
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("create g2: %v", err)
|
||
}
|
||
|
||
games, err := svc.ListForAccount(ctx, me)
|
||
if err != nil {
|
||
t.Fatalf("list: %v", err)
|
||
}
|
||
if len(games) != 2 {
|
||
t.Fatalf("got %d games, want 2", len(games))
|
||
}
|
||
seen := map[uuid.UUID]bool{}
|
||
for _, g := range games {
|
||
seen[g.ID] = true
|
||
if len(g.Seats) != 2 {
|
||
t.Errorf("game %s has %d seats, want 2", g.ID, len(g.Seats))
|
||
}
|
||
seated := false
|
||
for _, s := range g.Seats {
|
||
if s.AccountID == me {
|
||
seated = true
|
||
}
|
||
}
|
||
if !seated {
|
||
t.Errorf("account not found among seats of returned game %s", g.ID)
|
||
}
|
||
}
|
||
if !seen[g1.ID] || !seen[g2.ID] {
|
||
t.Errorf("returned games %v missing g1=%s or g2=%s", seen, g1.ID, g2.ID)
|
||
}
|
||
|
||
other := provisionAccount(t)
|
||
og, err := svc.ListForAccount(ctx, other)
|
||
if err != nil {
|
||
t.Fatalf("list other: %v", err)
|
||
}
|
||
if len(og) != 0 {
|
||
t.Errorf("outsider sees %d games, want 0", len(og))
|
||
}
|
||
}
|
||
|
||
// TestGameLifecycleAndStats drives a greedy two-player game to its natural end
|
||
// through the service and checks the finish state and statistics.
|
||
func TestGameLifecycleAndStats(t *testing.T) {
|
||
ctx := context.Background()
|
||
svc := newGameService()
|
||
seats := []uuid.UUID{provisionAccount(t), provisionAccount(t)}
|
||
seed := openingSeed(t)
|
||
|
||
g, err := svc.Create(ctx, game.CreateParams{
|
||
Variant: engine.VariantEnglish, Seats: seats, TurnTimeout: 24 * time.Hour,
|
||
HintsAllowed: true, HintsPerPlayer: 1, Seed: seed,
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("create: %v", err)
|
||
}
|
||
if g.Status != game.StatusActive || g.Players != 2 {
|
||
t.Fatalf("unexpected new game: %+v", g)
|
||
}
|
||
|
||
mirror := newMirror(t, seed, 2)
|
||
var last game.MoveResult
|
||
for i := 0; i < 300 && !mirror.Over(); i++ {
|
||
cur := seats[mirror.ToMove()]
|
||
if hint, ok := mirror.HintView(); ok {
|
||
last, err = svc.SubmitPlay(ctx, g.ID, cur, hint.Dir, hint.Tiles)
|
||
if err != nil {
|
||
t.Fatalf("submit play: %v", err)
|
||
}
|
||
if _, err := mirror.SubmitPlay(hint.Dir, hint.Tiles); err != nil {
|
||
t.Fatalf("mirror play: %v", err)
|
||
}
|
||
} else {
|
||
last, err = svc.Pass(ctx, g.ID, cur)
|
||
if err != nil {
|
||
t.Fatalf("pass: %v", err)
|
||
}
|
||
if _, err := mirror.Pass(); err != nil {
|
||
t.Fatalf("mirror pass: %v", err)
|
||
}
|
||
}
|
||
}
|
||
if !mirror.Over() {
|
||
t.Fatal("greedy game did not finish")
|
||
}
|
||
if last.Game.Status != game.StatusFinished || last.Game.EndReason == "" {
|
||
t.Fatalf("final game not finished: %+v", last.Game)
|
||
}
|
||
|
||
w0, l0, d0, mg0, _, ok0 := readStats(t, seats[0])
|
||
w1, l1, d1, mg1, _, ok1 := readStats(t, seats[1])
|
||
if !ok0 || !ok1 {
|
||
t.Fatal("both players must have a stats row")
|
||
}
|
||
if mg0 <= 0 || mg1 <= 0 {
|
||
t.Errorf("max game points must be positive: %d, %d", mg0, mg1)
|
||
}
|
||
decisive := (w0 == 1 && l1 == 1) || (w1 == 1 && l0 == 1)
|
||
draw := d0 == 1 && d1 == 1 && w0 == 0 && w1 == 0
|
||
if !decisive && !draw {
|
||
t.Errorf("inconsistent W/L/D: p0(%d/%d/%d) p1(%d/%d/%d)", w0, l0, d0, w1, l1, d1)
|
||
}
|
||
}
|
||
|
||
// TestReplayEquivalence plays a few moves through one service, then proves a
|
||
// second service with a cold cache rebuilds the identical hidden state from the
|
||
// journal.
|
||
func TestReplayEquivalence(t *testing.T) {
|
||
ctx := context.Background()
|
||
svc := newGameService()
|
||
seats := []uuid.UUID{provisionAccount(t), provisionAccount(t)}
|
||
seed := openingSeed(t)
|
||
g, err := svc.Create(ctx, game.CreateParams{
|
||
Variant: engine.VariantEnglish, Seats: seats, TurnTimeout: 24 * time.Hour, Seed: seed,
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("create: %v", err)
|
||
}
|
||
|
||
mirror := newMirror(t, seed, 2)
|
||
for i := 0; i < 6 && !mirror.Over(); i++ {
|
||
cur := seats[mirror.ToMove()]
|
||
if hint, ok := mirror.HintView(); ok {
|
||
if _, err := svc.SubmitPlay(ctx, g.ID, cur, hint.Dir, hint.Tiles); err != nil {
|
||
t.Fatalf("submit: %v", err)
|
||
}
|
||
mirror.SubmitPlay(hint.Dir, hint.Tiles)
|
||
} else {
|
||
if _, err := svc.Pass(ctx, g.ID, cur); err != nil {
|
||
t.Fatalf("pass: %v", err)
|
||
}
|
||
mirror.Pass()
|
||
}
|
||
}
|
||
|
||
warm, err := svc.GameState(ctx, g.ID, seats[0])
|
||
if err != nil {
|
||
t.Fatalf("warm state: %v", err)
|
||
}
|
||
cold, err := newGameService().GameState(ctx, g.ID, seats[0]) // fresh cache → replay
|
||
if err != nil {
|
||
t.Fatalf("cold state: %v", err)
|
||
}
|
||
if warm.BagLen != cold.BagLen {
|
||
t.Errorf("bag len warm %d != cold %d", warm.BagLen, cold.BagLen)
|
||
}
|
||
if !equalStrings(warm.Rack, cold.Rack) {
|
||
t.Errorf("rack warm %v != replayed %v", warm.Rack, cold.Rack)
|
||
}
|
||
}
|
||
|
||
// TestResignWinnerAndStats checks that a resigner loses and keeps their score
|
||
// while the opponent wins, with statistics to match.
|
||
func TestResignWinnerAndStats(t *testing.T) {
|
||
ctx := context.Background()
|
||
svc := newGameService()
|
||
seats := []uuid.UUID{provisionAccount(t), provisionAccount(t)}
|
||
seed := openingSeed(t)
|
||
g, err := svc.Create(ctx, game.CreateParams{
|
||
Variant: engine.VariantEnglish, Seats: seats, TurnTimeout: 24 * time.Hour, Seed: seed,
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("create: %v", err)
|
||
}
|
||
|
||
mirror := newMirror(t, seed, 2)
|
||
hint, ok := mirror.HintView()
|
||
if !ok {
|
||
t.Fatal("no opening move")
|
||
}
|
||
played, err := svc.SubmitPlay(ctx, g.ID, seats[0], hint.Dir, hint.Tiles) // p0 scores
|
||
if err != nil {
|
||
t.Fatalf("p0 play: %v", err)
|
||
}
|
||
score0 := played.Game.Seats[0].Score
|
||
|
||
res, err := svc.Resign(ctx, g.ID, seats[1]) // p1 (trailing 0) resigns
|
||
if err != nil {
|
||
t.Fatalf("resign: %v", err)
|
||
}
|
||
if res.Game.Status != game.StatusFinished || res.Game.EndReason != "resign" {
|
||
t.Fatalf("after resign: %+v", res.Game)
|
||
}
|
||
if !res.Game.Seats[0].IsWinner || res.Game.Seats[1].IsWinner {
|
||
t.Errorf("winner flags wrong: %+v", res.Game.Seats)
|
||
}
|
||
if res.Game.Seats[0].Score != score0 {
|
||
t.Errorf("winner score changed on resign: %d -> %d", score0, res.Game.Seats[0].Score)
|
||
}
|
||
|
||
w0, l0, _, _, _, _ := readStats(t, seats[0])
|
||
w1, l1, _, _, _, _ := readStats(t, seats[1])
|
||
if w0 != 1 || l0 != 0 || w1 != 0 || l1 != 1 {
|
||
t.Errorf("resign stats wrong: p0(%d/%d) p1(%d/%d)", w0, l0, w1, l1)
|
||
}
|
||
}
|
||
|
||
// TestResignOnOpponentTurn checks the Stage 17 fix: a player can forfeit on the
|
||
// opponent's turn. Seat 0 plays (so it is seat 1's turn), then seat 0 resigns its own
|
||
// seat while it is not its turn — no ErrNotYourTurn, the game ends, and seat 0 loses
|
||
// despite leading on score.
|
||
func TestResignOnOpponentTurn(t *testing.T) {
|
||
ctx := context.Background()
|
||
svc := newGameService()
|
||
seats := []uuid.UUID{provisionAccount(t), provisionAccount(t)}
|
||
seed := openingSeed(t)
|
||
g, err := svc.Create(ctx, game.CreateParams{
|
||
Variant: engine.VariantEnglish, Seats: seats, TurnTimeout: 24 * time.Hour, Seed: seed,
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("create: %v", err)
|
||
}
|
||
|
||
hint, ok := newMirror(t, seed, 2).HintView()
|
||
if !ok {
|
||
t.Fatal("no opening move")
|
||
}
|
||
if _, err := svc.SubmitPlay(ctx, g.ID, seats[0], hint.Dir, hint.Tiles); err != nil { // p0 scores, now p1's turn
|
||
t.Fatalf("p0 play: %v", err)
|
||
}
|
||
|
||
res, err := svc.Resign(ctx, g.ID, seats[0]) // p0 resigns OFF turn
|
||
if err != nil {
|
||
t.Fatalf("off-turn resign = %v, want nil", err)
|
||
}
|
||
if res.Game.Status != game.StatusFinished || res.Game.EndReason != "resign" {
|
||
t.Fatalf("after off-turn resign: %+v", res.Game)
|
||
}
|
||
if res.Game.Seats[0].IsWinner || !res.Game.Seats[1].IsWinner {
|
||
t.Errorf("winner flags wrong (resigner must lose): %+v", res.Game.Seats)
|
||
}
|
||
}
|
||
|
||
// TestTimeoutSweep auto-resigns an overdue game and records it as a timeout.
|
||
func TestTimeoutSweep(t *testing.T) {
|
||
ctx := context.Background()
|
||
svc := newGameService()
|
||
seats := []uuid.UUID{provisionAccount(t), provisionAccount(t)}
|
||
g, err := svc.Create(ctx, game.CreateParams{
|
||
Variant: engine.VariantEnglish, Seats: seats, TurnTimeout: time.Hour, Seed: openingSeed(t),
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("create: %v", err)
|
||
}
|
||
backdate(t, g.ID, time.Now().UTC().Add(-2*time.Hour))
|
||
|
||
// Disable the to-move account's away window: with the default 00:00–07:00
|
||
// window the sweeper (correctly) declines to time out a player whose deadline
|
||
// fell while they were asleep, which made this test fail whenever CI ran with
|
||
// now-1h inside that window (e.g. ~07:00 UTC). An empty window keeps the test
|
||
// deterministic regardless of the time of day.
|
||
setAway(t, seats[0], "UTC", "00:00", "00:00")
|
||
|
||
// The sweep is global over the shared pool; assert the target game itself,
|
||
// not the count, since other tests leave active games behind.
|
||
if n, err := svc.SweepTimeouts(ctx, time.Now().UTC()); err != nil || n < 1 {
|
||
t.Fatalf("sweep swept %d (err %v), want >= 1", n, err)
|
||
}
|
||
st, err := svc.History(ctx, g.ID)
|
||
if err != nil {
|
||
t.Fatalf("history: %v", err)
|
||
}
|
||
if st.Game.Status != game.StatusFinished || st.Game.EndReason != "timeout" {
|
||
t.Fatalf("game not timed out: %+v", st.Game)
|
||
}
|
||
if !st.Game.Seats[1].IsWinner { // seat 0 was to move and timed out
|
||
t.Errorf("opponent should win on timeout: %+v", st.Game.Seats)
|
||
}
|
||
w1, _, _, _, _, _ := readStats(t, seats[1])
|
||
if w1 != 1 {
|
||
t.Errorf("opponent wins = %d, want 1", w1)
|
||
}
|
||
}
|
||
|
||
// TestTimeoutRespectsAwayWindow keeps a player who is asleep from being timed
|
||
// out until their away window ends.
|
||
func TestTimeoutRespectsAwayWindow(t *testing.T) {
|
||
ctx := context.Background()
|
||
svc := newGameService()
|
||
seats := []uuid.UUID{provisionAccount(t), provisionAccount(t)}
|
||
// Seat 0 (to move) sleeps the whole UTC day except a one-minute gap, so any
|
||
// deadline lands inside the window.
|
||
setAway(t, seats[0], "UTC", "00:00", "23:59")
|
||
g, err := svc.Create(ctx, game.CreateParams{
|
||
Variant: engine.VariantEnglish, Seats: seats, TurnTimeout: time.Hour, Seed: openingSeed(t),
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("create: %v", err)
|
||
}
|
||
// Deadline at 12:00 UTC, well inside the away window.
|
||
turnStart := time.Date(2026, 6, 2, 11, 0, 0, 0, time.UTC)
|
||
backdate(t, g.ID, turnStart)
|
||
|
||
// A sweep whose clock sits inside the away window must leave the target game
|
||
// active. (The sweep is global; assert the target, not the count.)
|
||
if _, err := svc.SweepTimeouts(ctx, time.Date(2026, 6, 2, 12, 30, 0, 0, time.UTC)); err != nil {
|
||
t.Fatalf("sweep inside away window: %v", err)
|
||
}
|
||
if status, _ := gameStatus(t, svc, g.ID); status != game.StatusActive {
|
||
t.Fatalf("target timed out inside its away window (status %q)", status)
|
||
}
|
||
// Once the clock passes the window's end, it must time out.
|
||
if _, err := svc.SweepTimeouts(ctx, time.Date(2026, 6, 3, 23, 59, 0, 0, time.UTC)); err != nil {
|
||
t.Fatalf("sweep after away window: %v", err)
|
||
}
|
||
if status, reason := gameStatus(t, svc, g.ID); status != game.StatusFinished || reason != "timeout" {
|
||
t.Fatalf("target not timed out after window: status %q reason %q", status, reason)
|
||
}
|
||
}
|
||
|
||
// gameStatus returns a game's status and end reason via the service.
|
||
func gameStatus(t *testing.T, svc *game.Service, id uuid.UUID) (status, endReason string) {
|
||
t.Helper()
|
||
h, err := svc.History(context.Background(), id)
|
||
if err != nil {
|
||
t.Fatalf("game status: %v", err)
|
||
}
|
||
return h.Game.Status, h.Game.EndReason
|
||
}
|
||
|
||
// TestHintPolicy exercises the per-game allowance, the profile wallet and the
|
||
// disabled switch.
|
||
func TestHintPolicy(t *testing.T) {
|
||
ctx := context.Background()
|
||
svc := newGameService()
|
||
seats := []uuid.UUID{provisionAccount(t), provisionAccount(t)}
|
||
seed := openingSeed(t)
|
||
g, err := svc.Create(ctx, game.CreateParams{
|
||
Variant: engine.VariantEnglish, Seats: seats, TurnTimeout: 24 * time.Hour,
|
||
HintsAllowed: true, HintsPerPlayer: 1, Seed: seed,
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("create: %v", err)
|
||
}
|
||
|
||
if _, err := svc.Hint(ctx, g.ID, seats[0]); err != nil { // spends the allowance
|
||
t.Fatalf("first hint: %v", err)
|
||
}
|
||
if _, err := svc.Hint(ctx, g.ID, seats[0]); !errors.Is(err, game.ErrNoHintsLeft) {
|
||
t.Fatalf("second hint = %v, want ErrNoHintsLeft", err)
|
||
}
|
||
setHintBalance(t, seats[0], 2)
|
||
res, err := svc.Hint(ctx, g.ID, seats[0]) // spends the wallet
|
||
if err != nil {
|
||
t.Fatalf("wallet hint: %v", err)
|
||
}
|
||
if res.HintsRemaining != 1 {
|
||
t.Errorf("hints remaining = %d, want 1", res.HintsRemaining)
|
||
}
|
||
|
||
off, err := svc.Create(ctx, game.CreateParams{
|
||
Variant: engine.VariantEnglish, Seats: seats, TurnTimeout: 24 * time.Hour,
|
||
HintsAllowed: false, HintsPerPlayer: 1, Seed: seed,
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("create off: %v", err)
|
||
}
|
||
if _, err := svc.Hint(ctx, off.ID, seats[0]); !errors.Is(err, game.ErrHintsDisabled) {
|
||
t.Fatalf("disabled hint = %v, want ErrHintsDisabled", err)
|
||
}
|
||
}
|
||
|
||
// TestGameVariant covers the edge's lightweight variant lookup (Stage 13): it returns the
|
||
// created game's variant and ErrNotFound for an unknown id.
|
||
func TestGameVariant(t *testing.T) {
|
||
ctx := context.Background()
|
||
svc := newGameService()
|
||
seats := []uuid.UUID{provisionAccount(t), provisionAccount(t)}
|
||
g, err := svc.Create(ctx, game.CreateParams{
|
||
Variant: engine.VariantEnglish, Seats: seats, TurnTimeout: time.Hour, Seed: 1,
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("create: %v", err)
|
||
}
|
||
if v, err := svc.GameVariant(ctx, g.ID); err != nil || v != engine.VariantEnglish {
|
||
t.Fatalf("GameVariant = %v, %v; want english, nil", v, err)
|
||
}
|
||
if _, err := svc.GameVariant(ctx, uuid.New()); !errors.Is(err, game.ErrNotFound) {
|
||
t.Errorf("GameVariant(unknown) = %v, want ErrNotFound", err)
|
||
}
|
||
}
|
||
|
||
// TestCheckWordAndComplaint covers the word-check tool and complaint capture.
|
||
func TestCheckWordAndComplaint(t *testing.T) {
|
||
ctx := context.Background()
|
||
svc := newGameService()
|
||
seats := []uuid.UUID{provisionAccount(t), provisionAccount(t)}
|
||
g, err := svc.Create(ctx, game.CreateParams{
|
||
Variant: engine.VariantEnglish, Seats: seats, TurnTimeout: 24 * time.Hour, Seed: openingSeed(t),
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("create: %v", err)
|
||
}
|
||
|
||
if ok, err := svc.CheckWord(ctx, g.ID, "CAT"); err != nil || !ok {
|
||
t.Errorf("CheckWord cat = %v, %v; want true", ok, err)
|
||
}
|
||
if ok, err := svc.CheckWord(ctx, g.ID, "zzzzzz"); err != nil || ok {
|
||
t.Errorf("CheckWord zzzzzz = %v, %v; want false", ok, err)
|
||
}
|
||
|
||
c, err := svc.FileComplaint(ctx, g.ID, seats[0], "ZZZZZZ", "should be a word")
|
||
if err != nil {
|
||
t.Fatalf("file complaint: %v", err)
|
||
}
|
||
if c.ID == uuid.Nil || c.Word != "zzzzzz" || c.WasValid || c.Status != game.StatusComplaintOpen {
|
||
t.Errorf("unexpected complaint: %+v", c)
|
||
}
|
||
}
|
||
|
||
// TestEvaluatePlayPreview previews a legal and an illegal play without committing.
|
||
func TestEvaluatePlayPreview(t *testing.T) {
|
||
ctx := context.Background()
|
||
svc := newGameService()
|
||
seats := []uuid.UUID{provisionAccount(t), provisionAccount(t)}
|
||
seed := openingSeed(t)
|
||
g, err := svc.Create(ctx, game.CreateParams{
|
||
Variant: engine.VariantEnglish, Seats: seats, TurnTimeout: 24 * time.Hour, Seed: seed,
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("create: %v", err)
|
||
}
|
||
hint, _ := newMirror(t, seed, 2).HintView()
|
||
|
||
eval, err := svc.EvaluatePlay(ctx, g.ID, seats[0], hint.Dir, hint.Tiles)
|
||
if err != nil {
|
||
t.Fatalf("evaluate: %v", err)
|
||
}
|
||
if !eval.Valid || eval.Score <= 0 {
|
||
t.Errorf("legal preview = %+v, want valid with score", eval)
|
||
}
|
||
// The same play must still be available afterwards (no commit).
|
||
if _, err := svc.SubmitPlay(ctx, g.ID, seats[0], hint.Dir, hint.Tiles); err != nil {
|
||
t.Fatalf("submit after evaluate: %v", err)
|
||
}
|
||
|
||
bad, err := svc.EvaluatePlay(ctx, g.ID, seats[1], engine.Horizontal, []engine.TileRecord{{Row: 0, Col: 0, Letter: "q"}})
|
||
if err != nil {
|
||
t.Fatalf("evaluate illegal: %v", err)
|
||
}
|
||
if bad.Valid {
|
||
t.Error("disconnected play must be invalid")
|
||
}
|
||
}
|
||
|
||
// TestConcurrentSubmitSerialized confirms the per-game lock lets only one of two
|
||
// racing identical submissions win.
|
||
func TestConcurrentSubmitSerialized(t *testing.T) {
|
||
ctx := context.Background()
|
||
svc := newGameService()
|
||
seats := []uuid.UUID{provisionAccount(t), provisionAccount(t)}
|
||
seed := openingSeed(t)
|
||
g, err := svc.Create(ctx, game.CreateParams{
|
||
Variant: engine.VariantEnglish, Seats: seats, TurnTimeout: 24 * time.Hour, Seed: seed,
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("create: %v", err)
|
||
}
|
||
hint, _ := newMirror(t, seed, 2).HintView()
|
||
|
||
var wg sync.WaitGroup
|
||
var mu sync.Mutex
|
||
var ok int
|
||
for i := 0; i < 2; i++ {
|
||
wg.Add(1)
|
||
go func() {
|
||
defer wg.Done()
|
||
if _, err := svc.SubmitPlay(ctx, g.ID, seats[0], hint.Dir, hint.Tiles); err == nil {
|
||
mu.Lock()
|
||
ok++
|
||
mu.Unlock()
|
||
}
|
||
}()
|
||
}
|
||
wg.Wait()
|
||
if ok != 1 {
|
||
t.Errorf("successful submits = %d, want exactly 1", ok)
|
||
}
|
||
}
|
||
|
||
func backdate(t *testing.T, gameID uuid.UUID, at time.Time) {
|
||
t.Helper()
|
||
if _, err := testDB.ExecContext(context.Background(),
|
||
`UPDATE backend.games SET turn_started_at = $1 WHERE game_id = $2`, at, gameID); err != nil {
|
||
t.Fatalf("backdate: %v", err)
|
||
}
|
||
}
|
||
|
||
func setAway(t *testing.T, id uuid.UUID, tz, start, end string) {
|
||
t.Helper()
|
||
if _, err := testDB.ExecContext(context.Background(),
|
||
`UPDATE backend.accounts SET time_zone = $1, away_start = $2::time, away_end = $3::time WHERE account_id = $4`,
|
||
tz, start, end, id); err != nil {
|
||
t.Fatalf("set away: %v", err)
|
||
}
|
||
}
|
||
|
||
func setHintBalance(t *testing.T, id uuid.UUID, n int) {
|
||
t.Helper()
|
||
if _, err := testDB.ExecContext(context.Background(),
|
||
`UPDATE backend.accounts SET hint_balance = $1 WHERE account_id = $2`, n, id); err != nil {
|
||
t.Fatalf("set hint balance: %v", err)
|
||
}
|
||
}
|
||
|
||
func equalStrings(a, b []string) bool {
|
||
if len(a) != len(b) {
|
||
return false
|
||
}
|
||
for i := range a {
|
||
if a[i] != b[i] {
|
||
return false
|
||
}
|
||
}
|
||
return true
|
||
}
|
||
|
||
// TestExportGCGRefusesActiveGame checks the Stage 8 finished-only gate: a GCG export
|
||
// is allowed only once the game is over, so an active game leaks nothing mid-play.
|
||
func TestExportGCGRefusesActiveGame(t *testing.T) {
|
||
ctx := context.Background()
|
||
gameID, _ := newGameWithSeats(t, 2)
|
||
if _, err := newGameService().ExportGCG(ctx, gameID); !errors.Is(err, game.ErrGameActive) {
|
||
t.Fatalf("export of active game = %v, want ErrGameActive", err)
|
||
}
|
||
}
|