Files
scrabble-game/backend/internal/inttest/draft_test.go
T
Ilia Denisov 06c8039281
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 8s
CI / integration (pull_request) Successful in 11s
CI / ui (pull_request) Successful in 30s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m3s
Stage 17 round 6 (#4/#5/#6 backend): per-game draft store + conflict reset
Foundation for persisting a player's client-side composition: a game_drafts table
(game_id, account_id, rack_order, board_tiles jsonb) with raw-SQL store/service methods —
GetDraft/SaveDraft (seated-player check) and, on every committed move, clearing the actor's
own draft and resetting any opponent's board draft whose cell the play overlapped (the
draft can no longer be placed; the rack order is kept). Integration tests cover the
round-trip, the actor clear, the overlap reset, a non-conflicting survival, and the
outsider rejection. The gateway op slice + UI wiring follow.
2026-06-07 12:29:32 +02:00

105 lines
3.8 KiB
Go

//go:build integration
package inttest
import (
"context"
"testing"
"time"
"github.com/google/uuid"
"scrabble/backend/internal/engine"
"scrabble/backend/internal/game"
)
// newDraftGame creates a started two-player English game on an opening seed and returns the
// service, game id, seats, and the opening play (from a mirror) used to drive a real commit.
func newDraftGame(t *testing.T) (*game.Service, uuid.UUID, []uuid.UUID, engine.MoveRecord) {
t.Helper()
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 || len(hint.Tiles) == 0 {
t.Fatal("no opening move")
}
return svc, g.ID, seats, hint
}
// TestDraftPersistAndConflictReset covers Stage 17 draft persistence: a round-trip of the
// rack order + board tiles, the actor's own draft cleared on their move, and an opponent's
// board draft reset when a committed play overlaps one of its cells (the rack order kept).
func TestDraftPersistAndConflictReset(t *testing.T) {
ctx := context.Background()
svc, gameID, seats, hint := newDraftGame(t)
// Round-trip seat 0's rack order + a board draft.
d0 := game.Draft{RackOrder: "QANIWE?", BoardTiles: []game.DraftTile{{Row: 1, Col: 1, Letter: "Q"}}}
if err := svc.SaveDraft(ctx, gameID, seats[0], d0); err != nil {
t.Fatalf("save draft 0: %v", err)
}
if got, err := svc.GetDraft(ctx, gameID, seats[0]); err != nil ||
got.RackOrder != "QANIWE?" || len(got.BoardTiles) != 1 || got.BoardTiles[0].Letter != "Q" {
t.Fatalf("get draft 0 = %+v (err %v)", got, err)
}
// Seat 1 drafts a board tile on a cell the opening play will commit.
overlap := hint.Tiles[0]
if err := svc.SaveDraft(ctx, gameID, seats[1], game.Draft{
RackOrder: "ABCDEFG",
BoardTiles: []game.DraftTile{{Row: overlap.Row, Col: overlap.Col, Letter: "X"}},
}); err != nil {
t.Fatalf("save draft 1: %v", err)
}
if _, err := svc.SubmitPlay(ctx, gameID, seats[0], hint.Dir, hint.Tiles); err != nil {
t.Fatalf("seat0 play: %v", err)
}
// Seat 0's own draft is cleared by their move.
if d, _ := svc.GetDraft(ctx, gameID, seats[0]); d.RackOrder != "" || len(d.BoardTiles) != 0 {
t.Errorf("actor draft not cleared: %+v", d)
}
// Seat 1's board draft overlapped the play and is reset; the rack order is kept.
if d, _ := svc.GetDraft(ctx, gameID, seats[1]); len(d.BoardTiles) != 0 || d.RackOrder != "ABCDEFG" {
t.Errorf("conflicting draft not reset (or rack order lost): %+v", d)
}
}
// TestDraftSurvivesNonConflictingMove checks an opponent's board draft is kept when a
// committed play does not touch any of its cells.
func TestDraftSurvivesNonConflictingMove(t *testing.T) {
ctx := context.Background()
svc, gameID, seats, hint := newDraftGame(t)
// Seat 1 drafts a far corner tile the central opening play cannot reach.
if err := svc.SaveDraft(ctx, gameID, seats[1], game.Draft{
BoardTiles: []game.DraftTile{{Row: 0, Col: 0, Letter: "Z"}},
}); err != nil {
t.Fatalf("save draft 1: %v", err)
}
if _, err := svc.SubmitPlay(ctx, gameID, seats[0], hint.Dir, hint.Tiles); err != nil {
t.Fatalf("seat0 play: %v", err)
}
if d, _ := svc.GetDraft(ctx, gameID, seats[1]); len(d.BoardTiles) != 1 || d.BoardTiles[0].Letter != "Z" {
t.Errorf("non-conflicting draft should survive: %+v", d)
}
}
// TestSaveDraftRejectsOutsider checks only a seated player may save a draft.
func TestSaveDraftRejectsOutsider(t *testing.T) {
ctx := context.Background()
svc, gameID, _, _ := newDraftGame(t)
if err := svc.SaveDraft(ctx, gameID, provisionAccount(t), game.Draft{RackOrder: "X"}); err == nil {
t.Fatal("outsider SaveDraft should fail")
}
}