Files
scrabble-game/backend/internal/inttest/analytics_test.go
T
Ilia Denisov 635f2fd9fc Stage 17: backend defect fixes (nudge code, TG name, robot names/timing, multi-device push, move-duration metric + admin analytics)
- #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.')
2026-06-06 09:59:12 +02:00

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)
}
}
}