Files
scrabble-game/backend/internal/engine/domain_test.go
T
Ilia Denisov 26aa154547
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 11s
CI / ui (pull_request) Successful in 37s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m8s
R1: schema & naming reset — squash migrations, rename variants
Squash the 12 goose migrations into one 00001_baseline.sql (there is no prod
data; verified schema-identical to the chain via a pg_dump diff + the green
integration suite) and rename the game-variant labels
english/russian_scrabble/erudit -> scrabble_en/scrabble_ru/erudit_ru across the
backend, the FlatBuffers wire values and the UI.

dawg filenames and the Go enum identifiers are unchanged; the i18n display keys
are kept. Adds PRERELEASE.md (the R1-R7 pre-release tracker), linked from
CLAUDE.md. Contour DB wipe and the scrabble-dictionary tidy are follow-ups.
2026-06-09 12:09:50 +02:00

212 lines
6.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())
}
}
// TestCandidatesRankedAndMatchesHint checks that Candidates decodes every
// generated move, ranks them by descending score, and leads with the same move
// HintView reveals.
func TestCandidatesRankedAndMatchesHint(t *testing.T) {
g := openingGame(t)
cands := g.Candidates()
if len(cands) == 0 {
t.Fatal("opening game has no candidates")
}
if got, want := len(cands), len(g.GenerateMoves()); got != want {
t.Errorf("candidate count = %d, want %d (one per generated move)", got, want)
}
for i := 1; i < len(cands); i++ {
if cands[i-1].Score < cands[i].Score {
t.Errorf("candidates not ranked: [%d].Score=%d < [%d].Score=%d", i-1, cands[i-1].Score, i, cands[i].Score)
}
}
hint, ok := g.HintView()
if !ok {
t.Fatal("opening game has no hint")
}
if cands[0].Score != hint.Score {
t.Errorf("top candidate score = %d, want hint score %d", cands[0].Score, hint.Score)
}
for _, c := range cands {
if c.Action != ActionPlay {
t.Errorf("candidate action = %v, want play", c.Action)
}
}
}
// 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
}{
{"scrabble_en hit", VariantEnglish, "cat", true},
{"scrabble_en miss", VariantEnglish, "zzzz", false},
{"scrabble_ru hit", VariantRussianScrabble, "кот", true},
{"erudit_ru 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
}