1package spellpuzzle23import (4 "encoding/json"5 "math/rand/v2"6 "strings"7 "testing"89 tea "charm.land/bubbletea/v2"10)1112func newTestRNG(seed uint64) *rand.Rand {13 return rand.New(rand.NewPCG(seed, seed+1))14}1516func testPuzzle(t *testing.T) Model {17 t.Helper()1819 model := Model{20 modeTitle: "Test",21 bank: []rune("MATES"),22 placements: []WordPlacement{23 {Text: "MATE", Start: Position{X: 0, Y: 0}, Orientation: Horizontal},24 {Text: "META", Start: Position{X: 0, Y: 0}, Orientation: Vertical},25 },26 keys: DefaultKeyMap,27 hoverTile: -1,28 }29 model.rebuildDerivedState()30 return model31}3233func TestNewPuzzleStartsWithoutBankCursor(t *testing.T) {34 m := testPuzzle(t)35 if m.bankCursor != -1 {36 t.Fatalf("bank cursor = %d, want -1", m.bankCursor)37 }38 if len(m.bankColorSlots) != len(m.bank) {39 t.Fatalf("bank color slot count = %d, want %d", len(m.bankColorSlots), len(m.bank))40 }41}4243func TestGeneratePuzzleSeededDeterministic(t *testing.T) {44 mode := NewMode("Easy", "", 7, 6, 4)45 rngA := newTestRNG(12)46 rngB := newTestRNG(12)4748 puzzleA, err := GeneratePuzzleSeeded(mode, rngA)49 if err != nil {50 t.Fatal(err)51 }52 puzzleB, err := GeneratePuzzleSeeded(mode, rngB)53 if err != nil {54 t.Fatal(err)55 }5657 if puzzleA.Bank != puzzleB.Bank {58 t.Fatalf("bank mismatch: %q vs %q", puzzleA.Bank, puzzleB.Bank)59 }60 if len(puzzleA.Placements) != len(puzzleB.Placements) {61 t.Fatalf("placement count mismatch: %d vs %d", len(puzzleA.Placements), len(puzzleB.Placements))62 }63 for i := range puzzleA.Placements {64 if puzzleA.Placements[i] != puzzleB.Placements[i] {65 t.Fatalf("placement[%d] mismatch: %+v vs %+v", i, puzzleA.Placements[i], puzzleB.Placements[i])66 }67 }68}6970func TestGeneratedWordsRespectBankAndBonusMinimum(t *testing.T) {71 modes := []SpellPuzzleMode{72 NewMode("Beginner", "", 6, 4, 3),73 NewMode("Easy", "", 7, 6, 4),74 NewMode("Medium", "", 8, 8, 6),75 NewMode("Hard", "", 9, 9, 8),76 }7778 for _, mode := range modes {79 rng := newTestRNG(uint64(mode.BankSize * 11))80 puzzle, err := GeneratePuzzleSeeded(mode, rng)81 if err != nil {82 t.Fatalf("%s: %v", mode.Title(), err)83 }84 if len(puzzle.Placements) != mode.BoardWordCount {85 t.Fatalf("%s placement count = %d, want %d", mode.Title(), len(puzzle.Placements), mode.BoardWordCount)86 }87 seenPlacements := make(map[WordPlacement]struct{}, len(puzzle.Placements))88 for _, placement := range puzzle.Placements {89 if _, ok := seenPlacements[placement]; ok {90 t.Fatalf("%s contains duplicate placement %+v", mode.Title(), placement)91 }92 seenPlacements[placement] = struct{}{}93 }94 if len(puzzle.BonusWords) < mode.MinBonusWords {95 t.Fatalf("%s bonus count = %d, want at least %d", mode.Title(), len(puzzle.BonusWords), mode.MinBonusWords)96 }97 for _, placement := range puzzle.Placements {98 if !canSpellWord(placement.Text, countLetters(puzzle.Bank)) {99 t.Fatalf("%s placement %q cannot be spelled from bank %q", mode.Title(), placement.Text, puzzle.Bank)100 }101 }102 }103}104105func TestGeneratedBankIsScrambledAwayFromPlacedWords(t *testing.T) {106 mode := NewMode("Easy", "", 7, 6, 4)107 puzzle, err := GeneratePuzzleSeeded(mode, newTestRNG(21))108 if err != nil {109 t.Fatal(err)110 }111112 for _, placement := range puzzle.Placements {113 if puzzle.Bank == placement.Text {114 t.Fatalf("bank %q matches placed word %q", puzzle.Bank, placement.Text)115 }116 if puzzle.Bank == reverseWord(placement.Text) {117 t.Fatalf("bank %q matches reversed placed word %q", puzzle.Bank, placement.Text)118 }119 }120}121122func TestGeneratedCrosswordIsConnectedAndIntersecting(t *testing.T) {123 mode := NewMode("Medium", "", 8, 8, 6)124 puzzle, err := GeneratePuzzleSeeded(mode, newTestRNG(99))125 if err != nil {126 t.Fatal(err)127 }128129 seen := make(map[Position]int)130 for _, placement := range puzzle.Placements {131 for _, pos := range placement.Positions() {132 seen[pos]++133 }134 }135136 intersectionCount := 0137 for _, count := range seen {138 if count > 1 {139 intersectionCount++140 }141 }142 if intersectionCount == 0 {143 t.Fatal("expected at least one crossword intersection")144 }145146 board := buildBoard(puzzle.Placements)147 if board.Width == 0 || board.Height == 0 {148 t.Fatal("expected non-empty board")149 }150}151152func TestBoardEdgesOutlineWordZones(t *testing.T) {153 m := testPuzzle(t)154155 if !m.board.hasVerticalEdge(0, 0) {156 t.Fatal("expected left outer edge for occupied word cell")157 }158 if !m.board.hasHorizontalEdge(0, 0) {159 t.Fatal("expected top outer edge for occupied word cell")160 }161 if m.board.hasVerticalEdge(1, 0) {162 t.Fatal("expected no edge inside the horizontal word run")163 }164 if m.board.hasHorizontalEdge(0, 1) {165 t.Fatal("expected no edge inside the vertical word run")166 }167}168169func TestSubmitBoardWordAndBonusWord(t *testing.T) {170 m := testPuzzle(t)171 m.trace = []int{0, 1, 2, 3}172 m.bankCursor = 3173 m.submitTrace()174 if !m.placements[m.boardIndex["MATE"][0]].Found {175 t.Fatal("expected MATE to be revealed")176 }177 if m.feedback.Message != "word added to board" {178 t.Fatalf("feedback = %q", m.feedback.Message)179 }180 if m.bankCursor != -1 {181 t.Fatalf("bank cursor after submit = %d, want -1", m.bankCursor)182 }183184 m.clearFeedback()185 m.trace = []int{0, 3, 4, 1}186 m.submitTrace()187 if len(m.bonusWords) != 1 || m.bonusWords[0] != "MESA" {188 t.Fatalf("bonus words = %v, want [MESA]", m.bonusWords)189 }190 if m.feedback.Message != "bonus word counted" {191 t.Fatalf("feedback = %q", m.feedback.Message)192 }193}194195func TestSubmitNotAWordAndAlreadyFound(t *testing.T) {196 m := testPuzzle(t)197 m.trace = []int{0, 0, 0}198 m.submitTrace()199 if m.feedback.Message != "not a word" {200 t.Fatalf("feedback = %q, want not a word", m.feedback.Message)201 }202203 m.trace = []int{0, 1, 2, 3}204 m.submitTrace()205 m.clearFeedback()206 m.trace = []int{0, 1, 2, 3}207 m.submitTrace()208 if m.feedback.Message != "word already found" {209 t.Fatalf("feedback = %q, want word already found", m.feedback.Message)210 }211}212213func TestKeyboardInputAndSolveState(t *testing.T) {214 m := testPuzzle(t)215216 next, _ := m.Update(tea.KeyPressMsg{Code: 'm', Text: "m"})217 got := next.(Model)218 if got.traceWord() != "M" {219 t.Fatalf("trace after typing m = %q, want M", got.traceWord())220 }221222 next, _ = got.Update(tea.KeyPressMsg{Code: 'a', Text: "a"})223 got = next.(Model)224 next, _ = got.Update(tea.KeyPressMsg{Code: 't', Text: "t"})225 got = next.(Model)226 next, _ = got.Update(tea.KeyPressMsg{Code: 'e', Text: "e"})227 got = next.(Model)228 next, _ = got.Update(tea.KeyPressMsg{Code: tea.KeyEnter})229 got = next.(Model)230 if got.feedback.Message != "word added to board" {231 t.Fatalf("submit feedback = %q", got.feedback.Message)232 }233234 next, _ = got.Update(tea.KeyPressMsg{Code: 'm', Text: "m"})235 got = next.(Model)236 next, _ = got.Update(tea.KeyPressMsg{Code: 'e', Text: "e"})237 got = next.(Model)238 next, _ = got.Update(tea.KeyPressMsg{Code: 't', Text: "t"})239 got = next.(Model)240 next, _ = got.Update(tea.KeyPressMsg{Code: 'a', Text: "a"})241 got = next.(Model)242 next, _ = got.Update(tea.KeyPressMsg{Code: tea.KeyEnter})243 got = next.(Model)244 if !got.solved {245 t.Fatal("expected puzzle to be solved after both board words")246 }247}248249func TestKeyboardDisallowsLettersOutsideBank(t *testing.T) {250 m := testPuzzle(t)251252 next, _ := m.Update(tea.KeyPressMsg{Code: 'z', Text: "z"})253 got := next.(Model)254 if got.traceWord() != "" {255 t.Fatalf("trace after typing z = %q, want empty", got.traceWord())256 }257258 next, _ = got.Update(tea.KeyPressMsg{Code: 'm', Text: "m"})259 got = next.(Model)260 next, _ = got.Update(tea.KeyPressMsg{Code: 'm', Text: "m"})261 got = next.(Model)262 if got.traceWord() != "M" {263 t.Fatalf("trace after reusing unavailable m = %q, want M", got.traceWord())264 }265}266267func TestBackspaceClearsBankCursorWhenTraceBecomesEmpty(t *testing.T) {268 m := testPuzzle(t)269270 next, _ := m.Update(tea.KeyPressMsg{Code: 'm', Text: "m"})271 got := next.(Model)272 if got.bankCursor != 0 {273 t.Fatalf("bank cursor after typing = %d, want 0", got.bankCursor)274 }275276 next, _ = got.Update(tea.KeyPressMsg{Code: tea.KeyBackspace})277 got = next.(Model)278 if got.bankCursor != -1 {279 t.Fatalf("bank cursor after clearing trace = %d, want -1", got.bankCursor)280 }281}282283func TestSolvedStateOnlyAllowsCursorMovement(t *testing.T) {284 m := testPuzzle(t)285 m.solved = true286287 next, _ := m.Update(tea.KeyPressMsg{Code: 'm', Text: "m"})288 got := next.(Model)289 if got.traceWord() != "" {290 t.Fatalf("trace in solved state = %q, want empty", got.traceWord())291 }292293 next, _ = got.Update(tea.KeyPressMsg{Code: '1', Text: "1"})294 got = next.(Model)295 if string(got.bank) != string(m.bank) {296 t.Fatalf("bank changed in solved state: %q vs %q", string(got.bank), string(m.bank))297 }298299 next, _ = got.Update(tea.KeyPressMsg{Code: tea.KeyRight})300 got = next.(Model)301 if got.bankCursor != 0 {302 t.Fatalf("bank cursor after right in solved state = %d, want 0", got.bankCursor)303 }304}305306func TestShuffleReordersVisibleBankAndPreservesTrace(t *testing.T) {307 m := testPuzzle(t)308 m.trace = []int{0, 1}309 beforeTrace := m.traceWord()310 beforeBank := string(m.bank)311 beforeSlots := append([]int(nil), m.bankColorSlots...)312 beforePairs := make(map[string]int, len(m.bank))313 for i, letter := range m.bank {314 beforePairs[string(letter)+":"+string(rune('0'+beforeSlots[i]))]++315 }316317 next, _ := m.Update(tea.KeyPressMsg{Code: '1', Text: "1"})318 got := next.(Model)319320 if string(got.bank) == beforeBank {321 t.Fatalf("bank did not change: %q", string(got.bank))322 }323 if got.traceWord() != beforeTrace {324 t.Fatalf("trace after shuffle = %q, want %q", got.traceWord(), beforeTrace)325 }326 afterPairs := make(map[string]int, len(got.bank))327 for i, letter := range got.bank {328 afterPairs[string(letter)+":"+string(rune('0'+got.bankColorSlots[i]))]++329 }330 if len(afterPairs) != len(beforePairs) {331 t.Fatalf("bank color pair cardinality changed: %v vs %v", afterPairs, beforePairs)332 }333 for key, count := range beforePairs {334 if afterPairs[key] != count {335 t.Fatalf("bank color pairing for %q = %d, want %d", key, afterPairs[key], count)336 }337 }338}339340func TestMouseTraceSubmission(t *testing.T) {341 m := testPuzzle(t)342 m.termWidth = 100343 m.termHeight = 40344 x0, y := m.bankOrigin()345346 next, _ := m.Update(tea.MouseClickMsg{X: x0 + 1, Y: y, Button: tea.MouseLeft})347 got := next.(Model)348 next, _ = got.Update(tea.MouseMotionMsg{X: x0 + 5, Y: y, Button: tea.MouseLeft})349 got = next.(Model)350 next, _ = got.Update(tea.MouseMotionMsg{X: x0 + 9, Y: y, Button: tea.MouseLeft})351 got = next.(Model)352 next, _ = got.Update(tea.MouseMotionMsg{X: x0 + 13, Y: y, Button: tea.MouseLeft})353 got = next.(Model)354 next, _ = got.Update(tea.MouseReleaseMsg{X: x0 + 13, Y: y, Button: tea.MouseLeft})355 got = next.(Model)356 if got.feedback.Message != "word added to board" {357 t.Fatalf("feedback = %q", got.feedback.Message)358 }359}360361func TestMouseHoverDoesNotHighlightWithoutDrag(t *testing.T) {362 m := testPuzzle(t)363 m.termWidth = 100364 m.termHeight = 40365 x, y := m.bankOrigin()366367 next, _ := m.Update(tea.MouseMotionMsg{X: x + 1, Y: y})368 got := next.(Model)369 if got.hoverTile != -1 {370 t.Fatalf("hover tile = %d, want -1 without dragging", got.hoverTile)371 }372}373374func TestSolvedViewHidesTraceAndStatusRows(t *testing.T) {375 m := testPuzzle(t)376 m.solved = true377378 view := m.View()379 if strings.Contains(view, "trace:") {380 t.Fatalf("solved view unexpectedly contains trace row: %q", view)381 }382 if strings.Contains(view, "type: add letters") {383 t.Fatalf("solved view unexpectedly contains status row: %q", view)384 }385}386387func TestSaveLoadRoundTrip(t *testing.T) {388 m := testPuzzle(t)389 m.trace = []int{0, 1}390 m.bonusWords = []string{"MESA"}391 m.bankCursor = 1392 m.bankColorSlots = []int{3, 1, 4, 0, 2}393 m.rebuildDerivedState()394395 data, err := m.GetSave()396 if err != nil {397 t.Fatal(err)398 }399400 got, err := ImportModel(data)401 if err != nil {402 t.Fatal(err)403 }404 if string(got.bank) != string(m.bank) {405 t.Fatalf("bank = %q, want %q", string(got.bank), string(m.bank))406 }407 if len(got.bonusWords) != 1 || got.bonusWords[0] != "MESA" {408 t.Fatalf("bonusWords = %v", got.bonusWords)409 }410 if got.traceWord() != "MA" {411 t.Fatalf("trace = %q, want MA", got.traceWord())412 }413 if len(got.bankColorSlots) != len(m.bankColorSlots) {414 t.Fatalf("bankColorSlots len = %d, want %d", len(got.bankColorSlots), len(m.bankColorSlots))415 }416 for i := range got.bankColorSlots {417 if got.bankColorSlots[i] != m.bankColorSlots[i] {418 t.Fatalf("bankColorSlots[%d] = %d, want %d", i, got.bankColorSlots[i], m.bankColorSlots[i])419 }420 }421}422423func TestImportWithoutTraceClearsIdleBankCursor(t *testing.T) {424 data, err := json.Marshal(Save{425 ModeTitle: "Test",426 Bank: "MATES",427 Placements: []WordPlacement{{Text: "MATE", Start: Position{X: 0, Y: 0}, Orientation: Horizontal}},428 BankCursor: 0,429 })430 if err != nil {431 t.Fatal(err)432 }433434 got, err := ImportModel(data)435 if err != nil {436 t.Fatal(err)437 }438 if got.bankCursor != -1 {439 t.Fatalf("bank cursor after import = %d, want -1", got.bankCursor)440 }441}442443func TestImportDeduplicatesDuplicatePlacements(t *testing.T) {444 data, err := json.Marshal(Save{445 ModeTitle: "opal-crane",446 Bank: "PATACN",447 Placements: []WordPlacement{448 {Text: "CATNAP", Start: Position{X: 3, Y: 0}, Orientation: Vertical, Found: true},449 {Text: "CAPTAN", Start: Position{X: 0, Y: 2}, Orientation: Vertical, Found: true},450 {Text: "CANT", Start: Position{X: 0, Y: 2}, Orientation: Horizontal, Found: false},451 {Text: "CANT", Start: Position{X: 0, Y: 2}, Orientation: Horizontal, Found: true},452 },453 })454 if err != nil {455 t.Fatal(err)456 }457458 got, err := ImportModel(data)459 if err != nil {460 t.Fatal(err)461 }462 if len(got.placements) != 3 {463 t.Fatalf("placement count after import = %d, want 3", len(got.placements))464 }465 if got.countWordsLeft() != 0 {466 t.Fatalf("words left = %d, want 0", got.countWordsLeft())467 }468 if !got.solved {469 t.Fatal("expected imported model to be solved after deduping duplicate placement")470 }471}472473func TestCountBonusWordsTotal(t *testing.T) {474 m := testPuzzle(t)475476 want := len(m.allowedWords) - len(m.boardIndex)477 if got := m.countBonusWordsTotal(); got != want {478 t.Fatalf("bonus total = %d, want %d", got, want)479 }480}481482func TestImportModelEmptyData(t *testing.T) {483 data, _ := json.Marshal(Save{})484 got, err := ImportModel(data)485 if err != nil {486 t.Fatal(err)487 }488 if len(got.bank) != 0 {489 t.Fatalf("len(bank) = %d, want 0", len(got.bank))490 }491}