Files
gomoku/server/state/state_runner_test.go
T
tiennm99 03758b0e90 fix(exit): route ClientExit through state machine, keep WS alive
Bug: after clicking "Leave" on the game-over modal, the client landed
on the nickname entry screen and got stuck — typing a nickname produced
no response for up to 90 seconds.

Root cause chain:
1. Server handleClientExit (stateless) closed SendCh and nilled it but
   did not close the WS connection. Writer goroutine drained + exited
   without closing the conn; reader stayed blocked on ReadMessage; state
   machine stayed blocked on CmdCh. Zombie session.
2. Any subsequent player.Send() returned nil (SendCh was nil), silently
   dropping responses. The setNickname reply never reached the client.
3. Client only unstuck when reader's 90 s readDeadline fired, finally
   closing the WS and triggering the auto-reconnect path.
4. Client MenuScene.create() always called showNicknameScreen() without
   checking gameState.nickname, so even after the reconnect the user
   had to re-type their name.

Fix: "exit" now means "leave current room, return to home lobby" —
not "terminate WS session".

Server:
- dispatch.go: move Request_ClientExit from stateless to stateful path.
- handlers_stateless.go: delete handleClientExit entirely.
- waiting.go leaveRoom: broadcast ClientExitResponse via broadcastResponse
  BEFORE removing the player, so the exiting player also receives their
  own exit confirmation. Removes duplicated self-notification logic.
- waiting/gamePvp/gamePve/gameover/watching: on ClientExitRequest, call
  leaveRoom + return consts.StateHome (was return 0, ErrClientExit).
- home.go: on ClientExitRequest, send bare ClientExitResponse and stay
  in home (no room to leave).
- Update 4 state tests that asserted ErrClientExit → now assert StateHome.
- Rename TestWatching_ClientExitReturnsErrClientExit → ReturnsHome.

Client:
- menu-scene.js create(): if gameState.nickname is set, skip nickname
  screen and call showLobby() directly. Otherwise showNicknameScreen().
- game-scene.js _onClientExit: drop the explicit showLobby() call; let
  MenuScene.create() decide the panel based on nickname state. Remove
  now-unused menu-ui showLobby import.

Result: click Leave → server sends ClientExitResponse, state machine
transitions to StateHome, WS stays open, client transitions to
MenuScene → sees gameState.nickname is set → goes straight to lobby.
No reconnect, no re-typing, no 90-second stall.

go vet + go test ./... green. npm run build green.
2026-04-11 16:51:13 +07:00

178 lines
4.9 KiB
Go

package state
import (
"testing"
"time"
"github.com/tiennm99/gomoku/server/consts"
"github.com/tiennm99/gomoku/server/lobby"
"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) *lobby.Player {
p := &lobby.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 *lobby.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(_ *lobby.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(_ *lobby.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(*lobby.Player) (consts.StateID, error)
func (f stateFunc) Next(p *lobby.Player) (consts.StateID, error) { return f(p) }
// TestHomeExitOnClientExitRequest verifies homeState acknowledges ClientExit
// but stays in home (since there is no room to leave).
func TestHomeExitOnClientExitRequest(t *testing.T) {
p := makeTestPlayer(1, "Bob")
p.CmdCh <- &protocol.Request{
Payload: &protocol.Request_ClientExit{
ClientExit: &protocol.ClientExitRequest{},
},
}
s := &homeState{}
next, err := s.Next(p)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if next != consts.StateHome {
t.Errorf("expected StateHome, got %d", next)
}
}
// 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)
}
}