Files
scrabble-game/backend/internal/game/helpers_test.go
T
Ilia Denisov 7fc1301b31
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 15s
CI / ui (pull_request) Successful in 1m28s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m43s
feat(hint): unify the vs_ai idle hint online + offline (server-enforced, monotonic)
Online vs_ai hints were broken: #207 made the vs_ai hint button always-enabled and
wallet-free (assuming all vs_ai = the offline idle-gate), but the backend still served
online vs_ai from the allowance/wallet, so over-clicking hit ErrNoHintsLeft -> a generic
error toast. Now online vs_ai uses the SAME idle-gate model as offline.

Backend: Hint() for a vs_ai game skips the allowance/wallet and increments no hints_used
(owner: vs_ai counts toward no hint statistic), and is idle-gated from the SERVER clock --
it returns ErrHintLocked (code hint_locked) until the robot's last move + 30 min, else
serves the top move. GameState/StateView expose hint_unlock_left_seconds (server-computed
seconds remaining; 0 for a human game / first move / not-your-turn). Pure helper
hintUnlockLeftSeconds unit-tested.

Wire: StateView gains hint_unlock_left_seconds (FlatBuffers, additive); pkg/wire + gateway
transcode carry it (round-trip test).

Client: the gate counts down from a MONOTONIC clock (performance.now()) anchored to the
source's seconds-left when it lands (on load from the view; to the full window when the
robot moves), so a client clock change cannot skew it and a relaunch re-reads a fresh
value. The vs_ai hint button (online + offline) shows the lock + toast; doHint handles the
hint_locked backstop by re-syncing. Replaces #207's absolute hintUnlockAtMs on the
wire/model/delta (the offline record keeps the absolute for persistence; the view exposes
seconds-left).

Docs FUNCTIONAL(+_ru)/ARCHITECTURE updated. Verified: go build/vet + game/server/transcode
tests (+ new hintUnlockLeftSeconds); ui check 0 / unit 490 / e2e 198 (one pre-existing
webkit offline flake) / app entry 114.2/115.
2026-07-07 00:42:43 +02:00

163 lines
4.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 TestHintUnlockLeftSeconds(t *testing.T) {
now := time.Now()
// A gated vs_ai game on the caller's turn, the robot having moved 10 min ago (turn started then).
gated := Game{VsAI: true, ToMove: 0, MoveCount: 2, TurnStartedAt: now.Add(-10 * time.Minute)}
cases := []struct {
name string
g Game
seat int
want int
}{
{"non-vs_ai is open", Game{VsAI: false, ToMove: 0, MoveCount: 2, TurnStartedAt: now.Add(-10 * time.Minute)}, 0, 0},
{"not the caller's turn is open", gated, 1, 0},
{"human first move (no robot move) is open", Game{VsAI: true, ToMove: 0, MoveCount: 0, TurnStartedAt: now}, 0, 0},
{"robot moved 10 min ago leaves 20 min", gated, 0, 20 * 60},
{"robot moved past the window is open", Game{VsAI: true, ToMove: 0, MoveCount: 2, TurnStartedAt: now.Add(-40 * time.Minute)}, 0, 0},
}
for _, c := range cases {
if got := hintUnlockLeftSeconds(c.g, c.seat, now); got != c.want {
t.Errorf("%s: hintUnlockLeftSeconds = %d, want %d", c.name, 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, "scrabble_en", nil)
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)
}
}