Files
scrabble-game/backend/internal/engine/game_test.go
T
Ilia Denisov ec435c0e7f
Tests · Go / test (push) Successful in 8s
Tests · Integration / integration (push) Successful in 11s
Tests · Go / test (pull_request) Successful in 8s
Tests · Integration / integration (pull_request) Successful in 11s
Stage 14: solver & dictionary split — consume published module + DAWG artifact (TODO-1/TODO-2)
- backend/go.mod pins gitea.iliadenisov.ru/developer/scrabble-solver v1.0.0; the engine's
  imports use the published module path; go.work drops the solver replace (GOPRIVATE fetches
  it directly from Gitea). The solver's wordlist/dictdawg are now public packages.
- CI (go-unit, integration): drop the solver sibling-clone, set GOPRIVATE, and download the
  dictionary DAWG release artifact (scrabble-dawg-<DICT_VERSION>.tar.gz from the new
  scrabble-dictionary repo) for BACKEND_DICT_DIR.
- Docs: ARCHITECTURE §5/§11/§13/§14 + backend/README updated to the published-module +
  release-artifact model. PLAN.md re-scoped Stage 14 to the split and added Stages 15 (deploy
  infra & test contour), 16 (prod contour), 17 (dual Telegram bots); TODO-1/TODO-2 marked done.
2026-06-04 20:00:36 +02:00

226 lines
6.8 KiB
Go

