635f2fd9fc
- #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.')
111 lines
3.5 KiB
Go
111 lines
3.5 KiB
Go
package game
|
|
|
|
import (
|
|
"context"
|
|
"testing"
|
|
"time"
|
|
|
|
"go.opentelemetry.io/otel/attribute"
|
|
sdkmetric "go.opentelemetry.io/otel/sdk/metric"
|
|
"go.opentelemetry.io/otel/sdk/metric/metricdata"
|
|
|
|
"scrabble/backend/internal/engine"
|
|
)
|
|
|
|
// TestGameMetrics records each game instrument through a manual reader and asserts
|
|
// the counters carry the right "variant" attribute and the histograms observe.
|
|
func TestGameMetrics(t *testing.T) {
|
|
ctx := context.Background()
|
|
reader := sdkmetric.NewManualReader()
|
|
meter := sdkmetric.NewMeterProvider(sdkmetric.WithReader(reader)).Meter("test")
|
|
m := newGameMetrics(meter)
|
|
|
|
m.recordStarted(ctx, engine.VariantEnglish)
|
|
m.recordStarted(ctx, engine.VariantEnglish)
|
|
m.recordStarted(ctx, engine.VariantRussianScrabble)
|
|
m.recordAbandoned(ctx, engine.VariantErudit)
|
|
m.recordReplay(ctx, engine.VariantEnglish, time.Now().Add(-time.Millisecond))
|
|
m.recordValidate(ctx, engine.VariantRussianScrabble, time.Now().Add(-time.Millisecond))
|
|
m.recordMoveDuration(ctx, engine.VariantEnglish, 3, 5*time.Second)
|
|
m.recordMoveDuration(ctx, engine.VariantEnglish, 3, 0) // non-positive: dropped
|
|
|
|
var rm metricdata.ResourceMetrics
|
|
if err := reader.Collect(ctx, &rm); err != nil {
|
|
t.Fatalf("collect: %v", err)
|
|
}
|
|
|
|
started := counterByAttr(t, rm, "games_started_total", "variant")
|
|
if started["english"] != 2 || started["russian_scrabble"] != 1 {
|
|
t.Errorf("games_started_total = %v, want english:2 russian_scrabble:1", started)
|
|
}
|
|
if abandoned := counterByAttr(t, rm, "games_abandoned_total", "variant"); abandoned["erudit"] != 1 {
|
|
t.Errorf("games_abandoned_total = %v, want erudit:1", abandoned)
|
|
}
|
|
if c := histogramCount(t, rm, "game_replay_duration"); c != 1 {
|
|
t.Errorf("game_replay_duration observations = %d, want 1", c)
|
|
}
|
|
if c := histogramCount(t, rm, "game_move_validate_duration"); c != 1 {
|
|
t.Errorf("game_move_validate_duration observations = %d, want 1", c)
|
|
}
|
|
if c := histogramCount(t, rm, "game_move_duration"); c != 1 {
|
|
t.Errorf("game_move_duration observations = %d, want 1 (zero-duration dropped)", c)
|
|
}
|
|
}
|
|
|
|
// TestPhaseOf checks the move-ordinal to phase bucketing.
|
|
func TestPhaseOf(t *testing.T) {
|
|
cases := map[int]string{1: "opening", 8: "opening", 9: "middle", 20: "middle", 21: "endgame", 50: "endgame"}
|
|
for mc, want := range cases {
|
|
if got := phaseOf(mc); got != want {
|
|
t.Errorf("phaseOf(%d) = %q, want %q", mc, got, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// counterByAttr sums the int64 counter named name, grouped by the value of the
|
|
// attribute key attr.
|
|
func counterByAttr(t *testing.T, rm metricdata.ResourceMetrics, name, attr string) map[string]int64 {
|
|
t.Helper()
|
|
out := map[string]int64{}
|
|
for _, sm := range rm.ScopeMetrics {
|
|
for _, md := range sm.Metrics {
|
|
if md.Name != name {
|
|
continue
|
|
}
|
|
sum, ok := md.Data.(metricdata.Sum[int64])
|
|
if !ok {
|
|
t.Fatalf("%s is not an int64 sum", name)
|
|
}
|
|
for _, dp := range sum.DataPoints {
|
|
v, _ := dp.Attributes.Value(attribute.Key(attr))
|
|
out[v.AsString()] += dp.Value
|
|
}
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// histogramCount returns the total observation count of the float64 histogram
|
|
// named name.
|
|
func histogramCount(t *testing.T, rm metricdata.ResourceMetrics, name string) uint64 {
|
|
t.Helper()
|
|
for _, sm := range rm.ScopeMetrics {
|
|
for _, md := range sm.Metrics {
|
|
if md.Name != name {
|
|
continue
|
|
}
|
|
h, ok := md.Data.(metricdata.Histogram[float64])
|
|
if !ok {
|
|
t.Fatalf("%s is not a float64 histogram", name)
|
|
}
|
|
var n uint64
|
|
for _, dp := range h.DataPoints {
|
|
n += dp.Count
|
|
}
|
|
return n
|
|
}
|
|
}
|
|
t.Fatalf("%s not found", name)
|
|
return 0
|
|
}
|