Files
galaxy-game/gamemaster/contract_asyncapi_test.go
T
2026-05-03 07:59:03 +02:00

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
}