feat: gamemaster
This commit is contained in:
@@ -0,0 +1,360 @@
|
||||
package gamemaster
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
type runtimeEventPayloadExpectation struct {
|
||||
schemaName string
|
||||
eventTypeConst string
|
||||
required []string
|
||||
}
|
||||
|
||||
var expectedRuntimeEventPayloads = []runtimeEventPayloadExpectation{
|
||||
{
|
||||
schemaName: "RuntimeSnapshotUpdatePayload",
|
||||
eventTypeConst: "runtime_snapshot_update",
|
||||
required: []string{
|
||||
"event_type",
|
||||
"game_id",
|
||||
"current_turn",
|
||||
"runtime_status",
|
||||
"engine_health_summary",
|
||||
"player_turn_stats",
|
||||
"occurred_at_ms",
|
||||
},
|
||||
},
|
||||
{
|
||||
schemaName: "GameFinishedPayload",
|
||||
eventTypeConst: "game_finished",
|
||||
required: []string{
|
||||
"event_type",
|
||||
"game_id",
|
||||
"final_turn_number",
|
||||
"runtime_status",
|
||||
"player_turn_stats",
|
||||
"finished_at_ms",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
var expectedRuntimeStatusEnum = []string{
|
||||
"starting",
|
||||
"running",
|
||||
"generation_in_progress",
|
||||
"generation_failed",
|
||||
"stopped",
|
||||
"engine_unreachable",
|
||||
"finished",
|
||||
}
|
||||
|
||||
// TestRuntimeEventsAsyncAPISpecLoads verifies the spec parses as YAML and is
|
||||
// pinned to AsyncAPI 3.1.0.
|
||||
func TestRuntimeEventsAsyncAPISpecLoads(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
doc := loadAsyncAPISpec(t)
|
||||
require.Equal(t, "3.1.0", getStringValue(t, doc, "asyncapi"))
|
||||
}
|
||||
|
||||
// TestRuntimeEventsAsyncAPIChannel verifies the single channel address and
|
||||
// the two message references attached to it.
|
||||
func TestRuntimeEventsAsyncAPIChannel(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
doc := loadAsyncAPISpec(t)
|
||||
channel := getMapValue(t, doc, "channels", "lobbyEvents")
|
||||
|
||||
require.Equal(t, "gm:lobby_events", getStringValue(t, channel, "address"))
|
||||
|
||||
channelMessages := getMapValue(t, channel, "messages")
|
||||
require.ElementsMatch(t,
|
||||
[]string{"runtimeSnapshotUpdate", "gameFinished"},
|
||||
mapKeys(channelMessages))
|
||||
|
||||
require.Equal(t,
|
||||
"#/components/messages/RuntimeSnapshotUpdate",
|
||||
getStringValue(t, getMapValue(t, channelMessages, "runtimeSnapshotUpdate"), "$ref"))
|
||||
require.Equal(t,
|
||||
"#/components/messages/GameFinished",
|
||||
getStringValue(t, getMapValue(t, channelMessages, "gameFinished"), "$ref"))
|
||||
}
|
||||
|
||||
// TestRuntimeEventsAsyncAPIOperations verifies that each message has its own
|
||||
// `send` operation with the correct channel and message reference. Game
|
||||
// Master is the publisher; no `receive` operations exist on this stream.
|
||||
func TestRuntimeEventsAsyncAPIOperations(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
doc := loadAsyncAPISpec(t)
|
||||
operations := getMapValue(t, doc, "operations")
|
||||
|
||||
require.ElementsMatch(t,
|
||||
[]string{"publishRuntimeSnapshotUpdate", "publishGameFinished"},
|
||||
mapKeys(operations))
|
||||
|
||||
cases := []struct {
|
||||
operationName string
|
||||
messageKey string
|
||||
}{
|
||||
{"publishRuntimeSnapshotUpdate", "runtimeSnapshotUpdate"},
|
||||
{"publishGameFinished", "gameFinished"},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
tc := tc
|
||||
t.Run(tc.operationName, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
op := getMapValue(t, operations, tc.operationName)
|
||||
require.Equal(t, "send", getStringValue(t, op, "action"))
|
||||
require.Equal(t, "#/channels/lobbyEvents",
|
||||
getStringValue(t, getMapValue(t, op, "channel"), "$ref"))
|
||||
|
||||
messageRefs := getSliceValue(t, op, "messages")
|
||||
require.Len(t, messageRefs, 1, "%s must reference exactly one message", tc.operationName)
|
||||
|
||||
ref, ok := messageRefs[0].(map[string]any)
|
||||
require.True(t, ok, "%s message reference must be a map", tc.operationName)
|
||||
require.Equal(t,
|
||||
"#/channels/lobbyEvents/messages/"+tc.messageKey,
|
||||
getStringValue(t, ref, "$ref"))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestRuntimeEventsAsyncAPIMessageNames verifies that components.messages
|
||||
// contains exactly the two message names frozen by Stage 06.
|
||||
func TestRuntimeEventsAsyncAPIMessageNames(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
doc := loadAsyncAPISpec(t)
|
||||
messages := getMapValue(t, doc, "components", "messages")
|
||||
|
||||
require.ElementsMatch(t,
|
||||
[]string{"RuntimeSnapshotUpdate", "GameFinished"},
|
||||
mapKeys(messages))
|
||||
|
||||
for _, name := range []string{"RuntimeSnapshotUpdate", "GameFinished"} {
|
||||
message := getMapValue(t, messages, name)
|
||||
require.Equal(t, name, getStringValue(t, message, "name"),
|
||||
"message %s must declare its own name", name)
|
||||
require.Equal(t,
|
||||
"#/components/schemas/"+name+"Payload",
|
||||
getStringValue(t, getMapValue(t, message, "payload"), "$ref"),
|
||||
"message %s must reference its payload schema", name)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRuntimeEventsAsyncAPIPayloadFreeze verifies that each payload schema
|
||||
// has the expected required-field set, the correct `event_type` const, and
|
||||
// `additionalProperties: false`.
|
||||
func TestRuntimeEventsAsyncAPIPayloadFreeze(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
doc := loadAsyncAPISpec(t)
|
||||
schemas := getMapValue(t, doc, "components", "schemas")
|
||||
|
||||
for _, expectation := range expectedRuntimeEventPayloads {
|
||||
expectation := expectation
|
||||
t.Run(expectation.schemaName, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
payload := getMapValue(t, schemas, expectation.schemaName)
|
||||
|
||||
require.Equal(t, false, getScalarValue(t, payload, "additionalProperties"),
|
||||
"%s must reject unknown fields", expectation.schemaName)
|
||||
|
||||
require.ElementsMatch(t,
|
||||
toAnySlice(expectation.required),
|
||||
getSliceValue(t, payload, "required"),
|
||||
"%s required field set", expectation.schemaName)
|
||||
|
||||
properties := getMapValue(t, payload, "properties")
|
||||
|
||||
eventType := getMapValue(t, properties, "event_type")
|
||||
require.Equal(t, "string", getStringValue(t, eventType, "type"))
|
||||
require.Equal(t, expectation.eventTypeConst,
|
||||
getScalarValue(t, eventType, "const"),
|
||||
"%s.event_type const must be %q", expectation.schemaName, expectation.eventTypeConst)
|
||||
|
||||
runtimeStatus := getMapValue(t, properties, "runtime_status")
|
||||
require.Equal(t, "#/components/schemas/RuntimeStatus",
|
||||
getStringValue(t, runtimeStatus, "$ref"),
|
||||
"%s.runtime_status must reference RuntimeStatus", expectation.schemaName)
|
||||
|
||||
playerTurnStats := getMapValue(t, properties, "player_turn_stats")
|
||||
require.Equal(t, "array", getStringValue(t, playerTurnStats, "type"))
|
||||
require.Equal(t, "#/components/schemas/PlayerTurnStat",
|
||||
getStringValue(t, getMapValue(t, playerTurnStats, "items"), "$ref"),
|
||||
"%s.player_turn_stats items must reference PlayerTurnStat", expectation.schemaName)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestRuntimeEventsAsyncAPIPlayerTurnStat verifies the per-player stat
|
||||
// schema shape from gamemaster/README.md §Async Stream Contracts.
|
||||
func TestRuntimeEventsAsyncAPIPlayerTurnStat(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
doc := loadAsyncAPISpec(t)
|
||||
stat := getMapValue(t, doc, "components", "schemas", "PlayerTurnStat")
|
||||
|
||||
require.Equal(t, false, getScalarValue(t, stat, "additionalProperties"))
|
||||
require.ElementsMatch(t,
|
||||
[]any{"user_id", "planets", "population"},
|
||||
getSliceValue(t, stat, "required"))
|
||||
|
||||
properties := getMapValue(t, stat, "properties")
|
||||
require.Equal(t, "string", getStringValue(t, getMapValue(t, properties, "user_id"), "type"))
|
||||
require.Equal(t, "integer", getStringValue(t, getMapValue(t, properties, "planets"), "type"))
|
||||
require.Equal(t, "integer", getStringValue(t, getMapValue(t, properties, "population"), "type"))
|
||||
}
|
||||
|
||||
// TestRuntimeEventsAsyncAPIRuntimeStatusEnum verifies the RuntimeStatus
|
||||
// enum copied locally for the AsyncAPI surface contains the same seven
|
||||
// values as the OpenAPI surface.
|
||||
func TestRuntimeEventsAsyncAPIRuntimeStatusEnum(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
doc := loadAsyncAPISpec(t)
|
||||
schema := getMapValue(t, doc, "components", "schemas", "RuntimeStatus")
|
||||
|
||||
require.ElementsMatch(t, expectedRuntimeStatusEnum, getStringSlice(t, schema, "enum"))
|
||||
}
|
||||
|
||||
func loadAsyncAPISpec(t *testing.T) map[string]any {
|
||||
t.Helper()
|
||||
|
||||
payload := loadTextFile(t, filepath.Join("api", "runtime-events-asyncapi.yaml"))
|
||||
|
||||
var doc map[string]any
|
||||
if err := yaml.Unmarshal([]byte(payload), &doc); err != nil {
|
||||
require.Failf(t, "test failed", "decode spec: %v", err)
|
||||
}
|
||||
|
||||
return doc
|
||||
}
|
||||
|
||||
func loadTextFile(t *testing.T, relativePath string) string {
|
||||
t.Helper()
|
||||
|
||||
path := filepath.Join(moduleRoot(t), relativePath)
|
||||
payload, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
require.Failf(t, "test failed", "read file %s: %v", path, err)
|
||||
}
|
||||
|
||||
return string(payload)
|
||||
}
|
||||
|
||||
func moduleRoot(t *testing.T) string {
|
||||
t.Helper()
|
||||
|
||||
_, thisFile, _, ok := runtime.Caller(0)
|
||||
if !ok {
|
||||
require.FailNow(t, "runtime.Caller failed")
|
||||
}
|
||||
|
||||
return filepath.Dir(thisFile)
|
||||
}
|
||||
|
||||
func getMapValue(t *testing.T, value map[string]any, path ...string) map[string]any {
|
||||
t.Helper()
|
||||
|
||||
current := value
|
||||
for _, segment := range path {
|
||||
raw, ok := current[segment]
|
||||
if !ok {
|
||||
require.Failf(t, "test failed", "missing map key %s", segment)
|
||||
}
|
||||
next, ok := raw.(map[string]any)
|
||||
if !ok {
|
||||
require.Failf(t, "test failed", "value at %s is not a map", segment)
|
||||
}
|
||||
current = next
|
||||
}
|
||||
|
||||
return current
|
||||
}
|
||||
|
||||
func getStringValue(t *testing.T, value map[string]any, key string) string {
|
||||
t.Helper()
|
||||
|
||||
raw, ok := value[key]
|
||||
if !ok {
|
||||
require.Failf(t, "test failed", "missing key %s", key)
|
||||
}
|
||||
result, ok := raw.(string)
|
||||
if !ok {
|
||||
require.Failf(t, "test failed", "value at %s is not a string", key)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
func getStringSlice(t *testing.T, value map[string]any, key string) []string {
|
||||
t.Helper()
|
||||
|
||||
raw := getSliceValue(t, value, key)
|
||||
result := make([]string, 0, len(raw))
|
||||
for _, item := range raw {
|
||||
text, ok := item.(string)
|
||||
if !ok {
|
||||
require.Failf(t, "test failed", "value at %s is not a string slice", key)
|
||||
}
|
||||
result = append(result, text)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
func getScalarValue(t *testing.T, value map[string]any, key string) any {
|
||||
t.Helper()
|
||||
|
||||
raw, ok := value[key]
|
||||
if !ok {
|
||||
require.Failf(t, "test failed", "missing key %s", key)
|
||||
}
|
||||
|
||||
return raw
|
||||
}
|
||||
|
||||
func getSliceValue(t *testing.T, value map[string]any, key string) []any {
|
||||
t.Helper()
|
||||
|
||||
raw, ok := value[key]
|
||||
if !ok {
|
||||
require.Failf(t, "test failed", "missing key %s", key)
|
||||
}
|
||||
result, ok := raw.([]any)
|
||||
if !ok {
|
||||
require.Failf(t, "test failed", "value at %s is not a slice", key)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
func mapKeys(value map[string]any) []string {
|
||||
keys := make([]string, 0, len(value))
|
||||
for key := range value {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
|
||||
return keys
|
||||
}
|
||||
|
||||
func toAnySlice(values []string) []any {
|
||||
result := make([]any, 0, len(values))
|
||||
for _, value := range values {
|
||||
result = append(result, value)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
Reference in New Issue
Block a user