init
This commit is contained in:
@@ -0,0 +1,459 @@
|
||||
package ws
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
|
||||
"hakemsho/internal/game"
|
||||
)
|
||||
|
||||
// anyMsg تمام فیلدهای ممکن پیامهای سرور را برای تست جمع میکند.
|
||||
type anyMsg struct {
|
||||
Type string `json:"type"`
|
||||
Seat int `json:"seat"`
|
||||
YourSeat int `json:"your_seat"`
|
||||
Phase string `json:"phase"`
|
||||
Hakem int `json:"hakem"`
|
||||
Turn int `json:"turn"`
|
||||
YourHand []string `json:"your_hand"`
|
||||
LeadSuit string `json:"lead_suit"`
|
||||
TrickDone bool `json:"trick_done"`
|
||||
Players []PlayerInfo `json:"players"`
|
||||
}
|
||||
|
||||
// TestFourPlayersFullGame یک بازی کامل را روی WebSocket واقعی با ۴ ربات اجرا میکند.
|
||||
func TestFourPlayersFullGame(t *testing.T) {
|
||||
hub := testHub() // مهلتهای کوتاه (شامل trickHold) برای سرعت تست
|
||||
srv := httptest.NewServer(http.HandlerFunc(hub.ServeWS))
|
||||
defer srv.Close()
|
||||
wsURL := "ws" + strings.TrimPrefix(srv.URL, "http")
|
||||
|
||||
var wg sync.WaitGroup
|
||||
|
||||
for i := 0; i < 4; i++ {
|
||||
wg.Add(1)
|
||||
go func(idx int) {
|
||||
defer wg.Done()
|
||||
conn, _, err := websocket.DefaultDialer.Dial(wsURL+"?token="+strconv.Itoa(idx), nil)
|
||||
if err != nil {
|
||||
t.Errorf("dial %d: %v", idx, err)
|
||||
return
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
// ورود به صف
|
||||
if err := conn.WriteJSON(map[string]string{"type": "join_queue", "mode": "normal"}); err != nil {
|
||||
t.Errorf("join %d: %v", idx, err)
|
||||
return
|
||||
}
|
||||
|
||||
seat := -1
|
||||
for {
|
||||
_ = conn.SetReadDeadline(time.Now().Add(5 * time.Second))
|
||||
_, raw, err := conn.ReadMessage()
|
||||
if err != nil {
|
||||
t.Errorf("read %d (seat %d): %v", idx, seat, err)
|
||||
return
|
||||
}
|
||||
var m anyMsg
|
||||
if err := json.Unmarshal(raw, &m); err != nil {
|
||||
t.Errorf("unmarshal %d: %v", idx, err)
|
||||
return
|
||||
}
|
||||
switch m.Type {
|
||||
case "matched":
|
||||
seat = m.Seat
|
||||
case "state":
|
||||
act(conn, &m)
|
||||
case "game_over":
|
||||
return
|
||||
case "error":
|
||||
t.Errorf("client %d got error: %s", idx, raw)
|
||||
}
|
||||
}
|
||||
}(i)
|
||||
}
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() { wg.Wait(); close(done) }()
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(25 * time.Second):
|
||||
t.Fatal("game did not finish in time")
|
||||
}
|
||||
}
|
||||
|
||||
// act ربات سمتکلاینت: حکم میزند یا کارت مجاز بازی میکند.
|
||||
// در فاز انتخاب حکم، حاکم دقیقاً یک state دریافت میکند پس یکبار حکم میزند (هر هَند).
|
||||
func act(conn *websocket.Conn, m *anyMsg) {
|
||||
switch m.Phase {
|
||||
case "choose_trump":
|
||||
if m.YourSeat == m.Hakem && len(m.YourHand) > 0 {
|
||||
c, _ := game.ParseCard(m.YourHand[0])
|
||||
_ = conn.WriteJSON(map[string]string{"type": "choose_trump", "suit": c.Suit.String()})
|
||||
}
|
||||
case "playing":
|
||||
if m.TrickDone || m.Turn != m.YourSeat {
|
||||
return
|
||||
}
|
||||
card := legalCard(m.YourHand, m.LeadSuit)
|
||||
_ = conn.WriteJSON(map[string]string{"type": "play_card", "card": card})
|
||||
}
|
||||
}
|
||||
|
||||
// testHub یک هاب با مهلتهای کوتاه برای تستها میسازد.
|
||||
func testHub() *Hub {
|
||||
hub := NewHub(func(token string) (int64, string, error) {
|
||||
id, err := strconv.ParseInt(token, 10, 64)
|
||||
return id, "p" + token, err
|
||||
})
|
||||
hub.turnTimeout = 8 * time.Millisecond
|
||||
hub.botDelay = 8 * time.Millisecond
|
||||
hub.matchWait = 300 * time.Millisecond
|
||||
hub.trickHold = 5 * time.Millisecond
|
||||
go hub.Run()
|
||||
return hub
|
||||
}
|
||||
|
||||
// TestDisconnectMidGameNoPanic: قطع بازیکن وسط بازی باید بدون panic، رویداد
|
||||
// player_disconnected تولید کند و بازی با حرکت خودکار ادامه یابد.
|
||||
func TestDisconnectMidGameNoPanic(t *testing.T) {
|
||||
hub := testHub()
|
||||
srv := httptest.NewServer(http.HandlerFunc(hub.ServeWS))
|
||||
defer srv.Close()
|
||||
wsURL := "ws" + strings.TrimPrefix(srv.URL, "http")
|
||||
|
||||
conns := make([]*websocket.Conn, 4)
|
||||
for i := 0; i < 4; i++ {
|
||||
c, _, err := websocket.DefaultDialer.Dial(wsURL+"?token="+strconv.Itoa(i), nil)
|
||||
if err != nil {
|
||||
t.Fatalf("dial %d: %v", i, err)
|
||||
}
|
||||
conns[i] = c
|
||||
if err := c.WriteJSON(map[string]string{"type": "join_queue"}); err != nil {
|
||||
t.Fatalf("join %d: %v", i, err)
|
||||
}
|
||||
}
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
conns[0].Close()
|
||||
|
||||
gotDisconnect := false
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
_ = conns[1].SetReadDeadline(time.Now().Add(2 * time.Second))
|
||||
_, raw, err := conns[1].ReadMessage()
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
var m anyMsg
|
||||
_ = json.Unmarshal(raw, &m)
|
||||
if m.Type == "player_disconnected" {
|
||||
gotDisconnect = true
|
||||
break
|
||||
}
|
||||
}
|
||||
for _, c := range conns[1:] {
|
||||
c.Close()
|
||||
}
|
||||
if !gotDisconnect {
|
||||
t.Fatal("expected player_disconnected after a player disconnected")
|
||||
}
|
||||
}
|
||||
|
||||
// TestBotFill: اگر کمتر از ۴ انسان در صف بماند، میز با بات کامل و بازی شروع میشود.
|
||||
func TestBotFill(t *testing.T) {
|
||||
hub := testHub()
|
||||
srv := httptest.NewServer(http.HandlerFunc(hub.ServeWS))
|
||||
defer srv.Close()
|
||||
wsURL := "ws" + strings.TrimPrefix(srv.URL, "http")
|
||||
|
||||
// فقط ۲ انسان وصل میشوند.
|
||||
conns := make([]*websocket.Conn, 2)
|
||||
for i := 0; i < 2; i++ {
|
||||
c, _, err := websocket.DefaultDialer.Dial(wsURL+"?token="+strconv.Itoa(i), nil)
|
||||
if err != nil {
|
||||
t.Fatalf("dial %d: %v", i, err)
|
||||
}
|
||||
defer c.Close()
|
||||
conns[i] = c
|
||||
_ = c.WriteJSON(map[string]string{"type": "join_queue"})
|
||||
}
|
||||
|
||||
// بازیکن ۰ باید matched با ۴ بازیکن (۲ بات) دریافت کند.
|
||||
_ = conns[0].SetReadDeadline(time.Now().Add(3 * time.Second))
|
||||
bots := -1
|
||||
for {
|
||||
_, raw, err := conns[0].ReadMessage()
|
||||
if err != nil {
|
||||
t.Fatalf("read matched: %v", err)
|
||||
}
|
||||
var m anyMsg
|
||||
_ = json.Unmarshal(raw, &m)
|
||||
if m.Type == "matched" {
|
||||
if len(m.Players) != 4 {
|
||||
t.Fatalf("expected 4 players, got %d", len(m.Players))
|
||||
}
|
||||
bots = 0
|
||||
for _, p := range m.Players {
|
||||
if p.Bot {
|
||||
bots++
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
if bots != 2 {
|
||||
t.Fatalf("expected 2 bots, got %d", bots)
|
||||
}
|
||||
}
|
||||
|
||||
// TestReconnect: بازیکن قطعشده میتواند با همان توکن به جایگاه قبلی بازگردد.
|
||||
func TestReconnect(t *testing.T) {
|
||||
hub := testHub()
|
||||
srv := httptest.NewServer(http.HandlerFunc(hub.ServeWS))
|
||||
defer srv.Close()
|
||||
wsURL := "ws" + strings.TrimPrefix(srv.URL, "http")
|
||||
|
||||
conns := make([]*websocket.Conn, 4)
|
||||
for i := 0; i < 4; i++ {
|
||||
c, _, err := websocket.DefaultDialer.Dial(wsURL+"?token="+strconv.Itoa(i), nil)
|
||||
if err != nil {
|
||||
t.Fatalf("dial %d: %v", i, err)
|
||||
}
|
||||
conns[i] = c
|
||||
_ = c.WriteJSON(map[string]string{"type": "join_queue"})
|
||||
}
|
||||
time.Sleep(150 * time.Millisecond)
|
||||
|
||||
// بازیکن ۰ قطع و سپس با همان توکن دوباره وصل میشود.
|
||||
conns[0].Close()
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
rc, _, err := websocket.DefaultDialer.Dial(wsURL+"?token=0", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("reconnect dial: %v", err)
|
||||
}
|
||||
defer rc.Close()
|
||||
|
||||
// باید matched با seat=0 و سپس state دریافت کند.
|
||||
gotMatched, gotState := false, false
|
||||
_ = rc.SetReadDeadline(time.Now().Add(3 * time.Second))
|
||||
for !gotState {
|
||||
_, raw, err := rc.ReadMessage()
|
||||
if err != nil {
|
||||
t.Fatalf("read after reconnect: %v", err)
|
||||
}
|
||||
var m anyMsg
|
||||
_ = json.Unmarshal(raw, &m)
|
||||
switch m.Type {
|
||||
case "matched":
|
||||
if m.Seat != 0 {
|
||||
t.Fatalf("reconnect to wrong seat: %d", m.Seat)
|
||||
}
|
||||
gotMatched = true
|
||||
case "state":
|
||||
gotState = true
|
||||
}
|
||||
}
|
||||
if !gotMatched || !gotState {
|
||||
t.Fatal("reconnected client did not receive matched+state")
|
||||
}
|
||||
for _, c := range conns[1:] {
|
||||
c.Close()
|
||||
}
|
||||
}
|
||||
|
||||
// TestTurnTimeoutAutoPlays: حتی اگر هیچ بازیکنی حرکت نکند، تایمر نوبت کل بازی
|
||||
// را خودکار پیش میبرد تا game_over (مسیر مشترک با حرکت بات/قطعشده).
|
||||
func TestTurnTimeoutAutoPlays(t *testing.T) {
|
||||
hub := testHub()
|
||||
srv := httptest.NewServer(http.HandlerFunc(hub.ServeWS))
|
||||
defer srv.Close()
|
||||
wsURL := "ws" + strings.TrimPrefix(srv.URL, "http")
|
||||
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < 4; i++ {
|
||||
wg.Add(1)
|
||||
go func(idx int) {
|
||||
defer wg.Done()
|
||||
conn, _, err := websocket.DefaultDialer.Dial(wsURL+"?token="+strconv.Itoa(idx), nil)
|
||||
if err != nil {
|
||||
t.Errorf("dial %d: %v", idx, err)
|
||||
return
|
||||
}
|
||||
defer conn.Close()
|
||||
_ = conn.WriteJSON(map[string]string{"type": "join_queue"})
|
||||
// عمداً هیچ حرکتی نمیزنیم؛ فقط منتظر game_over میمانیم.
|
||||
for {
|
||||
_ = conn.SetReadDeadline(time.Now().Add(5 * time.Second))
|
||||
_, raw, err := conn.ReadMessage()
|
||||
if err != nil {
|
||||
t.Errorf("client %d read: %v", idx, err)
|
||||
return
|
||||
}
|
||||
var m anyMsg
|
||||
_ = json.Unmarshal(raw, &m)
|
||||
if m.Type == "game_over" {
|
||||
return
|
||||
}
|
||||
}
|
||||
}(i)
|
||||
}
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() { wg.Wait(); close(done) }()
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(20 * time.Second):
|
||||
t.Fatal("turn-timeout auto-play did not finish the game")
|
||||
}
|
||||
}
|
||||
|
||||
// recordingSettler فراخوانیهای تسویه را برای بررسی در تست میشمارد.
|
||||
type recordingSettler struct {
|
||||
mu sync.Mutex
|
||||
charges int
|
||||
refunds int
|
||||
awards int
|
||||
records int
|
||||
}
|
||||
|
||||
func (s *recordingSettler) ChargeEntry(int64, string) error {
|
||||
s.mu.Lock()
|
||||
s.charges++
|
||||
s.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
func (s *recordingSettler) Refund(int64, string) {
|
||||
s.mu.Lock()
|
||||
s.refunds++
|
||||
s.mu.Unlock()
|
||||
}
|
||||
func (s *recordingSettler) AwardWinner(int64, string) {
|
||||
s.mu.Lock()
|
||||
s.awards++
|
||||
s.mu.Unlock()
|
||||
}
|
||||
func (s *recordingSettler) RecordGame(string, string, int, bool) {
|
||||
s.mu.Lock()
|
||||
s.records++
|
||||
s.mu.Unlock()
|
||||
}
|
||||
func (s *recordingSettler) snapshot() (c, r, a, rec int) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.charges, s.refunds, s.awards, s.records
|
||||
}
|
||||
|
||||
// TestSettlementOnGameOver: ۴ انسانِ بیکار ⇒ ۴ کسر ورودی، بازی خودکار تا پایان،
|
||||
// ۲ جایزهی برنده و یک ثبت بازی، بدون بازگشت.
|
||||
func TestSettlementOnGameOver(t *testing.T) {
|
||||
hub := testHub()
|
||||
settler := &recordingSettler{}
|
||||
hub.SetSettler(settler)
|
||||
srv := httptest.NewServer(http.HandlerFunc(hub.ServeWS))
|
||||
defer srv.Close()
|
||||
wsURL := "ws" + strings.TrimPrefix(srv.URL, "http")
|
||||
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < 4; i++ {
|
||||
wg.Add(1)
|
||||
go func(idx int) {
|
||||
defer wg.Done()
|
||||
conn, _, err := websocket.DefaultDialer.Dial(wsURL+"?token="+strconv.Itoa(idx+1), nil)
|
||||
if err != nil {
|
||||
t.Errorf("dial %d: %v", idx, err)
|
||||
return
|
||||
}
|
||||
defer conn.Close()
|
||||
_ = conn.WriteJSON(map[string]string{"type": "join_queue", "tier": "beginner"})
|
||||
for {
|
||||
_ = conn.SetReadDeadline(time.Now().Add(6 * time.Second))
|
||||
_, raw, err := conn.ReadMessage()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
var m anyMsg
|
||||
_ = json.Unmarshal(raw, &m)
|
||||
if m.Type == "game_over" {
|
||||
return
|
||||
}
|
||||
}
|
||||
}(i)
|
||||
}
|
||||
done := make(chan struct{})
|
||||
go func() { wg.Wait(); close(done) }()
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(20 * time.Second):
|
||||
t.Fatal("game did not finish")
|
||||
}
|
||||
time.Sleep(100 * time.Millisecond) // فرصت پردازش settle در goroutine میز
|
||||
|
||||
c, r, a, rec := settler.snapshot()
|
||||
if c != 4 {
|
||||
t.Errorf("charges = %d, want 4", c)
|
||||
}
|
||||
if a != 2 {
|
||||
t.Errorf("awards = %d, want 2 (winning team humans)", a)
|
||||
}
|
||||
if rec != 1 {
|
||||
t.Errorf("records = %d, want 1", rec)
|
||||
}
|
||||
if r != 0 {
|
||||
t.Errorf("refunds = %d, want 0 on normal finish", r)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRefundOnAbort: اگر همهی انسانها وسط بازی قطع شوند، ورودی بازگردانده میشود.
|
||||
func TestRefundOnAbort(t *testing.T) {
|
||||
hub := testHub()
|
||||
settler := &recordingSettler{}
|
||||
hub.SetSettler(settler)
|
||||
srv := httptest.NewServer(http.HandlerFunc(hub.ServeWS))
|
||||
defer srv.Close()
|
||||
wsURL := "ws" + strings.TrimPrefix(srv.URL, "http")
|
||||
|
||||
conns := make([]*websocket.Conn, 4)
|
||||
for i := 0; i < 4; i++ {
|
||||
c, _, err := websocket.DefaultDialer.Dial(wsURL+"?token="+strconv.Itoa(i+1), nil)
|
||||
if err != nil {
|
||||
t.Fatalf("dial %d: %v", i, err)
|
||||
}
|
||||
conns[i] = c
|
||||
_ = c.WriteJSON(map[string]string{"type": "join_queue", "tier": "beginner"})
|
||||
}
|
||||
time.Sleep(150 * time.Millisecond) // میز تشکیل شود
|
||||
for _, c := range conns {
|
||||
c.Close()
|
||||
}
|
||||
time.Sleep(400 * time.Millisecond) // قطع همه ⇒ abort + refund
|
||||
|
||||
charges, refunds, _, _ := settler.snapshot()
|
||||
if charges != 4 {
|
||||
t.Errorf("charges = %d, want 4", charges)
|
||||
}
|
||||
if refunds != 4 {
|
||||
t.Errorf("refunds = %d, want 4 on abort", refunds)
|
||||
}
|
||||
}
|
||||
|
||||
// legalCard یک کارت مجاز انتخاب میکند (follow suit در صورت امکان).
|
||||
func legalCard(hand []string, leadSuit string) string {
|
||||
if leadSuit != "" {
|
||||
for _, cs := range hand {
|
||||
c, err := game.ParseCard(cs)
|
||||
if err == nil && c.Suit.String() == leadSuit {
|
||||
return cs
|
||||
}
|
||||
}
|
||||
}
|
||||
return hand[0]
|
||||
}
|
||||
Reference in New Issue
Block a user