package ws import ( "encoding/json" "net/http" "net/http/httptest" "strconv" "strings" "sync" "sync/atomic" "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"` Code string `json:"code"` // table_lobby Host bool `json:"host"` // table_lobby } // 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 hub.firstHandDelay = 0 hub.dealDelay = 0 hub.countdownDelay = 20 * time.Millisecond go hub.Run() return hub } // TestPrivateTableFlow: میزبان میز خصوصی می‌سازد، بازیکن دوم می‌پیوندد، // میزبان شروع می‌کند و بازی (با بات برای ۲ جای خالی) تا پایان پیش می‌رود. func TestPrivateTableFlow(t *testing.T) { hub := testHub() srv := httptest.NewServer(http.HandlerFunc(hub.ServeWS)) defer srv.Close() wsURL := "ws" + strings.TrimPrefix(srv.URL, "http") dial := func(tok string) *websocket.Conn { c, _, err := websocket.DefaultDialer.Dial(wsURL+"?token="+tok, nil) if err != nil { t.Fatalf("dial %s: %v", tok, err) } return c } host := dial("1") defer host.Close() if err := host.WriteJSON(map[string]string{"type": "create_table"}); err != nil { t.Fatal(err) } // میزبان باید table_lobby با کد دریافت کند. code := "" for code == "" { _ = host.SetReadDeadline(time.Now().Add(2 * time.Second)) _, raw, err := host.ReadMessage() if err != nil { t.Fatalf("host read: %v", err) } var m anyMsg _ = json.Unmarshal(raw, &m) if m.Type == "table_lobby" { if !m.Host { t.Fatal("host flag must be true for creator") } code = m.Code } } if len(code) != 5 { t.Fatalf("expected 5-digit code, got %q", code) } joiner := dial("2") defer joiner.Close() if err := joiner.WriteJSON(map[string]string{"type": "join_table", "code": code}); err != nil { t.Fatal(err) } // میزبان منتظر می‌ماند تا میز ۲ نفره شود، سپس شروع می‌کند. var gotCountdown atomic.Bool play := func(conn *websocket.Conn, m *anyMsg) { if m.Type == "state" { act(conn, m) } } done := make(chan string, 2) run := func(conn *websocket.Conn, isHost bool) { started := false for { _ = conn.SetReadDeadline(time.Now().Add(6 * time.Second)) _, raw, err := conn.ReadMessage() if err != nil { done <- "read err: " + err.Error() return } var m anyMsg _ = json.Unmarshal(raw, &m) switch m.Type { case "table_lobby": if isHost && !started && len(m.Players) >= 2 { started = true _ = conn.WriteJSON(map[string]string{"type": "start_table"}) } case "countdown": gotCountdown.Store(true) case "state": play(conn, &m) case "game_over": done <- "ok" return case "error": done <- "error: " + string(raw) return } } } go run(host, true) go run(joiner, false) for i := 0; i < 2; i++ { select { case res := <-done: if res != "ok" { t.Fatalf("client finished with: %s", res) } case <-time.After(25 * time.Second): t.Fatal("private game did not finish in time") } } if !gotCountdown.Load() { t.Fatal("expected countdown message before game start") } } // 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 hands int cuts int results 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) RecordHand(int64, bool, bool, bool) { s.mu.Lock() s.hands++ s.mu.Unlock() } func (s *recordingSettler) RecordCut(int64) { s.mu.Lock() s.cuts++ s.mu.Unlock() } func (s *recordingSettler) RecordGameResult(int64, bool) { s.mu.Lock() s.results++ s.mu.Unlock() } func (s *recordingSettler) ChargePrivateTable(int64) error { return nil } func (s *recordingSettler) PrivateTableInfo(int64) (int, bool) { return 5, false } // برای سرعتِ تست، بازی پس از ۲ هَند تمام می‌شود. func (s *recordingSettler) TargetHands(string) int { return 2 } 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] }