Files
scrabble-game/loadtest/internal/moves/moves_test.go
T
Ilia Denisov 92f48a3b12
CI / changes (pull_request) Successful in 1s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 12s
CI / ui (pull_request) Successful in 44s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m9s
Backend infers play direction; UI previews words and gates submit on legality
A single tile that only extended a word perpendicular to the client-declared
direction was rejected: the UI always sent dir=H for one-tile plays (the
dirOverride/Controls toggle was orphaned in the Stage 7 game rework), so placing
"А" above "БАК" to form "АБАК" failed the solver's main-word-length check even
though the word is in the dictionary.

Make the backend infer a play's orientation from the placed tiles and the board
(internal/engine.resolveDirection): two or more tiles by the line they share, a
lone tile by the axis it abuts (longer word wins, horizontal on a tie). Direction
becomes an output, not an input: drop dir from the SubmitPlay/Eval wire requests
and add it to EvalResult. Journal replay keeps trusting the stored "H"/"V"
(SubmitPlayDir) so a rebuilt game matches the one committed.

UI: stop computing/sending direction; the preview now shows the words a move
forms with its total score (game.previewWords); the make-move control is disabled
until the play is confirmed legal; the "your turn" label hides while tiles are
pending. Delete the orphaned Controls.svelte.

Regenerate the FlatBuffers bindings (Go + TS) and update the gateway transcode
and the loadtest edge client to the new contract. Bake the decision into
ARCHITECTURE.md (§5/§9.1), FUNCTIONAL.md (+ _ru) and the backend README.
2026-06-11 22:42:33 +02:00

155 lines
4.6 KiB
Go

package moves
import (
"math/rand"
"os"
"strings"
"testing"
"gitea.iliadenisov.ru/developer/scrabble-solver/board"
"gitea.iliadenisov.ru/developer/scrabble-solver/rules"
"gitea.iliadenisov.ru/developer/scrabble-solver/scrabble"
"scrabble/loadtest/internal/edge"
)
// TestReplayBoardMatchesParse checks that replaying decoded history reproduces the
// exact board (positions, letters and blank flags) that board.Parse builds from the
// equivalent text grid, and that non-play records are ignored.
func TestReplayBoardMatchesParse(t *testing.T) {
rs := rules.English()
history := []edge.Move{
{Action: "pass"}, // must be ignored
{Action: "play", Tiles: []edge.Tile{
{Row: 7, Col: 7, Letter: "c"},
{Row: 7, Col: 8, Letter: "a"},
{Row: 7, Col: 9, Letter: "t"},
}},
{Action: "play", Tiles: []edge.Tile{
{Row: 7, Col: 10, Letter: "s", Blank: true}, // a blank standing for s
}},
}
got, err := replayBoard(rs, history)
if err != nil {
t.Fatalf("replayBoard: %v", err)
}
rows := make([]string, rs.Rows)
for i := range rows {
rows[i] = strings.Repeat(".", rs.Cols)
}
// row 7: cols 0-6 empty, cat at 7-9, an uppercase S (blank) at 10.
rows[7] = strings.Repeat(".", 7) + "cat" + "S" + strings.Repeat(".", rs.Cols-11)
want, err := board.Parse(rows, rs.Alphabet)
if err != nil {
t.Fatalf("board.Parse: %v", err)
}
for r := 0; r < rs.Rows; r++ {
for c := 0; c < rs.Cols; c++ {
if got.At(r, c) != want.At(r, c) {
t.Fatalf("cell (%d,%d): replay = %#x, parse = %#x", r, c, got.At(r, c), want.At(r, c))
}
}
}
}
// TestBuildRack checks the alphabet-index rack (255 a blank) is reconstructed faithfully.
func TestBuildRack(t *testing.T) {
rs := rules.English()
rk := buildRack(rs, []byte{0, 0, 2, blankIndex}) // a a c blank
if rk.Count(0) != 2 {
t.Errorf("count(a) = %d, want 2", rk.Count(0))
}
if rk.Count(2) != 1 {
t.Errorf("count(c) = %d, want 1", rk.Count(2))
}
if rk.Blanks() != 1 {
t.Errorf("blanks = %d, want 1", rk.Blanks())
}
if rk.Total() != 4 {
t.Errorf("total = %d, want 4", rk.Total())
}
}
// TestMidRanked checks the pick always lands in the middle third of a ranked list and
// that tiny lists yield their lowest-scoring move.
func TestMidRanked(t *testing.T) {
ms := make([]scrabble.Move, 9) // scores 100..92, index i has score 100-i
for i := range ms {
ms[i] = scrabble.Move{Score: 100 - i}
}
rng := rand.New(rand.NewSource(1))
for n := 0; n < 100; n++ {
idx := 100 - midRanked(ms, rng).Score // recover the index from the score
if idx < 3 || idx >= 6 {
t.Fatalf("picked index %d outside middle third [3,6)", idx)
}
}
if got := midRanked([]scrabble.Move{{Score: 5}}, rng).Score; got != 5 {
t.Errorf("n=1 pick score = %d, want 5", got)
}
if got := midRanked([]scrabble.Move{{Score: 9}, {Score: 4}}, rng).Score; got != 4 {
t.Errorf("n=2 pick score = %d, want 4 (lower-scoring)", got)
}
}
// TestToPlayTiles checks the solver-placement to edge-tile mapping, including blanks.
func TestToPlayTiles(t *testing.T) {
tiles := toPlayTiles([]scrabble.Placement{
{Row: 1, Col: 2, Letter: 5},
{Row: 1, Col: 3, Letter: 255, Blank: true},
})
want := []edge.PlayTile{
{Row: 1, Col: 2, Letter: 5},
{Row: 1, Col: 3, Letter: 255, Blank: true},
}
if len(tiles) != len(want) {
t.Fatalf("len = %d, want %d", len(tiles), len(want))
}
for i := range want {
if tiles[i] != want[i] {
t.Errorf("tile %d = %+v, want %+v", i, tiles[i], want[i])
}
}
}
// TestPickUnknownVariant rejects a variant the registry does not hold.
func TestPickUnknownVariant(t *testing.T) {
reg := &Registry{engines: map[string]*engine{}}
if _, err := reg.Pick("nope", nil, nil, 0, rand.New(rand.NewSource(1))); err == nil {
t.Fatal("want error for an unknown variant")
}
}
// TestPickWithDawg drives the full path against the committed DAWGs when they are
// available (BACKEND_DICT_DIR, as the engine tests use); it generates a first-move
// play from a productive rack.
func TestPickWithDawg(t *testing.T) {
dir := os.Getenv("BACKEND_DICT_DIR")
if dir == "" {
t.Skip("BACKEND_DICT_DIR not set; skipping DAWG-backed test")
}
reg, err := Open(dir)
if err != nil {
t.Fatalf("Open(%s): %v", dir, err)
}
defer reg.Close()
rng := rand.New(rand.NewSource(1))
rack := []byte{2, 0, 19, 18, 4, 17, 13} // c a t s e r n — a productive English rack
act, err := reg.Pick("scrabble_en", nil, rack, 90, rng)
if err != nil {
t.Fatalf("Pick: %v", err)
}
switch act.Kind {
case "play":
if len(act.Tiles) == 0 {
t.Error("play action has no tiles")
}
case "exchange", "pass":
// acceptable when the rack has no legal first move
default:
t.Errorf("unexpected action kind %q", act.Kind)
}
}