Files
scrabble-game/backend/internal/engine/direction_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

160 lines
6.1 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package engine
import (
"errors"
"testing"
"gitea.iliadenisov.ru/developer/scrabble-solver/board"
"gitea.iliadenisov.ru/developer/scrabble-solver/scrabble"
)
// boardWith returns a 15x15 board with the given (row, col) cells occupied. The
// concrete letter is irrelevant to direction inference, which reads only
// occupancy, so every filler uses alphabet index 0.
func boardWith(cells ...[2]int) *board.Board {
b := board.New(15, 15)
ps := make([]scrabble.Placement, len(cells))
for i, c := range cells {
ps[i] = scrabble.Placement{Row: c[0], Col: c[1], Letter: 0}
}
scrabble.Apply(b, scrabble.Move{Tiles: ps})
return b
}
// TestRunLength covers the word-span measurement that drives single-tile
// direction inference, including the board edge.
func TestRunLength(t *testing.T) {
tests := []struct {
name string
filled [][2]int
row, col int
dir scrabble.Direction
want int
}{
{"isolated horizontal", nil, 7, 7, scrabble.Horizontal, 1},
{"isolated vertical", nil, 7, 7, scrabble.Vertical, 1},
{"neighbour below", [][2]int{{8, 7}}, 7, 7, scrabble.Vertical, 2},
{"neighbour above", [][2]int{{6, 7}}, 7, 7, scrabble.Vertical, 2},
{"run below", [][2]int{{8, 7}, {9, 7}}, 7, 7, scrabble.Vertical, 3},
{"bridge vertical", [][2]int{{6, 7}, {8, 7}}, 7, 7, scrabble.Vertical, 3},
{"neighbour left", [][2]int{{7, 6}}, 7, 7, scrabble.Horizontal, 2},
{"neighbour right", [][2]int{{7, 8}}, 7, 7, scrabble.Horizontal, 2},
{"bridge horizontal", [][2]int{{7, 6}, {7, 8}}, 7, 7, scrabble.Horizontal, 3},
{"perpendicular ignored", [][2]int{{7, 6}}, 7, 7, scrabble.Vertical, 1},
{"top edge", [][2]int{{1, 0}}, 0, 0, scrabble.Vertical, 2},
{"left edge", [][2]int{{0, 1}}, 0, 0, scrabble.Horizontal, 2},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
b := boardWith(tt.filled...)
if got := runLength(b, tt.row, tt.col, tt.dir); got != tt.want {
t.Errorf("runLength(%d,%d,%v) = %d, want %d", tt.row, tt.col, tt.dir, got, tt.want)
}
})
}
}
// TestResolveDirection covers orientation inference for both multi-tile plays
// (fixed by the shared line) and the ambiguous single tile (the axis it abuts,
// longer word winning and horizontal on a tie; disconnected falls back to
// horizontal for the downstream rejection).
func TestResolveDirection(t *testing.T) {
at := func(cells ...[2]int) []scrabble.Placement {
ps := make([]scrabble.Placement, len(cells))
for i, c := range cells {
ps[i] = scrabble.Placement{Row: c[0], Col: c[1]}
}
return ps
}
tests := []struct {
name string
filled [][2]int
play []scrabble.Placement
want scrabble.Direction
}{
{"single extends down", [][2]int{{8, 7}, {9, 7}}, at([2]int{7, 7}), scrabble.Vertical},
{"single extends up", [][2]int{{6, 7}, {5, 7}}, at([2]int{7, 7}), scrabble.Vertical},
{"single extends left", [][2]int{{7, 6}}, at([2]int{7, 7}), scrabble.Horizontal},
{"single extends right", [][2]int{{7, 8}}, at([2]int{7, 7}), scrabble.Horizontal},
{"single both axes vertical longer", [][2]int{{6, 7}, {8, 7}, {7, 6}}, at([2]int{7, 7}), scrabble.Vertical},
{"single both axes horizontal longer", [][2]int{{7, 6}, {7, 8}, {6, 7}}, at([2]int{7, 7}), scrabble.Horizontal},
{"single both axes equal prefers horizontal", [][2]int{{6, 7}, {7, 6}}, at([2]int{7, 7}), scrabble.Horizontal},
{"single disconnected falls back to horizontal", nil, at([2]int{7, 7}), scrabble.Horizontal},
{"multi shared row is horizontal", nil, at([2]int{7, 7}, [2]int{7, 8}), scrabble.Horizontal},
{"multi shared column is vertical", nil, at([2]int{7, 7}, [2]int{8, 7}), scrabble.Vertical},
{"multi non-linear is vertical", nil, at([2]int{7, 7}, [2]int{8, 8}), scrabble.Vertical},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
b := boardWith(tt.filled...)
if got := resolveDirection(b, tt.play); got != tt.want {
t.Errorf("resolveDirection = %v, want %v", got, tt.want)
}
})
}
}
// TestResolveDirectionEmpty checks the degenerate empty placement does not panic
// and falls back to horizontal (Evaluate rejects the empty play downstream).
func TestResolveDirectionEmpty(t *testing.T) {
if got := resolveDirection(boardWith(), nil); got != scrabble.Horizontal {
t.Errorf("resolveDirection(empty) = %v, want Horizontal", got)
}
}
// TestSubmitPlaySingleTileVerticalExtension is the regression for the reported
// bug: a single tile placed above an existing vertical word forms a legal play
// the engine must accept by inferring the vertical orientation. Trusting a
// horizontal orientation (the pre-fix client default) wrongly rejects it.
func TestSubmitPlaySingleTileVerticalExtension(t *testing.T) {
// БАК runs down column 7 (rows 7..9); the mover holds А and plays it at
// (6,7), prefixing АБАК. This mirrors the contour game that surfaced the bug.
setup := func(t *testing.T) (*Game, []TileRecord) {
t.Helper()
g, err := New(testReg, Options{Variant: VariantErudit, Version: testVersion, Players: 2, Seed: 1})
if err != nil {
t.Fatalf("new erudit game: %v", err)
}
idx := func(s string) byte {
i, err := g.rules.Alphabet.Index(s)
if err != nil {
t.Fatalf("index %q: %v", s, err)
}
return i
}
scrabble.Apply(g.board, scrabble.Move{Tiles: []scrabble.Placement{
{Row: 7, Col: 7, Letter: idx("б")},
{Row: 8, Col: 7, Letter: idx("а")},
{Row: 9, Col: 7, Letter: idx("к")},
}})
g.hands[0] = []byte{idx("а")}
return g, []TileRecord{{Row: 6, Col: 7, Letter: "а"}}
}
t.Run("inferred direction accepts the play", func(t *testing.T) {
g, tiles := setup(t)
rec, err := g.SubmitPlay(tiles)
if err != nil {
t.Fatalf("submit play: %v", err)
}
if rec.Dir != Vertical {
t.Errorf("direction = %v, want Vertical", rec.Dir)
}
if len(rec.Words) == 0 || rec.Words[0] != "абак" {
t.Errorf("words = %v, want main word абак", rec.Words)
}
if rec.Score <= 0 {
t.Errorf("score = %d, want positive", rec.Score)
}
})
t.Run("trusting horizontal rejects it", func(t *testing.T) {
g, tiles := setup(t)
if _, err := g.SubmitPlayDir(Horizontal, tiles); !errors.Is(err, ErrIllegalPlay) {
t.Errorf("submit horizontal = %v, want ErrIllegalPlay", err)
}
})
}