Files
scrabble-solver/scrabble/solver_test.go
T
Ilia Denisov 15c7959d96 Implement Scrabble move generator (DAWG) with English and Russian rules
A Go library that returns every legal play ranked by score and scores or
validates plays, using the Appel-Jacobson DAWG algorithm over
github.com/iliadenisov/dafsa v1.1.0.

- DAWG move generation (across / down / both), full tournament scoring with a
  per-tile breakdown; public Solver: GenerateMoves (ranked), ScorePlay,
  ValidatePlay.
- Rulesets: English Scrabble, Russian Scrabble, Эрудит (parameterizable Ruleset).
- cmd/builddict (build the DAWG from the dictionaries submodule), cmd/stress
  (self-play benchmark), selfplay engine; brute-force test oracle.
- A GADDAG was implemented, benchmarked and removed (the DAWG was smaller and
  faster for a scoring solver); see RESULTS.md and ALGORITHM.md.
2026-06-01 16:07:32 +02:00

89 lines
2.9 KiB
Go

package scrabble
import (
"testing"
"github.com/iliadenisov/alphabet"
"scrabble-solver/board"
"scrabble-solver/internal/dictdawg"
"scrabble-solver/internal/wordlist"
)
func newTestSolver(t *testing.T) *Solver {
t.Helper()
words := wordlist.Encode(testWords, alphabet.Latin(), 2, 15)
f, err := dictdawg.Build(alphabet.Latin(), words)
if err != nil {
t.Fatal(err)
}
return NewSolver(plainRulesShared, f)
}
func TestSolverGenerateMovesRanked(t *testing.T) {
s := newTestSolver(t)
b := board.New(s.rules.Rows, s.rules.Cols)
moves := s.GenerateMoves(b, makeRack("cat", 0), Both)
if len(moves) == 0 {
t.Fatal("no first moves generated")
}
for i := 1; i < len(moves); i++ {
if moves[i-1].Score < moves[i].Score {
t.Fatalf("moves not ranked: %d before %d", moves[i-1].Score, moves[i].Score)
}
}
}
func TestSolverValidatePlay(t *testing.T) {
s := newTestSolver(t)
// indices: c=2 a=0 t=19 z=25
cat := []Placement{{Row: 3, Col: 2, Letter: 2}, {Row: 3, Col: 3, Letter: 0}, {Row: 3, Col: 4, Letter: 19}}
// First move through the centre (3,3) is legal.
if _, err := s.ValidatePlay(board.New(s.rules.Rows, s.rules.Cols), Horizontal, cat); err != nil {
t.Errorf("valid first move rejected: %v", err)
}
// First move that misses the centre is rejected.
off := []Placement{{Row: 0, Col: 0, Letter: 2}, {Row: 0, Col: 1, Letter: 0}, {Row: 0, Col: 2, Letter: 19}}
if _, err := s.ValidatePlay(board.New(s.rules.Rows, s.rules.Cols), Horizontal, off); err == nil {
t.Error("first move off the centre was accepted")
}
// A non-word ("caz") is rejected.
caz := []Placement{{Row: 3, Col: 2, Letter: 2}, {Row: 3, Col: 3, Letter: 0}, {Row: 3, Col: 4, Letter: 25}}
if _, err := s.ValidatePlay(board.New(s.rules.Rows, s.rules.Cols), Horizontal, caz); err == nil {
t.Error("non-word 'caz' was accepted")
}
// A disconnected play on a non-empty board is rejected.
b := board.New(s.rules.Rows, s.rules.Cols)
placeWord(b, 3, 2, Horizontal, "cat")
disc := []Placement{{Row: 0, Col: 0, Letter: 0}, {Row: 0, Col: 1, Letter: 18}} // "as" far away
if _, err := s.ValidatePlay(b, Horizontal, disc); err == nil {
t.Error("disconnected play was accepted")
}
// Extending "cat" to "cats" connects and is a word.
cats := []Placement{{Row: 3, Col: 5, Letter: 18}} // s after cat
if m, err := s.ValidatePlay(b, Horizontal, cats); err != nil {
t.Errorf("valid extension rejected: %v", err)
} else if string(m.Main.Letters) != string([]byte{2, 0, 19, 18}) {
t.Errorf("main word = %v, want cats", m.Main.Letters)
}
}
func TestSolverScorePlay(t *testing.T) {
s := newTestSolver(t)
b := board.New(s.rules.Rows, s.rules.Cols)
m, err := s.ScorePlay(b, Horizontal, []Placement{
{Row: 3, Col: 2, Letter: 2}, {Row: 3, Col: 3, Letter: 0}, {Row: 3, Col: 4, Letter: 19},
})
if err != nil {
t.Fatal(err)
}
if m.Score != 5 { // c3 a1 t1, no premiums on the plain board
t.Errorf("cat score = %d, want 5", m.Score)
}
}