package engine import ( "errors" "slices" "testing" ) // TestDirectionString covers the H/V rendering used by the journal and GCG. func TestDirectionString(t *testing.T) { if Horizontal.String() != "H" { t.Errorf("Horizontal = %q, want H", Horizontal.String()) } if Vertical.String() != "V" { t.Errorf("Vertical = %q, want V", Vertical.String()) } } // TestSubmitPlayMatchesHint plays the decoded top-1 move through SubmitPlay and // checks it scores and advances exactly like the underlying solver move, proving // the decode→encode round trip. func TestSubmitPlayMatchesHint(t *testing.T) { g := openingGame(t) hint, ok := g.HintView() if !ok { t.Fatal("opening game has no hint") } rec, err := g.SubmitPlay(hint.Dir, hint.Tiles) if err != nil { t.Fatalf("submit play: %v", err) } if rec.Score != hint.Score { t.Errorf("played score = %d, want hint score %d", rec.Score, hint.Score) } if rec.Action != ActionPlay { t.Errorf("action = %v, want play", rec.Action) } if g.Score(0) != hint.Score { t.Errorf("player 0 score = %d, want %d", g.Score(0), hint.Score) } if g.ToMove() != 1 { t.Errorf("to move = %d, want 1 after a play", g.ToMove()) } } // TestCandidatesRankedAndMatchesHint checks that Candidates decodes every // generated move, ranks them by descending score, and leads with the same move // HintView reveals. func TestCandidatesRankedAndMatchesHint(t *testing.T) { g := openingGame(t) cands := g.Candidates() if len(cands) == 0 { t.Fatal("opening game has no candidates") } if got, want := len(cands), len(g.GenerateMoves()); got != want { t.Errorf("candidate count = %d, want %d (one per generated move)", got, want) } for i := 1; i < len(cands); i++ { if cands[i-1].Score < cands[i].Score { t.Errorf("candidates not ranked: [%d].Score=%d < [%d].Score=%d", i-1, cands[i-1].Score, i, cands[i].Score) } } hint, ok := g.HintView() if !ok { t.Fatal("opening game has no hint") } if cands[0].Score != hint.Score { t.Errorf("top candidate score = %d, want hint score %d", cands[0].Score, hint.Score) } for _, c := range cands { if c.Action != ActionPlay { t.Errorf("candidate action = %v, want play", c.Action) } } } // TestEvaluatePlayDoesNotCommit checks the preview scores like a real play but // leaves the board, scores, turn and bag untouched. func TestEvaluatePlayDoesNotCommit(t *testing.T) { g := openingGame(t) hint, ok := g.HintView() if !ok { t.Fatal("opening game has no hint") } boardBefore := g.BoardClone() scoreBefore, toMoveBefore, bagBefore := g.Score(0), g.ToMove(), g.BagLen() rec, err := g.EvaluatePlay(hint.Dir, hint.Tiles) if err != nil { t.Fatalf("evaluate play: %v", err) } if rec.Score != hint.Score { t.Errorf("evaluated score = %d, want %d", rec.Score, hint.Score) } if !boardsEqual(boardBefore, g.BoardClone()) { t.Error("evaluate must not mutate the board") } if g.Score(0) != scoreBefore || g.ToMove() != toMoveBefore || g.BagLen() != bagBefore { t.Errorf("evaluate mutated state: score %d->%d, toMove %d->%d, bag %d->%d", scoreBefore, g.Score(0), toMoveBefore, g.ToMove(), bagBefore, g.BagLen()) } } // TestEvaluatePlayRejectsIllegal reports ErrIllegalPlay for a play the solver // rejects (a single off-centre opening tile) without committing. func TestEvaluatePlayRejectsIllegal(t *testing.T) { g := newEnglishGame(t, 1) letter := g.Hand(0)[0] _, err := g.EvaluatePlay(Horizontal, []TileRecord{{Row: 0, Col: 0, Letter: letter}}) if !errors.Is(err, ErrIllegalPlay) { t.Errorf("evaluate off-centre opening = %v, want ErrIllegalPlay", err) } } // TestSubmitExchangeWithBlank exchanges a full rack that includes a blank, // exercising the "?" encoding path, and checks the turn advances. func TestSubmitExchangeWithBlank(t *testing.T) { g := gameWithBlankInHand(t) hand := g.Hand(0) if !slices.Contains(hand, blankLetter) { t.Fatalf("hand %v has no blank", hand) } rec, err := g.SubmitExchange(hand) if err != nil { t.Fatalf("submit exchange: %v", err) } if rec.Action != ActionExchange || rec.Count != len(hand) { t.Errorf("exchange record = %+v, want action exchange count %d", rec, len(hand)) } if g.ToMove() != 1 { t.Errorf("to move = %d, want 1 after an exchange", g.ToMove()) } } // TestHandDecodesBlank checks Hand returns concrete letters and "?" for a blank, // agreeing with the internal hand. func TestHandDecodesBlank(t *testing.T) { g := gameWithBlankInHand(t) hand := g.Hand(0) if len(hand) != g.rules.RackSize { t.Fatalf("hand size = %d, want %d", len(hand), g.rules.RackSize) } var blanks int for _, s := range hand { if s == "" { t.Errorf("hand %v has an empty letter", hand) } if s == blankLetter { blanks++ } } var want int for _, t := range g.hands[0] { if t == blankTile { want++ } } if blanks != want { t.Errorf("decoded blanks = %d, want %d", blanks, want) } } // TestRegistryLookup covers word-check membership and its error taxonomy. func TestRegistryLookup(t *testing.T) { cases := []struct { name string variant Variant word string want bool }{ {"english hit", VariantEnglish, "cat", true}, {"english miss", VariantEnglish, "zzzz", false}, {"russian hit", VariantRussianScrabble, "кот", true}, {"erudit hit", VariantErudit, "кот", true}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { got, err := testReg.Lookup(tc.variant, testVersion, tc.word) if err != nil { t.Fatalf("lookup: %v", err) } if got != tc.want { t.Errorf("lookup %q = %v, want %v", tc.word, got, tc.want) } }) } if _, err := testReg.Lookup(VariantEnglish, "missing", "cat"); !errors.Is(err, ErrUnknownVersion) { t.Errorf("unknown version = %v, want ErrUnknownVersion", err) } if _, err := NewRegistry().Lookup(VariantEnglish, testVersion, "cat"); !errors.Is(err, ErrUnknownVariant) { t.Errorf("empty registry = %v, want ErrUnknownVariant", err) } if _, err := testReg.Lookup(VariantEnglish, testVersion, "кот"); err == nil { t.Error("out-of-alphabet lookup must error") } } // gameWithBlankInHand returns a two-player English game whose player 0 holds at // least one blank, searching a deterministic range of seeds. func gameWithBlankInHand(t *testing.T) *Game { t.Helper() for seed := int64(1); seed <= 200; seed++ { g := newEnglishGame(t, seed) if slices.Contains(g.Hand(0), blankLetter) { return g } } t.Fatal("no opening rack with a blank found in seeds 1..200") return nil }