Files
scrabble-game/loadtest/internal/moves/moves_test.go
T
Ilia Denisov aa137e3558
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 11s
CI / ui (pull_request) Successful in 38s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Failing after 3s
R2: load-test harness + contour resource observability
New scrabble/loadtest module (the pre-release stress harness): seeds 1000 guest +
10000 durable accounts with pre-created sessions directly in Postgres (token hash
matches backend/internal/session), drives virtual players through the edge protocol
(real 2-4p games assembled via invitations, mid-ranked legal moves generated locally
by the embedded scrabble-solver — the edge carries no board, so the client replays
history), plus nudge/chat/check-word/draft/profile/stats and a gateway-hammer that
verifies the rate limiter. Prints a trip-report summary (per-op latency percentiles,
result codes, live-event tally). Go unit tests cover the pure pieces; the DAWG-backed
move test runs under BACKEND_DICT_DIR.

Contour: add cAdvisor + postgres_exporter + a 'Scrabble - Resources' Grafana
dashboard and the two Prometheus scrape jobs, for the R2/R7 stress-run resource
baseline.

CI: gate ./loadtest/... (path filter + vet/build/test). Docs: TESTING, ARCHITECTURE,
project CLAUDE repo layout.
2026-06-09 23:45:24 +02:00

158 lines
4.7 KiB
Go

package moves
import (
"math/rand"
"os"
"strings"
"testing"
"gitea.iliadenisov.ru/developer/scrabble-solver/board"
"gitea.iliadenisov.ru/developer/scrabble-solver/rules"
"gitea.iliadenisov.ru/developer/scrabble-solver/scrabble"
"scrabble/loadtest/internal/edge"
)
// TestReplayBoardMatchesParse checks that replaying decoded history reproduces the
// exact board (positions, letters and blank flags) that board.Parse builds from the
// equivalent text grid, and that non-play records are ignored.
func TestReplayBoardMatchesParse(t *testing.T) {
rs := rules.English()
history := []edge.Move{
{Action: "pass"}, // must be ignored
{Action: "play", Tiles: []edge.Tile{
{Row: 7, Col: 7, Letter: "c"},
{Row: 7, Col: 8, Letter: "a"},
{Row: 7, Col: 9, Letter: "t"},
}},
{Action: "play", Tiles: []edge.Tile{
{Row: 7, Col: 10, Letter: "s", Blank: true}, // a blank standing for s
}},
}
got, err := replayBoard(rs, history)
if err != nil {
t.Fatalf("replayBoard: %v", err)
}
rows := make([]string, rs.Rows)
for i := range rows {
rows[i] = strings.Repeat(".", rs.Cols)
}
// row 7: cols 0-6 empty, cat at 7-9, an uppercase S (blank) at 10.
rows[7] = strings.Repeat(".", 7) + "cat" + "S" + strings.Repeat(".", rs.Cols-11)
want, err := board.Parse(rows, rs.Alphabet)
if err != nil {
t.Fatalf("board.Parse: %v", err)
}
for r := 0; r < rs.Rows; r++ {
for c := 0; c < rs.Cols; c++ {
if got.At(r, c) != want.At(r, c) {
t.Fatalf("cell (%d,%d): replay = %#x, parse = %#x", r, c, got.At(r, c), want.At(r, c))
}
}
}
}
// TestBuildRack checks the alphabet-index rack (255 a blank) is reconstructed faithfully.
func TestBuildRack(t *testing.T) {
rs := rules.English()
rk := buildRack(rs, []byte{0, 0, 2, blankIndex}) // a a c blank
if rk.Count(0) != 2 {
t.Errorf("count(a) = %d, want 2", rk.Count(0))
}
if rk.Count(2) != 1 {
t.Errorf("count(c) = %d, want 1", rk.Count(2))
}
if rk.Blanks() != 1 {
t.Errorf("blanks = %d, want 1", rk.Blanks())
}
if rk.Total() != 4 {
t.Errorf("total = %d, want 4", rk.Total())
}
}
// TestMidRanked checks the pick always lands in the middle third of a ranked list and
// that tiny lists yield their lowest-scoring move.
func TestMidRanked(t *testing.T) {
ms := make([]scrabble.Move, 9) // scores 100..92, index i has score 100-i
for i := range ms {
ms[i] = scrabble.Move{Score: 100 - i}
}
rng := rand.New(rand.NewSource(1))
for n := 0; n < 100; n++ {
idx := 100 - midRanked(ms, rng).Score // recover the index from the score
if idx < 3 || idx >= 6 {
t.Fatalf("picked index %d outside middle third [3,6)", idx)
}
}
if got := midRanked([]scrabble.Move{{Score: 5}}, rng).Score; got != 5 {
t.Errorf("n=1 pick score = %d, want 5", got)
}
if got := midRanked([]scrabble.Move{{Score: 9}, {Score: 4}}, rng).Score; got != 4 {
t.Errorf("n=2 pick score = %d, want 4 (lower-scoring)", got)
}
}
// TestToPlayTiles checks the solver-placement to edge-tile mapping, including blanks.
func TestToPlayTiles(t *testing.T) {
tiles := toPlayTiles([]scrabble.Placement{
{Row: 1, Col: 2, Letter: 5},
{Row: 1, Col: 3, Letter: 255, Blank: true},
})
want := []edge.PlayTile{
{Row: 1, Col: 2, Letter: 5},
{Row: 1, Col: 3, Letter: 255, Blank: true},
}
if len(tiles) != len(want) {
t.Fatalf("len = %d, want %d", len(tiles), len(want))
}
for i := range want {
if tiles[i] != want[i] {
t.Errorf("tile %d = %+v, want %+v", i, tiles[i], want[i])
}
}
}
// TestPickUnknownVariant rejects a variant the registry does not hold.
func TestPickUnknownVariant(t *testing.T) {
reg := &Registry{engines: map[string]*engine{}}
if _, err := reg.Pick("nope", nil, nil, 0, rand.New(rand.NewSource(1))); err == nil {
t.Fatal("want error for an unknown variant")
}
}
// TestPickWithDawg drives the full path against the committed DAWGs when they are
// available (BACKEND_DICT_DIR, as the engine tests use); it generates a first-move
// play from a productive rack.
func TestPickWithDawg(t *testing.T) {
dir := os.Getenv("BACKEND_DICT_DIR")
if dir == "" {
t.Skip("BACKEND_DICT_DIR not set; skipping DAWG-backed test")
}
reg, err := Open(dir)
if err != nil {
t.Fatalf("Open(%s): %v", dir, err)
}
defer reg.Close()
rng := rand.New(rand.NewSource(1))
rack := []byte{2, 0, 19, 18, 4, 17, 13} // c a t s e r n — a productive English rack
act, err := reg.Pick("scrabble_en", nil, rack, 90, rng)
if err != nil {
t.Fatalf("Pick: %v", err)
}
switch act.Kind {
case "play":
if len(act.Tiles) == 0 {
t.Error("play action has no tiles")
}
if act.Dir != "H" && act.Dir != "V" {
t.Errorf("dir = %q, want H or V", act.Dir)
}
case "exchange", "pass":
// acceptable when the rack has no legal first move
default:
t.Errorf("unexpected action kind %q", act.Kind)
}
}