Files
tiennm99 55abdb36c1 refactor(wsapi): dedupe routing and seating, fix ordering races
Route room inputs through one toRoom helper, iterate live seats with
connected(), and share seating and reconnect-window code. Declare every
error code once in errcodes.go, which the web vocabulary test now reads.

Fixes:
- a kicked player is released before being told, so their next action
  is answered not_in_a_room
- a rejected word is counted before move_rejected is sent
- room code draw and registration share one lock hold
- two concurrent resumes with one token can no longer both take a seat
- multiplayer tests ready guests one at a time instead of racing

Behaviour changes:
- opening or joining another room while a game is running is refused
  with already_in_a_game
- resigning in the lobby answers game_not_started
2026-09-28 15:19:59 +07:00

222 lines
7.3 KiB
Go

package wsapi
import (
"context"
"testing"
"time"
"github.com/coder/websocket"
noituv1 "github.com/tiennm99dev/noitu/server/gen/noitu/v1"
)
// TestQuickMatchPairsTwoWaiters is the core promise: two strangers who both
// ask are seated together and the first game begins itself, with nobody
// pressing ready or start.
func TestQuickMatchPairsTwoWaiters(t *testing.T) {
_, url := newTestServer(t, chainDict(), Config{})
first := dial(t, url)
first.hello("Người chờ")
first.quickMatch()
if !first.await("quick_match_status").GetQuickMatchStatus().GetQueued() {
t.Fatal("the first asker should be queued, nobody else waiting yet")
}
second := dial(t, url)
second.hello("Người đến sau")
second.quickMatch()
if got := second.await("quick_match_status").GetQuickMatchStatus().GetQueued(); got {
t.Error("the one who completes the pair must not be left queued")
}
if got := first.await("quick_match_status").GetQuickMatchStatus().GetQueued(); got {
t.Error("the waiter must be told the wait is over once matched")
}
// The room begins its own first game: neither side sets ready or start.
firstStart := first.await("game_started").GetGameStarted()
secondStart := second.await("game_started").GetGameStarted()
if firstStart.GetMyTurn() == secondStart.GetMyTurn() {
t.Fatal("exactly one player should be dealt the opening turn")
}
}
// TestQuickMatchCancelStopsTheWait covers withdrawing from the queue and
// staying out of it: a cancelled waiter must not surface later as somebody's
// match.
func TestQuickMatchCancelStopsTheWait(t *testing.T) {
_, url := newTestServer(t, chainDict(), Config{})
waiter := dial(t, url)
waiter.hello("Người chờ")
waiter.quickMatch()
if !waiter.await("quick_match_status").GetQuickMatchStatus().GetQueued() {
t.Fatal("expected to be queued")
}
waiter.cancelQuickMatch()
if got := waiter.await("quick_match_status").GetQuickMatchStatus().GetQueued(); got {
t.Error("a cancel should answer queued:false")
}
// A cancel a second time is not an error: it is idempotent.
waiter.cancelQuickMatch()
if got := waiter.await("quick_match_status").GetQuickMatchStatus().GetQueued(); got {
t.Error("cancelling twice should still answer queued:false, not an error")
}
other := dial(t, url)
other.hello("Người khác")
other.quickMatch()
if !other.await("quick_match_status").GetQuickMatchStatus().GetQueued() {
t.Error("the cancelled waiter must not still be in line to be matched with")
}
}
// TestQuickMatchDropsADisconnectedWaiter is the ghost case: a waiter whose
// socket ends without a CancelQuickMatch must not be handed to the next
// stranger who asks.
func TestQuickMatchDropsADisconnectedWaiter(t *testing.T) {
_, url := newTestServer(t, chainDict(), Config{})
ghost := dial(t, url)
ghost.hello("Ma")
ghost.quickMatch()
ghost.await("quick_match_status")
_ = ghost.conn.Close(websocket.StatusNormalClosure, "")
settle()
a := dial(t, url)
a.hello("A")
a.quickMatch()
if !a.await("quick_match_status").GetQuickMatchStatus().GetQueued() {
t.Fatal("A should be freshly queued, not matched with a ghost")
}
b := dial(t, url)
b.hello("B")
b.quickMatch()
a.await("game_started")
b.await("game_started")
}
// TestQuickMatchRefusedWhenAlreadySeated guards against a session that
// already holds a seat asking to be queued as well.
func TestQuickMatchRefusedWhenAlreadySeated(t *testing.T) {
_, url := newTestServer(t, chainDict(), Config{})
c := dial(t, url)
c.hello("Chủ phòng")
c.createRoom()
c.await("room_state")
c.quickMatch()
if got := c.await("error").GetError().GetCode(); got != "already_in_a_room" {
t.Errorf("error code = %q, want already_in_a_room", got)
}
}
// TestQuickMatchRefusesASecondRequest guards the other half of the same
// check: a session already queued must not be queued twice.
func TestQuickMatchRefusesASecondRequest(t *testing.T) {
_, url := newTestServer(t, chainDict(), Config{})
c := dial(t, url)
c.hello("Người chờ")
c.quickMatch()
c.await("quick_match_status")
c.quickMatch()
if got := c.await("error").GetError().GetCode(); got != "already_queued" {
t.Errorf("error code = %q, want already_queued", got)
}
}
// TestQuickMatchServerFullTellsBothSides is the room-cap edge: a match that
// cannot open a room must not leave either side believing it is still
// queued.
func TestQuickMatchServerFullTellsBothSides(t *testing.T) {
_, url := newTestServer(t, chainDict(), Config{MaxRooms: 1})
filler := dial(t, url)
filler.hello("Chiếm chỗ")
filler.createRoom()
filler.await("room_state")
waiter := dial(t, url)
waiter.hello("Người chờ")
waiter.quickMatch()
waiter.await("quick_match_status")
newcomer := dial(t, url)
newcomer.hello("Người đến sau")
newcomer.quickMatch()
if got := newcomer.await("error").GetError().GetCode(); got != "server_full" {
t.Errorf("newcomer error = %q, want server_full", got)
}
if got := waiter.await("error").GetError().GetCode(); got != "server_full" {
t.Errorf("waiter error = %q, want server_full", got)
}
// Neither is left queued: a fresh pair must not resurrect this one.
third := dial(t, url)
third.hello("Người thứ ba")
third.quickMatch()
if !third.await("quick_match_status").GetQuickMatchStatus().GetQueued() {
t.Fatal("third should be freshly queued, not immediately matched with a stale waiter")
}
}
// TestQuickMatchSkipsAWaiterWhoseConnectionEnded covers the window between a
// waiter's socket closing and its teardown dequeuing it: a match drawn in that
// window must not seat the ghost.
func TestQuickMatchSkipsAWaiterWhoseConnectionEnded(t *testing.T) {
h := newHub(t.Context(), chainDict(), time.Second, time.Second, time.Minute, 0)
gone, cancelGone := context.WithCancel(t.Context())
cancelGone()
ghost := &session{id: "ghost", ctx: gone, out: make(chan []byte, 1)}
live := &session{id: "live", ctx: t.Context(), out: make(chan []byte, 8)}
h.mu.Lock()
h.waiting = append(h.waiting, ghost)
h.mu.Unlock()
if err := h.quickMatch(live); err != nil {
t.Fatalf("quickMatch: %v", err)
}
h.mu.Lock()
defer h.mu.Unlock()
if len(h.waiting) != 1 || h.waiting[0] != live {
t.Fatalf("queue = %v, want the live session alone", h.waiting)
}
if len(h.rooms) != 0 {
t.Fatal("a room was opened for a waiter with no connection behind it")
}
}
// TestNearMissIsWithheldWhenUnplayable: a real word one tone mark away is not
// offered when it does not answer the syllable in play, because taking the
// suggestion would only earn a second refusal.
func TestNearMissIsWithheldWhenUnplayable(t *testing.T) {
// Opening "ngôn ngữ" leaves "ngữ" to answer; "cà phê" is real but does not.
_, url := newTestServer(t, newTestDict("ngôn ngữ", "ngữ pháp", "cà phê"), Config{})
c := dial(t, url)
c.hello("Người chơi")
c.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_StartBotGame{
StartBotGame: &noituv1.StartBotGame{Difficulty: noituv1.Difficulty_DIFFICULTY_EASY},
}})
started := c.await("game_started").GetGameStarted()
c.submit("ca phe", started.GetTurnSeq())
rejected := c.await("move_rejected").GetMoveRejected()
if rejected.GetReason() != noituv1.RejectReason_REJECT_REASON_NOT_IN_DICTIONARY {
t.Fatalf("reason = %v, want NOT_IN_DICTIONARY", rejected.GetReason())
}
if rejected.GetSuggestion() != "" {
t.Errorf("suggestion = %q, want none for a word that does not link", rejected.GetSuggestion())
}
}