This commit is contained in:
2026-06-15 16:53:25 +03:30
commit 390ef96e7e
34 changed files with 4463 additions and 0 deletions
+459
View File
@@ -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]
}