Files
scrabble-game/backend/internal/engine/domain_test.go
T
Ilia Denisov 751e74b14f
Tests · Go / test (push) Successful in 6s
Tests · Integration / integration (push) Successful in 8s
Tests · Go / test (pull_request) Successful in 5s
Tests · Integration / integration (pull_request) Successful in 8s
Stage 3: game domain (lifecycle, journal+cache, hint, word-check, GCG, stats)
internal/game drives the engine over a single match and owns everything the
engine does not: event-sourced persistence (a games row + an append-only decoded
move journal, with the live engine.Game kept warm in a cache and rebuilt by
replay on a miss), the play/pass/exchange/resign transitions with
validate-at-submit scoring, an unlimited score/legality preview, the hint
(per-game allowance + profile wallet), the word-check tool with complaint
capture, per-player game state, history and GCG export (Poslfit dialect), and a
background turn-timeout sweeper that auto-resigns overdue turns honouring each
player's daily away window. Like Stages 1-2 it is a service/store layer with no
HTTP; the gateway surface lands in Stage 6.

Engine: additive decoded domain API (Direction, SubmitPlay/SubmitExchange/
EvaluatePlay/HintView/Hand, MoveRecord.{Dir,MainRow,MainCol}, Registry.Lookup,
ParseVariant) so internal/game never imports scrabble-solver; and a Resign fix
so the resigner keeps their score yet never wins (the other player wins a
two-player game). Timeout reuses Resign.

Persistence: migration 00002 adds games, game_players, game_moves, complaints,
account_stats and extends accounts with away_start/away_end/hint_balance; go-jet
regenerated. account gained SpendHint. Config adds BACKEND_DICT_DIR (required),
BACKEND_DICT_VERSION, BACKEND_GAME_TIMEOUT_SWEEP_INTERVAL, BACKEND_GAME_CACHE_TTL;
main loads the registry at boot (hard dependency) and starts the sweeper.

Tests: engine resign + decoded-API tests; game unit tests (GCG, away-window
boundaries, hint budget, cache, keyed mutex, payload); inttest integration
(lifecycle, replay equivalence, timeout sweep with away grace, resign stats,
hint policy, word-check/complaint, per-game-lock). Docs (PLAN, ARCHITECTURE,
FUNCTIONAL +_ru, TESTING, README) updated.
2026-06-02 17:33:49 +02:00

181 lines
5.4 KiB
Go

