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
+}