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.')
This commit is contained in:
@@ -0,0 +1,119 @@
|
||||
package robot
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"scrabble/backend/internal/engine"
|
||||
)
|
||||
|
||||
// TestComposeName covers the three rendering forms, including a Cyrillic initial.
|
||||
func TestComposeName(t *testing.T) {
|
||||
cases := []struct {
|
||||
first, surname string
|
||||
form int
|
||||
want string
|
||||
}{
|
||||
{"Anna", "Carter", nameFormFirstOnly, "Anna"},
|
||||
{"Anna", "Carter", nameFormInitial, "Anna C."},
|
||||
{"Anna", "Carter", nameFormFull, "Anna Carter"},
|
||||
{"Маша", "Суханова", nameFormInitial, "Маша С."},
|
||||
{"Маша", "Суханова", nameFormFull, "Маша Суханова"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := composeName(c.first, c.surname, c.form); got != c.want {
|
||||
t.Errorf("composeName(%q,%q,%d) = %q, want %q", c.first, c.surname, c.form, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestNamePoolsPaired checks the full and colloquial first-name pools line up by
|
||||
// index (so a slot's gender and person are consistent) and the surname forms differ.
|
||||
func TestNamePoolsPaired(t *testing.T) {
|
||||
if len(firstNamesFullEN) != robotPoolSize || len(firstNamesShortEN) != robotPoolSize {
|
||||
t.Fatalf("EN first-name pools must each hold %d names", robotPoolSize)
|
||||
}
|
||||
if len(firstNamesFullRU) != robotPoolSize || len(firstNamesShortRU) != robotPoolSize {
|
||||
t.Fatalf("RU first-name pools must each hold %d names", robotPoolSize)
|
||||
}
|
||||
for i := range firstNamesFullRU {
|
||||
if firstNamesFullRU[i].female != firstNamesShortRU[i].female {
|
||||
t.Errorf("RU pair %d disagrees on gender: %q vs %q", i, firstNamesFullRU[i].name, firstNamesShortRU[i].name)
|
||||
}
|
||||
}
|
||||
for _, sp := range surnamesRU {
|
||||
if sp.m == sp.f {
|
||||
t.Errorf("RU surname forms should differ: %q", sp.m)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestRobotDisplayNames checks the generated pools are the right size, non-empty and
|
||||
// deterministic — durable robot accounts must keep a stable name across restarts.
|
||||
func TestRobotDisplayNames(t *testing.T) {
|
||||
en1, en2 := robotDisplayNamesEN(), robotDisplayNamesEN()
|
||||
ru1, ru2 := robotDisplayNamesRU(), robotDisplayNamesRU()
|
||||
if len(en1) != robotPoolSize || len(ru1) != robotPoolSize {
|
||||
t.Fatalf("pool sizes en=%d ru=%d, want %d", len(en1), len(ru1), robotPoolSize)
|
||||
}
|
||||
for i := range en1 {
|
||||
if en1[i] != en2[i] || ru1[i] != ru2[i] {
|
||||
t.Fatalf("pool not deterministic at %d: en %q/%q ru %q/%q", i, en1[i], en2[i], ru1[i], ru2[i])
|
||||
}
|
||||
if en1[i] == "" || ru1[i] == "" {
|
||||
t.Fatalf("empty composed name at index %d", i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestPickVariantRouting checks English games draw the Latin pool and Russian games
|
||||
// draw mostly Russian names with a Latin minority.
|
||||
func TestPickVariantRouting(t *testing.T) {
|
||||
enID, ruID := uuid.New(), uuid.New()
|
||||
s := &Service{poolEN: []uuid.UUID{enID}, poolRU: []uuid.UUID{ruID}}
|
||||
for i := 0; i < 200; i++ {
|
||||
if got, err := s.Pick(engine.VariantEnglish); err != nil || got != enID {
|
||||
t.Fatalf("english Pick = (%v, %v), want (%v, nil)", got, err, enID)
|
||||
}
|
||||
}
|
||||
var en, ru int
|
||||
for i := 0; i < 4000; i++ {
|
||||
got, err := s.Pick(engine.VariantRussianScrabble)
|
||||
if err != nil {
|
||||
t.Fatalf("russian Pick: %v", err)
|
||||
}
|
||||
switch got {
|
||||
case enID:
|
||||
en++
|
||||
case ruID:
|
||||
ru++
|
||||
}
|
||||
}
|
||||
if ru <= en {
|
||||
t.Errorf("russian names should dominate a Russian game: ru=%d en=%d", ru, en)
|
||||
}
|
||||
if en == 0 {
|
||||
t.Errorf("some Latin names should appear in Russian games (got 0 of 4000)")
|
||||
}
|
||||
// Эрудит routes like Russian Scrabble.
|
||||
if _, err := s.Pick(engine.VariantErudit); err != nil {
|
||||
t.Errorf("erudit Pick: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPickFallback checks an empty side falls back to the other pool and an empty pool
|
||||
// errors.
|
||||
func TestPickFallback(t *testing.T) {
|
||||
id := uuid.New()
|
||||
if got, err := (&Service{poolEN: []uuid.UUID{id}}).Pick(engine.VariantRussianScrabble); err != nil || got != id {
|
||||
t.Errorf("russian fallback to EN = (%v, %v), want (%v, nil)", got, err, id)
|
||||
}
|
||||
if got, err := (&Service{poolRU: []uuid.UUID{id}}).Pick(engine.VariantEnglish); err != nil || got != id {
|
||||
t.Errorf("english fallback to RU = (%v, %v), want (%v, nil)", got, err, id)
|
||||
}
|
||||
if _, err := (&Service{}).Pick(engine.VariantEnglish); !errors.Is(err, ErrNoRobotAvailable) {
|
||||
t.Errorf("empty pool err = %v, want ErrNoRobotAvailable", err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user