Files
scrabble-game/backend/internal/server/dto_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

135 lines
4.4 KiB
Go

package server
import (
"net/http"
"testing"
"time"
"github.com/google/uuid"
"scrabble/backend/internal/account"
"scrabble/backend/internal/engine"
"scrabble/backend/internal/game"
"scrabble/backend/internal/session"
"scrabble/backend/internal/social"
)
func TestParseDirection(t *testing.T) {
cases := map[string]struct {
in string
want engine.Direction
ok bool
}{
"horizontal": {"H", engine.Horizontal, true},
"vertical": {"V", engine.Vertical, true},
"lowercase": {"h", engine.Horizontal, true},
"trimmed": {" V ", engine.Vertical, true},
"invalid": {"X", 0, false},
"empty": {"", 0, false},
"diagonal-is-not": {"D", 0, false},
}
for name, tc := range cases {
t.Run(name, func(t *testing.T) {
got, ok := parseDirection(tc.in)
if ok != tc.ok || (ok && got != tc.want) {
t.Fatalf("parseDirection(%q) = (%v, %v), want (%v, %v)", tc.in, got, ok, tc.want, tc.ok)
}
})
}
}
func TestStatusForError(t *testing.T) {
cases := map[string]struct {
err error
wantStatus int
wantCode string
}{
"not a player": {game.ErrNotAPlayer, http.StatusForbidden, "not_a_player"},
"not your turn": {game.ErrNotYourTurn, http.StatusConflict, "not_your_turn"},
"nudge own turn": {social.ErrNudgeOnOwnTurn, http.StatusConflict, "nudge_own_turn"},
"illegal play": {engine.ErrIllegalPlay, http.StatusUnprocessableEntity, "illegal_play"},
"email taken": {account.ErrEmailTaken, http.StatusConflict, "email_taken"},
"code mismatch": {account.ErrCodeMismatch, http.StatusUnauthorized, "code_invalid"},
"session gone": {session.ErrNotFound, http.StatusUnauthorized, "session_invalid"},
"chat forbidden": {social.ErrForbiddenContent, http.StatusUnprocessableEntity, "chat_rejected"},
"no hint move": {game.ErrNoHintAvailable, http.StatusConflict, "no_hint_available"},
"no hints left": {game.ErrNoHintsLeft, http.StatusConflict, "hint_unavailable"},
"unknown -> 500": {context_deadline, http.StatusInternalServerError, "internal"},
}
for name, tc := range cases {
t.Run(name, func(t *testing.T) {
status, code := statusForError(tc.err)
if status != tc.wantStatus || code != tc.wantCode {
t.Fatalf("statusForError(%v) = (%d, %q), want (%d, %q)", tc.err, status, code, tc.wantStatus, tc.wantCode)
}
})
}
}
// context_deadline is an arbitrary unmapped error standing in for "anything
// unrecognised", which must fall through to 500/internal.
var context_deadline = errNew("boom")
type simpleErr string
func (e simpleErr) Error() string { return string(e) }
func errNew(s string) error { return simpleErr(s) }
func TestGameDTOFromGame(t *testing.T) {
gid, aid := uuid.New(), uuid.New()
g := game.Game{
ID: gid,
Variant: engine.VariantEnglish,
DictVersion: "v1",
Status: game.StatusActive,
Players: 2,
ToMove: 1,
TurnTimeout: 24 * time.Hour,
MoveCount: 3,
Seats: []game.Seat{{Seat: 0, AccountID: aid, Score: 12}},
}
dto := gameDTOFromGame(g)
if dto.ID != gid.String() || dto.Variant != "english" || dto.ToMove != 1 || dto.TurnTimeoutSecs != 86400 {
t.Fatalf("game dto mismatch: %+v", dto)
}
if len(dto.Seats) != 1 || dto.Seats[0].AccountID != aid.String() || dto.Seats[0].Score != 12 {
t.Fatalf("seat dto mismatch: %+v", dto.Seats)
}
}
func TestProfileResponseForAwayWindow(t *testing.T) {
acc := account.Account{
ID: uuid.New(),
DisplayName: "Kaya",
PreferredLanguage: "ru",
TimeZone: "Europe/Moscow",
AwayStart: time.Date(0, 1, 1, 0, 0, 0, 0, time.UTC),
AwayEnd: time.Date(0, 1, 1, 7, 30, 0, 0, time.UTC),
}
dto := profileResponseFor(acc)
if dto.AwayStart != "00:00" || dto.AwayEnd != "07:30" {
t.Fatalf("away window = (%q, %q), want (00:00, 07:30)", dto.AwayStart, dto.AwayEnd)
}
if dto.PreferredLanguage != "ru" || dto.TimeZone != "Europe/Moscow" {
t.Fatalf("profile dto mismatch: %+v", dto)
}
}
func TestMoveRecordDTOFrom(t *testing.T) {
rec := engine.MoveRecord{
Player: 1,
Action: engine.ActionPlay,
Dir: engine.Vertical,
MainRow: 7,
MainCol: 7,
Tiles: []engine.TileRecord{{Row: 7, Col: 7, Letter: "A", Blank: false}},
Words: []string{"AB"},
Score: 10,
Total: 10,
}
dto := moveRecordDTOFrom(rec)
if dto.Action != "play" || dto.Dir != "V" || dto.Score != 10 || len(dto.Tiles) != 1 || dto.Tiles[0].Letter != "A" {
t.Fatalf("move dto mismatch: %+v", dto)
}
}