635f2fd9fc
- #3 nudge-on-own-turn: distinct result code nudge_own_turn + i18n (was reused 'not_your_turn') - #2 sanitize connector registration name to the editable format; Player/Игрок-XXXXX fallback - #5 variant-aware robot name pools (composed full/colloquial first + surname forms; ru gets <=20% latin) - #4 move-number-aware robot move timing (early 1-5min -> late 10-90min, skew k=4) - #7 emit move event to the actor too (multi-device sync); opponent_moved stays in-app only - #1 live game_move_duration{variant,phase} histogram + admin console per-user min/avg/max columns and an inline-SVG move-time-by-move-number chart (offline from the journal) - ProvisionRobot bypasses editor name validation (system names like 'Peter J.')
82 lines
2.7 KiB
Go
82 lines
2.7 KiB
Go
//go:build integration
|
|
|
|
package inttest
|
|
|
|
import (
|
|
"context"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
|
|
"scrabble/backend/internal/account"
|
|
"scrabble/backend/internal/game"
|
|
)
|
|
|
|
// TestMoveDurationAnalytics seeds a game with crafted move timestamps and checks the
|
|
// admin-console move-duration reports compute the think time (gap to the previous
|
|
// move, the first move measured from game creation) correctly, per account and per
|
|
// the account's move ordinal.
|
|
func TestMoveDurationAnalytics(t *testing.T) {
|
|
ctx := context.Background()
|
|
accounts := account.NewStore(testDB)
|
|
a, err := accounts.ProvisionByIdentity(ctx, account.KindTelegram, "tg-"+uuid.NewString())
|
|
if err != nil {
|
|
t.Fatalf("provision A: %v", err)
|
|
}
|
|
b, err := accounts.ProvisionByIdentity(ctx, account.KindTelegram, "tg-"+uuid.NewString())
|
|
if err != nil {
|
|
t.Fatalf("provision B: %v", err)
|
|
}
|
|
|
|
gid := uuid.New()
|
|
t0 := time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC)
|
|
if _, err := testDB.ExecContext(ctx,
|
|
`INSERT INTO backend.games (game_id, variant, dict_version, seed, players, turn_timeout_secs, created_at)
|
|
VALUES ($1,'english','v1',1,2,86400,$2)`, gid, t0); err != nil {
|
|
t.Fatalf("insert game: %v", err)
|
|
}
|
|
if _, err := testDB.ExecContext(ctx,
|
|
`INSERT INTO backend.game_players (game_id, seat, account_id) VALUES ($1,0,$2),($1,1,$3)`, gid, a.ID, b.ID); err != nil {
|
|
t.Fatalf("insert seats: %v", err)
|
|
}
|
|
// seq, seat, commit time as seconds from t0. Durations: A:60,50 B:120,200.
|
|
moves := []struct{ seq, seat, at int }{{0, 0, 60}, {1, 1, 180}, {2, 0, 230}, {3, 1, 430}}
|
|
for _, m := range moves {
|
|
if _, err := testDB.ExecContext(ctx,
|
|
`INSERT INTO backend.game_moves (game_id, seq, seat, action, created_at) VALUES ($1,$2,$3,'play',$4)`,
|
|
gid, m.seq, m.seat, t0.Add(time.Duration(m.at)*time.Second)); err != nil {
|
|
t.Fatalf("insert move %d: %v", m.seq, err)
|
|
}
|
|
}
|
|
|
|
store := game.NewStore(testDB)
|
|
stats, err := store.MoveDurationStats(ctx, []uuid.UUID{a.ID, b.ID})
|
|
if err != nil {
|
|
t.Fatalf("stats: %v", err)
|
|
}
|
|
if sa := stats[a.ID]; sa.Moves != 2 || sa.MinSecs != 50 || sa.MaxSecs != 60 || sa.AvgSecs != 55 {
|
|
t.Errorf("A stats = %+v, want min50 max60 avg55 moves2", sa)
|
|
}
|
|
if sb := stats[b.ID]; sb.Moves != 2 || sb.MinSecs != 120 || sb.MaxSecs != 200 || sb.AvgSecs != 160 {
|
|
t.Errorf("B stats = %+v, want min120 max200 avg160 moves2", sb)
|
|
}
|
|
|
|
byOrd, err := store.MoveDurationByOrdinal(ctx, a.ID)
|
|
if err != nil {
|
|
t.Fatalf("by ordinal: %v", err)
|
|
}
|
|
want := []game.OrdinalDuration{
|
|
{Ordinal: 1, MinSecs: 60, MaxSecs: 60, AvgSecs: 60},
|
|
{Ordinal: 2, MinSecs: 50, MaxSecs: 50, AvgSecs: 50},
|
|
}
|
|
if len(byOrd) != len(want) {
|
|
t.Fatalf("by ordinal = %+v, want %+v", byOrd, want)
|
|
}
|
|
for i, w := range want {
|
|
if byOrd[i] != w {
|
|
t.Errorf("ordinal[%d] = %+v, want %+v", i, byOrd[i], w)
|
|
}
|
|
}
|
|
}
|