package engine
import (
"errors"
"slices"
"testing"
)
// TestDirectionString covers the H/V rendering used by the journal and GCG.
func TestDirectionString(t *testing.T) {
if Horizontal.String() != "H" {
t.Errorf("Horizontal = %q, want H", Horizontal.String())
}
if Vertical.String() != "V" {
t.Errorf("Vertical = %q, want V", Vertical.String())
}
}
// TestSubmitPlayMatchesHint plays the decoded top-1 move through SubmitPlay and
// checks it scores and advances exactly like the underlying solver move, proving
// the decode→encode round trip.
func TestSubmitPlayMatchesHint(t *testing.T) {
g := openingGame(t)
hint, ok := g.HintView()
if !ok {
t.Fatal("opening game has no hint")
}
rec, err := g.SubmitPlay(hint.Dir, hint.Tiles)
if err != nil {
t.Fatalf("submit play: %v", err)
}
if rec.Score != hint.Score {
t.Errorf("played score = %d, want hint score %d", rec.Score, hint.Score)
}
if rec.Action != ActionPlay {
t.Errorf("action = %v, want play", rec.Action)
}
if g.Score(0) != hint.Score {
t.Errorf("player 0 score = %d, want %d", g.Score(0), hint.Score)
}
if g.ToMove() != 1 {
t.Errorf("to move = %d, want 1 after a play", g.ToMove())
}
}
// TestEvaluatePlayDoesNotCommit checks the preview scores like a real play but
// leaves the board, scores, turn and bag untouched.
func TestEvaluatePlayDoesNotCommit(t *testing.T) {
g := openingGame(t)
hint, ok := g.HintView()
if !ok {
t.Fatal("opening game has no hint")
}
boardBefore := g.BoardClone()
scoreBefore, toMoveBefore, bagBefore := g.Score(0), g.ToMove(), g.BagLen()
rec, err := g.EvaluatePlay(hint.Dir, hint.Tiles)
if err != nil {
t.Fatalf("evaluate play: %v", err)
}
if rec.Score != hint.Score {
t.Errorf("evaluated score = %d, want %d", rec.Score, hint.Score)
}
if !boardsEqual(boardBefore, g.BoardClone()) {
t.Error("evaluate must not mutate the board")
}
if g.Score(0) != scoreBefore || g.ToMove() != toMoveBefore || g.BagLen() != bagBefore {
t.Errorf("evaluate mutated state: score %d->%d, toMove %d->%d, bag %d->%d",
scoreBefore, g.Score(0), toMoveBefore, g.ToMove(), bagBefore, g.BagLen())
}
}
// TestEvaluatePlayRejectsIllegal reports ErrIllegalPlay for a play the solver
// rejects (a single off-centre opening tile) without committing.
func TestEvaluatePlayRejectsIllegal(t *testing.T) {
g := newEnglishGame(t, 1)
letter := g.Hand(0)[0]
_, err := g.EvaluatePlay(Horizontal, []TileRecord{{Row: 0, Col: 0, Letter: letter}})
if !errors.Is(err, ErrIllegalPlay) {
t.Errorf("evaluate off-centre opening = %v, want ErrIllegalPlay", err)
}
}
// TestSubmitExchangeWithBlank exchanges a full rack that includes a blank,
// exercising the "?" encoding path, and checks the turn advances.
func TestSubmitExchangeWithBlank(t *testing.T) {
g := gameWithBlankInHand(t)
hand := g.Hand(0)
if !slices.Contains(hand, blankLetter) {
t.Fatalf("hand %v has no blank", hand)
}
rec, err := g.SubmitExchange(hand)
if err != nil {
t.Fatalf("submit exchange: %v", err)
}
if rec.Action != ActionExchange || rec.Count != len(hand) {
t.Errorf("exchange record = %+v, want action exchange count %d", rec, len(hand))
}
if g.ToMove() != 1 {
t.Errorf("to move = %d, want 1 after an exchange", g.ToMove())
}
}
// TestHandDecodesBlank checks Hand returns concrete letters and "?" for a blank,
// agreeing with the internal hand.
func TestHandDecodesBlank(t *testing.T) {
g := gameWithBlankInHand(t)
hand := g.Hand(0)
if len(hand) != g.rules.RackSize {
t.Fatalf("hand size = %d, want %d", len(hand), g.rules.RackSize)
}
var blanks int
for _, s := range hand {
if s == "" {
t.Errorf("hand %v has an empty letter", hand)
}
if s == blankLetter {
blanks++
}
}
var want int
for _, t := range g.hands[0] {
if t == blankTile {
want++
}
}
if blanks != want {
t.Errorf("decoded blanks = %d, want %d", blanks, want)
}
}
// TestRegistryLookup covers word-check membership and its error taxonomy.
func TestRegistryLookup(t *testing.T) {
cases := []struct {
name string
variant Variant
word string
want bool
}{
{"english hit", VariantEnglish, "cat", true},
{"english miss", VariantEnglish, "zzzz", false},
{"russian hit", VariantRussianScrabble, "кот", true},
{"erudit hit", VariantErudit, "кот", true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got, err := testReg.Lookup(tc.variant, testVersion, tc.word)
if err != nil {
t.Fatalf("lookup: %v", err)
}
if got != tc.want {
t.Errorf("lookup %q = %v, want %v", tc.word, got, tc.want)
}
})
}
if _, err := testReg.Lookup(VariantEnglish, "missing", "cat"); !errors.Is(err, ErrUnknownVersion) {
t.Errorf("unknown version = %v, want ErrUnknownVersion", err)
}
if _, err := NewRegistry().Lookup(VariantEnglish, testVersion, "cat"); !errors.Is(err, ErrUnknownVariant) {
t.Errorf("empty registry = %v, want ErrUnknownVariant", err)
}
if _, err := testReg.Lookup(VariantEnglish, testVersion, "кот"); err == nil {
t.Error("out-of-alphabet lookup must error")
}
}
// gameWithBlankInHand returns a two-player English game whose player 0 holds at
// least one blank, searching a deterministic range of seeds.
func gameWithBlankInHand(t *testing.T) *Game {
t.Helper()
for seed := int64(1); seed <= 200; seed++ {
g := newEnglishGame(t, seed)
if slices.Contains(g.Hand(0), blankLetter) {
return g
}
}
t.Fatal("no opening rack with a blank found in seeds 1..200")
return nil
}