mirror of
https://github.com/tiennm99/noitu.git
synced 2026-10-12 14:15:12 +00:00
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
222 lines
7.3 KiB
Go
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())
|
|
}
|
|
}
|