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.
This commit is contained in:
2026-04-11 14:28:58 +07:00
parent 530b96d161
commit ca933abeed
6 changed files with 1007 additions and 30 deletions
-30
View File
@@ -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()
}
+217
View File
@@ -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)
}
}
+235
View File
@@ -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")
}
}
+203
View File
@@ -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)
}
}
+173
View File
@@ -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)
}
}
+179
View File
@@ -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)
}
}