package tetris import ( "context" "strings" "testing" "time" tea "github.com/charmbracelet/bubbletea" "code.t-juice.club/torjus/oubliette/internal/shell" "code.t-juice.club/torjus/oubliette/internal/storage" ) // newTestModel creates a model with a test session context. func newTestModel(t *testing.T) (*model, *storage.MemoryStore) { t.Helper() store := storage.NewMemoryStore() sessID, _ := store.CreateSession(context.Background(), "127.0.0.1", "player", "tetris", "") sess := &shell.SessionContext{ SessionID: sessID, Username: "player", Store: store, } m := newModel(sess, "normal") return m, store } // sendKey sends a single key message to the model and returns the command. func sendKey(m *model, key string) tea.Cmd { var msg tea.KeyMsg switch key { case "enter": msg = tea.KeyMsg{Type: tea.KeyEnter} case "up": msg = tea.KeyMsg{Type: tea.KeyUp} case "down": msg = tea.KeyMsg{Type: tea.KeyDown} case "left": msg = tea.KeyMsg{Type: tea.KeyLeft} case "right": msg = tea.KeyMsg{Type: tea.KeyRight} case "space": msg = tea.KeyMsg{Type: tea.KeySpace} case "ctrl+c": msg = tea.KeyMsg{Type: tea.KeyCtrlC} default: if len(key) == 1 { msg = tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(key)} } } _, cmd := m.Update(msg) return cmd } // sendTick sends a tick message to the model. func sendTick(m *model) tea.Cmd { _, cmd := m.Update(tickMsg(time.Now())) return cmd } // execCmds recursively executes tea.Cmd functions (including batches). func execCmds(cmd tea.Cmd) { if cmd == nil { return } msg := cmd() if batch, ok := msg.(tea.BatchMsg); ok { for _, c := range batch { execCmds(c) } } } func TestTetrisShellName(t *testing.T) { sh := NewTetrisShell() if sh.Name() != "tetris" { t.Errorf("Name() = %q, want %q", sh.Name(), "tetris") } if sh.Description() == "" { t.Error("Description() should not be empty") } } func TestConfigString(t *testing.T) { cfg := map[string]any{ "difficulty": "hard", } if got := configString(cfg, "difficulty", "normal"); got != "hard" { t.Errorf("configString() = %q, want %q", got, "hard") } if got := configString(cfg, "missing", "normal"); got != "normal" { t.Errorf("configString() = %q, want %q", got, "normal") } if got := configString(nil, "difficulty", "normal"); got != "normal" { t.Errorf("configString(nil) = %q, want %q", got, "normal") } } func TestTitleScreenRenders(t *testing.T) { m, _ := newTestModel(t) view := m.View() if !strings.Contains(view, "████") { t.Error("title screen should show TETRIS logo") } if !strings.Contains(view, "Press any key") { t.Error("title screen should show 'Press any key'") } } func TestTitleToGame(t *testing.T) { m, _ := newTestModel(t) if m.screen != screenTitle { t.Fatalf("expected screenTitle, got %d", m.screen) } sendKey(m, "enter") if m.screen != screenGame { t.Errorf("expected screenGame after keypress, got %d", m.screen) } if m.game == nil { t.Fatal("game should be initialized") } } func TestGameRenders(t *testing.T) { m, _ := newTestModel(t) sendKey(m, "enter") // start game view := m.View() if !strings.Contains(view, "|") { t.Error("game view should contain board borders") } if !strings.Contains(view, "SCORE") { t.Error("game view should show SCORE") } if !strings.Contains(view, "LEVEL") { t.Error("game view should show LEVEL") } if !strings.Contains(view, "LINES") { t.Error("game view should show LINES") } if !strings.Contains(view, "NEXT") { t.Error("game view should show NEXT") } } // --- Pure game logic tests --- func TestNewGame(t *testing.T) { g := newGame(0) if g.gameOver { t.Error("new game should not be game over") } if g.score != 0 { t.Errorf("initial score = %d, want 0", g.score) } if g.level != 0 { t.Errorf("initial level = %d, want 0", g.level) } if g.lines != 0 { t.Errorf("initial lines = %d, want 0", g.lines) } } func TestNewGameHardLevel(t *testing.T) { g := newGame(5) if g.level != 5 { t.Errorf("hard start level = %d, want 5", g.level) } } func TestMoveLeft(t *testing.T) { g := newGame(0) startCol := g.currentCol g.moveLeft() if g.currentCol != startCol-1 { t.Errorf("after moveLeft: col = %d, want %d", g.currentCol, startCol-1) } } func TestMoveRight(t *testing.T) { g := newGame(0) startCol := g.currentCol g.moveRight() if g.currentCol != startCol+1 { t.Errorf("after moveRight: col = %d, want %d", g.currentCol, startCol+1) } } func TestMoveDown(t *testing.T) { g := newGame(0) startRow := g.currentRow moved := g.moveDown() if !moved { t.Error("moveDown should succeed from starting position") } if g.currentRow != startRow+1 { t.Errorf("after moveDown: row = %d, want %d", g.currentRow, startRow+1) } } func TestCannotMoveLeftBeyondWall(t *testing.T) { g := newGame(0) // Move all the way left. for range boardCols { g.moveLeft() } col := g.currentCol g.moveLeft() // should not move further if g.currentCol != col { t.Errorf("should not move past left wall: col = %d, was %d", g.currentCol, col) } } func TestCannotMoveRightBeyondWall(t *testing.T) { g := newGame(0) // Move all the way right. for range boardCols { g.moveRight() } col := g.currentCol g.moveRight() // should not move further if g.currentCol != col { t.Errorf("should not move past right wall: col = %d, was %d", g.currentCol, col) } } func TestRotate(t *testing.T) { g := newGame(0) startRot := g.currentRot g.rotate() // Rotation should change (possibly with wall kick). if g.currentRot == startRot { // Rotation might legitimately fail in some edge cases, so just check // that the game state is valid. if !g.canPlace(g.current, g.currentRot, g.currentRow, g.currentCol) { t.Error("piece should be in a valid position after rotate attempt") } } } func TestHardDrop(t *testing.T) { g := newGame(0) startRow := g.currentRow dropped := g.hardDrop() if dropped == 0 { t.Error("hard drop should move piece down at least some rows from top") } if g.currentRow <= startRow { t.Errorf("after hardDrop: row = %d should be > %d", g.currentRow, startRow) } } func TestGhostRow(t *testing.T) { g := newGame(0) ghost := g.ghostRow() if ghost < g.currentRow { t.Errorf("ghost row %d should be >= current row %d", ghost, g.currentRow) } // Ghost should be at a position where moving down one more is impossible. if g.canPlace(g.current, g.currentRot, ghost+1, g.currentCol) { t.Error("ghost row should be the lowest valid position") } } func TestLockPiece(t *testing.T) { g := newGame(0) g.hardDrop() pt := g.current row, col, rot := g.currentRow, g.currentCol, g.currentRot g.lockPiece() // Verify that the piece's cells are now filled. shape := pieces[pt][rot] for _, off := range shape { r, c := row+off[0], col+off[1] if !g.board[r][c].filled { t.Errorf("cell (%d, %d) should be filled after lockPiece", r, c) } } } func TestClearLines(t *testing.T) { g := newGame(0) // Fill the bottom row completely. for c := range boardCols { g.board[boardRows-1][c] = cell{filled: true, piece: pieceI} } cleared := g.clearLines() if cleared != 1 { t.Errorf("clearLines() = %d, want 1", cleared) } // Bottom row should now be empty (shifted from above). for c := range boardCols { if g.board[boardRows-1][c].filled { t.Errorf("bottom row col %d should be empty after clearing", c) } } } func TestClearMultipleLines(t *testing.T) { g := newGame(0) // Fill the bottom 4 rows. for r := boardRows - 4; r < boardRows; r++ { for c := range boardCols { g.board[r][c] = cell{filled: true, piece: pieceI} } } cleared := g.clearLines() if cleared != 4 { t.Errorf("clearLines() = %d, want 4", cleared) } } func TestScoring(t *testing.T) { tests := []struct { lines int level int want int }{ {1, 0, 40}, {2, 0, 100}, {3, 0, 300}, {4, 0, 1200}, {1, 1, 80}, {4, 2, 3600}, } for _, tt := range tests { g := newGame(tt.level) g.addScore(tt.lines) if g.score != tt.want { t.Errorf("score for %d lines at level %d = %d, want %d", tt.lines, tt.level, g.score, tt.want) } } } func TestLevelUp(t *testing.T) { g := newGame(0) g.lines = 9 g.addScore(1) // This should push lines to 10, triggering level 1. if g.level != 1 { t.Errorf("level = %d, want 1 after 10 lines", g.level) } } func TestTickInterval(t *testing.T) { if got := tickInterval(0); got != 800 { t.Errorf("tickInterval(0) = %d, want 800", got) } if got := tickInterval(5); got != 500 { t.Errorf("tickInterval(5) = %d, want 500", got) } // Floor at 100ms. if got := tickInterval(20); got != 100 { t.Errorf("tickInterval(20) = %d, want 100", got) } } func TestFormatScore(t *testing.T) { tests := []struct { n int want string }{ {0, "0"}, {100, "100"}, {1250, "1,250"}, {1000000, "1,000,000"}, } for _, tt := range tests { if got := formatScore(tt.n); got != tt.want { t.Errorf("formatScore(%d) = %q, want %q", tt.n, got, tt.want) } } } func TestGameOverScreen(t *testing.T) { m, _ := newTestModel(t) sendKey(m, "enter") // start game // Force game over. m.game.gameOver = true m.screen = screenGameOver view := m.View() if !strings.Contains(view, "GAME OVER") { t.Error("game over screen should show GAME OVER") } if !strings.Contains(view, "Score") { t.Error("game over screen should show score") } } func TestRestartFromGameOver(t *testing.T) { m, _ := newTestModel(t) sendKey(m, "enter") // start game m.game.gameOver = true m.screen = screenGameOver sendKey(m, "r") if m.screen != screenGame { t.Errorf("expected screenGame after restart, got %d", m.screen) } if m.game.gameOver { t.Error("game should not be over after restart") } } func TestQuitFromGame(t *testing.T) { m, _ := newTestModel(t) sendKey(m, "enter") // start game sendKey(m, "q") if !m.quitting { t.Error("should be quitting after pressing q") } } func TestQuitFromGameOver(t *testing.T) { m, _ := newTestModel(t) sendKey(m, "enter") // start game m.game.gameOver = true m.screen = screenGameOver sendKey(m, "q") if !m.quitting { t.Error("should be quitting after pressing q in game over") } } func TestSoftDropScoring(t *testing.T) { m, _ := newTestModel(t) sendKey(m, "enter") // start game scoreBefore := m.game.score sendKey(m, "down") if m.game.score != scoreBefore+1 { t.Errorf("score after soft drop = %d, want %d", m.game.score, scoreBefore+1) } } func TestHardDropScoring(t *testing.T) { m, _ := newTestModel(t) sendKey(m, "enter") // start game // Hard drop gives 2 points per row dropped. sendKey(m, "space") if m.game.score < 2 { t.Errorf("score after hard drop = %d, should be at least 2", m.game.score) } } func TestTickMovesDown(t *testing.T) { m, _ := newTestModel(t) sendKey(m, "enter") // start game rowBefore := m.game.currentRow sendTick(m) // Piece should either move down by 1, or lock and spawn a new piece at top. movedDown := m.game.currentRow == rowBefore+1 respawned := m.game.currentRow < rowBefore if !movedDown && !respawned && !m.game.gameOver { t.Errorf("tick should move piece down or lock+respawn: row was %d, now %d", rowBefore, m.game.currentRow) } } func TestSessionLogs(t *testing.T) { m, store := newTestModel(t) // Press key to start game — returns a logAction cmd. cmd := sendKey(m, "enter") if cmd != nil { execCmds(cmd) } time.Sleep(50 * time.Millisecond) found := false for _, log := range store.SessionLogs { if strings.Contains(log.Input, "GAME START") { found = true break } } if !found { t.Error("expected GAME START in session logs") } } func TestKeypressCounter(t *testing.T) { m, _ := newTestModel(t) sendKey(m, "enter") // start game sendKey(m, "left") sendKey(m, "right") sendKey(m, "down") if m.keypresses != 4 { // enter + 3 game keys t.Errorf("keypresses = %d, want 4", m.keypresses) } } func TestLockDelay(t *testing.T) { m, _ := newTestModel(t) sendKey(m, "enter") // start game // Drop piece to the bottom via ticks until it can't move down. for range boardRows + 5 { if m.locking { break } sendTick(m) } if !m.locking { t.Fatal("piece should be in locking state after hitting bottom") } // During lock delay, we should still be able to move left/right. colBefore := m.game.currentCol sendKey(m, "left") if m.game.currentCol >= colBefore { // Might not have moved if against wall, try right. sendKey(m, "right") } // Sending a lockMsg should finalize the piece. m.Update(lockMsg(time.Now())) // After lock, a new piece should have spawned (row near top). if m.game.currentRow > 1 && !m.game.gameOver { t.Errorf("after lock delay, new piece should spawn near top, got row %d", m.game.currentRow) } } func TestLockDelayCancelledByDrop(t *testing.T) { m, _ := newTestModel(t) sendKey(m, "enter") // start game // Build a ledge: fill rows 18-19 but leave column 0 empty. for r := boardRows - 2; r < boardRows; r++ { for c := 1; c < boardCols; c++ { m.game.board[r][c] = cell{filled: true, piece: pieceI} } } // Move piece to column 0 area and drop it onto the ledge. for range boardCols { m.game.moveLeft() } // Tick down until locking. for range boardRows + 5 { if m.locking { break } sendTick(m) } // If piece is on the ledge and we slide it to col 0 (open column), // the lock delay should cancel since it can fall further. // This test just validates the locking flag logic works. if m.locking { // Try moving — if piece can drop further, locking should cancel. sendKey(m, "left") // Whether locking cancels depends on the board state; just verify no crash. } } func TestSpawnCol(t *testing.T) { // All pieces should spawn roughly centered. for pt := range pieceType(numPieceTypes) { col := spawnCol(pt, 0) if col < 0 || col > boardCols-1 { t.Errorf("spawnCol(%d, 0) = %d, out of range", pt, col) } // Verify piece fits at spawn position. shape := pieces[pt][0] for _, off := range shape { c := col + off[1] if c < 0 || c >= boardCols { t.Errorf("piece %d overflows board at spawn: col+offset = %d", pt, c) } } } }