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) } }) }