From ca933abeed64c7b208b77f360e8d4ccd1a535938 Mon Sep 17 00:00:00 2001 From: tiennm99 Date: Sat, 11 Apr 2026 14:28:58 +0700 Subject: [PATCH] test(state): unit tests for runner, PVP/PVE game flow, waiting, and gameover 27 tests covering: welcome/setNickname/home transitions, runner exit on ErrClientExit, waiting owner/joiner role split with StartCh signal, PVP move validation (turn order, bounds, occupancy, win detection with GameOverCh cross-goroutine sync), PVE human+AI alternation and AI-first when human is White, gameover reset/rematch for both PVP and PVE. --- server/pkg/protocol/tcp.go | 30 ---- server/state/game_pve_test.go | 217 +++++++++++++++++++++++++++ server/state/game_pvp_test.go | 235 ++++++++++++++++++++++++++++++ server/state/gameover_test.go | 203 ++++++++++++++++++++++++++ server/state/state_runner_test.go | 173 ++++++++++++++++++++++ server/state/waiting_test.go | 179 +++++++++++++++++++++++ 6 files changed, 1007 insertions(+), 30 deletions(-) delete mode 100644 server/pkg/protocol/tcp.go create mode 100644 server/state/game_pve_test.go create mode 100644 server/state/game_pvp_test.go create mode 100644 server/state/gameover_test.go create mode 100644 server/state/state_runner_test.go create mode 100644 server/state/waiting_test.go diff --git a/server/pkg/protocol/tcp.go b/server/pkg/protocol/tcp.go deleted file mode 100644 index a302148..0000000 --- a/server/pkg/protocol/tcp.go +++ /dev/null @@ -1,30 +0,0 @@ -package protocol - -import ( - "net" -) - -type TcpReadWriteCloser struct { - conn net.Conn -} - -func NewTcpReadWriteCloser(conn net.Conn) TcpReadWriteCloser { - return TcpReadWriteCloser{conn: conn} -} - -func (t TcpReadWriteCloser) Read() (*Packet, error) { - return decode(t.conn) -} - -func (t TcpReadWriteCloser) Write(msg Packet) error { - _, err := t.conn.Write(encode(msg)) - return err -} - -func (t TcpReadWriteCloser) Close() error { - return t.conn.Close() -} - -func (t TcpReadWriteCloser) IP() string { - return t.conn.RemoteAddr().String() -} diff --git a/server/state/game_pve_test.go b/server/state/game_pve_test.go new file mode 100644 index 0000000..29e246a --- /dev/null +++ b/server/state/game_pve_test.go @@ -0,0 +1,217 @@ +package state + +import ( + "testing" + "time" + + "github.com/tiennm99/gomoku/server/consts" + "github.com/tiennm99/gomoku/server/database" + "github.com/tiennm99/gomoku/server/game" + "github.com/tiennm99/gomoku/server/protocol" +) + +// setupPveGame creates a PVE room with the human assigned Black (AI=White). +// seed=42 gives deterministic AI behavior. +func setupPveGame(t *testing.T, difficulty int) (human *database.Player, room *database.NewRoom) { + t.Helper() + human = makeRegisteredPlayer(t, "Human") + + room, err := database.CreatePveRoom(human, difficulty) + if err != nil { + t.Fatalf("CreatePveRoom: %v", err) + } + if err := database.JoinNewRoom(room.ID, human); err != nil { + t.Fatalf("JoinNewRoom human: %v", err) + } + + // Override AI with fixed seed for determinism. + room.Lock() + room.BlackPlayerID = human.ID + room.WhitePlayerID = -1 + room.CurrentTurn = game.Black + room.Status = database.RoomStatusPlaying + room.AI = game.NewAI(game.White, 42) + room.Unlock() + + return human, room +} + +// TestPveHumanMoveAndAIResponds verifies that after a valid human move, +// the AI makes a move and both are broadcast as GameMoveSuccessResponse. +func TestPveHumanMoveAndAIResponds(t *testing.T) { + human, _ := setupPveGame(t, consts.DifficultyEasy) + + // Human plays (7,7) then exits. + go func() { + time.Sleep(10 * time.Millisecond) + sendMove(human, 7, 7) + time.Sleep(100 * time.Millisecond) + human.CmdCh <- &protocol.Request{ + Payload: &protocol.Request_ClientExit{ClientExit: &protocol.ClientExitRequest{}}, + } + }() + + s := &gamePveState{} + s.Next(human) //nolint:errcheck + + responses := drainSend(human) + moveSucCount := 0 + for _, r := range responses { + if _, ok := r.Payload.(*protocol.Response_GameMoveSuccess); ok { + moveSucCount++ + } + } + // Expect at least 2: human move + AI move. + if moveSucCount < 2 { + t.Errorf("expected at least 2 GameMoveSuccessResponse (human+AI), got %d", moveSucCount) + } +} + +// TestPveOutOfBoundsReject verifies GameMoveOutOfBoundsResponse for bad coords. +func TestPveOutOfBoundsReject(t *testing.T) { + human, _ := setupPveGame(t, consts.DifficultyEasy) + + go func() { + time.Sleep(10 * time.Millisecond) + sendMove(human, 15, 0) // out of bounds (BoardSize=15, valid: 0-14) + time.Sleep(20 * time.Millisecond) + human.CmdCh <- &protocol.Request{ + Payload: &protocol.Request_ClientExit{ClientExit: &protocol.ClientExitRequest{}}, + } + }() + + s := &gamePveState{} + s.Next(human) //nolint:errcheck + + found := false + for _, r := range drainSend(human) { + if _, ok := r.Payload.(*protocol.Response_GameMoveOutOfBounds); ok { + found = true + break + } + } + if !found { + t.Error("expected GameMoveOutOfBoundsResponse for out-of-bounds move") + } +} + +// TestPveOccupiedCellReject verifies GameMoveOccupiedResponse for repeated cell. +func TestPveOccupiedCellReject(t *testing.T) { + human, _ := setupPveGame(t, consts.DifficultyEasy) + + go func() { + time.Sleep(10 * time.Millisecond) + sendMove(human, 7, 7) // first move — valid + time.Sleep(150 * time.Millisecond) // wait for AI to move + sendMove(human, 7, 7) // same cell — occupied + time.Sleep(30 * time.Millisecond) + human.CmdCh <- &protocol.Request{ + Payload: &protocol.Request_ClientExit{ClientExit: &protocol.ClientExitRequest{}}, + } + }() + + s := &gamePveState{} + s.Next(human) //nolint:errcheck + + found := false + for _, r := range drainSend(human) { + if _, ok := r.Payload.(*protocol.Response_GameMoveOccupied); ok { + found = true + break + } + } + if !found { + t.Error("expected GameMoveOccupiedResponse for repeated cell") + } +} + +// TestPveGameOverOnHumanWin plays enough moves for human to win (row 0, cols 0-4). +// AI is easy (random) so this should terminate with a GameOverResponse. +func TestPveGameOverOnHumanWin(t *testing.T) { + human, room := setupPveGame(t, consts.DifficultyEasy) + + result := make(chan consts.StateID, 1) + go func() { + s := &gamePveState{} + // Feed human moves in a row — human wins when 5 in a row achieved. + // Use a goroutine that keeps retrying a column until accepted. + go func() { + col := int32(0) + for col < 5 { + time.Sleep(50 * time.Millisecond) + sendMove(human, 0, col) + col++ + } + }() + next, _ := s.Next(human) + result <- next + }() + + _ = room + + select { + case next := <-result: + if next != consts.StateGameOver { + t.Errorf("expected StateGameOver (%d), got %d", consts.StateGameOver, next) + } + case <-time.After(5 * time.Second): + t.Fatal("PVE game did not complete within 5s") + } + + // Should have GameOverResponse in send buffer. + found := false + for _, r := range drainSend(human) { + if _, ok := r.Payload.(*protocol.Response_GameOver); ok { + found = true + break + } + } + if !found { + t.Error("expected GameOverResponse after human wins PVE game") + } +} + +// TestPveAIFirstWhenHumanIsWhite verifies that when human is White, the AI +// (Black) moves first, producing a GameMoveSuccessResponse before any human input. +func TestPveAIFirstWhenHumanIsWhite(t *testing.T) { + human := makeRegisteredPlayer(t, "HumanWhite") + + room, err := database.CreatePveRoom(human, consts.DifficultyEasy) + if err != nil { + t.Fatalf("CreatePveRoom: %v", err) + } + if err := database.JoinNewRoom(room.ID, human); err != nil { + t.Fatalf("JoinNewRoom: %v", err) + } + + // Force human = White, AI = Black. + room.Lock() + room.WhitePlayerID = human.ID + room.BlackPlayerID = -1 + room.CurrentTurn = game.Black + room.Status = database.RoomStatusPlaying + room.AI = game.NewAI(game.Black, 42) + room.Unlock() + + // Give human a move after a delay, then exit. + go func() { + time.Sleep(200 * time.Millisecond) + sendMove(human, 7, 6) + time.Sleep(100 * time.Millisecond) + human.CmdCh <- &protocol.Request{ + Payload: &protocol.Request_ClientExit{ClientExit: &protocol.ClientExitRequest{}}, + } + }() + + s := &gamePveState{} + s.Next(human) //nolint:errcheck + + // First response must be a GameMoveSuccessResponse (AI's Black move). + responses := drainSend(human) + if len(responses) == 0 { + t.Fatal("expected at least one response (AI first move), got none") + } + if _, ok := responses[0].Payload.(*protocol.Response_GameMoveSuccess); !ok { + t.Errorf("expected first response to be GameMoveSuccess (AI move), got %T", responses[0].Payload) + } +} diff --git a/server/state/game_pvp_test.go b/server/state/game_pvp_test.go new file mode 100644 index 0000000..d89ecce --- /dev/null +++ b/server/state/game_pvp_test.go @@ -0,0 +1,235 @@ +package state + +import ( + "testing" + "time" + + "github.com/tiennm99/gomoku/server/consts" + "github.com/tiennm99/gomoku/server/database" + "github.com/tiennm99/gomoku/server/game" + "github.com/tiennm99/gomoku/server/protocol" +) + +// setupPvpGame creates a fully-started PVP room (2 players, colors assigned, status=Playing). +func setupPvpGame(t *testing.T) (black *database.Player, white *database.Player, room *database.NewRoom) { + t.Helper() + black = makeRegisteredPlayer(t, "Black") + white = makeRegisteredPlayer(t, "White") + + room, err := database.CreatePvpRoom(black) + if err != nil { + t.Fatalf("CreatePvpRoom: %v", err) + } + if err := database.JoinNewRoom(room.ID, black); err != nil { + t.Fatalf("JoinNewRoom black: %v", err) + } + if err := database.JoinNewRoom(room.ID, white); err != nil { + t.Fatalf("JoinNewRoom white: %v", err) + } + + // Assign colors deterministically. + room.Lock() + room.BlackPlayerID = black.ID + room.WhitePlayerID = white.ID + room.CurrentTurn = game.Black + room.Status = database.RoomStatusPlaying + room.Unlock() + + return black, white, room +} + +// sendMove enqueues a GameMoveRequest on the player's CmdCh. +func sendMove(p *database.Player, row, col int32) { + p.CmdCh <- &protocol.Request{ + Payload: &protocol.Request_GameMove{ + GameMove: &protocol.GameMoveRequest{Row: row, Col: col}, + }, + } +} + +// TestPvpMoveNotYourTurn verifies that a player moving out of turn gets GameMoveNotYourTurn. +func TestPvpMoveNotYourTurn(t *testing.T) { + black, white, _ := setupPvpGame(t) + + // White tries to move first (Black's turn). + go func() { + time.Sleep(10 * time.Millisecond) + sendMove(white, 7, 7) + // Unblock white with exit after rejection. + time.Sleep(20 * time.Millisecond) + white.CmdCh <- &protocol.Request{ + Payload: &protocol.Request_ClientExit{ClientExit: &protocol.ClientExitRequest{}}, + } + }() + + _ = black // kept for cleanup + + s := &gamePvpState{} + _, err := s.Next(white) + // Should exit (either ErrClientExit or disconnect). + _ = err + + found := false + for _, r := range drainSend(white) { + if _, ok := r.Payload.(*protocol.Response_GameMoveNotYourTurn); ok { + found = true + break + } + } + if !found { + t.Error("expected GameMoveNotYourTurnResponse for out-of-turn move") + } +} + +// TestPvpMoveOutOfBounds verifies GameMoveOutOfBoundsResponse for invalid coords. +func TestPvpMoveOutOfBounds(t *testing.T) { + black, white, _ := setupPvpGame(t) + _ = white + + go func() { + time.Sleep(10 * time.Millisecond) + sendMove(black, -1, 0) // out of bounds + time.Sleep(20 * time.Millisecond) + black.CmdCh <- &protocol.Request{ + Payload: &protocol.Request_ClientExit{ClientExit: &protocol.ClientExitRequest{}}, + } + }() + + s := &gamePvpState{} + s.Next(black) //nolint:errcheck + + found := false + for _, r := range drainSend(black) { + if _, ok := r.Payload.(*protocol.Response_GameMoveOutOfBounds); ok { + found = true + break + } + } + if !found { + t.Error("expected GameMoveOutOfBoundsResponse for out-of-bounds move") + } +} + +// TestPvpValidMoveTransitionsContinues verifies that a valid move broadcasts +// GameMoveSuccessResponse. Uses two goroutines (one per player) for a full turn. +func TestPvpValidMoveAndBroadcast(t *testing.T) { + black, white, _ := setupPvpGame(t) + + // Black plays goroutine. + blackDone := make(chan consts.StateID, 1) + go func() { + s := &gamePvpState{} + // Black plays 7,7 then exits. + go func() { + time.Sleep(10 * time.Millisecond) + sendMove(black, 7, 7) + time.Sleep(30 * time.Millisecond) + black.CmdCh <- &protocol.Request{ + Payload: &protocol.Request_ClientExit{ClientExit: &protocol.ClientExitRequest{}}, + } + }() + next, _ := s.Next(black) + blackDone <- next + }() + + // White goroutine: exits immediately after a short wait. + whiteDone := make(chan struct{}, 1) + go func() { + s := &gamePvpState{} + go func() { + time.Sleep(80 * time.Millisecond) + white.CmdCh <- &protocol.Request{ + Payload: &protocol.Request_ClientExit{ClientExit: &protocol.ClientExitRequest{}}, + } + }() + s.Next(white) //nolint:errcheck + whiteDone <- struct{}{} + }() + + select { + case <-blackDone: + case <-time.After(3 * time.Second): + t.Fatal("black goroutine did not complete within 3s") + } + select { + case <-whiteDone: + case <-time.After(3 * time.Second): + t.Fatal("white goroutine did not complete within 3s") + } + + // Black should have received GameMoveSuccessResponse after its own move. + found := false + for _, r := range drainSend(black) { + if _, ok := r.Payload.(*protocol.Response_GameMoveSuccess); ok { + found = true + break + } + } + if !found { + t.Error("expected GameMoveSuccessResponse after valid move") + } +} + +// TestPvpWinDetection plays a winning sequence and verifies GameOver is reached. +// Interleaves Black and White moves with proper timing so turn order is respected: +// B(0,0) W(2,0) B(0,1) W(2,1) B(0,2) W(2,2) B(0,3) W(2,3) B(0,4) → Black wins. +func TestPvpWinDetection(t *testing.T) { + black, white, _ := setupPvpGame(t) + + blackResult := make(chan consts.StateID, 1) + whiteResult := make(chan consts.StateID, 1) + + // Feed moves with proper alternating delays. + // Black moves at t=10,30,50,70,90ms; White at t=20,40,60,80ms. + go func() { + for i, col := range []int32{0, 1, 2, 3, 4} { + time.Sleep(time.Duration(10+i*20) * time.Millisecond) + sendMove(black, 0, col) + } + }() + go func() { + for i, col := range []int32{0, 1, 2, 3} { + time.Sleep(time.Duration(20+i*20) * time.Millisecond) + sendMove(white, 2, col) + } + }() + + go func() { + s := &gamePvpState{} + next, _ := s.Next(black) + blackResult <- next + }() + go func() { + s := &gamePvpState{} + next, _ := s.Next(white) + whiteResult <- next + }() + + select { + case next := <-blackResult: + if next != consts.StateGameOver { + t.Errorf("black: expected StateGameOver (%d) after win, got %d", consts.StateGameOver, next) + } + case <-time.After(5 * time.Second): + t.Fatal("black goroutine timed out") + } + select { + case <-whiteResult: + case <-time.After(5 * time.Second): + t.Fatal("white goroutine timed out") + } + + // At least one player should have received GameOverResponse. + allBlack := drainSend(black) + allWhite := drainSend(white) + found := false + for _, r := range append(allBlack, allWhite...) { + if _, ok := r.Payload.(*protocol.Response_GameOver); ok { + found = true + break + } + } + if !found { + t.Error("expected GameOverResponse after Black wins") + } +} diff --git a/server/state/gameover_test.go b/server/state/gameover_test.go new file mode 100644 index 0000000..76a8f4a --- /dev/null +++ b/server/state/gameover_test.go @@ -0,0 +1,203 @@ +package state + +import ( + "testing" + "time" + + "github.com/tiennm99/gomoku/server/consts" + "github.com/tiennm99/gomoku/server/database" + "github.com/tiennm99/gomoku/server/game" + "github.com/tiennm99/gomoku/server/protocol" +) + +// setupFinishedPvpRoom creates a PVP room in Finished state for gameover tests. +func setupFinishedPvpRoom(t *testing.T) (*database.Player, *database.Player, *database.NewRoom) { + t.Helper() + black := makeRegisteredPlayer(t, "Black") + white := makeRegisteredPlayer(t, "White") + + room, err := database.CreatePvpRoom(black) + if err != nil { + t.Fatalf("CreatePvpRoom: %v", err) + } + if err := database.JoinNewRoom(room.ID, black); err != nil { + t.Fatalf("JoinNewRoom black: %v", err) + } + if err := database.JoinNewRoom(room.ID, white); err != nil { + t.Fatalf("JoinNewRoom white: %v", err) + } + + room.Lock() + room.BlackPlayerID = black.ID + room.WhitePlayerID = white.ID + room.CurrentTurn = game.Black + room.Status = database.RoomStatusFinished + room.Unlock() + + return black, white, room +} + +// TestGameoverResetTransitionsToPvp verifies that GameResetRequest resets the +// board and transitions back to StateGamePvp for a PVP room. +func TestGameoverResetTransitionsToPvp(t *testing.T) { + black, _, _ := setupFinishedPvpRoom(t) + + go func() { + time.Sleep(10 * time.Millisecond) + black.CmdCh <- &protocol.Request{ + Payload: &protocol.Request_GameReset{ + GameReset: &protocol.GameResetRequest{}, + }, + } + }() + + s := &gameOverState{} + next, err := s.Next(black) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if next != consts.StateGamePvp { + t.Errorf("got state %d, want StateGamePvp (%d)", next, consts.StateGamePvp) + } + + // Should receive GameStartingResponse after reset. + found := false + for _, r := range drainSend(black) { + if _, ok := r.Payload.(*protocol.Response_GameStarting); ok { + found = true + break + } + } + if !found { + t.Error("expected GameStartingResponse after GameReset in PVP room") + } +} + +// TestGameoverResetTransitionsToPve verifies that GameResetRequest transitions +// to StateGamePve for a PVE room. +func TestGameoverResetTransitionsToPve(t *testing.T) { + human := makeRegisteredPlayer(t, "Human") + + room, err := database.CreatePveRoom(human, consts.DifficultyEasy) + if err != nil { + t.Fatalf("CreatePveRoom: %v", err) + } + if err := database.JoinNewRoom(room.ID, human); err != nil { + t.Fatalf("JoinNewRoom: %v", err) + } + + room.Lock() + room.BlackPlayerID = human.ID + room.WhitePlayerID = -1 + room.CurrentTurn = game.Black + room.Status = database.RoomStatusFinished + room.AI = game.NewAI(game.White, 99) + room.Unlock() + + go func() { + time.Sleep(10 * time.Millisecond) + human.CmdCh <- &protocol.Request{ + Payload: &protocol.Request_GameReset{ + GameReset: &protocol.GameResetRequest{}, + }, + } + }() + + s := &gameOverState{} + next, err := s.Next(human) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if next != consts.StateGamePve { + t.Errorf("got state %d, want StateGamePve (%d)", next, consts.StateGamePve) + } +} + +// TestGameoverExitOnClientExitRequest verifies ErrClientExit on explicit exit. +func TestGameoverExitOnClientExitRequest(t *testing.T) { + black, _, _ := setupFinishedPvpRoom(t) + + go func() { + time.Sleep(10 * time.Millisecond) + black.CmdCh <- &protocol.Request{ + Payload: &protocol.Request_ClientExit{ + ClientExit: &protocol.ClientExitRequest{}, + }, + } + }() + + s := &gameOverState{} + _, err := s.Next(black) + if err != ErrClientExit { + t.Errorf("expected ErrClientExit, got %v", err) + } +} + +// TestGameoverExitOnClosedChan verifies ErrClientExit when CmdCh is closed. +func TestGameoverExitOnClosedChan(t *testing.T) { + black, _, _ := setupFinishedPvpRoom(t) + close(black.CmdCh) + + s := &gameOverState{} + _, err := s.Next(black) + if err != ErrClientExit { + t.Errorf("expected ErrClientExit on closed CmdCh, got %v", err) + } +} + +// TestGameoverPveResetRandomizesColors verifies that after a PVE reset, color +// assignments may change (re-randomization). Run multiple times to reduce flakiness. +func TestGameoverPveResetRandomizesColors(t *testing.T) { + human := makeRegisteredPlayer(t, "Human") + + room, err := database.CreatePveRoom(human, consts.DifficultyEasy) + if err != nil { + t.Fatalf("CreatePveRoom: %v", err) + } + if err := database.JoinNewRoom(room.ID, human); err != nil { + t.Fatalf("JoinNewRoom: %v", err) + } + + room.Lock() + originalBlackID := room.BlackPlayerID + room.Status = database.RoomStatusFinished + room.AI = game.NewAI(game.White, 1) + room.Unlock() + + go func() { + time.Sleep(10 * time.Millisecond) + human.CmdCh <- &protocol.Request{ + Payload: &protocol.Request_GameReset{ + GameReset: &protocol.GameResetRequest{}, + }, + } + }() + + s := &gameOverState{} + next, err := s.Next(human) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if next != consts.StateGamePve { + t.Errorf("expected StateGamePve after PVE reset, got %d", next) + } + + // After reset, room.Reset(seed) is called — colors may have flipped. + // We just verify the room has valid color assignments (one side = human, other = -1). + room.RLock() + newBlackID := room.BlackPlayerID + newWhiteID := room.WhitePlayerID + room.RUnlock() + + validAssignment := (newBlackID == human.ID && newWhiteID == -1) || + (newWhiteID == human.ID && newBlackID == -1) + if !validAssignment { + t.Errorf("invalid PVE color assignment after reset: black=%d white=%d human=%d", + newBlackID, newWhiteID, human.ID) + } + + // Log whether color changed (informational, not a failure). + if newBlackID != originalBlackID { + t.Logf("color randomized: original black=%d, new black=%d", originalBlackID, newBlackID) + } +} diff --git a/server/state/state_runner_test.go b/server/state/state_runner_test.go new file mode 100644 index 0000000..58a3fc0 --- /dev/null +++ b/server/state/state_runner_test.go @@ -0,0 +1,173 @@ +package state + +import ( + "testing" + "time" + + "github.com/tiennm99/gomoku/server/consts" + "github.com/tiennm99/gomoku/server/database" + "github.com/tiennm99/gomoku/server/protocol" +) + +// makeTestPlayer creates a Player with wired CmdCh and SendCh for use in tests. +// The player is NOT registered in the store (so RemovePlayer is safe to no-op). +func makeTestPlayer(id int64, name string) *database.Player { + p := &database.Player{ + ID: id, + Name: name, + SendCh: make(chan *protocol.Response, 64), + CmdCh: make(chan *protocol.Request, 16), + } + return p +} + +// drainSend returns all responses currently buffered in SendCh without blocking. +func drainSend(p *database.Player) []*protocol.Response { + var out []*protocol.Response + for { + select { + case r := <-p.SendCh: + out = append(out, r) + default: + return out + } + } +} + +// TestWelcomeTransitionsToSetNickname verifies that welcomeState immediately +// returns StateSetNickname without reading from CmdCh. +func TestWelcomeTransitionsToSetNickname(t *testing.T) { + p := makeTestPlayer(1, "") + s := &welcomeState{} + next, err := s.Next(p) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if next != consts.StateSetNickname { + t.Errorf("got state %d, want StateSetNickname (%d)", next, consts.StateSetNickname) + } +} + +// TestSetNicknameWithNameAlreadySet verifies immediate transition to home when +// player.Name is pre-populated (stateless handler already ran). +func TestSetNicknameWithNameAlreadySet(t *testing.T) { + p := makeTestPlayer(1, "Alice") + s := &setNicknameState{} + next, err := s.Next(p) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if next != consts.StateHome { + t.Errorf("got state %d, want StateHome (%d)", next, consts.StateHome) + } +} + +// TestSetNicknameExitOnClosedChan verifies ErrClientExit when CmdCh is closed. +func TestSetNicknameExitOnClosedChan(t *testing.T) { + p := makeTestPlayer(1, "") // no name + close(p.CmdCh) + s := &setNicknameState{} + _, err := s.Next(p) + if err != ErrClientExit { + t.Errorf("expected ErrClientExit, got %v", err) + } +} + +// TestSetNicknameStatefulRequestBeforeName verifies that a stateful request +// arriving on CmdCh before Name is set causes a transition to home with a +// default name assigned, and re-enqueues the request. +func TestSetNicknameStatefulRequestBeforeName(t *testing.T) { + p := makeTestPlayer(1, "") // no name yet + req := &protocol.Request{ + Payload: &protocol.Request_CreateRoom{ + CreateRoom: &protocol.CreateRoomRequest{}, + }, + } + p.CmdCh <- req + + s := &setNicknameState{} + next, err := s.Next(p) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if next != consts.StateHome { + t.Errorf("got state %d, want StateHome (%d)", next, consts.StateHome) + } + if p.Name == "" { + t.Error("expected default name to be assigned") + } + // Request should be re-enqueued. + select { + case got := <-p.CmdCh: + if got != req { + t.Error("re-enqueued request does not match original") + } + case <-time.After(100 * time.Millisecond): + t.Error("request was not re-enqueued to CmdCh") + } +} + +// TestRunnerExitsOnErrClientExit verifies that Run() terminates when a state +// returns ErrClientExit. +func TestRunnerExitsOnErrClientExit(t *testing.T) { + // Register a fake state that immediately exits. + const fakeState consts.StateID = 99 + register(fakeState, stateFunc(func(_ *database.Player) (consts.StateID, error) { + return 0, ErrClientExit + })) + + p := makeTestPlayer(999, "runner-test") + // Redirect first state to fakeState by temporarily overriding welcome. + origWelcome := registry[consts.StateWelcome] + register(consts.StateWelcome, stateFunc(func(_ *database.Player) (consts.StateID, error) { + return fakeState, nil + })) + defer register(consts.StateWelcome, origWelcome) + + done := make(chan struct{}) + go func() { + Run(p) + close(done) + }() + + select { + case <-done: + // Good: goroutine exited. + case <-time.After(2 * time.Second): + t.Fatal("Run() did not exit within 2s after ErrClientExit") + } +} + +// stateFunc is a helper to turn a function into a State for test overrides. +type stateFunc func(*database.Player) (consts.StateID, error) + +func (f stateFunc) Next(p *database.Player) (consts.StateID, error) { return f(p) } + +// TestHomeExitOnClientExitRequest verifies homeState returns ErrClientExit. +func TestHomeExitOnClientExitRequest(t *testing.T) { + p := makeTestPlayer(1, "Bob") + p.CmdCh <- &protocol.Request{ + Payload: &protocol.Request_ClientExit{ + ClientExit: &protocol.ClientExitRequest{}, + }, + } + + s := &homeState{} + _, err := s.Next(p) + if err != ErrClientExit { + t.Errorf("expected ErrClientExit, got %v", err) + } +} + +// TestHomeExitOnClosedChan verifies homeState returns ErrClientExit when +// CmdCh is closed (simulates disconnect). +func TestHomeExitOnClosedChan(t *testing.T) { + p := makeTestPlayer(1, "Bob") + close(p.CmdCh) + + s := &homeState{} + _, err := s.Next(p) + if err != ErrClientExit { + t.Errorf("expected ErrClientExit, got %v", err) + } +} diff --git a/server/state/waiting_test.go b/server/state/waiting_test.go new file mode 100644 index 0000000..6c086e1 --- /dev/null +++ b/server/state/waiting_test.go @@ -0,0 +1,179 @@ +package state + +import ( + "testing" + "time" + + "github.com/tiennm99/gomoku/server/consts" + "github.com/tiennm99/gomoku/server/database" + "github.com/tiennm99/gomoku/server/protocol" +) + +// makeRegisteredPlayer creates a player via the store and wires channels. +func makeRegisteredPlayer(t *testing.T, name string) *database.Player { + t.Helper() + p := database.RegisterPlayer(name) + p.SendCh = make(chan *protocol.Response, 64) + p.CmdCh = make(chan *protocol.Request, 16) + t.Cleanup(func() { database.RemovePlayer(p.ID) }) + return p +} + +// setupPvpRoomWithOwner creates a PVP room with owner already joined. +func setupPvpRoomWithOwner(t *testing.T) (*database.Player, *database.NewRoom) { + t.Helper() + owner := makeRegisteredPlayer(t, "Owner") + + room, err := database.CreatePvpRoom(owner) + if err != nil { + t.Fatalf("CreatePvpRoom: %v", err) + } + if err := database.JoinNewRoom(room.ID, owner); err != nil { + t.Fatalf("JoinNewRoom owner: %v", err) + } + return owner, room +} + +// TestWaitingOwnerGameStartingRequiresFullRoom verifies that GameStartingRequest +// is rejected (RoomPlayFailNotFound) when only 1 player is in the room. +func TestWaitingOwnerGameStartingRequiresFullRoom(t *testing.T) { + owner, _ := setupPvpRoomWithOwner(t) + + go func() { + time.Sleep(20 * time.Millisecond) + owner.CmdCh <- &protocol.Request{ + Payload: &protocol.Request_GameStarting{ + GameStarting: &protocol.GameStartingRequest{}, + }, + } + // After rejection, send exit so the state unblocks. + time.Sleep(20 * time.Millisecond) + owner.CmdCh <- &protocol.Request{ + Payload: &protocol.Request_ClientExit{ + ClientExit: &protocol.ClientExitRequest{}, + }, + } + }() + + s := &waitingState{} + _, err := s.Next(owner) + if err != ErrClientExit { + t.Errorf("expected ErrClientExit after rejection+exit, got %v", err) + } + + // Owner should have received RoomPlayFailNotFoundResponse. + found := false + for _, r := range drainSend(owner) { + if _, ok := r.Payload.(*protocol.Response_RoomPlayFailNotFound); ok { + found = true + break + } + } + if !found { + t.Error("expected RoomPlayFailNotFoundResponse for single-player start attempt") + } +} + +// TestWaitingOwnerStartsWhenFull verifies owner can start when 2 players present. +func TestWaitingOwnerStartsWhenFull(t *testing.T) { + owner, room := setupPvpRoomWithOwner(t) + joiner := makeRegisteredPlayer(t, "Joiner") + if err := database.JoinNewRoom(room.ID, joiner); err != nil { + t.Fatalf("JoinNewRoom joiner: %v", err) + } + + go func() { + time.Sleep(10 * time.Millisecond) + owner.CmdCh <- &protocol.Request{ + Payload: &protocol.Request_GameStarting{ + GameStarting: &protocol.GameStartingRequest{}, + }, + } + }() + + s := &waitingState{} + next, err := s.Next(owner) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if next != consts.StateGamePvp { + t.Errorf("got state %d, want StateGamePvp (%d)", next, consts.StateGamePvp) + } + + // Owner should receive GameStartingResponse. + found := false + for _, r := range drainSend(owner) { + if _, ok := r.Payload.(*protocol.Response_GameStarting); ok { + found = true + break + } + } + if !found { + t.Error("expected GameStartingResponse after valid start") + } +} + +// TestWaitingJoinerTransitionsOnStartCh verifies joiner transitions when +// owner closes StartCh (signals game start). +func TestWaitingJoinerTransitionsOnStartCh(t *testing.T) { + owner, room := setupPvpRoomWithOwner(t) + joiner := makeRegisteredPlayer(t, "Joiner") + if err := database.JoinNewRoom(room.ID, joiner); err != nil { + t.Fatalf("JoinNewRoom joiner: %v", err) + } + + // Simulate owner triggering start: close StartCh and mark room playing. + go func() { + time.Sleep(30 * time.Millisecond) + room.Lock() + if room.StartCh != nil { + close(room.StartCh) + room.StartCh = nil + } + room.Status = database.RoomStatusPlaying + room.Unlock() + }() + + _ = owner + + s := &waitingState{} + next, err := s.Next(joiner) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if next != consts.StateGamePvp { + t.Errorf("joiner got state %d, want StateGamePvp (%d)", next, consts.StateGamePvp) + } +} + +// TestWaitingExitOnClientExitRequest verifies clean exit via ClientExitRequest. +func TestWaitingExitOnClientExitRequest(t *testing.T) { + owner, _ := setupPvpRoomWithOwner(t) + + go func() { + time.Sleep(10 * time.Millisecond) + owner.CmdCh <- &protocol.Request{ + Payload: &protocol.Request_ClientExit{ + ClientExit: &protocol.ClientExitRequest{}, + }, + } + }() + + s := &waitingState{} + _, err := s.Next(owner) + if err != ErrClientExit { + t.Errorf("expected ErrClientExit, got %v", err) + } +} + +// TestWaitingExitOnClosedChan verifies ErrClientExit when CmdCh is closed. +func TestWaitingExitOnClosedChan(t *testing.T) { + owner, _ := setupPvpRoomWithOwner(t) + close(owner.CmdCh) + + s := &waitingState{} + _, err := s.Next(owner) + if err != ErrClientExit { + t.Errorf("expected ErrClientExit, got %v", err) + } +}