package engine
import (
"errors"
"testing"
"gitea.iliadenisov.ru/developer/scrabble-solver/board"
"gitea.iliadenisov.ru/developer/scrabble-solver/scrabble"
)
// newEnglishGame starts a two-player English game with the given seed.
func newEnglishGame(t *testing.T, seed int64) *Game {
t.Helper()
g, err := New(testReg, Options{Variant: VariantEnglish, Version: testVersion, Players: 2, Seed: seed})
if err != nil {
t.Fatalf("new game: %v", err)
}
return g
}
// openingGame returns a two-player English game whose opening rack has at least
// one legal move, searching a deterministic range of seeds.
func openingGame(t *testing.T) *Game {
t.Helper()
for seed := int64(1); seed <= 100; seed++ {
g := newEnglishGame(t, seed)
if len(g.GenerateMoves()) > 0 {
return g
}
}
t.Fatal("no opening move found in seeds 1..100")
return nil
}
// boardsEqual reports whether two boards have identical dimensions and cells.
func boardsEqual(a, b *board.Board) bool {
if a.Rows() != b.Rows() || a.Cols() != b.Cols() {
return false
}
for r := range a.Rows() {
for c := range a.Cols() {
if a.At(r, c) != b.At(r, c) {
return false
}
}
}
return true
}
// TestNewDealsRacks checks the initial state of a fresh game.
func TestNewDealsRacks(t *testing.T) {
g := newEnglishGame(t, 1)
if g.Players() != 2 {
t.Errorf("players = %d, want 2", g.Players())
}
if g.ToMove() != 0 {
t.Errorf("to move = %d, want 0", g.ToMove())
}
if g.Over() {
t.Error("a fresh game must not be over")
}
if g.Score(0) != 0 || g.Score(1) != 0 {
t.Errorf("scores = (%d, %d), want (0, 0)", g.Score(0), g.Score(1))
}
rackSize := g.rules.RackSize
if len(g.hands[0]) != rackSize || len(g.hands[1]) != rackSize {
t.Fatalf("hand sizes = (%d, %d), want %d each", len(g.hands[0]), len(g.hands[1]), rackSize)
}
if want := len(allTiles(g.rules)) - 2*rackSize; g.BagLen() != want {
t.Errorf("bag len = %d, want %d", g.BagLen(), want)
}
}
// TestNewRejectsBadPlayerCount rejects player counts outside 2..4.
func TestNewRejectsBadPlayerCount(t *testing.T) {
for _, n := range []int{0, 1, 5} {
if _, err := New(testReg, Options{Variant: VariantEnglish, Version: testVersion, Players: n, Seed: 1}); err == nil {
t.Errorf("players=%d: expected an error", n)
}
}
}
// TestNewUnknownVariant surfaces the registry's not-found error.
func TestNewUnknownVariant(t *testing.T) {
if _, err := New(testReg, Options{Variant: Variant(99), Version: testVersion, Players: 2}); !errors.Is(err, ErrUnknownVariant) {
t.Fatalf("got %v, want ErrUnknownVariant", err)
}
}
// TestPlayScoresAndAdvances plays the top opening move and checks the score,
// running total, refill and turn advance.
func TestPlayScoresAndAdvances(t *testing.T) {
g := openingGame(t)
move := g.GenerateMoves()[0]
played := len(move.Tiles)
bagBefore := g.BagLen()
rec, err := g.Play(move.Dir, move.Tiles)
if err != nil {
t.Fatalf("play: %v", err)
}
if rec.Action != ActionPlay {
t.Errorf("action = %v, want play", rec.Action)
}
if rec.Score != move.Score || g.Score(0) != move.Score {
t.Errorf("score: rec=%d game=%d, want %d", rec.Score, g.Score(0), move.Score)
}
if rec.Total != move.Score {
t.Errorf("running total = %d, want %d", rec.Total, move.Score)
}
if len(rec.Tiles) != played {
t.Errorf("recorded tiles = %d, want %d", len(rec.Tiles), played)
}
if g.ToMove() != 1 {
t.Errorf("to move = %d, want 1", g.ToMove())
}
if len(g.hands[0]) != g.rules.RackSize {
t.Errorf("hand refilled to %d, want %d", len(g.hands[0]), g.rules.RackSize)
}
if g.BagLen() != bagBefore-played {
t.Errorf("bag len = %d, want %d", g.BagLen(), bagBefore-played)
}
}
// TestPlayRejectsTilesNotOnRack rejects a play using tiles the player lacks.
func TestPlayRejectsTilesNotOnRack(t *testing.T) {
g := newEnglishGame(t, 1)
row, col := centre(g.rules)
ps := placementsForWord(t, g.rules, row, col, scrabble.Horizontal, "cat")
// Clear the hand so the player provably lacks the tiles; the holds check
// must reject the play before any dictionary check.
g.hands[0] = nil
if _, err := g.Play(scrabble.Horizontal, ps); !errors.Is(err, ErrTilesNotOnRack) {
t.Fatalf("got %v, want ErrTilesNotOnRack", err)
}
}
// TestExchangeSwapsTiles exchanges two tiles and checks the bag and turn state.
func TestExchangeSwapsTiles(t *testing.T) {
g := newEnglishGame(t, 1)
bagBefore := g.BagLen()
swap := append([]byte(nil), g.hands[0][:2]...)
rec, err := g.Exchange(swap)
if err != nil {
t.Fatalf("exchange: %v", err)
}
if rec.Action != ActionExchange || rec.Count != 2 {
t.Errorf("record = %+v, want exchange of 2", rec)
}
if len(g.hands[0]) != g.rules.RackSize {
t.Errorf("hand size = %d, want %d", len(g.hands[0]), g.rules.RackSize)
}
if g.BagLen() != bagBefore {
t.Errorf("bag len = %d, want %d (draw and return cancel)", g.BagLen(), bagBefore)
}
if g.ToMove() != 1 {
t.Errorf("to move = %d, want 1", g.ToMove())
}
if g.scorelessRun != 1 {
t.Errorf("scoreless run = %d, want 1", g.scorelessRun)
}
}
// TestExchangeNeedsFullBag rejects an exchange once the bag is below a rack.
func TestExchangeNeedsFullBag(t *testing.T) {
g := newEnglishGame(t, 1)
g.bag.Draw(g.bag.Len()) // drain the bag
if _, err := g.Exchange(g.hands[0][:1]); !errors.Is(err, ErrNotEnoughTilesToExchange) {
t.Fatalf("got %v, want ErrNotEnoughTilesToExchange", err)
}
}
// TestPassEndsAfterSixScoreless ends the game after the scoreless limit and then
// rejects further transitions.
func TestPassEndsAfterSixScoreless(t *testing.T) {
g := newEnglishGame(t, 1)
for i := range scorelessLimit {
if _, err := g.Pass(); err != nil {
t.Fatalf("pass %d: %v", i, err)
}
}
if !g.Over() {
t.Fatal("game must be over after six scoreless turns")
}
if g.Reason() != EndScoreless {
t.Errorf("reason = %v, want scoreless", g.Reason())
}
if _, err := g.Pass(); !errors.Is(err, ErrGameOver) {
t.Errorf("pass after end: got %v, want ErrGameOver", err)
}
}
// TestGreedyPlaythroughEndsAndReplays drives a full greedy game to its end and
// proves the dictionary-independent replay reproduces the final board.
func TestGreedyPlaythroughEndsAndReplays(t *testing.T) {
g := newEnglishGame(t, 20250602)
const maxTurns = 600
for turn := 0; turn < maxTurns && !g.Over(); turn++ {
if moves := g.GenerateMoves(); len(moves) > 0 {
if _, err := g.Play(moves[0].Dir, moves[0].Tiles); err != nil {
t.Fatalf("turn %d play: %v", turn, err)
}
continue
}
if _, err := g.Pass(); err != nil {
t.Fatalf("turn %d pass: %v", turn, err)
}
}
if !g.Over() {
t.Fatalf("game did not finish within %d turns", maxTurns)
}
rs, err := Ruleset(VariantEnglish)
if err != nil {
t.Fatalf("ruleset: %v", err)
}
replayed, err := ReplayBoard(rs, g.Log())
if err != nil {
t.Fatalf("replay: %v", err)
}
if !boardsEqual(replayed, g.BoardClone()) {
t.Fatal("replayed board differs from the final board")
}
}