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