mirror of
https://github.com/tiennm99/noitu.git
synced 2026-10-11 03:13:45 +00:00
test(wsapi): cover ghost seats, drain refusals and a silent resume
Direct room-level tests for the torn-down-connection race on create, join, quick match's auto-start, start-bot and resume, plus the non-resumable-token answer and freezeBoard's new signature after UsedWords replaced its history reconstruction.
This commit is contained in:
1 parent
8b3e8f7e67
commit
b1b6e4ba92
1 file changed
+155
-1
@@ -440,6 +440,122 @@ func TestABotGameOpensWithTheHuman(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestCreateOpensGraceWindowForATornDownConnection covers the race between a
|
||||||
|
// connection dying and the room draining the message that seats it: nothing
|
||||||
|
// else would ever tell this room the creator is gone, since leaveRoom only
|
||||||
|
// notifies a room the session had already attached to.
|
||||||
|
func TestCreateOpensGraceWindowForATornDownConnection(t *testing.T) {
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
cancel()
|
||||||
|
dead := &session{id: "dead", ctx: ctx, out: make(chan []byte, 1)}
|
||||||
|
|
||||||
|
r := &room{hub: &hub{}, dict: chainDict(), turnLimit: time.Second, graceFor: time.Minute}
|
||||||
|
r.handleCreate(createInput{sess: dead})
|
||||||
|
|
||||||
|
s := r.seats[0]
|
||||||
|
if s == nil {
|
||||||
|
t.Fatal("handleCreate did not seat the creator")
|
||||||
|
}
|
||||||
|
if s.sess != nil {
|
||||||
|
t.Error("a torn-down connection was left looking connected")
|
||||||
|
}
|
||||||
|
if s.graceUntil.IsZero() {
|
||||||
|
t.Error("no grace window was opened for the torn-down connection")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestJoinOpensGraceWindowForATornDownConnection is the same race on the
|
||||||
|
// other seating path: without this, allConnected() would report true for a
|
||||||
|
// seat nobody is behind.
|
||||||
|
func TestJoinOpensGraceWindowForATornDownConnection(t *testing.T) {
|
||||||
|
live := &session{id: "live", ctx: context.Background(), out: make(chan []byte, 8)}
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
cancel()
|
||||||
|
dead := &session{id: "dead", ctx: ctx, out: make(chan []byte, 1)}
|
||||||
|
|
||||||
|
r := &room{hub: &hub{}, dict: chainDict(), turnLimit: time.Second, graceFor: time.Minute}
|
||||||
|
r.handleCreate(createInput{sess: live})
|
||||||
|
r.handleJoin(joinInput{sess: dead})
|
||||||
|
|
||||||
|
s := r.seats[1]
|
||||||
|
if s == nil {
|
||||||
|
t.Fatal("handleJoin did not seat the second player")
|
||||||
|
}
|
||||||
|
if s.sess != nil {
|
||||||
|
t.Error("a torn-down connection was left looking connected")
|
||||||
|
}
|
||||||
|
if r.allConnected() {
|
||||||
|
t.Error("allConnected must not report true with a ghost seat behind it")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestQuickMatchAutoStartSkipsAGhostSeat is the consequence C1 warns about: a
|
||||||
|
// real player auto-started into a game against a dead socket burns the whole
|
||||||
|
// turn clock before anyone notices. allConnected() reporting the ghost seat
|
||||||
|
// honestly is what keeps this from ever reaching beginGame.
|
||||||
|
func TestQuickMatchAutoStartSkipsAGhostSeat(t *testing.T) {
|
||||||
|
live := &session{id: "live", ctx: context.Background(), out: make(chan []byte, 8)}
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
cancel()
|
||||||
|
dead := &session{id: "dead", ctx: ctx, out: make(chan []byte, 1)}
|
||||||
|
|
||||||
|
r := &room{hub: &hub{}, dict: chainDict(), turnLimit: time.Second, graceFor: time.Minute}
|
||||||
|
r.handleCreate(createInput{sess: live, autoStart: true})
|
||||||
|
r.handleJoin(joinInput{sess: dead})
|
||||||
|
|
||||||
|
if r.engine != nil {
|
||||||
|
t.Error("a game was auto-started against a connection that had already torn down")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestStartBotCancelsTheRoomForATornDownConnection: a bot room has no lobby to
|
||||||
|
// wait in and no idle timer while it has no engine yet, so a ghost seat here
|
||||||
|
// must end the room outright rather than being left to a grace window that
|
||||||
|
// nothing would ever clear.
|
||||||
|
func TestStartBotCancelsTheRoomForATornDownConnection(t *testing.T) {
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
cancel()
|
||||||
|
dead := &session{id: "dead", ctx: ctx, out: make(chan []byte, 1)}
|
||||||
|
|
||||||
|
hctx, hcancel := context.WithCancel(context.Background())
|
||||||
|
defer hcancel()
|
||||||
|
r := newRoom(&hub{ctx: hctx}, "AAAAAA", time.Second, time.Second, time.Minute, roomModeBot)
|
||||||
|
r.dict = chainDict()
|
||||||
|
r.handleStartBot(startBotInput{sess: dead, difficulty: bot.Easy})
|
||||||
|
|
||||||
|
if r.ctx.Err() == nil {
|
||||||
|
t.Error("a room seated only by a torn-down connection must be cancelled")
|
||||||
|
}
|
||||||
|
if r.engine != nil {
|
||||||
|
t.Error("a bot game was started against a connection that had already torn down")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestResumeOpensGraceWindowForATornDownConnection covers the same race on
|
||||||
|
// the resume path: the new connection presenting the token can die before the
|
||||||
|
// room drains the resumeInput it produced.
|
||||||
|
func TestResumeOpensGraceWindowForATornDownConnection(t *testing.T) {
|
||||||
|
live := &session{id: "live", ctx: context.Background(), out: make(chan []byte, 8)}
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
cancel()
|
||||||
|
dead := &session{id: "dead", ctx: ctx, out: make(chan []byte, 1)}
|
||||||
|
|
||||||
|
r := &room{hub: &hub{}, dict: chainDict(), turnLimit: time.Second, graceFor: time.Minute}
|
||||||
|
r.handleCreate(createInput{sess: live})
|
||||||
|
r.seats[0].sess = nil
|
||||||
|
r.seats[0].graceUntil = time.Now().Add(time.Minute)
|
||||||
|
|
||||||
|
r.handleResume(resumeInput{player: "p1", sess: dead})
|
||||||
|
|
||||||
|
s := r.seats[0]
|
||||||
|
if s.sess != nil {
|
||||||
|
t.Error("a torn-down resuming connection was left looking connected")
|
||||||
|
}
|
||||||
|
if s.graceUntil.IsZero() {
|
||||||
|
t.Error("no grace window was reopened for the torn-down resuming connection")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TestPvPGameAlternatesTurns runs two clients through a full game and checks
|
// TestPvPGameAlternatesTurns runs two clients through a full game and checks
|
||||||
// that each sees the other's move rendered from its own side.
|
// that each sees the other's move rendered from its own side.
|
||||||
func TestPvPGameAlternatesTurns(t *testing.T) {
|
func TestPvPGameAlternatesTurns(t *testing.T) {
|
||||||
@@ -706,6 +822,44 @@ func TestProtocolVersionMismatchIsRefused(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestUnknownResumeTokenIsAnsweredNotSilent: a token the server never
|
||||||
|
// registered, or has already forgotten past its grace window, used to get
|
||||||
|
// silence. The client's own resume latch then waited forever for a reply that
|
||||||
|
// was never coming — this is the fix, and the connection must still be usable
|
||||||
|
// afterward as the fresh session it is.
|
||||||
|
func TestUnknownResumeTokenIsAnsweredNotSilent(t *testing.T) {
|
||||||
|
_, url := newTestServer(t, chainDict(), Config{})
|
||||||
|
c := dial(t, url)
|
||||||
|
c.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_Hello{Hello: &noituv1.Hello{
|
||||||
|
ProtocolVersion: ProtocolVersion,
|
||||||
|
Nickname: "Người chơi",
|
||||||
|
ResumeToken: "no-such-token",
|
||||||
|
}}})
|
||||||
|
c.await("welcome")
|
||||||
|
|
||||||
|
if code := c.await("error").GetError().GetCode(); code != "session_not_resumable" {
|
||||||
|
t.Errorf("error code = %q, want session_not_resumable", code)
|
||||||
|
}
|
||||||
|
|
||||||
|
c.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_CreateRoom{CreateRoom: &noituv1.CreateRoom{}}})
|
||||||
|
if code := c.await("room_state").GetRoomState().GetRoomCode(); code == "" {
|
||||||
|
t.Error("a connection answered session_not_resumable must still be usable as a fresh session")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFreshHelloIsNotToldItCannotResume: a Hello with no resume token at all
|
||||||
|
// is not a resume attempt, and must not be answered as a failed one.
|
||||||
|
func TestFreshHelloIsNotToldItCannotResume(t *testing.T) {
|
||||||
|
_, url := newTestServer(t, chainDict(), Config{})
|
||||||
|
c := dial(t, url)
|
||||||
|
c.hello("Người chơi")
|
||||||
|
|
||||||
|
c.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_CreateRoom{CreateRoom: &noituv1.CreateRoom{}}})
|
||||||
|
if m := c.recv(); payloadCase(m) == "error" {
|
||||||
|
t.Fatalf("a fresh Hello with no resume token got %q, want none", m.GetError().GetCode())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TestHandshakeIsRequiredFirst rejects a client that skips Hello.
|
// TestHandshakeIsRequiredFirst rejects a client that skips Hello.
|
||||||
func TestHandshakeIsRequiredFirst(t *testing.T) {
|
func TestHandshakeIsRequiredFirst(t *testing.T) {
|
||||||
_, url := newTestServer(t, chainDict(), Config{})
|
_, url := newTestServer(t, chainDict(), Config{})
|
||||||
@@ -901,7 +1055,7 @@ func TestFreezeBoardIncludesTheOpeningWord(t *testing.T) {
|
|||||||
t.Fatalf("engine: %v", err)
|
t.Fatalf("engine: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
board := freezeBoard(e, "a b")
|
board := freezeBoard(e)
|
||||||
if !board.Used("a b") {
|
if !board.Used("a b") {
|
||||||
t.Error("frozen board does not consider the opening word played")
|
t.Error("frozen board does not consider the opening word played")
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user