diff --git a/Sources/KarrotCodableKit/PolymorphicCodable/DefaultEmptyPolymorphicArrayValue.swift b/Sources/KarrotCodableKit/PolymorphicCodable/DefaultEmptyPolymorphicArrayValue.swift index 40c4917..46b4cad 100644 --- a/Sources/KarrotCodableKit/PolymorphicCodable/DefaultEmptyPolymorphicArrayValue.swift +++ b/Sources/KarrotCodableKit/PolymorphicCodable/DefaultEmptyPolymorphicArrayValue.swift @@ -14,21 +14,30 @@ import Foundation /// Decoding Behavior: /// - It attempts to decode an unkeyed container (JSON array). /// - If the container is successfully obtained, it decodes each element using `PolymorphicValue`. -/// - **Crucially, if *any* element within the array fails to decode according to the `PolymorphicType` strategy, the entire decoding process for the wrapper fails, and an error is thrown.** This wrapper does **not** skip invalid elements. -/// - If the initial step of obtaining the unkeyed container fails (e.g., the key is missing in the parent JSON object, or the corresponding value is `null` or not an array), it catches the error, assigns `[]` to `wrappedValue`, and logs the error using `print`. +/// - **Crucially, if *any* element within the array fails to decode according to the `PolymorphicType` strategy, +/// the error is caught and the *entire* array falls back to an empty array `[]`.** This wrapper does **not** +/// skip individual invalid elements while keeping the valid ones. +/// - If obtaining the unkeyed container fails (e.g., the key is missing, the value is `null`, or the value is +/// not an array), it likewise catches the error and assigns `[]` to `wrappedValue`. +/// - Every recovered error is recorded as a `.recoveredFrom` outcome and, in DEBUG builds, reported to the +/// resilient decoding error reporter. /// /// Encoding Behavior: -/// - Encodes the `wrappedValue` array. Each element is wrapped using `PolymorphicValue` before being added to the encoded array. +/// - Encodes the `wrappedValue` array. Each element is wrapped using `PolymorphicValue` +/// before being added to the encoded array. /// -/// Use this wrapper when you expect an array that should either be present and entirely valid (according to the strategy) or completely absent/null, in which case an empty array is acceptable. -/// If you need to gracefully handle individual invalid elements within the array, use `@PolymorphicLossyArrayValue` instead. +/// Use this wrapper when you expect an array that should either be entirely valid (according to the strategy) +/// or absent/null — any failure yields an empty array rather than a decoding error. +/// If you need to gracefully handle individual invalid elements within the array, +/// use `@PolymorphicLossyArrayValue` instead. /// -/// **Note:** If you need to decode JSON arrays that may contain some invalid elements and want to ignore just those elements -/// while keeping the valid ones, use `@PolymorphicLossyArrayValue` instead of this wrapper. +/// **Note:** If you need to decode JSON arrays that may contain some invalid elements and want to ignore just +/// those elements while keeping the valid ones, use `@PolymorphicLossyArrayValue` instead of this wrapper. /// @propertyWrapper public struct DefaultEmptyPolymorphicArrayValue { - /// The decoded array of values. Defaults to an empty array `[]` if the array key is missing or decoding fails at the array level. + /// The decoded array of values. Defaults to an empty array `[]` if the array key is missing + /// or decoding fails at the array level. public var wrappedValue: [PolymorphicType.ExpectedType] /// Tracks the outcome of the decoding process for resilient decoding diff --git a/Sources/KarrotCodableKit/PolymorphicCodable/Extensions/KeyedDecodingContainer+OptionalPolymorphicLossyArrayValue.swift b/Sources/KarrotCodableKit/PolymorphicCodable/Extensions/KeyedDecodingContainer+OptionalPolymorphicLossyArrayValue.swift index c3c5053..c1624ab 100644 --- a/Sources/KarrotCodableKit/PolymorphicCodable/Extensions/KeyedDecodingContainer+OptionalPolymorphicLossyArrayValue.swift +++ b/Sources/KarrotCodableKit/PolymorphicCodable/Extensions/KeyedDecodingContainer+OptionalPolymorphicLossyArrayValue.swift @@ -29,13 +29,7 @@ extension KeyedDecodingContainer { return nil } - // Check if value is null - if try decodeNil(forKey: key) { - return OptionalPolymorphicLossyArrayValue(wrappedValue: nil, outcome: .valueWasNil) - } - - // Try to decode the array with lossy behavior - let decoder = try superDecoder(forKey: key) - return try OptionalPolymorphicLossyArrayValue(from: decoder) + // Null, non-array values, and element failures are all handled inside `init(from:)`. + return try OptionalPolymorphicLossyArrayValue(from: superDecoder(forKey: key)) } } diff --git a/Sources/KarrotCodableKit/PolymorphicCodable/Extensions/KeyedDecodingContainer+PolymorphicLossyArrayValue.swift b/Sources/KarrotCodableKit/PolymorphicCodable/Extensions/KeyedDecodingContainer+PolymorphicLossyArrayValue.swift index 0960e0a..34cbfdd 100644 --- a/Sources/KarrotCodableKit/PolymorphicCodable/Extensions/KeyedDecodingContainer+PolymorphicLossyArrayValue.swift +++ b/Sources/KarrotCodableKit/PolymorphicCodable/Extensions/KeyedDecodingContainer+PolymorphicLossyArrayValue.swift @@ -33,42 +33,8 @@ extension KeyedDecodingContainer { #endif } - // Check if value is null - if try decodeNil(forKey: key) { - #if DEBUG - let context = DecodingError.Context( - codingPath: codingPath + [key], - debugDescription: "Value was nil but property is non-optional" - ) - let error = DecodingError.valueNotFound([Any].self, context) - let decoder = try superDecoder(forKey: key) - decoder.reportError(error) - return PolymorphicLossyArrayValue( - wrappedValue: [], - outcome: .recoveredFrom(error, wasReported: true), - results: [] - ) - #else - return PolymorphicLossyArrayValue(wrappedValue: [], outcome: .valueWasNil) - #endif - } - - // Try to decode the array - do { - let decoder = try superDecoder(forKey: key) - return try PolymorphicLossyArrayValue(from: decoder) - } catch { - // If decoding fails (e.g., not an array), return empty array - #if DEBUG - return PolymorphicLossyArrayValue( - wrappedValue: [], - outcome: .recoveredFrom(error, wasReported: false), - results: [] - ) - #else - return PolymorphicLossyArrayValue(wrappedValue: [], outcome: .recoveredFrom(error, wasReported: false)) - #endif - } + // Null, non-array values, and element failures are all recovered inside `init(from:)`. + return try PolymorphicLossyArrayValue(from: superDecoder(forKey: key)) } public func decodeIfPresent( @@ -80,28 +46,7 @@ extension KeyedDecodingContainer { return nil } - // Check if value is null - if try decodeNil(forKey: key) { - #if DEBUG - let context = DecodingError.Context( - codingPath: codingPath + [key], - debugDescription: "Value was nil but property is non-optional" - ) - let error = DecodingError.valueNotFound([Any].self, context) - let decoder = try superDecoder(forKey: key) - decoder.reportError(error) - return PolymorphicLossyArrayValue( - wrappedValue: [], - outcome: .recoveredFrom(error, wasReported: true), - results: [] - ) - #else - return PolymorphicLossyArrayValue(wrappedValue: []) - #endif - } - - // Try to decode using PolymorphicLossyArrayValue's decoder - let decoder = try superDecoder(forKey: key) - return try PolymorphicLossyArrayValue(from: decoder) + // Null, non-array values, and element failures are all recovered inside `init(from:)`. + return try PolymorphicLossyArrayValue(from: superDecoder(forKey: key)) } } diff --git a/Sources/KarrotCodableKit/PolymorphicCodable/Extensions/UnkeyedDecodingContainer+LossyPolymorphicElements.swift b/Sources/KarrotCodableKit/PolymorphicCodable/Extensions/UnkeyedDecodingContainer+LossyPolymorphicElements.swift new file mode 100644 index 0000000..d3d0fcf --- /dev/null +++ b/Sources/KarrotCodableKit/PolymorphicCodable/Extensions/UnkeyedDecodingContainer+LossyPolymorphicElements.swift @@ -0,0 +1,56 @@ +// +// UnkeyedDecodingContainer+LossyPolymorphicElements.swift +// KarrotCodableKit +// +// Created by Elon on 7/15/26. +// Copyright © 2026 Danggeun Market Inc. All rights reserved. +// + +import Foundation + +/// The outcome of a lossy polymorphic array pass. Per-element results are retained only in +/// DEBUG builds, so release builds allocate a single element array and no `Result` storage. +struct LossyPolymorphicElements { + let elements: [Element] + #if DEBUG + let results: [Result] + #endif +} + +extension UnkeyedDecodingContainer { + /// Decodes every element with the polymorphic `strategy`, skipping elements that fail to + /// decode instead of throwing, so lossy array wrappers can keep the valid elements. + /// In DEBUG builds each element's `Result` is also captured for resilient decoding outcomes. + mutating func decodeLossyPolymorphicElements( + of strategy: Strategy.Type + ) throws -> LossyPolymorphicElements { + var elements = [Strategy.ExpectedType]() + #if DEBUG + var results = [Result]() + #endif + + while !isAtEnd { + // Decoding through the element's super decoder always advances the container, + // even when the element fails to decode. + let elementDecoder = try superDecoder() + do { + let value = try PolymorphicValue(from: elementDecoder).wrappedValue + elements.append(value) + #if DEBUG + results.append(.success(value)) + #endif + } catch { + #if DEBUG + elementDecoder.reportError(error) + results.append(.failure(error)) + #endif + } + } + + #if DEBUG + return LossyPolymorphicElements(elements: elements, results: results) + #else + return LossyPolymorphicElements(elements: elements) + #endif + } +} diff --git a/Sources/KarrotCodableKit/PolymorphicCodable/OptionalPolymorphicLossyArrayValue.swift b/Sources/KarrotCodableKit/PolymorphicCodable/OptionalPolymorphicLossyArrayValue.swift index f1ecd87..5819218 100644 --- a/Sources/KarrotCodableKit/PolymorphicCodable/OptionalPolymorphicLossyArrayValue.swift +++ b/Sources/KarrotCodableKit/PolymorphicCodable/OptionalPolymorphicLossyArrayValue.swift @@ -11,23 +11,29 @@ import Foundation /// A property wrapper that decodes an optional array of polymorphic objects with lossy behavior /// for individual elements. /// -/// This wrapper combines the optionality handling of ``OptionalPolymorphicArrayValue`` with -/// the lossy element decoding of ``PolymorphicLossyArrayValue``. +/// This is the optional variant of ``PolymorphicLossyArrayValue`` and follows the exact same +/// policy; the only difference is that a missing key or an explicit `null` is represented +/// as `nil` instead of `[]`. /// /// Key behaviors: /// - The array itself is optional (`[Element]?`), returning `nil` when the key is missing or the value is `null` /// - Invalid elements within a present array are silently skipped rather than causing decoding failure +/// - A present value that is not a valid JSON array recovers to an empty array `[]` instead of +/// throwing; `nil` stays reserved for a missing key or an explicit `null` /// /// Comparison with similar wrappers: -/// - ``PolymorphicLossyArrayValue``: For required arrays that default to `[]` when missing or null +/// - ``PolymorphicLossyArrayValue``: For required arrays that default to `[]` when missing, null, +/// or not a valid JSON array /// - ``OptionalPolymorphicArrayValue``: For optional arrays that throw on invalid elements /// - ``DefaultEmptyPolymorphicArrayValue``: For required arrays that default to `[]` when missing or null, -/// strict on elements +/// and fall back to `[]` entirely when any element is invalid /// /// Decoding behavior: /// - If the key is missing or the value is `null`, `wrappedValue` is set to `nil` /// - If the value is a valid array, each element is decoded using `PolymorphicValue` -/// - If an element fails to decode, the error is caught and the element is **skipped** +/// - If an element fails to decode, the error is caught and the element is **skipped**; +/// the failure is recorded in the decoding `outcome` as an `ArrayDecodingError` +/// - If the value is not a valid JSON array, the error is recovered and `wrappedValue` is set to `[]` /// - Empty arrays are decoded as empty arrays, not `nil` /// /// Encoding behavior: @@ -60,7 +66,7 @@ public struct OptionalPolymorphicLossyArrayValue] = [] + results: [Result] = [], ) { self.wrappedValue = wrappedValue self.outcome = outcome @@ -82,45 +88,41 @@ public struct OptionalPolymorphicLossyArrayValue]() - #endif - - while !container.isAtEnd { - do { - let value = try container.decode(PolymorphicValue.self).wrappedValue - elements.append(value) - #if DEBUG - results.append(.success(value)) - #endif - } catch { - // Decoding processing to prevent infinite loops if decoding fails. - _ = try? container.decode(AnyDecodableValue.self) - #if DEBUG - results.append(.failure(error)) - #endif + do { + var container = try decoder.unkeyedContainer() + let decoded = try container.decodeLossyPolymorphicElements(of: PolymorphicType.self) + + #if DEBUG + if decoded.results.contains(where: \.isFailure) { + let error = ResilientDecodingOutcome.ArrayDecodingError(results: decoded.results) + self.init( + wrappedValue: decoded.elements, + outcome: .recoveredFrom(error, wasReported: false), + results: decoded.results, + ) + } else { + self.init(wrappedValue: decoded.elements, outcome: .decodedSuccessfully, results: decoded.results) } + #else + self.init(wrappedValue: decoded.elements, outcome: .decodedSuccessfully) + #endif + } catch { + // Same policy as `PolymorphicLossyArrayValue`: an invalid array-level value recovers to `[]`. + // `nil` stays reserved for a missing key or an explicit `null`. + #if DEBUG + decoder.reportError(error) + self.init(wrappedValue: [], outcome: .recoveredFrom(error, wasReported: true), results: []) + #else + self.init(wrappedValue: [], outcome: .recoveredFrom(error, wasReported: false)) + #endif } - - #if DEBUG - self.init(wrappedValue: elements, outcome: .decodedSuccessfully, results: results) - #else - self.init(wrappedValue: elements, outcome: .decodedSuccessfully) - #endif } } diff --git a/Sources/KarrotCodableKit/PolymorphicCodable/PolymorphicLossyArrayValue.swift b/Sources/KarrotCodableKit/PolymorphicCodable/PolymorphicLossyArrayValue.swift index 9af5b39..7fef80f 100644 --- a/Sources/KarrotCodableKit/PolymorphicCodable/PolymorphicLossyArrayValue.swift +++ b/Sources/KarrotCodableKit/PolymorphicCodable/PolymorphicLossyArrayValue.swift @@ -12,32 +12,42 @@ import Foundation public typealias DefaultEmptyPolymorphicLossyArrayValue = PolymorphicLossyArrayValue -/// A property wrapper that decodes an array of polymorphic objects with lossy behavior for individual elements, -/// and defaults to an empty array `[]` if the array key is missing, the value is `null`, or not a valid JSON array. +/// A property wrapper that decodes an array of polymorphic objects with lossy behavior for individual +/// elements, and defaults to an empty array `[]` if the array key is missing, the value is `null`, +/// or not a valid JSON array. /// /// Decoding Behavior: /// - Attempts to decode an unkeyed container (JSON array). /// - If the container is successfully obtained, it iterates through the elements: /// - For each element, it attempts to decode using `PolymorphicValue`. /// - If an element's decoding succeeds, the resulting value is kept. -/// - If an element's decoding fails (due to any error caught by the `PolymorphicType` strategy or `PolymorphicValue`), the error is caught, logged using `print`, and the element is **skipped**. -/// - To prevent infinite loops on invalid data, it attempts to decode the failing element as `AnyDecodableValue` to advance the container's position. +/// - If an element's decoding fails (due to any error caught by the `PolymorphicType` strategy or +/// `PolymorphicValue`), the error is caught and the element is **skipped**. The failure is recorded +/// in the decoding `outcome` as an `ArrayDecodingError` and, in DEBUG builds, reported to the +/// resilient decoding error reporter — matching `@LossyArray` in BetterCodable. /// - After iterating, `wrappedValue` contains an array of only the successfully decoded elements. -/// - If the initial step of obtaining the unkeyed container fails (e.g., missing key, `null` value, wrong type), it catches the error, assigns `[]` to `wrappedValue`, and logs the error. +/// - If the value is `null` or not a valid JSON array, the error is recovered inside `init(from:)` and +/// `wrappedValue` is set to `[]`, so the recovery also applies when the wrapper is decoded directly +/// (e.g., nested in other collections). A missing key is handled by the `KeyedDecodingContainer` +/// overloads and likewise yields `[]`. /// /// Encoding Behavior: -/// - Encodes the `wrappedValue` array. Each valid element is wrapped using `PolymorphicValue` before being added to the encoded array. +/// - Encodes the `wrappedValue` array. Each valid element is wrapped using +/// `PolymorphicValue` before being added to the encoded array. /// -/// This wrapper is ideal for handling arrays where some elements might be malformed, represent unknown types, -/// or are otherwise invalid, allowing the application to process the remaining valid elements without failure. +/// This wrapper is ideal for handling arrays where some elements might be malformed, represent unknown +/// types, or are otherwise invalid, allowing the application to process the remaining valid elements +/// without failure. /// -/// **Important:** When decoding, invalid or malformed elements in the JSON array are simply omitted from the resulting array -/// rather than causing the entire decoding process to fail. This allows you to successfully process JSON arrays -/// even when they contain elements that don't conform to your expected structure or type. +/// **Important:** When decoding, invalid or malformed elements in the JSON array are simply omitted from +/// the resulting array rather than causing the entire decoding process to fail. This allows you to +/// successfully process JSON arrays even when they contain elements that don't conform to your expected +/// structure or type. /// @propertyWrapper public struct PolymorphicLossyArrayValue { - /// The decoded array containing only the successfully decoded polymorphic elements. Defaults to an empty array `[]` if the array key is missing or the value is not an array. + /// The decoded array containing only the successfully decoded polymorphic elements. + /// Defaults to an empty array `[]` if the array key is missing or the value is not an array. public var wrappedValue: [PolymorphicType.ExpectedType] /// Tracks the outcome of the decoding process for resilient decoding @@ -50,9 +60,9 @@ public struct PolymorphicLossyArrayValue] = [] + results: [Result] = [], ) { self.wrappedValue = wrappedValue self.outcome = outcome @@ -82,38 +92,52 @@ public struct PolymorphicLossyArrayValue]() - #endif + // Check for null first + if let singleValueContainer = try? decoder.singleValueContainer(), singleValueContainer.decodeNil() { + #if DEBUG + let context = DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "Value was nil but property is non-optional", + ) + let error = DecodingError.valueNotFound([PolymorphicType.ExpectedType].self, context) + decoder.reportError(error) + self.init(wrappedValue: [], outcome: .recoveredFrom(error, wasReported: true), results: []) + #else + self.init(wrappedValue: [], outcome: .valueWasNil) + #endif + return + } - while !container.isAtEnd { - do { - let value = try container.decode(PolymorphicValue.self).wrappedValue - elements.append(value) - #if DEBUG - results.append(.success(value)) - #endif - } catch { - // Decoding processing to prevent infinite loops if decoding fails. - _ = try? container.decode(AnyDecodableValue.self) - #if DEBUG - results.append(.failure(error)) - #endif + do { + var container = try decoder.unkeyedContainer() + let decoded = try container.decodeLossyPolymorphicElements(of: PolymorphicType.self) + + #if DEBUG + if decoded.results.contains(where: \.isFailure) { + let error = ResilientDecodingOutcome.ArrayDecodingError(results: decoded.results) + self.init( + wrappedValue: decoded.elements, + outcome: .recoveredFrom(error, wasReported: false), + results: decoded.results, + ) + } else { + self.init(wrappedValue: decoded.elements, outcome: .decodedSuccessfully, results: decoded.results) } + #else + self.init(wrappedValue: decoded.elements) + #endif + } catch { + // An invalid array-level value (e.g., not an array) recovers to an empty array. + #if DEBUG + decoder.reportError(error) + self.init(wrappedValue: [], outcome: .recoveredFrom(error, wasReported: true), results: []) + #else + self.init(wrappedValue: [], outcome: .recoveredFrom(error, wasReported: false)) + #endif } - - self.wrappedValue = elements - self.outcome = .decodedSuccessfully - #if DEBUG - self.results = results - #endif } + } extension PolymorphicLossyArrayValue: Encodable { diff --git a/Tests/KarrotCodableKitTests/PolymorphicCodable/ArrayValue/LossyArrayDecodeIfPresentTests.swift b/Tests/KarrotCodableKitTests/PolymorphicCodable/ArrayValue/LossyArrayDecodeIfPresentTests.swift new file mode 100644 index 0000000..781370e --- /dev/null +++ b/Tests/KarrotCodableKitTests/PolymorphicCodable/ArrayValue/LossyArrayDecodeIfPresentTests.swift @@ -0,0 +1,166 @@ +// +// LossyArrayDecodeIfPresentTests.swift +// KarrotCodableKit +// +// Created by Elon on 7/15/26. +// Copyright © 2026 Danggeun Market Inc. All rights reserved. +// + +import Testing +import Foundation + +import KarrotCodableKit + +/// Characterizes the `decodeIfPresent(_:forKey:)` container overload for +/// `PolymorphicLossyArrayValue`, which synthesized `Codable` never calls for +/// `@PolymorphicLossyArray` (its `wrappedValue` is a non-optional `[T]`, so the +/// compiler-generated code always calls `decode`). +struct LossyArrayDecodeIfPresentTests { + + @Test func testDecodeIfPresentReturnsNilForMissingKey() throws { + // given + let jsonData = #"{ }"# + + // when + let result = try JSONDecoder().decode( + LossyArrayDecodeIfPresentDummyResponse.self, + from: Data(jsonData.utf8) + ) + + // then + #expect(result.notices1 == nil) + } + + @Test func testDecodeIfPresentReturnsEmptyArrayForNullValue() throws { + // given + let jsonData = #""" + { + "notices1" : null + } + """# + + // when + let result = try JSONDecoder().decode( + LossyArrayDecodeIfPresentDummyResponse.self, + from: Data(jsonData.utf8) + ) + + // then + let notices1 = try #require(result.notices1) + #expect(notices1.wrappedValue.isEmpty) + } + + @Test func testDecodeIfPresentDecodesPresentArray() throws { + // given + let jsonData = #""" + { + "notices1" : [ + { + "description" : "test", + "icon" : "test_icon", + "type" : "callout" + } + ] + } + """# + + // when + let result = try JSONDecoder().decode( + LossyArrayDecodeIfPresentDummyResponse.self, + from: Data(jsonData.utf8) + ) + + // then + let notices1 = try #require(result.notices1) + #expect(notices1.wrappedValue.count == 1) + #expect(notices1.wrappedValue.first?.type == .callout) + } + + @Test func testDecodeIfPresentRecoversEmptyArrayForNonArrayValue() throws { + // given + let jsonData = #""" + { + "notices1" : "not an array" + } + """# + + // when + let result = try JSONDecoder().decode( + LossyArrayDecodeIfPresentDummyResponse.self, + from: Data(jsonData.utf8) + ) + + // then: matches `decode(_:forKey:)`, which recovers a non-array value to an empty array + let notices1 = try #require(result.notices1) + #expect(notices1.wrappedValue.isEmpty) + } + + #if DEBUG + @Test func testDecodeIfPresentNonArrayOutcomeMatchesDecode() throws { + // given: the same non-array payload decoded through both container entry points + let jsonData = #""" + { + "notices1" : "not an array" + } + """# + + // when + let decodeResult = try JSONDecoder().decode( + OptionalLossyArrayDummyResponse.self, + from: Data(jsonData.utf8) + ) + let decodeIfPresentResult = try JSONDecoder().decode( + LossyArrayDecodeIfPresentDummyResponse.self, + from: Data(jsonData.utf8) + ) + + // then: both entry points recover and report the same outcome + let notices1 = try #require(decodeIfPresentResult.notices1) + #expect(notices1.outcome != .decodedSuccessfully) + #expect(notices1.outcome == decodeResult.$notices1.outcome) + } + + @Test func testDecodeIfPresentNullOutcomeMatchesDecode() throws { + // given: the same null payload decoded through both container entry points + let jsonData = #""" + { + "notices1" : null + } + """# + + // when + let decodeResult = try JSONDecoder().decode( + OptionalLossyArrayDummyResponse.self, + from: Data(jsonData.utf8) + ) + let decodeIfPresentResult = try JSONDecoder().decode( + LossyArrayDecodeIfPresentDummyResponse.self, + from: Data(jsonData.utf8) + ) + + // then: a null value must not be reported as a clean success by either entry point + let notices1 = try #require(decodeIfPresentResult.notices1) + #expect(notices1.outcome != .decodedSuccessfully) + #expect(notices1.outcome == decodeResult.$notices1.outcome) + } + #endif +} + +/// Reaches the container-level `decodeIfPresent(_:forKey:)` overload through a manual +/// `init(from:)`, since property-wrapper synthesis never routes through it. +private struct LossyArrayDecodeIfPresentDummyResponse: Decodable { + + let notices1: PolymorphicLossyArrayValue? + + enum CodingKeys: String, CodingKey { + case notices1 + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.notices1 = try container.decodeIfPresent( + PolymorphicLossyArrayValue.self, + forKey: .notices1 + ) + } +} diff --git a/Tests/KarrotCodableKitTests/PolymorphicCodable/ArrayValue/LossyArrayRecoveryPolicyTests.swift b/Tests/KarrotCodableKitTests/PolymorphicCodable/ArrayValue/LossyArrayRecoveryPolicyTests.swift new file mode 100644 index 0000000..388bb6c --- /dev/null +++ b/Tests/KarrotCodableKitTests/PolymorphicCodable/ArrayValue/LossyArrayRecoveryPolicyTests.swift @@ -0,0 +1,163 @@ +// +// LossyArrayRecoveryPolicyTests.swift +// KarrotCodableKit +// +// Created by Elon on 7/15/26. +// Copyright © 2026 Danggeun Market Inc. All rights reserved. +// + +import Testing +import Foundation + +import KarrotCodableKit + +/// Pins the shared recovery policy of the lossy array wrappers, aligned with BetterCodable's +/// `@LossyArray`: array-level failures (e.g., a non-array value) recover instead of throwing, +/// and element-level failures are recorded in the decoding `outcome`. +/// +/// `OptionalPolymorphicLossyArrayValue` follows the exact same policy as +/// `PolymorphicLossyArrayValue`; the only difference is that a missing key or an explicit +/// `null` is represented as `nil` instead of `[]`. +struct LossyArrayRecoveryPolicyTests { + + // MARK: - Array-level recovery for a non-array value + + @Test func testOptionalLossyArrayRecoversEmptyArrayForNonArrayValue() throws { + // given + let jsonData = #""" + { + "notices1" : "not an array" + } + """# + + // when + let result = try JSONDecoder().decode( + OptionalPolymorphicLossyArrayDummyResponse.self, + from: Data(jsonData.utf8) + ) + + // then: recovers to an empty array; `nil` stays reserved for a missing key or explicit null + #expect(try #require(result.notices1).isEmpty) + #expect(result.notices2 == nil) + } + + #if DEBUG + @Test func testOptionalLossyArrayNonArrayValueOutcomeIsRecovered() throws { + // given + let jsonData = #""" + { + "notices1" : "not an array" + } + """# + + // when + let result = try JSONDecoder().decode( + OptionalPolymorphicLossyArrayDummyResponse.self, + from: Data(jsonData.utf8) + ) + + // then: the recovery is visible in the outcome, matching PolymorphicLossyArrayValue + #expect(result.$notices1.error != nil) + } + #endif + + // MARK: - Direct decoding (bypasses the KeyedDecodingContainer overloads) + + @Test func testLossyArrayRecoversWhenDecodedDirectly() throws { + // given: dictionary values invoke `init(from:)` directly, bypassing container overloads + let jsonData = #""" + { + "group" : "not an array" + } + """# + + // when + let result = try JSONDecoder().decode( + [String: PolymorphicLossyArrayValue].self, + from: Data(jsonData.utf8) + ) + + // then + #expect(try #require(result["group"]).wrappedValue.isEmpty) + } + + @Test func testOptionalLossyArrayRecoversWhenDecodedDirectly() throws { + // given + let jsonData = #""" + { + "group" : "not an array" + } + """# + + // when + let result = try JSONDecoder().decode( + [String: OptionalPolymorphicLossyArrayValue].self, + from: Data(jsonData.utf8) + ) + + // then + #expect(try #require(result["group"]).wrappedValue?.isEmpty == true) + } + + // MARK: - Element failures are recorded in the outcome (as with @LossyArray) + + #if DEBUG + @Test func testLossyArrayElementFailureIsRecordedInOutcome() throws { + // given: one invalid element (missing required 'description') and one valid element + let jsonData = #""" + { + "notices1" : [ + { + "icon" : "test_icon", + "type" : "callout" + }, + { + "description" : "test", + "icon" : "test_icon", + "type" : "callout" + } + ] + } + """# + + // when + let result = try JSONDecoder().decode( + OptionalLossyArrayDummyResponse.self, + from: Data(jsonData.utf8) + ) + + // then: valid elements are kept and the failure surfaces through the outcome + #expect(result.notices1.count == 1) + #expect(result.$notices1.error is ResilientDecodingOutcome.ArrayDecodingError) + } + + @Test func testOptionalLossyArrayElementFailureIsRecordedInOutcome() throws { + // given + let jsonData = #""" + { + "notices1" : [ + { + "icon" : "test_icon", + "type" : "callout" + }, + { + "description" : "test", + "icon" : "test_icon", + "type" : "callout" + } + ] + } + """# + + // when + let result = try JSONDecoder().decode( + OptionalPolymorphicLossyArrayDummyResponse.self, + from: Data(jsonData.utf8) + ) + + // then + #expect(try #require(result.notices1).count == 1) + #expect(result.$notices1.error is ResilientDecodingOutcome.ArrayDecodingError) + } + #endif +} diff --git a/Tests/KarrotCodableKitTests/PolymorphicCodable/ArrayValue/PolymorphicLossyArrayValueResilientTests.swift b/Tests/KarrotCodableKitTests/PolymorphicCodable/ArrayValue/PolymorphicLossyArrayValueResilientTests.swift index c2dcf93..0cf40b4 100644 --- a/Tests/KarrotCodableKitTests/PolymorphicCodable/ArrayValue/PolymorphicLossyArrayValueResilientTests.swift +++ b/Tests/KarrotCodableKitTests/PolymorphicCodable/ArrayValue/PolymorphicLossyArrayValueResilientTests.swift @@ -12,10 +12,10 @@ struct PolymorphicLossyArrayValueResilientTests { func emptyArray() throws { // given let json = """ - { - "notices": [] - } - """ + { + "notices": [] + } + """ // when let data = try #require(json.data(using: .utf8)) @@ -34,23 +34,23 @@ struct PolymorphicLossyArrayValueResilientTests { func successfulArrayDecoding() throws { // given let json = """ - { - "notices": [ - { - "type": "callout", - "title": "First", - "description": "First callout", - "icon": "icon1.png" - }, - { - "type": "actionable-callout", - "title": "Second", - "description": "Second callout", - "action": "https://example.com" - } - ] - } - """ + { + "notices": [ + { + "type": "callout", + "title": "First", + "description": "First callout", + "icon": "icon1.png" + }, + { + "type": "actionable-callout", + "title": "Second", + "description": "Second callout", + "action": "https://example.com" + } + ] + } + """ // when let data = try #require(json.data(using: .utf8)) @@ -73,29 +73,29 @@ struct PolymorphicLossyArrayValueResilientTests { func arrayWithInvalidElements() throws { // given let json = """ - { - "notices": [ - { - "type": "callout", - "title": "Valid", - "description": "Valid callout", - "icon": "icon.png" - }, - { - "type": "invalid-type" - }, - { - "type": "dismissible-callout", - "title": "Also Valid", - "description": "Another valid callout", - "key": "dismiss-key" - }, - "not-an-object", - null, - 123 - ] - } - """ + { + "notices": [ + { + "type": "callout", + "title": "Valid", + "description": "Valid callout", + "icon": "icon.png" + }, + { + "type": "invalid-type" + }, + { + "type": "dismissible-callout", + "title": "Also Valid", + "description": "Another valid callout", + "key": "dismiss-key" + }, + "not-an-object", + null, + 123 + ] + } + """ // when let data = try #require(json.data(using: .utf8)) @@ -107,7 +107,8 @@ struct PolymorphicLossyArrayValueResilientTests { #expect(result.notices[1] is DummyDismissibleCallout) #if DEBUG - #expect(result.$notices.outcome == .decodedSuccessfully) + // Element failures surface through the outcome as an ArrayDecodingError, matching @LossyArray. + #expect(result.$notices.error is ResilientDecodingOutcome.ArrayDecodingError) #expect(result.$notices.results.count == 6) // Only first and third elements succeed @@ -124,8 +125,8 @@ struct PolymorphicLossyArrayValueResilientTests { func missingKey() throws { // given let json = """ - {} - """ + {} + """ // when let data = try #require(json.data(using: .utf8)) @@ -144,10 +145,10 @@ struct PolymorphicLossyArrayValueResilientTests { func invalidType() throws { // given let json = """ - { - "notices": "not an array" - } - """ + { + "notices": "not an array" + } + """ // when let data = try #require(json.data(using: .utf8)) @@ -170,20 +171,20 @@ struct PolymorphicLossyArrayValueResilientTests { func partialErrorReporting() throws { /// given let json = """ - { - "notices": [ - { - "type": "callout", - "title": "Valid", - "description": "Valid callout", - "icon": "icon.png" - }, - { - "type": "invalid-type" - } - ] - } - """ + { + "notices": [ + { + "type": "callout", + "title": "Valid", + "description": "Valid callout", + "icon": "icon.png" + }, + { + "type": "invalid-type" + } + ] + } + """ // when let data = try #require(json.data(using: .utf8))