Files
scrabble-game/backend/internal/engine/singleword_test.go
T
Ilia Denisov 5fa51d04d9
CI / changes (pull_request) Successful in 1s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 14s
CI / ui (pull_request) Has been skipped
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m5s
fix(engine): EvaluatePlay honors the single-word rule
The move preview (EvaluatePlay) validated under standard rules — it called
ValidatePlay without the game's play options — so under the single-word
rule it rejected a play whose only flaw was incidental invalid perpendicular
cross-words, even though SubmitPlay accepts it. The UI gates the submit
button on the preview, so such a play (e.g. КРАН bridging an existing Р on
the test contour) could not be made.

Pass g.playOpts() via ValidatePlayOpts, mirroring Play, so the preview's
legality and score match submission. Robots are unaffected — they search
via GenerateMovesOpts and submit via Play, both already opts-aware — and a
regression test asserts that too.
2026-06-12 11:14:20 +02:00

166 lines
6.2 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/scrabble"
)
// TestSingleWordRuleWiring confirms Options.MultipleWordsPerTurn reaches the solver. The
// single-word game ignores perpendicular cross-words, so move generation from a shared
// position is a superset of the standard game's; the standard game does not relax them.
func TestSingleWordRuleWiring(t *testing.T) {
const seed = 7
mk := func(multipleWords bool) *Game {
g, err := New(testReg, Options{
Variant: VariantEnglish,
Version: testVersion,
Players: 2,
Seed: seed,
MultipleWordsPerTurn: multipleWords,
})
if err != nil {
t.Fatalf("new game: %v", err)
}
return g
}
std, single := mk(true), mk(false)
if std.playOpts().IgnoreCrossWords {
t.Error("standard game must not ignore cross-words")
}
if !single.playOpts().IgnoreCrossWords {
t.Error("single-word game must ignore cross-words")
}
// Play the same opening (the standard game's top move) in both games, then compare
// the next player's candidate moves. Both games share the seed, so the next rack is
// identical; relaxed (single-word) generation never drops a legal standard move.
hint, ok := std.HintView()
if !ok {
t.Fatal("opening game has no hint")
}
if _, err := std.SubmitPlay(hint.Tiles); err != nil {
t.Fatalf("standard opening: %v", err)
}
if _, err := single.SubmitPlay(hint.Tiles); err != nil {
t.Fatalf("single-word opening: %v", err)
}
stdMoves, singleMoves := len(std.GenerateMoves()), len(single.GenerateMoves())
if singleMoves < stdMoves {
t.Errorf("single-word generation produced %d moves, want >= standard %d", singleMoves, stdMoves)
}
}
// setupSingleWordKran builds an Erudit position that reproduces the test-contour
// bug. It replaces the bag-dealt rack with к/а/н and places the existing Р the play
// bridges plus perpendicular neighbours (г, е, н) so that each of the three new
// tiles of the vertical КРАН forms an *invalid* cross-word (гк, еа, нн). The
// multipleWords argument selects the rule. It returns the game and the decoded КРАН
// placement (the three new tiles К, А, Н around the existing Р).
func setupSingleWordKran(t *testing.T, multipleWords bool) (*Game, []TileRecord) {
t.Helper()
g, err := New(testReg, Options{
Variant: VariantErudit,
Version: testVersion,
Players: 2,
Seed: 1,
MultipleWordsPerTurn: multipleWords,
})
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: 5, Col: 8, Letter: idx("р")}, // the existing tile the play bridges
{Row: 4, Col: 7, Letter: idx("г")}, // left of К(4,8): cross-word "гк"
{Row: 6, Col: 7, Letter: idx("е")}, // left of А(6,8): cross-word "еа"
{Row: 7, Col: 7, Letter: idx("н")}, // left of Н(7,8): cross-word "нн"
}})
g.hands[0] = []byte{idx("к"), idx("а"), idx("н")}
tiles := []TileRecord{
{Row: 4, Col: 8, Letter: "к"},
{Row: 6, Col: 8, Letter: "а"},
{Row: 7, Col: 8, Letter: "н"},
}
return g, tiles
}
// TestEvaluatePlayHonorsSingleWordRule is the regression for the contour bug: under
// the single-word rule the EvaluatePlay preview (the "what would this score, and is
// it legal?" tool) must honour the same rule as SubmitPlay and ignore perpendicular
// cross-words, so a play whose only flaw is invalid cross-words is reported legal and
// scored on its main word alone. Before the fix EvaluatePlay validated under standard
// rules and wrongly rejected it.
func TestEvaluatePlayHonorsSingleWordRule(t *testing.T) {
// The main word must be a real Erudit word, so any rejection can only come from
// the (ignored) cross-words rather than the main word itself.
if ok, err := testReg.Lookup(VariantErudit, testVersion, "кран"); err != nil || !ok {
t.Fatalf("precondition: кран must be in the Erudit dictionary (ok=%v, err=%v)", ok, err)
}
t.Run("single-word rule accepts the cross-invalid play", func(t *testing.T) {
g, tiles := setupSingleWordKran(t, false)
rec, err := g.EvaluatePlay(tiles)
if err != nil {
t.Fatalf("evaluate under single-word rule: %v", err)
}
if len(rec.Words) != 1 || rec.Words[0] != "кран" {
t.Errorf("words = %v, want [кран] only (cross-words ignored)", rec.Words)
}
if rec.Dir != Vertical {
t.Errorf("dir = %v, want Vertical", rec.Dir)
}
if rec.Score <= 0 {
t.Errorf("score = %d, want positive", rec.Score)
}
})
t.Run("standard rules reject the same play", func(t *testing.T) {
g, tiles := setupSingleWordKran(t, true)
if _, err := g.EvaluatePlay(tiles); !errors.Is(err, ErrIllegalPlay) {
t.Errorf("evaluate under standard rules = %v, want ErrIllegalPlay", err)
}
})
t.Run("evaluate agrees with submit under the single-word rule", func(t *testing.T) {
g, tiles := setupSingleWordKran(t, false)
if _, err := g.SubmitPlay(tiles); err != nil {
t.Errorf("submit under single-word rule: %v", err)
}
})
}
// TestSingleWordRuleRobotCandidates proves the robot opponent never trips the same
// cross-word check while searching for its move: its move source, Candidates ->
// GenerateMovesOpts, already honours the rule. Under the single-word rule the bridged
// КРАН (whose cross-words are invalid) appears among the candidates the robot chooses
// from; under standard rules it is correctly absent. The robot submits its pick through
// SubmitPlay (covered above), so this holds both before and after the EvaluatePlay fix —
// the robot never uses EvaluatePlay.
func TestSingleWordRuleRobotCandidates(t *testing.T) {
hasKran := func(cands []MoveRecord) bool {
for _, c := range cands {
if len(c.Words) > 0 && c.Words[0] == "кран" {
return true
}
}
return false
}
single, _ := setupSingleWordKran(t, false)
if !hasKran(single.Candidates()) {
t.Error("single-word candidates must include the bridged кран play the robot can pick")
}
std, _ := setupSingleWordKran(t, true)
if hasKran(std.Candidates()) {
t.Error("standard candidates must not include кран (its cross-words are invalid)")
}
}