Update Go module path and all import references to reflect the migration from Gitea (git.t-juice.club) to Forgejo (code.t-juice.club). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
583 lines
14 KiB
Go
583 lines
14 KiB
Go
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)
|
|
}
|
|
}
|
|
}
|
|
}
|