d70bd35470
Under PlayOptions.IgnoreCrossWords a play connected to the board through any perpendicular contact, even one forming a non-word, because connected() reused the standard rule's touchesPerpendicular test while cross-words were suppressed. That let an all-new word merely abut the board sideways and be accepted, and the generator, with relaxed cross-sets, offered such plays. Require the main word to run through an existing tile along the play direction when cross-words are ignored, and filter generated moves by the same predicate so generation agrees with ValidatePlayOpts. Standard rules are unchanged.
181 lines
9.2 KiB
Go
181 lines
9.2 KiB
Go
package scrabble
|
|
|
|
import (
|
|
"testing"
|
|
|
|
"gitea.iliadenisov.ru/developer/scrabble-solver/board"
|
|
)
|
|
|
|
// singleWord is the PlayOptions for the "single word per turn" rule: only the main word is
|
|
// validated and scored, perpendicular cross-words are ignored.
|
|
var singleWord = PlayOptions{IgnoreCrossWords: true}
|
|
|
|
// TestValidatePlayOptsIgnoresCrossWords is the core of the single-word rule: a legal play
|
|
// (its main word runs through an existing tile) whose only flaw is an invalid perpendicular
|
|
// cross-word is rejected under the standard rules yet accepted, and scored by its main word
|
|
// only, under IgnoreCrossWords. "tea" laid down the column through the existing 't' of "cat"
|
|
// bridges that tile, so it connects; the new 'e' abuts a lone 'b', spelling the non-word "eb".
|
|
func TestValidatePlayOptsIgnoresCrossWords(t *testing.T) {
|
|
s := newTestSolver(t)
|
|
b := board.New(s.rules.Rows, s.rules.Cols)
|
|
placeWord(b, 3, 1, Horizontal, "cat") // c(3,1) a(3,2) t(3,3)
|
|
placeWord(b, 4, 4, Horizontal, "b") // a lone tile so the new 'e' forms a cross-word
|
|
tea := []Placement{{Row: 4, Col: 3, Letter: 4}, {Row: 5, Col: 3, Letter: 0}} // e(4,3) a(5,3)
|
|
|
|
if _, err := s.ValidatePlay(b, Vertical, tea); err == nil {
|
|
t.Fatal("standard rules accepted a play forming the invalid cross-word \"eb\"")
|
|
}
|
|
m, err := s.ValidatePlayOpts(b, Vertical, tea, singleWord)
|
|
if err != nil {
|
|
t.Fatalf("single-word rule rejected a valid main word: %v", err)
|
|
}
|
|
if len(m.Cross) != 0 {
|
|
t.Errorf("single-word move carries %d cross-words, want 0", len(m.Cross))
|
|
}
|
|
if m.Score != 3 { // t1 e1 a1, no premiums on the plain board, no cross-words
|
|
t.Errorf("single-word score = %d, want 3", m.Score)
|
|
}
|
|
// The same placement scores 7 under standard geometry (main "tea" 3 + cross "eb" 4), so
|
|
// the 4 dropped points are exactly the ignored cross-word.
|
|
if std, _ := s.ScorePlay(b, Vertical, tea); std.Main.Score != 3 || std.Score != 7 {
|
|
t.Errorf("standard score: main %d total %d, want main 3 total 7", std.Main.Score, std.Score)
|
|
}
|
|
}
|
|
|
|
// TestScorePlayOptsExcludesValidCrossWords checks that even a *valid* cross-word is dropped
|
|
// from the score under the single-word rule. "tat" laid down the column through the existing
|
|
// 't' of "cat" connects in-line; the new 'a' abuts a lone 'a', spelling the valid word "aa",
|
|
// which both rules accept but only the standard rule scores.
|
|
func TestScorePlayOptsExcludesValidCrossWords(t *testing.T) {
|
|
s := newTestSolver(t)
|
|
b := board.New(s.rules.Rows, s.rules.Cols)
|
|
placeWord(b, 3, 1, Horizontal, "cat") // c(3,1) a(3,2) t(3,3)
|
|
placeWord(b, 4, 4, Horizontal, "a") // a lone tile so the new 'a' forms the cross "aa"
|
|
tat := []Placement{{Row: 4, Col: 3, Letter: 0}, {Row: 5, Col: 3, Letter: 19}} // a(4,3) t(5,3)
|
|
|
|
std, err := s.ValidatePlay(b, Vertical, tat)
|
|
if err != nil {
|
|
t.Fatalf("standard rules rejected a play with a valid cross-word: %v", err)
|
|
}
|
|
if std.Score != 5 { // main "tat" 3 + cross "aa" 2
|
|
t.Errorf("standard score = %d, want 5", std.Score)
|
|
}
|
|
m, err := s.ValidatePlayOpts(b, Vertical, tat, singleWord)
|
|
if err != nil {
|
|
t.Fatalf("single-word rule rejected the play: %v", err)
|
|
}
|
|
if m.Score != 3 || len(m.Cross) != 0 { // main "tat" only
|
|
t.Errorf("single-word score = %d with %d cross-words, want 3 and 0", m.Score, len(m.Cross))
|
|
}
|
|
}
|
|
|
|
// TestValidatePlayOptsStillEnforcesMainAndCentre confirms the single-word rule relaxes only
|
|
// cross-words: the main word is still checked against the dictionary and the first move must
|
|
// still cover the centre.
|
|
func TestValidatePlayOptsStillEnforcesMainAndCentre(t *testing.T) {
|
|
s := newTestSolver(t)
|
|
empty := func() *board.Board { return board.New(s.rules.Rows, s.rules.Cols) }
|
|
// A valid first move through the centre (3,3) is accepted under the single-word rule.
|
|
cat := []Placement{{Row: 3, Col: 2, Letter: 2}, {Row: 3, Col: 3, Letter: 0}, {Row: 3, Col: 4, Letter: 19}}
|
|
if _, err := s.ValidatePlayOpts(empty(), Horizontal, cat, singleWord); err != nil {
|
|
t.Errorf("single-word rule rejected the valid first move \"cat\": %v", err)
|
|
}
|
|
// The main word is still validated: "caz" is rejected even though cross-words are ignored.
|
|
caz := []Placement{{Row: 3, Col: 2, Letter: 2}, {Row: 3, Col: 3, Letter: 0}, {Row: 3, Col: 4, Letter: 25}}
|
|
if _, err := s.ValidatePlayOpts(empty(), Horizontal, caz, singleWord); err == nil {
|
|
t.Error("single-word rule accepted the non-word main \"caz\"")
|
|
}
|
|
// An off-centre first move is still rejected under the single-word rule.
|
|
off := []Placement{{Row: 0, Col: 0, Letter: 2}, {Row: 0, Col: 1, Letter: 0}, {Row: 0, Col: 2, Letter: 19}}
|
|
if _, err := s.ValidatePlayOpts(empty(), Horizontal, off, singleWord); err == nil {
|
|
t.Error("single-word rule accepted a first move that misses the centre")
|
|
}
|
|
}
|
|
|
|
// TestSingleWordRuleRequiresInlineConnection is the contour regression. Under the single-word
|
|
// rule a play must still form its word ALONG the play direction together with an existing
|
|
// tile: a multi-tile play whose main word is all-new and that touches the board only
|
|
// perpendicular to itself does not connect — exactly as under the standard rules, the
|
|
// difference being only that the (invalid) cross-word is never formed. A play whose main word
|
|
// runs through an existing tile still connects.
|
|
func TestSingleWordRuleRequiresInlineConnection(t *testing.T) {
|
|
s := newTestSolver(t)
|
|
|
|
// "art" all-new on the row below "cat" touches the board only perpendicular to its line
|
|
// (the non-words "ca"/"ar"/"tt"); it forms no word along its own row, so it does not
|
|
// connect — rejected under BOTH rules.
|
|
b := board.New(s.rules.Rows, s.rules.Cols)
|
|
placeWord(b, 3, 1, Horizontal, "cat")
|
|
art := []Placement{{Row: 4, Col: 1, Letter: 0}, {Row: 4, Col: 2, Letter: 17}, {Row: 4, Col: 3, Letter: 19}}
|
|
if _, err := s.ValidatePlayOpts(b, Horizontal, art, singleWord); err == nil {
|
|
t.Error("single-word rule accepted a parallel play that forms no word along its line")
|
|
}
|
|
if _, err := s.ValidatePlay(b, Horizontal, art); err == nil {
|
|
t.Error("standard rules accepted the same parallel play")
|
|
}
|
|
|
|
// "car" laid down the column through the existing 'c' of "cat" forms its word along its
|
|
// own line and reuses that tile, so it connects and is accepted under the single-word rule.
|
|
b2 := board.New(s.rules.Rows, s.rules.Cols)
|
|
placeWord(b2, 3, 1, Horizontal, "cat") // c(3,1)
|
|
car := []Placement{{Row: 4, Col: 1, Letter: 0}, {Row: 5, Col: 1, Letter: 17}} // a(4,1) r(5,1)
|
|
if _, err := s.ValidatePlayOpts(b2, Vertical, car, singleWord); err != nil {
|
|
t.Errorf("single-word rule rejected an in-line play \"car\": %v", err)
|
|
}
|
|
}
|
|
|
|
// TestGenerateMovesOptsConnectivity confirms single-word generation obeys the same
|
|
// connectivity rule as validation: it never offers a parallel play that forms no word along
|
|
// its own line (such a play would be rejected on submit), so every generated move validates.
|
|
// The "art" beside "cat" play is therefore absent.
|
|
func TestGenerateMovesOptsConnectivity(t *testing.T) {
|
|
s := newTestSolver(t)
|
|
b := board.New(s.rules.Rows, s.rules.Cols)
|
|
placeWord(b, 3, 1, Horizontal, "cat")
|
|
|
|
moves := s.GenerateMovesOpts(b, makeRack("art", 0), Both, singleWord)
|
|
if len(moves) == 0 {
|
|
t.Fatal("single-word generation produced no moves")
|
|
}
|
|
// "art" on the row below "cat" forms no word along its row, so generation must not offer it.
|
|
art := []Placement{{Row: 4, Col: 1, Letter: 0}, {Row: 4, Col: 2, Letter: 17}, {Row: 4, Col: 3, Letter: 19}}
|
|
if _, ok := genMoves(moves)[moveKey(Horizontal, art)]; ok {
|
|
t.Error("single-word generation produced \"art\" beside \"cat\" (forms no word along its line)")
|
|
}
|
|
// Every generated single-word move must pass single-word validation.
|
|
for _, m := range moves {
|
|
if _, err := s.ValidatePlayOpts(b, m.Dir, m.Tiles, singleWord); err != nil {
|
|
t.Errorf("generated move %s fails its own validation: %v", moveKey(m.Dir, m.Tiles), err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestGenerateMovesOptsRelaxesCrossChecks confirms relaxed generation still relaxes the
|
|
// cross-word checks for IN-LINE plays: "tar" laid down the column through the existing 't' of
|
|
// "cat" connects through that tile, but its new 'r' abuts a lone 'w', spelling the non-word
|
|
// "rw". Standard generation omits it; single-word generation offers it, scored on its main
|
|
// word only.
|
|
func TestGenerateMovesOptsRelaxesCrossChecks(t *testing.T) {
|
|
s := newTestSolver(t)
|
|
b := board.New(s.rules.Rows, s.rules.Cols)
|
|
placeWord(b, 3, 1, Horizontal, "cat") // c(3,1) a(3,2) t(3,3)
|
|
placeWord(b, 5, 4, Horizontal, "w") // a lone tile so the new 'r' forms the cross "rw"
|
|
|
|
std := genMoves(s.GenerateMoves(b, makeRack("ar", 0), Both))
|
|
rel := genMoves(s.GenerateMovesOpts(b, makeRack("ar", 0), Both, singleWord))
|
|
|
|
// "tar" down through the existing 't': a(4,3) r(5,3).
|
|
tar := []Placement{{Row: 4, Col: 3, Letter: 0}, {Row: 5, Col: 3, Letter: 17}}
|
|
key := moveKey(Vertical, tar)
|
|
if _, ok := std[key]; ok {
|
|
t.Error("standard generation produced \"tar\" despite its invalid cross-word \"rw\"")
|
|
}
|
|
rm, ok := rel[key]
|
|
if !ok {
|
|
t.Fatal("single-word generation did not produce the in-line \"tar\" (its cross-word is ignored)")
|
|
}
|
|
if rm.Score != 3 { // main "tar" only: t1 a1 r1
|
|
t.Errorf("single-word \"tar\" scored %d, want 3", rm.Score)
|
|
}
|
|
}
|