361 lines
9.9 KiB
Go
361 lines
9.9 KiB
Go
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
|
|
}
|