mirror of
https://github.com/tiennm99/gomoku.git
synced 2026-09-02 18:19:36 +00:00
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:
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user