package integration import ( "fmt" "os" "path/filepath" "regexp" "sort" "strings" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "gopkg.in/yaml.v3" ) // TestKMPModelSchemaContract validates that KMP @Serializable model classes match // the OpenAPI spec schemas field-by-field. It checks: // - Every spec field has a matching KMP property (via @SerialName or property name) // - Types are compatible (spec string→String, integer→Int, number→Double, etc.) // - Nullability is compatible (spec nullable:true → Kotlin Type?) // // This catches schema drift when the Go API evolves the spec but KMP models aren't updated. func TestKMPModelSchemaContract(t *testing.T) { specSchemas := loadSpecSchemas(t) kmpModels := loadKMPModels(t) require.NotEmpty(t, specSchemas, "should parse schemas from openapi.yaml") require.NotEmpty(t, kmpModels, "should parse @Serializable classes from KMP models") t.Logf("Spec schemas: %d, KMP models: %d", len(specSchemas), len(kmpModels)) // ------------------------------------------------------------------- // Direction 1: spec → KMP — every mapped spec schema field should exist in KMP // ------------------------------------------------------------------- t.Run("spec fields exist in KMP models", func(t *testing.T) { for specName, mapping := range schemaToKMPClass { schema, ok := specSchemas[specName] if !ok { t.Errorf("spec schema %q not found in openapi.yaml", specName) continue } kmpClass, ok := kmpModels[mapping.kmpClassName] if !ok { t.Errorf("KMP class %q (mapped from spec %q) not found in model files", mapping.kmpClassName, specName) continue } t.Run(specName+"→"+mapping.kmpClassName, func(t *testing.T) { // Build KMP field index by JSON name kmpFieldsByJSON := make(map[string]kmpField) for _, f := range kmpClass.fields { kmpFieldsByJSON[f.jsonName] = f } for fieldName, specField := range schema.properties { overrideKey := specName + "." + fieldName // Skip fields known to be absent from KMP if _, ok := knownMissingFromKMP[overrideKey]; ok { continue } kf, found := kmpFieldsByJSON[fieldName] if !found { t.Errorf("spec field %q not found in KMP class %s", fieldName, mapping.kmpClassName) continue } // Type check (unless overridden) if _, ok := knownTypeOverrides[overrideKey]; !ok { expectedKotlin := mapSpecTypeToKotlin(specField) actualKotlin := normalizeKotlinType(kf.kotlinType) if !typesCompatible(expectedKotlin, actualKotlin) { t.Errorf("type mismatch: %s.%s — spec %s(%s) → expected Kotlin %q, got %q", mapping.kmpClassName, fieldName, specField.typeName, specField.format, expectedKotlin, actualKotlin) } } // Nullability: if spec says nullable, KMP must allow it if specField.nullable && !kf.nullable && !specField.isRef && !specField.isArray { if _, ok := knownTypeOverrides[overrideKey]; !ok { t.Errorf("nullability mismatch: %s.%s — spec says nullable, KMP type %s is non-nullable", mapping.kmpClassName, fieldName, kf.kotlinType) } } } }) } }) // ------------------------------------------------------------------- // Direction 2: KMP → spec — KMP fields should exist in spec (or be documented) // ------------------------------------------------------------------- t.Run("KMP fields exist in spec", func(t *testing.T) { for specName, mapping := range schemaToKMPClass { schema, ok := specSchemas[specName] if !ok { continue } kmpClass, ok := kmpModels[mapping.kmpClassName] if !ok { continue } t.Run(mapping.kmpClassName+"→"+specName, func(t *testing.T) { for _, kf := range kmpClass.fields { overrideKey := specName + "." + kf.jsonName if _, ok := knownExtraInKMP[overrideKey]; ok { continue } // Skip private backing fields (e.g., _verified) if strings.HasPrefix(kf.propertyName, "_") { continue } if _, found := schema.properties[kf.jsonName]; !found { t.Errorf("KMP field %s.%s (json: %q) not in spec schema %s", mapping.kmpClassName, kf.propertyName, kf.jsonName, specName) } } }) } }) // ------------------------------------------------------------------- // Direction 3: all spec schemas should be mapped (or excluded) // ------------------------------------------------------------------- t.Run("all spec schemas mapped", func(t *testing.T) { var unmapped []string for name := range specSchemas { if _, ok := schemaToKMPClass[name]; ok { continue } if _, ok := excludedSchemas[name]; ok { continue } unmapped = append(unmapped, name) } sort.Strings(unmapped) if len(unmapped) > 0 { t.Errorf("OpenAPI schemas without KMP mapping:\n %s\nAdd to schemaToKMPClass or excludedSchemas.", strings.Join(unmapped, "\n ")) } }) } // ========================================================================== // Schema ↔ KMP class mapping // ========================================================================== type classMapping struct { kmpClassName string } var schemaToKMPClass = map[string]classMapping{ // Auth "LoginRequest": {kmpClassName: "LoginRequest"}, "RegisterRequest": {kmpClassName: "RegisterRequest"}, "ForgotPasswordRequest": {kmpClassName: "ForgotPasswordRequest"}, "VerifyResetCodeRequest": {kmpClassName: "VerifyResetCodeRequest"}, "ResetPasswordRequest": {kmpClassName: "ResetPasswordRequest"}, "UpdateProfileRequest": {kmpClassName: "UpdateProfileRequest"}, "VerifyEmailRequest": {kmpClassName: "VerifyEmailRequest"}, "AppleSignInRequest": {kmpClassName: "AppleSignInRequest"}, "GoogleSignInRequest": {kmpClassName: "GoogleSignInRequest"}, "UserResponse": {kmpClassName: "User"}, "UserProfileResponse": {kmpClassName: "UserProfile"}, "LoginResponse": {kmpClassName: "AuthResponse"}, "RegisterResponse": {kmpClassName: "RegisterResponse"}, "SocialSignInResponse": {kmpClassName: "AppleSignInResponse"}, // Same shape "VerifyEmailResponse": {kmpClassName: "VerifyEmailResponse"}, "VerifyResetCodeResponse": {kmpClassName: "VerifyResetCodeResponse"}, // Lookups "ResidenceTypeResponse": {kmpClassName: "ResidenceType"}, "TaskCategoryResponse": {kmpClassName: "TaskCategory"}, "TaskPriorityResponse": {kmpClassName: "TaskPriority"}, "TaskFrequencyResponse": {kmpClassName: "TaskFrequency"}, "ContractorSpecialtyResponse": {kmpClassName: "ContractorSpecialty"}, "SeededDataResponse": {kmpClassName: "SeededDataResponse"}, "TaskTemplateResponse": {kmpClassName: "TaskTemplate"}, "TaskTemplateCategoryGroup": {kmpClassName: "TaskTemplateCategoryGroup"}, "TaskTemplatesGroupedResponse": {kmpClassName: "TaskTemplatesGroupedResponse"}, // Residence "CreateResidenceRequest": {kmpClassName: "ResidenceCreateRequest"}, "UpdateResidenceRequest": {kmpClassName: "ResidenceUpdateRequest"}, "JoinWithCodeRequest": {kmpClassName: "JoinResidenceRequest"}, "GenerateShareCodeRequest": {kmpClassName: "GenerateShareCodeRequest"}, "ResidenceUserResponse": {kmpClassName: "ResidenceUserResponse"}, "ResidenceResponse": {kmpClassName: "ResidenceResponse"}, "TotalSummary": {kmpClassName: "TotalSummary"}, "MyResidencesResponse": {kmpClassName: "MyResidencesResponse"}, "ShareCodeResponse": {kmpClassName: "ShareCodeResponse"}, "JoinResidenceResponse": {kmpClassName: "JoinResidenceResponse"}, "GenerateShareCodeResponse": {kmpClassName: "GenerateShareCodeResponse"}, // Task "CreateTaskRequest": {kmpClassName: "TaskCreateRequest"}, "UpdateTaskRequest": {kmpClassName: "TaskUpdateRequest"}, "TaskUserResponse": {kmpClassName: "TaskUserResponse"}, "TaskResponse": {kmpClassName: "TaskResponse"}, "KanbanColumnResponse": {kmpClassName: "TaskColumn"}, "KanbanBoardResponse": {kmpClassName: "TaskColumnsResponse"}, // Task Completion "CreateTaskCompletionRequest": {kmpClassName: "TaskCompletionCreateRequest"}, "TaskCompletionImageResponse": {kmpClassName: "TaskCompletionImage"}, "TaskCompletionResponse": {kmpClassName: "TaskCompletionResponse"}, // Contractor "CreateContractorRequest": {kmpClassName: "ContractorCreateRequest"}, "UpdateContractorRequest": {kmpClassName: "ContractorUpdateRequest"}, "ContractorResponse": {kmpClassName: "Contractor"}, // Document "CreateDocumentRequest": {kmpClassName: "DocumentCreateRequest"}, "UpdateDocumentRequest": {kmpClassName: "DocumentUpdateRequest"}, "DocumentImageResponse": {kmpClassName: "DocumentImage"}, "DocumentResponse": {kmpClassName: "Document"}, // Notification "RegisterDeviceRequest": {kmpClassName: "DeviceRegistrationRequest"}, "DeviceResponse": {kmpClassName: "DeviceRegistrationResponse"}, "NotificationPreference": {kmpClassName: "NotificationPreference"}, "UpdatePreferencesRequest": {kmpClassName: "UpdateNotificationPreferencesRequest"}, "Notification": {kmpClassName: "Notification"}, "NotificationListResponse": {kmpClassName: "NotificationListResponse"}, // Subscription "SubscriptionStatusResponse": {kmpClassName: "SubscriptionStatus"}, "UsageResponse": {kmpClassName: "UsageStats"}, "TierLimitsClientResponse": {kmpClassName: "TierLimits"}, "FeatureBenefit": {kmpClassName: "FeatureBenefit"}, "Promotion": {kmpClassName: "Promotion"}, // Common "ErrorResponse": {kmpClassName: "ErrorResponse"}, "MessageResponse": {kmpClassName: "MessageResponse"}, } // excludedSchemas are spec schemas intentionally not mapped to KMP classes. var excludedSchemas = map[string]string{ "TaskWithSummaryResponse": "KMP uses generic WithSummaryResponse", "DeleteWithSummaryResponse": "KMP uses generic WithSummaryResponse", "ResidenceWithSummaryResponse": "KMP uses generic WithSummaryResponse", "ResidenceDeleteWithSummaryResponse": "KMP uses generic WithSummaryResponse", "TaskCompletionWithSummaryResponse": "KMP uses generic WithSummaryResponse", "ProcessPurchaseRequest": "KMP splits into platform-specific requests", "CurrentUserResponse": "KMP unifies into User class", "DocumentType": "Enum — KMP uses DocumentType enum class", "NotificationType": "Enum — KMP uses String", "ToggleFavoriteResponse": "Simple message+bool, not worth a dedicated mapping", "SharePackageResponse": "Used for .casera file sharing, handled by SharedContractor", "UnregisterDeviceRequest": "Simple oneoff request", "UpdateTaskCompletionRequest": "Not yet used in KMP", "SubscriptionResponse": "Inline in purchase/restore handler — KMP maps via VerificationResponse.subscription", "UpgradeTriggerResponse": "Shape differs from KMP UpgradeTriggerData", "UploadResult": "Handled inline in upload response parsing", } // knownTypeOverrides documents intentional type differences. var knownTypeOverrides = map[string]string{ // Spec says string (decimal), KMP uses Double for form binding "TaskResponse.estimated_cost": "KMP uses Double for numeric form binding", "TaskResponse.actual_cost": "KMP uses Double for numeric form binding", "CreateTaskRequest.estimated_cost": "KMP uses Double for numeric form binding", "UpdateTaskRequest.estimated_cost": "KMP uses Double for numeric form binding", "UpdateTaskRequest.actual_cost": "KMP uses Double for numeric form binding", "ResidenceResponse.bathrooms": "KMP uses Double for numeric form binding", "ResidenceResponse.lot_size": "KMP uses Double for numeric form binding", "ResidenceResponse.purchase_price": "KMP uses Double for numeric form binding", "CreateResidenceRequest.bathrooms": "KMP uses Double for numeric form binding", "CreateResidenceRequest.lot_size": "KMP uses Double for numeric form binding", "CreateResidenceRequest.purchase_price": "KMP uses Double for numeric form binding", "UpdateResidenceRequest.bathrooms": "KMP uses Double for numeric form binding", "UpdateResidenceRequest.lot_size": "KMP uses Double for numeric form binding", "UpdateResidenceRequest.purchase_price": "KMP uses Double for numeric form binding", "CreateTaskCompletionRequest.actual_cost": "KMP uses Double for numeric form binding", "TaskCompletionResponse.actual_cost": "KMP uses Double for numeric form binding", // Spec says nullable Boolean, KMP uses non-nullable Boolean (defaults to false) "CreateContractorRequest.is_favorite": "KMP defaults is_favorite to false, not nullable", // Spec uses inline object for created_by, KMP uses typed classes "DocumentResponse.created_by": "Spec uses inline object, KMP uses DocumentUser typed class", "ContractorResponse.created_by": "Spec uses inline object, KMP uses ContractorUser typed class", // Spec says string (JSON), KMP uses Map "Notification.data": "KMP deserializes JSON string into Map", // Spec uses $ref to enum, KMP uses String "CreateDocumentRequest.document_type": "KMP uses String; spec uses DocumentType $ref", "UpdateDocumentRequest.document_type": "KMP uses String; spec uses DocumentType $ref", "Notification.notification_type": "KMP uses String; spec uses NotificationType $ref", // Spec has number/double, KMP has Double — these are actually compatible // but the parser sees "number" vs "Double" which the type checker handles } // knownMissingFromKMP: spec fields intentionally absent from KMP. var knownMissingFromKMP = map[string]string{ "ErrorResponse.details": "KMP uses 'errors' field with different type", "TaskTemplateResponse.created_at": "KMP doesn't use template timestamps", "TaskTemplateResponse.updated_at": "KMP doesn't use template timestamps", "Notification.user_id": "KMP doesn't need user_id on notifications", "Notification.error_message": "KMP doesn't surface notification error messages", "Notification.updated_at": "KMP doesn't use notification updated_at", "NotificationPreference.id": "KMP doesn't need preference record ID", "NotificationPreference.user_id": "KMP doesn't need user_id on preferences", "FeatureBenefit.id": "KMP doesn't use benefit record ID", "FeatureBenefit.display_order": "KMP doesn't use benefit display order", "FeatureBenefit.is_active": "KMP doesn't filter by active status", "Promotion.id": "KMP uses promotion_id string instead", "Promotion.start_date": "KMP doesn't filter by promotion dates", "Promotion.end_date": "KMP doesn't filter by promotion dates", "Promotion.target_tier": "KMP doesn't filter by target tier", "Promotion.is_active": "KMP doesn't filter by active status", "LoginRequest.email": "Spec allows email login, KMP only sends username", // Document create/update file fields — KMP handles file upload via multipart, not JSON "CreateDocumentRequest.file_name": "KMP handles file upload via multipart, not JSON fields", "CreateDocumentRequest.file_size": "KMP handles file upload via multipart, not JSON fields", "CreateDocumentRequest.mime_type": "KMP handles file upload via multipart, not JSON fields", "CreateDocumentRequest.file_url": "KMP handles file upload via multipart, not JSON fields", "UpdateDocumentRequest.file_name": "KMP handles file upload via multipart, not JSON fields", "UpdateDocumentRequest.file_size": "KMP handles file upload via multipart, not JSON fields", "UpdateDocumentRequest.mime_type": "KMP handles file upload via multipart, not JSON fields", "UpdateDocumentRequest.file_url": "KMP handles file upload via multipart, not JSON fields", } // knownExtraInKMP: KMP fields not in the spec (client-side additions). var knownExtraInKMP = map[string]string{ // Document client-side fields "DocumentResponse.category": "Client-side field for UI grouping", "DocumentResponse.tags": "Client-side field", "DocumentResponse.notes": "Client-side field", "DocumentResponse.item_name": "Client-side warranty field", "DocumentResponse.provider": "Client-side warranty field", "DocumentResponse.provider_contact": "Client-side warranty field", "DocumentResponse.claim_phone": "Client-side warranty field", "DocumentResponse.claim_email": "Client-side warranty field", "DocumentResponse.claim_website": "Client-side warranty field", "DocumentResponse.start_date": "Client-side warranty field", "DocumentResponse.days_until_expiration": "Client-side computed field", "DocumentResponse.warranty_status": "Client-side computed field", // DocumentImage extra fields "DocumentImageResponse.uploaded_at": "KMP has uploaded_at for display", // TaskCompletionImage extra fields "TaskCompletionImageResponse.uploaded_at": "KMP has uploaded_at for display", // TaskResponse completions array (included in kanban response) "TaskResponse.completions": "KMP includes completions array for kanban", "TaskResponse.custom_interval_days": "KMP supports custom frequency intervals", // ErrorResponse: KMP has 'errors', 'status_code', 'detail' not in spec "ErrorResponse.errors": "KMP error response includes field-level errors map", "ErrorResponse.status_code": "KMP includes HTTP status code", "ErrorResponse.detail": "KMP error response includes detail field for validation errors", // User: KMP has 'profile' field, spec splits into UserResponse + CurrentUserResponse "UserResponse.profile": "KMP User unifies UserResponse + CurrentUserResponse; profile is on CurrentUserResponse", // Contractor addedBy alias "ContractorResponse.added_by": "KMP has added_by alias for created_by_id", // SeededDataResponse: KMP field types differ (direct objects vs Response wrappers) // These are compatible at JSON level but the type names differ } // ========================================================================== // OpenAPI spec parsing // ========================================================================== type specSchema struct { properties map[string]specField } type specField struct { typeName string format string nullable bool isRef bool isArray bool hasAdditionalProperties bool } func loadSpecSchemas(t *testing.T) map[string]specSchema { t.Helper() data, err := os.ReadFile("../../docs/openapi.yaml") require.NoError(t, err, "Failed to read openapi.yaml") var doc struct { Components struct { Schemas map[string]yaml.Node `yaml:"schemas"` } `yaml:"components"` } require.NoError(t, yaml.Unmarshal(data, &doc), "Failed to parse openapi.yaml") result := make(map[string]specSchema) for name, node := range doc.Components.Schemas { schema := parseSchemaNode(&node) result[name] = schema } return result } func parseSchemaNode(node *yaml.Node) specSchema { s := specSchema{properties: make(map[string]specField)} if node.Kind != yaml.MappingNode { return s } // Find "properties" key in the mapping for i := 0; i < len(node.Content)-1; i += 2 { key := node.Content[i] val := node.Content[i+1] if key.Value == "properties" && val.Kind == yaml.MappingNode { // Parse each property for j := 0; j < len(val.Content)-1; j += 2 { propName := val.Content[j].Value propNode := val.Content[j+1] s.properties[propName] = parseFieldNode(propNode) } } } return s } func parseFieldNode(node *yaml.Node) specField { f := specField{} if node.Kind != yaml.MappingNode { return f } for i := 0; i < len(node.Content)-1; i += 2 { key := node.Content[i].Value val := node.Content[i+1] switch key { case "type": f.typeName = val.Value case "format": f.format = val.Value case "nullable": f.nullable = val.Value == "true" case "$ref": f.isRef = true case "items": // Array items — check if array type case "additionalProperties": f.hasAdditionalProperties = true } if key == "type" && val.Value == "array" { f.isArray = true } } return f } // ========================================================================== // KMP Kotlin model parsing // ========================================================================== type kmpModel struct { className string fields []kmpField } type kmpField struct { propertyName string jsonName string // from @SerialName or property name kotlinType string nullable bool } // Regex patterns for parsing Kotlin data classes var ( // Match: @Serializable\ndata class ClassName( reSerializableClass = regexp.MustCompile(`@Serializable\s*\n\s*data\s+class\s+(\w+)\s*\(`) // Match: @SerialName("json_name") val propName: Type // or: @SerialName("json_name") private val propName: Type reSerialNameField = regexp.MustCompile(`@SerialName\("([^"]+)"\)\s*(?:private\s+)?val\s+(\w+)\s*:\s*([^\n=,)]+)`) // Match: val propName: Type (without @SerialName) rePlainField = regexp.MustCompile(`(?:^|\n)\s+val\s+(\w+)\s*:\s*([^\n=,)]+)`) ) func loadKMPModels(t *testing.T) map[string]kmpModel { t.Helper() modelsDir := filepath.Join("..", "..", "..", "MyCribKMM", "composeApp", "src", "commonMain", "kotlin", "com", "example", "casera", "models") info, err := os.Stat(modelsDir) if os.IsNotExist(err) { t.Skipf("KMP models directory not found at %s", modelsDir) } require.NoError(t, err) require.True(t, info.IsDir()) matches, err := filepath.Glob(filepath.Join(modelsDir, "*.kt")) require.NoError(t, err) require.NotEmpty(t, matches) result := make(map[string]kmpModel) for _, file := range matches { data, err := os.ReadFile(file) require.NoError(t, err) content := string(data) models := parseKotlinModels(content) for _, m := range models { result[m.className] = m } } return result } func parseKotlinModels(content string) []kmpModel { var models []kmpModel // Find all @Serializable data class declarations classMatches := reSerializableClass.FindAllStringSubmatchIndex(content, -1) for _, loc := range classMatches { className := content[loc[2]:loc[3]] classStart := loc[0] // Find the constructor body (from opening ( to matching ) ) openParen := strings.Index(content[classStart:], "(") if openParen < 0 { continue } openParen += classStart closeParen := findMatchingParen(content, openParen) if closeParen < 0 { continue } constructorBody := content[openParen+1 : closeParen] // Parse fields from constructor fields := parseConstructorFields(constructorBody) models = append(models, kmpModel{ className: className, fields: fields, }) } return models } func parseConstructorFields(body string) []kmpField { var fields []kmpField // Split by lines and parse each val declaration lines := strings.Split(body, "\n") for i := 0; i < len(lines); i++ { line := strings.TrimSpace(lines[i]) // Check for @SerialName annotation if strings.Contains(line, "@SerialName(") { // May be on same line as val, or next line combined := line // If val is not on this line, combine with next if !strings.Contains(line, "val ") && i+1 < len(lines) { i++ combined = line + " " + strings.TrimSpace(lines[i]) } match := reSerialNameField.FindStringSubmatch(combined) if match != nil { jsonName := match[1] propName := match[2] kotlinType := strings.TrimSpace(match[3]) nullable := strings.HasSuffix(kotlinType, "?") // Clean up type: remove trailing comma, default value kotlinType = cleanKotlinType(kotlinType) fields = append(fields, kmpField{ propertyName: propName, jsonName: jsonName, kotlinType: kotlinType, nullable: nullable, }) } continue } // Check for plain val (no @SerialName) if strings.Contains(line, "val ") && !strings.Contains(line, "get()") { // Skip computed properties (have get() = ...) match := rePlainField.FindStringSubmatch("\n" + line) if match != nil { propName := match[1] kotlinType := strings.TrimSpace(match[2]) nullable := strings.HasSuffix(kotlinType, "?") kotlinType = cleanKotlinType(kotlinType) fields = append(fields, kmpField{ propertyName: propName, jsonName: propName, // No @SerialName, so JSON name = property name kotlinType: kotlinType, nullable: nullable, }) } } } return fields } func cleanKotlinType(t string) string { // Remove trailing comma t = strings.TrimSuffix(strings.TrimSpace(t), ",") // Remove default value assignment if idx := strings.Index(t, " ="); idx > 0 { t = strings.TrimSpace(t[:idx]) } // Remove trailing ? for the clean type name // (but we already captured nullable separately) return strings.TrimSpace(t) } func findMatchingParen(s string, openIdx int) int { depth := 0 for i := openIdx; i < len(s); i++ { switch s[i] { case '(': depth++ case ')': depth-- if depth == 0 { return i } } } return -1 } // ========================================================================== // Type mapping and comparison // ========================================================================== func mapSpecTypeToKotlin(f specField) string { if f.isArray { return "List" } if f.isRef { return "Object" // Any object reference } if f.hasAdditionalProperties { return "Map" } switch f.typeName { case "string": return "String" case "integer": if f.format == "int64" { return "Long" } return "Int" case "number": return "Double" case "boolean": return "Boolean" case "object": return "Map" default: return "Any" } } func normalizeKotlinType(t string) string { // Remove nullable marker t = strings.TrimSuffix(t, "?") // Extract base type from generics if idx := strings.Index(t, "<"); idx > 0 { t = t[:idx] } return t } func typesCompatible(expected, actual string) bool { if expected == actual { return true } // $ref matches any object type if expected == "Object" { return true } // Long ↔ Int is acceptable for most API integers if expected == "Long" && actual == "Int" { return true } if expected == "Int" && actual == "Long" { return true } return false } // ========================================================================== // Summary test — prints a nice overview // ========================================================================== func TestKMPModelContractSummary(t *testing.T) { specSchemas := loadSpecSchemas(t) kmpModels := loadKMPModels(t) t.Logf("=== KMP Model Schema Contract Summary ===") t.Logf("OpenAPI schemas: %d", len(specSchemas)) t.Logf("KMP model classes: %d", len(kmpModels)) t.Logf("Mapped schema→class: %d", len(schemaToKMPClass)) t.Logf("Excluded schemas: %d", len(excludedSchemas)) t.Logf("Known type overrides: %d", len(knownTypeOverrides)) t.Logf("Known missing from KMP: %d", len(knownMissingFromKMP)) t.Logf("Known extra in KMP: %d", len(knownExtraInKMP)) // List mapped pairs var pairs []string for spec, mapping := range schemaToKMPClass { pairs = append(pairs, fmt.Sprintf(" %s → %s", spec, mapping.kmpClassName)) } sort.Strings(pairs) t.Logf("Mappings:\n%s", strings.Join(pairs, "\n")) // Count total fields validated totalFields := 0 for specName := range schemaToKMPClass { if schema, ok := specSchemas[specName]; ok { totalFields += len(schema.properties) } } t.Logf("Total spec fields validated: %d", totalFields) // Verify all overrides reference valid schema.field combos for key := range knownTypeOverrides { parts := strings.SplitN(key, ".", 2) assert.Len(t, parts, 2, "knownTypeOverrides key %q should be Schema.field", key) } for key := range knownMissingFromKMP { parts := strings.SplitN(key, ".", 2) assert.Len(t, parts, 2, "knownMissingFromKMP key %q should be Schema.field", key) } for key := range knownExtraInKMP { parts := strings.SplitN(key, ".", 2) assert.Len(t, parts, 2, "knownExtraInKMP key %q should be Schema.field", key) } }