Files
scrabble-game/backend/internal/game/away_test.go
T
Ilia Denisov 751e74b14f
Tests · Go / test (push) Successful in 6s
Tests · Integration / integration (push) Successful in 8s
Tests · Go / test (pull_request) Successful in 5s
Tests · Integration / integration (pull_request) Successful in 8s
Stage 3: game domain (lifecycle, journal+cache, hint, word-check, GCG, stats)
internal/game drives the engine over a single match and owns everything the
engine does not: event-sourced persistence (a games row + an append-only decoded
move journal, with the live engine.Game kept warm in a cache and rebuilt by
replay on a miss), the play/pass/exchange/resign transitions with
validate-at-submit scoring, an unlimited score/legality preview, the hint
(per-game allowance + profile wallet), the word-check tool with complaint
capture, per-player game state, history and GCG export (Poslfit dialect), and a
background turn-timeout sweeper that auto-resigns overdue turns honouring each
player's daily away window. Like Stages 1-2 it is a service/store layer with no
HTTP; the gateway surface lands in Stage 6.

Engine: additive decoded domain API (Direction, SubmitPlay/SubmitExchange/
EvaluatePlay/HintView/Hand, MoveRecord.{Dir,MainRow,MainCol}, Registry.Lookup,
ParseVariant) so internal/game never imports scrabble-solver; and a Resign fix
so the resigner keeps their score yet never wins (the other player wins a
two-player game). Timeout reuses Resign.

Persistence: migration 00002 adds games, game_players, game_moves, complaints,
account_stats and extends accounts with away_start/away_end/hint_balance; go-jet
regenerated. account gained SpendHint. Config adds BACKEND_DICT_DIR (required),
BACKEND_DICT_VERSION, BACKEND_GAME_TIMEOUT_SWEEP_INTERVAL, BACKEND_GAME_CACHE_TTL;
main loads the registry at boot (hard dependency) and starts the sweeper.

Tests: engine resign + decoded-API tests; game unit tests (GCG, away-window
boundaries, hint budget, cache, keyed mutex, payload); inttest integration
(lifecycle, replay equivalence, timeout sweep with away grace, resign stats,
hint policy, word-check/complaint, per-game-lock). Docs (PLAN, ARCHITECTURE,
FUNCTIONAL +_ru, TESTING, README) updated.
2026-06-02 17:33:49 +02:00

79 lines
2.3 KiB
Go

package game
import (
"testing"
"time"
)
func TestInAwayWindow(t *testing.T) {
cases := []struct {
name string
dl, start, end int
wantIn, wantToday bool
}{
{"non-crossing inside", 120, 0, 420, true, true},
{"non-crossing before", 500, 0, 420, false, false},
{"non-crossing at start", 0, 0, 420, true, true},
{"non-crossing at end excluded", 420, 0, 420, false, false},
{"crossing evening", 1380, 1320, 360, true, false},
{"crossing morning", 180, 1320, 360, true, true},
{"crossing daytime out", 720, 1320, 360, false, false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
in, today := inAwayWindow(tc.dl, tc.start, tc.end)
if in != tc.wantIn || today != tc.wantToday {
t.Errorf("inAwayWindow(%d,%d,%d) = (%v,%v), want (%v,%v)",
tc.dl, tc.start, tc.end, in, today, tc.wantIn, tc.wantToday)
}
})
}
}
func TestEffectiveDeadline(t *testing.T) {
utc := time.UTC
day := func(h, m int) time.Time { return time.Date(2026, 6, 2, h, m, 0, 0, utc) }
hour := time.Hour
cases := []struct {
name string
start time.Time
timeout time.Duration
awayStart int
awayEnd int
want time.Time
}{
{"no window", day(1, 0), hour, 0, 0, day(2, 0)},
{"outside window", day(8, 0), hour, 0, 420, day(9, 0)},
{"inside non-crossing pushed to end", day(1, 0), hour, 0, 420, day(7, 0)},
{"inside non-crossing at boundary", day(2, 30), 3 * hour, 0, 420, day(7, 0)},
{"crossing evening pushed to next day", day(22, 0), hour, 1320, 360, day(6, 0).AddDate(0, 0, 1)},
{"crossing morning pushed to today end", day(2, 0), hour, 1320, 360, day(6, 0)},
{"crossing daytime untouched", day(11, 0), hour, 1320, 360, day(12, 0)},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := effectiveDeadline(tc.start, tc.timeout, utc, tc.awayStart, tc.awayEnd)
if !got.Equal(tc.want) {
t.Errorf("effectiveDeadline = %s, want %s", got, tc.want)
}
})
}
}
func TestMinutesOfDay(t *testing.T) {
got := minutesOfDay(time.Date(1, 1, 1, 7, 30, 0, 0, time.UTC))
if got != 450 {
t.Errorf("minutesOfDay(07:30) = %d, want 450", got)
}
}
func TestLoadLocationFallsBackToUTC(t *testing.T) {
if loadLocation("") != time.UTC {
t.Error("empty zone must fall back to UTC")
}
if loadLocation("Totally/Bogus") != time.UTC {
t.Error("unknown zone must fall back to UTC")
}
}