Files
scrabble-game/backend/internal/engine/registry_test.go
T
Ilia Denisov 6d0dd4fb14
Tests · Go / test (push) Successful in 6s
Tests · Integration / integration (push) Successful in 7s
Stage 2: engine package over scrabble-solver (registry, bag, Game, replay)
backend/internal/engine wraps the sibling scrabble-solver library in-process:

- Registry: versioned DAWG load via dafsa.Load, keyed by (variant, dict_version),
  latest-per-variant; English / Russian / Эрудит handled uniformly.
- Bag: own deterministic, seeded tile bag with Draw + Return (for exchanges),
  since the solver's self-play bag cannot return tiles.
- Game: pure rules engine — deal, play/pass/exchange/resign, refill, per-move
  scoring, turn order, and end-condition detection (empty bag + empty rack, six
  scoreless turns, resignation) with end-game rack adjustment.
- decode/ReplayBoard: dictionary-independent MoveRecords and board replay via
  scrabble.Apply (no internal/encoding), realising ARCHITECTURE §9.1.

Wiring: go.work gains "replace scrabble-solver => ../scrabble-solver"; backend
requires scrabble-solver (placeholder) and github.com/iliadenisov/dafsa directly.
Both Go CI workflows clone the public solver sibling (master HEAD, no token) and
set BACKEND_DICT_DIR.

Docs: ARCHITECTURE §5/§14, TESTING engine layer, backend README, and PLAN
refinements + deferred TODOs (publish/version solver; split engine vs dictionary
generator).
2026-06-02 15:10:08 +02:00

101 lines
3.1 KiB
Go

package engine
import (
"errors"
"testing"
"scrabble-solver/board"
"scrabble-solver/scrabble"
)
// TestRegistryOpensEveryVariant checks that Open loads all three variants at the
// requested version and reports them through Latest and Versions.
func TestRegistryOpensEveryVariant(t *testing.T) {
for _, v := range Variants() {
version, solver, err := testReg.Latest(v)
if err != nil {
t.Fatalf("latest %s: %v", v, err)
}
if version != testVersion {
t.Errorf("latest %s version = %q, want %q", v, version, testVersion)
}
if solver == nil {
t.Errorf("latest %s solver is nil", v)
}
if got := testReg.Versions(v); len(got) != 1 || got[0] != testVersion {
t.Errorf("versions %s = %v, want [%q]", v, got, testVersion)
}
}
}
// TestRegistryValidatesKnownWords is the per-variant smoke test: a known word
// laid over the centre validates against the loaded dictionary, including the
// Эрудит variant.
func TestRegistryValidatesKnownWords(t *testing.T) {
cases := []struct {
variant Variant
word string
}{
{VariantEnglish, "cat"},
{VariantRussianScrabble, "кот"},
{VariantErudit, "кот"},
}
for _, tc := range cases {
t.Run(tc.variant.String(), func(t *testing.T) {
solver, err := testReg.Solver(tc.variant, testVersion)
if err != nil {
t.Fatalf("solver: %v", err)
}
rs := solver.Rules()
row, col := centre(rs)
ps := placementsForWord(t, rs, row, col, scrabble.Horizontal, tc.word)
if _, err := solver.ValidatePlay(board.New(rs.Rows, rs.Cols), scrabble.Horizontal, ps); err != nil {
t.Fatalf("validate %q against %s: %v", tc.word, tc.variant, err)
}
})
}
}
// TestRegistryUnknownLookups covers the not-found error taxonomy.
func TestRegistryUnknownLookups(t *testing.T) {
reg, err := Open(testDictDir(), testVersion, VariantEnglish)
if err != nil {
t.Fatalf("open english-only registry: %v", err)
}
defer reg.Close()
if _, err := reg.Solver(VariantEnglish, "absent"); !errors.Is(err, ErrUnknownVersion) {
t.Errorf("solver with bad version: got %v, want ErrUnknownVersion", err)
}
if _, err := reg.Solver(VariantErudit, testVersion); !errors.Is(err, ErrUnknownVariant) {
t.Errorf("solver for unloaded variant: got %v, want ErrUnknownVariant", err)
}
if _, _, err := reg.Latest(VariantErudit); !errors.Is(err, ErrUnknownVariant) {
t.Errorf("latest for unloaded variant: got %v, want ErrUnknownVariant", err)
}
if got := reg.Versions(VariantErudit); got != nil {
t.Errorf("versions for unloaded variant = %v, want nil", got)
}
}
// TestRegistryCloseIdempotent verifies Close may be called more than once.
func TestRegistryCloseIdempotent(t *testing.T) {
reg, err := Open(testDictDir(), testVersion, VariantEnglish)
if err != nil {
t.Fatalf("open: %v", err)
}
if err := reg.Close(); err != nil {
t.Fatalf("first close: %v", err)
}
if err := reg.Close(); err != nil {
t.Fatalf("second close: %v", err)
}
}
// TestRegistryOpenMissingDir fails when a dictionary file is absent.
func TestRegistryOpenMissingDir(t *testing.T) {
if _, err := Open(t.TempDir(), testVersion, VariantEnglish); err == nil {
t.Fatal("expected an error opening a registry over an empty directory")
}
}