R6: refactor + docs reconciliation + de-staging #37
@@ -314,19 +314,6 @@ func TestNotificationsInAppOnlyRoundTrip(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// provisionGuest creates a fresh ephemeral guest account and returns its id.
|
|
||||||
func provisionGuest(t *testing.T) uuid.UUID {
|
|
||||||
t.Helper()
|
|
||||||
acc, err := account.NewStore(testDB).ProvisionGuest(context.Background())
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("provision guest: %v", err)
|
|
||||||
}
|
|
||||||
if !acc.IsGuest {
|
|
||||||
t.Fatalf("provisioned account %s is not flagged guest", acc.ID)
|
|
||||||
}
|
|
||||||
return acc.ID
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestGuestAutoMatchLeavesNoStats drives a guest through a full auto-match game
|
// TestGuestAutoMatchLeavesNoStats drives a guest through a full auto-match game
|
||||||
// against a robot to a natural end and checks the guest holds a seat (the
|
// against a robot to a natural end and checks the guest holds a seat (the
|
||||||
// game_players foreign key is satisfied) yet accrues no statistics, while the
|
// game_players foreign key is satisfied) yet accrues no statistics, while the
|
||||||
|
|||||||
@@ -5,35 +5,10 @@ package inttest
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/google/uuid"
|
|
||||||
|
|
||||||
"scrabble/backend/internal/engine"
|
|
||||||
"scrabble/backend/internal/game"
|
"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 draft persistence: a round-trip of the
|
// TestDraftPersistAndConflictReset covers draft persistence: a round-trip of the
|
||||||
// rack order + board tiles, the actor's own draft cleared on their move, and an opponent's
|
// 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).
|
// board draft reset when a committed play overlaps one of its cells (the rack order kept).
|
||||||
|
|||||||
@@ -4,88 +4,17 @@ package inttest
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"database/sql"
|
|
||||||
"errors"
|
"errors"
|
||||||
"sync"
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"go.uber.org/zap"
|
|
||||||
|
|
||||||
"scrabble/backend/internal/account"
|
|
||||||
"scrabble/backend/internal/engine"
|
"scrabble/backend/internal/engine"
|
||||||
"scrabble/backend/internal/game"
|
"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
|
// 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.
|
// games the account is seated in (each with its seats), and nothing for an outsider.
|
||||||
func TestListForAccount(t *testing.T) {
|
func TestListForAccount(t *testing.T) {
|
||||||
|
|||||||
@@ -0,0 +1,167 @@
|
|||||||
|
//go:build integration
|
||||||
|
|
||||||
|
package inttest
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"go.opentelemetry.io/otel/metric/noop"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
|
||||||
|
"scrabble/backend/internal/account"
|
||||||
|
"scrabble/backend/internal/engine"
|
||||||
|
"scrabble/backend/internal/game"
|
||||||
|
"scrabble/backend/internal/lobby"
|
||||||
|
"scrabble/backend/internal/robot"
|
||||||
|
"scrabble/backend/internal/social"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Shared fixtures for the Postgres-backed integration suite: the service
|
||||||
|
// constructors over the shared pool/registry, account provisioning, game
|
||||||
|
// assembly, and the stats reader. Helpers used by a single test file stay in
|
||||||
|
// that file; everything reused across files lives here.
|
||||||
|
|
||||||
|
// 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(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// newSocialService builds a social service over the shared pool, reading game
|
||||||
|
// state through a real game service.
|
||||||
|
func newSocialService() *social.Service {
|
||||||
|
return social.NewService(social.NewStore(testDB), account.NewStore(testDB), newGameService())
|
||||||
|
}
|
||||||
|
|
||||||
|
// newRobotService builds a robot service over games (shared so its moves and the
|
||||||
|
// test's human moves use the same live-game cache and per-game locks), a fresh
|
||||||
|
// social service for nudges, and a no-op meter.
|
||||||
|
func newRobotService(t *testing.T, games *game.Service) *robot.Service {
|
||||||
|
t.Helper()
|
||||||
|
return robot.NewService(games, account.NewStore(testDB), newSocialService(), noop.NewMeterProvider().Meter("robot-test"), zap.NewNop())
|
||||||
|
}
|
||||||
|
|
||||||
|
// newMatchmaker builds a matchmaker starting real games and substituting from
|
||||||
|
// robots after wait.
|
||||||
|
func newMatchmaker(t *testing.T, robots lobby.RobotProvider, wait time.Duration) *lobby.Matchmaker {
|
||||||
|
t.Helper()
|
||||||
|
return lobby.NewMatchmaker(newGameService(), robots, wait, 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
|
||||||
|
}
|
||||||
|
|
||||||
|
// provisionGuest creates a fresh ephemeral guest account and returns its id.
|
||||||
|
func provisionGuest(t *testing.T) uuid.UUID {
|
||||||
|
t.Helper()
|
||||||
|
acc, err := account.NewStore(testDB).ProvisionGuest(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("provision guest: %v", err)
|
||||||
|
}
|
||||||
|
if !acc.IsGuest {
|
||||||
|
t.Fatalf("provisioned account %s is not flagged guest", acc.ID)
|
||||||
|
}
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// newGameWithSeats creates a started game seating n fresh accounts and returns the
|
||||||
|
// game id and the seated account ids in seat order.
|
||||||
|
func newGameWithSeats(t *testing.T, n int) (uuid.UUID, []uuid.UUID) {
|
||||||
|
t.Helper()
|
||||||
|
seats := make([]uuid.UUID, n)
|
||||||
|
for i := range seats {
|
||||||
|
seats[i] = provisionAccount(t)
|
||||||
|
}
|
||||||
|
g, err := newGameService().Create(context.Background(), game.CreateParams{
|
||||||
|
Variant: engine.VariantEnglish, Seats: seats, TurnTimeout: 24 * time.Hour, Seed: openingSeed(t),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create game: %v", err)
|
||||||
|
}
|
||||||
|
return g.ID, seats
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
@@ -8,31 +8,12 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"go.opentelemetry.io/otel/metric/noop"
|
|
||||||
"go.uber.org/zap"
|
|
||||||
|
|
||||||
"scrabble/backend/internal/account"
|
"scrabble/backend/internal/account"
|
||||||
"scrabble/backend/internal/engine"
|
"scrabble/backend/internal/engine"
|
||||||
"scrabble/backend/internal/game"
|
"scrabble/backend/internal/game"
|
||||||
"scrabble/backend/internal/lobby"
|
|
||||||
"scrabble/backend/internal/robot"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// newRobotService builds a robot service over games (shared so its moves and the
|
|
||||||
// test's human moves use the same live-game cache and per-game locks), a fresh
|
|
||||||
// social service for nudges, and a no-op meter.
|
|
||||||
func newRobotService(t *testing.T, games *game.Service) *robot.Service {
|
|
||||||
t.Helper()
|
|
||||||
return robot.NewService(games, account.NewStore(testDB), newSocialService(), noop.NewMeterProvider().Meter("robot-test"), zap.NewNop())
|
|
||||||
}
|
|
||||||
|
|
||||||
// newMatchmaker builds a matchmaker starting real games and substituting from
|
|
||||||
// robots after wait.
|
|
||||||
func newMatchmaker(t *testing.T, robots lobby.RobotProvider, wait time.Duration) *lobby.Matchmaker {
|
|
||||||
t.Helper()
|
|
||||||
return lobby.NewMatchmaker(newGameService(), robots, wait, zap.NewNop())
|
|
||||||
}
|
|
||||||
|
|
||||||
// setTurnStarted rewrites a game's turn clock so a robot turn can be made due (or
|
// setTurnStarted rewrites a game's turn clock so a robot turn can be made due (or
|
||||||
// idle) at a chosen instant, independent of wall time.
|
// idle) at a chosen instant, independent of wall time.
|
||||||
func setTurnStarted(t *testing.T, id uuid.UUID, at time.Time) {
|
func setTurnStarted(t *testing.T, id uuid.UUID, at time.Time) {
|
||||||
|
|||||||
@@ -45,29 +45,6 @@ func (c *capturePublisher) notified(user uuid.UUID, sub string) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// newSocialService builds a social service over the shared pool, reading game
|
|
||||||
// state through a real game service.
|
|
||||||
func newSocialService() *social.Service {
|
|
||||||
return social.NewService(social.NewStore(testDB), account.NewStore(testDB), newGameService())
|
|
||||||
}
|
|
||||||
|
|
||||||
// newGameWithSeats creates a started game seating n fresh accounts and returns the
|
|
||||||
// game id and the seated account ids in seat order.
|
|
||||||
func newGameWithSeats(t *testing.T, n int) (uuid.UUID, []uuid.UUID) {
|
|
||||||
t.Helper()
|
|
||||||
seats := make([]uuid.UUID, n)
|
|
||||||
for i := range seats {
|
|
||||||
seats[i] = provisionAccount(t)
|
|
||||||
}
|
|
||||||
g, err := newGameService().Create(context.Background(), game.CreateParams{
|
|
||||||
Variant: engine.VariantEnglish, Seats: seats, TurnTimeout: 24 * time.Hour, Seed: openingSeed(t),
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("create game: %v", err)
|
|
||||||
}
|
|
||||||
return g.ID, seats
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestFriendRequestToRobotStaysPending checks a friend request to a robot is accepted as
|
// TestFriendRequestToRobotStaysPending checks a friend request to a robot is accepted as
|
||||||
// pending rather than blocked: robots no longer block friend requests, so the request
|
// pending rather than blocked: robots no longer block friend requests, so the request
|
||||||
// just sits unanswered and later expires — mirroring a human who ignores it.
|
// just sits unanswered and later expires — mirroring a human who ignores it.
|
||||||
|
|||||||
Reference in New Issue
Block a user