Files
Ilia Denisov 256999b42c Publish as versioned Gitea module; move dictionary pipeline out
- Rename module to gitea.iliadenisov.ru/developer/scrabble-solver so it can be
  consumed as a versioned dependency (no go.work replace / CI clone).
- De-internalize wordlist and dictdawg as public packages.
- Remove cmd/builddict, dictprep/, the dictionaries submodule and the dawg
  Makefile: the word-list parsing and DAWG build now live in the separate
  scrabble-dictionary repository, which publishes the DAWG set as a release artifact.
- internal/dict loads the committed dawg/en_sowpods.dawg fixture for cmd/stress.
- Update README/CLAUDE docs accordingly.
2026-06-04 19:11:46 +02:00

122 lines
3.4 KiB
Go

package scrabble
import (
"testing"
"github.com/iliadenisov/alphabet"
"gitea.iliadenisov.ru/developer/scrabble-solver/board"
"gitea.iliadenisov.ru/developer/scrabble-solver/dictdawg"
"gitea.iliadenisov.ru/developer/scrabble-solver/internal/encoding"
"gitea.iliadenisov.ru/developer/scrabble-solver/rack"
"gitea.iliadenisov.ru/developer/scrabble-solver/rules"
"gitea.iliadenisov.ru/developer/scrabble-solver/wordlist"
)
func makeRack(letters string, blanks int) rack.Rack {
r := rack.New(26)
for i := range len(letters) {
r.Add(letters[i] - 'a')
}
for range blanks {
r.AddBlank()
}
return r
}
func placeWord(b *board.Board, r, c int, dir Direction, word string) {
for i := range len(word) {
rr, cc := r, c+i
if dir == Vertical {
rr, cc = r+i, c
}
b.Set(rr, cc, encoding.Cell(word[i]-'a', false))
}
}
func genMoves(moves []Move) map[string]Move {
out := make(map[string]Move, len(moves))
for _, m := range moves {
out[moveKey(m.Dir, m.Tiles)] = m
}
return out
}
// testWords is a small lexicon with enough overlaps to form across and cross plays.
var testWords = []string{
"aa", "ace", "act", "arc", "are", "art", "as", "at", "ate",
"cab", "cap", "car", "care", "cars", "cart", "cat", "cats", "cot",
"oat", "oats", "ta", "tar", "tare", "tat", "tea", "teat",
}
func compareToBrute(t *testing.T, name string, gen Generator, b *board.Board, d dict, rk rack.Rack, mode Mode) {
t.Helper()
want := bruteForce(b, plainRulesShared, d, rk, mode)
got := genMoves(gen.GenerateMoves(b, rk, mode))
for k, wm := range want {
gm, ok := got[k]
if !ok {
t.Errorf("%s [%s]: %s missing %s (score %d)", name, gen.Name(), gen.Name(), k, wm.Score)
continue
}
if gm.Score != wm.Score {
t.Errorf("%s [%s]: %s score %d, want %d", name, gen.Name(), k, gm.Score, wm.Score)
}
}
for k := range got {
if _, ok := want[k]; !ok {
t.Errorf("%s [%s]: extra move %s", name, gen.Name(), k)
}
}
if len(got) != len(want) {
t.Errorf("%s [%s]: %d moves, oracle has %d", name, gen.Name(), len(got), len(want))
}
}
func mustPlainRules() *rules.Ruleset {
eng := rules.English()
rs, err := rules.FromTemplate("plain7", eng.Alphabet, eng.Values, eng.Counts, 2, 7, 50, plain7)
if err != nil {
panic(err)
}
return rs
}
var plainRulesShared = mustPlainRules()
type scenario struct {
name string
setup func(*board.Board)
rack rack.Rack
mode Mode
}
func genScenarios() []scenario {
return []scenario{
{"first move", func(*board.Board) {}, makeRack("cat", 0), Both},
{"first move blank", func(*board.Board) {}, makeRack("ca", 1), Both},
{"extend cat", func(b *board.Board) { placeWord(b, 3, 1, Horizontal, "cat") }, makeRack("srs", 0), Both},
{"cross cat", func(b *board.Board) { placeWord(b, 1, 3, Horizontal, "cat") }, makeRack("aort", 0), Both},
{"only horizontal", func(b *board.Board) { placeWord(b, 3, 1, Horizontal, "cat") }, makeRack("aser", 0), OnlyHorizontal},
{"only vertical", func(b *board.Board) { placeWord(b, 1, 3, Vertical, "cat") }, makeRack("aser", 0), OnlyVertical},
}
}
func TestDAWGGeneratorVsBruteForce(t *testing.T) {
rs := plainRulesShared
words := wordlist.Encode(testWords, alphabet.Latin(), 2, 15)
f, err := dictdawg.Build(alphabet.Latin(), words)
if err != nil {
t.Fatal(err)
}
gen := NewDAWGGenerator(rs, f)
d := makeDict(words)
for _, c := range genScenarios() {
b := board.New(rs.Rows, rs.Cols)
c.setup(b)
compareToBrute(t, c.name, gen, b, d, c.rack, c.mode)
}
}