From 662ec07be4b8b8c74c442f6ea847ed8bb3845114 Mon Sep 17 00:00:00 2001 From: Venkata Krishna Garapati Date: Tue, 7 Apr 2026 17:12:41 -0400 Subject: [PATCH] Add generic XML decoding tests and remove internal references --- .../XML/GenericXMLDecodingTests.swift | 1075 +++++++++++++++++ .../HeaderConfigurationDecodingTests.swift | 444 +++++++ .../XML/SearchResultDecodingTests.swift | 518 ++++++++ Tests/KumoTests/Mocks/XML/GenericModels.swift | 365 ++++++ .../Mocks/XML/GetPriceResponse.swift | 4 +- .../Mocks/XML/HeaderConfiguration.swift | 187 +++ Tests/KumoTests/Mocks/XML/SearchResult.swift | 178 +++ Tests/KumoTests/Mocks/XML/Unkeyed.swift | 40 +- 8 files changed, 2806 insertions(+), 5 deletions(-) create mode 100644 Tests/KumoTests/Fixtures/XML/GenericXMLDecodingTests.swift create mode 100644 Tests/KumoTests/Fixtures/XML/HeaderConfigurationDecodingTests.swift create mode 100644 Tests/KumoTests/Fixtures/XML/SearchResultDecodingTests.swift create mode 100644 Tests/KumoTests/Mocks/XML/GenericModels.swift create mode 100644 Tests/KumoTests/Mocks/XML/HeaderConfiguration.swift create mode 100644 Tests/KumoTests/Mocks/XML/SearchResult.swift diff --git a/Tests/KumoTests/Fixtures/XML/GenericXMLDecodingTests.swift b/Tests/KumoTests/Fixtures/XML/GenericXMLDecodingTests.swift new file mode 100644 index 0000000..6ad96a2 --- /dev/null +++ b/Tests/KumoTests/Fixtures/XML/GenericXMLDecodingTests.swift @@ -0,0 +1,1075 @@ +import Foundation +import XCTest +@testable import Kumo +@testable import KumoCoding + +// All XML fixtures in this file are synthetic test data. +// Domains (weather, e-commerce, employee directory, metrics) are fictional +// and used solely to exercise KumoCoding's XMLDecoder against a broad +// range of structural patterns found in real-world XML APIs. + +class GenericXMLDecodingTests: XCTestCase { + + // MARK: - Multiple Entries + + /// Decodes a response containing multiple forecast entries, + /// verifying that repeated sibling elements decode as an array. + func testDecodeMultipleForecasts() { + let decoder = XMLDecoder() + let data = """ + + Springfield + + + 2025-04-07 + 72.5 + 54.0 + Sunny + 0.0 + 12.3 + UV Index High + + + 2025-04-08 + 65.0 + 48.5 + Cloudy + 0.25 + + + 2025-04-09 + 58.0 + 42.0 + Rain + 1.5 + 25.0 + + + + """.data(using: .utf8)! + + do { + let response = try decoder.decode(WeatherForecastResponse.self, from: data) + XCTAssertEqual(response.Location, "Springfield") + XCTAssertEqual(response.Forecasts.count, 3) + XCTAssertEqual(response.Forecasts[0].Date, "2025-04-07") + XCTAssertEqual(response.Forecasts[0].High, 72.5) + XCTAssertEqual(response.Forecasts[0].Condition, "Sunny") + XCTAssertEqual(response.Forecasts[0].Advisory, "UV Index High") + XCTAssertEqual(response.Forecasts[1].Date, "2025-04-08") + XCTAssertNil(response.Forecasts[1].WindSpeed) + XCTAssertNil(response.Forecasts[1].Advisory) + XCTAssertEqual(response.Forecasts[2].Precipitation, 1.5) + XCTAssertEqual(response.Forecasts[2].WindSpeed, 25.0) + XCTAssertNil(response.Forecasts[2].Advisory) + } catch { + XCTFail("Decode failed: \(error)") + } + } + + // MARK: - Missing Optional Fields + + /// Decodes a forecast where all optional fields are absent. + func testDecodeForecastWithAllOptionalsAbsent() { + let decoder = XMLDecoder() + let data = """ + + Shelbyville + + + 2025-04-07 + 60.0 + 45.0 + Overcast + + + + """.data(using: .utf8)! + + do { + let response = try decoder.decode(WeatherForecastResponse.self, from: data) + XCTAssertEqual(response.Forecasts.count, 1) + let forecast = response.Forecasts[0] + XCTAssertEqual(forecast.Condition, "Overcast") + XCTAssertNil(forecast.Precipitation) + XCTAssertNil(forecast.WindSpeed) + XCTAssertNil(forecast.Advisory) + } catch { + XCTFail("Decode failed: \(error)") + } + } + + /// Decodes a forecast where optional fields are present but self-closing (empty). + func testDecodeForecastWithSelfClosingOptionals() { + let decoder = XMLDecoder() + let data = """ + + Capital City + + + 2025-04-10 + 55.0 + 40.0 + Clear + + + + + + + """.data(using: .utf8)! + + do { + let response = try decoder.decode(WeatherForecastResponse.self, from: data) + let forecast = response.Forecasts[0] + XCTAssertEqual(forecast.Date, "2025-04-10") + XCTAssertNil(forecast.Precipitation) + XCTAssertNil(forecast.WindSpeed) + XCTAssertNil(forecast.Advisory) + } catch { + XCTFail("Decode failed: \(error)") + } + } + + // MARK: - Empty Arrays + + /// Decodes an order where the notes array is a self-closing empty tag. + func testDecodeOrderWithEmptyNotes() { + let decoder = XMLDecoder() + let data = """ + + 1 + + + ORD-1001 + Shipped + + Alice Johnson + alice@example.com + + + + WIDGET-42 + Blue Widget + 3 + 9.99 + + + + + + + """.data(using: .utf8)! + + do { + let response = try decoder.decode(OrderListResponse.self, from: data) + XCTAssertEqual(response.TotalCount, 1) + XCTAssertEqual(response.Orders.count, 1) + XCTAssertEqual(response.Orders[0].OrderId, "ORD-1001") + XCTAssertNil(response.Orders[0].Notes) + } catch { + XCTFail("Decode failed: \(error)") + } + } + + /// Decodes an order where the notes array is completely absent. + func testDecodeOrderWithAbsentNotes() { + let decoder = XMLDecoder() + let data = """ + + 1 + + + ORD-1002 + Processing + + Bob Smith + + + + GADGET-99 + Red Gadget + 1 + 24.50 + + + + + + """.data(using: .utf8)! + + do { + let response = try decoder.decode(OrderListResponse.self, from: data) + XCTAssertEqual(response.Orders[0].OrderId, "ORD-1002") + XCTAssertNil(response.Orders[0].Customer.Email) + XCTAssertNil(response.Orders[0].Customer.Phone) + XCTAssertNil(response.Orders[0].Notes) + } catch { + XCTFail("Decode failed: \(error)") + } + } + + // MARK: - Multiple Entries with Varying Completeness + + /// Decodes multiple orders where some have notes and others don't, + /// testing heterogeneous content within the same array. + func testDecodeMultipleOrdersWithVaryingCompleteness() { + let decoder = XMLDecoder() + let data = """ + + 3 + + + ORD-2001 + Delivered + + Charlie Brown + charlie@example.com + 555-0101 + + + + BOOK-A1 + Adventure Novel + 2 + 14.99 + + + BOOK-B2 + Mystery Collection + 1 + 22.50 + + + + Gift wrap requested + Leave at door + + + + ORD-2002 + Cancelled + + Diana Prince + + + + TOY-X7 + Action Figure + 1 + 19.99 + + + + + ORD-2003 + Processing + + Eve Torres + eve@example.com + + + + ELEC-Z3 + Wireless Headphones + 1 + 79.99 + + + + + + + """.data(using: .utf8)! + + do { + let response = try decoder.decode(OrderListResponse.self, from: data) + XCTAssertEqual(response.TotalCount, 3) + XCTAssertEqual(response.Orders.count, 3) + + // Order 1: fully populated with notes + XCTAssertEqual(response.Orders[0].OrderId, "ORD-2001") + XCTAssertEqual(response.Orders[0].Customer.Phone, "555-0101") + XCTAssertEqual(response.Orders[0].Items.count, 2) + XCTAssertEqual(response.Orders[0].Items[1].Sku, "BOOK-B2") + XCTAssertEqual(response.Orders[0].Notes, ["Gift wrap requested", "Leave at door"]) + + // Order 2: minimal — no email, no phone, no notes + XCTAssertEqual(response.Orders[1].OrderId, "ORD-2002") + XCTAssertNil(response.Orders[1].Customer.Email) + XCTAssertNil(response.Orders[1].Customer.Phone) + XCTAssertNil(response.Orders[1].Notes) + + // Order 3: empty self-closing notes + XCTAssertEqual(response.Orders[2].OrderId, "ORD-2003") + XCTAssertNil(response.Orders[2].Notes) + } catch { + XCTFail("Decode failed: \(error)") + } + } + + // MARK: - Deeply Nested Elements + + /// Decodes an employee directory with deeply nested address information + /// and optional skill/project arrays. + func testDecodeEmployeeDirectoryWithDeepNesting() { + let decoder = XMLDecoder() + let data = """ + + Engineering + + + EMP-001 + Grace Hopper + Principal Engineer + + grace@example.com + 555-0199 +
+ 123 Oak Avenue + Metropolis + NY + 10001 +
+
+ + Swift + Objective-C + Python + + + + Atlas + Lead + true + + + Beacon + Contributor + false + + +
+
+
+ """.data(using: .utf8)! + + do { + let directory = try decoder.decode(EmployeeDirectory.self, from: data) + XCTAssertEqual(directory.Department, "Engineering") + XCTAssertEqual(directory.Employees.count, 1) + + let emp = directory.Employees[0] + XCTAssertEqual(emp.Id, "EMP-001") + XCTAssertEqual(emp.Name, "Grace Hopper") + + // Deeply nested address + XCTAssertEqual(emp.Contact.Address?.Street, "123 Oak Avenue") + XCTAssertEqual(emp.Contact.Address?.City, "Metropolis") + XCTAssertEqual(emp.Contact.Address?.State, "NY") + XCTAssertEqual(emp.Contact.Address?.Zip, "10001") + + // Skills array + XCTAssertEqual(emp.Skills, ["Swift", "Objective-C", "Python"]) + + // Projects with nested boolean + XCTAssertEqual(emp.Projects?.count, 2) + XCTAssertEqual(emp.Projects?[0].Name, "Atlas") + XCTAssertEqual(emp.Projects?[0].Active, true) + XCTAssertEqual(emp.Projects?[1].Active, false) + } catch { + XCTFail("Decode failed: \(error)") + } + } + + /// Decodes an employee with no address, no skills, and no projects. + func testDecodeEmployeeWithMinimalData() { + let decoder = XMLDecoder() + let data = """ + + Marketing + + + EMP-002 + Alan Turing + Analyst + + alan@example.com + + + + + """.data(using: .utf8)! + + do { + let directory = try decoder.decode(EmployeeDirectory.self, from: data) + let emp = directory.Employees[0] + XCTAssertEqual(emp.Id, "EMP-002") + XCTAssertEqual(emp.Contact.Email, "alan@example.com") + XCTAssertNil(emp.Contact.Phone) + XCTAssertNil(emp.Contact.Address) + XCTAssertNil(emp.Skills) + XCTAssertNil(emp.Projects) + } catch { + XCTFail("Decode failed: \(error)") + } + } + + /// Decodes an employee with empty self-closing skills and projects. + func testDecodeEmployeeWithEmptySelfClosingArrays() { + let decoder = XMLDecoder() + let data = """ + + Design + + + EMP-003 + Ada Lovelace + Designer + + ada@example.com + + + + + + + + """.data(using: .utf8)! + + do { + let directory = try decoder.decode(EmployeeDirectory.self, from: data) + let emp = directory.Employees[0] + XCTAssertEqual(emp.Id, "EMP-003") + XCTAssertNil(emp.Contact.Phone) + XCTAssertNil(emp.Skills) + XCTAssertNil(emp.Projects) + } catch { + XCTFail("Decode failed: \(error)") + } + } + + // MARK: - Multiple Employees (varied completeness) + + /// Decodes a directory with multiple employees at different levels + /// of data completeness, testing heterogeneous sibling elements. + func testDecodeMultipleEmployeesWithVaryingCompleteness() { + let decoder = XMLDecoder() + let data = """ + + Research + + + EMP-101 + Marie Curie + Research Scientist + + marie@example.com + 555-0201 +
+ 456 Elm Street + Gotham + NJ + 07001 +
+
+ + Chemistry + Physics + + + + Radiance + Principal Investigator + true + + +
+ + EMP-102 + Nikola Tesla + Staff Engineer + + nikola@example.com + + + + EMP-103 + Rosalind Franklin + Senior Scientist + + rosalind@example.com + 555-0203 + + + + +
+
+ """.data(using: .utf8)! + + do { + let directory = try decoder.decode(EmployeeDirectory.self, from: data) + XCTAssertEqual(directory.Department, "Research") + XCTAssertEqual(directory.Employees.count, 3) + + // Fully populated employee + XCTAssertEqual(directory.Employees[0].Skills, ["Chemistry", "Physics"]) + XCTAssertEqual(directory.Employees[0].Projects?.count, 1) + XCTAssertEqual(directory.Employees[0].Contact.Address?.City, "Gotham") + + // Minimal employee — no phone, no address, no skills, no projects + XCTAssertNil(directory.Employees[1].Contact.Phone) + XCTAssertNil(directory.Employees[1].Contact.Address) + XCTAssertNil(directory.Employees[1].Skills) + XCTAssertNil(directory.Employees[1].Projects) + + // Employee with empty arrays (self-closing tags) + XCTAssertEqual(directory.Employees[2].Contact.Phone, "555-0203") + XCTAssertNil(directory.Employees[2].Skills) + XCTAssertNil(directory.Employees[2].Projects) + } catch { + XCTFail("Decode failed: \(error)") + } + } + + // MARK: - SOAP Envelope with Nested Catalog Item + + /// Decodes a deeply nested SOAP response for a catalog item. + func testDecodeSOAPCatalogItem() { + let decoder = SOAPDecoder() + decoder.keyDecodingStrategy = .convertFromPascalCase + let data = """ + + + + + CAT-5001 + Ergonomic Keyboard + Electronics + + 89.99 + USD + 0.15 + 0.08 + + + Central + 150 + 12 + 25 + + + keyboard + ergonomic + office + + + + + """.data(using: .utf8)! + + do { + let item: CatalogItem = try decoder.decode(from: data) + XCTAssertEqual(item.Id, "CAT-5001") + XCTAssertEqual(item.Name, "Ergonomic Keyboard") + XCTAssertEqual(item.Category, "Electronics") + XCTAssertEqual(item.Pricing.BasePrice, 89.99) + XCTAssertEqual(item.Pricing.Currency, "USD") + XCTAssertEqual(item.Pricing.Discount, 0.15) + XCTAssertEqual(item.Pricing.TaxRate, 0.08) + XCTAssertEqual(item.Inventory.Warehouse, "Central") + XCTAssertEqual(item.Inventory.Quantity, 150) + XCTAssertEqual(item.Inventory.Reserved, 12) + XCTAssertEqual(item.Inventory.ReorderThreshold, 25) + XCTAssertEqual(item.Tags, ["keyboard", "ergonomic", "office"]) + } catch { + XCTFail("Decode failed: \(error)") + } + } + + /// Decodes a SOAP catalog item where optional pricing/inventory + /// fields and the tags array are absent. + func testDecodeSOAPCatalogItemWithOptionalsMissing() { + let decoder = SOAPDecoder() + decoder.keyDecodingStrategy = .convertFromPascalCase + let data = """ + + + + + CAT-5002 + Basic Mouse + Accessories + + 12.99 + EUR + + + East + 500 + 0 + + + + + """.data(using: .utf8)! + + do { + let item: CatalogItem = try decoder.decode(from: data) + XCTAssertEqual(item.Id, "CAT-5002") + XCTAssertEqual(item.Pricing.BasePrice, 12.99) + XCTAssertNil(item.Pricing.Discount) + XCTAssertNil(item.Pricing.TaxRate) + XCTAssertNil(item.Inventory.ReorderThreshold) + XCTAssertNil(item.Tags) + } catch { + XCTFail("Decode failed: \(error)") + } + } + + /// Decodes a SOAP catalog item where the tags array is empty (self-closing). + func testDecodeSOAPCatalogItemWithEmptyTags() { + let decoder = SOAPDecoder() + decoder.keyDecodingStrategy = .convertFromPascalCase + let data = """ + + + + + CAT-5003 + Notebook Stand + Furniture + + 45.00 + USD + + + West + 30 + 5 + + + + + + """.data(using: .utf8)! + + do { + let item: CatalogItem = try decoder.decode(from: data) + XCTAssertEqual(item.Id, "CAT-5003") + XCTAssertNil(item.Tags) + } catch { + XCTFail("Decode failed: \(error)") + } + } + + // MARK: - Atom Feed–Style with Unkeyed Iteration + + /// Decodes an Atom-style feed containing notification entries, + /// verifying the unkeyed container iteration pattern. + func testDecodeNotificationFeed() { + let decoder = XMLDecoder() + let data = """ + + http://example.com/notifications + Notifications + 2025-04-07T12:00:00Z + System + + http://example.com/notification/1 + Notification + 2025-04-07T12:00:00Z + System + + + N-001 + Deployment Complete + Version 2.5.0 deployed successfully. + High + false + 2025-04-07T11:30:00Z + + + + + http://example.com/notification/2 + Notification + 2025-04-07T12:00:00Z + System + + + N-002 + Scheduled Maintenance + Low + true + 2025-04-06T09:00:00Z + + + + + http://example.com/notification/3 + Notification + 2025-04-07T12:00:00Z + System + + + N-003 + New User Registered + User john@example.com registered. + Medium + false + 2025-04-07T08:15:00Z + + + + + """.data(using: .utf8)! + + do { + let feed = try decoder.decode(NotificationFeed.self, from: data) + XCTAssertEqual(feed.notifications.count, 3) + + // Entry 1: has message + let n1 = feed.notifications[0].content.notification + XCTAssertEqual(n1.Id, "N-001") + XCTAssertEqual(n1.Title, "Deployment Complete") + XCTAssertEqual(n1.Message, "Version 2.5.0 deployed successfully.") + XCTAssertEqual(n1.Priority, "High") + XCTAssertEqual(n1.Read, false) + + // Entry 2: missing message + let n2 = feed.notifications[1].content.notification + XCTAssertEqual(n2.Id, "N-002") + XCTAssertNil(n2.Message) + XCTAssertEqual(n2.Read, true) + + // Entry 3: has message + let n3 = feed.notifications[2].content.notification + XCTAssertEqual(n3.Id, "N-003") + XCTAssertEqual(n3.Message, "User john@example.com registered.") + } catch { + XCTFail("Decode failed: \(error)") + } + } + + // MARK: - Polymorphic Metric Values + + /// Decodes metric rows with polymorphic value types (Gauge, Counter, Timestamp), + /// verifying that only the populated sub-element is non-nil. + func testDecodeMetricRowWithPolymorphicValues() { + let decoder = XMLDecoder() + let data = """ + + http://example.com/metrics/cpu-usage + Server + + + ServerCPUUsage + 87.5 + + + ServerRequestCount + 14523 + + + ServerLastRestart + 2025-04-01T06:00:00Z + + + ServerErrorRate + + + + + """.data(using: .utf8)! + + do { + let row = try decoder.decode(MetricRow.self, from: data) + XCTAssertEqual(row.Id, "http://example.com/metrics/cpu-usage") + XCTAssertEqual(row.Source, "Server") + XCTAssertEqual(row.Metrics.count, 4) + + // Gauge value + XCTAssertEqual(row.Metrics[0].Value.Gauge, "87.5") + XCTAssertNil(row.Metrics[0].Value.Counter) + XCTAssertNil(row.Metrics[0].Value.Timestamp) + + // Counter value + XCTAssertNil(row.Metrics[1].Value.Gauge) + XCTAssertEqual(row.Metrics[1].Value.Counter, "14523") + + // Timestamp value + XCTAssertEqual(row.Metrics[2].Value.Timestamp, "2025-04-01T06:00:00Z") + XCTAssertNil(row.Metrics[2].Value.Gauge) + + // Empty Gauge (self-closing) + XCTAssertNil(row.Metrics[3].Value.Gauge) + XCTAssertNil(row.Metrics[3].Value.Counter) + XCTAssertNil(row.Metrics[3].Value.Timestamp) + } catch { + XCTFail("Decode failed: \(error)") + } + } + + // MARK: - Single Item Array + + /// Verifies that an array containing exactly one element decodes correctly. + func testDecodeSingleItemList() { + let decoder = XMLDecoder() + decoder.keyDecodingStrategy = .convertFromPascalCase + let data = """ + + + only-one + + + """.data(using: .utf8)! + + do { + let response = try decoder.decode(ListContainer.self, from: data) + XCTAssertEqual(response.simpleList, ["only-one"]) + } catch { + XCTFail("Decode failed: \(error)") + } + } + + // MARK: - Numeric Edge Cases + + /// Decodes various numeric representations (zero, negative, large values). + func testDecodeNumericEdgeCases() { + let decoder = XMLDecoder() + + // Zero + let zeroData = """ + + Numbers + 0 + + """.data(using: .utf8)! + + do { + let response = try decoder.decode(DefaultKeyModel.self, from: zeroData) + XCTAssertEqual(response.count, 0) + } catch { + XCTFail("Decode failed: \(error)") + } + + // Negative + let negativeData = """ + + Negative + -42 + + """.data(using: .utf8)! + + do { + let response = try decoder.decode(DefaultKeyModel.self, from: negativeData) + XCTAssertEqual(response.count, -42) + } catch { + XCTFail("Decode failed: \(error)") + } + + // Int max + let largeData = """ + + Large + 2147483647 + + """.data(using: .utf8)! + + do { + let response = try decoder.decode(DefaultKeyModel.self, from: largeData) + XCTAssertEqual(response.count, 2147483647) + } catch { + XCTFail("Decode failed: \(error)") + } + } + + // MARK: - Whitespace-Only Content Treated as Empty + + /// Verifies that elements containing only whitespace are treated as empty nodes. + func testDecodeWhitespaceOnlyContentAsEmpty() { + let decoder = XMLDecoder() + decoder.keyDecodingStrategy = .convertFromPascalCase + let data = """ + + WhitespaceTest + + + """.data(using: .utf8)! + + do { + let response = try decoder.decode(NilableContainer.self, from: data) + XCTAssertEqual(response.name, "WhitespaceTest") + // The XMLDeserializer trims whitespace content, so whitespace-only + // becomes an empty node which decodes as nil. + XCTAssertNil(response.nickname) + } catch { + XCTFail("Decode failed: \(error)") + } + } + + // MARK: - Namespace Stripping + + /// Verifies that elements with namespace prefixes decode correctly + /// since XMLParser with shouldProcessNamespaces strips prefixes. + func testDecodeNamespacedElements() { + let decoder = XMLDecoder() + let data = """ + + Namespaced + 7 + + """.data(using: .utf8)! + + do { + let response = try decoder.decode(DefaultKeyModel.self, from: data) + XCTAssertEqual(response.title, "Namespaced") + XCTAssertEqual(response.count, 7) + } catch { + XCTFail("Decode failed: \(error)") + } + } + + // MARK: - Complex List with Single Element + + /// Verifies that a complex object list with a single element decodes correctly. + func testDecodeComplexListWithSingleElement() { + let decoder = XMLDecoder() + decoder.keyDecodingStrategy = .convertFromPascalCase + let data = """ + + + + alpha + beta + + + + """.data(using: .utf8)! + + do { + let response = try decoder.decode(ComplexListContainer.self, from: data) + XCTAssertEqual(response.complexList.count, 1) + XCTAssertEqual(response.complexList[0].x, "alpha") + XCTAssertEqual(response.complexList[0].y, "beta") + } catch { + XCTFail("Decode failed: \(error)") + } + } + + // MARK: - SOAP Round-Trip with Catalog Payload + + /// Encodes then decodes a price response through SOAP to verify round-trip integrity. + func testSOAPCatalogItemRoundTrip() { + let original = GetPriceResponse( + price: GetPriceResponse.Price(amount: 42.0, units: "GBP"), + discount: 0.10 + ) + + let encoder = SOAPEncoder() + encoder.keyEncodingStrategy = .convertToPascalCase + encoder.soapNamespaceUsage = .define( + using: XMLNamespace(prefix: "soap", uri: "http://www.w3.org/2003/05/soap-envelope/"), + including: [] + ) + encoder.requestPayloadNamespaceUsage = .defineBeneath( + XMLNamespace(prefix: "m", uri: "https://www.example.com/prices") + ) + + let decoder = SOAPDecoder() + decoder.keyDecodingStrategy = .convertFromPascalCase + + do { + let data = try encoder.encode(original) + let decoded: GetPriceResponse = try decoder.decode(from: data) + XCTAssertEqual(decoded, original) + } catch { + XCTFail("Round-trip failed: \(error)") + } + } + + // MARK: - Error: Missing Required Key in Nested Object + + /// Verifies that a missing required key in a nested object throws keyNotFound. + func testDecodingMissingRequiredKeyInNestedObjectThrows() { + let decoder = XMLDecoder() + decoder.keyDecodingStrategy = .convertFromPascalCase + let data = """ + + QA + + + EMP-BAD + Missing Title + + bad@example.com + + + + + """.data(using: .utf8)! + + XCTAssertThrowsError(try decoder.decode(EmployeeDirectory.self, from: data)) { error in + guard case DecodingError.keyNotFound = error else { + XCTFail("Expected DecodingError.keyNotFound but got \(error)") + return + } + } + } + + // MARK: - Large Array Decode + + /// Decodes a string list with many elements to verify the unkeyed + /// container handles larger counts correctly. + func testDecodeLargeStringList() { + let decoder = XMLDecoder() + decoder.keyDecodingStrategy = .convertFromPascalCase + let elements = (1...50).map { "item-\($0)" }.joined(separator: "\n") + let data = """ + + + \(elements) + + + """.data(using: .utf8)! + + do { + let response = try decoder.decode(ListContainer.self, from: data) + XCTAssertEqual(response.simpleList.count, 50) + XCTAssertEqual(response.simpleList.first, "item-1") + XCTAssertEqual(response.simpleList.last, "item-50") + } catch { + XCTFail("Decode failed: \(error)") + } + } + + // MARK: - Decode with Multiple Namespaces + + /// Verifies decode still works when multiple namespace declarations exist + /// on the same element, since XMLParser strips prefixes. + func testDecodeWithMultipleNamespaceDeclarations() { + let decoder = XMLDecoder() + decoder.keyDecodingStrategy = .convertFromPascalCase + let data = """ + + MultiNS + + """.data(using: .utf8)! + + do { + let response = try decoder.decode(NilableContainer.self, from: data) + XCTAssertEqual(response.name, "MultiNS") + XCTAssertNil(response.nickname) + } catch { + XCTFail("Decode failed: \(error)") + } + } +} diff --git a/Tests/KumoTests/Fixtures/XML/HeaderConfigurationDecodingTests.swift b/Tests/KumoTests/Fixtures/XML/HeaderConfigurationDecodingTests.swift new file mode 100644 index 0000000..9a19ff5 --- /dev/null +++ b/Tests/KumoTests/Fixtures/XML/HeaderConfigurationDecodingTests.swift @@ -0,0 +1,444 @@ +import Foundation +import XCTest +@testable import Kumo +@testable import KumoCoding + +// All XML fixtures in this file are synthetic test data. +// The domain (inventory / catalog) is fictional and used solely to +// exercise KumoCoding's XMLDecoder against nested, mixed-content XML. + +class HeaderConfigurationDecodingTests: XCTestCase { + + // MARK: - Single Configuration (content only) + + func testDecodeSingleConfiguration() { + let decoder = XMLDecoder() + let data = """ + + Catalog Alpha +
+ + + ProductSKU + Text + False + False + 20 + + + ProductName + Text + False + False + 350 + + +
+ + + Product + Warehouse + + Product + + + + Text + ProductBrand + + + + + Text + ProductModel + + + + + + Shipment + + Shipment + + + Text + ProductSKU + + + + + Text + WarehouseBinNumber + + + +
+ """.data(using: .utf8)! + + do { + let config = try decoder.decode(Configuration.self, from: data) + XCTAssertEqual(config.Name, "Catalog Alpha") + XCTAssertEqual(config.Details.Controls.count, 2) + XCTAssertEqual(config.Details.Controls[0].Field.Source, "Product") + XCTAssertEqual(config.Details.Controls[0].Field.Attribute, "SKU") + XCTAssertEqual(config.Details.Controls[0].DataType, "Text") + XCTAssertEqual(config.Details.Controls[0].MaximumLength, 20) + XCTAssertNil(config.Details.Controls[0].Label) + XCTAssertNil(config.Details.Controls[0].Choices) + + XCTAssertEqual(config.ItemSearching.Sources, ["Product", "Warehouse"]) + XCTAssertEqual(config.ItemSearching.Target, "Product") + XCTAssertEqual(config.ItemSearching.Criteria.count, 1) + XCTAssertEqual(config.ItemSearching.Criteria[0].Label, "Brand") + + XCTAssertEqual(config.ShipmentSearching.Sources, ["Shipment"]) + XCTAssertEqual(config.ShipmentSearching.Target, "Shipment") + XCTAssertEqual(config.ShipmentSearching.ReadOnlyCriteria.count, 1) + } catch { + XCTFail("Decode failed: \(error)") + } + } + + // MARK: - Empty ReadOnlyCriteria + + func testDecodeConfigurationWithEmptyReadOnlyCriteria() { + let decoder = XMLDecoder() + let data = """ + + Catalog Beta +
+ + + ProductSKU + Text + False + False + 20 + + +
+ + Product + Product + + + + Text + ProductBrand + + + + + Text + ProductModel + + + + + Warehouse + Warehouse + + + + Date + WarehouseReceivedDate + + + +
+ """.data(using: .utf8)! + + + do { + let config = try decoder.decode(Configuration.self, from: data) + XCTAssertEqual(config.Name, "Catalog Beta") + XCTAssertEqual(config.ShipmentSearching.ReadOnlyCriteria, []) + } catch { + XCTFail("Decode failed: \(error)") + } + } + + // MARK: - Empty Choices on Control + + func testDecodeControlWithEmptyChoices() { + let decoder = XMLDecoder() + let data = """ + + Choices Test +
+ + + WarehouseZone + Text + False + False + 100 + + + +
+ + Product + Product + + TextProductSKU + + + TextProductSKU + + + + Shipment + Shipment + + + TextShipmentTrackingNumber + + +
+ """.data(using: .utf8)! + + do { + let config = try decoder.decode(Configuration.self, from: data) + XCTAssertEqual(config.Details.Controls.count, 1) + // Empty should decode as nil + XCTAssertNil(config.Details.Controls[0].Choices) + } catch { + XCTFail("Decode failed: \(error)") + } + } + + // MARK: - Full Atom Feed Decoding + + func testDecodeFullAtomFeed() { + let decoder = XMLDecoder() + let data = Self.fullAtomFeedXML.data(using: .utf8)! + + do { + let feed = try decoder.decode(HeaderConfigurationFeed.self, from: data) + XCTAssertEqual(feed.entry.count, 2) + + let first = feed.entry[0].content.configuration + XCTAssertEqual(first.Name, "Catalog Alpha") + XCTAssertEqual(first.Details.Controls.count, 23) + + // Verify first control + XCTAssertEqual(first.Details.Controls[0].Field.Source, "Product") + XCTAssertEqual(first.Details.Controls[0].Field.Attribute, "SKU") + XCTAssertEqual(first.Details.Controls[0].DataType, "Text") + XCTAssertEqual(first.Details.Controls[0].MaximumLength, 20) + + // Verify ItemSearching + XCTAssertEqual(first.ItemSearching.Sources, ["Product", "Warehouse"]) + XCTAssertEqual(first.ItemSearching.Target, "Product") + XCTAssertEqual(first.ItemSearching.Criteria.count, 5) + XCTAssertEqual(first.ItemSearching.Criteria[0].Label, "Brand") + XCTAssertEqual(first.ItemSearching.Columns.count, 8) + + // Verify ShipmentSearching — has ReadOnlyCriteria + XCTAssertEqual(first.ShipmentSearching.Sources, ["Shipment"]) + XCTAssertEqual(first.ShipmentSearching.Target, "Shipment") + XCTAssertEqual(first.ShipmentSearching.ReadOnlyCriteria.count, 6) + XCTAssertEqual(first.ShipmentSearching.Columns.count, 8) + + let second = feed.entry[1].content.configuration + XCTAssertEqual(second.Name, "Catalog Beta") + XCTAssertEqual(second.Details.Controls.count, 23) + + // Second entry has empty ReadOnlyCriteria + XCTAssertEqual(second.ShipmentSearching.ReadOnlyCriteria, []) + XCTAssertEqual(second.ShipmentSearching.Columns.count, 15) + + // Verify a criterion without Label + let criterion = first.ItemSearching.Criteria[2] // ManufactureDate — no Label + XCTAssertNil(criterion.Label) + XCTAssertEqual(criterion.Type, "Date") + XCTAssertEqual(criterion.Field.Attribute, "ManufactureDate") + + // Verify a column with Label + let labeledColumn = first.ItemSearching.Columns[6] // Weight + XCTAssertEqual(labeledColumn.Label, "Weight") + + // Verify last control is read-only + let lastControl = first.Details.Controls[22] + XCTAssertEqual(lastControl.ReadOnly, "True") + XCTAssertEqual(lastControl.Field.Attribute, "Supplier") + } catch { + XCTFail("Decode failed: \(error)") + } + } + + // MARK: - Full XML Fixture + + // Synthetic Atom feed using a fictional inventory domain. + // All URLs, identifiers, and field names are purely illustrative. + static let fullAtomFeedXML = """ + \ + \ + http://feed.example.com/Tenant/00000/InventoryConfigurations\ + Inventory Configurations\ + 2025-01-01T00:00:00Z\ + Admin\ + \ + http://feed.example.com/Tenant/00000/InventoryConfiguration/1001\ + Inventory Configuration\ + 2025-01-01T00:00:00Z\ + Admin\ + \ + \ + \ + Catalog Alpha\ +
\ + ProductSKUTextFalseFalse20\ + ProductNameTextFalseFalse350\ + ProductCategoryTextFalseFalse1\ + ProductManufactureDateDateFalseFalse\ + WarehouseTrackingNumberTextFalseFalse50\ + WarehouseBinNumberTextFalseFalse50\ + WarehouseReceivedDateDateFalseFalse\ + WarehouseReceivedTimeTimeFalseFalse\ + CatalogListedDateMomentFalseFalse\ + WarehouseStockedDateMomentFalseFalse\ + WarehouseShippedDateMomentFalseFalse\ + CatalogPublisherUriFalseFalse\ + CatalogListingTypeUriFalseFalse\ + CatalogRegionUriFalseFalse\ + CatalogFeaturedUriFalseFalse\ + WarehouseCustomField1TextFalseFalse100\ + WarehouseCustomField2TextFalseFalse100\ + WarehouseCustomField3TextFalseFalse100\ + WarehouseCustomField4TextFalseFalse100\ + WarehouseCustomField5TextFalseFalse100\ + WarehouseAisleTextFalseFalse20\ + WarehouseShelfTextFalseFalse20\ + WarehouseSupplierTextFalseTrue\ +
\ + \ + ProductWarehouse\ + Product\ + \ + TextProductBrand\ + TextProductModel\ + DateProductManufactureDate\ + TextProductSKU\ + TextWarehouseGrossWeight\ + \ + \ + TextProductModel\ + TextProductBrand\ + TextProductCategory\ + DateProductManufactureDate\ + DateProductReceivedDate\ + TextProductSKU\ + TextWarehouseGrossWeight\ + TextWarehouseRegionName\ + \ + \ + \ + Shipment\ + Shipment\ + \ + TextProductSKU\ + TextProductName\ + DateProductManufactureDate\ + TextProductCategory\ + TextProductModel\ + TextProductBrand\ + \ + \ + TextWarehouseBinNumber\ + MomentWarehouseStockedDate\ + TextShipmentTrackingNumber\ + TextShipmentCarrierCode\ + TextShipmentStatus\ + DateShipmentShipDate\ + TextShipmentDescription\ + TextShipmentCarrier\ + \ + \ +
\ +
\ +
\ + \ + http://feed.example.com/Tenant/00000/InventoryConfiguration/1002\ + Inventory Configuration\ + 2025-01-01T00:00:00Z\ + Admin\ + \ + \ + \ + Catalog Beta\ +
\ + ProductSKUTextFalseFalse20\ + ProductNameTextFalseFalse350\ + ProductCategoryTextFalseFalse1\ + ProductManufactureDateDateFalseFalse\ + WarehouseReceivedDateDateFalseFalse\ + WarehouseReceivedTimeTimeFalseFalse\ + CatalogListedDateMomentFalseFalse\ + WarehouseStockedDateMomentFalseFalse\ + WarehouseShippedDateMomentFalseFalse\ + WarehouseTrackingNumberTextFalseFalse50\ + CatalogPublisherUriFalseFalse\ + CatalogListingTypeUriFalseFalse\ + CatalogRegionUriFalseFalse\ + CatalogFeaturedUriFalseFalse\ + WarehouseCustomField1TextFalseFalse100\ + WarehouseCustomField2TextFalseFalse100\ + WarehouseCustomField3TextFalseFalse100\ + WarehouseCustomField4TextFalseFalse100\ + WarehouseCustomField5TextFalseFalse100\ + WarehouseAisleTextFalseFalse20\ + WarehouseBinNumberTextFalseFalse50\ + WarehouseShelfTextFalseFalse20\ + WarehouseSupplierTextFalseTrue\ +
\ + \ + ProductWarehouse\ + Product\ + \ + TextProductBrand\ + TextProductModel\ + DateProductManufactureDate\ + TextProductSKU\ + TextWarehouseGrossWeight\ + \ + \ + TextProductModel\ + TextProductBrand\ + TextProductCategory\ + DateProductManufactureDate\ + DateProductReceivedDate\ + TextProductSKU\ + TextWarehouseGrossWeight\ + TextWarehouseRegionName\ + \ + \ + \ + Warehouse\ + Warehouse\ + \ + \ + DateWarehouseReceivedDate\ + TimeWarehouseReceivedTime\ + TextWarehouseDescription\ + TextProductSKU\ + TextProductName\ + TextProductCategory\ + DateProductManufactureDate\ + TextWarehouseRegionId\ + TextWarehouseRegionName\ + TextWarehouseTrackingNumber\ + TextWarehouseCustomField1\ + TextWarehouseCustomField2\ + TextWarehouseCustomField3\ + TextWarehouseCustomField4\ + TextWarehouseCustomField5\ + \ + \ +
\ +
\ +
\ +
+ """ +} diff --git a/Tests/KumoTests/Fixtures/XML/SearchResultDecodingTests.swift b/Tests/KumoTests/Fixtures/XML/SearchResultDecodingTests.swift new file mode 100644 index 0000000..8ed8c74 --- /dev/null +++ b/Tests/KumoTests/Fixtures/XML/SearchResultDecodingTests.swift @@ -0,0 +1,518 @@ +import Foundation +import XCTest +@testable import Kumo +@testable import KumoCoding + +// All XML fixtures in this file are synthetic test data. +// The domain (library / book catalog) is fictional and used solely to +// exercise KumoCoding's XMLDecoder against query/result XML patterns +// including polymorphic data types, self-closing elements, and Atom feeds. + +class SearchResultDecodingTests: XCTestCase { + + // MARK: - Search Query Decoding + + /// Decodes a standalone element with Sources, Parameters, and Columns. + func testDecodeSearchQuery() { + let decoder = XMLDecoder() + let data = """ + + + Book + + + + + Book + Author + + + http://example.com/Tenant/100/Author/42 + + + + + + Book + URI + + + Book + PublishedDate + + + Reader + Id + + + Reader + FullName + + + + """.data(using: .utf8)! + + do { + let query = try decoder.decode(SearchQuery.self, from: data) + XCTAssertEqual(query.Sources, ["Book"]) + XCTAssertEqual(query.Parameters.count, 1) + XCTAssertEqual(query.Parameters[0].Field.Source, "Book") + XCTAssertEqual(query.Parameters[0].Field.Attribute, "Author") + XCTAssertEqual(query.Parameters[0].Data.Uri, "http://example.com/Tenant/100/Author/42") + XCTAssertNil(query.Parameters[0].Data.Moment) + XCTAssertNil(query.Parameters[0].Data.Text) + XCTAssertEqual(query.Columns.count, 4) + XCTAssertEqual(query.Columns[0].Attribute, "URI") + XCTAssertEqual(query.Columns[3].Source, "Reader") + } catch { + XCTFail("Decode failed: \(error)") + } + } + + // MARK: - Single Row with Polymorphic Data + + /// Decodes a single containing Uri, Moment, and Text data types. + func testDecodeSingleRowWithPolymorphicData() { + let decoder = XMLDecoder() + let data = """ + + http://example.com/Tenant/100/Book/5001 + Book + + + BookURI + http://example.com/Tenant/100/Book/5001 + + + BookPublishedDate + 2025-03-15T10:30:00-05:00 + + + ReaderId + R-414 + + + ReaderFullName + Jane Doe + + + + """.data(using: .utf8)! + + do { + let row = try decoder.decode(SearchResultRow.self, from: data) + XCTAssertEqual(row.Id, "http://example.com/Tenant/100/Book/5001") + XCTAssertEqual(row.Source, "Book") + XCTAssertEqual(row.Fields.count, 4) + + // Item 0: Uri data + XCTAssertEqual(row.Fields[0].Field.Source, "Book") + XCTAssertEqual(row.Fields[0].Field.Attribute, "URI") + XCTAssertEqual(row.Fields[0].Data.Uri, "http://example.com/Tenant/100/Book/5001") + XCTAssertNil(row.Fields[0].Data.Moment) + XCTAssertNil(row.Fields[0].Data.Text) + + // Item 1: Moment data + XCTAssertNil(row.Fields[1].Data.Uri) + XCTAssertEqual(row.Fields[1].Data.Moment, "2025-03-15T10:30:00-05:00") + XCTAssertNil(row.Fields[1].Data.Text) + + // Item 2: Text data + XCTAssertNil(row.Fields[2].Data.Uri) + XCTAssertNil(row.Fields[2].Data.Moment) + XCTAssertEqual(row.Fields[2].Data.Text, "R-414") + + // Item 3: Text data + XCTAssertEqual(row.Fields[3].Data.Text, "Jane Doe") + } catch { + XCTFail("Decode failed: \(error)") + } + } + + // MARK: - Self-Closing / Empty Data Elements + + /// Tests that self-closing elements like , , + /// and empty elements like decode as nil. + func testDecodeSelfClosingDataElements() { + let decoder = XMLDecoder() + let data = """ + + http://example.com/Tenant/100/Book/5002 + Book + + + BookCheckoutDate + + + + ReaderId + + + + ReaderFullName + + + + ShelfURI + + + + + """.data(using: .utf8)! + + do { + let row = try decoder.decode(SearchResultRow.self, from: data) + XCTAssertEqual(row.Fields.count, 4) + + // — self-closing, should be nil + XCTAssertNil(row.Fields[0].Data.Moment) + XCTAssertNil(row.Fields[0].Data.Uri) + XCTAssertNil(row.Fields[0].Data.Text) + + // — empty content, should be nil + XCTAssertNil(row.Fields[1].Data.Text) + + // — self-closing, should be nil + XCTAssertNil(row.Fields[2].Data.Text) + + // — self-closing, should be nil + XCTAssertNil(row.Fields[3].Data.Uri) + } catch { + XCTFail("Decode failed: \(error)") + } + } + + // MARK: - Row with Mixed Populated and Empty Fields + + /// A row where some fields have values and others are empty/self-closing, + /// mirroring real-world patterns where optional data is sparse. + func testDecodeRowWithMixedPopulatedAndEmptyFields() { + let decoder = XMLDecoder() + let data = """ + + http://example.com/Tenant/100/Book/5003 + Book + + + BookURI + http://example.com/Tenant/100/Book/5003 + + + BookPublishedDate + 2025-06-01T14:00:00-05:00 + + + BookCheckoutDate + + + + ReaderId + R-123 + + + ReaderFullName + + + + GenreURI + http://example.com/Tenant/100/Genre/7001 + + + ShelfURI + + + + + """.data(using: .utf8)! + + do { + let row = try decoder.decode(SearchResultRow.self, from: data) + XCTAssertEqual(row.Fields.count, 7) + + // Populated URI + XCTAssertEqual(row.Fields[0].Data.Uri, "http://example.com/Tenant/100/Book/5003") + // Populated Moment + XCTAssertEqual(row.Fields[1].Data.Moment, "2025-06-01T14:00:00-05:00") + // Empty Moment + XCTAssertNil(row.Fields[2].Data.Moment) + // Populated Text + XCTAssertEqual(row.Fields[3].Data.Text, "R-123") + // Empty Text + XCTAssertNil(row.Fields[4].Data.Text) + // Populated URI + XCTAssertEqual(row.Fields[5].Data.Uri, "http://example.com/Tenant/100/Genre/7001") + // Empty URI + XCTAssertNil(row.Fields[6].Data.Uri) + } catch { + XCTFail("Decode failed: \(error)") + } + } + + // MARK: - Multiple Entries in Atom Feed + + /// Decodes a full Atom feed with 3 entries representing search results. + /// Entry 1: fully populated (all data present, 9 fields including links) + /// Entry 2: sparse data (empty Text/Moment, only 7 fields, no link fields) + /// Entry 3: all fields populated with different data values + func testDecodeSearchResultFeedWithMultipleEntries() { + let decoder = XMLDecoder() + let data = Self.searchResultFeedXML.data(using: .utf8)! + + do { + let feed = try decoder.decode(SearchResultFeed.self, from: data) + XCTAssertEqual(feed.entries.count, 3) + + // --- Entry 1: Fully populated --- + let row1 = feed.entries[0].content.row + XCTAssertEqual(row1.Id, "http://example.com/Tenant/100/Book/5001") + XCTAssertEqual(row1.Source, "Book") + XCTAssertEqual(row1.Fields.count, 9) + + // URI field + XCTAssertEqual(row1.Fields[0].Field.Attribute, "URI") + XCTAssertEqual(row1.Fields[0].Data.Uri, "http://example.com/Tenant/100/Book/5001") + + // Moment field (PublishedDate) + XCTAssertEqual(row1.Fields[1].Field.Attribute, "PublishedDate") + XCTAssertEqual(row1.Fields[1].Data.Moment, "2025-04-06T15:04:56-05:00") + + // Moment field (CheckoutDate) — self-closing + XCTAssertEqual(row1.Fields[2].Field.Attribute, "CheckoutDate") + XCTAssertNil(row1.Fields[2].Data.Moment) + + // Text field (Reader Id) + XCTAssertEqual(row1.Fields[3].Field.Attribute, "Id") + XCTAssertEqual(row1.Fields[3].Data.Text, "R-414") + + // Text field (Reader FullName) + XCTAssertEqual(row1.Fields[4].Field.Attribute, "FullName") + XCTAssertEqual(row1.Fields[4].Data.Text, "Alice Wonderland") + + // Uri field (Genre) + XCTAssertEqual(row1.Fields[5].Field.Attribute, "URI") + XCTAssertEqual(row1.Fields[5].Data.Uri, "http://example.com/Tenant/100/Genre/7001") + + // Uri field (Shelf) — self-closing + XCTAssertEqual(row1.Fields[6].Field.Attribute, "URI") + XCTAssertNil(row1.Fields[6].Data.Uri) + + // Link fields (ContentLink, DocumentLink) + XCTAssertEqual(row1.Fields[7].Field.Attribute, "ContentLink") + XCTAssertEqual(row1.Fields[7].Data.Uri, + "https://api.example.com/Tenant/100/Book/5001/Content/HTML") + XCTAssertEqual(row1.Fields[8].Field.Attribute, "CoverLink") + XCTAssertEqual(row1.Fields[8].Data.Uri, + "https://api.example.com/Tenant/100/Book/5001/Cover/JPG") + + // --- Entry 2: Sparse — empty Text and Moment fields, no links --- + let row2 = feed.entries[1].content.row + XCTAssertEqual(row2.Id, "http://example.com/Tenant/100/Book/5002") + XCTAssertEqual(row2.Fields.count, 7) + + // Reader Id — empty + XCTAssertEqual(row2.Fields[3].Field.Source, "Reader") + XCTAssertEqual(row2.Fields[3].Field.Attribute, "Id") + XCTAssertNil(row2.Fields[3].Data.Text) + + // Reader FullName — self-closing + XCTAssertEqual(row2.Fields[4].Field.Attribute, "FullName") + XCTAssertNil(row2.Fields[4].Data.Text) + + // Shelf URI — self-closing + XCTAssertEqual(row2.Fields[6].Field.Attribute, "URI") + XCTAssertNil(row2.Fields[6].Data.Uri) + + // --- Entry 3: All populated, different genre URI --- + let row3 = feed.entries[2].content.row + XCTAssertEqual(row3.Id, "http://example.com/Tenant/100/Book/5003") + XCTAssertEqual(row3.Fields.count, 9) + + // Reader populated + XCTAssertEqual(row3.Fields[3].Data.Text, "R-789") + XCTAssertEqual(row3.Fields[4].Data.Text, "Bob Smith") + + // Different genre + XCTAssertEqual(row3.Fields[5].Data.Uri, "http://example.com/Tenant/100/Genre/7002") + } catch { + XCTFail("Decode failed: \(error)") + } + } + + // MARK: - Query with Multiple Parameters + + /// Decodes a query that has multiple parameter bindings, testing that + /// wrapper arrays with more than one child decode correctly. + func testDecodeQueryWithMultipleParameters() { + let decoder = XMLDecoder() + let data = """ + + + Book + Reader + + + + BookAuthor + http://example.com/Tenant/100/Author/42 + + + BookGenre + Fiction + + + BookPublishedAfter + 2024-01-01T00:00:00Z + + + + BookTitle + BookISBN + + + """.data(using: .utf8)! + + do { + let query = try decoder.decode(SearchQuery.self, from: data) + XCTAssertEqual(query.Sources, ["Book", "Reader"]) + XCTAssertEqual(query.Parameters.count, 3) + + // Parameter 1: Uri + XCTAssertEqual(query.Parameters[0].Data.Uri, "http://example.com/Tenant/100/Author/42") + XCTAssertNil(query.Parameters[0].Data.Text) + XCTAssertNil(query.Parameters[0].Data.Moment) + + // Parameter 2: Text + XCTAssertNil(query.Parameters[1].Data.Uri) + XCTAssertEqual(query.Parameters[1].Data.Text, "Fiction") + XCTAssertNil(query.Parameters[1].Data.Moment) + + // Parameter 3: Moment + XCTAssertNil(query.Parameters[2].Data.Uri) + XCTAssertNil(query.Parameters[2].Data.Text) + XCTAssertEqual(query.Parameters[2].Data.Moment, "2024-01-01T00:00:00Z") + + XCTAssertEqual(query.Columns.count, 2) + XCTAssertEqual(query.Columns[0].Attribute, "Title") + XCTAssertEqual(query.Columns[1].Attribute, "ISBN") + } catch { + XCTFail("Decode failed: \(error)") + } + } + + // MARK: - Row with Minimum Fields + + /// Decodes a row with only the bare minimum fields (no link fields), + /// ensuring the decoder handles varying field counts gracefully. + func testDecodeRowWithMinimumFields() { + let decoder = XMLDecoder() + let data = """ + + http://example.com/Tenant/100/Book/9999 + Book + + + BookURI + http://example.com/Tenant/100/Book/9999 + + + + """.data(using: .utf8)! + + do { + let row = try decoder.decode(SearchResultRow.self, from: data) + XCTAssertEqual(row.Id, "http://example.com/Tenant/100/Book/9999") + XCTAssertEqual(row.Source, "Book") + XCTAssertEqual(row.Fields.count, 1) + XCTAssertEqual(row.Fields[0].Data.Uri, "http://example.com/Tenant/100/Book/9999") + } catch { + XCTFail("Decode failed: \(error)") + } + } + + // MARK: - Full Atom Feed XML Fixture + + // Synthetic Atom feed using a fictional library/book catalog domain. + // Three entries with varying field counts and data completeness: + // Entry 1 (Book/5001): 9 fields, fully populated (Uri, Moment, Text, links) + // Entry 2 (Book/5002): 7 fields, sparse (empty Text, self-closing Moment/Uri, no links) + // Entry 3 (Book/5003): 9 fields, all populated with different values + static let searchResultFeedXML = """ + \ + \ + http://example.com/Tenant/100/BookSearch\ + Book Search Results\ + 2025-04-07T12:00:00Z\ + System\ + \ + http://example.com/Tenant/100/Book/5001\ + Row\ + 2025-04-07T12:00:00Z\ + System\ + \ + \ + \ + http://example.com/Tenant/100/Book/5001\ + Book\ + \ + BookURIhttp://example.com/Tenant/100/Book/5001\ + BookPublishedDate2025-04-06T15:04:56-05:00\ + BookCheckoutDate\ + ReaderIdR-414\ + ReaderFullNameAlice Wonderland\ + GenreURIhttp://example.com/Tenant/100/Genre/7001\ + ShelfURI\ + BookContentLinkhttps://api.example.com/Tenant/100/Book/5001/Content/HTML\ + BookCoverLinkhttps://api.example.com/Tenant/100/Book/5001/Cover/JPG\ + \ + \ + \ + \ + \ + http://example.com/Tenant/100/Book/5002\ + Row\ + 2025-04-07T12:00:00Z\ + System\ + \ + \ + \ + http://example.com/Tenant/100/Book/5002\ + Book\ + \ + BookURIhttp://example.com/Tenant/100/Book/5002\ + BookPublishedDate2025-04-06T15:03:52-05:00\ + BookCheckoutDate\ + ReaderId\ + ReaderFullName\ + GenreURIhttp://example.com/Tenant/100/Genre/7001\ + ShelfURI\ + \ + \ + \ + \ + \ + http://example.com/Tenant/100/Book/5003\ + Row\ + 2025-04-07T12:00:00Z\ + System\ + \ + \ + \ + http://example.com/Tenant/100/Book/5003\ + Book\ + \ + BookURIhttp://example.com/Tenant/100/Book/5003\ + BookPublishedDate2025-03-15T10:30:00-05:00\ + BookCheckoutDate\ + ReaderIdR-789\ + ReaderFullNameBob Smith\ + GenreURIhttp://example.com/Tenant/100/Genre/7002\ + ShelfURI\ + BookContentLinkhttps://api.example.com/Tenant/100/Book/5003/Content/HTML\ + BookCoverLinkhttps://api.example.com/Tenant/100/Book/5003/Cover/JPG\ + \ + \ + \ + \ + + """ +} diff --git a/Tests/KumoTests/Mocks/XML/GenericModels.swift b/Tests/KumoTests/Mocks/XML/GenericModels.swift new file mode 100644 index 0000000..6b0cb35 --- /dev/null +++ b/Tests/KumoTests/Mocks/XML/GenericModels.swift @@ -0,0 +1,365 @@ +// Generic domain models for XML decoding tests. +// Uses fictional domains (weather, e-commerce, employee directory, metrics) +// to exercise KumoCoding XMLDecoder edge cases without referencing +// any internal or proprietary systems. +// +// NOTE: Properties use PascalCase with explicit CodingKeys so that +// KeyedXMLDecodingContainer.contains() and decodeNil(forKey:) — which +// both match by exact stringValue — work correctly with the XML element +// names. This follows the same convention used by the existing +// HeaderConfiguration and SearchResult mock models. +import Foundation + +// MARK: - Weather Forecast (nested optionals, multiple entries) + +struct WeatherForecastResponse: Decodable, Equatable { + let Location: String + let Forecasts: [Forecast] + + struct Forecast: Decodable, Equatable { + let Date: String + let High: Double + let Low: Double + let Condition: String + let Precipitation: Double? + let WindSpeed: Double? + let Advisory: String? + + private enum CodingKeys: String, CodingKey { + case Date, High, Low, Condition, Precipitation, WindSpeed, Advisory + } + } + + private enum CodingKeys: String, CodingKey { + case Location, Forecasts + } +} + +// MARK: - Order List (multiple entries, empty arrays, nested elements) + +struct OrderListResponse: Decodable, Equatable { + let TotalCount: Int + let Orders: [Order] + + private enum CodingKeys: String, CodingKey { + case TotalCount, Orders + } +} + +struct Order: Decodable, Equatable { + let OrderId: String + let Status: String + let Customer: OrderCustomer + let Items: [OrderItem] + let Notes: [String]? + + private enum CodingKeys: String, CodingKey { + case OrderId, Status, Customer, Items, Notes + } + + init(orderId: String, status: String, customer: OrderCustomer, items: [OrderItem], notes: [String]?) { + self.OrderId = orderId + self.Status = status + self.Customer = customer + self.Items = items + self.Notes = notes + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + OrderId = try container.decode(String.self, forKey: .OrderId) + Status = try container.decode(String.self, forKey: .Status) + Customer = try container.decode(OrderCustomer.self, forKey: .Customer) + Items = try container.decode([OrderItem].self, forKey: .Items) + + if container.contains(.Notes) { + let isNil = try container.decodeNil(forKey: .Notes) + if isNil { + Notes = nil + } else { + Notes = try container.decode([String].self, forKey: .Notes) + } + } else { + Notes = nil + } + } +} + +struct OrderCustomer: Decodable, Equatable { + let Name: String + let Email: String? + let Phone: String? + + private enum CodingKeys: String, CodingKey { + case Name, Email, Phone + } +} + +struct OrderItem: Decodable, Equatable { + let Sku: String + let Name: String + let Quantity: Int + let Price: Double + + private enum CodingKeys: String, CodingKey { + case Sku, Name, Quantity, Price + } +} + +// MARK: - Employee Directory (deeply nested, optional sections) + +struct EmployeeDirectory: Decodable, Equatable { + let Department: String + let Employees: [Employee] + + private enum CodingKeys: String, CodingKey { + case Department, Employees + } +} + +struct Employee: Decodable, Equatable { + let Id: String + let Name: String + let Title: String + let Contact: EmployeeContact + let Skills: [String]? + let Projects: [EmployeeProject]? + + private enum CodingKeys: String, CodingKey { + case Id, Name, Title, Contact, Skills, Projects + } + + init(id: String, name: String, title: String, contact: EmployeeContact, + skills: [String]?, projects: [EmployeeProject]?) { + self.Id = id + self.Name = name + self.Title = title + self.Contact = contact + self.Skills = skills + self.Projects = projects + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + Id = try container.decode(String.self, forKey: .Id) + Name = try container.decode(String.self, forKey: .Name) + Title = try container.decode(String.self, forKey: .Title) + Contact = try container.decode(EmployeeContact.self, forKey: .Contact) + + if container.contains(.Skills) { + let isNil = try container.decodeNil(forKey: .Skills) + Skills = isNil ? nil : try container.decode([String].self, forKey: .Skills) + } else { + Skills = nil + } + + if container.contains(.Projects) { + let isNil = try container.decodeNil(forKey: .Projects) + Projects = isNil ? nil : try container.decode([EmployeeProject].self, forKey: .Projects) + } else { + Projects = nil + } + } +} + +struct EmployeeContact: Decodable, Equatable { + let Email: String + let Phone: String? + let Address: EmployeeAddress? + + private enum CodingKeys: String, CodingKey { + case Email, Phone, Address + } +} + +struct EmployeeAddress: Decodable, Equatable { + let Street: String + let City: String + let State: String + let Zip: String + + private enum CodingKeys: String, CodingKey { + case Street, City, State, Zip + } +} + +struct EmployeeProject: Decodable, Equatable { + let Name: String + let Role: String + let Active: Bool + + private enum CodingKeys: String, CodingKey { + case Name, Role, Active + } +} + +// MARK: - Catalog Item (SOAP payload with deeply nested structure) + +struct CatalogItem: Decodable, Equatable { + let Id: String + let Name: String + let Category: String + let Pricing: CatalogPricing + let Inventory: CatalogInventory + let Tags: [String]? + + private enum CodingKeys: String, CodingKey { + case Id, Name, Category, Pricing, Inventory, Tags + } + + init(id: String, name: String, category: String, pricing: CatalogPricing, + inventory: CatalogInventory, tags: [String]?) { + self.Id = id + self.Name = name + self.Category = category + self.Pricing = pricing + self.Inventory = inventory + self.Tags = tags + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + Id = try container.decode(String.self, forKey: .Id) + Name = try container.decode(String.self, forKey: .Name) + Category = try container.decode(String.self, forKey: .Category) + Pricing = try container.decode(CatalogPricing.self, forKey: .Pricing) + Inventory = try container.decode(CatalogInventory.self, forKey: .Inventory) + + if container.contains(.Tags) { + let isNil = try container.decodeNil(forKey: .Tags) + Tags = isNil ? nil : try container.decode([String].self, forKey: .Tags) + } else { + Tags = nil + } + } +} + +struct CatalogPricing: Decodable, Equatable { + let BasePrice: Double + let Currency: String + let Discount: Double? + let TaxRate: Double? + + private enum CodingKeys: String, CodingKey { + case BasePrice, Currency, Discount, TaxRate + } +} + +struct CatalogInventory: Decodable, Equatable { + let Warehouse: String + let Quantity: Int + let Reserved: Int + let ReorderThreshold: Int? + + private enum CodingKeys: String, CodingKey { + case Warehouse, Quantity, Reserved, ReorderThreshold + } +} + +// MARK: - Notification List (Atom feed-style with unkeyed iteration) + +struct NotificationFeed: Decodable { + let notifications: [NotificationEntry] + + init(from decoder: Decoder) throws { + var results: [NotificationEntry] = [] + var container = try decoder.unkeyedContainer() + while !container.isAtEnd { + if let entry = try? container.decode(NotificationEntry.self) { + results.append(entry) + } + } + self.notifications = results + } +} + +struct NotificationEntry: Decodable, Equatable { + let content: NotificationContent + + private enum CodingKeys: String, CodingKey { + case content + } +} + +struct NotificationContent: Decodable, Equatable { + let notification: NotificationPayload + + private enum CodingKeys: String, CodingKey { + case notification = "Notification" + } +} + +struct NotificationPayload: Decodable, Equatable { + let Id: String + let Title: String + let Message: String? + let Priority: String + let Read: Bool + let Timestamp: String + + private enum CodingKeys: String, CodingKey { + case Id, Title, Message, Priority, Read, Timestamp + } +} + +// MARK: - Polymorphic Metric Values + +struct MetricValue: Decodable, Equatable { + let Gauge: String? + let Counter: String? + let Timestamp: String? + + private enum CodingKeys: String, CodingKey { + case Gauge, Counter, Timestamp + } + + init(gauge: String? = nil, counter: String? = nil, timestamp: String? = nil) { + self.Gauge = gauge + self.Counter = counter + self.Timestamp = timestamp + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + if container.contains(.Gauge) { + let isNil = try container.decodeNil(forKey: .Gauge) + Gauge = isNil ? nil : try container.decode(String.self, forKey: .Gauge) + } else { + Gauge = nil + } + + if container.contains(.Counter) { + let isNil = try container.decodeNil(forKey: .Counter) + Counter = isNil ? nil : try container.decode(String.self, forKey: .Counter) + } else { + Counter = nil + } + + if container.contains(.Timestamp) { + let isNil = try container.decodeNil(forKey: .Timestamp) + Timestamp = isNil ? nil : try container.decode(String.self, forKey: .Timestamp) + } else { + Timestamp = nil + } + } +} + +struct MetricItem: Decodable, Equatable { + let Field: FieldModel + let Value: MetricValue + + private enum CodingKeys: String, CodingKey { + case Field, Value + } +} + +struct MetricRow: Decodable, Equatable { + let Id: String + let Source: String + let Metrics: [MetricItem] + + private enum CodingKeys: String, CodingKey { + case Id, Source, Metrics + } +} diff --git a/Tests/KumoTests/Mocks/XML/GetPriceResponse.swift b/Tests/KumoTests/Mocks/XML/GetPriceResponse.swift index 592db81..b7da3ef 100644 --- a/Tests/KumoTests/Mocks/XML/GetPriceResponse.swift +++ b/Tests/KumoTests/Mocks/XML/GetPriceResponse.swift @@ -1,7 +1,7 @@ import Foundation -struct GetPriceResponse: Decodable, Equatable { - struct Price: Decodable, Equatable { +struct GetPriceResponse: Codable, Equatable { + struct Price: Codable, Equatable { let amount: Double let units: String } diff --git a/Tests/KumoTests/Mocks/XML/HeaderConfiguration.swift b/Tests/KumoTests/Mocks/XML/HeaderConfiguration.swift new file mode 100644 index 0000000..01f6722 --- /dev/null +++ b/Tests/KumoTests/Mocks/XML/HeaderConfiguration.swift @@ -0,0 +1,187 @@ +// HeaderConfiguration models for decoding XML configuration payloads. +// Used by KumoCoding XMLDecoder tests. +import Foundation + +// MARK: - Atom Feed Wrappers + +/// Top-level Atom `` element containing `` elements. +/// Uses a custom decoder because the Atom feed has mixed children +/// (id, title, updated, author, entry, …) and the KumoCoding XMLDecoder +/// only supports keyed-find-first, so we iterate with an unkeyed container +/// and collect successful entry decodes. +struct HeaderConfigurationFeed: Decodable { + let entry: [HeaderConfigurationEntry] + + init(from decoder: Decoder) throws { + var entries: [HeaderConfigurationEntry] = [] + var container = try decoder.unkeyedContainer() + while !container.isAtEnd { + if let entry = try? container.decode(HeaderConfigurationEntry.self) { + entries.append(entry) + } + } + self.entry = entries + } +} + +/// Single Atom `` whose `` holds a `Configuration`. +struct HeaderConfigurationEntry: Decodable { + let content: HeaderConfigurationContent + + private enum CodingKeys: String, CodingKey { + case content = "content" + } +} + +/// The `` wrapper that contains the nested `Configuration`. +struct HeaderConfigurationContent: Decodable { + let configuration: Configuration + + private enum CodingKeys: String, CodingKey { + case configuration = "Configuration" + } +} + +// MARK: - Domain Models + +struct Configuration: Codable, Equatable { + var Name: String + var Details: Details + var ItemSearching: ItemSearching + var ShipmentSearching: ShipmentSearching + + private enum CodingKeys: String, CodingKey { + case Name = "Name" + case Details = "Details" + case ItemSearching = "ItemSearching" + case ShipmentSearching = "ShipmentSearching" + } +} + +struct Details: Codable, Equatable { + var Controls: [Control] + + private enum CodingKeys: String, CodingKey { + case Controls = "Controls" + } +} + +struct Control: Codable, Equatable { + var Label: String? // optional — not present in every control + var Field: FieldModel + var DataType: String + var Required: String + var ReadOnly: String + var MaximumLength: Int? + var Choices: [String]? // optional — may be absent or empty self-closing tag + + private enum CodingKeys: String, CodingKey { + case Label = "Label" + case Field = "Field" + case DataType = "DataType" + case Required = "Required" + case ReadOnly = "ReadOnly" + case MaximumLength = "MaximumLength" + case Choices = "Choices" + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + Label = try container.decodeIfPresent(String.self, forKey: .Label) + Field = try container.decode(FieldModel.self, forKey: .Field) + DataType = try container.decode(String.self, forKey: .DataType) + Required = try container.decode(String.self, forKey: .Required) + ReadOnly = try container.decode(String.self, forKey: .ReadOnly) + MaximumLength = try container.decodeIfPresent(Int.self, forKey: .MaximumLength) + + // Choices can be absent, empty (), or contain children. + if container.contains(.Choices) { + let isNil = try container.decodeNil(forKey: .Choices) + if isNil { + Choices = nil + } else { + Choices = try container.decode([String].self, forKey: .Choices) + } + } else { + Choices = nil + } + } +} + +struct FieldModel: Codable, Equatable { + var Source: String + var Attribute: String + + private enum CodingKeys: String, CodingKey { + case Source = "Source" + case Attribute = "Attribute" + } +} + +struct ItemSearching: Codable, Equatable { + var Sources: [String] + var Target: String + var Criteria: [Criterion] + var Columns: [Column] + + private enum CodingKeys: String, CodingKey { + case Sources = "Sources" + case Target = "Target" + case Criteria = "Criteria" + case Columns = "Columns" + } +} + +struct ShipmentSearching: Codable, Equatable { + var Sources: [String] + var Target: String + var ReadOnlyCriteria: [Criterion] + var Columns: [Column] + + private enum CodingKeys: String, CodingKey { + case Sources = "Sources" + case Target = "Target" + case ReadOnlyCriteria = "ReadOnlyCriteria" + case Columns = "Columns" + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + Sources = try container.decode([String].self, forKey: .Sources) + Target = try container.decode(String.self, forKey: .Target) + + // ReadOnlyCriteria can be an empty self-closing tag () + let isNil = try container.decodeNil(forKey: .ReadOnlyCriteria) + if isNil { + ReadOnlyCriteria = [] + } else { + ReadOnlyCriteria = try container.decode([Criterion].self, forKey: .ReadOnlyCriteria) + } + + Columns = try container.decode([Column].self, forKey: .Columns) + } +} + +struct Criterion: Codable, Equatable { + var Label: String? // optional — not always present + var `Type`: String + var Field: FieldModel + + private enum CodingKeys: String, CodingKey { + case Label = "Label" + case `Type` = "Type" + case Field = "Field" + } +} + +struct Column: Codable, Equatable { + var Label: String? // optional — not always present + var `Type`: String + var Field: FieldModel + + private enum CodingKeys: String, CodingKey { + case Label = "Label" + case `Type` = "Type" + case Field = "Field" + } +} diff --git a/Tests/KumoTests/Mocks/XML/SearchResult.swift b/Tests/KumoTests/Mocks/XML/SearchResult.swift new file mode 100644 index 0000000..6e042b4 --- /dev/null +++ b/Tests/KumoTests/Mocks/XML/SearchResult.swift @@ -0,0 +1,178 @@ +// Models for decoding search query/result XML payloads. +// Used by KumoCoding XMLDecoder tests. +// +// Covers patterns: +// - Atom feed with multiple rows (unkeyed iteration) +// - Polymorphic elements containing one of , , or +// - Self-closing / empty elements (, , , ) +// - Nested wrapper arrays (Fields > Item, Parameters > Parameter) +// - Mixed sibling types in (id, title, updated, author, link, content) +import Foundation + +// MARK: - Atom Feed Wrappers + +/// Top-level Atom `` element containing search result `` rows. +/// Uses the same unkeyed-container pattern as HeaderConfigurationFeed to +/// iterate mixed children and collect entry elements. +struct SearchResultFeed: Decodable { + let entries: [SearchResultEntry] + + init(from decoder: Decoder) throws { + var results: [SearchResultEntry] = [] + var container = try decoder.unkeyedContainer() + while !container.isAtEnd { + if let entry = try? container.decode(SearchResultEntry.self) { + results.append(entry) + } + } + self.entries = results + } +} + +/// Single Atom `` whose `` holds a `SearchResultRow`. +struct SearchResultEntry: Decodable { + let content: SearchResultContent + + private enum CodingKeys: String, CodingKey { + case content + } +} + +/// The `` wrapper containing the nested `Row`. +struct SearchResultContent: Decodable { + let row: SearchResultRow + + private enum CodingKeys: String, CodingKey { + case row = "Row" + } +} + +// MARK: - Search Query (request payload) + +/// A search query containing sources, parameters, and requested columns. +/// +/// XML structure: +/// ``` +/// +/// ... +/// +/// ...... +/// +/// ... +/// +/// ``` +struct SearchQuery: Decodable, Equatable { + let Sources: [String] + let Parameters: [SearchParameter] + let Columns: [FieldModel] + + private enum CodingKeys: String, CodingKey { + case Sources + case Parameters + case Columns + } +} + +/// A single query parameter binding a field to a data value. +struct SearchParameter: Decodable, Equatable { + let Field: FieldModel + let Data: DataValue + + private enum CodingKeys: String, CodingKey { + case Field + case Data + } +} + +// MARK: - Search Result Row (response payload) + +/// A single search result row containing an identifier, source, and +/// a dynamic list of field/value items. +/// +/// XML structure: +/// ``` +/// +/// ... +/// ... +/// +/// ...... +/// +/// +/// ``` +struct SearchResultRow: Decodable, Equatable { + let Id: String + let Source: String + let Fields: [SearchResultItem] + + private enum CodingKeys: String, CodingKey { + case Id + case Source + case Fields + } +} + +/// A single field/value pair within a result row. +struct SearchResultItem: Decodable, Equatable { + let Field: FieldModel + let Data: DataValue + + private enum CodingKeys: String, CodingKey { + case Field + case Data + } +} + +// MARK: - Polymorphic Data Value + +/// Represents a polymorphic data element that contains exactly one of: +/// - `value` — a resource identifier / link +/// - `value` — a timestamp string +/// - `value` — plain text +/// +/// Any of these child elements may be self-closing or empty, indicating +/// an absent/null value (e.g. ``, ``, ``). +struct DataValue: Decodable, Equatable { + let Uri: String? + let Moment: String? + let Text: String? + + private enum CodingKeys: String, CodingKey { + case Uri + case Moment + case Text + } + + init(uri: String? = nil, moment: String? = nil, text: String? = nil) { + self.Uri = uri + self.Moment = moment + self.Text = text + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + // Each sub-element may be absent, empty/self-closing, or have content. + // decodeIfPresent returns nil when the key is absent. + // When present but empty (), the String decode returns "". + if container.contains(.Uri) { + let isNil = try container.decodeNil(forKey: .Uri) + Uri = isNil ? nil : try container.decode(String.self, forKey: .Uri) + } else { + Uri = nil + } + + if container.contains(.Moment) { + let isNil = try container.decodeNil(forKey: .Moment) + Moment = isNil ? nil : try container.decode(String.self, forKey: .Moment) + } else { + Moment = nil + } + + if container.contains(.Text) { + let isNil = try container.decodeNil(forKey: .Text) + Text = isNil ? nil : try container.decode(String.self, forKey: .Text) + } else { + Text = nil + } + } +} diff --git a/Tests/KumoTests/Mocks/XML/Unkeyed.swift b/Tests/KumoTests/Mocks/XML/Unkeyed.swift index cfc4c0e..f4ec9eb 100644 --- a/Tests/KumoTests/Mocks/XML/Unkeyed.swift +++ b/Tests/KumoTests/Mocks/XML/Unkeyed.swift @@ -8,15 +8,49 @@ struct Message: Decodable, Equatable { let date: String } -struct ListContainer: Encodable, Equatable { +struct ListContainer: Codable, Equatable { let simpleList: [String] } -struct ComplexListContainer: Encodable, Equatable { +struct ComplexListContainer: Codable, Equatable { let complexList: [ComplexElement] } -struct ComplexElement: Encodable, Equatable { +struct ComplexElement: Codable, Equatable { let x: String let y: String } + +struct NilableContainer: Decodable, Equatable { + let name: String + let nickname: String? +} + +struct NilableEncodable: Encodable, Equatable { + let name: String + let nickname: String? + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(name, forKey: .name) + if let nickname = nickname { + try container.encode(nickname, forKey: .nickname) + } else { + try container.encodeNil(forKey: .nickname) + } + } + + private enum CodingKeys: String, CodingKey { + case name, nickname + } +} + +struct SnakeCaseModel: Codable, Equatable { + let firstName: String + let lastName: String +} + +struct DefaultKeyModel: Codable, Equatable { + let title: String + let count: Int +}