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 }