feat: add tetris shell (Tetris game TUI)
Full-screen Tetris game using Bubbletea with title screen, ghost piece, lock delay, NES-style scoring, configurable difficulty (easy/normal/hard), and honeypot event logging. Bumps version to 0.17.0. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
582
internal/shell/tetris/tetris_test.go
Normal file
582
internal/shell/tetris/tetris_test.go
Normal file
@@ -0,0 +1,582 @@
|
||||
package tetris
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
|
||||
"git.t-juice.club/torjus/oubliette/internal/shell"
|
||||
"git.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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user