dcd8de8b00
- pkg/telemetry: shared OTel provider bootstrap (none/stdout/otlp + W3C
propagators + Go runtime metrics); backend/internal/telemetry becomes a thin
facade keeping its gin middleware.
- Telemetry parity: gateway and the Telegram connector gain telemetry runtimes
and config (GATEWAY_/TELEGRAM_ SERVICE_NAME + OTEL_*); otelgrpc instruments the
backend push server, the gateway's backend+connector clients and the connector
server. Default exporter stays none (collector/dashboards are Stage 14).
- Operational metrics (variant attribute on game-scoped ones): game_replay_duration,
game_move_validate_duration, games_started_total, games_abandoned_total,
game_cache_active, chat_messages_total{kind}, gateway edge_request_duration.
Wired via the SetMetrics setter pattern (default no-op meter).
- TODO-3: account.GuestReaper deletes guests with no game seat past
BACKEND_GUEST_RETENTION (default 30d, swept every BACKEND_GUEST_REAP_INTERVAL).
- Tests: pkg/telemetry exporter selection; game/social/edge metric recording via
a manual reader; config (otlp accepted, guest knobs); inttest guest reaper.
- Docs: PLAN.md re-scopes Stage 12 and adds Stage 13 (alphabet-on-wire) + Stage 14
(CI/deploy) with the agreed dictionary-versioning resolution; ARCHITECTURE 11/13,
TESTING, the three READMEs and FUNCTIONAL(+ru) updated.
140 lines
3.5 KiB
Go
140 lines
3.5 KiB
Go
package game
|
|
|
|
import (
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
|
|
"scrabble/backend/internal/engine"
|
|
)
|
|
|
|
func TestPayloadPlayRoundTrip(t *testing.T) {
|
|
rec := engine.MoveRecord{
|
|
Action: engine.ActionPlay, Dir: engine.Vertical, MainRow: 3, MainCol: 4,
|
|
Tiles: []engine.TileRecord{{Row: 3, Col: 4, Letter: "q", Blank: true}, {Row: 4, Col: 4, Letter: "i"}},
|
|
Words: []string{"qi"},
|
|
}
|
|
s, err := buildPayload(rec, []string{"q", "i", "?"}, nil).marshal()
|
|
if err != nil {
|
|
t.Fatalf("marshal: %v", err)
|
|
}
|
|
p, err := parsePayload(s)
|
|
if err != nil {
|
|
t.Fatalf("parse: %v", err)
|
|
}
|
|
if p.direction() != engine.Vertical || p.MainRow != 3 || p.MainCol != 4 {
|
|
t.Errorf("dir/anchor = %v/(%d,%d)", p.direction(), p.MainRow, p.MainCol)
|
|
}
|
|
tiles := p.tileRecords()
|
|
if len(tiles) != 2 || tiles[0].Letter != "q" || !tiles[0].Blank || tiles[1].Letter != "i" {
|
|
t.Errorf("tiles = %+v", tiles)
|
|
}
|
|
if len(p.Rack) != 3 || p.Rack[2] != "?" {
|
|
t.Errorf("rack = %v", p.Rack)
|
|
}
|
|
}
|
|
|
|
func TestPayloadExchangeRoundTrip(t *testing.T) {
|
|
rec := engine.MoveRecord{Action: engine.ActionExchange, Count: 2}
|
|
s, err := buildPayload(rec, []string{"a", "b", "c"}, []string{"a", "b"}).marshal()
|
|
if err != nil {
|
|
t.Fatalf("marshal: %v", err)
|
|
}
|
|
p, err := parsePayload(s)
|
|
if err != nil {
|
|
t.Fatalf("parse: %v", err)
|
|
}
|
|
if len(p.Exchanged) != 2 || p.Exchanged[0] != "a" {
|
|
t.Errorf("exchanged = %v", p.Exchanged)
|
|
}
|
|
if len(p.Tiles) != 0 || p.Dir != "" {
|
|
t.Errorf("exchange payload carried play fields: %+v", p)
|
|
}
|
|
}
|
|
|
|
func TestHintsRemaining(t *testing.T) {
|
|
cases := []struct{ allowance, used, wallet, want int }{
|
|
{1, 0, 3, 4},
|
|
{1, 1, 3, 3},
|
|
{1, 2, 3, 3}, // used past allowance clamps to 0
|
|
{0, 0, 5, 5},
|
|
{2, 1, 0, 1},
|
|
}
|
|
for _, c := range cases {
|
|
if got := hintsRemaining(c.allowance, c.used, c.wallet); got != c.want {
|
|
t.Errorf("hintsRemaining(%d,%d,%d) = %d, want %d", c.allowance, c.used, c.wallet, got, c.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestAllowedTimeout(t *testing.T) {
|
|
if !allowedTimeout(24 * time.Hour) {
|
|
t.Error("24h must be allowed")
|
|
}
|
|
if !allowedTimeout(5 * time.Minute) {
|
|
t.Error("5m must be allowed")
|
|
}
|
|
if allowedTimeout(7 * time.Minute) {
|
|
t.Error("7m must not be allowed")
|
|
}
|
|
if allowedTimeout(0) {
|
|
t.Error("zero must not be allowed")
|
|
}
|
|
}
|
|
|
|
func TestNormalizeWord(t *testing.T) {
|
|
if got := normalizeWord(" CaT \n"); got != "cat" {
|
|
t.Errorf("normalizeWord = %q, want cat", got)
|
|
}
|
|
}
|
|
|
|
func TestGameCacheEviction(t *testing.T) {
|
|
cur := time.Unix(1_700_000_000, 0)
|
|
cache := newGameCache(time.Hour, func() time.Time { return cur })
|
|
id := uuid.New()
|
|
cache.put(id, nil, "english")
|
|
if _, ok := cache.get(id); !ok {
|
|
t.Fatal("game must be resident after put")
|
|
}
|
|
cur = cur.Add(30 * time.Minute)
|
|
cache.get(id) // refresh idle timer
|
|
cur = cur.Add(90 * time.Minute)
|
|
if n := cache.sweep(); n != 1 {
|
|
t.Errorf("sweep evicted %d, want 1", n)
|
|
}
|
|
if _, ok := cache.get(id); ok {
|
|
t.Error("game must be evicted after idle TTL")
|
|
}
|
|
if cache.size() != 0 {
|
|
t.Errorf("cache size = %d, want 0", cache.size())
|
|
}
|
|
}
|
|
|
|
func TestKeyedMutexSerializes(t *testing.T) {
|
|
km := newKeyedMutex()
|
|
id := uuid.New()
|
|
var counter int
|
|
var wg sync.WaitGroup
|
|
for i := 0; i < 200; i++ {
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
unlock := km.lock(id)
|
|
counter++ // serialised; -race would flag a missing lock
|
|
unlock()
|
|
}()
|
|
}
|
|
wg.Wait()
|
|
if counter != 200 {
|
|
t.Errorf("counter = %d, want 200", counter)
|
|
}
|
|
km.mu.Lock()
|
|
left := len(km.locks)
|
|
km.mu.Unlock()
|
|
if left != 0 {
|
|
t.Errorf("lock map not cleaned up: %d entries left", left)
|
|
}
|
|
}
|