clone

fifteen puzzle games for the terminal, with daily puzzles, weekly ladders and xp, built on bubble tea

main
puzzletea / games / spellpuzzle / spellpuzzle_test.go
spellpuzzle_test.go 15.5K
1package spellpuzzle23import (4 "encoding/json"5 "math/rand/v2"6 "strings"7 "testing"89 tea "charm.land/bubbletea/v2"10 "github.com/FelineStateMachine/puzzletea/difficulty"11)1213func newTestRNG(seed uint64) *rand.Rand {14 return rand.New(rand.NewPCG(seed, seed+1))15}1617func testPuzzle(t *testing.T) Model {18 t.Helper()1920 model := Model{21 modeTitle: "Test",22 bank: []rune("MATES"),23 placements: []WordPlacement{24 {Text: "MATE", Start: Position{X: 0, Y: 0}, Orientation: Horizontal},25 {Text: "META", Start: Position{X: 0, Y: 0}, Orientation: Vertical},26 },27 keys: DefaultKeyMap,28 hoverTile: -1,29 }30 model.rebuildDerivedState()31 return model32}3334func TestNewPuzzleStartsWithoutBankCursor(t *testing.T) {35 m := testPuzzle(t)36 if m.bankCursor != -1 {37 t.Fatalf("bank cursor = %d, want -1", m.bankCursor)38 }39 if len(m.bankColorSlots) != len(m.bank) {40 t.Fatalf("bank color slot count = %d, want %d", len(m.bankColorSlots), len(m.bank))41 }42}4344func TestGeneratePuzzleSeededDeterministic(t *testing.T) {45 mode := NewMode("Easy", "", 7, 6, 4)46 rngA := newTestRNG(12)47 rngB := newTestRNG(12)4849 puzzleA, err := GeneratePuzzleSeeded(mode, rngA)50 if err != nil {51 t.Fatal(err)52 }53 puzzleB, err := GeneratePuzzleSeeded(mode, rngB)54 if err != nil {55 t.Fatal(err)56 }5758 if puzzleA.Bank != puzzleB.Bank {59 t.Fatalf("bank mismatch: %q vs %q", puzzleA.Bank, puzzleB.Bank)60 }61 if len(puzzleA.Placements) != len(puzzleB.Placements) {62 t.Fatalf("placement count mismatch: %d vs %d", len(puzzleA.Placements), len(puzzleB.Placements))63 }64 for i := range puzzleA.Placements {65 if puzzleA.Placements[i] != puzzleB.Placements[i] {66 t.Fatalf("placement[%d] mismatch: %+v vs %+v", i, puzzleA.Placements[i], puzzleB.Placements[i])67 }68 }69}7071func TestSpawnEloRejectsInvalidElo(t *testing.T) {72 mode := NewMode("Easy", "", 7, 6, 4)7374 if _, _, err := mode.SpawnElo("seed", difficulty.SoftCapElo+1); err == nil {75 t.Fatal("SpawnElo returned nil error for invalid Elo")76 }77}7879func TestSpawnEloDeterministicForSameSeedAndElo(t *testing.T) {80 mode := NewMode("Easy", "", 7, 6, 4)8182 gameA, reportA, err := mode.SpawnElo("same-seed", 1200)83 if err != nil {84 t.Fatal(err)85 }86 gameB, reportB, err := mode.SpawnElo("same-seed", 1200)87 if err != nil {88 t.Fatal(err)89 }9091 saveA, err := gameA.GetSave()92 if err != nil {93 t.Fatal(err)94 }95 saveB, err := gameB.GetSave()96 if err != nil {97 t.Fatal(err)98 }99100 if string(saveA) != string(saveB) {101 t.Fatalf("SpawnElo saves differ for same seed and Elo:\n%s\n%s", saveA, saveB)102 }103 if reportA.ActualElo != reportB.ActualElo {104 t.Fatalf("ActualElo mismatch: %d vs %d", reportA.ActualElo, reportB.ActualElo)105 }106}107108func TestSpawnEloReportPopulated(t *testing.T) {109 mode := NewMode("Easy", "", 7, 6, 4)110 _, report, err := mode.SpawnElo("report-seed", 1800)111 if err != nil {112 t.Fatal(err)113 }114115 if report.TargetElo != 1800 {116 t.Fatalf("TargetElo = %d, want 1800", report.TargetElo)117 }118 if report.ActualElo == 0 {119 t.Fatal("ActualElo is zero")120 }121 if report.Confidence == "" {122 t.Fatal("Confidence is empty")123 }124 if len(report.Metrics) == 0 {125 t.Fatal("Metrics is empty")126 }127128 for _, key := range []string{129 "bank_size",130 "board_word_count",131 "bonus_word_count",132 "allowed_word_count",133 "average_word_length",134 "crossing_count",135 "board_width",136 "board_height",137 "occupied_cell_count",138 } {139 if _, ok := report.Metrics[key]; !ok {140 t.Fatalf("report metric %q missing from %+v", key, report.Metrics)141 }142 }143}144145func TestGeneratedWordsRespectBankAndBonusMinimum(t *testing.T) {146 modes := []SpellPuzzleMode{147 NewMode("Beginner", "", 6, 4, 3),148 NewMode("Easy", "", 7, 6, 4),149 NewMode("Medium", "", 8, 8, 6),150 NewMode("Hard", "", 9, 9, 8),151 }152153 for _, mode := range modes {154 rng := newTestRNG(uint64(mode.BankSize * 11))155 puzzle, err := GeneratePuzzleSeeded(mode, rng)156 if err != nil {157 t.Fatalf("%s: %v", mode.Title(), err)158 }159 if len(puzzle.Placements) != mode.BoardWordCount {160 t.Fatalf("%s placement count = %d, want %d", mode.Title(), len(puzzle.Placements), mode.BoardWordCount)161 }162 seenPlacements := make(map[WordPlacement]struct{}, len(puzzle.Placements))163 for _, placement := range puzzle.Placements {164 if _, ok := seenPlacements[placement]; ok {165 t.Fatalf("%s contains duplicate placement %+v", mode.Title(), placement)166 }167 seenPlacements[placement] = struct{}{}168 }169 if len(puzzle.BonusWords) < mode.MinBonusWords {170 t.Fatalf("%s bonus count = %d, want at least %d", mode.Title(), len(puzzle.BonusWords), mode.MinBonusWords)171 }172 for _, placement := range puzzle.Placements {173 if !canSpellWord(placement.Text, countLetters(puzzle.Bank)) {174 t.Fatalf("%s placement %q cannot be spelled from bank %q", mode.Title(), placement.Text, puzzle.Bank)175 }176 }177 }178}179180func TestGeneratedBankIsScrambledAwayFromPlacedWords(t *testing.T) {181 mode := NewMode("Easy", "", 7, 6, 4)182 puzzle, err := GeneratePuzzleSeeded(mode, newTestRNG(21))183 if err != nil {184 t.Fatal(err)185 }186187 for _, placement := range puzzle.Placements {188 if puzzle.Bank == placement.Text {189 t.Fatalf("bank %q matches placed word %q", puzzle.Bank, placement.Text)190 }191 if puzzle.Bank == reverseWord(placement.Text) {192 t.Fatalf("bank %q matches reversed placed word %q", puzzle.Bank, placement.Text)193 }194 }195}196197func TestGeneratedCrosswordIsConnectedAndIntersecting(t *testing.T) {198 mode := NewMode("Medium", "", 8, 8, 6)199 puzzle, err := GeneratePuzzleSeeded(mode, newTestRNG(99))200 if err != nil {201 t.Fatal(err)202 }203204 seen := make(map[Position]int)205 for _, placement := range puzzle.Placements {206 for _, pos := range placement.Positions() {207 seen[pos]++208 }209 }210211 intersectionCount := 0212 for _, count := range seen {213 if count > 1 {214 intersectionCount++215 }216 }217 if intersectionCount == 0 {218 t.Fatal("expected at least one crossword intersection")219 }220221 board := buildBoard(puzzle.Placements)222 if board.Width == 0 || board.Height == 0 {223 t.Fatal("expected non-empty board")224 }225}226227func TestBoardEdgesOutlineWordZones(t *testing.T) {228 m := testPuzzle(t)229230 if !m.board.hasVerticalEdge(0, 0) {231 t.Fatal("expected left outer edge for occupied word cell")232 }233 if !m.board.hasHorizontalEdge(0, 0) {234 t.Fatal("expected top outer edge for occupied word cell")235 }236 if m.board.hasVerticalEdge(1, 0) {237 t.Fatal("expected no edge inside the horizontal word run")238 }239 if m.board.hasHorizontalEdge(0, 1) {240 t.Fatal("expected no edge inside the vertical word run")241 }242}243244func TestSubmitBoardWordAndBonusWord(t *testing.T) {245 m := testPuzzle(t)246 m.trace = []int{0, 1, 2, 3}247 m.bankCursor = 3248 m.submitTrace()249 if !m.placements[m.boardIndex["MATE"][0]].Found {250 t.Fatal("expected MATE to be revealed")251 }252 if m.feedback.Message != "word added to board" {253 t.Fatalf("feedback = %q", m.feedback.Message)254 }255 if m.bankCursor != -1 {256 t.Fatalf("bank cursor after submit = %d, want -1", m.bankCursor)257 }258259 m.clearFeedback()260 m.trace = []int{0, 3, 4, 1}261 m.submitTrace()262 if len(m.bonusWords) != 1 || m.bonusWords[0] != "MESA" {263 t.Fatalf("bonus words = %v, want [MESA]", m.bonusWords)264 }265 if m.feedback.Message != "bonus word counted" {266 t.Fatalf("feedback = %q", m.feedback.Message)267 }268}269270func TestSubmitNotAWordAndAlreadyFound(t *testing.T) {271 m := testPuzzle(t)272 m.trace = []int{0, 0, 0}273 m.submitTrace()274 if m.feedback.Message != "not a word" {275 t.Fatalf("feedback = %q, want not a word", m.feedback.Message)276 }277278 m.trace = []int{0, 1, 2, 3}279 m.submitTrace()280 m.clearFeedback()281 m.trace = []int{0, 1, 2, 3}282 m.submitTrace()283 if m.feedback.Message != "word already found" {284 t.Fatalf("feedback = %q, want word already found", m.feedback.Message)285 }286}287288func TestKeyboardInputAndSolveState(t *testing.T) {289 m := testPuzzle(t)290291 next, _ := m.Update(tea.KeyPressMsg{Code: 'm', Text: "m"})292 got := next.(Model)293 if got.traceWord() != "M" {294 t.Fatalf("trace after typing m = %q, want M", got.traceWord())295 }296297 next, _ = got.Update(tea.KeyPressMsg{Code: 'a', Text: "a"})298 got = next.(Model)299 next, _ = got.Update(tea.KeyPressMsg{Code: 't', Text: "t"})300 got = next.(Model)301 next, _ = got.Update(tea.KeyPressMsg{Code: 'e', Text: "e"})302 got = next.(Model)303 next, _ = got.Update(tea.KeyPressMsg{Code: tea.KeyEnter})304 got = next.(Model)305 if got.feedback.Message != "word added to board" {306 t.Fatalf("submit feedback = %q", got.feedback.Message)307 }308309 next, _ = got.Update(tea.KeyPressMsg{Code: 'm', Text: "m"})310 got = next.(Model)311 next, _ = got.Update(tea.KeyPressMsg{Code: 'e', Text: "e"})312 got = next.(Model)313 next, _ = got.Update(tea.KeyPressMsg{Code: 't', Text: "t"})314 got = next.(Model)315 next, _ = got.Update(tea.KeyPressMsg{Code: 'a', Text: "a"})316 got = next.(Model)317 next, _ = got.Update(tea.KeyPressMsg{Code: tea.KeyEnter})318 got = next.(Model)319 if !got.solved {320 t.Fatal("expected puzzle to be solved after both board words")321 }322}323324func TestKeyboardDisallowsLettersOutsideBank(t *testing.T) {325 m := testPuzzle(t)326327 next, _ := m.Update(tea.KeyPressMsg{Code: 'z', Text: "z"})328 got := next.(Model)329 if got.traceWord() != "" {330 t.Fatalf("trace after typing z = %q, want empty", got.traceWord())331 }332333 next, _ = got.Update(tea.KeyPressMsg{Code: 'm', Text: "m"})334 got = next.(Model)335 next, _ = got.Update(tea.KeyPressMsg{Code: 'm', Text: "m"})336 got = next.(Model)337 if got.traceWord() != "M" {338 t.Fatalf("trace after reusing unavailable m = %q, want M", got.traceWord())339 }340}341342func TestBackspaceClearsBankCursorWhenTraceBecomesEmpty(t *testing.T) {343 m := testPuzzle(t)344345 next, _ := m.Update(tea.KeyPressMsg{Code: 'm', Text: "m"})346 got := next.(Model)347 if got.bankCursor != 0 {348 t.Fatalf("bank cursor after typing = %d, want 0", got.bankCursor)349 }350351 next, _ = got.Update(tea.KeyPressMsg{Code: tea.KeyBackspace})352 got = next.(Model)353 if got.bankCursor != -1 {354 t.Fatalf("bank cursor after clearing trace = %d, want -1", got.bankCursor)355 }356}357358func TestSolvedStateOnlyAllowsCursorMovement(t *testing.T) {359 m := testPuzzle(t)360 m.solved = true361362 next, _ := m.Update(tea.KeyPressMsg{Code: 'm', Text: "m"})363 got := next.(Model)364 if got.traceWord() != "" {365 t.Fatalf("trace in solved state = %q, want empty", got.traceWord())366 }367368 next, _ = got.Update(tea.KeyPressMsg{Code: '1', Text: "1"})369 got = next.(Model)370 if string(got.bank) != string(m.bank) {371 t.Fatalf("bank changed in solved state: %q vs %q", string(got.bank), string(m.bank))372 }373374 next, _ = got.Update(tea.KeyPressMsg{Code: tea.KeyRight})375 got = next.(Model)376 if got.bankCursor != 0 {377 t.Fatalf("bank cursor after right in solved state = %d, want 0", got.bankCursor)378 }379}380381func TestShuffleReordersVisibleBankAndPreservesTrace(t *testing.T) {382 m := testPuzzle(t)383 m.trace = []int{0, 1}384 beforeTrace := m.traceWord()385 beforeBank := string(m.bank)386 beforeSlots := append([]int(nil), m.bankColorSlots...)387 beforePairs := make(map[string]int, len(m.bank))388 for i, letter := range m.bank {389 beforePairs[string(letter)+":"+string(rune('0'+beforeSlots[i]))]++390 }391392 next, _ := m.Update(tea.KeyPressMsg{Code: '1', Text: "1"})393 got := next.(Model)394395 if string(got.bank) == beforeBank {396 t.Fatalf("bank did not change: %q", string(got.bank))397 }398 if got.traceWord() != beforeTrace {399 t.Fatalf("trace after shuffle = %q, want %q", got.traceWord(), beforeTrace)400 }401 afterPairs := make(map[string]int, len(got.bank))402 for i, letter := range got.bank {403 afterPairs[string(letter)+":"+string(rune('0'+got.bankColorSlots[i]))]++404 }405 if len(afterPairs) != len(beforePairs) {406 t.Fatalf("bank color pair cardinality changed: %v vs %v", afterPairs, beforePairs)407 }408 for key, count := range beforePairs {409 if afterPairs[key] != count {410 t.Fatalf("bank color pairing for %q = %d, want %d", key, afterPairs[key], count)411 }412 }413}414415func TestMouseTraceSubmission(t *testing.T) {416 m := testPuzzle(t)417 m.termWidth = 100418 m.termHeight = 40419 x0, y := m.bankOrigin()420421 next, _ := m.Update(tea.MouseClickMsg{X: x0 + 1, Y: y, Button: tea.MouseLeft})422 got := next.(Model)423 next, _ = got.Update(tea.MouseMotionMsg{X: x0 + 5, Y: y, Button: tea.MouseLeft})424 got = next.(Model)425 next, _ = got.Update(tea.MouseMotionMsg{X: x0 + 9, Y: y, Button: tea.MouseLeft})426 got = next.(Model)427 next, _ = got.Update(tea.MouseMotionMsg{X: x0 + 13, Y: y, Button: tea.MouseLeft})428 got = next.(Model)429 next, _ = got.Update(tea.MouseReleaseMsg{X: x0 + 13, Y: y, Button: tea.MouseLeft})430 got = next.(Model)431 if got.feedback.Message != "word added to board" {432 t.Fatalf("feedback = %q", got.feedback.Message)433 }434}435436func TestMouseHoverDoesNotHighlightWithoutDrag(t *testing.T) {437 m := testPuzzle(t)438 m.termWidth = 100439 m.termHeight = 40440 x, y := m.bankOrigin()441442 next, _ := m.Update(tea.MouseMotionMsg{X: x + 1, Y: y})443 got := next.(Model)444 if got.hoverTile != -1 {445 t.Fatalf("hover tile = %d, want -1 without dragging", got.hoverTile)446 }447}448449func TestSolvedViewHidesTraceAndStatusRows(t *testing.T) {450 m := testPuzzle(t)451 m.solved = true452453 view := m.View()454 if strings.Contains(view, "trace:") {455 t.Fatalf("solved view unexpectedly contains trace row: %q", view)456 }457 if strings.Contains(view, "type: add letters") {458 t.Fatalf("solved view unexpectedly contains status row: %q", view)459 }460}461462func TestSaveLoadRoundTrip(t *testing.T) {463 m := testPuzzle(t)464 m.trace = []int{0, 1}465 m.bonusWords = []string{"MESA"}466 m.bankCursor = 1467 m.bankColorSlots = []int{3, 1, 4, 0, 2}468 m.rebuildDerivedState()469470 data, err := m.GetSave()471 if err != nil {472 t.Fatal(err)473 }474475 got, err := ImportModel(data)476 if err != nil {477 t.Fatal(err)478 }479 if string(got.bank) != string(m.bank) {480 t.Fatalf("bank = %q, want %q", string(got.bank), string(m.bank))481 }482 if len(got.bonusWords) != 1 || got.bonusWords[0] != "MESA" {483 t.Fatalf("bonusWords = %v", got.bonusWords)484 }485 if got.traceWord() != "MA" {486 t.Fatalf("trace = %q, want MA", got.traceWord())487 }488 if len(got.bankColorSlots) != len(m.bankColorSlots) {489 t.Fatalf("bankColorSlots len = %d, want %d", len(got.bankColorSlots), len(m.bankColorSlots))490 }491 for i := range got.bankColorSlots {492 if got.bankColorSlots[i] != m.bankColorSlots[i] {493 t.Fatalf("bankColorSlots[%d] = %d, want %d", i, got.bankColorSlots[i], m.bankColorSlots[i])494 }495 }496}497498func TestImportWithoutTraceClearsIdleBankCursor(t *testing.T) {499 data, err := json.Marshal(Save{500 ModeTitle: "Test",501 Bank: "MATES",502 Placements: []WordPlacement{{Text: "MATE", Start: Position{X: 0, Y: 0}, Orientation: Horizontal}},503 BankCursor: 0,504 })505 if err != nil {506 t.Fatal(err)507 }508509 got, err := ImportModel(data)510 if err != nil {511 t.Fatal(err)512 }513 if got.bankCursor != -1 {514 t.Fatalf("bank cursor after import = %d, want -1", got.bankCursor)515 }516}517518func TestImportDeduplicatesDuplicatePlacements(t *testing.T) {519 data, err := json.Marshal(Save{520 ModeTitle: "opal-crane",521 Bank: "PATACN",522 Placements: []WordPlacement{523 {Text: "CATNAP", Start: Position{X: 3, Y: 0}, Orientation: Vertical, Found: true},524 {Text: "CAPTAN", Start: Position{X: 0, Y: 2}, Orientation: Vertical, Found: true},525 {Text: "CANT", Start: Position{X: 0, Y: 2}, Orientation: Horizontal, Found: false},526 {Text: "CANT", Start: Position{X: 0, Y: 2}, Orientation: Horizontal, Found: true},527 },528 })529 if err != nil {530 t.Fatal(err)531 }532533 got, err := ImportModel(data)534 if err != nil {535 t.Fatal(err)536 }537 if len(got.placements) != 3 {538 t.Fatalf("placement count after import = %d, want 3", len(got.placements))539 }540 if got.countWordsLeft() != 0 {541 t.Fatalf("words left = %d, want 0", got.countWordsLeft())542 }543 if !got.solved {544 t.Fatal("expected imported model to be solved after deduping duplicate placement")545 }546}547548func TestCountBonusWordsTotal(t *testing.T) {549 m := testPuzzle(t)550551 want := len(m.allowedWords) - len(m.boardIndex)552 if got := m.countBonusWordsTotal(); got != want {553 t.Fatalf("bonus total = %d, want %d", got, want)554 }555}556557func TestImportModelEmptyData(t *testing.T) {558 data, _ := json.Marshal(Save{})559 got, err := ImportModel(data)560 if err != nil {561 t.Fatal(err)562 }563 if len(got.bank) != 0 {564 t.Fatalf("len(bank) = %d, want 0", len(got.bank))565 }566}