From 6a06c7ea2b231cdd27e48b18cfc9443cb68ab4e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elon=20Park=20=28=E1=84=8B=E1=85=A6=E1=86=AF=E1=84=85?= =?UTF-8?q?=E1=85=A9=E1=86=AB=29?= Date: Mon, 28 Jul 2025 21:43:41 +0900 Subject: [PATCH 01/22] docs: Add ResilientDecoding license and update README MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ThirdPartyLicenses/ResilientDecoding/LICENSE 파일 추가 - README.md에 ResilientDecoding acknowledgement 추가 --- README.md | 1 + ThirdPartyLicenses/ResilientDecoding/LICENSE | 21 ++++++++++++++++++++ 2 files changed, 22 insertions(+) create mode 100644 ThirdPartyLicenses/ResilientDecoding/LICENSE diff --git a/README.md b/README.md index 6513f71..f0493b0 100644 --- a/README.md +++ b/README.md @@ -277,3 +277,4 @@ This project is licensed under the MIT. See LICENSE for details. - PolymorphicCodable was inspired by [Encode and decode polymorphic types in Swift](https://nilcoalescing.com/blog/BringingPolymorphismToCodable/). - AnyCodable was adapted from [Flight-School/AnyCodable](https://github.com/Flight-School/AnyCodable). - BetterCodable was adapted from [marksands/BetterCodable](https://github.com/marksands/BetterCodable). +- ResilientDecodingOutcome was adapted from [airbnb/ResilientDecoding](https://github.com/airbnb/ResilientDecoding). \ No newline at end of file diff --git a/ThirdPartyLicenses/ResilientDecoding/LICENSE b/ThirdPartyLicenses/ResilientDecoding/LICENSE new file mode 100644 index 0000000..649eb52 --- /dev/null +++ b/ThirdPartyLicenses/ResilientDecoding/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2020 Airbnb + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file From 3dc52649c3a7e1399a5dbf5c365b3d3b322797d8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elon=20Park=20=28=E1=84=8B=E1=85=A6=E1=86=AF=E1=84=85?= =?UTF-8?q?=E1=85=A9=E1=86=AB=29?= Date: Tue, 29 Jul 2025 22:10:27 +0900 Subject: [PATCH 02/22] docs: Update README.md with ResilientDecoding information --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index f0493b0..eec06b8 100644 --- a/README.md +++ b/README.md @@ -277,4 +277,4 @@ This project is licensed under the MIT. See LICENSE for details. - PolymorphicCodable was inspired by [Encode and decode polymorphic types in Swift](https://nilcoalescing.com/blog/BringingPolymorphismToCodable/). - AnyCodable was adapted from [Flight-School/AnyCodable](https://github.com/Flight-School/AnyCodable). - BetterCodable was adapted from [marksands/BetterCodable](https://github.com/marksands/BetterCodable). -- ResilientDecodingOutcome was adapted from [airbnb/ResilientDecoding](https://github.com/airbnb/ResilientDecoding). \ No newline at end of file +- ResilientDecodingOutcome was adapted from [airbnb/ResilientDecoding](https://github.com/airbnb/ResilientDecoding). From efd36da965c045af9985b4ce4de027ddd6bef4b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elon=20Park=20=28=E1=84=8B=E1=85=A6=E1=86=AF=E1=84=85?= =?UTF-8?q?=E1=85=A9=E1=86=AB=29?= Date: Tue, 29 Jul 2025 22:10:41 +0900 Subject: [PATCH 03/22] feat: Add Resilient Decoding core infrastructure - Add ErrorReporting.swift for error tracking and reporting - Add ResilientDecodingOutcome enum for decoding results - Add ArrayDecodingError for array-specific error handling - Add DictionaryDecodingError for dictionary-specific error handling --- .../Resilient/ArrayDecodingError.swift | 43 ++++ .../Resilient/DictionaryDecodingError.swift | 43 ++++ .../Resilient/ErrorReporting.swift | 202 ++++++++++++++++++ .../Resilient/ResilientDecodingOutcome.swift | 40 ++++ 4 files changed, 328 insertions(+) create mode 100644 Sources/KarrotCodableKit/Resilient/ArrayDecodingError.swift create mode 100644 Sources/KarrotCodableKit/Resilient/DictionaryDecodingError.swift create mode 100644 Sources/KarrotCodableKit/Resilient/ErrorReporting.swift create mode 100644 Sources/KarrotCodableKit/Resilient/ResilientDecodingOutcome.swift diff --git a/Sources/KarrotCodableKit/Resilient/ArrayDecodingError.swift b/Sources/KarrotCodableKit/Resilient/ArrayDecodingError.swift new file mode 100644 index 0000000..4387915 --- /dev/null +++ b/Sources/KarrotCodableKit/Resilient/ArrayDecodingError.swift @@ -0,0 +1,43 @@ +// +// ArrayDecodingError.swift +// KarrotCodableKit +// +// Created by Elon on 4/9/25. +// + +import Foundation + +#if DEBUG +extension ResilientDecodingOutcome { + public struct ArrayDecodingError: Error { + public let results: [Result] + public var errors: [Error] { + results.compactMap { result in + switch result { + case .success: + return nil + case .failure(let error): + return error + } + } + } + + public init(results: [Result]) { + self.results = results + } + } + + func arrayDecodingError() -> ResilientDecodingOutcome.ArrayDecodingError { + typealias ArrayDecodingError = ResilientDecodingOutcome.ArrayDecodingError + switch self { + case .decodedSuccessfully, .keyNotFound, .valueWasNil: + return .init(results: []) + case let .recoveredFrom(error as ArrayDecodingError, wasReported): + assert(!wasReported) + return error + case .recoveredFrom(let error, _): + return .init(results: [.failure(error)]) + } + } +} +#endif \ No newline at end of file diff --git a/Sources/KarrotCodableKit/Resilient/DictionaryDecodingError.swift b/Sources/KarrotCodableKit/Resilient/DictionaryDecodingError.swift new file mode 100644 index 0000000..60d7681 --- /dev/null +++ b/Sources/KarrotCodableKit/Resilient/DictionaryDecodingError.swift @@ -0,0 +1,43 @@ +// +// DictionaryDecodingError.swift +// KarrotCodableKit +// +// Created by Elon on 4/9/25. +// + +import Foundation + +#if DEBUG +extension ResilientDecodingOutcome { + public struct DictionaryDecodingError: Error { + public let results: [Key: Result] + public var errors: [Key: Error] { + results.compactMapValues { result in + switch result { + case .success: + return nil + case .failure(let error): + return error + } + } + } + + public init(results: [Key: Result]) { + self.results = results + } + } + + func dictionaryDecodingError() -> ResilientDecodingOutcome.DictionaryDecodingError { + typealias DictionaryDecodingError = ResilientDecodingOutcome.DictionaryDecodingError + switch self { + case .decodedSuccessfully, .keyNotFound, .valueWasNil: + return .init(results: [:]) + case let .recoveredFrom(error as DictionaryDecodingError, wasReported): + assert(!wasReported) + return error + case .recoveredFrom(_, _): + return .init(results: [:]) + } + } +} +#endif \ No newline at end of file diff --git a/Sources/KarrotCodableKit/Resilient/ErrorReporting.swift b/Sources/KarrotCodableKit/Resilient/ErrorReporting.swift new file mode 100644 index 0000000..66412e9 --- /dev/null +++ b/Sources/KarrotCodableKit/Resilient/ErrorReporting.swift @@ -0,0 +1,202 @@ +// +// ErrorReporting.swift +// KarrotCodableKit +// +// Created by Elon on 4/9/25. +// + +import Foundation + +// MARK: - Enabling Error Reporting + +extension CodingUserInfoKey { + public static let resilientDecodingErrorReporter = CodingUserInfoKey( + rawValue: "ResilientDecodingErrorReporter" + )! +} + +extension [CodingUserInfoKey: Any] { + public mutating func enableResilientDecodingErrorReporting() -> ResilientDecodingErrorReporter { + let errorReporter = ResilientDecodingErrorReporter() + _ = replaceResilientDecodingErrorReporter(with: errorReporter) + return errorReporter + } + + fileprivate mutating func replaceResilientDecodingErrorReporter( + with errorReporter: ResilientDecodingErrorReporter + ) -> Any? { + if let existingValue = self[.resilientDecodingErrorReporter] { + assertionFailure() + if let existingReporter = existingValue as? ResilientDecodingErrorReporter { + existingReporter.currentDigest.mayBeMissingReportedErrors = true + } + } + self[.resilientDecodingErrorReporter] = errorReporter + return errorReporter + } +} + +extension JSONDecoder { + public func enableResilientDecodingErrorReporting() -> ResilientDecodingErrorReporter { + userInfo.enableResilientDecodingErrorReporting() + } + + public func decode( + _ type: T.Type, + from data: Data, + reportResilientDecodingErrors: Bool + ) throws -> (T, ErrorDigest?) { + guard reportResilientDecodingErrors else { + return (try decode(T.self, from: data), nil) + } + let errorReporter = ResilientDecodingErrorReporter() + let oldValue = userInfo.replaceResilientDecodingErrorReporter(with: errorReporter) + let value = try decode(T.self, from: data) + userInfo[.resilientDecodingErrorReporter] = oldValue + return (value, errorReporter.flushReportedErrors()) + } +} + +// MARK: - Accessing Reported Errors + +public final class ResilientDecodingErrorReporter { + public init() {} + + public func flushReportedErrors() -> ErrorDigest? { + #if DEBUG + let digest = hasErrors ? currentDigest : nil + hasErrors = false + currentDigest = ErrorDigest() + return digest + #else + // Release 빌드에서는 성능 최적화를 위해 에러 정보를 반환하지 않음 + hasErrors = false + currentDigest = ErrorDigest() + return nil + #endif + } + + func resilientDecodingHandled(_ error: Error, at path: [String]) { + hasErrors = true + currentDigest.root.insert(error, at: path) + } + + fileprivate var currentDigest = ErrorDigest() + private var hasErrors = false +} + +public struct ErrorDigest { + public var errors: [Error] { + errors(includeUnknownNovelValueErrors: false) + } + + public func errors(includeUnknownNovelValueErrors: Bool) -> [Error] { + let allErrors: [Error] = + if mayBeMissingReportedErrors { + [MayBeMissingReportedErrors()] + root.errors + } else { + root.errors + } + + return allErrors.filter { includeUnknownNovelValueErrors || !($0 is UnknownNovelValueError) } + } + + fileprivate var mayBeMissingReportedErrors = false + + fileprivate struct Node { + private var children: [String: Node] = [:] + private var shallowErrors: [Error] = [] + + mutating func insert(_ error: Error, at path: some Collection) { + if let next = path.first { + children[next, default: Node()].insert(error, at: path.dropFirst()) + } else { + shallowErrors.append(error) + } + } + + var errors: [Error] { + shallowErrors + children.flatMap { $0.value.errors } + } + } + + fileprivate var root = Node() +} + +// MARK: - Reporting Errors + +extension Decoder { + public func reportError(_ error: Swift.Error) { + guard let errorReporterAny = userInfo[.resilientDecodingErrorReporter] else { + return + } + + guard let errorReporter = errorReporterAny as? ResilientDecodingErrorReporter else { + assertionFailure() + return + } + + errorReporter.resilientDecodingHandled(error, at: codingPath.map { $0.stringValue }) + } +} + +// MARK: - Pretty Printing + +#if DEBUG +extension ErrorDigest: CustomDebugStringConvertible { + public var debugDescription: String { + root.debugDescriptionLines.joined(separator: "\n") + } +} + +extension ErrorDigest.Node { + var debugDescriptionLines: [String] { + let errorLines = shallowErrors.map { "- " + $0.abridgedDescription }.sorted() + let childrenLines = children + .sorted(by: { $0.key < $1.key }) + .flatMap { child in + [child.key] + child.value.debugDescriptionLines.map { " " + $0 } + } + + return errorLines + childrenLines + } +} + +extension Error { + fileprivate var abridgedDescription: String { + switch self { + case let decodingError as DecodingError: + switch decodingError { + case .dataCorrupted: + return "Data corrupted" + case .keyNotFound(let key, _): + return "Key \"\(key.stringValue)\" not found" + case .typeMismatch(let attempted, _): + return "Could not decode as `\(attempted)`" + case .valueNotFound(let attempted, _): + return "Expected `\(attempted)` but found null instead" + @unknown default: + return localizedDescription + } + + case let error as UnknownNovelValueError: + return "Unknown novel value \"\(error.novelValue)\" (this error is not reported by default)" + + default: + return localizedDescription + } + } +} +#endif + +// MARK: - Specific Errors + +private struct MayBeMissingReportedErrors: Error {} + +public struct UnknownNovelValueError: Error { + public let novelValue: Any + + public init(novelValue: T) { + self.novelValue = novelValue + } +} diff --git a/Sources/KarrotCodableKit/Resilient/ResilientDecodingOutcome.swift b/Sources/KarrotCodableKit/Resilient/ResilientDecodingOutcome.swift new file mode 100644 index 0000000..cf598ca --- /dev/null +++ b/Sources/KarrotCodableKit/Resilient/ResilientDecodingOutcome.swift @@ -0,0 +1,40 @@ +// +// ResilientDecodingOutcome.swift +// KarrotCodableKit +// +// Created by Elon on 4/9/25. +// + +import Foundation + +#if DEBUG +public enum ResilientDecodingOutcome: Sendable { + case decodedSuccessfully + case keyNotFound + case valueWasNil + case recoveredFrom(any Error, wasReported: Bool) +} + +extension ResilientDecodingOutcome: Equatable { + public static func == (lhs: ResilientDecodingOutcome, rhs: ResilientDecodingOutcome) -> Bool { + switch (lhs, rhs) { + case (.decodedSuccessfully, .decodedSuccessfully), + (.keyNotFound, .keyNotFound), + (.valueWasNil, .valueWasNil): + true + case (.recoveredFrom(_, let lhsReported), .recoveredFrom(_, let rhsReported)): + lhsReported == rhsReported + default: + false + } + } +} +#else +public struct ResilientDecodingOutcome: Sendable { + public static let decodedSuccessfully = Self() + public static let keyNotFound = Self() + public static let valueWasNil = Self() + public static let recoveredFromDebugOnlyError = Self() + public static func recoveredFrom(_: any Error, wasReported: Bool) -> Self { Self() } +} +#endif From 19234f0346688a619a0f25eae0ee7e15a9059020 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elon=20Park=20=28=E1=84=8B=E1=85=A6=E1=86=AF=E1=84=85?= =?UTF-8?q?=E1=85=A9=E1=86=AB=29?= Date: Tue, 29 Jul 2025 22:10:53 +0900 Subject: [PATCH 04/22] feat: Add BetterCodable projected value infrastructure - Add ResilientProjectedValue base implementation - Add ResilientArrayProjectedValue for array property wrappers - Add ResilientDictionaryProjectedValue for dictionary property wrappers --- .../ResilientProjectedValue.swift | 78 +++++++++++++++++++ 1 file changed, 78 insertions(+) create mode 100644 Sources/KarrotCodableKit/BetterCodable/ResilientProjectedValue.swift diff --git a/Sources/KarrotCodableKit/BetterCodable/ResilientProjectedValue.swift b/Sources/KarrotCodableKit/BetterCodable/ResilientProjectedValue.swift new file mode 100644 index 0000000..79b42f2 --- /dev/null +++ b/Sources/KarrotCodableKit/BetterCodable/ResilientProjectedValue.swift @@ -0,0 +1,78 @@ +// +// ResilientProjectedValue.swift +// KarrotCodableKit +// +// Created by Elon on 4/9/25. +// + +import Foundation + +#if DEBUG +/// Common protocol for all resilient projected values. +public protocol ResilientProjectedValueProtocol { + var outcome: ResilientDecodingOutcome { get } + var error: Error? { get } +} + +/// Default implementation for error extraction. +extension ResilientProjectedValueProtocol { + public var error: Error? { + switch outcome { + case .decodedSuccessfully, .keyNotFound, .valueWasNil: + return nil + case .recoveredFrom(let error, _): + return error + } + } +} + +/// A base implementation for property wrapper projected values that track decoding outcomes. +/// +/// This struct provides common functionality for all BetterCodable property wrappers' projected values, +/// including error tracking and resilient decoding outcome information. +public struct ResilientProjectedValue: ResilientProjectedValueProtocol { + public let outcome: ResilientDecodingOutcome + + public init(outcome: ResilientDecodingOutcome) { + self.outcome = outcome + } +} + +/// A dynamic member lookup extension for array-based property wrappers. +/// +/// This struct extends the base projected value with dynamic member lookup capabilities +/// for accessing detailed array decoding errors. +@dynamicMemberLookup +public struct ResilientArrayProjectedValue: ResilientProjectedValueProtocol { + public let outcome: ResilientDecodingOutcome + + public init(outcome: ResilientDecodingOutcome) { + self.outcome = outcome + } + + public subscript( + dynamicMember keyPath: KeyPath, U> + ) -> U { + outcome.arrayDecodingError()[keyPath: keyPath] + } +} + +/// A dynamic member lookup extension for dictionary-based property wrappers. +/// +/// This struct extends the base projected value with dynamic member lookup capabilities +/// for accessing detailed dictionary decoding errors. +@dynamicMemberLookup +public struct ResilientDictionaryProjectedValue: ResilientProjectedValueProtocol { + public let outcome: ResilientDecodingOutcome + + public init(outcome: ResilientDecodingOutcome) { + self.outcome = outcome + } + + public subscript( + dynamicMember keyPath: KeyPath, U> + ) -> U { + outcome.dictionaryDecodingError()[keyPath: keyPath] + } +} +#endif From ddd2dbe13ab4b00886aa5a6a92f42f42cc8d2162 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elon=20Park=20=28=E1=84=8B=E1=85=A6=E1=86=AF=E1=84=85?= =?UTF-8?q?=E1=85=A9=E1=86=AB=29?= Date: Tue, 29 Jul 2025 22:11:05 +0900 Subject: [PATCH 05/22] feat: Add PolymorphicCodable projected value infrastructure - Add PolymorphicProjectedValue base implementation - Add PolymorphicLossyArrayProjectedValue for lossy array operations --- .../PolymorphicProjectedValue.swift | 59 +++++++++++++++++++ 1 file changed, 59 insertions(+) create mode 100644 Sources/KarrotCodableKit/PolymorphicCodable/PolymorphicProjectedValue.swift diff --git a/Sources/KarrotCodableKit/PolymorphicCodable/PolymorphicProjectedValue.swift b/Sources/KarrotCodableKit/PolymorphicCodable/PolymorphicProjectedValue.swift new file mode 100644 index 0000000..81bc381 --- /dev/null +++ b/Sources/KarrotCodableKit/PolymorphicCodable/PolymorphicProjectedValue.swift @@ -0,0 +1,59 @@ +// +// PolymorphicProjectedValue.swift +// KarrotCodableKit +// +// Created by Elon on 2025-07-23. +// Copyright © 2025 Danggeun Market Inc. All rights reserved. +// + +import Foundation + +#if DEBUG +/// Common protocol for polymorphic projected values. +public protocol PolymorphicProjectedValueProtocol { + var outcome: ResilientDecodingOutcome { get } + var error: Error? { get } +} + +/// Default implementation for error extraction. +extension PolymorphicProjectedValueProtocol { + public var error: Error? { + switch outcome { + case .decodedSuccessfully, .keyNotFound, .valueWasNil: + return nil + case .recoveredFrom(let error, _): + return error + } + } +} + +/// A generic projected value for polymorphic property wrappers. +/// +/// This struct provides common functionality for all polymorphic property wrappers' projected values, +/// including error tracking and resilient decoding outcome information. +public struct PolymorphicProjectedValue: PolymorphicProjectedValueProtocol { + /// The outcome of the decoding process + public let outcome: ResilientDecodingOutcome + + public init(outcome: ResilientDecodingOutcome) { + self.outcome = outcome + } +} + +/// A specialized projected value for lossy array property wrappers that includes element-level results. +/// +/// This struct extends the base projected value with additional information about individual +/// element decoding results for lossy array operations. +public struct PolymorphicLossyArrayProjectedValue: PolymorphicProjectedValueProtocol { + /// The outcome of the decoding process + public let outcome: ResilientDecodingOutcome + + /// Results of decoding each element in the array + public let results: [Result] + + public init(outcome: ResilientDecodingOutcome, results: [Result]) { + self.outcome = outcome + self.results = results + } +} +#endif From f18b39613d9f30fcee7de590b361f211e44163d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elon=20Park=20=28=E1=84=8B=E1=85=A6=E1=86=AF=E1=84=85?= =?UTF-8?q?=E1=85=A9=E1=86=AB=29?= Date: Tue, 29 Jul 2025 22:11:47 +0900 Subject: [PATCH 06/22] test: Reorganize PolymorphicCodable test files - Move PolymorphicEnumCodableTests.swift to Enum/ directory - Move PolymorphicEnumDecodableTests.swift to Enum/ directory - Move PolymorphicValueTests.swift to Value/ directory --- .../{ => Enum}/PolymorphicEnumCodableTests.swift | 0 .../{ => Enum}/PolymorphicEnumDecodableTests.swift | 0 .../PolymorphicCodable/{ => Value}/PolymorphicValueTests.swift | 0 3 files changed, 0 insertions(+), 0 deletions(-) rename Tests/KarrotCodableKitTests/PolymorphicCodable/{ => Enum}/PolymorphicEnumCodableTests.swift (100%) rename Tests/KarrotCodableKitTests/PolymorphicCodable/{ => Enum}/PolymorphicEnumDecodableTests.swift (100%) rename Tests/KarrotCodableKitTests/PolymorphicCodable/{ => Value}/PolymorphicValueTests.swift (100%) diff --git a/Tests/KarrotCodableKitTests/PolymorphicCodable/PolymorphicEnumCodableTests.swift b/Tests/KarrotCodableKitTests/PolymorphicCodable/Enum/PolymorphicEnumCodableTests.swift similarity index 100% rename from Tests/KarrotCodableKitTests/PolymorphicCodable/PolymorphicEnumCodableTests.swift rename to Tests/KarrotCodableKitTests/PolymorphicCodable/Enum/PolymorphicEnumCodableTests.swift diff --git a/Tests/KarrotCodableKitTests/PolymorphicCodable/PolymorphicEnumDecodableTests.swift b/Tests/KarrotCodableKitTests/PolymorphicCodable/Enum/PolymorphicEnumDecodableTests.swift similarity index 100% rename from Tests/KarrotCodableKitTests/PolymorphicCodable/PolymorphicEnumDecodableTests.swift rename to Tests/KarrotCodableKitTests/PolymorphicCodable/Enum/PolymorphicEnumDecodableTests.swift diff --git a/Tests/KarrotCodableKitTests/PolymorphicCodable/PolymorphicValueTests.swift b/Tests/KarrotCodableKitTests/PolymorphicCodable/Value/PolymorphicValueTests.swift similarity index 100% rename from Tests/KarrotCodableKitTests/PolymorphicCodable/PolymorphicValueTests.swift rename to Tests/KarrotCodableKitTests/PolymorphicCodable/Value/PolymorphicValueTests.swift From 3d06adb354d43a3c244030cc716552ac5ea42724 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elon=20Park=20=28=E1=84=8B=E1=85=A6=E1=86=AF=E1=84=85?= =?UTF-8?q?=E1=85=A9=E1=86=AB=29?= Date: Tue, 29 Jul 2025 22:11:59 +0900 Subject: [PATCH 07/22] test: Replace force unwrap with #require in UnnestedPolymorphicDecodableTests Improve test safety by using #require instead of force unwrapping --- .../UnnestedPolymorphicDecodableTests.swift | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/Tests/KarrotCodableKitTests/PolymorphicCodable/UnnestedPolymorphicTests/UnnestedPolymorphicDecodableTests.swift b/Tests/KarrotCodableKitTests/PolymorphicCodable/UnnestedPolymorphicTests/UnnestedPolymorphicDecodableTests.swift index 1cf0f11..d3edb10 100644 --- a/Tests/KarrotCodableKitTests/PolymorphicCodable/UnnestedPolymorphicTests/UnnestedPolymorphicDecodableTests.swift +++ b/Tests/KarrotCodableKitTests/PolymorphicCodable/UnnestedPolymorphicTests/UnnestedPolymorphicDecodableTests.swift @@ -56,7 +56,7 @@ struct UnnestedPolymorphicDecodableTests { """ // when - let data = json.data(using: .utf8)! + let data = try #require(json.data(using: .utf8)) let decoder = JSONDecoder() let result = try decoder.decode(OptionalDecodableViewItem.self, from: data) @@ -82,7 +82,7 @@ struct UnnestedPolymorphicDecodableTests { """ // when - let data = json.data(using: .utf8)! + let data = try #require(json.data(using: .utf8)) let decoder = JSONDecoder() let result = try decoder.decode(OptionalDecodableViewItem.self, from: data) @@ -106,7 +106,7 @@ struct UnnestedPolymorphicDecodableTests { """ // when - let data = json.data(using: .utf8)! + let data = try #require(json.data(using: .utf8)) let decoder = JSONDecoder() // then @@ -125,7 +125,7 @@ struct UnnestedPolymorphicDecodableTests { """ // when - let data = json.data(using: .utf8)! + let data = try #require(json.data(using: .utf8)) let decoder = JSONDecoder() // then @@ -145,7 +145,7 @@ struct UnnestedPolymorphicDecodableTests { """ // when - let data = json.data(using: .utf8)! + let data = try #require(json.data(using: .utf8)) let decoder = JSONDecoder() // then From d5ee95609b2b07a3166ee265e3c7a06d12e39c30 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elon=20Park=20=28=E1=84=8B=E1=85=A6=E1=86=AF=E1=84=85?= =?UTF-8?q?=E1=85=A9=E1=86=AB=29?= Date: Tue, 29 Jul 2025 22:12:14 +0900 Subject: [PATCH 08/22] feat: Add resilient decoding support to DefaultCodable - Add outcome tracking to DefaultCodable property wrapper - Implement projected value for error reporting - Handle nil values and decoding errors gracefully - Add comprehensive tests for resilient behavior --- .../Defaults/DefaultCodable.swift | 193 +++++++- .../DefaultCodableResilientTests.swift | 423 ++++++++++++++++++ 2 files changed, 613 insertions(+), 3 deletions(-) create mode 100644 Tests/KarrotCodableKitTests/BetterCodable/Defaults/DefaultCodableResilientTests.swift diff --git a/Sources/KarrotCodableKit/BetterCodable/Defaults/DefaultCodable.swift b/Sources/KarrotCodableKit/BetterCodable/Defaults/DefaultCodable.swift index 60ad2d9..d1cb02f 100644 --- a/Sources/KarrotCodableKit/BetterCodable/Defaults/DefaultCodable.swift +++ b/Sources/KarrotCodableKit/BetterCodable/Defaults/DefaultCodable.swift @@ -16,6 +16,16 @@ public protocol DefaultCodableStrategy { /// The fallback value used when decoding fails static var defaultValue: DefaultValue { get } + + /// When true, unknown raw values for RawRepresentable types will be reported as errors. + /// When false, unknown raw values will use the defaultValue without reporting an error. + /// Defaults to false. + static var isFrozen: Bool { get } +} + +// Default implementation +extension DefaultCodableStrategy { + public static var isFrozen: Bool { false } } /// Decodes values with a reasonable default value @@ -25,22 +35,56 @@ public protocol DefaultCodableStrategy { @propertyWrapper public struct DefaultCodable { public var wrappedValue: Default.DefaultValue + + public let outcome: ResilientDecodingOutcome public init(wrappedValue: Default.DefaultValue) { self.wrappedValue = wrappedValue + self.outcome = .decodedSuccessfully } + + init(wrappedValue: Default.DefaultValue, outcome: ResilientDecodingOutcome) { + self.wrappedValue = wrappedValue + self.outcome = outcome + } + + #if DEBUG + public var projectedValue: ResilientProjectedValue { ResilientProjectedValue(outcome: outcome) } + #endif } extension DefaultCodable where Default.Type == Default.DefaultValue.Type { public init(wrappedValue: Default) { self.wrappedValue = wrappedValue + self.outcome = .decodedSuccessfully } } extension DefaultCodable: Decodable { public init(from decoder: Decoder) throws { let container = try decoder.singleValueContainer() - self.wrappedValue = (try? container.decode(Default.DefaultValue.self)) ?? Default.defaultValue + + // Check for nil first + if container.decodeNil() { + #if DEBUG + self.init(wrappedValue: Default.defaultValue, outcome: .valueWasNil) + #else + self.init(wrappedValue: Default.defaultValue) + #endif + return + } + + do { + let value = try container.decode(Default.DefaultValue.self) + self.init(wrappedValue: value) + } catch { + decoder.reportError(error) + #if DEBUG + self.init(wrappedValue: Default.defaultValue, outcome: .recoveredFrom(error, wasReported: true)) + #else + self.init(wrappedValue: Default.defaultValue) + #endif + } } } @@ -51,8 +95,18 @@ extension DefaultCodable: Encodable where Default.DefaultValue: Encodable { } } -extension DefaultCodable: Equatable where Default.DefaultValue: Equatable {} -extension DefaultCodable: Hashable where Default.DefaultValue: Hashable {} +extension DefaultCodable: Equatable where Default.DefaultValue: Equatable { + public static func ==(lhs: Self, rhs: Self) -> Bool { + lhs.wrappedValue == rhs.wrappedValue + } +} + +extension DefaultCodable: Hashable where Default.DefaultValue: Hashable { + public func hash(into hasher: inout Hasher) { + hasher.combine(wrappedValue) + } +} + extension DefaultCodable: Sendable where Default.DefaultValue: Sendable {} // MARK: - KeyedDecodingContainer @@ -64,10 +118,33 @@ extension KeyedDecodingContainer { /// /// Decodes successfully if key is available if not fallsback to the default value provided. public func decode

(_: DefaultCodable

.Type, forKey key: Key) throws -> DefaultCodable

{ + // Check if key exists + if !contains(key) { + #if DEBUG + return DefaultCodable(wrappedValue: P.defaultValue, outcome: .keyNotFound) + #else + return DefaultCodable(wrappedValue: P.defaultValue) + #endif + } + + // Check for nil + if (try? decodeNil(forKey: key)) == true { + #if DEBUG + return DefaultCodable(wrappedValue: P.defaultValue, outcome: .valueWasNil) + #else + return DefaultCodable(wrappedValue: P.defaultValue) + #endif + } + + // Try to decode normally if let value = try decodeIfPresent(DefaultCodable

.self, forKey: key) { return value } else { + #if DEBUG + return DefaultCodable(wrappedValue: P.defaultValue, outcome: .keyNotFound) + #else return DefaultCodable(wrappedValue: P.defaultValue) + #endif } } @@ -78,13 +155,38 @@ extension KeyedDecodingContainer { /// the data provider might be sending the value as different types. If everything fails defaults to /// the `defaultValue` provided by the strategy. public func decode(_: DefaultCodable

.Type, forKey key: Key) throws -> DefaultCodable

{ + // Check if key exists + if !contains(key) { + #if DEBUG + return DefaultCodable(wrappedValue: P.defaultValue, outcome: .keyNotFound) + #else + return DefaultCodable(wrappedValue: P.defaultValue) + #endif + } + + // Check for nil first + if (try? decodeNil(forKey: key)) == true { + #if DEBUG + return DefaultCodable(wrappedValue: P.defaultValue, outcome: .valueWasNil) + #else + return DefaultCodable(wrappedValue: P.defaultValue) + #endif + } + do { let value = try decode(Bool.self, forKey: key) return DefaultCodable(wrappedValue: value) } catch let error { guard let decodingError = error as? DecodingError, case .typeMismatch = decodingError else { + // Report error and use default + let decoder = try superDecoder(forKey: key) + decoder.reportError(error) + #if DEBUG + return DefaultCodable(wrappedValue: P.defaultValue, outcome: .recoveredFrom(error, wasReported: true)) + #else return DefaultCodable(wrappedValue: P.defaultValue) + #endif } if let intValue = try? decodeIfPresent(Int.self, forKey: key), let bool = Bool(exactly: NSNumber(value: intValue)) { @@ -93,8 +195,93 @@ extension KeyedDecodingContainer { let bool = Bool(stringValue) { return DefaultCodable(wrappedValue: bool) } else { + // Type mismatch - report error + let decoder = try superDecoder(forKey: key) + decoder.reportError(decodingError) + #if DEBUG + return DefaultCodable(wrappedValue: P.defaultValue, outcome: .recoveredFrom(decodingError, wasReported: true)) + #else + return DefaultCodable(wrappedValue: P.defaultValue) + #endif + } + } + } + + /// Decodes a DefaultCodable where the strategy's DefaultValue is RawRepresentable + /// + /// This method provides special handling for RawRepresentable types: + /// - If `isFrozen` is false (default), unknown raw values result in UnknownNovelValueError and use the default value + /// - If `isFrozen` is true, unknown raw values result in DecodingError and use the default value + public func decode

(_: DefaultCodable

.Type, forKey key: Key) throws -> DefaultCodable

+ where P.DefaultValue: RawRepresentable, P.DefaultValue.RawValue: Decodable + { + // Check if key exists + if !contains(key) { + #if DEBUG + return DefaultCodable(wrappedValue: P.defaultValue, outcome: .keyNotFound) + #else + return DefaultCodable(wrappedValue: P.defaultValue) + #endif + } + + // Check for nil + if (try? decodeNil(forKey: key)) == true { + #if DEBUG + return DefaultCodable(wrappedValue: P.defaultValue, outcome: .valueWasNil) + #else + return DefaultCodable(wrappedValue: P.defaultValue) + #endif + } + + // Try to decode the raw value + do { + let rawValue = try decode(P.DefaultValue.RawValue.self, forKey: key) + + // Try to create the enum from raw value + if let value = P.DefaultValue(rawValue: rawValue) { + return DefaultCodable(wrappedValue: value) + } else { + // Unknown raw value + let error = Self.createUnknownRawValueError( + for: P.DefaultValue.self, + rawValue: rawValue, + codingPath: codingPath + [key], + isFrozen: P.isFrozen + ) + + let decoder = try superDecoder(forKey: key) + decoder.reportError(error) + + #if DEBUG + return DefaultCodable(wrappedValue: P.defaultValue, outcome: .recoveredFrom(error, wasReported: true)) + #else return DefaultCodable(wrappedValue: P.defaultValue) + #endif } + } catch { + // Decoding the raw value failed (e.g., type mismatch) + let decoder = try superDecoder(forKey: key) + decoder.reportError(error) + + #if DEBUG + return DefaultCodable(wrappedValue: P.defaultValue, outcome: .recoveredFrom(error, wasReported: true)) + #else + return DefaultCodable(wrappedValue: P.defaultValue) + #endif } } + + private static func createUnknownRawValueError( + for type: T.Type, + rawValue: T.RawValue, + codingPath: [CodingKey], + isFrozen: Bool + ) -> Error { + guard isFrozen else { return UnknownNovelValueError(novelValue: rawValue) } + let context = DecodingError.Context( + codingPath: codingPath, + debugDescription: "Cannot initialize \(type) from invalid raw value \(rawValue)" + ) + return DecodingError.dataCorrupted(context) + } } diff --git a/Tests/KarrotCodableKitTests/BetterCodable/Defaults/DefaultCodableResilientTests.swift b/Tests/KarrotCodableKitTests/BetterCodable/Defaults/DefaultCodableResilientTests.swift new file mode 100644 index 0000000..6a36de5 --- /dev/null +++ b/Tests/KarrotCodableKitTests/BetterCodable/Defaults/DefaultCodableResilientTests.swift @@ -0,0 +1,423 @@ +// +// DefaultCodableResilientTests.swift +// KarrotCodableKitTests +// +// Created by Elon on 4/9/25. +// + +import Testing +import Foundation +import KarrotCodableKit + +@Suite("DefaultCodable Resilient Decoding") +struct DefaultCodableResilientTests { + struct Fixture: Decodable { + @DefaultZeroInt var intValue: Int + @DefaultEmptyString var stringValue: String + @DefaultFalse var boolValue: Bool + @DefaultEmptyArray var arrayValue: [String] + @DefaultEmptyDictionary var dictValue: [String: Int] + } + + @Test("projected value provides error information for failed decoding") + func testProjectedValueProvidesErrorInfo() throws { + let json = """ + { + "intValue": "not a number", + "stringValue": 123, + "boolValue": "not a bool", + "arrayValue": "not an array", + "dictValue": "not a dict" + } + """ + + let decoder = JSONDecoder() + let data = json.data(using: .utf8)! + let fixture = try decoder.decode(Fixture.self, from: data) + + // Verify default behavior - use default value on decoding failure + #expect(fixture.intValue == 0) + #expect(fixture.stringValue == "") + #expect(fixture.boolValue == false) + #expect(fixture.arrayValue == []) + #expect(fixture.dictValue == [:]) + + #if DEBUG + // Access error info through projected value + #expect(fixture.$intValue.error != nil) + #expect(fixture.$stringValue.error != nil) + #expect(fixture.$boolValue.error != nil) + #expect(fixture.$arrayValue.error != nil) + #expect(fixture.$dictValue.error != nil) + + // Check error type + let error = try #require(fixture.$intValue.error as? DecodingError) + switch error { + case .typeMismatch: + // Expected + break + default: + Issue.record("Expected typeMismatch error") + } + #endif + } + + @Test("missing keys use default values without error") + func testMissingKeysUseDefaultValues() throws { + let json = "{}" + + let decoder = JSONDecoder() + let data = json.data(using: .utf8)! + let fixture = try decoder.decode(Fixture.self, from: data) + + // Check default values + #expect(fixture.intValue == 0) + #expect(fixture.stringValue == "") + #expect(fixture.boolValue == false) + #expect(fixture.arrayValue == []) + #expect(fixture.dictValue == [:]) + + #if DEBUG + // No error when key is missing (default behavior) + #expect(fixture.$intValue.error == nil) + #expect(fixture.$stringValue.error == nil) + #expect(fixture.$boolValue.error == nil) + #expect(fixture.$arrayValue.error == nil) + #expect(fixture.$dictValue.error == nil) + #endif + } + + @Test("valid values decode successfully") + func testValidValuesDecodeSuccessfully() throws { + let json = """ + { + "intValue": 42, + "stringValue": "hello", + "boolValue": true, + "arrayValue": ["a", "b", "c"], + "dictValue": {"key": 123} + } + """ + + let decoder = JSONDecoder() + let data = json.data(using: .utf8)! + let fixture = try decoder.decode(Fixture.self, from: data) + + // Check normal values + #expect(fixture.intValue == 42) + #expect(fixture.stringValue == "hello") + #expect(fixture.boolValue == true) + #expect(fixture.arrayValue == ["a", "b", "c"]) + #expect(fixture.dictValue == ["key": 123]) + + #if DEBUG + // No error when successfully decoded + #expect(fixture.$intValue.error == nil) + #expect(fixture.$stringValue.error == nil) + #expect(fixture.$boolValue.error == nil) + #expect(fixture.$arrayValue.error == nil) + #expect(fixture.$dictValue.error == nil) + #endif + } + + @Test("null values use default values") + func testNullValuesUseDefaultValues() throws { + let json = """ + { + "intValue": null, + "stringValue": null, + "boolValue": null, + "arrayValue": null, + "dictValue": null + } + """ + + let decoder = JSONDecoder() + let data = json.data(using: .utf8)! + let fixture = try decoder.decode(Fixture.self, from: data) + + // Use default value for null + #expect(fixture.intValue == 0) + #expect(fixture.stringValue == "") + #expect(fixture.boolValue == false) + #expect(fixture.arrayValue == []) + #expect(fixture.dictValue == [:]) + + #if DEBUG + // null is not considered an error + #expect(fixture.$intValue.error == nil) + #expect(fixture.$stringValue.error == nil) + #expect(fixture.$boolValue.error == nil) + #expect(fixture.$arrayValue.error == nil) + #expect(fixture.$dictValue.error == nil) + #endif + } + + @Test("error reporting with JSONDecoder") + func testErrorReportingWithDecoder() throws { + let json = """ + { + "intValue": "invalid", + "stringValue": [], + "boolValue": {} + } + """ + + let decoder = JSONDecoder() + let errorReporter = decoder.enableResilientDecodingErrorReporting() + + let data = json.data(using: .utf8)! + _ = try decoder.decode(Fixture.self, from: data) + + let errorDigest = errorReporter.flushReportedErrors() + + #if DEBUG + let digest = try #require(errorDigest) + // At least 3 errors should be reported + #expect(digest.errors.count >= 3) + print("Error digest: \(digest.debugDescription)") + #else + #expect(errorDigest == nil) + #endif + } + + @Test("LossyOptional behavior") + func testLossyOptional() throws { + struct OptionalFixture: Decodable { + @LossyOptional var url: URL? + @LossyOptional var date: Date? + @LossyOptional var number: Int? + } + + let json = """ + { + "url": "https://example .com", + "date": "not a date", + "number": "not a number" + } + """ + + let decoder = JSONDecoder() + let data = json.data(using: .utf8)! + let fixture = try decoder.decode(OptionalFixture.self, from: data) + + // nil on decoding failure + #expect(fixture.url == nil) + #expect(fixture.date == nil) + #expect(fixture.number == nil) + + #if DEBUG + // Check error info + #expect(fixture.$url.error != nil) + #expect(fixture.$date.error != nil) + #expect(fixture.$number.error != nil) + #endif + } + + // MARK: - RawRepresentable Support Tests + + enum TestEnum: String, Decodable, DefaultCodableStrategy { + case first + case second + case unknown + + static var defaultValue: TestEnum { .unknown } + } + + enum FrozenTestEnum: String, Decodable, DefaultCodableStrategy { + case alpha + case beta + case fallback + + static var defaultValue: FrozenTestEnum { .fallback } + static var isFrozen: Bool { true } + } + + struct RawRepresentableFixture: Decodable { + @DefaultCodable var normalEnum: TestEnum + @DefaultCodable var frozenEnum: FrozenTestEnum + } + + @Test("RawRepresentable with valid raw values") + func testRawRepresentableValidValues() throws { + // given + let json = """ + { + "normalEnum": "first", + "frozenEnum": "alpha" + } + """ + + // when + let decoder = JSONDecoder() + let data = json.data(using: .utf8)! + let fixture = try decoder.decode(RawRepresentableFixture.self, from: data) + + // then + #expect(fixture.normalEnum == .first) + #expect(fixture.frozenEnum == .alpha) + + #if DEBUG + #expect(fixture.$normalEnum.outcome == .decodedSuccessfully) + #expect(fixture.$frozenEnum.outcome == .decodedSuccessfully) + #expect(fixture.$normalEnum.error == nil) + #expect(fixture.$frozenEnum.error == nil) + #endif + } + + @Test("RawRepresentable with unknown raw values (non-frozen)") + func testRawRepresentableUnknownValueNonFrozen() throws { + // given + let json = """ + { + "normalEnum": "third", + "frozenEnum": "beta" + } + """ + + // when + let decoder = JSONDecoder() + let data = json.data(using: .utf8)! + let fixture = try decoder.decode(RawRepresentableFixture.self, from: data) + + // then + #expect(fixture.normalEnum == .unknown) // Should use default value + #expect(fixture.frozenEnum == .beta) + + #if DEBUG + // Non-frozen enum should recover with UnknownNovelValueError + if case .recoveredFrom(let error, _) = fixture.$normalEnum.outcome { + #expect(error is UnknownNovelValueError) + if let unknownError = error as? UnknownNovelValueError { + #expect(unknownError.novelValue as? String == "third") + } + } else { + Issue.record("Expected recoveredFrom outcome for non-frozen enum") + } + + #expect(fixture.$frozenEnum.outcome == .decodedSuccessfully) + #endif + } + + @Test("RawRepresentable with unknown raw values (frozen)") + func testRawRepresentableUnknownValueFrozen() throws { + // given + let json = """ + { + "normalEnum": "first", + "frozenEnum": "gamma" + } + """ + + // when + let decoder = JSONDecoder() + let errorReporter = decoder.enableResilientDecodingErrorReporting() + let data = json.data(using: .utf8)! + let fixture = try decoder.decode(RawRepresentableFixture.self, from: data) + + // then + #expect(fixture.normalEnum == .first) + #expect(fixture.frozenEnum == .fallback) // Should use default value due to error + + #if DEBUG + // Frozen enum should report DecodingError + if case .recoveredFrom(let error, _) = fixture.$frozenEnum.outcome { + #expect(error is DecodingError) + if case .dataCorrupted = error as? DecodingError { + // Expected + } else { + Issue.record("Expected dataCorrupted DecodingError for frozen enum") + } + } else { + Issue.record("Expected recoveredFrom outcome for frozen enum") + } + + // Error should be reported to error reporter + let errorDigest = errorReporter.flushReportedErrors() + #expect(errorDigest != nil) + #endif + } + + @Test("RawRepresentable with missing keys") + func testRawRepresentableMissingKeys() throws { + // given + let json = "{}" + + // when + let decoder = JSONDecoder() + let data = json.data(using: .utf8)! + let fixture = try decoder.decode(RawRepresentableFixture.self, from: data) + + // then + #expect(fixture.normalEnum == .unknown) + #expect(fixture.frozenEnum == .fallback) + + #if DEBUG + #expect(fixture.$normalEnum.outcome == .keyNotFound) + #expect(fixture.$frozenEnum.outcome == .keyNotFound) + #expect(fixture.$normalEnum.error == nil) + #expect(fixture.$frozenEnum.error == nil) + #endif + } + + @Test("RawRepresentable with null values") + func testRawRepresentableNullValues() throws { + // given + let json = """ + { + "normalEnum": null, + "frozenEnum": null + } + """ + + // when + let decoder = JSONDecoder() + let data = json.data(using: .utf8)! + let fixture = try decoder.decode(RawRepresentableFixture.self, from: data) + + // then + #expect(fixture.normalEnum == .unknown) + #expect(fixture.frozenEnum == .fallback) + + #if DEBUG + #expect(fixture.$normalEnum.outcome == .valueWasNil) + #expect(fixture.$frozenEnum.outcome == .valueWasNil) + #expect(fixture.$normalEnum.error == nil) + #expect(fixture.$frozenEnum.error == nil) + #endif + } + + @Test("RawRepresentable with type mismatch") + func testRawRepresentableTypeMismatch() throws { + // given - enums expect String but we provide numbers + let json = """ + { + "normalEnum": 123, + "frozenEnum": true + } + """ + + // when + let decoder = JSONDecoder() + let data = json.data(using: .utf8)! + let fixture = try decoder.decode(RawRepresentableFixture.self, from: data) + + // then + #expect(fixture.normalEnum == .unknown) + #expect(fixture.frozenEnum == .fallback) + + #if DEBUG + // Both should have type mismatch errors + if case .recoveredFrom(let error, _) = fixture.$normalEnum.outcome { + #expect(error is DecodingError) + if case .typeMismatch = error as? DecodingError { + // Expected + } else { + Issue.record("Expected typeMismatch DecodingError") + } + } else { + Issue.record("Expected recoveredFrom outcome") + } + #endif + } +} From bb08f3d42b6d7c5a09ed38b5dfcf5c3dee3c6544 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elon=20Park=20=28=E1=84=8B=E1=85=A6=E1=86=AF=E1=84=85?= =?UTF-8?q?=E1=85=A9=E1=86=AB=29?= Date: Tue, 29 Jul 2025 22:12:26 +0900 Subject: [PATCH 09/22] feat: Add resilient decoding support to DataValue - Add outcome tracking to DataValue property wrapper - Implement projected value for error reporting - Handle decoding errors with proper error tracking - Add comprehensive tests for resilient behavior --- .../BetterCodable/DataValue/DataValue.swift | 40 +++++- .../DataValue/DataValueResilientTests.swift | 122 ++++++++++++++++++ 2 files changed, 159 insertions(+), 3 deletions(-) create mode 100644 Tests/KarrotCodableKitTests/BetterCodable/DataValue/DataValueResilientTests.swift diff --git a/Sources/KarrotCodableKit/BetterCodable/DataValue/DataValue.swift b/Sources/KarrotCodableKit/BetterCodable/DataValue/DataValue.swift index 5e20573..50f1173 100644 --- a/Sources/KarrotCodableKit/BetterCodable/DataValue/DataValue.swift +++ b/Sources/KarrotCodableKit/BetterCodable/DataValue/DataValue.swift @@ -24,15 +24,39 @@ public protocol DataValueCodableStrategy { @propertyWrapper public struct DataValue { public var wrappedValue: Coder.DataType + + public let outcome: ResilientDecodingOutcome public init(wrappedValue: Coder.DataType) { self.wrappedValue = wrappedValue + self.outcome = .decodedSuccessfully } + + init(wrappedValue: Coder.DataType, outcome: ResilientDecodingOutcome) { + self.wrappedValue = wrappedValue + self.outcome = outcome + } + + #if DEBUG + public var projectedValue: ResilientProjectedValue { ResilientProjectedValue(outcome: outcome) } + #endif } extension DataValue: Decodable { public init(from decoder: Decoder) throws { - self.wrappedValue = try Coder.decode(String(from: decoder)) + do { + let stringValue = try String(from: decoder) + do { + self.wrappedValue = try Coder.decode(stringValue) + self.outcome = .decodedSuccessfully + } catch { + decoder.reportError(error) + throw error + } + } catch { + decoder.reportError(error) + throw error + } } } @@ -42,6 +66,16 @@ extension DataValue: Encodable { } } -extension DataValue: Equatable where Coder.DataType: Equatable {} -extension DataValue: Hashable where Coder.DataType: Hashable {} +extension DataValue: Equatable where Coder.DataType: Equatable { + public static func ==(lhs: Self, rhs: Self) -> Bool { + lhs.wrappedValue == rhs.wrappedValue + } +} + +extension DataValue: Hashable where Coder.DataType: Hashable { + public func hash(into hasher: inout Hasher) { + hasher.combine(wrappedValue) + } +} + extension DataValue: Sendable where Coder.DataType: Sendable {} diff --git a/Tests/KarrotCodableKitTests/BetterCodable/DataValue/DataValueResilientTests.swift b/Tests/KarrotCodableKitTests/BetterCodable/DataValue/DataValueResilientTests.swift new file mode 100644 index 0000000..59d82ce --- /dev/null +++ b/Tests/KarrotCodableKitTests/BetterCodable/DataValue/DataValueResilientTests.swift @@ -0,0 +1,122 @@ +// +// DataValueResilientTests.swift +// KarrotCodableKitTests +// +// Created by Elon on 4/9/25. +// + +import Testing +import Foundation +@testable import KarrotCodableKit + +@Suite("DataValue Resilient Decoding") +struct DataValueResilientTests { + struct Fixture: Decodable { + @DataValue var base64Data: Data + @DataValue var anotherData: Data + } + + @Test("projected value provides error information") + func testProjectedValueProvidesErrorInfo() throws { + let json = """ + { + "base64Data": "SGVsbG8gV29ybGQ=", + "anotherData": "VGVzdCBEYXRh" + } + """ + + let decoder = JSONDecoder() + let data = try #require(json.data(using: .utf8)) + let fixture = try decoder.decode(Fixture.self, from: data) + + // Verify default behavior + #expect(String(data: fixture.base64Data, encoding: .utf8) == "Hello World") + #expect(String(data: fixture.anotherData, encoding: .utf8) == "Test Data") + + #if DEBUG + // Access success info through projected value + #expect(fixture.$base64Data.outcome == .decodedSuccessfully) + #expect(fixture.$anotherData.outcome == .decodedSuccessfully) + #endif + } + + @Test("invalid base64 format handling") + func testInvalidBase64Format() async throws { + let json = """ + { + "base64Data": "Invalid!@#$%^&*()Base64", + "anotherData": "=====" + } + """ + + let decoder = JSONDecoder() + let data = try #require(json.data(using: .utf8)) + + await confirmation(expectedCount: 1) { confirmation in + do { + _ = try decoder.decode(Fixture.self, from: data) + Issue.record("Should have thrown") + } catch { + // Invalid Base64 format causes decoding failure + confirmation() + } + } + } + + @Test("null values handling") + func testNullValues() async throws { + let json = """ + { + "base64Data": null, + "anotherData": null + } + """ + + let decoder = JSONDecoder() + let data = try #require(json.data(using: .utf8)) + + await confirmation(expectedCount: 1) { confirmation in + do { + _ = try decoder.decode(Fixture.self, from: data) + Issue.record("Should have thrown") + } catch { + // null values cannot be converted to Data + confirmation() + } + } + } + + @Test("error reporting with JSONDecoder") + func testErrorReporting() async throws { + let json = """ + { + "base64Data": 12345, + "anotherData": {"key": "value"} + } + """ + + let decoder = JSONDecoder() + let errorReporter = decoder.enableResilientDecodingErrorReporting() + + let data = try #require(json.data(using: .utf8)) + + await confirmation(expectedCount: 1) { confirmation in + do { + _ = try decoder.decode(Fixture.self, from: data) + Issue.record("Should have thrown") + } catch { + // Type mismatch causes decoding failure + confirmation() + } + } + + let errorDigest = errorReporter.flushReportedErrors() + + #if DEBUG + let digest = try #require(errorDigest) + #expect(digest.errors.count >= 1) + #else + #expect(errorDigest == nil) + #endif + } +} From ce476b9f789de5d24cb7bcb7101ceb2802a2c679 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elon=20Park=20=28=E1=84=8B=E1=85=A6=E1=86=AF=E1=84=85?= =?UTF-8?q?=E1=85=A9=E1=86=AB=29?= Date: Tue, 29 Jul 2025 22:12:38 +0900 Subject: [PATCH 10/22] feat: Add resilient decoding support to DateValue - Add outcome tracking to DateValue and OptionalDateValue - Implement projected value for error reporting - Handle decoding errors with proper error tracking - Add comprehensive tests for resilient behavior --- .../BetterCodable/DateValue/DateValue.swift | 24 +++- .../DateValue/OptionalDateValue.swift | 24 +++- .../DateValue/DateValueResilientTests.swift | 129 ++++++++++++++++++ 3 files changed, 172 insertions(+), 5 deletions(-) create mode 100644 Tests/KarrotCodableKitTests/BetterCodable/DateValue/DateValueResilientTests.swift diff --git a/Sources/KarrotCodableKit/BetterCodable/DateValue/DateValue.swift b/Sources/KarrotCodableKit/BetterCodable/DateValue/DateValue.swift index 357ea7d..bc9fd0a 100644 --- a/Sources/KarrotCodableKit/BetterCodable/DateValue/DateValue.swift +++ b/Sources/KarrotCodableKit/BetterCodable/DateValue/DateValue.swift @@ -24,16 +24,34 @@ public protocol DateValueCodableStrategy { @propertyWrapper public struct DateValue { public var wrappedValue: Date + + public let outcome: ResilientDecodingOutcome public init(wrappedValue: Date) { self.wrappedValue = wrappedValue + self.outcome = .decodedSuccessfully } + + init(wrappedValue: Date, outcome: ResilientDecodingOutcome) { + self.wrappedValue = wrappedValue + self.outcome = outcome + } + + #if DEBUG + public var projectedValue: ResilientProjectedValue { ResilientProjectedValue(outcome: outcome) } + #endif } extension DateValue: Decodable where Formatter.RawValue: Decodable { public init(from decoder: Decoder) throws { - let value = try Formatter.RawValue(from: decoder) - self.wrappedValue = try Formatter.decode(value) + do { + let value = try Formatter.RawValue(from: decoder) + self.wrappedValue = try Formatter.decode(value) + self.outcome = .decodedSuccessfully + } catch { + decoder.reportError(error) + throw error + } } } @@ -45,7 +63,7 @@ extension DateValue: Encodable where Formatter.RawValue: Encodable { } extension DateValue: Equatable { - public static func == (lhs: DateValue, rhs: DateValue) -> Bool { + public static func == (lhs: Self, rhs: Self) -> Bool { lhs.wrappedValue == rhs.wrappedValue } } diff --git a/Sources/KarrotCodableKit/BetterCodable/DateValue/OptionalDateValue.swift b/Sources/KarrotCodableKit/BetterCodable/DateValue/OptionalDateValue.swift index baa5f2a..744f102 100644 --- a/Sources/KarrotCodableKit/BetterCodable/DateValue/OptionalDateValue.swift +++ b/Sources/KarrotCodableKit/BetterCodable/DateValue/OptionalDateValue.swift @@ -25,20 +25,40 @@ public protocol OptionalDateValueCodableStrategy { @propertyWrapper public struct OptionalDateValue { public var wrappedValue: Date? + + public let outcome: ResilientDecodingOutcome public init(wrappedValue: Date?) { self.wrappedValue = wrappedValue + self.outcome = .decodedSuccessfully } + + init(wrappedValue: Date?, outcome: ResilientDecodingOutcome) { + self.wrappedValue = wrappedValue + self.outcome = outcome + } + + #if DEBUG + public var projectedValue: ResilientProjectedValue { ResilientProjectedValue(outcome: outcome) } + #endif } extension OptionalDateValue: Decodable where Formatter.RawValue: Decodable { public init(from decoder: Decoder) throws { do { let value = try Formatter.RawValue(from: decoder) - self.wrappedValue = try Formatter.decode(value) + do { + self.wrappedValue = try Formatter.decode(value) + self.outcome = .decodedSuccessfully + } catch { + decoder.reportError(error) + throw error + } } catch DecodingError.valueNotFound(let rawType, _) where rawType == Formatter.RawValue.self { self.wrappedValue = nil + self.outcome = .valueWasNil } catch { + decoder.reportError(error) throw error } } @@ -52,7 +72,7 @@ extension OptionalDateValue: Encodable where Formatter.RawValue: Encodable { } extension OptionalDateValue: Equatable { - public static func == (lhs: OptionalDateValue, rhs: OptionalDateValue) -> Bool { + public static func == (lhs: Self, rhs: Self) -> Bool { lhs.wrappedValue == rhs.wrappedValue } } diff --git a/Tests/KarrotCodableKitTests/BetterCodable/DateValue/DateValueResilientTests.swift b/Tests/KarrotCodableKitTests/BetterCodable/DateValue/DateValueResilientTests.swift new file mode 100644 index 0000000..8d2ef30 --- /dev/null +++ b/Tests/KarrotCodableKitTests/BetterCodable/DateValue/DateValueResilientTests.swift @@ -0,0 +1,129 @@ +// +// DateValueResilientTests.swift +// KarrotCodableKitTests +// +// Created by Elon on 4/9/25. +// + +import Testing +import Foundation +@testable import KarrotCodableKit + +@Suite("DateValue Resilient Decoding") +struct DateValueResilientTests { + struct Fixture: Decodable { + @DateValue var isoDate: Date + @DateValue var rfcDate: Date + @DateValue var timestampDate: Date + } + + @Test("projected value provides error information") + func testProjectedValueProvidesErrorInfo() throws { + let json = """ + { + "isoDate": "2025-01-01T12:00:00Z", + "rfcDate": "2025-01-01T12:00:00+00:00", + "timestampDate": 1735728000 + } + """ + + let decoder = JSONDecoder() + let data = try #require(json.data(using: .utf8)) + let fixture = try decoder.decode(Fixture.self, from: data) + + // Verify basic functionality + #expect(fixture.isoDate.timeIntervalSince1970 > 0) + #expect(fixture.rfcDate.timeIntervalSince1970 > 0) + #expect(fixture.timestampDate.timeIntervalSince1970 > 0) + + #if DEBUG + // Access success info via projected value + #expect(fixture.$isoDate.outcome == .decodedSuccessfully) + #expect(fixture.$rfcDate.outcome == .decodedSuccessfully) + #expect(fixture.$timestampDate.outcome == .decodedSuccessfully) + #endif + } + + @Test("invalid date format handling") + func testInvalidDateFormat() async throws { + let json = """ + { + "isoDate": "invalid-date", + "rfcDate": "2025-01-01", + "timestampDate": "not a number" + } + """ + + let decoder = JSONDecoder() + let data = try #require(json.data(using: .utf8)) + + await confirmation(expectedCount: 1) { confirmation in + do { + _ = try decoder.decode(Fixture.self, from: data) + Issue.record("Should have thrown") + } catch { + // Invalid Base64 format causes decoding failure + confirmation() + } + } + } + + @Test("null values handling") + func testNullValues() async throws { + let json = """ + { + "isoDate": null, + "rfcDate": null, + "timestampDate": null + } + """ + + let decoder = JSONDecoder() + let data = try #require(json.data(using: .utf8)) + + await confirmation(expectedCount: 1) { confirmation in + do { + _ = try decoder.decode(Fixture.self, from: data) + Issue.record("Should have thrown") + } catch { + // null values cannot be converted to Data + confirmation() + } + } + } + + @Test("error reporting with JSONDecoder") + func testErrorReporting() async throws { + let json = """ + { + "isoDate": 12345, + "rfcDate": true, + "timestampDate": ["array"] + } + """ + + let decoder = JSONDecoder() + let errorReporter = decoder.enableResilientDecodingErrorReporting() + + let data = try #require(json.data(using: .utf8)) + + await confirmation(expectedCount: 1) { confirmation in + do { + _ = try decoder.decode(Fixture.self, from: data) + Issue.record("Should have thrown") + } catch { + // Type mismatch causes decoding failure + confirmation() + } + } + + let errorDigest = errorReporter.flushReportedErrors() + + #if DEBUG + let digest = try #require(errorDigest) + #expect(digest.errors.count >= 1) + #else + #expect(errorDigest == nil) + #endif + } +} From fcbcfb0fe00c4a10d90d596058489dc055f42dda Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elon=20Park=20=28=E1=84=8B=E1=85=A6=E1=86=AF=E1=84=85?= =?UTF-8?q?=E1=85=A9=E1=86=AB=29?= Date: Tue, 29 Jul 2025 22:13:17 +0900 Subject: [PATCH 11/22] feat: Add resilient decoding support to LosslessValue - Add outcome tracking to LosslessValue and LosslessArray - Implement projected value for error reporting - Handle decoding errors with proper error tracking - Add comprehensive tests for resilient behavior --- .../LosslessValue/LosslessArray.swift | 48 +++++- .../LosslessValue/LosslessValue.swift | 35 ++++- .../LosslessArrayResilientTests.swift | 143 ++++++++++++++++++ .../LosslessValueResilientTests.swift | 137 +++++++++++++++++ 4 files changed, 355 insertions(+), 8 deletions(-) create mode 100644 Tests/KarrotCodableKitTests/BetterCodable/LosslessValue/LosslessArrayResilientTests.swift create mode 100644 Tests/KarrotCodableKitTests/BetterCodable/LosslessValue/LosslessValueResilientTests.swift diff --git a/Sources/KarrotCodableKit/BetterCodable/LosslessValue/LosslessArray.swift b/Sources/KarrotCodableKit/BetterCodable/LosslessValue/LosslessArray.swift index 588b752..55664b6 100644 --- a/Sources/KarrotCodableKit/BetterCodable/LosslessValue/LosslessArray.swift +++ b/Sources/KarrotCodableKit/BetterCodable/LosslessValue/LosslessArray.swift @@ -18,10 +18,22 @@ import Foundation @propertyWrapper public struct LosslessArray { public var wrappedValue: [T] + + public let outcome: ResilientDecodingOutcome public init(wrappedValue: [T]) { self.wrappedValue = wrappedValue + self.outcome = .decodedSuccessfully } + + init(wrappedValue: [T], outcome: ResilientDecodingOutcome) { + self.wrappedValue = wrappedValue + self.outcome = outcome + } + + #if DEBUG + public var projectedValue: ResilientArrayProjectedValue { ResilientArrayProjectedValue(outcome: outcome) } + #endif } extension LosslessArray: Decodable where T: Decodable { @@ -31,16 +43,36 @@ extension LosslessArray: Decodable where T: Decodable { var container = try decoder.unkeyedContainer() var elements: [T] = [] + #if DEBUG + var results: [Result] = [] + #endif + while !container.isAtEnd { do { let value = try container.decode(LosslessValue.self).wrappedValue elements.append(value) + #if DEBUG + results.append(.success(value)) + #endif } catch { _ = try? container.decode(AnyDecodableValue.self) + decoder.reportError(error) + #if DEBUG + results.append(.failure(error)) + #endif } } - self.wrappedValue = elements + #if DEBUG + if elements.count == results.count { + self.init(wrappedValue: elements, outcome: .decodedSuccessfully) + } else { + let error = ResilientDecodingOutcome.ArrayDecodingError(results: results) + self.init(wrappedValue: elements, outcome: .recoveredFrom(error, wasReported: false)) + } + #else + self.init(wrappedValue: elements) + #endif } } @@ -50,6 +82,16 @@ extension LosslessArray: Encodable where T: Encodable { } } -extension LosslessArray: Equatable where T: Equatable {} -extension LosslessArray: Hashable where T: Hashable {} +extension LosslessArray: Equatable where T: Equatable { + public static func ==(lhs: Self, rhs: Self) -> Bool { + lhs.wrappedValue == rhs.wrappedValue + } +} + +extension LosslessArray: Hashable where T: Hashable { + public func hash(into hasher: inout Hasher) { + hasher.combine(wrappedValue) + } +} + extension LosslessArray: Sendable where T: Sendable {} diff --git a/Sources/KarrotCodableKit/BetterCodable/LosslessValue/LosslessValue.swift b/Sources/KarrotCodableKit/BetterCodable/LosslessValue/LosslessValue.swift index c4e61b9..16a71e0 100644 --- a/Sources/KarrotCodableKit/BetterCodable/LosslessValue/LosslessValue.swift +++ b/Sources/KarrotCodableKit/BetterCodable/LosslessValue/LosslessValue.swift @@ -30,23 +30,48 @@ public struct LosslessValueCodable: Codable private let type: LosslessStringCodable.Type public var wrappedValue: Strategy.Value + + public let outcome: ResilientDecodingOutcome public init(wrappedValue: Strategy.Value) { self.wrappedValue = wrappedValue self.type = Strategy.Value.self + self.outcome = .decodedSuccessfully } + + init( + wrappedValue: Strategy.Value, + outcome: ResilientDecodingOutcome, + type: LosslessStringCodable.Type + ) { + self.wrappedValue = wrappedValue + self.outcome = outcome + self.type = type + } + + #if DEBUG + public var projectedValue: ResilientProjectedValue { + ResilientProjectedValue(outcome: outcome) + } + #endif public init(from decoder: Decoder) throws { do { self.wrappedValue = try Strategy.Value(from: decoder) self.type = Strategy.Value.self - } catch let error { - guard let rawValue = Strategy.losslessDecodableTypes.lazy.compactMap({ $0(decoder) }).first, - let value = Strategy.Value("\(rawValue)") - else { throw error } + self.outcome = .decodedSuccessfully + } catch { + guard + let rawValue = Strategy.losslessDecodableTypes.lazy.compactMap({ $0(decoder) }).first, + let value = Strategy.Value("\(rawValue)") + else { + decoder.reportError(error) + throw error + } self.wrappedValue = value self.type = Swift.type(of: rawValue) + self.outcome = .decodedSuccessfully } } @@ -63,7 +88,7 @@ public struct LosslessValueCodable: Codable } extension LosslessValueCodable: Equatable where Strategy.Value: Equatable { - public static func == (lhs: LosslessValueCodable, rhs: LosslessValueCodable) -> Bool { + public static func == (lhs: Self, rhs: Self) -> Bool { lhs.wrappedValue == rhs.wrappedValue } } diff --git a/Tests/KarrotCodableKitTests/BetterCodable/LosslessValue/LosslessArrayResilientTests.swift b/Tests/KarrotCodableKitTests/BetterCodable/LosslessValue/LosslessArrayResilientTests.swift new file mode 100644 index 0000000..9d143f9 --- /dev/null +++ b/Tests/KarrotCodableKitTests/BetterCodable/LosslessValue/LosslessArrayResilientTests.swift @@ -0,0 +1,143 @@ +// +// LosslessArrayResilientTests.swift +// KarrotCodableKitTests +// +// Created by Elon on 4/9/25. +// + +import Testing +import Foundation +@testable import KarrotCodableKit + +@Suite("LosslessArray Resilient Decoding") +struct LosslessArrayResilientTests { + struct Fixture: Decodable { + @LosslessArray var stringArray: [String] + @LosslessArray var intArray: [Int] + @LosslessArray var doubleArray: [Double] + + struct NestedObject: Decodable, Equatable, LosslessStringConvertible { + let id: Int + let name: String + + init?(_ description: String) { + return nil // Not convertible from string + } + + var description: String { + "NestedObject(id: \(id), name: \(name))" + } + } + @LosslessArray var objectArray: [String] // Objects cannot be converted to String + } + + @Test("projected value provides error information for each failed element") + func testProjectedValueProvidesErrorInfo() throws { + let json = """ + { + "stringArray": [1, "two", true, null, 5.5], + "intArray": ["invalid", 2, 3.14, 4, true], + "doubleArray": [1.5, "2.5", 3, null, "invalid"], + "objectArray": [{"id": 1, "name": "test"}, "string"] + } + """ + + let decoder = JSONDecoder() + let data = try #require(json.data(using: .utf8)) + let fixture = try decoder.decode(Fixture.self, from: data) + + // Verify default behavior - only convertible values included + #expect(fixture.stringArray == ["1", "two", "true", "5.5"]) + #expect(fixture.intArray == [2, 4]) + #expect(fixture.doubleArray == [1.5, 2.5, 3.0]) // 1.5, "2.5"->2.5, 3->3.0 + #expect(fixture.objectArray == ["string"]) + + #if DEBUG + // Access error info through projected value + #expect(fixture.$stringArray.results.count == 5) + #expect(fixture.$stringArray.errors.count == 1) // null + + #expect(fixture.$intArray.results.count == 5) + #expect(fixture.$intArray.errors.count == 3) // "invalid", 3.14, true + + #expect(fixture.$doubleArray.results.count == 5) + #expect(fixture.$doubleArray.errors.count == 2) // null, "invalid" + + #expect(fixture.$objectArray.results.count == 2) + #expect(fixture.$objectArray.errors.count == 1) // Objects cannot be converted to String + #endif + } + + @Test("error reporting with JSONDecoder") + func testErrorReporting() throws { + let json = """ + { + "stringArray": [1, null, "three"], + "intArray": ["not a number", 2], + "doubleArray": [1.5, "invalid", null], + "objectArray": [] + } + """ + + let decoder = JSONDecoder() + let errorReporter = decoder.enableResilientDecodingErrorReporting() + + let data = try #require(json.data(using: .utf8)) + _ = try decoder.decode(Fixture.self, from: data) + + let errorDigest = errorReporter.flushReportedErrors() + + #if DEBUG + // Check if errors were reported + let digest = try #require(errorDigest) + // null and conversion failure errors + #expect(digest.errors.count >= 3) + print("Error digest: \(digest.debugDescription)") + #else + #expect(errorDigest == nil) + #endif + } + + @Test("complete failure results in empty array") + func testCompleteFailure() async throws { + let json = """ + { + "stringArray": "not an array", + "intArray": 123, + "doubleArray": null, + "objectArray": true + } + """ + + let decoder = JSONDecoder() + let data = try #require(json.data(using: .utf8)) + + await confirmation(expectedCount: 1) { confirmation in + do { + _ = try decoder.decode(Fixture.self, from: data) + Issue.record("Should have thrown") + } catch { + // Non-array values cause decoding failure + confirmation() + } + } + } + + @Test("missing keys result in decoding error") + func testMissingKeys() async throws { + let json = "{}" + + let decoder = JSONDecoder() + let data = try #require(json.data(using: .utf8)) + + await confirmation(expectedCount: 1) { confirmation in + do { + _ = try decoder.decode(Fixture.self, from: data) + Issue.record("Should have thrown") + } catch { + // Decoding failure as required property + confirmation() + } + } + } +} diff --git a/Tests/KarrotCodableKitTests/BetterCodable/LosslessValue/LosslessValueResilientTests.swift b/Tests/KarrotCodableKitTests/BetterCodable/LosslessValue/LosslessValueResilientTests.swift new file mode 100644 index 0000000..eaaa5b3 --- /dev/null +++ b/Tests/KarrotCodableKitTests/BetterCodable/LosslessValue/LosslessValueResilientTests.swift @@ -0,0 +1,137 @@ +// +// LosslessValueResilientTests.swift +// KarrotCodableKitTests +// +// Created by Elon on 4/9/25. +// + +import Testing +import Foundation +@testable import KarrotCodableKit + +@Suite("LosslessValue Resilient Decoding") +struct LosslessValueResilientTests { + struct Fixture: Decodable { + @LosslessValue var stringValue: String + @LosslessValue var intValue: Int + @LosslessValue var boolValue: Bool + @LosslessValue var doubleValue: Double + } + + @Test("projected value provides error information") + func testProjectedValueProvidesErrorInfo() throws { + let json = """ + { + "stringValue": 123, + "intValue": "456", + "boolValue": "true", + "doubleValue": "3.14" + } + """ + + let decoder = JSONDecoder() + let data = try #require(json.data(using: .utf8)) + let fixture = try decoder.decode(Fixture.self, from: data) + + // Verify default behavior - all values converted + #expect(fixture.stringValue == "123") + #expect(fixture.intValue == 456) + #expect(fixture.boolValue == true) + #expect(fixture.doubleValue == 3.14) + + #if DEBUG + // Access success info through projected value + #expect(fixture.$stringValue.outcome == .decodedSuccessfully) + #expect(fixture.$intValue.outcome == .decodedSuccessfully) + #expect(fixture.$boolValue.outcome == .decodedSuccessfully) + #expect(fixture.$doubleValue.outcome == .decodedSuccessfully) + #endif + } + + @Test("null values handling") + func testNullValues() async throws { + let json = """ + { + "stringValue": null, + "intValue": null, + "boolValue": null, + "doubleValue": null + } + """ + + let decoder = JSONDecoder() + let data = try #require(json.data(using: .utf8)) + + await confirmation(expectedCount: 1) { confirmation in + do { + _ = try decoder.decode(Fixture.self, from: data) + Issue.record("Should have thrown") + } catch { + // null values cannot be handled by LosslessValue + confirmation() + } + } + } + + @Test("unconvertible values") + func testUnconvertibleValues() async throws { + let json = """ + { + "stringValue": {"key": "value"}, + "intValue": [1, 2, 3], + "boolValue": {"nested": true}, + "doubleValue": ["array"] + } + """ + + let decoder = JSONDecoder() + let data = try #require(json.data(using: .utf8)) + + await confirmation(expectedCount: 1) { confirmation in + do { + _ = try decoder.decode(Fixture.self, from: data) + Issue.record("Should have thrown") + } catch { + // Complex types cannot be converted + confirmation() + } + } + } + + @Test("error reporting with JSONDecoder") + func testErrorReporting() async throws { + let json = """ + { + "stringValue": {"key": "value"}, + "intValue": [1, 2, 3], + "boolValue": {"nested": true}, + "doubleValue": ["array"] + } + """ + + let decoder = JSONDecoder() + let errorReporter = decoder.enableResilientDecodingErrorReporting() + + let data = try #require(json.data(using: .utf8)) + + await confirmation(expectedCount: 1) { confirmation in + do { + _ = try decoder.decode(Fixture.self, from: data) + Issue.record("Should have thrown") + } catch { + // Complex types cannot be converted + confirmation() + } + } + + let errorDigest = errorReporter.flushReportedErrors() + + #if DEBUG + // Check if errors were reported + let digest = try #require(errorDigest) + #expect(digest.errors.count >= 1) // At least 1 error occurred + #else + #expect(errorDigest == nil) + #endif + } +} From f26288d7a76baadc16de52fdc36c68041135d62f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elon=20Park=20=28=E1=84=8B=E1=85=A6=E1=86=AF=E1=84=85?= =?UTF-8?q?=E1=85=A9=E1=86=AB=29?= Date: Tue, 29 Jul 2025 22:13:31 +0900 Subject: [PATCH 12/22] feat: Add resilient decoding support to LossyValue - Add outcome tracking to LossyArray and LossyDictionary - Implement projected value for error reporting - Track element-level decoding results - Add comprehensive tests for resilient behavior --- .../BetterCodable/LossyValue/LossyArray.swift | 90 ++++++- .../LossyValue/LossyDictionary.swift | 251 ++++++++++++++---- .../LossyValue/LossyArrayResilientTests.swift | 167 ++++++++++++ .../LossyDictionaryResilientTests.swift | 164 ++++++++++++ 4 files changed, 614 insertions(+), 58 deletions(-) create mode 100644 Tests/KarrotCodableKitTests/BetterCodable/LossyValue/LossyArrayResilientTests.swift create mode 100644 Tests/KarrotCodableKitTests/BetterCodable/LossyValue/LossyDictionaryResilientTests.swift diff --git a/Sources/KarrotCodableKit/BetterCodable/LossyValue/LossyArray.swift b/Sources/KarrotCodableKit/BetterCodable/LossyValue/LossyArray.swift index 68ff363..66efb39 100644 --- a/Sources/KarrotCodableKit/BetterCodable/LossyValue/LossyArray.swift +++ b/Sources/KarrotCodableKit/BetterCodable/LossyValue/LossyArray.swift @@ -15,29 +15,85 @@ import Foundation @propertyWrapper public struct LossyArray { public var wrappedValue: [T] + + public let outcome: ResilientDecodingOutcome public init(wrappedValue: [T]) { self.wrappedValue = wrappedValue + self.outcome = .decodedSuccessfully } + + init(wrappedValue: [T], outcome: ResilientDecodingOutcome) { + self.wrappedValue = wrappedValue + self.outcome = outcome + } + + #if DEBUG + public var projectedValue: ResilientArrayProjectedValue { ResilientArrayProjectedValue(outcome: outcome) } + #endif } extension LossyArray: Decodable where T: Decodable { private struct AnyDecodableValue: Decodable {} public init(from decoder: Decoder) throws { - var container = try decoder.unkeyedContainer() - - var elements: [T] = [] - while !container.isAtEnd { - do { - let value = try container.decode(T.self) - elements.append(value) - } catch { - _ = try? container.decode(AnyDecodableValue.self) + do { + // Check for null first + let singleValueContainer = try decoder.singleValueContainer() + if singleValueContainer.decodeNil() { + #if DEBUG + self.init(wrappedValue: [], outcome: .valueWasNil) + #else + self.init(wrappedValue: []) + #endif + return } + } catch { + // Not a single value container, proceed with array decoding } + + do { + var container = try decoder.unkeyedContainer() + + var elements: [T] = [] + #if DEBUG + var results: [Result] = [] + #endif + + while !container.isAtEnd { + let elementDecoder = try container.superDecoder() + do { + let value = try elementDecoder.singleValueContainer().decode(T.self) + elements.append(value) + #if DEBUG + results.append(.success(value)) + #endif + } catch { + elementDecoder.reportError(error) + #if DEBUG + results.append(.failure(error)) + #endif + } + } - self.wrappedValue = elements + #if DEBUG + if elements.count == results.count { + self.init(wrappedValue: elements, outcome: .decodedSuccessfully) + } else { + let error = ResilientDecodingOutcome.ArrayDecodingError(results: results) + self.init(wrappedValue: elements, outcome: .recoveredFrom(error, wasReported: false)) + } + #else + self.init(wrappedValue: elements) + #endif + } catch { + decoder.reportError(error) + #if DEBUG + self.init(wrappedValue: [], outcome: .recoveredFrom(error, wasReported: true)) + #else + self.init(wrappedValue: []) + #endif + } } } @@ -47,6 +103,16 @@ extension LossyArray: Encodable where T: Encodable { } } -extension LossyArray: Equatable where T: Equatable {} -extension LossyArray: Hashable where T: Hashable {} +extension LossyArray: Equatable where T: Equatable { + public static func ==(lhs: Self, rhs: Self) -> Bool { + lhs.wrappedValue == rhs.wrappedValue + } +} + +extension LossyArray: Hashable where T: Hashable { + public func hash(into hasher: inout Hasher) { + hasher.combine(wrappedValue) + } +} + extension LossyArray: Sendable where T: Sendable {} diff --git a/Sources/KarrotCodableKit/BetterCodable/LossyValue/LossyDictionary.swift b/Sources/KarrotCodableKit/BetterCodable/LossyValue/LossyDictionary.swift index 8fbc209..c48a3aa 100644 --- a/Sources/KarrotCodableKit/BetterCodable/LossyValue/LossyDictionary.swift +++ b/Sources/KarrotCodableKit/BetterCodable/LossyValue/LossyDictionary.swift @@ -15,10 +15,24 @@ import Foundation @propertyWrapper public struct LossyDictionary { public var wrappedValue: [Key: Value] + + public let outcome: ResilientDecodingOutcome public init(wrappedValue: [Key: Value]) { self.wrappedValue = wrappedValue + self.outcome = .decodedSuccessfully } + + init(wrappedValue: [Key: Value], outcome: ResilientDecodingOutcome) { + self.wrappedValue = wrappedValue + self.outcome = outcome + } + + #if DEBUG + public var projectedValue: ResilientDictionaryProjectedValue { + ResilientDictionaryProjectedValue(outcome: outcome) + } + #endif } extension LossyDictionary: Decodable where Key: Decodable, Value: Decodable { @@ -46,60 +60,183 @@ extension LossyDictionary: Decodable where Key: Decodable, Value: Decodable { self.value = try container.decode(DecodablValue.self) } } - - public init(from decoder: Decoder) throws { + + private struct ExtractedKey { + let codingKey: DictionaryCodingKey + let originalKey: String + } + + private struct DecodingState { var elements: [Key: Value] = [:] - if Key.self == String.self { - let container = try decoder.container(keyedBy: DictionaryCodingKey.self) - let keys = try Self.extractKeys(from: decoder, container: container) - - for (key, stringKey) in keys { - do { - let value = try container.decode(LossyDecodableValue.self, forKey: key).value - elements[stringKey as! Key] = value - } catch { - _ = try? container.decode(AnyDecodableValue.self, forKey: key) - } - } - } else if Key.self == Int.self { - let container = try decoder.container(keyedBy: DictionaryCodingKey.self) - - for key in container.allKeys { - guard key.intValue != nil else { - var codingPath = decoder.codingPath - codingPath.append(key) - throw DecodingError.typeMismatch( - Int.self, - DecodingError.Context( - codingPath: codingPath, - debugDescription: "Expected Int key but found String key instead." - ) - ) - } - - do { - let value = try container.decode(LossyDecodableValue.self, forKey: key).value - elements[key.intValue! as! Key] = value - } catch { - _ = try? container.decode(AnyDecodableValue.self, forKey: key) - } + #if DEBUG + var results: [Key: Result] = [:] + #endif + } + + private static func decodeNilValue(from decoder: Decoder) -> Bool { + do { + let singleValueContainer = try decoder.singleValueContainer() + return singleValueContainer.decodeNil() + } catch { + return false + } + } + + private static func decodeStringKeyedDictionary( + from decoder: Decoder + ) throws -> DecodingState { + guard Key.self == String.self else { + fatalError("This method should only be called for String keys") + } + + var state = DecodingState() + let container = try decoder.container(keyedBy: DictionaryCodingKey.self) + let keys = try extractKeys(from: decoder, container: container) + + for extractedKey in keys { + decodeSingleKeyValue( + container: container, + key: extractedKey.codingKey, + originalKey: extractedKey.originalKey, + state: &state + ) + } + + return state + } + + private static func decodeIntKeyedDictionary( + from decoder: Decoder + ) throws -> DecodingState { + guard Key.self == Int.self else { + fatalError("This method should only be called for Int keys") + } + + var state = DecodingState() + let container = try decoder.container(keyedBy: DictionaryCodingKey.self) + + for key in container.allKeys { + guard let intValue = key.intValue else { + // Skip non-integer keys instead of throwing + continue } + + decodeSingleKeyValueForInt( + container: container, + key: key, + intKey: intValue, + state: &state + ) + } + + return state + } + + private static func decodeSingleKeyValue( + container: KeyedDecodingContainer, + key: DictionaryCodingKey, + originalKey: String, + state: inout DecodingState + ) { + // Safe casting - if it fails, we skip this key entirely + guard let castKey = originalKey as? Key else { return } + + do { + let value = try container.decode(LossyDecodableValue.self, forKey: key).value + state.elements[castKey] = value + #if DEBUG + state.results[castKey] = .success(value) + #endif + } catch { + _ = try? container.decode(AnyDecodableValue.self, forKey: key) + let decoder = try? container.superDecoder(forKey: key) + decoder?.reportError(error) + #if DEBUG + state.results[castKey] = .failure(error) + #endif + } + } + + private static func decodeSingleKeyValueForInt( + container: KeyedDecodingContainer, + key: DictionaryCodingKey, + intKey: Int, + state: inout DecodingState + ) { + // Safe casting - if it fails, we skip this key entirely + guard let castKey = intKey as? Key else { return } + + do { + let value = try container.decode(LossyDecodableValue.self, forKey: key).value + state.elements[castKey] = value + #if DEBUG + state.results[castKey] = .success(value) + #endif + } catch { + _ = try? container.decode(AnyDecodableValue.self, forKey: key) + let decoder = try? container.superDecoder(forKey: key) + decoder?.reportError(error) + #if DEBUG + state.results[castKey] = .failure(error) + #endif + } + } + + private static func createFinalResult(from state: DecodingState) -> LossyDictionary { + #if DEBUG + if state.elements.count == state.results.count { + return LossyDictionary(wrappedValue: state.elements, outcome: .decodedSuccessfully) } else { - throw DecodingError.dataCorrupted( - DecodingError.Context( - codingPath: decoder.codingPath, - debugDescription: "Unable to decode key type." + let error = ResilientDecodingOutcome.DictionaryDecodingError(results: state.results) + return LossyDictionary(wrappedValue: state.elements, outcome: .recoveredFrom(error, wasReported: false)) + } + #else + return LossyDictionary(wrappedValue: state.elements) + #endif + } + + public init(from decoder: Decoder) throws { + // Check for nil first + if Self.decodeNilValue(from: decoder) { + #if DEBUG + self.init(wrappedValue: [:], outcome: .valueWasNil) + #else + self.init(wrappedValue: [:]) + #endif + return + } + + do { + let state: DecodingState + + if Key.self == String.self { + state = try Self.decodeStringKeyedDictionary(from: decoder) + } else if Key.self == Int.self { + state = try Self.decodeIntKeyedDictionary(from: decoder) + } else { + throw DecodingError.dataCorrupted( + DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "Unable to decode key type." + ) ) - ) + } + + self = Self.createFinalResult(from: state) + } catch { + decoder.reportError(error) + #if DEBUG + self.init(wrappedValue: [:], outcome: .recoveredFrom(error, wasReported: true)) + #else + self.init(wrappedValue: [:]) + #endif } - - self.wrappedValue = elements } private static func extractKeys( from decoder: Decoder, container: KeyedDecodingContainer - ) throws -> [(DictionaryCodingKey, String)] { + ) throws -> [ExtractedKey] { // Decode a dictionary ignoring the values to decode the original keys // without using the `JSONDecoder.KeyDecodingStrategy`. let keys = try decoder.singleValueContainer().decode([String: AnyDecodableValue].self).keys @@ -108,7 +245,7 @@ extension LossyDictionary: Decodable where Key: Decodable, Value: Decodable { container.allKeys.sorted(by: { $0.stringValue < $1.stringValue }), keys.sorted() ) - .map { ($0, $1) } + .map { ExtractedKey(codingKey: $0, originalKey: $1) } } } @@ -118,5 +255,27 @@ extension LossyDictionary: Encodable where Key: Encodable, Value: Encodable { } } -extension LossyDictionary: Equatable where Value: Equatable {} +extension LossyDictionary: Equatable where Value: Equatable { + public static func ==(lhs: Self, rhs: Self) -> Bool { + lhs.wrappedValue == rhs.wrappedValue + } +} + extension LossyDictionary: Sendable where Key: Sendable, Value: Sendable {} + +// MARK: - KeyedDecodingContainer + +extension KeyedDecodingContainer { + public func decode(_: LossyDictionary.Type, forKey key: Key) throws -> LossyDictionary where DictKey: Hashable & Decodable, DictValue: Decodable { + if let value = try decodeIfPresent(LossyDictionary.self, forKey: key) { + return value + } else { + #if DEBUG + return LossyDictionary(wrappedValue: [:], outcome: .keyNotFound) + #else + return LossyDictionary(wrappedValue: [:]) + #endif + } + } +} + diff --git a/Tests/KarrotCodableKitTests/BetterCodable/LossyValue/LossyArrayResilientTests.swift b/Tests/KarrotCodableKitTests/BetterCodable/LossyValue/LossyArrayResilientTests.swift new file mode 100644 index 0000000..683df4d --- /dev/null +++ b/Tests/KarrotCodableKitTests/BetterCodable/LossyValue/LossyArrayResilientTests.swift @@ -0,0 +1,167 @@ +// +// LossyArrayResilientTests.swift +// KarrotCodableKitTests +// +// Created by Elon on 4/9/25. +// + +import Testing +import Foundation +@testable import KarrotCodableKit + +@Suite("LossyArray Resilient Decoding") +struct LossyArrayResilientTests { + struct Fixture: Decodable { + @LossyArray var integers: [Int] + @LossyArray var strings: [String] + + struct NestedObject: Decodable, Equatable { + let id: Int + let name: String + } + @LossyArray var objects: [NestedObject] + } + + @Test("projected value provides error information in DEBUG") + func testProjectedValueProvidesErrorInfo() throws { + let json = """ + { + "integers": [1, "invalid", 3, null, 5], + "strings": ["hello", 123, "world", null], + "objects": [ + {"id": 1, "name": "first"}, + {"id": "invalid", "name": "second"}, + {"id": 3, "name": "third"} + ] + } + """ + + let decoder = JSONDecoder() + let data = try #require(json.data(using: .utf8)) + let fixture = try decoder.decode(Fixture.self, from: data) + + // Verify default behavior + #expect(fixture.integers == [1, 3, 5]) + #expect(fixture.strings == ["hello", "world"]) + #expect(fixture.objects == [ + Fixture.NestedObject(id: 1, name: "first"), + Fixture.NestedObject(id: 3, name: "third") + ]) + + #if DEBUG + // Access error info through projected value + #expect(fixture.$integers.results.count == 5) + #expect(fixture.$integers.errors.count == 2) // "invalid" and null + + // Check success/failure of each element + let intResults = fixture.$integers.results + #expect(try intResults[0].get() == 1) + #expect(intResults[1].isFailure == true) + #expect(try intResults[2].get() == 3) + #expect(intResults[3].isFailure == true) + #expect(try intResults[4].get() == 5) + + // strings validation + #expect(fixture.$strings.results.count == 4) + #expect(fixture.$strings.errors.count == 2) // 123 and null + + // objects validation + #expect(fixture.$objects.results.count == 3) + #expect(fixture.$objects.errors.count == 1) // "invalid" id + #endif + } + + @Test("error reporting with JSONDecoder") + func testErrorReporting() throws { + let json = """ + { + "integers": [1, "two", 3], + "strings": ["a", "b", "c"], + "objects": [] + } + """ + + let decoder = JSONDecoder() + let errorReporter = decoder.enableResilientDecodingErrorReporting() + + let data = try #require(json.data(using: .utf8)) + _ = try decoder.decode(Fixture.self, from: data) + + let errorDigest = errorReporter.flushReportedErrors() + + #if DEBUG + // Check if errors were reported + let digest = try #require(errorDigest) + #expect(digest.errors.count >= 1) + #else + // No error info in Release builds + #expect(errorDigest == nil) + #endif + } + + @Test("decode with reportResilientDecodingErrors") + func testDecodeWithReportFlag() throws { + let json = """ + { + "integers": [1, "invalid", 3], + "strings": [], + "objects": [] + } + """ + + let decoder = JSONDecoder() + let data = try #require(json.data(using: .utf8)) + + let (fixture, errorDigest) = try decoder.decode( + Fixture.self, + from: data, + reportResilientDecodingErrors: true + ) + + #expect(fixture.integers == [1, 3]) + + #if DEBUG + #expect(errorDigest != nil) + #expect(errorDigest?.errors.count ?? 0 >= 1) + #else + #expect(errorDigest == nil) + #endif + } + + @Test("empty array on complete failure") + func testEmptyArrayOnCompleteFailure() throws { + let json = """ + { + "integers": "not an array", + "strings": 123, + "objects": null + } + """ + + let decoder = JSONDecoder() + let data = try #require(json.data(using: .utf8)) + + let fixture = try decoder.decode(Fixture.self, from: data) + + #expect(fixture.integers == []) + #expect(fixture.strings == []) + #expect(fixture.objects == []) + + #if DEBUG + // Error info when entire array decoding fails + #expect(fixture.$integers.error != nil) + #expect(fixture.$strings.error != nil) + #expect(fixture.$objects.error == nil) // null is not an error + #endif + } +} + +// Result extension for testing +extension Result { + var isFailure: Bool { + switch self { + case .success: return false + case .failure: return true + } + } +} diff --git a/Tests/KarrotCodableKitTests/BetterCodable/LossyValue/LossyDictionaryResilientTests.swift b/Tests/KarrotCodableKitTests/BetterCodable/LossyValue/LossyDictionaryResilientTests.swift new file mode 100644 index 0000000..69a12a2 --- /dev/null +++ b/Tests/KarrotCodableKitTests/BetterCodable/LossyValue/LossyDictionaryResilientTests.swift @@ -0,0 +1,164 @@ +// +// LossyDictionaryResilientTests.swift +// KarrotCodableKitTests +// +// Created by Elon on 4/9/25. +// + +import Testing +import Foundation +@testable import KarrotCodableKit + +@Suite("LossyDictionary Resilient Decoding") +struct LossyDictionaryResilientTests { + struct Fixture: Decodable { + @LossyDictionary var stringDict: [String: Int] + @LossyDictionary var intDict: [Int: String] + + struct NestedObject: Decodable, Equatable { + let id: Int + let name: String + } + @LossyDictionary var objectDict: [String: NestedObject] + } + + @Test("projected value provides error information for each failed key-value pair") + func testProjectedValueProvidesErrorInfo() throws { + let json = """ + { + "stringDict": { + "one": 1, + "two": "invalid", + "three": 3, + "four": null + }, + "intDict": { + "1": "first", + "2": "second", + "3": "third", + "invalid": "should be ignored" + }, + "objectDict": { + "obj1": {"id": 1, "name": "first"}, + "obj2": {"id": "invalid", "name": "second"}, + "obj3": {"id": 3, "name": "third"} + } + } + """ + + let decoder = JSONDecoder() + let data = try #require(json.data(using: .utf8)) + let fixture = try decoder.decode(Fixture.self, from: data) + + // Verify default behavior - only valid key-value pairs included + #expect(fixture.stringDict == ["one": 1, "three": 3]) + #expect(fixture.intDict == [1: "first", 2: "second", 3: "third"]) // All Int keys are valid + #expect(fixture.objectDict == [ + "obj1": Fixture.NestedObject(id: 1, name: "first"), + "obj3": Fixture.NestedObject(id: 3, name: "third") + ]) + + #if DEBUG + // Access error info through projected value + #expect(fixture.$stringDict.results.count == 4) + #expect(fixture.$stringDict.errors.count == 2) // "invalid" and null + + // Check success/failure of each key + let stringResults = fixture.$stringDict.results + #expect(stringResults["one"] != nil) + if let result = stringResults["one"], case .success(let value) = result { + #expect(value == 1) + } + #expect(stringResults["two"] != nil) + if let result = stringResults["two"], case .failure = result { + // Expected failure + } + + // intDict validation - Int key type + #expect(fixture.$intDict.errors.count == 0) // All keys are valid + + // objectDict validation + #expect(fixture.$objectDict.results.count == 3) + #expect(fixture.$objectDict.errors.count == 1) // "invalid" id + #endif + } + + @Test("error reporting with JSONDecoder") + func testErrorReporting() throws { + let json = """ + { + "stringDict": { + "a": "not a number", + "b": 2, + "c": false + }, + "intDict": {}, + "objectDict": {} + } + """ + + let decoder = JSONDecoder() + let errorReporter = decoder.enableResilientDecodingErrorReporting() + + let data = try #require(json.data(using: .utf8)) + _ = try decoder.decode(Fixture.self, from: data) + + let errorDigest = errorReporter.flushReportedErrors() + + #if DEBUG + // Check if errors were reported + let digest = try #require(errorDigest) + #expect(digest.errors.count >= 2) // Errors for keys "a" and "c" + #else + #expect(errorDigest == nil) + #endif + } + + @Test("complete failure results in empty dictionary") + func testCompleteFailure() throws { + let json = """ + { + "stringDict": "not a dictionary", + "intDict": 123, + "objectDict": null + } + """ + + let decoder = JSONDecoder() + let data = try #require(json.data(using: .utf8)) + + let fixture = try decoder.decode(Fixture.self, from: data) + + #expect(fixture.stringDict == [:]) + #expect(fixture.intDict == [:]) + #expect(fixture.objectDict == [:]) + + #if DEBUG + // Error info when entire dictionary decoding fails + #expect(fixture.$stringDict.error != nil) + #expect(fixture.$intDict.error != nil) + #expect(fixture.$objectDict.error == nil) // null is not an error + #endif + } + + @Test("missing keys result in empty dictionary") + func testMissingKeys() throws { + let json = "{}" + + let decoder = JSONDecoder() + let data = try #require(json.data(using: .utf8)) + + let fixture = try decoder.decode(Fixture.self, from: data) + + #expect(fixture.stringDict == [:]) + #expect(fixture.intDict == [:]) + #expect(fixture.objectDict == [:]) + + #if DEBUG + // No error when key is missing + #expect(fixture.$stringDict.error == nil) + #expect(fixture.$intDict.error == nil) + #expect(fixture.$objectDict.error == nil) + #endif + } +} From 6ae0db132309581a06337687d76e012727553484 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elon=20Park=20=28=E1=84=8B=E1=85=A6=E1=86=AF=E1=84=85?= =?UTF-8?q?=E1=85=A9=E1=86=AB=29?= Date: Tue, 29 Jul 2025 22:13:44 +0900 Subject: [PATCH 13/22] feat: Add resilient decoding support to PolymorphicValue - Add outcome tracking to PolymorphicValue and PolymorphicArrayValue - Implement projected value for error reporting - Handle decoding errors with proper error tracking - Add comprehensive tests for resilient behavior --- .../PolymorphicArrayValue.swift | 36 ++- .../PolymorphicCodable/PolymorphicValue.swift | 34 ++- ...yPolymorphicArrayValueResilientTests.swift | 204 ++++++++++++++++ ...faultEmptyPolymorphicArrayValueTests.swift | 168 ++++++++++++++ .../PolymorphicArrayValueResilientTests.swift | 161 +++++++++++++ ...morphicLossyArrayValueResilientTests.swift | 218 ++++++++++++++++++ .../PolymorphicLossyArrayValueTests.swift | 169 ++++++++++++++ .../PolymorphicValueResilientTests.swift | 144 ++++++++++++ 8 files changed, 1129 insertions(+), 5 deletions(-) create mode 100644 Tests/KarrotCodableKitTests/PolymorphicCodable/ArrayValue/DefaultEmptyPolymorphicArrayValueResilientTests.swift create mode 100644 Tests/KarrotCodableKitTests/PolymorphicCodable/ArrayValue/DefaultEmptyPolymorphicArrayValueTests.swift create mode 100644 Tests/KarrotCodableKitTests/PolymorphicCodable/ArrayValue/PolymorphicArrayValueResilientTests.swift create mode 100644 Tests/KarrotCodableKitTests/PolymorphicCodable/ArrayValue/PolymorphicLossyArrayValueResilientTests.swift create mode 100644 Tests/KarrotCodableKitTests/PolymorphicCodable/ArrayValue/PolymorphicLossyArrayValueTests.swift create mode 100644 Tests/KarrotCodableKitTests/PolymorphicCodable/Value/PolymorphicValueResilientTests.swift diff --git a/Sources/KarrotCodableKit/PolymorphicCodable/PolymorphicArrayValue.swift b/Sources/KarrotCodableKit/PolymorphicCodable/PolymorphicArrayValue.swift index 2915aec..d889b36 100644 --- a/Sources/KarrotCodableKit/PolymorphicCodable/PolymorphicArrayValue.swift +++ b/Sources/KarrotCodableKit/PolymorphicCodable/PolymorphicArrayValue.swift @@ -19,11 +19,32 @@ import Foundation public struct PolymorphicArrayValue { /// The decoded array of values, each conforming to the expected polymorphic type. public var wrappedValue: [PolymorphicType.ExpectedType] + + /// Tracks the outcome of the decoding process for resilient decoding + public let outcome: ResilientDecodingOutcome /// Initializes the property wrapper with a pre-decoded array of values. public init(wrappedValue: [PolymorphicType.ExpectedType]) { self.wrappedValue = wrappedValue + self.outcome = .decodedSuccessfully } + + init(wrappedValue: [PolymorphicType.ExpectedType], outcome: ResilientDecodingOutcome) { + self.wrappedValue = wrappedValue + self.outcome = outcome + } + + #if DEBUG + /// The projected value providing access to decoding outcome + public var projectedValue: PolymorphicProjectedValue { + return PolymorphicProjectedValue(outcome: outcome) + } + #else + /// In non-DEBUG builds, accessing projectedValue is a programmer error + public var projectedValue: Never { + fatalError("@\(Self.self) projectedValue should not be used in non-DEBUG builds") + } + #endif } extension PolymorphicArrayValue: Decodable { @@ -37,6 +58,7 @@ extension PolymorphicArrayValue: Decodable { } self.wrappedValue = elements + self.outcome = .decodedSuccessfully } } @@ -49,6 +71,16 @@ extension PolymorphicArrayValue: Encodable { } } -extension PolymorphicArrayValue: Equatable where PolymorphicType.ExpectedType: Equatable {} -extension PolymorphicArrayValue: Hashable where PolymorphicType.ExpectedType: Hashable {} +extension PolymorphicArrayValue: Equatable where PolymorphicType.ExpectedType: Equatable { + public static func == (lhs: Self, rhs: Self) -> Bool { + lhs.wrappedValue == rhs.wrappedValue + } +} + +extension PolymorphicArrayValue: Hashable where PolymorphicType.ExpectedType: Hashable { + public func hash(into hasher: inout Hasher) { + hasher.combine(wrappedValue) + } +} + extension PolymorphicArrayValue: Sendable where PolymorphicType.ExpectedType: Sendable {} diff --git a/Sources/KarrotCodableKit/PolymorphicCodable/PolymorphicValue.swift b/Sources/KarrotCodableKit/PolymorphicCodable/PolymorphicValue.swift index 4c8e03e..ec38d56 100644 --- a/Sources/KarrotCodableKit/PolymorphicCodable/PolymorphicValue.swift +++ b/Sources/KarrotCodableKit/PolymorphicCodable/PolymorphicValue.swift @@ -24,10 +24,22 @@ public struct PolymorphicValue { /// The decoded value of the expected polymorphic type. public var wrappedValue: PolymorphicType.ExpectedType + public let outcome: ResilientDecodingOutcome + /// Initializes the property wrapper with a pre-decoded value. public init(wrappedValue: PolymorphicType.ExpectedType) { self.wrappedValue = wrappedValue + self.outcome = .decodedSuccessfully + } + + init(wrappedValue: PolymorphicType.ExpectedType, outcome: ResilientDecodingOutcome) { + self.wrappedValue = wrappedValue + self.outcome = outcome } + + #if DEBUG + public var projectedValue: PolymorphicProjectedValue { PolymorphicProjectedValue(outcome: outcome) } + #endif } extension PolymorphicValue: Encodable { @@ -38,10 +50,26 @@ extension PolymorphicValue: Encodable { extension PolymorphicValue: Decodable { public init(from decoder: Decoder) throws { - self.wrappedValue = try PolymorphicType.decode(from: decoder) + do { + self.wrappedValue = try PolymorphicType.decode(from: decoder) + self.outcome = .decodedSuccessfully + } catch { + decoder.reportError(error) + throw error + } + } +} + +extension PolymorphicValue: Equatable where PolymorphicType.ExpectedType: Equatable { + public static func == (lhs: Self, rhs: Self) -> Bool { + lhs.wrappedValue == rhs.wrappedValue + } +} + +extension PolymorphicValue: Hashable where PolymorphicType.ExpectedType: Hashable { + public func hash(into hasher: inout Hasher) { + hasher.combine(wrappedValue) } } -extension PolymorphicValue: Equatable where PolymorphicType.ExpectedType: Equatable {} -extension PolymorphicValue: Hashable where PolymorphicType.ExpectedType: Hashable {} extension PolymorphicValue: Sendable where PolymorphicType.ExpectedType: Sendable {} diff --git a/Tests/KarrotCodableKitTests/PolymorphicCodable/ArrayValue/DefaultEmptyPolymorphicArrayValueResilientTests.swift b/Tests/KarrotCodableKitTests/PolymorphicCodable/ArrayValue/DefaultEmptyPolymorphicArrayValueResilientTests.swift new file mode 100644 index 0000000..a3573d9 --- /dev/null +++ b/Tests/KarrotCodableKitTests/PolymorphicCodable/ArrayValue/DefaultEmptyPolymorphicArrayValueResilientTests.swift @@ -0,0 +1,204 @@ +import Foundation +import Testing +@testable import KarrotCodableKit + +@Suite("DefaultEmptyPolymorphicArrayValue Resilient Decoding") +struct DefaultEmptyPolymorphicArrayValueResilientTests { + struct Fixture: Decodable { + @DummyNotice.DefaultEmptyPolymorphicArray var notices: [any DummyNotice] + } + + @Test("Empty array decoding should have decodedSuccessfully outcome") + func emptyArray() throws { + // given + let json = """ + { + "notices": [] + } + """ + + // when + let data = try #require(json.data(using: .utf8)) + let result = try JSONDecoder().decode(Fixture.self, from: data) + + // then + #expect(result.notices.isEmpty) + #if DEBUG + #expect(result.$notices.outcome == .decodedSuccessfully) + #expect(result.$notices.error == nil) + #endif + } + + @Test("Successful array decoding should have decodedSuccessfully outcome") + 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" + } + ] + } + """ + + // when + let data = try #require(json.data(using: .utf8)) + let result = try JSONDecoder().decode(Fixture.self, from: data) + + // then + #expect(result.notices.count == 2) + #expect(result.notices[0] is DummyCallout) + #expect(result.notices[1] is DummyActionableCallout) + + #if DEBUG + #expect(result.$notices.outcome == .decodedSuccessfully) + #expect(result.$notices.error == nil) + #endif + } + + @Test("Should return empty array when array contains any invalid element") + func arrayWithAnyInvalidElement() throws { + // given + let json = """ + { + "notices": [ + { + "type": "callout", + "title": "Valid", + "description": "Valid callout", + "icon": "icon.png" + }, + { + "type": "invalid-type" + } + ] + } + """ + + // when + let data = try #require(json.data(using: .utf8)) + let result = try JSONDecoder().decode(Fixture.self, from: data) + + // then + #expect(result.notices.isEmpty) + + #if DEBUG + if case .recoveredFrom = result.$notices.outcome { + // Expected + } else { + Issue.record("Expected recoveredFrom outcome") + } + #expect(result.$notices.error != nil) + #endif + } + + @Test("Should return empty array when key is missing") + func missingKey() throws { + // given + let json = """ + {} + """ + + // when + let data = try #require(json.data(using: .utf8)) + let result = try JSONDecoder().decode(Fixture.self, from: data) + + // then + #expect(result.notices.isEmpty) + #if DEBUG + #expect(result.$notices.outcome == .keyNotFound) + #expect(result.$notices.error == nil) + #endif + } + + @Test("Should return empty array for null value") + func nullValue() throws { + // given + let json = """ + { + "notices": null + } + """ + + // when + let data = try #require(json.data(using: .utf8)) + let result = try JSONDecoder().decode(Fixture.self, from: data) + + // then + #expect(result.notices.isEmpty) + #if DEBUG + #expect(result.$notices.outcome == .valueWasNil) + #expect(result.$notices.error == nil) + #endif + } + + @Test("Should return empty array for invalid type") + func invalidType() throws { + // given + let json = """ + { + "notices": "not an array" + } + """ + + // when + let data = try #require(json.data(using: .utf8)) + let result = try JSONDecoder().decode(Fixture.self, from: data) + + // then + #expect(result.notices.isEmpty) + #if DEBUG + if case .recoveredFrom = result.$notices.outcome { + // Expected + } else { + Issue.record("Expected recoveredFrom outcome") + } + #expect(result.$notices.error != nil) + #endif + } + + @Test("Error reporter should be called") + func errorReporting() throws { + // given + let json = """ + { + "notices": [ + { + "type": "callout", + "title": "Valid", + "description": "Valid callout", + "icon": "icon.png" + }, + { + "type": "invalid-type" + } + ] + } + """ + + // when + let data = try #require(json.data(using: .utf8)) + let decoder = JSONDecoder() + let errorReporter = decoder.enableResilientDecodingErrorReporting() + + let result = try decoder.decode(Fixture.self, from: data) + + // then + #expect(result.notices.isEmpty) + + let errorDigest = errorReporter.flushReportedErrors() + let digest = try #require(errorDigest) + let errors = digest.errors + #expect(errors.count >= 1) + } +} diff --git a/Tests/KarrotCodableKitTests/PolymorphicCodable/ArrayValue/DefaultEmptyPolymorphicArrayValueTests.swift b/Tests/KarrotCodableKitTests/PolymorphicCodable/ArrayValue/DefaultEmptyPolymorphicArrayValueTests.swift new file mode 100644 index 0000000..10e91c1 --- /dev/null +++ b/Tests/KarrotCodableKitTests/PolymorphicCodable/ArrayValue/DefaultEmptyPolymorphicArrayValueTests.swift @@ -0,0 +1,168 @@ +// +// DefaultEmptyPolymorphicArrayValueTests.swift +// +// +// Created by Elon on 10/17/24. +// Copyright © 2025 Danggeun Market Inc. All rights reserved. +// + +import XCTest + +import KarrotCodableKit + +class DefaultEmptyPolymorphicArrayValueTests: XCTestCase { + + func testEncodingDefaultEmptyPolymorphicArrayValue() throws { + // given + let response = OptionalArrayDummyResponse( + notices1: [ + DummyCallout( + type: .callout, + title: nil, + description: "test", + icon: "test_icon" + ), + ], + notices2: [] + ) + + let expectResult = #""" + { + "notices1" : [ + { + "description" : "test", + "icon" : "test_icon", + "type" : "callout" + } + ], + "notices2" : [ + + ] + } + """# + + // when + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + let data = try encoder.encode(response) + + // then + let jsonString = String(decoding: data, as: UTF8.self) + XCTAssertEqual(jsonString, expectResult) + } + + func testDecodingDefaultEmptyPolymorphicArrayValue() throws { + // given + let jsonData = #""" + { + "notices1" : [ + { + "description" : "test", + "icon" : "test_icon", + "type" : "callout" + } + ] + } + """# + + // when + let result = try JSONDecoder().decode(OptionalArrayDummyResponse.self, from: Data(jsonData.utf8)) + + // then + XCTAssertEqual(result.notices1.count, 1) + XCTAssertEqual(result.notices1.first?.type, .callout) + XCTAssertTrue(result.notices2.isEmpty) + } + + func testDecodingEncodingDefaultEmptyPolymorphicArrayValue() throws { + // given + let json = #""" + { + "notices1" : null, + "notices2" : null + } + """# + + // when + let result = try JSONDecoder().decode(OptionalArrayDummyResponse.self, from: Data(json.utf8)) + + // then + XCTAssertTrue(result.notices1.isEmpty) + XCTAssertTrue(result.notices2.isEmpty) + + // when + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + let data = try encoder.encode(result) + + // then + let expectResult = #""" + { + "notices1" : [ + + ], + "notices2" : [ + + ] + } + """# + let jsonString = String(decoding: data, as: UTF8.self) + XCTAssertEqual(jsonString, expectResult) + } +} + +extension DefaultEmptyPolymorphicArrayValueTests { + func testDecodingFailElementInDefaultEmptyPolymorphicArrayValue() throws { + // given + let jsonData = #""" + { + "notices1" : [ + { + "icon" : "test_icon", + "type" : "callout" + }, + { + "description" : "test", + "icon" : "test_icon", + "type" : "callout" + } + ] + } + """# + + // when + let result = try JSONDecoder().decode(OptionalArrayDummyResponse.self, from: Data(jsonData.utf8)) + + // then + XCTAssertTrue(result.notices1.isEmpty) + } +} + +extension DefaultEmptyPolymorphicArrayValueTests { + func testDecodingOnlyValue() throws { + // given + let jsonData = #""" + { + "notices2" : [ + { + "description" : "test", + "icon" : "test_icon", + "type" : "callout" + } + ], + "notice3" : null + } + """# + + // when + let result = try JSONDecoder().decode( + OptionalAarrayDummyDecodableResponse.self, + from: Data(jsonData.utf8) + ) + + // then + XCTAssertTrue(result.notices1.isEmpty) + XCTAssertEqual(result.notices2.first?.type, .callout) + XCTAssertTrue(result.notices3.isEmpty) + } +} diff --git a/Tests/KarrotCodableKitTests/PolymorphicCodable/ArrayValue/PolymorphicArrayValueResilientTests.swift b/Tests/KarrotCodableKitTests/PolymorphicCodable/ArrayValue/PolymorphicArrayValueResilientTests.swift new file mode 100644 index 0000000..e22ead7 --- /dev/null +++ b/Tests/KarrotCodableKitTests/PolymorphicCodable/ArrayValue/PolymorphicArrayValueResilientTests.swift @@ -0,0 +1,161 @@ +import Foundation +import Testing +@testable import KarrotCodableKit + +@Suite("PolymorphicArrayValue Resilient Decoding") +struct PolymorphicArrayValueResilientTests { + struct Fixture: Decodable { + @DummyNotice.PolymorphicArray var notices: [any DummyNotice] + } + + @Test("Empty array decoding should have decodedSuccessfully outcome") + func emptyArray() throws { + // given + let json = """ + { + "notices": [] + } + """ + + // when + let data = try #require(json.data(using: .utf8)) + let result = try JSONDecoder().decode(Fixture.self, from: data) + + // then + #expect(result.notices.isEmpty) + #if DEBUG + #expect(result.$notices.outcome == .decodedSuccessfully) + #expect(result.$notices.error == nil) + #endif + } + + @Test("Successful array decoding should have decodedSuccessfully outcome") + 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" + }, + { + "type": "dismissible-callout", + "title": "Third", + "description": "Third callout", + "key": "dismiss-key" + } + ] + } + """ + + // when + let data = try #require(json.data(using: .utf8)) + let result = try JSONDecoder().decode(Fixture.self, from: data) + + // then + #expect(result.notices.count == 3) + #expect(result.notices[0] is DummyCallout) + #expect(result.notices[1] is DummyActionableCallout) + #expect(result.notices[2] is DummyDismissibleCallout) + + #if DEBUG + #expect(result.$notices.outcome == .decodedSuccessfully) + #expect(result.$notices.error == nil) + #endif + } + + @Test("Array should fail to decode if any element fails") + func arrayWithInvalidElement() throws { + // given + let json = """ + { + "notices": [ + { + "type": "callout", + "title": "Valid", + "description": "Valid callout", + "icon": "icon.png" + }, + { + "type": "invalid-type" + } + ] + } + """ + + // when/Then + let data = try #require(json.data(using: .utf8)) + #expect(throws: Error.self) { + _ = try JSONDecoder().decode(Fixture.self, from: data) + } + } + + @Test("Should throw error when key is missing") + func missingKey() throws { + // given + let json = """ + {} + """ + + // when/Then + let data = try #require(json.data(using: .utf8)) + #expect(throws: Error.self) { + _ = try JSONDecoder().decode(Fixture.self, from: data) + } + } + + @Test("Should throw error for invalid type") + func invalidType() throws { + // given + let json = """ + { + "notices": "not an array" + } + """ + + // when/Then + let data = try #require(json.data(using: .utf8)) + #expect(throws: Error.self) { + _ = try JSONDecoder().decode(Fixture.self, from: data) + } + } + + @Test("Array element errors should be reported") + func arrayElementErrorReported() throws { + // given + let json = """ + { + "notices": [ + { + "type": "invalid-type" + } + ] + } + """ + + // when + let data = try #require(json.data(using: .utf8)) + let decoder = JSONDecoder() + let errorReporter = decoder.enableResilientDecodingErrorReporting() + + // then + #expect(throws: Error.self) { + _ = try decoder.decode(Fixture.self, from: data) + } + + // PolymorphicValue reports errors, so error digest should exist + let errorDigest = errorReporter.flushReportedErrors() + let digest = try #require(errorDigest) + let errors = digest.errors + #expect(errors.count >= 1) + } +} diff --git a/Tests/KarrotCodableKitTests/PolymorphicCodable/ArrayValue/PolymorphicLossyArrayValueResilientTests.swift b/Tests/KarrotCodableKitTests/PolymorphicCodable/ArrayValue/PolymorphicLossyArrayValueResilientTests.swift new file mode 100644 index 0000000..2d09c19 --- /dev/null +++ b/Tests/KarrotCodableKitTests/PolymorphicCodable/ArrayValue/PolymorphicLossyArrayValueResilientTests.swift @@ -0,0 +1,218 @@ +import Foundation +import Testing +@testable import KarrotCodableKit + +@Suite("PolymorphicLossyArrayValue Resilient Decoding") +struct PolymorphicLossyArrayValueResilientTests { + struct Fixture: Decodable { + @DummyNotice.PolymorphicLossyArray var notices: [any DummyNotice] + } + + @Test("Empty array decoding should have decodedSuccessfully outcome") + func emptyArray() throws { + // given + let json = """ + { + "notices": [] + } + """ + + // when + let data = try #require(json.data(using: .utf8)) + let result = try JSONDecoder().decode(Fixture.self, from: data) + + // then + #expect(result.notices.isEmpty) + #if DEBUG + #expect(result.$notices.outcome == .decodedSuccessfully) + #expect(result.$notices.error == nil) + #expect(result.$notices.results.isEmpty) + #endif + } + + @Test("Successful array decoding should have all elements succeed") + 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" + } + ] + } + """ + + // when + let data = try #require(json.data(using: .utf8)) + let result = try JSONDecoder().decode(Fixture.self, from: data) + + // then + #expect(result.notices.count == 2) + #expect(result.notices[0] is DummyCallout) + #expect(result.notices[1] is DummyActionableCallout) + + #if DEBUG + #expect(result.$notices.outcome == .decodedSuccessfully) + #expect(result.$notices.error == nil) + #expect(result.$notices.results.count == 2) + #expect(result.$notices.results.allSatisfy { result in + if case .success = result { return true } + return false + }) + #endif + } + + @Test("Should decode only valid elements when array has invalid elements") + 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 + ] + } + """ + + // when + let data = try #require(json.data(using: .utf8)) + let result = try JSONDecoder().decode(Fixture.self, from: data) + + // then + #expect(result.notices.count == 2) + #expect(result.notices[0] is DummyCallout) + #expect(result.notices[1] is DummyDismissibleCallout) + + #if DEBUG + #expect(result.$notices.outcome == .decodedSuccessfully) + #expect(result.$notices.results.count == 6) + + // Only first and third elements succeed + if case .success = result.$notices.results[0] {} else { + Issue.record("Expected success at index 0") + } + if case .failure = result.$notices.results[1] {} else { + Issue.record("Expected failure at index 1") + } + if case .success = result.$notices.results[2] {} else { + Issue.record("Expected success at index 2") + } + if case .failure = result.$notices.results[3] {} else { + Issue.record("Expected failure at index 3") + } + if case .failure = result.$notices.results[4] {} else { + Issue.record("Expected failure at index 4") + } + if case .failure = result.$notices.results[5] {} else { + Issue.record("Expected failure at index 5") + } + #endif + } + + @Test("Should return empty array when key is missing") + func missingKey() throws { + // given + let json = """ + {} + """ + + // when + let data = try #require(json.data(using: .utf8)) + let result = try JSONDecoder().decode(Fixture.self, from: data) + + // then + #expect(result.notices.isEmpty) + #if DEBUG + #expect(result.$notices.outcome == .keyNotFound) + #expect(result.$notices.error == nil) + #expect(result.$notices.results.isEmpty) + #endif + } + + @Test("Should return empty array for invalid type") + func invalidType() throws { + // given + let json = """ + { + "notices": "not an array" + } + """ + + // when + let data = try #require(json.data(using: .utf8)) + let result = try JSONDecoder().decode(Fixture.self, from: data) + + // then + #expect(result.notices.isEmpty) + #if DEBUG + if case .recoveredFrom = result.$notices.outcome { + // Expected + } else { + Issue.record("Expected recoveredFrom outcome") + } + #expect(result.$notices.error != nil) + #expect(result.$notices.results.isEmpty) + #endif + } + + @Test("Error reporter should be called partially") + func partialErrorReporting() throws { + // given + let json = """ + { + "notices": [ + { + "type": "callout", + "title": "Valid", + "description": "Valid callout", + "icon": "icon.png" + }, + { + "type": "invalid-type" + } + ] + } + """ + + // when + let data = try #require(json.data(using: .utf8)) + let decoder = JSONDecoder() + let errorReporter = decoder.enableResilientDecodingErrorReporting() + + let result = try decoder.decode(Fixture.self, from: data) + + // then + #expect(result.notices.count == 1) + + let errorDigest = errorReporter.flushReportedErrors() + let digest = try #require(errorDigest) + let errors = digest.errors + #expect(errors.count >= 1) + } +} diff --git a/Tests/KarrotCodableKitTests/PolymorphicCodable/ArrayValue/PolymorphicLossyArrayValueTests.swift b/Tests/KarrotCodableKitTests/PolymorphicCodable/ArrayValue/PolymorphicLossyArrayValueTests.swift new file mode 100644 index 0000000..0b9d618 --- /dev/null +++ b/Tests/KarrotCodableKitTests/PolymorphicCodable/ArrayValue/PolymorphicLossyArrayValueTests.swift @@ -0,0 +1,169 @@ +// +// PolymorphicLossyArrayValueTests.swift +// +// +// Created by Elon on 10/18/24. +// Copyright © 2025 Danggeun Market Inc. All rights reserved. +// + +import XCTest + +import KarrotCodableKit + +class PolymorphicLossyArrayValueTests: XCTestCase { + + func testEncodingDefaultEmptyPolymorphicArrayValue() throws { + // given + let response = OptionalLossyArrayDummyResponse( + notices1: [ + DummyCallout( + type: .callout, + title: nil, + description: "test", + icon: "test_icon" + ), + ], + notices2: [] + ) + + let expectResult = #""" + { + "notices1" : [ + { + "description" : "test", + "icon" : "test_icon", + "type" : "callout" + } + ], + "notices2" : [ + + ] + } + """# + + // when + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + let data = try encoder.encode(response) + + // then + let jsonString = String(decoding: data, as: UTF8.self) + XCTAssertEqual(jsonString, expectResult) + } + + func testDecodingDefaultEmptyPolymorphicArrayValue() throws { + // given + let jsonData = #""" + { + "notices1" : [ + { + "description" : "test", + "icon" : "test_icon", + "type" : "callout" + } + ] + } + """# + + // when + let result = try JSONDecoder().decode(OptionalLossyArrayDummyResponse.self, from: Data(jsonData.utf8)) + + // then + XCTAssertEqual(result.notices1.count, 1) + XCTAssertEqual(result.notices1.first?.type, .callout) + XCTAssertTrue(result.notices2.isEmpty) + } + + func testDecodingEncodingDefaultEmptyPolymorphicArrayValue() throws { + // given + let json = #""" + { + "notices1" : null, + "notices2" : null + } + """# + + // when + let result = try JSONDecoder().decode(OptionalLossyArrayDummyResponse.self, from: Data(json.utf8)) + + // then + XCTAssertTrue(result.notices1.isEmpty) + XCTAssertTrue(result.notices2.isEmpty) + + // when + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + let data = try encoder.encode(result) + + // then + let expectResult = #""" + { + "notices1" : [ + + ], + "notices2" : [ + + ] + } + """# + let jsonString = String(decoding: data, as: UTF8.self) + XCTAssertEqual(jsonString, expectResult) + } +} + +extension PolymorphicLossyArrayValueTests { + func testDecodingFailElementInDefaultEmptyPolymorphicArrayValue() throws { + // given: An array where one element (notice) is missing the required 'description' parameter. + let jsonData = #""" + { + "notices1" : [ + { + "icon" : "test_icon", + "type" : "callout" + }, + { + "description" : "test", + "icon" : "test_icon", + "type" : "callout" + } + ] + } + """# + + // when: During decoding. + let result = try JSONDecoder().decode(OptionalLossyArrayDummyResponse.self, from: Data(jsonData.utf8)) + + // then: Returns an array excluding the element that failed decoding. + XCTAssertEqual(result.notices1.count, 1) + XCTAssertEqual(result.notices1.first?.description, "test") + } +} + +extension PolymorphicLossyArrayValueTests { + func testDecodingOnlyValue() throws { + // given + let jsonData = #""" + { + "notices2" : [ + { + "description" : "test", + "icon" : "test_icon", + "type" : "callout" + } + ], + "notice3" : null + } + """# + + // when + let result = try JSONDecoder().decode( + OptionalLossyAarrayDummyDecodableResponse.self, + from: Data(jsonData.utf8) + ) + + // then + XCTAssertTrue(result.notices1.isEmpty) + XCTAssertEqual(result.notices2.first?.type, .callout) + XCTAssertTrue(result.notices3.isEmpty) + } +} diff --git a/Tests/KarrotCodableKitTests/PolymorphicCodable/Value/PolymorphicValueResilientTests.swift b/Tests/KarrotCodableKitTests/PolymorphicCodable/Value/PolymorphicValueResilientTests.swift new file mode 100644 index 0000000..9a34faf --- /dev/null +++ b/Tests/KarrotCodableKitTests/PolymorphicCodable/Value/PolymorphicValueResilientTests.swift @@ -0,0 +1,144 @@ +// +// PolymorphicValueResilientTests.swift +// KarrotCodableKitTests +// +// Created by Elon on 4/9/25. +// + +import Testing +import Foundation +@testable import KarrotCodableKit + +@Suite("PolymorphicValue Resilient Decoding") +struct PolymorphicValueResilientTests { + struct Fixture: Decodable { + @DummyNotice.Polymorphic var notice: any DummyNotice + } + + @Test("projected value provides error information") + func testProjectedValueProvidesErrorInfo() throws { + // given + let json = """ + { + "notice": { + "type": "callout", + "description": "test description", + "icon": "test_icon" + } + } + """ + + // when + let decoder = JSONDecoder() + let data = try #require(json.data(using: .utf8)) + let fixture = try decoder.decode(Fixture.self, from: data) + + // then + // Verify basic behavior + #expect(fixture.notice.description == "test description") + #expect((fixture.notice as? DummyCallout)?.icon == "test_icon") + + #if DEBUG + // Access success info via projected value + #expect(fixture.$notice.outcome == .decodedSuccessfully) + #endif + } + + @Test("unknown type handling with fallback") + func testUnknownType() throws { + // given + let json = """ + { + "notice": { + "type": "unknown-type", + "description": "test description" + } + } + """ + + // when + let decoder = JSONDecoder() + let data = try #require(json.data(using: .utf8)) + + // DummyNotice has fallback type configured so should succeed + let fixture = try decoder.decode(Fixture.self, from: data) + + // then + // Verify decoded as fallback type + #expect(fixture.notice is DummyUndefinedCallout) + #expect(fixture.notice.description == "test description") + + #if DEBUG + // Access success info via projected value + #expect(fixture.$notice.outcome == .decodedSuccessfully) + #endif + } + + @Test("null values handling") + func testNullValues() async throws { + // given + let json = """ + { + "notice": null + } + """ + + // when / then + let decoder = JSONDecoder() + let data = try #require(json.data(using: .utf8)) + + await confirmation(expectedCount: 1) { confirmation in + do { + _ = try decoder.decode(Fixture.self, from: data) + Issue.record("Should have thrown") + } catch { + // Cannot handle null values + confirmation() + } + } + } + + @Test("error reporting with JSONDecoder") + func testErrorReporting() async throws { + // given + let json = """ + { + "notice": { + "type": "dismissible-callout", + "description": "test", + "title": "title", + "key": 123 + } + } + """ + + // when + let decoder = JSONDecoder() + let errorReporter = decoder.enableResilientDecodingErrorReporting() + + let data = try #require(json.data(using: .utf8)) + + // then + await confirmation(expectedCount: 1) { confirmation in + do { + _ = try decoder.decode(Fixture.self, from: data) + Issue.record("Should have thrown") + } catch { + // Decoding failed due to type mismatch (key should be String) + confirmation() + } + } + + // then + let errorDigest = errorReporter.flushReportedErrors() + + #if DEBUG + // Check if error was reported + let digest = try #require(errorDigest) + #expect(digest.errors.count >= 1) + #else + #expect(errorDigest == nil) + #endif + } +} + From 74a872a9703537118c882f8578c1907043cda7aa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elon=20Park=20=28=E1=84=8B=E1=85=A6=E1=86=AF=E1=84=85?= =?UTF-8?q?=E1=85=A9=E1=86=AB=29?= Date: Tue, 29 Jul 2025 22:13:58 +0900 Subject: [PATCH 14/22] feat: Add resilient decoding support to OptionalPolymorphicValue - Add outcome tracking to OptionalPolymorphicValue - Update KeyedDecodingContainer extension - Implement projected value for error reporting - Add comprehensive tests for resilient behavior --- ...ngContainer+OptionalPolymorphicValue.swift | 29 ++++- .../OptionalPolymorphicValue.swift | 43 ++++++- ...tionalPolymorphicValueResilientTests.swift | 117 ++++++++++++++++++ .../OptionalPolymorphicValueTests.swift | 0 4 files changed, 182 insertions(+), 7 deletions(-) create mode 100644 Tests/KarrotCodableKitTests/PolymorphicCodable/OptionalValue/OptionalPolymorphicValueResilientTests.swift rename Tests/KarrotCodableKitTests/PolymorphicCodable/{ => OptionalValue}/OptionalPolymorphicValueTests.swift (100%) diff --git a/Sources/KarrotCodableKit/PolymorphicCodable/Extensions/KeyedDecodingContainer+OptionalPolymorphicValue.swift b/Sources/KarrotCodableKit/PolymorphicCodable/Extensions/KeyedDecodingContainer+OptionalPolymorphicValue.swift index 2bdf143..bab48df 100644 --- a/Sources/KarrotCodableKit/PolymorphicCodable/Extensions/KeyedDecodingContainer+OptionalPolymorphicValue.swift +++ b/Sources/KarrotCodableKit/PolymorphicCodable/Extensions/KeyedDecodingContainer+OptionalPolymorphicValue.swift @@ -13,14 +13,35 @@ extension KeyedDecodingContainer { _ type: OptionalPolymorphicValue.Type, forKey key: Key ) throws -> OptionalPolymorphicValue where T: PolymorphicCodableStrategy { - try decodeIfPresent(type, forKey: key) ?? OptionalPolymorphicValue(wrappedValue: nil) + if let value = try decodeIfPresent(type, forKey: key) { + return value + } else { + return OptionalPolymorphicValue(wrappedValue: nil, outcome: .keyNotFound) + } } public func decodeIfPresent( _ type: OptionalPolymorphicValue.Type, forKey key: Self.Key - ) throws -> OptionalPolymorphicValue where T.ExpectedType: Decodable { - let optionalValue = try decodeIfPresent(T.ExpectedType.self, forKey: key) - return OptionalPolymorphicValue(wrappedValue: optionalValue) + ) throws -> OptionalPolymorphicValue? where T: PolymorphicCodableStrategy { + // Check if key exists + guard contains(key) else { + return nil + } + + // Check if value is null + if try decodeNil(forKey: key) { + return OptionalPolymorphicValue(wrappedValue: nil, outcome: .valueWasNil) + } + + // Try to decode the polymorphic value + do { + let decoder = try superDecoder(forKey: key) + let value = try T.decode(from: decoder) + return OptionalPolymorphicValue(wrappedValue: value, outcome: .decodedSuccessfully) + } catch { + // OptionalPolymorphicValue throws errors instead of recovering + throw error + } } } diff --git a/Sources/KarrotCodableKit/PolymorphicCodable/OptionalPolymorphicValue.swift b/Sources/KarrotCodableKit/PolymorphicCodable/OptionalPolymorphicValue.swift index 72634f7..0ad6462 100644 --- a/Sources/KarrotCodableKit/PolymorphicCodable/OptionalPolymorphicValue.swift +++ b/Sources/KarrotCodableKit/PolymorphicCodable/OptionalPolymorphicValue.swift @@ -26,9 +26,30 @@ public struct OptionalPolymorphicValue Bool { + lhs.wrappedValue == rhs.wrappedValue + } +} + +extension OptionalPolymorphicValue: Hashable where PolymorphicType.ExpectedType: Hashable { + public func hash(into hasher: inout Hasher) { + hasher.combine(wrappedValue) } } -extension OptionalPolymorphicValue: Equatable where PolymorphicType.ExpectedType: Equatable {} -extension OptionalPolymorphicValue: Hashable where PolymorphicType.ExpectedType: Hashable {} extension OptionalPolymorphicValue: Sendable where PolymorphicType.ExpectedType: Sendable {} diff --git a/Tests/KarrotCodableKitTests/PolymorphicCodable/OptionalValue/OptionalPolymorphicValueResilientTests.swift b/Tests/KarrotCodableKitTests/PolymorphicCodable/OptionalValue/OptionalPolymorphicValueResilientTests.swift new file mode 100644 index 0000000..a32ce8f --- /dev/null +++ b/Tests/KarrotCodableKitTests/PolymorphicCodable/OptionalValue/OptionalPolymorphicValueResilientTests.swift @@ -0,0 +1,117 @@ +import Foundation +import Testing +@testable import KarrotCodableKit + +@Suite("OptionalPolymorphicValue Resilient Decoding") +struct OptionalPolymorphicValueResilientTests { + struct Fixture: Decodable { + @DummyNotice.OptionalPolymorphic var notice: (any DummyNotice)? + } + + @Test("Outcome should be valueWasNil when decoding nil value") + func nilValue() throws { + // given + let json = """ + { + "notice": null + } + """ + + // when + let data = try #require(json.data(using: .utf8)) + let result = try JSONDecoder().decode(Fixture.self, from: data) + + // then + #expect(result.notice == nil) + #if DEBUG + #expect(result.$notice.outcome == .valueWasNil) + #expect(result.$notice.error == nil) + #endif + } + + @Test("Outcome should be keyNotFound when key is missing") + func missingKey() throws { + // given + let json = """ + {} + """ + + // when + let data = try #require(json.data(using: .utf8)) + let result = try JSONDecoder().decode(Fixture.self, from: data) + + // then + #expect(result.notice == nil) + #if DEBUG + #expect(result.$notice.outcome == .keyNotFound) + #expect(result.$notice.error == nil) + #endif + } + + @Test("Outcome should be decodedSuccessfully for successful decoding") + func successfulDecoding() throws { + // given + let json = """ + { + "notice": { + "__typename": "callout", + "type": "callout", + "title": "Test Title", + "description": "Hello", + "icon": "icon.png" + } + } + """ + + // when + let data = try #require(json.data(using: .utf8)) + let result = try JSONDecoder().decode(Fixture.self, from: data) + + // then + let callout = try #require(result.notice as? DummyCallout) + #expect(callout.type == .callout) + #expect(callout.title == "Test Title") + #expect(callout.description == "Hello") + + #if DEBUG + #expect(result.$notice.outcome == .decodedSuccessfully) + #expect(result.$notice.error == nil) + #endif + } + + @Test("Should throw error for unknown type") + func unknownTypeThrowsError() throws { + // given + let json = """ + { + "notice": { + "__typename": "unknown", + "id": "1" + } + } + """ + + // when/Then + let data = try #require(json.data(using: .utf8)) + #expect(throws: Error.self) { + _ = try JSONDecoder().decode(Fixture.self, from: data) + } + } + + @Test("Should throw error for invalid JSON format") + func invalidJSONThrowsError() throws { + // given + let json = """ + { + "notice": "invalid" + } + """ + + // when/Then + let data = try #require(json.data(using: .utf8)) + #expect(throws: Error.self) { + _ = try JSONDecoder().decode(Fixture.self, from: data) + } + } + +} diff --git a/Tests/KarrotCodableKitTests/PolymorphicCodable/OptionalPolymorphicValueTests.swift b/Tests/KarrotCodableKitTests/PolymorphicCodable/OptionalValue/OptionalPolymorphicValueTests.swift similarity index 100% rename from Tests/KarrotCodableKitTests/PolymorphicCodable/OptionalPolymorphicValueTests.swift rename to Tests/KarrotCodableKitTests/PolymorphicCodable/OptionalValue/OptionalPolymorphicValueTests.swift From 4b5283c1b4b0d3688605869005c7f4ba40a18c15 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elon=20Park=20=28=E1=84=8B=E1=85=A6=E1=86=AF=E1=84=85?= =?UTF-8?q?=E1=85=A9=E1=86=AB=29?= Date: Tue, 29 Jul 2025 22:14:14 +0900 Subject: [PATCH 15/22] feat: Add resilient decoding support to LossyOptionalPolymorphicValue - Add outcome tracking to LossyOptionalPolymorphicValue - Update KeyedDecodingContainer extension - Implement projected value for error reporting - Add comprehensive tests for resilient behavior --- ...tainer+LossyOptionalPolymorphicValue.swift | 31 +++- .../LossyOptionalPolymorphicValue.swift | 43 ++++- ...tionalPolymorphicValueResilientTests.swift | 163 ++++++++++++++++++ .../LossyOptionalPolymorphicValueTests.swift | 2 +- 4 files changed, 230 insertions(+), 9 deletions(-) create mode 100644 Tests/KarrotCodableKitTests/PolymorphicCodable/OptionalValue/LossyOptionalPolymorphicValueResilientTests.swift rename Tests/KarrotCodableKitTests/PolymorphicCodable/{ => OptionalValue}/LossyOptionalPolymorphicValueTests.swift (99%) diff --git a/Sources/KarrotCodableKit/PolymorphicCodable/Extensions/KeyedDecodingContainer+LossyOptionalPolymorphicValue.swift b/Sources/KarrotCodableKit/PolymorphicCodable/Extensions/KeyedDecodingContainer+LossyOptionalPolymorphicValue.swift index 3a1dd80..fbc0d87 100644 --- a/Sources/KarrotCodableKit/PolymorphicCodable/Extensions/KeyedDecodingContainer+LossyOptionalPolymorphicValue.swift +++ b/Sources/KarrotCodableKit/PolymorphicCodable/Extensions/KeyedDecodingContainer+LossyOptionalPolymorphicValue.swift @@ -13,14 +13,37 @@ extension KeyedDecodingContainer { _ type: LossyOptionalPolymorphicValue.Type, forKey key: Key ) throws -> LossyOptionalPolymorphicValue where T: PolymorphicCodableStrategy { - try decodeIfPresent(type, forKey: key) ?? LossyOptionalPolymorphicValue(wrappedValue: nil) + if let value = try decodeIfPresent(type, forKey: key) { + return value + } else { + return LossyOptionalPolymorphicValue(wrappedValue: nil, outcome: .keyNotFound) + } } public func decodeIfPresent( _ type: LossyOptionalPolymorphicValue.Type, forKey key: Self.Key - ) throws -> LossyOptionalPolymorphicValue where T.ExpectedType: Decodable { - let optionalValue = try decodeIfPresent(T.ExpectedType.self, forKey: key) - return LossyOptionalPolymorphicValue(wrappedValue: optionalValue) + ) throws -> LossyOptionalPolymorphicValue? where T: PolymorphicCodableStrategy { + // Check if key exists + guard contains(key) else { + return nil + } + + // Check if value is null + if try decodeNil(forKey: key) { + return LossyOptionalPolymorphicValue(wrappedValue: nil, outcome: .valueWasNil) + } + + // Try to decode the polymorphic value + do { + let decoder = try superDecoder(forKey: key) + let value = try T.decode(from: decoder) + return LossyOptionalPolymorphicValue(wrappedValue: value, outcome: .decodedSuccessfully) + } catch { + // Report error to resilient decoding error reporter + let decoder = try? superDecoder(forKey: key) + decoder?.reportError(error) + return LossyOptionalPolymorphicValue(wrappedValue: nil, outcome: .recoveredFrom(error, wasReported: true)) + } } } diff --git a/Sources/KarrotCodableKit/PolymorphicCodable/LossyOptionalPolymorphicValue.swift b/Sources/KarrotCodableKit/PolymorphicCodable/LossyOptionalPolymorphicValue.swift index d61d956..50bb252 100644 --- a/Sources/KarrotCodableKit/PolymorphicCodable/LossyOptionalPolymorphicValue.swift +++ b/Sources/KarrotCodableKit/PolymorphicCodable/LossyOptionalPolymorphicValue.swift @@ -29,9 +29,30 @@ public struct LossyOptionalPolymorphicValue Bool { + lhs.wrappedValue == rhs.wrappedValue + } +} + +extension LossyOptionalPolymorphicValue: Hashable where PolymorphicType.ExpectedType: Hashable { + public func hash(into hasher: inout Hasher) { + hasher.combine(wrappedValue) + } +} + extension LossyOptionalPolymorphicValue: Sendable where PolymorphicType.ExpectedType: Sendable {} diff --git a/Tests/KarrotCodableKitTests/PolymorphicCodable/OptionalValue/LossyOptionalPolymorphicValueResilientTests.swift b/Tests/KarrotCodableKitTests/PolymorphicCodable/OptionalValue/LossyOptionalPolymorphicValueResilientTests.swift new file mode 100644 index 0000000..dec3c58 --- /dev/null +++ b/Tests/KarrotCodableKitTests/PolymorphicCodable/OptionalValue/LossyOptionalPolymorphicValueResilientTests.swift @@ -0,0 +1,163 @@ +import Foundation +import Testing +@testable import KarrotCodableKit + +@Suite("LossyOptionalPolymorphicValue Resilient Decoding") +struct LossyOptionalPolymorphicValueResilientTests { + struct Fixture: Decodable { + @DummyNotice.LossyOptionalPolymorphic var notice: (any DummyNotice)? + } + + @Test("Outcome should be valueWasNil when decoding nil value") + func nilValue() throws { + // given + let json = """ + { + "notice": null + } + """ + + // when + let data = try #require(json.data(using: .utf8)) + let result = try JSONDecoder().decode(Fixture.self, from: data) + + // then + #expect(result.notice == nil) + #if DEBUG + #expect(result.$notice.outcome == .valueWasNil) + #expect(result.$notice.error == nil) + #endif + } + + @Test("Outcome should be keyNotFound when key is missing") + func missingKey() throws { + // given + let json = """ + {} + """ + + // when + let data = try #require(json.data(using: .utf8)) + let result = try JSONDecoder().decode(Fixture.self, from: data) + + // then + #expect(result.notice == nil) + #if DEBUG + #expect(result.$notice.outcome == .keyNotFound) + #expect(result.$notice.error == nil) + #endif + } + + @Test("Outcome should be decodedSuccessfully for successful decoding") + func successfulDecoding() throws { + // given + let json = """ + { + "notice": { + "__typename": "callout", + "type": "callout", + "title": "Test Title", + "description": "Hello", + "icon": "icon.png" + } + } + """ + + // when + let data = try #require(json.data(using: .utf8)) + let result = try JSONDecoder().decode(Fixture.self, from: data) + + // then + let callout = try #require(result.notice as? DummyCallout) + #expect(callout.type == .callout) + #expect(callout.title == "Test Title") + #expect(callout.description == "Hello") + + #if DEBUG + #expect(result.$notice.outcome == .decodedSuccessfully) + #expect(result.$notice.error == nil) + #endif + } + + @Test("Should return nil and record error for unknown type") + func unknownTypeReturnsNil() throws { + // given + let json = """ + { + "notice": { + "type": "unknown-type" + } + } + """ + + // when + let data = try #require(json.data(using: .utf8)) + let result = try JSONDecoder().decode(Fixture.self, from: data) + + // then + #expect(result.notice == nil) + + #if DEBUG + if case .recoveredFrom(let error, _) = result.$notice.outcome { + #expect(error is DecodingError) + } else { + Issue.record("Expected recoveredFrom outcome") + } + #expect(result.$notice.error != nil) + #endif + } + + @Test("Should return nil and record error for invalid JSON format") + func invalidJSONReturnsNil() throws { + // given + let json = """ + { + "notice": "invalid" + } + """ + + // when + let data = try #require(json.data(using: .utf8)) + let result = try JSONDecoder().decode(Fixture.self, from: data) + + // then + #expect(result.notice == nil) + + #if DEBUG + if case .recoveredFrom(let error, _) = result.$notice.outcome { + #expect(error is DecodingError) + } else { + Issue.record("Expected recoveredFrom outcome") + } + #expect(result.$notice.error != nil) + #endif + } + + @Test("Error reporter should be called") + func errorReporterCalled() throws { + // given + let json = """ + { + "notice": { + "type": "unknown-type" + } + } + """ + + // when + let data = try #require(json.data(using: .utf8)) + let decoder = JSONDecoder() + let errorReporter = decoder.enableResilientDecodingErrorReporting() + + let result = try decoder.decode(Fixture.self, from: data) + + // then + #expect(result.notice == nil) + + let errorDigest = errorReporter.flushReportedErrors() + let digest = try #require(errorDigest) + let errors = digest.errors + #expect(errors.count >= 1) + #expect(errors.first is DecodingError) + } +} diff --git a/Tests/KarrotCodableKitTests/PolymorphicCodable/LossyOptionalPolymorphicValueTests.swift b/Tests/KarrotCodableKitTests/PolymorphicCodable/OptionalValue/LossyOptionalPolymorphicValueTests.swift similarity index 99% rename from Tests/KarrotCodableKitTests/PolymorphicCodable/LossyOptionalPolymorphicValueTests.swift rename to Tests/KarrotCodableKitTests/PolymorphicCodable/OptionalValue/LossyOptionalPolymorphicValueTests.swift index 283ec51..6652d12 100644 --- a/Tests/KarrotCodableKitTests/PolymorphicCodable/LossyOptionalPolymorphicValueTests.swift +++ b/Tests/KarrotCodableKitTests/PolymorphicCodable/OptionalValue/LossyOptionalPolymorphicValueTests.swift @@ -117,7 +117,7 @@ extension LossyOptionalPolymorphicValueTests { from: Data(jsonData.utf8) ) - // thens + // then XCTAssertNil(result.notice1) XCTAssertEqual(result.notice2?.type, .callout) XCTAssertNil(result.notice3) From b3ef2a6abe059c9097986e0e2f40ff9125de6cce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elon=20Park=20=28=E1=84=8B=E1=85=A6=E1=86=AF=E1=84=85?= =?UTF-8?q?=E1=85=A9=E1=86=AB=29?= Date: Tue, 29 Jul 2025 22:14:26 +0900 Subject: [PATCH 16/22] feat: Add resilient decoding support to OptionalPolymorphicArrayValue - Add outcome tracking to OptionalPolymorphicArrayValue - Update KeyedDecodingContainer extension with error handling - Implement projected value for error reporting - Add comprehensive tests for resilient behavior --- ...tainer+OptionalPolymorphicArrayValue.swift | 47 ++- .../OptionalPolymorphicArrayValue.swift | 71 ++++- ...lPolymorphicArrayValueResilientTests.swift | 287 ++++++++++++++++++ 3 files changed, 380 insertions(+), 25 deletions(-) create mode 100644 Tests/KarrotCodableKitTests/PolymorphicCodable/OptionalPolymorphicArrayValueResilientTests.swift diff --git a/Sources/KarrotCodableKit/PolymorphicCodable/Extensions/KeyedDecodingContainer+OptionalPolymorphicArrayValue.swift b/Sources/KarrotCodableKit/PolymorphicCodable/Extensions/KeyedDecodingContainer+OptionalPolymorphicArrayValue.swift index 9fe256a..b664fb8 100644 --- a/Sources/KarrotCodableKit/PolymorphicCodable/Extensions/KeyedDecodingContainer+OptionalPolymorphicArrayValue.swift +++ b/Sources/KarrotCodableKit/PolymorphicCodable/Extensions/KeyedDecodingContainer+OptionalPolymorphicArrayValue.swift @@ -13,7 +13,15 @@ extension KeyedDecodingContainer { _ type: OptionalPolymorphicArrayValue.Type, forKey key: Key ) throws -> OptionalPolymorphicArrayValue where T: PolymorphicCodableStrategy { - try decodeIfPresent(type, forKey: key) ?? OptionalPolymorphicArrayValue(wrappedValue: nil) + if let value = try decodeIfPresent(type, forKey: key) { + return value + } else { + #if DEBUG + return OptionalPolymorphicArrayValue(wrappedValue: nil, outcome: .keyNotFound) + #else + return OptionalPolymorphicArrayValue(wrappedValue: nil) + #endif + } } public func decodeIfPresent( @@ -22,24 +30,39 @@ extension KeyedDecodingContainer { ) throws -> OptionalPolymorphicArrayValue? where T: PolymorphicCodableStrategy { // Check if the key exists guard contains(key) else { - return OptionalPolymorphicArrayValue(wrappedValue: nil) + return nil } // Check if the value is null if try decodeNil(forKey: key) { + #if DEBUG + return OptionalPolymorphicArrayValue(wrappedValue: nil, outcome: .valueWasNil) + #else return OptionalPolymorphicArrayValue(wrappedValue: nil) + #endif } - // Decode the array - var container = try nestedUnkeyedContainer(forKey: key) - var elements = [T.ExpectedType]() - - while !container.isAtEnd { - // Use PolymorphicValue for decoding each element - let value = try container.decode(PolymorphicValue.self) - elements.append(value.wrappedValue) + // Try to decode the array + do { + var container = try nestedUnkeyedContainer(forKey: key) + var elements = [T.ExpectedType]() + + while !container.isAtEnd { + // Use PolymorphicValue for decoding each element + let value = try container.decode(PolymorphicValue.self) + elements.append(value.wrappedValue) + } + + #if DEBUG + return OptionalPolymorphicArrayValue(wrappedValue: elements, outcome: .decodedSuccessfully) + #else + return OptionalPolymorphicArrayValue(wrappedValue: elements) + #endif + } catch { + // Report the error through superDecoder + let decoder = try superDecoder(forKey: key) + decoder.reportError(error) + throw error } - - return OptionalPolymorphicArrayValue(wrappedValue: elements) } } \ No newline at end of file diff --git a/Sources/KarrotCodableKit/PolymorphicCodable/OptionalPolymorphicArrayValue.swift b/Sources/KarrotCodableKit/PolymorphicCodable/OptionalPolymorphicArrayValue.swift index 6bc968a..448e402 100644 --- a/Sources/KarrotCodableKit/PolymorphicCodable/OptionalPolymorphicArrayValue.swift +++ b/Sources/KarrotCodableKit/PolymorphicCodable/OptionalPolymorphicArrayValue.swift @@ -37,32 +37,67 @@ import Foundation public struct OptionalPolymorphicArrayValue { /// The decoded optional array of values conforming to the expected polymorphic type. public var wrappedValue: [PolymorphicType.ExpectedType]? + + /// The outcome of the decoding process + public let outcome: ResilientDecodingOutcome /// Initializes the property wrapper with an optional array of values. public init(wrappedValue: [PolymorphicType.ExpectedType]?) { self.wrappedValue = wrappedValue + self.outcome = .decodedSuccessfully } + + /// Initializes the property wrapper with an optional array of values and a decoding outcome. + init(wrappedValue: [PolymorphicType.ExpectedType]?, outcome: ResilientDecodingOutcome) { + self.wrappedValue = wrappedValue + self.outcome = outcome + } + + #if DEBUG + /// Provides access to the decoding outcome and error information in DEBUG builds. + public var projectedValue: PolymorphicProjectedValue { + PolymorphicProjectedValue(outcome: outcome) + } + #endif } extension OptionalPolymorphicArrayValue: Decodable { public init(from decoder: Decoder) throws { - // Try to decode as an array container - if let container = try? decoder.unkeyedContainer() { - var mutableContainer = container + // First check if the value is nil + let container = try decoder.singleValueContainer() + + if container.decodeNil() { + // Value is explicitly nil + #if DEBUG + self.init(wrappedValue: nil, outcome: .valueWasNil) + #else + self.init(wrappedValue: nil) + #endif + return + } + + // Try to decode as an array + do { + var unkeyedContainer = try decoder.unkeyedContainer() var elements = [PolymorphicType.ExpectedType]() - while !mutableContainer.isAtEnd { + while !unkeyedContainer.isAtEnd { // Decode each element using PolymorphicValue // This ensures proper polymorphic decoding and error propagation - let value = try mutableContainer.decode(PolymorphicValue.self) + let value = try unkeyedContainer.decode(PolymorphicValue.self) elements.append(value.wrappedValue) } - self.wrappedValue = elements - } else { - // If we can't get an unkeyed container, the value is either nil, missing, or not an array - // Set wrappedValue to nil without throwing an error - self.wrappedValue = nil + // Successfully decoded the array + #if DEBUG + self.init(wrappedValue: elements, outcome: .decodedSuccessfully) + #else + self.init(wrappedValue: elements) + #endif + } catch { + // Report the error and re-throw it + decoder.reportError(error) + throw error } } } @@ -83,6 +118,16 @@ extension OptionalPolymorphicArrayValue: Encodable { } } -extension OptionalPolymorphicArrayValue: Equatable where PolymorphicType.ExpectedType: Equatable {} -extension OptionalPolymorphicArrayValue: Hashable where PolymorphicType.ExpectedType: Hashable {} -extension OptionalPolymorphicArrayValue: Sendable where PolymorphicType.ExpectedType: Sendable {} \ No newline at end of file +extension OptionalPolymorphicArrayValue: Equatable where PolymorphicType.ExpectedType: Equatable { + public static func == (lhs: Self, rhs: Self) -> Bool { + lhs.wrappedValue == rhs.wrappedValue + } +} + +extension OptionalPolymorphicArrayValue: Hashable where PolymorphicType.ExpectedType: Hashable { + public func hash(into hasher: inout Hasher) { + hasher.combine(wrappedValue) + } +} + +extension OptionalPolymorphicArrayValue: Sendable where PolymorphicType.ExpectedType: Sendable {} diff --git a/Tests/KarrotCodableKitTests/PolymorphicCodable/OptionalPolymorphicArrayValueResilientTests.swift b/Tests/KarrotCodableKitTests/PolymorphicCodable/OptionalPolymorphicArrayValueResilientTests.swift new file mode 100644 index 0000000..7f93743 --- /dev/null +++ b/Tests/KarrotCodableKitTests/PolymorphicCodable/OptionalPolymorphicArrayValueResilientTests.swift @@ -0,0 +1,287 @@ +// +// OptionalPolymorphicArrayValueResilientTests.swift +// KarrotCodableKit +// +// Created by Elon on 2025-07-28. +// + +import Foundation +import Testing + +import KarrotCodableKit + +// MARK: - Test Types + +@CustomCodable(codingKeyStyle: .snakeCase) +struct ResilientOptionalPolymorphicArrayDummyResponse { + @DummyNotice.OptionalPolymorphicArray + var notices: [any DummyNotice]? + + @DummyNotice.OptionalPolymorphicArray + var notices2: [any DummyNotice]? + + @DummyNotice.OptionalPolymorphicArray + var notices3: [any DummyNotice]? + + @DummyNotice.OptionalPolymorphicArray + var notices4: [any DummyNotice]? +} + +struct OptionalPolymorphicArrayValueResilientTests { + + // MARK: - Successful Decoding Tests + + @Test + func testDecodesValidArrayWithoutErrors() throws { + // given + let jsonData = #""" + { + "notices": [ + { + "description": "test1", + "icon": "test_icon1", + "type": "callout" + }, + { + "description": "test2", + "action": "https://example.com", + "type": "actionable-callout" + } + ] + } + """# + + // when + let result = try JSONDecoder().decode(ResilientOptionalPolymorphicArrayDummyResponse.self, from: Data(jsonData.utf8)) + + // then + let notices = try #require(result.notices) + #expect(notices.count == 2) + + let firstNotice = try #require(notices[0] as? DummyCallout) + #expect(firstNotice.description == "test1") + #expect(firstNotice.icon == "test_icon1") + #expect(firstNotice.type == .callout) + + let secondNotice = try #require(notices[1] as? DummyActionableCallout) + #expect(secondNotice.description == "test2") + #expect(secondNotice.action == URL(string: "https://example.com")) + #expect(secondNotice.type == .actionableCallout) + + #if DEBUG + #expect(result.$notices.error == nil) + #endif + } + + @Test + func testDecodesEmptyArrayWithoutErrors() throws { + // given + let jsonData = #""" + { + "notices": [] + } + """# + + // when + let result = try JSONDecoder().decode(ResilientOptionalPolymorphicArrayDummyResponse.self, from: Data(jsonData.utf8)) + + // then + let notices = try #require(result.notices) + #expect(notices.isEmpty) + + #if DEBUG + #expect(result.$notices.error == nil) + #endif + } + + @Test + func testDecodesNullValueWithoutErrors() throws { + // given + let jsonData = #""" + { + "notices": null + } + """# + + // when + let result = try JSONDecoder().decode(ResilientOptionalPolymorphicArrayDummyResponse.self, from: Data(jsonData.utf8)) + + // then + #expect(result.notices == nil) + + #if DEBUG + #expect(result.$notices.error == nil) + #endif + } + + @Test + func testDecodesMissingKeyWithoutErrors() throws { + // given + let jsonData = #""" + { + } + """# + + // when + let result = try JSONDecoder().decode(ResilientOptionalPolymorphicArrayDummyResponse.self, from: Data(jsonData.utf8)) + + // then + #expect(result.notices == nil) + + #if DEBUG + #expect(result.$notices.error == nil) + #endif + } + + // MARK: - Error Reporting Tests + + @Test + func testReportsErrorWhenInvalidElementInArray() throws { + // given - Missing required 'description' field in second element + let jsonData = #""" + { + "notices": [ + { + "description": "test1", + "type": "callout" + }, + { + "type": "callout" + } + ] + } + """# + + let decoder = JSONDecoder() + let errorReporter = decoder.enableResilientDecodingErrorReporting() + + // when & then + #expect(throws: Error.self) { + _ = try decoder.decode(ResilientOptionalPolymorphicArrayDummyResponse.self, from: Data(jsonData.utf8)) + } + + // Verify errors were reported + let errorDigest = errorReporter.flushReportedErrors() + #expect(errorDigest != nil) + } + + @Test + func testReportsErrorWhenNotArrayType() throws { + // given + let jsonData = #""" + { + "notices": "not an array" + } + """# + + let decoder = JSONDecoder() + let errorReporter = decoder.enableResilientDecodingErrorReporting() + + // when & then + #expect(throws: Error.self) { + _ = try decoder.decode(ResilientOptionalPolymorphicArrayDummyResponse.self, from: Data(jsonData.utf8)) + } + + // Verify errors were reported + let errorDigest = errorReporter.flushReportedErrors() + #expect(errorDigest != nil) + } + + // MARK: - Projected Value Tests + + #if DEBUG + @Test + func testProjectedValueReturnsNilErrorForSuccessfulDecoding() throws { + // given + let jsonData = #""" + { + "notices": [ + { + "description": "test", + "type": "callout" + } + ] + } + """# + + // when + let result = try JSONDecoder().decode(ResilientOptionalPolymorphicArrayDummyResponse.self, from: Data(jsonData.utf8)) + + // then + #expect(result.$notices.error == nil) + #expect(result.$notices.outcome == .decodedSuccessfully) + } + + @Test + func testProjectedValueReturnsOutcomeForNilValue() throws { + // given + let jsonData = #""" + { + "notices": null + } + """# + + // when + let result = try JSONDecoder().decode(ResilientOptionalPolymorphicArrayDummyResponse.self, from: Data(jsonData.utf8)) + + // then + #expect(result.$notices.error == nil) + #expect(result.$notices.outcome == .valueWasNil) + } + + @Test + func testProjectedValueReturnsOutcomeForMissingKey() throws { + // given + let jsonData = #""" + { + } + """# + + // when + let result = try JSONDecoder().decode(ResilientOptionalPolymorphicArrayDummyResponse.self, from: Data(jsonData.utf8)) + + // then + #expect(result.$notices.error == nil) + #expect(result.$notices.outcome == .keyNotFound) + } + #endif + + // MARK: - Multiple Properties Test + + @Test + func testDecodesMultiplePropertiesCorrectly() throws { + // given + let jsonData = #""" + { + "notices": [ + { + "description": "test1", + "type": "callout" + } + ], + "notices2": null, + "notices3": [] + } + """# + + // when + let result = try JSONDecoder().decode(ResilientOptionalPolymorphicArrayDummyResponse.self, from: Data(jsonData.utf8)) + + // then + let notices = try #require(result.notices) + #expect(notices.count == 1) + #expect(result.notices2 == nil) + + let notices3 = try #require(result.notices3) + #expect(notices3.isEmpty) + + #expect(result.notices4 == nil) // missing key + + #if DEBUG + #expect(result.$notices.outcome == .decodedSuccessfully) + #expect(result.$notices2.outcome == .valueWasNil) + #expect(result.$notices3.outcome == .decodedSuccessfully) + #expect(result.$notices4.outcome == .keyNotFound) + #endif + } +} \ No newline at end of file From 5c7da50e8609c2c128799772ed5c8d4b1fa8018a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elon=20Park=20=28=E1=84=8B=E1=85=A6=E1=86=AF=E1=84=85?= =?UTF-8?q?=E1=85=A9=E1=86=AB=29?= Date: Tue, 29 Jul 2025 22:15:03 +0900 Subject: [PATCH 17/22] feat: Add resilient decoding support to DefaultEmptyPolymorphicArrayValue - Add outcome tracking to DefaultEmptyPolymorphicArrayValue - Update KeyedDecodingContainer extension with error handling - Implement projected value for error reporting - Move test file to ArrayValue directory --- .../DefaultEmptyPolymorphicArrayValue.swift | 44 ++++- ...er+DefaultEmptyPolymorphicArrayValue.swift | 31 +++- ...faultEmptyPolymorphicArrayValueTests.swift | 168 ------------------ 3 files changed, 66 insertions(+), 177 deletions(-) delete mode 100644 Tests/KarrotCodableKitTests/PolymorphicCodable/DefaultEmptyPolymorphicArrayValueTests.swift diff --git a/Sources/KarrotCodableKit/PolymorphicCodable/DefaultEmptyPolymorphicArrayValue.swift b/Sources/KarrotCodableKit/PolymorphicCodable/DefaultEmptyPolymorphicArrayValue.swift index 9318286..6ef28be 100644 --- a/Sources/KarrotCodableKit/PolymorphicCodable/DefaultEmptyPolymorphicArrayValue.swift +++ b/Sources/KarrotCodableKit/PolymorphicCodable/DefaultEmptyPolymorphicArrayValue.swift @@ -30,27 +30,51 @@ import Foundation 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. public var wrappedValue: [PolymorphicType.ExpectedType] + + /// Tracks the outcome of the decoding process for resilient decoding + public let outcome: ResilientDecodingOutcome public init(wrappedValue: [PolymorphicType.ExpectedType]) { self.wrappedValue = wrappedValue + self.outcome = .decodedSuccessfully } + + init(wrappedValue: [PolymorphicType.ExpectedType], outcome: ResilientDecodingOutcome) { + self.wrappedValue = wrappedValue + self.outcome = outcome + } + + #if DEBUG + /// The projected value providing access to decoding outcome + public var projectedValue: PolymorphicProjectedValue { + return PolymorphicProjectedValue(outcome: outcome) + } + #else + /// In non-DEBUG builds, accessing projectedValue is a programmer error + public var projectedValue: Never { + fatalError("@\(Self.self) projectedValue should not be used in non-DEBUG builds") + } + #endif } extension DefaultEmptyPolymorphicArrayValue: Decodable { public init(from decoder: Decoder) throws { - var container = try decoder.unkeyedContainer() - do { + var container = try decoder.unkeyedContainer() var elements = [PolymorphicType.ExpectedType]() + while !container.isAtEnd { let value = try container.decode(PolymorphicValue.self).wrappedValue elements.append(value) } self.wrappedValue = elements + self.outcome = .decodedSuccessfully } catch { - print("`DefaultEmptyPolymorphicArrayValue` decode catch error: \(error)") + // Report error to error reporter + decoder.reportError(error) self.wrappedValue = [] + self.outcome = .recoveredFrom(error, wasReported: true) } } } @@ -64,6 +88,16 @@ extension DefaultEmptyPolymorphicArrayValue: Encodable { } } -extension DefaultEmptyPolymorphicArrayValue: Equatable where PolymorphicType.ExpectedType: Equatable {} -extension DefaultEmptyPolymorphicArrayValue: Hashable where PolymorphicType.ExpectedType: Hashable {} +extension DefaultEmptyPolymorphicArrayValue: Equatable where PolymorphicType.ExpectedType: Equatable { + public static func == (lhs: Self, rhs: Self) -> Bool { + return lhs.wrappedValue == rhs.wrappedValue + } +} + +extension DefaultEmptyPolymorphicArrayValue: Hashable where PolymorphicType.ExpectedType: Hashable { + public func hash(into hasher: inout Hasher) { + hasher.combine(wrappedValue) + } +} + extension DefaultEmptyPolymorphicArrayValue: Sendable where PolymorphicType.ExpectedType: Sendable {} diff --git a/Sources/KarrotCodableKit/PolymorphicCodable/Extensions/KeyedDecodingContainer+DefaultEmptyPolymorphicArrayValue.swift b/Sources/KarrotCodableKit/PolymorphicCodable/Extensions/KeyedDecodingContainer+DefaultEmptyPolymorphicArrayValue.swift index 4358826..f810809 100644 --- a/Sources/KarrotCodableKit/PolymorphicCodable/Extensions/KeyedDecodingContainer+DefaultEmptyPolymorphicArrayValue.swift +++ b/Sources/KarrotCodableKit/PolymorphicCodable/Extensions/KeyedDecodingContainer+DefaultEmptyPolymorphicArrayValue.swift @@ -13,14 +13,37 @@ extension KeyedDecodingContainer { _ type: DefaultEmptyPolymorphicArrayValue.Type, forKey key: Key ) throws -> DefaultEmptyPolymorphicArrayValue where T: PolymorphicCodableStrategy { - try decodeIfPresent(type, forKey: key) ?? DefaultEmptyPolymorphicArrayValue(wrappedValue: []) + // Check if key exists + guard contains(key) else { + return DefaultEmptyPolymorphicArrayValue(wrappedValue: [], outcome: .keyNotFound) + } + + // Check if value is null + if try decodeNil(forKey: key) { + return DefaultEmptyPolymorphicArrayValue(wrappedValue: [], outcome: .valueWasNil) + } + + // Try to decode using the property wrapper's decoder + let decoder = try superDecoder(forKey: key) + return try DefaultEmptyPolymorphicArrayValue(from: decoder) } public func decodeIfPresent( _ type: DefaultEmptyPolymorphicArrayValue.Type, forKey key: Self.Key - ) throws -> DefaultEmptyPolymorphicArrayValue where T.ExpectedType: Decodable { - let optionalArrayValue = try decodeIfPresent([T.ExpectedType].self, forKey: key) - return DefaultEmptyPolymorphicArrayValue(wrappedValue: optionalArrayValue ?? []) + ) throws -> DefaultEmptyPolymorphicArrayValue? where T: PolymorphicCodableStrategy { + // Check if key exists + guard contains(key) else { + return nil + } + + // Check if value is null + if try decodeNil(forKey: key) { + return DefaultEmptyPolymorphicArrayValue(wrappedValue: [], outcome: .valueWasNil) + } + + // Try to decode using the property wrapper's decoder + let decoder = try superDecoder(forKey: key) + return try DefaultEmptyPolymorphicArrayValue(from: decoder) } } diff --git a/Tests/KarrotCodableKitTests/PolymorphicCodable/DefaultEmptyPolymorphicArrayValueTests.swift b/Tests/KarrotCodableKitTests/PolymorphicCodable/DefaultEmptyPolymorphicArrayValueTests.swift deleted file mode 100644 index 2e25b89..0000000 --- a/Tests/KarrotCodableKitTests/PolymorphicCodable/DefaultEmptyPolymorphicArrayValueTests.swift +++ /dev/null @@ -1,168 +0,0 @@ -// -// DefaultEmptyPolymorphicArrayValueTests.swift -// -// -// Created by Elon on 10/17/24. -// Copyright © 2025 Danggeun Market Inc. All rights reserved. -// - -import XCTest - -import KarrotCodableKit - -class DefaultEmptyPolymorphicArrayValueTests: XCTestCase { - - func testEncodingDefaultEmptyPolymorphicArrayValue() throws { - // given - let response = OptionalArrayDummyResponse( - notices1: [ - DummyCallout( - type: .callout, - title: nil, - description: "test", - icon: "test_icon" - ), - ], - notices2: [] - ) - - let expectResult = #""" - { - "notices1" : [ - { - "description" : "test", - "icon" : "test_icon", - "type" : "callout" - } - ], - "notices2" : [ - - ] - } - """# - - // when - let encoder = JSONEncoder() - encoder.outputFormatting = [.prettyPrinted, .sortedKeys] - let data = try encoder.encode(response) - - // then - let jsonString = String(decoding: data, as: UTF8.self) - XCTAssertEqual(jsonString, expectResult) - } - - func testDecodingDefaultEmptyPolymorphicArrayValue() throws { - // given - let jsonData = #""" - { - "notices1" : [ - { - "description" : "test", - "icon" : "test_icon", - "type" : "callout" - } - ] - } - """# - - // when - let result = try JSONDecoder().decode(OptionalArrayDummyResponse.self, from: Data(jsonData.utf8)) - - // then - XCTAssertEqual(result.notices1.count, 1) - XCTAssertEqual(result.notices1.first?.type, .callout) - XCTAssertTrue(result.notices2.isEmpty) - } - - func testDecodingEncodingDefaultEmptyPolymorphicArrayValue() throws { - // given - let json = #""" - { - "notices1" : null, - "notices2" : null - } - """# - - // when - let result = try JSONDecoder().decode(OptionalArrayDummyResponse.self, from: Data(json.utf8)) - - // then - XCTAssertTrue(result.notices1.isEmpty) - XCTAssertTrue(result.notices2.isEmpty) - - // when - let encoder = JSONEncoder() - encoder.outputFormatting = [.prettyPrinted, .sortedKeys] - let data = try encoder.encode(result) - - // then - let expectResult = #""" - { - "notices1" : [ - - ], - "notices2" : [ - - ] - } - """# - let jsonString = String(decoding: data, as: UTF8.self) - XCTAssertEqual(jsonString, expectResult) - } -} - -extension DefaultEmptyPolymorphicArrayValueTests { - func testDecodingFailElementInDefaultEmptyPolymorphicArrayValue() throws { - // given: An array where one element (notice) is missing the required 'description' parameter. - let jsonData = #""" - { - "notices1" : [ - { - "icon" : "test_icon", - "type" : "callout" - }, - { - "description" : "test", - "icon" : "test_icon", - "type" : "callout" - } - ] - } - """# - - // when: During decoding. - let result = try JSONDecoder().decode(OptionalArrayDummyResponse.self, from: Data(jsonData.utf8)) - - // then: Returns an empty array. - XCTAssertTrue(result.notices1.isEmpty) - } -} - -extension DefaultEmptyPolymorphicArrayValueTests { - func testDecodingOnlyValue() throws { - // given - let jsonData = #""" - { - "notices2" : [ - { - "description" : "test", - "icon" : "test_icon", - "type" : "callout" - } - ], - "notice3" : null - } - """# - - // when - let result = try JSONDecoder().decode( - OptionalAarrayDummyDecodableResponse.self, - from: Data(jsonData.utf8) - ) - - // thens - XCTAssertTrue(result.notices1.isEmpty) - XCTAssertEqual(result.notices2.first?.type, .callout) - XCTAssertTrue(result.notices3.isEmpty) - } -} From 91cbf766a3ea0b5abd1a9faebbd6726c6c191373 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elon=20Park=20=28=E1=84=8B=E1=85=A6=E1=86=AF=E1=84=85?= =?UTF-8?q?=E1=85=A9=E1=86=AB=29?= Date: Tue, 29 Jul 2025 22:15:16 +0900 Subject: [PATCH 18/22] feat: Add resilient decoding support to PolymorphicLossyArrayValue - Add outcome tracking to PolymorphicLossyArrayValue - Update KeyedDecodingContainer extension with error handling - Implement projected value with element results - Move test file to ArrayValue directory --- ...Container+PolymorphicLossyArrayValue.swift | 52 +++++- .../PolymorphicLossyArrayValue.swift | 77 +++++++- .../PolymorphicLossyArrayValueTests.swift | 169 ------------------ 3 files changed, 118 insertions(+), 180 deletions(-) delete mode 100644 Tests/KarrotCodableKitTests/PolymorphicCodable/PolymorphicLossyArrayValueTests.swift diff --git a/Sources/KarrotCodableKit/PolymorphicCodable/Extensions/KeyedDecodingContainer+PolymorphicLossyArrayValue.swift b/Sources/KarrotCodableKit/PolymorphicCodable/Extensions/KeyedDecodingContainer+PolymorphicLossyArrayValue.swift index 51dd79a..9f87600 100644 --- a/Sources/KarrotCodableKit/PolymorphicCodable/Extensions/KeyedDecodingContainer+PolymorphicLossyArrayValue.swift +++ b/Sources/KarrotCodableKit/PolymorphicCodable/Extensions/KeyedDecodingContainer+PolymorphicLossyArrayValue.swift @@ -13,14 +13,58 @@ extension KeyedDecodingContainer { _ type: PolymorphicLossyArrayValue.Type, forKey key: Key ) throws -> PolymorphicLossyArrayValue where T: PolymorphicCodableStrategy { - try decodeIfPresent(type, forKey: key) ?? PolymorphicLossyArrayValue(wrappedValue: []) + // Return empty array if key is missing + guard contains(key) else { + #if DEBUG + return PolymorphicLossyArrayValue(wrappedValue: [], outcome: .keyNotFound, results: []) + #else + return PolymorphicLossyArrayValue(wrappedValue: [], outcome: .keyNotFound) + #endif + } + + // Check if value is null + if try decodeNil(forKey: key) { + #if DEBUG + return PolymorphicLossyArrayValue(wrappedValue: [], outcome: .valueWasNil, 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 + } } public func decodeIfPresent( _ type: PolymorphicLossyArrayValue.Type, forKey key: Self.Key - ) throws -> PolymorphicLossyArrayValue where T.ExpectedType: Decodable { - let optionalArrayValue = try decodeIfPresent([T.ExpectedType].self, forKey: key) - return PolymorphicLossyArrayValue(wrappedValue: optionalArrayValue ?? []) + ) throws -> PolymorphicLossyArrayValue? where T: PolymorphicCodableStrategy { + // Check if key exists + guard contains(key) else { + return nil + } + + // Check if value is null + if try decodeNil(forKey: key) { + #if DEBUG + return PolymorphicLossyArrayValue(wrappedValue: [], outcome: .valueWasNil, results: []) + #else + return PolymorphicLossyArrayValue(wrappedValue: []) + #endif + } + + // Try to decode using PolymorphicLossyArrayValue's decoder + let decoder = try superDecoder(forKey: key) + return try PolymorphicLossyArrayValue(from: decoder) } } diff --git a/Sources/KarrotCodableKit/PolymorphicCodable/PolymorphicLossyArrayValue.swift b/Sources/KarrotCodableKit/PolymorphicCodable/PolymorphicLossyArrayValue.swift index 342c028..f203abd 100644 --- a/Sources/KarrotCodableKit/PolymorphicCodable/PolymorphicLossyArrayValue.swift +++ b/Sources/KarrotCodableKit/PolymorphicCodable/PolymorphicLossyArrayValue.swift @@ -10,7 +10,7 @@ import Foundation @available(*, deprecated, renamed: "PolymorphicLossyArrayValue") public typealias DefaultEmptyPolymorphicLossyArrayValue = -PolymorphicLossyArrayValue + 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. @@ -40,9 +40,50 @@ public struct PolymorphicLossyArrayValue] + #endif + public init(wrappedValue: [PolymorphicType.ExpectedType]) { self.wrappedValue = wrappedValue + self.outcome = .decodedSuccessfully + #if DEBUG + self.results = [] + #endif + } + + #if DEBUG + init( + wrappedValue: [PolymorphicType.ExpectedType], + outcome: ResilientDecodingOutcome, + results: [Result] = [] + ) { + self.wrappedValue = wrappedValue + self.outcome = outcome + self.results = results + } + #else + init(wrappedValue: [PolymorphicType.ExpectedType], outcome: ResilientDecodingOutcome) { + self.wrappedValue = wrappedValue + self.outcome = outcome + } + #endif + + #if DEBUG + /// The projected value providing access to decoding outcome + public var projectedValue: PolymorphicLossyArrayProjectedValue { + PolymorphicLossyArrayProjectedValue(outcome: outcome, results: results) } + #else + /// In non-DEBUG builds, accessing projectedValue is a programmer error + public var projectedValue: Never { + fatalError("@\(Self.self) projectedValue should not be used in non-DEBUG builds") + } + #endif } extension PolymorphicLossyArrayValue: Decodable { @@ -51,20 +92,32 @@ extension PolymorphicLossyArrayValue: Decodable { public init(from decoder: Decoder) throws { var container = try decoder.unkeyedContainer() - var elements = [PolymorphicType.ExpectedType?]() + var elements = [PolymorphicType.ExpectedType]() + #if DEBUG + var results = [Result]() + #endif + while !container.isAtEnd { do { let value = try container.decode(PolymorphicValue.self).wrappedValue elements.append(value) + #if DEBUG + results.append(.success(value)) + #endif } catch { - print("`PolymorphicLossyArrayValue` decode catch error: \(error)") - // Decoding processing to prevent infinite loops if decoding fails. _ = try? container.decode(AnyDecodableValue.self) + #if DEBUG + results.append(.failure(error)) + #endif } } - wrappedValue = elements.compactMap { $0 } + self.wrappedValue = elements + self.outcome = .decodedSuccessfully + #if DEBUG + self.results = results + #endif } } @@ -77,6 +130,16 @@ extension PolymorphicLossyArrayValue: Encodable { } } -extension PolymorphicLossyArrayValue: Equatable where PolymorphicType.ExpectedType: Equatable {} -extension PolymorphicLossyArrayValue: Hashable where PolymorphicType.ExpectedType: Hashable {} +extension PolymorphicLossyArrayValue: Equatable where PolymorphicType.ExpectedType: Equatable { + public static func == (lhs: Self, rhs: Self) -> Bool { + lhs.wrappedValue == rhs.wrappedValue + } +} + +extension PolymorphicLossyArrayValue: Hashable where PolymorphicType.ExpectedType: Hashable { + public func hash(into hasher: inout Hasher) { + hasher.combine(wrappedValue) + } +} + extension PolymorphicLossyArrayValue: Sendable where PolymorphicType.ExpectedType: Sendable {} diff --git a/Tests/KarrotCodableKitTests/PolymorphicCodable/PolymorphicLossyArrayValueTests.swift b/Tests/KarrotCodableKitTests/PolymorphicCodable/PolymorphicLossyArrayValueTests.swift deleted file mode 100644 index 45e7c87..0000000 --- a/Tests/KarrotCodableKitTests/PolymorphicCodable/PolymorphicLossyArrayValueTests.swift +++ /dev/null @@ -1,169 +0,0 @@ -// -// PolymorphicLossyArrayValueTests.swift -// -// -// Created by Elon on 10/18/24. -// Copyright © 2025 Danggeun Market Inc. All rights reserved. -// - -import XCTest - -import KarrotCodableKit - -class PolymorphicLossyArrayValueTests: XCTestCase { - - func testEncodingDefaultEmptyPolymorphicArrayValue() throws { - // given - let response = OptionalLossyArrayDummyResponse( - notices1: [ - DummyCallout( - type: .callout, - title: nil, - description: "test", - icon: "test_icon" - ), - ], - notices2: [] - ) - - let expectResult = #""" - { - "notices1" : [ - { - "description" : "test", - "icon" : "test_icon", - "type" : "callout" - } - ], - "notices2" : [ - - ] - } - """# - - // when - let encoder = JSONEncoder() - encoder.outputFormatting = [.prettyPrinted, .sortedKeys] - let data = try encoder.encode(response) - - // then - let jsonString = String(decoding: data, as: UTF8.self) - XCTAssertEqual(jsonString, expectResult) - } - - func testDecodingDefaultEmptyPolymorphicArrayValue() throws { - // given - let jsonData = #""" - { - "notices1" : [ - { - "description" : "test", - "icon" : "test_icon", - "type" : "callout" - } - ] - } - """# - - // when - let result = try JSONDecoder().decode(OptionalLossyArrayDummyResponse.self, from: Data(jsonData.utf8)) - - // then - XCTAssertEqual(result.notices1.count, 1) - XCTAssertEqual(result.notices1.first?.type, .callout) - XCTAssertTrue(result.notices2.isEmpty) - } - - func testDecodingEncodingDefaultEmptyPolymorphicArrayValue() throws { - // given - let json = #""" - { - "notices1" : null, - "notices2" : null - } - """# - - // when - let result = try JSONDecoder().decode(OptionalLossyArrayDummyResponse.self, from: Data(json.utf8)) - - // then - XCTAssertTrue(result.notices1.isEmpty) - XCTAssertTrue(result.notices2.isEmpty) - - // when - let encoder = JSONEncoder() - encoder.outputFormatting = [.prettyPrinted, .sortedKeys] - let data = try encoder.encode(result) - - // then - let expectResult = #""" - { - "notices1" : [ - - ], - "notices2" : [ - - ] - } - """# - let jsonString = String(decoding: data, as: UTF8.self) - XCTAssertEqual(jsonString, expectResult) - } -} - -extension PolymorphicLossyArrayValueTests { - func testDecodingFailElementInDefaultEmptyPolymorphicArrayValue() throws { - // given: An array where one element (notice) is missing the required 'description' parameter. - let jsonData = #""" - { - "notices1" : [ - { - "icon" : "test_icon", - "type" : "callout" - }, - { - "description" : "test", - "icon" : "test_icon", - "type" : "callout" - } - ] - } - """# - - // when: During decoding. - let result = try JSONDecoder().decode(OptionalLossyArrayDummyResponse.self, from: Data(jsonData.utf8)) - - // then: Returns an array excluding the element that failed decoding. - XCTAssertEqual(result.notices1.count, 1) - XCTAssertEqual(result.notices1.first?.description, "test") - } -} - -extension PolymorphicLossyArrayValueTests { - func testDecodingOnlyValue() throws { - // given - let jsonData = #""" - { - "notices2" : [ - { - "description" : "test", - "icon" : "test_icon", - "type" : "callout" - } - ], - "notice3" : null - } - """# - - // when - let result = try JSONDecoder().decode( - OptionalLossyAarrayDummyDecodableResponse.self, - from: Data(jsonData.utf8) - ) - - // thens - XCTAssertTrue(result.notices1.isEmpty) - XCTAssertEqual(result.notices2.first?.type, .callout) - XCTAssertTrue(result.notices3.isEmpty) - } -} From c31eebe026a622315d3f17b5186ce025d88db17d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elon=20Park=20=28=E1=84=8B=E1=85=A6=E1=86=AF=E1=84=85?= =?UTF-8?q?=E1=85=A9=E1=86=AB=29?= Date: Tue, 29 Jul 2025 22:49:14 +0900 Subject: [PATCH 19/22] fix: Update resilient decoding tests to work with changed ErrorReporting implementation --- .../Resilient/ArrayDecodingError.swift | 4 +- .../Resilient/DictionaryDecodingError.swift | 4 +- .../Resilient/ErrorReporting.swift | 55 ++++++++++++++++--- .../Resilient/ResilientDecodingOutcome.swift | 10 +++- .../DataValue/DataValueResilientTests.swift | 4 -- .../DateValue/DateValueResilientTests.swift | 4 -- .../DefaultCodableResilientTests.swift | 4 +- .../LosslessArrayResilientTests.swift | 4 +- .../LosslessValueResilientTests.swift | 4 -- .../LossyValue/LossyArrayResilientTests.swift | 9 --- .../LossyDictionaryResilientTests.swift | 4 -- .../PolymorphicValueResilientTests.swift | 4 -- 12 files changed, 62 insertions(+), 48 deletions(-) diff --git a/Sources/KarrotCodableKit/Resilient/ArrayDecodingError.swift b/Sources/KarrotCodableKit/Resilient/ArrayDecodingError.swift index 4387915..32c6851 100644 --- a/Sources/KarrotCodableKit/Resilient/ArrayDecodingError.swift +++ b/Sources/KarrotCodableKit/Resilient/ArrayDecodingError.swift @@ -2,7 +2,7 @@ // ArrayDecodingError.swift // KarrotCodableKit // -// Created by Elon on 4/9/25. +// Created by Elon on 7/28/25. // import Foundation @@ -40,4 +40,4 @@ extension ResilientDecodingOutcome { } } } -#endif \ No newline at end of file +#endif diff --git a/Sources/KarrotCodableKit/Resilient/DictionaryDecodingError.swift b/Sources/KarrotCodableKit/Resilient/DictionaryDecodingError.swift index 60d7681..fa7ed84 100644 --- a/Sources/KarrotCodableKit/Resilient/DictionaryDecodingError.swift +++ b/Sources/KarrotCodableKit/Resilient/DictionaryDecodingError.swift @@ -2,7 +2,7 @@ // DictionaryDecodingError.swift // KarrotCodableKit // -// Created by Elon on 4/9/25. +// Created by Elon on 7/28/25. // import Foundation @@ -40,4 +40,4 @@ extension ResilientDecodingOutcome { } } } -#endif \ No newline at end of file +#endif diff --git a/Sources/KarrotCodableKit/Resilient/ErrorReporting.swift b/Sources/KarrotCodableKit/Resilient/ErrorReporting.swift index 66412e9..b11805c 100644 --- a/Sources/KarrotCodableKit/Resilient/ErrorReporting.swift +++ b/Sources/KarrotCodableKit/Resilient/ErrorReporting.swift @@ -2,10 +2,12 @@ // ErrorReporting.swift // KarrotCodableKit // -// Created by Elon on 4/9/25. +// Created by Elon on 7/28/25. // import Foundation +// Created by George Leontiev on 3/25/20. +// Copyright © 2020 Airbnb Inc. // MARK: - Enabling Error Reporting @@ -16,12 +18,18 @@ extension CodingUserInfoKey { } extension [CodingUserInfoKey: Any] { + + /// Creates and registers a `ResilientDecodingErrorReporter` with this `userInfo` dictionary. + /// Any `Resilient` properties which are decoded by a `Decoder` with this user info will report their errors to the returned error reporter. + /// - note: May only be called once on a particular `userInfo` dictionary public mutating func enableResilientDecodingErrorReporting() -> ResilientDecodingErrorReporter { let errorReporter = ResilientDecodingErrorReporter() _ = replaceResilientDecodingErrorReporter(with: errorReporter) return errorReporter } + /// Replaces the existing error reporter with the provided one + /// - returns: The previous value of the `resilientDecodingErrorReporter` key, which can be used to restore this dictionary to its original state. fileprivate mutating func replaceResilientDecodingErrorReporter( with errorReporter: ResilientDecodingErrorReporter ) -> Any? { @@ -31,12 +39,18 @@ extension [CodingUserInfoKey: Any] { existingReporter.currentDigest.mayBeMissingReportedErrors = true } } + self[.resilientDecodingErrorReporter] = errorReporter return errorReporter } + } extension JSONDecoder { + + /// Creates and registers a `ResilientDecodingErrorReporter` with this `JSONDecoder`. + /// Any `Resilient` properties which this `JSONDecoder` decodes will report their errors to the returned error reporter. + /// - note: May only be called once per `JSONDecoder` public func enableResilientDecodingErrorReporting() -> ResilientDecodingErrorReporter { userInfo.enableResilientDecodingErrorReporting() } @@ -49,10 +63,12 @@ extension JSONDecoder { guard reportResilientDecodingErrors else { return (try decode(T.self, from: data), nil) } + let errorReporter = ResilientDecodingErrorReporter() let oldValue = userInfo.replaceResilientDecodingErrorReporter(with: errorReporter) let value = try decode(T.self, from: data) userInfo[.resilientDecodingErrorReporter] = oldValue + return (value, errorReporter.flushReportedErrors()) } } @@ -60,22 +76,23 @@ extension JSONDecoder { // MARK: - Accessing Reported Errors public final class ResilientDecodingErrorReporter { + + /// Creates a `ResilientDecodingErrorReporter`, which is only useful + /// if it is the value for the key `resilientDecodingErrorReporter` in a `Decoder`'s `userInfo` public init() {} + /// This is meant to be called immediately after decoding a `Decodable` type from a `Decoder`. + /// - returns: Any errors encountered up to this point in time public func flushReportedErrors() -> ErrorDigest? { - #if DEBUG let digest = hasErrors ? currentDigest : nil hasErrors = false currentDigest = ErrorDigest() return digest - #else - // Release 빌드에서는 성능 최적화를 위해 에러 정보를 반환하지 않음 - hasErrors = false - currentDigest = ErrorDigest() - return nil - #endif } + /// This should only ever be called by `Decoder.resilientDecodingHandled` when an error is handled, + /// consider calling that method instead. + /// It is `internal` and not `fileprivate` only to allow us to split the up the two files. func resilientDecodingHandled(_ error: Error, at path: [String]) { hasErrors = true currentDigest.root.insert(error, at: path) @@ -86,6 +103,7 @@ public final class ResilientDecodingErrorReporter { } public struct ErrorDigest { + public var errors: [Error] { errors(includeUnknownNovelValueErrors: false) } @@ -101,12 +119,17 @@ public struct ErrorDigest { return allErrors.filter { includeUnknownNovelValueErrors || !($0 is UnknownNovelValueError) } } + /// This should only ever be set from `Decoder.enableResilientDecodingErrorReporting` + /// to signify that reporting has been enabled multiple times and the first + /// `ResilientDecodingErrorReporter` may be missing errors. + /// This behavior is behind an `assert` so it is highly unlikely to happen in production. fileprivate var mayBeMissingReportedErrors = false fileprivate struct Node { private var children: [String: Node] = [:] private var shallowErrors: [Error] = [] + /// Inserts an error at the provided path mutating func insert(_ error: Error, at path: some Collection) { if let next = path.first { children[next, default: Node()].insert(error, at: path.dropFirst()) @@ -126,11 +149,17 @@ public struct ErrorDigest { // MARK: - Reporting Errors extension Decoder { + + /// Reports an error which did not cause decoding to fail. + /// This error can be accessed after decoding is complete using `ResilientDecodingErrorReporter`. + /// Care should be taken that this is called on the most relevant `Decoder` object, + /// since this method uses the `Decoder`'s `codingPath` to place the error in the correct location in the tree. public func reportError(_ error: Swift.Error) { guard let errorReporterAny = userInfo[.resilientDecodingErrorReporter] else { return } + /// Check that we haven't hit the very unlikely case where someone has overriden our user info key with something we do not expect. guard let errorReporter = errorReporterAny as? ResilientDecodingErrorReporter else { assertionFailure() return @@ -143,6 +172,7 @@ extension Decoder { // MARK: - Pretty Printing #if DEBUG + extension ErrorDigest: CustomDebugStringConvertible { public var debugDescription: String { root.debugDescriptionLines.joined(separator: "\n") @@ -163,6 +193,8 @@ extension ErrorDigest.Node { } extension Error { + + /// An abridged description which does not include the coding path fileprivate var abridgedDescription: String { switch self { case let decodingError as DecodingError: @@ -187,15 +219,22 @@ extension Error { } } } + #endif // MARK: - Specific Errors +/// In the unlikely event that `enableResilientDecodingErrorReporting()` is called multiple times, this error will be reported to the earlier `ResilientDecodingErrorReporter` to signify that the later one may have eaten some of its errors. private struct MayBeMissingReportedErrors: Error {} +/// An error which is surfaced at the property level but is not reported via `ResilientDecodingErrorReporter` by default (it can still be accessed by calling `errorDigest.errors(includeUnknownNovelValueErrors: true)`). This error is meant to indicate that the client detected a type it does not understand but believes to be valid, for instance a novel `case` of a `String`-backed `enum`. +/// This is primarily used by `ResilientRawRepresentable`, but more complex use-cases exist where it is desirable to suppress error reporting but it would be awkward to implement using `ResilientRawRepresentable`. One such example is a type which inspects a `type` key before deciding how to decode the rest of the data (this pattern is often used to decode `enum`s with associated values). If it is desirable to suppress error reporting when encountering a new `type`, the custom type can explicitly throw this error. public struct UnknownNovelValueError: Error { + + /// The raw value for which `init(rawValue:)` returned `nil`. public let novelValue: Any + /// - parameter novelValue: A value which is believed to be valid but the code does not know how to handle. public init(novelValue: T) { self.novelValue = novelValue } diff --git a/Sources/KarrotCodableKit/Resilient/ResilientDecodingOutcome.swift b/Sources/KarrotCodableKit/Resilient/ResilientDecodingOutcome.swift index cf598ca..46a484a 100644 --- a/Sources/KarrotCodableKit/Resilient/ResilientDecodingOutcome.swift +++ b/Sources/KarrotCodableKit/Resilient/ResilientDecodingOutcome.swift @@ -2,16 +2,21 @@ // ResilientDecodingOutcome.swift // KarrotCodableKit // -// Created by Elon on 4/9/25. +// Created by Elon on 7/28/25. // import Foundation #if DEBUG public enum ResilientDecodingOutcome: Sendable { + /// A value was decoded successfully case decodedSuccessfully + /// The key was missing, and it was not treated as an error (for instance when decoding an `Optional`) case keyNotFound + /// The value was `nil`, and it was not treated as an error (for instance when decoding an `Optional`) case valueWasNil + /// An error was recovered from during decoding + /// - parameter `wasReported`: Some errors are not reported, for instance `ArrayDecodingError` case recoveredFrom(any Error, wasReported: Bool) } @@ -30,6 +35,9 @@ extension ResilientDecodingOutcome: Equatable { } } #else +/// In release, we don't want the decoding outcome mechanism taking up space, +/// so we define an empty struct with `static` properties and functions which match the `enum` above. +/// This reduces the number of places we need to use `#if DEBUG` substantially. public struct ResilientDecodingOutcome: Sendable { public static let decodedSuccessfully = Self() public static let keyNotFound = Self() diff --git a/Tests/KarrotCodableKitTests/BetterCodable/DataValue/DataValueResilientTests.swift b/Tests/KarrotCodableKitTests/BetterCodable/DataValue/DataValueResilientTests.swift index 59d82ce..f62d68d 100644 --- a/Tests/KarrotCodableKitTests/BetterCodable/DataValue/DataValueResilientTests.swift +++ b/Tests/KarrotCodableKitTests/BetterCodable/DataValue/DataValueResilientTests.swift @@ -112,11 +112,7 @@ struct DataValueResilientTests { let errorDigest = errorReporter.flushReportedErrors() - #if DEBUG let digest = try #require(errorDigest) #expect(digest.errors.count >= 1) - #else - #expect(errorDigest == nil) - #endif } } diff --git a/Tests/KarrotCodableKitTests/BetterCodable/DateValue/DateValueResilientTests.swift b/Tests/KarrotCodableKitTests/BetterCodable/DateValue/DateValueResilientTests.swift index 8d2ef30..5ab6da7 100644 --- a/Tests/KarrotCodableKitTests/BetterCodable/DateValue/DateValueResilientTests.swift +++ b/Tests/KarrotCodableKitTests/BetterCodable/DateValue/DateValueResilientTests.swift @@ -119,11 +119,7 @@ struct DateValueResilientTests { let errorDigest = errorReporter.flushReportedErrors() - #if DEBUG let digest = try #require(errorDigest) #expect(digest.errors.count >= 1) - #else - #expect(errorDigest == nil) - #endif } } diff --git a/Tests/KarrotCodableKitTests/BetterCodable/Defaults/DefaultCodableResilientTests.swift b/Tests/KarrotCodableKitTests/BetterCodable/Defaults/DefaultCodableResilientTests.swift index 6a36de5..bfc38c3 100644 --- a/Tests/KarrotCodableKitTests/BetterCodable/Defaults/DefaultCodableResilientTests.swift +++ b/Tests/KarrotCodableKitTests/BetterCodable/Defaults/DefaultCodableResilientTests.swift @@ -171,13 +171,11 @@ struct DefaultCodableResilientTests { let errorDigest = errorReporter.flushReportedErrors() - #if DEBUG let digest = try #require(errorDigest) // At least 3 errors should be reported #expect(digest.errors.count >= 3) + #if DEBUG print("Error digest: \(digest.debugDescription)") - #else - #expect(errorDigest == nil) #endif } diff --git a/Tests/KarrotCodableKitTests/BetterCodable/LosslessValue/LosslessArrayResilientTests.swift b/Tests/KarrotCodableKitTests/BetterCodable/LosslessValue/LosslessArrayResilientTests.swift index 9d143f9..f3ece77 100644 --- a/Tests/KarrotCodableKitTests/BetterCodable/LosslessValue/LosslessArrayResilientTests.swift +++ b/Tests/KarrotCodableKitTests/BetterCodable/LosslessValue/LosslessArrayResilientTests.swift @@ -87,14 +87,12 @@ struct LosslessArrayResilientTests { let errorDigest = errorReporter.flushReportedErrors() - #if DEBUG // Check if errors were reported let digest = try #require(errorDigest) // null and conversion failure errors #expect(digest.errors.count >= 3) + #if DEBUG print("Error digest: \(digest.debugDescription)") - #else - #expect(errorDigest == nil) #endif } diff --git a/Tests/KarrotCodableKitTests/BetterCodable/LosslessValue/LosslessValueResilientTests.swift b/Tests/KarrotCodableKitTests/BetterCodable/LosslessValue/LosslessValueResilientTests.swift index eaaa5b3..5b53d0d 100644 --- a/Tests/KarrotCodableKitTests/BetterCodable/LosslessValue/LosslessValueResilientTests.swift +++ b/Tests/KarrotCodableKitTests/BetterCodable/LosslessValue/LosslessValueResilientTests.swift @@ -126,12 +126,8 @@ struct LosslessValueResilientTests { let errorDigest = errorReporter.flushReportedErrors() - #if DEBUG // Check if errors were reported let digest = try #require(errorDigest) #expect(digest.errors.count >= 1) // At least 1 error occurred - #else - #expect(errorDigest == nil) - #endif } } diff --git a/Tests/KarrotCodableKitTests/BetterCodable/LossyValue/LossyArrayResilientTests.swift b/Tests/KarrotCodableKitTests/BetterCodable/LossyValue/LossyArrayResilientTests.swift index 683df4d..776e212 100644 --- a/Tests/KarrotCodableKitTests/BetterCodable/LossyValue/LossyArrayResilientTests.swift +++ b/Tests/KarrotCodableKitTests/BetterCodable/LossyValue/LossyArrayResilientTests.swift @@ -89,14 +89,9 @@ struct LossyArrayResilientTests { let errorDigest = errorReporter.flushReportedErrors() - #if DEBUG // Check if errors were reported let digest = try #require(errorDigest) #expect(digest.errors.count >= 1) - #else - // No error info in Release builds - #expect(errorDigest == nil) - #endif } @Test("decode with reportResilientDecodingErrors") @@ -120,12 +115,8 @@ struct LossyArrayResilientTests { #expect(fixture.integers == [1, 3]) - #if DEBUG #expect(errorDigest != nil) #expect(errorDigest?.errors.count ?? 0 >= 1) - #else - #expect(errorDigest == nil) - #endif } @Test("empty array on complete failure") diff --git a/Tests/KarrotCodableKitTests/BetterCodable/LossyValue/LossyDictionaryResilientTests.swift b/Tests/KarrotCodableKitTests/BetterCodable/LossyValue/LossyDictionaryResilientTests.swift index 69a12a2..ac2dd32 100644 --- a/Tests/KarrotCodableKitTests/BetterCodable/LossyValue/LossyDictionaryResilientTests.swift +++ b/Tests/KarrotCodableKitTests/BetterCodable/LossyValue/LossyDictionaryResilientTests.swift @@ -105,13 +105,9 @@ struct LossyDictionaryResilientTests { let errorDigest = errorReporter.flushReportedErrors() - #if DEBUG // Check if errors were reported let digest = try #require(errorDigest) #expect(digest.errors.count >= 2) // Errors for keys "a" and "c" - #else - #expect(errorDigest == nil) - #endif } @Test("complete failure results in empty dictionary") diff --git a/Tests/KarrotCodableKitTests/PolymorphicCodable/Value/PolymorphicValueResilientTests.swift b/Tests/KarrotCodableKitTests/PolymorphicCodable/Value/PolymorphicValueResilientTests.swift index 9a34faf..77ad3c0 100644 --- a/Tests/KarrotCodableKitTests/PolymorphicCodable/Value/PolymorphicValueResilientTests.swift +++ b/Tests/KarrotCodableKitTests/PolymorphicCodable/Value/PolymorphicValueResilientTests.swift @@ -132,13 +132,9 @@ struct PolymorphicValueResilientTests { // then let errorDigest = errorReporter.flushReportedErrors() - #if DEBUG // Check if error was reported let digest = try #require(errorDigest) #expect(digest.errors.count >= 1) - #else - #expect(errorDigest == nil) - #endif } } From 34498431cdc595a9790d084b3138474322487386 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elon=20Park=20=28=E1=84=8B=E1=85=A6=E1=86=AF=E1=84=85?= =?UTF-8?q?=E1=85=A9=E1=86=AB=29?= Date: Thu, 31 Jul 2025 21:37:11 +0900 Subject: [PATCH 20/22] test: apply coderabbit comments --- .../BetterCodable/DataValue/DataValue.swift | 17 +- .../BetterCodable/DateValue/DateValue.swift | 6 +- .../DateValue/OptionalDateValue.swift | 6 +- .../Defaults/DefaultCodable.swift | 56 ++-- .../Extensions/Result+Extension.swift | 35 +++ .../LosslessValue/LosslessArray.swift | 15 +- .../LosslessValue/LosslessValue.swift | 8 +- .../BetterCodable/LossyValue/LossyArray.swift | 12 +- .../LossyValue/LossyDictionary.swift | 56 ++-- .../LossyValue/LossyOptional.swift | 1 - .../ResilientProjectedValue.swift | 14 +- .../Encodable+ToDictionary.swift | 9 +- .../DefaultEmptyPolymorphicArrayValue.swift | 12 +- ...er+DefaultEmptyPolymorphicArrayValue.swift | 8 +- ...tainer+LossyOptionalPolymorphicValue.swift | 8 +- ...tainer+OptionalPolymorphicArrayValue.swift | 10 +- ...ngContainer+OptionalPolymorphicValue.swift | 8 +- ...Container+PolymorphicLossyArrayValue.swift | 14 +- .../OptionalPolymorphicArrayValue.swift | 18 +- .../PolymorphicArrayValue.swift | 8 +- .../PolymorphicProjectedValue.swift | 8 +- .../Resilient/ArrayDecodingError.swift | 21 +- .../Resilient/DictionaryDecodingError.swift | 21 +- .../Resilient/ErrorReporting.swift | 17 +- .../DataValue/DataValueResilientTests.swift | 82 +++--- .../DateValue/DateValueResilientTests.swift | 90 +++--- .../DefaultCodableResilientTests.swift | 278 +++++++++--------- .../Defaults/DefaultCodableTests.swift | 4 +- .../Defaults/DefaultEmptyArrayTests.swift | 1 - .../LosslessArrayResilientTests.swift | 99 ++++--- .../LosslessValue/LosslessArrayTests.swift | 1 - .../LosslessValueResilientTests.swift | 100 +++---- .../LossyValue/LossyArrayResilientTests.swift | 130 ++++---- .../LossyValue/LossyArrayTests.swift | 1 - .../LossyDictionaryResilientTests.swift | 147 +++++---- .../PolymorphicArrayValueResilientTests.swift | 6 +- ...morphicLossyArrayValueResilientTests.swift | 29 +- .../Enum/PolymorphicEnumDecodableTests.swift | 1 - ...lPolymorphicArrayValueResilientTests.swift | 158 +++++----- .../OptionalPolymorphicArrayValueTests.swift | 24 +- ...tionalPolymorphicValueResilientTests.swift | 4 +- .../UnnestedPolymorphicCodableDummy.swift | 1 - .../PolymorphicValueResilientTests.swift | 102 +++---- .../Value/PolymorphicValueTests.swift | 1 - .../CustomCodableMacroTests.swift | 5 +- .../CustomDecodableMacroTests.swift | 5 +- .../CustomEncodableMacroTests.swift | 5 +- .../PolymorphicCodableMacroTests.swift | 2 +- ...icCodableStrategyProvidingMacroTests.swift | 2 +- .../PolymorphicDeodableMacroTests.swift | 3 +- .../PolymorphicEncodableMacroTests.swift | 2 +- .../PolymorphicEnumCodableMacroTests.swift | 13 +- .../PolymorphicEnumDecodableMacroTests.swift | 13 +- .../PolymorphicEnumEncodableMacroTests.swift | 8 +- 54 files changed, 858 insertions(+), 847 deletions(-) create mode 100644 Sources/KarrotCodableKit/BetterCodable/Extensions/Result+Extension.swift diff --git a/Sources/KarrotCodableKit/BetterCodable/DataValue/DataValue.swift b/Sources/KarrotCodableKit/BetterCodable/DataValue/DataValue.swift index 50f1173..0219c4d 100644 --- a/Sources/KarrotCodableKit/BetterCodable/DataValue/DataValue.swift +++ b/Sources/KarrotCodableKit/BetterCodable/DataValue/DataValue.swift @@ -24,19 +24,19 @@ public protocol DataValueCodableStrategy { @propertyWrapper public struct DataValue { public var wrappedValue: Coder.DataType - + public let outcome: ResilientDecodingOutcome public init(wrappedValue: Coder.DataType) { self.wrappedValue = wrappedValue self.outcome = .decodedSuccessfully } - + init(wrappedValue: Coder.DataType, outcome: ResilientDecodingOutcome) { self.wrappedValue = wrappedValue self.outcome = outcome } - + #if DEBUG public var projectedValue: ResilientProjectedValue { ResilientProjectedValue(outcome: outcome) } #endif @@ -46,13 +46,8 @@ extension DataValue: Decodable { public init(from decoder: Decoder) throws { do { let stringValue = try String(from: decoder) - do { - self.wrappedValue = try Coder.decode(stringValue) - self.outcome = .decodedSuccessfully - } catch { - decoder.reportError(error) - throw error - } + self.wrappedValue = try Coder.decode(stringValue) + self.outcome = .decodedSuccessfully } catch { decoder.reportError(error) throw error @@ -67,7 +62,7 @@ extension DataValue: Encodable { } extension DataValue: Equatable where Coder.DataType: Equatable { - public static func ==(lhs: Self, rhs: Self) -> Bool { + public static func == (lhs: Self, rhs: Self) -> Bool { lhs.wrappedValue == rhs.wrappedValue } } diff --git a/Sources/KarrotCodableKit/BetterCodable/DateValue/DateValue.swift b/Sources/KarrotCodableKit/BetterCodable/DateValue/DateValue.swift index bc9fd0a..73f929b 100644 --- a/Sources/KarrotCodableKit/BetterCodable/DateValue/DateValue.swift +++ b/Sources/KarrotCodableKit/BetterCodable/DateValue/DateValue.swift @@ -24,19 +24,19 @@ public protocol DateValueCodableStrategy { @propertyWrapper public struct DateValue { public var wrappedValue: Date - + public let outcome: ResilientDecodingOutcome public init(wrappedValue: Date) { self.wrappedValue = wrappedValue self.outcome = .decodedSuccessfully } - + init(wrappedValue: Date, outcome: ResilientDecodingOutcome) { self.wrappedValue = wrappedValue self.outcome = outcome } - + #if DEBUG public var projectedValue: ResilientProjectedValue { ResilientProjectedValue(outcome: outcome) } #endif diff --git a/Sources/KarrotCodableKit/BetterCodable/DateValue/OptionalDateValue.swift b/Sources/KarrotCodableKit/BetterCodable/DateValue/OptionalDateValue.swift index 744f102..e9f3957 100644 --- a/Sources/KarrotCodableKit/BetterCodable/DateValue/OptionalDateValue.swift +++ b/Sources/KarrotCodableKit/BetterCodable/DateValue/OptionalDateValue.swift @@ -25,19 +25,19 @@ public protocol OptionalDateValueCodableStrategy { @propertyWrapper public struct OptionalDateValue { public var wrappedValue: Date? - + public let outcome: ResilientDecodingOutcome public init(wrappedValue: Date?) { self.wrappedValue = wrappedValue self.outcome = .decodedSuccessfully } - + init(wrappedValue: Date?, outcome: ResilientDecodingOutcome) { self.wrappedValue = wrappedValue self.outcome = outcome } - + #if DEBUG public var projectedValue: ResilientProjectedValue { ResilientProjectedValue(outcome: outcome) } #endif diff --git a/Sources/KarrotCodableKit/BetterCodable/Defaults/DefaultCodable.swift b/Sources/KarrotCodableKit/BetterCodable/Defaults/DefaultCodable.swift index d1cb02f..4d3ae59 100644 --- a/Sources/KarrotCodableKit/BetterCodable/Defaults/DefaultCodable.swift +++ b/Sources/KarrotCodableKit/BetterCodable/Defaults/DefaultCodable.swift @@ -16,14 +16,14 @@ public protocol DefaultCodableStrategy { /// The fallback value used when decoding fails static var defaultValue: DefaultValue { get } - + /// When true, unknown raw values for RawRepresentable types will be reported as errors. /// When false, unknown raw values will use the defaultValue without reporting an error. /// Defaults to false. static var isFrozen: Bool { get } } -// Default implementation +/// Default implementation extension DefaultCodableStrategy { public static var isFrozen: Bool { false } } @@ -35,19 +35,19 @@ extension DefaultCodableStrategy { @propertyWrapper public struct DefaultCodable { public var wrappedValue: Default.DefaultValue - + public let outcome: ResilientDecodingOutcome public init(wrappedValue: Default.DefaultValue) { self.wrappedValue = wrappedValue self.outcome = .decodedSuccessfully } - + init(wrappedValue: Default.DefaultValue, outcome: ResilientDecodingOutcome) { self.wrappedValue = wrappedValue self.outcome = outcome } - + #if DEBUG public var projectedValue: ResilientProjectedValue { ResilientProjectedValue(outcome: outcome) } #endif @@ -63,7 +63,7 @@ extension DefaultCodable where Default.Type == Default.DefaultValue.Type { extension DefaultCodable: Decodable { public init(from decoder: Decoder) throws { let container = try decoder.singleValueContainer() - + // Check for nil first if container.decodeNil() { #if DEBUG @@ -73,7 +73,7 @@ extension DefaultCodable: Decodable { #endif return } - + do { let value = try container.decode(Default.DefaultValue.self) self.init(wrappedValue: value) @@ -96,7 +96,7 @@ extension DefaultCodable: Encodable where Default.DefaultValue: Encodable { } extension DefaultCodable: Equatable where Default.DefaultValue: Equatable { - public static func ==(lhs: Self, rhs: Self) -> Bool { + public static func == (lhs: Self, rhs: Self) -> Bool { lhs.wrappedValue == rhs.wrappedValue } } @@ -126,7 +126,7 @@ extension KeyedDecodingContainer { return DefaultCodable(wrappedValue: P.defaultValue) #endif } - + // Check for nil if (try? decodeNil(forKey: key)) == true { #if DEBUG @@ -135,7 +135,7 @@ extension KeyedDecodingContainer { return DefaultCodable(wrappedValue: P.defaultValue) #endif } - + // Try to decode normally if let value = try decodeIfPresent(DefaultCodable

.self, forKey: key) { return value @@ -163,22 +163,24 @@ extension KeyedDecodingContainer { return DefaultCodable(wrappedValue: P.defaultValue) #endif } - + // Check for nil first if (try? decodeNil(forKey: key)) == true { #if DEBUG return DefaultCodable(wrappedValue: P.defaultValue, outcome: .valueWasNil) - #else + #else return DefaultCodable(wrappedValue: P.defaultValue) #endif } - + do { let value = try decode(Bool.self, forKey: key) return DefaultCodable(wrappedValue: value) } catch let error { - guard let decodingError = error as? DecodingError, - case .typeMismatch = decodingError else { + guard + let decodingError = error as? DecodingError, + case .typeMismatch = decodingError + else { // Report error and use default let decoder = try superDecoder(forKey: key) decoder.reportError(error) @@ -188,11 +190,15 @@ extension KeyedDecodingContainer { return DefaultCodable(wrappedValue: P.defaultValue) #endif } - if let intValue = try? decodeIfPresent(Int.self, forKey: key), - let bool = Bool(exactly: NSNumber(value: intValue)) { + if + let intValue = try? decodeIfPresent(Int.self, forKey: key), + let bool = Bool(exactly: NSNumber(value: intValue)) + { return DefaultCodable(wrappedValue: bool) - } else if let stringValue = try? decodeIfPresent(String.self, forKey: key), - let bool = Bool(stringValue) { + } else if + let stringValue = try? decodeIfPresent(String.self, forKey: key), + let bool = Bool(stringValue) + { return DefaultCodable(wrappedValue: bool) } else { // Type mismatch - report error @@ -206,7 +212,7 @@ extension KeyedDecodingContainer { } } } - + /// Decodes a DefaultCodable where the strategy's DefaultValue is RawRepresentable /// /// This method provides special handling for RawRepresentable types: @@ -223,7 +229,7 @@ extension KeyedDecodingContainer { return DefaultCodable(wrappedValue: P.defaultValue) #endif } - + // Check for nil if (try? decodeNil(forKey: key)) == true { #if DEBUG @@ -232,11 +238,11 @@ extension KeyedDecodingContainer { return DefaultCodable(wrappedValue: P.defaultValue) #endif } - + // Try to decode the raw value do { let rawValue = try decode(P.DefaultValue.RawValue.self, forKey: key) - + // Try to create the enum from raw value if let value = P.DefaultValue(rawValue: rawValue) { return DefaultCodable(wrappedValue: value) @@ -251,7 +257,7 @@ extension KeyedDecodingContainer { let decoder = try superDecoder(forKey: key) decoder.reportError(error) - + #if DEBUG return DefaultCodable(wrappedValue: P.defaultValue, outcome: .recoveredFrom(error, wasReported: true)) #else @@ -262,7 +268,7 @@ extension KeyedDecodingContainer { // Decoding the raw value failed (e.g., type mismatch) let decoder = try superDecoder(forKey: key) decoder.reportError(error) - + #if DEBUG return DefaultCodable(wrappedValue: P.defaultValue, outcome: .recoveredFrom(error, wasReported: true)) #else diff --git a/Sources/KarrotCodableKit/BetterCodable/Extensions/Result+Extension.swift b/Sources/KarrotCodableKit/BetterCodable/Extensions/Result+Extension.swift new file mode 100644 index 0000000..e4d2522 --- /dev/null +++ b/Sources/KarrotCodableKit/BetterCodable/Extensions/Result+Extension.swift @@ -0,0 +1,35 @@ +// +// Result+Extension.swift +// KarrotCodableKit +// +// Created by elon on 7/31/25. +// + +import Foundation + +extension Result { + package var isSuccess: Bool { + switch self { + case .success: true + case .failure: false + } + } + + package var isFailure: Bool { + !isSuccess + } + + package var success: Success? { + try? get() + } + + package var failure: Failure? { + switch self { + + case .success: + nil + case .failure(let error): + error + } + } +} diff --git a/Sources/KarrotCodableKit/BetterCodable/LosslessValue/LosslessArray.swift b/Sources/KarrotCodableKit/BetterCodable/LosslessValue/LosslessArray.swift index 55664b6..3069a8e 100644 --- a/Sources/KarrotCodableKit/BetterCodable/LosslessValue/LosslessArray.swift +++ b/Sources/KarrotCodableKit/BetterCodable/LosslessValue/LosslessArray.swift @@ -5,7 +5,6 @@ // Created by Elon on 4/9/25. // - import Foundation /// Decodes Arrays by attempting to decode its elements into their preferred types. @@ -18,21 +17,23 @@ import Foundation @propertyWrapper public struct LosslessArray { public var wrappedValue: [T] - + public let outcome: ResilientDecodingOutcome public init(wrappedValue: [T]) { self.wrappedValue = wrappedValue self.outcome = .decodedSuccessfully } - + init(wrappedValue: [T], outcome: ResilientDecodingOutcome) { self.wrappedValue = wrappedValue self.outcome = outcome } - + #if DEBUG - public var projectedValue: ResilientArrayProjectedValue { ResilientArrayProjectedValue(outcome: outcome) } + public var projectedValue: ResilientArrayProjectedValue { + ResilientArrayProjectedValue(outcome: outcome) + } #endif } @@ -46,7 +47,7 @@ extension LosslessArray: Decodable where T: Decodable { #if DEBUG var results: [Result] = [] #endif - + while !container.isAtEnd { do { let value = try container.decode(LosslessValue.self).wrappedValue @@ -83,7 +84,7 @@ extension LosslessArray: Encodable where T: Encodable { } extension LosslessArray: Equatable where T: Equatable { - public static func ==(lhs: Self, rhs: Self) -> Bool { + public static func == (lhs: Self, rhs: Self) -> Bool { lhs.wrappedValue == rhs.wrappedValue } } diff --git a/Sources/KarrotCodableKit/BetterCodable/LosslessValue/LosslessValue.swift b/Sources/KarrotCodableKit/BetterCodable/LosslessValue/LosslessValue.swift index 16a71e0..1915334 100644 --- a/Sources/KarrotCodableKit/BetterCodable/LosslessValue/LosslessValue.swift +++ b/Sources/KarrotCodableKit/BetterCodable/LosslessValue/LosslessValue.swift @@ -30,7 +30,7 @@ public struct LosslessValueCodable: Codable private let type: LosslessStringCodable.Type public var wrappedValue: Strategy.Value - + public let outcome: ResilientDecodingOutcome public init(wrappedValue: Strategy.Value) { @@ -38,7 +38,7 @@ public struct LosslessValueCodable: Codable self.type = Strategy.Value.self self.outcome = .decodedSuccessfully } - + init( wrappedValue: Strategy.Value, outcome: ResilientDecodingOutcome, @@ -48,7 +48,7 @@ public struct LosslessValueCodable: Codable self.outcome = outcome self.type = type } - + #if DEBUG public var projectedValue: ResilientProjectedValue { ResilientProjectedValue(outcome: outcome) @@ -147,5 +147,3 @@ public struct LosslessDefaultStrategy: LosslessDec public typealias LosslessValue< T: LosslessStringCodable > = LosslessValueCodable> - - diff --git a/Sources/KarrotCodableKit/BetterCodable/LossyValue/LossyArray.swift b/Sources/KarrotCodableKit/BetterCodable/LossyValue/LossyArray.swift index 66efb39..dd68a75 100644 --- a/Sources/KarrotCodableKit/BetterCodable/LossyValue/LossyArray.swift +++ b/Sources/KarrotCodableKit/BetterCodable/LossyValue/LossyArray.swift @@ -15,19 +15,19 @@ import Foundation @propertyWrapper public struct LossyArray { public var wrappedValue: [T] - + public let outcome: ResilientDecodingOutcome public init(wrappedValue: [T]) { self.wrappedValue = wrappedValue self.outcome = .decodedSuccessfully } - + init(wrappedValue: [T], outcome: ResilientDecodingOutcome) { self.wrappedValue = wrappedValue self.outcome = outcome } - + #if DEBUG public var projectedValue: ResilientArrayProjectedValue { ResilientArrayProjectedValue(outcome: outcome) } #endif @@ -51,7 +51,7 @@ extension LossyArray: Decodable where T: Decodable { } catch { // Not a single value container, proceed with array decoding } - + do { var container = try decoder.unkeyedContainer() @@ -59,7 +59,7 @@ extension LossyArray: Decodable where T: Decodable { #if DEBUG var results: [Result] = [] #endif - + while !container.isAtEnd { let elementDecoder = try container.superDecoder() do { @@ -104,7 +104,7 @@ extension LossyArray: Encodable where T: Encodable { } extension LossyArray: Equatable where T: Equatable { - public static func ==(lhs: Self, rhs: Self) -> Bool { + public static func == (lhs: Self, rhs: Self) -> Bool { lhs.wrappedValue == rhs.wrappedValue } } diff --git a/Sources/KarrotCodableKit/BetterCodable/LossyValue/LossyDictionary.swift b/Sources/KarrotCodableKit/BetterCodable/LossyValue/LossyDictionary.swift index c48a3aa..5ad98bf 100644 --- a/Sources/KarrotCodableKit/BetterCodable/LossyValue/LossyDictionary.swift +++ b/Sources/KarrotCodableKit/BetterCodable/LossyValue/LossyDictionary.swift @@ -15,19 +15,19 @@ import Foundation @propertyWrapper public struct LossyDictionary { public var wrappedValue: [Key: Value] - + public let outcome: ResilientDecodingOutcome public init(wrappedValue: [Key: Value]) { self.wrappedValue = wrappedValue self.outcome = .decodedSuccessfully } - + init(wrappedValue: [Key: Value], outcome: ResilientDecodingOutcome) { self.wrappedValue = wrappedValue self.outcome = outcome } - + #if DEBUG public var projectedValue: ResilientDictionaryProjectedValue { ResilientDictionaryProjectedValue(outcome: outcome) @@ -60,12 +60,12 @@ extension LossyDictionary: Decodable where Key: Decodable, Value: Decodable { self.value = try container.decode(DecodablValue.self) } } - + private struct ExtractedKey { let codingKey: DictionaryCodingKey let originalKey: String } - + private struct DecodingState { var elements: [Key: Value] = [:] #if DEBUG @@ -81,18 +81,18 @@ extension LossyDictionary: Decodable where Key: Decodable, Value: Decodable { return false } } - + private static func decodeStringKeyedDictionary( from decoder: Decoder ) throws -> DecodingState { guard Key.self == String.self else { fatalError("This method should only be called for String keys") } - + var state = DecodingState() let container = try decoder.container(keyedBy: DictionaryCodingKey.self) let keys = try extractKeys(from: decoder, container: container) - + for extractedKey in keys { decodeSingleKeyValue( container: container, @@ -101,26 +101,26 @@ extension LossyDictionary: Decodable where Key: Decodable, Value: Decodable { state: &state ) } - + return state } - + private static func decodeIntKeyedDictionary( from decoder: Decoder ) throws -> DecodingState { guard Key.self == Int.self else { fatalError("This method should only be called for Int keys") } - + var state = DecodingState() let container = try decoder.container(keyedBy: DictionaryCodingKey.self) - + for key in container.allKeys { guard let intValue = key.intValue else { // Skip non-integer keys instead of throwing continue } - + decodeSingleKeyValueForInt( container: container, key: key, @@ -128,10 +128,10 @@ extension LossyDictionary: Decodable where Key: Decodable, Value: Decodable { state: &state ) } - + return state } - + private static func decodeSingleKeyValue( container: KeyedDecodingContainer, key: DictionaryCodingKey, @@ -140,7 +140,7 @@ extension LossyDictionary: Decodable where Key: Decodable, Value: Decodable { ) { // Safe casting - if it fails, we skip this key entirely guard let castKey = originalKey as? Key else { return } - + do { let value = try container.decode(LossyDecodableValue.self, forKey: key).value state.elements[castKey] = value @@ -156,7 +156,7 @@ extension LossyDictionary: Decodable where Key: Decodable, Value: Decodable { #endif } } - + private static func decodeSingleKeyValueForInt( container: KeyedDecodingContainer, key: DictionaryCodingKey, @@ -165,7 +165,7 @@ extension LossyDictionary: Decodable where Key: Decodable, Value: Decodable { ) { // Safe casting - if it fails, we skip this key entirely guard let castKey = intKey as? Key else { return } - + do { let value = try container.decode(LossyDecodableValue.self, forKey: key).value state.elements[castKey] = value @@ -181,7 +181,7 @@ extension LossyDictionary: Decodable where Key: Decodable, Value: Decodable { #endif } } - + private static func createFinalResult(from state: DecodingState) -> LossyDictionary { #if DEBUG if state.elements.count == state.results.count { @@ -194,7 +194,7 @@ extension LossyDictionary: Decodable where Key: Decodable, Value: Decodable { return LossyDictionary(wrappedValue: state.elements) #endif } - + public init(from decoder: Decoder) throws { // Check for nil first if Self.decodeNilValue(from: decoder) { @@ -205,10 +205,10 @@ extension LossyDictionary: Decodable where Key: Decodable, Value: Decodable { #endif return } - + do { let state: DecodingState - + if Key.self == String.self { state = try Self.decodeStringKeyedDictionary(from: decoder) } else if Key.self == Int.self { @@ -221,7 +221,7 @@ extension LossyDictionary: Decodable where Key: Decodable, Value: Decodable { ) ) } - + self = Self.createFinalResult(from: state) } catch { decoder.reportError(error) @@ -256,7 +256,7 @@ extension LossyDictionary: Encodable where Key: Encodable, Value: Encodable { } extension LossyDictionary: Equatable where Value: Equatable { - public static func ==(lhs: Self, rhs: Self) -> Bool { + public static func == (lhs: Self, rhs: Self) -> Bool { lhs.wrappedValue == rhs.wrappedValue } } @@ -266,7 +266,12 @@ extension LossyDictionary: Sendable where Key: Sendable, Value: Sendable {} // MARK: - KeyedDecodingContainer extension KeyedDecodingContainer { - public func decode(_: LossyDictionary.Type, forKey key: Key) throws -> LossyDictionary where DictKey: Hashable & Decodable, DictValue: Decodable { + public func decode( + _: LossyDictionary.Type, + forKey key: Key + ) throws -> LossyDictionary + where DictKey: Hashable & Decodable, DictValue: Decodable + { if let value = try decodeIfPresent(LossyDictionary.self, forKey: key) { return value } else { @@ -278,4 +283,3 @@ extension KeyedDecodingContainer { } } } - diff --git a/Sources/KarrotCodableKit/BetterCodable/LossyValue/LossyOptional.swift b/Sources/KarrotCodableKit/BetterCodable/LossyValue/LossyOptional.swift index 278ca9e..0f7bf61 100644 --- a/Sources/KarrotCodableKit/BetterCodable/LossyValue/LossyOptional.swift +++ b/Sources/KarrotCodableKit/BetterCodable/LossyValue/LossyOptional.swift @@ -5,7 +5,6 @@ // Created by Elon on 4/9/25. // - import Foundation public struct DefaultNilStrategy: DefaultCodableStrategy { diff --git a/Sources/KarrotCodableKit/BetterCodable/ResilientProjectedValue.swift b/Sources/KarrotCodableKit/BetterCodable/ResilientProjectedValue.swift index 79b42f2..1581f06 100644 --- a/Sources/KarrotCodableKit/BetterCodable/ResilientProjectedValue.swift +++ b/Sources/KarrotCodableKit/BetterCodable/ResilientProjectedValue.swift @@ -19,9 +19,9 @@ extension ResilientProjectedValueProtocol { public var error: Error? { switch outcome { case .decodedSuccessfully, .keyNotFound, .valueWasNil: - return nil + nil case .recoveredFrom(let error, _): - return error + error } } } @@ -32,7 +32,7 @@ extension ResilientProjectedValueProtocol { /// including error tracking and resilient decoding outcome information. public struct ResilientProjectedValue: ResilientProjectedValueProtocol { public let outcome: ResilientDecodingOutcome - + public init(outcome: ResilientDecodingOutcome) { self.outcome = outcome } @@ -45,11 +45,11 @@ public struct ResilientProjectedValue: ResilientProjectedValueProtocol { @dynamicMemberLookup public struct ResilientArrayProjectedValue: ResilientProjectedValueProtocol { public let outcome: ResilientDecodingOutcome - + public init(outcome: ResilientDecodingOutcome) { self.outcome = outcome } - + public subscript( dynamicMember keyPath: KeyPath, U> ) -> U { @@ -64,11 +64,11 @@ public struct ResilientArrayProjectedValue: ResilientProjectedValueProt @dynamicMemberLookup public struct ResilientDictionaryProjectedValue: ResilientProjectedValueProtocol { public let outcome: ResilientDecodingOutcome - + public init(outcome: ResilientDecodingOutcome) { self.outcome = outcome } - + public subscript( dynamicMember keyPath: KeyPath, U> ) -> U { diff --git a/Sources/KarrotCodableKit/Encodable+ToDictionary.swift b/Sources/KarrotCodableKit/Encodable+ToDictionary.swift index d48c7ce..c1b358c 100644 --- a/Sources/KarrotCodableKit/Encodable+ToDictionary.swift +++ b/Sources/KarrotCodableKit/Encodable+ToDictionary.swift @@ -19,10 +19,11 @@ extension Encodable { */ public func toDictionary() throws -> [String: Any] { let data = try JSONEncoder().encode(self) - guard let dictionary = try JSONSerialization.jsonObject( - with: data, - options: .fragmentsAllowed - ) as? [String: Any] + guard + let dictionary = try JSONSerialization.jsonObject( + with: data, + options: .fragmentsAllowed + ) as? [String: Any] else { throw NSError(domain: "JSONSerialization Failed", code: 0) } diff --git a/Sources/KarrotCodableKit/PolymorphicCodable/DefaultEmptyPolymorphicArrayValue.swift b/Sources/KarrotCodableKit/PolymorphicCodable/DefaultEmptyPolymorphicArrayValue.swift index 6ef28be..2b89fda 100644 --- a/Sources/KarrotCodableKit/PolymorphicCodable/DefaultEmptyPolymorphicArrayValue.swift +++ b/Sources/KarrotCodableKit/PolymorphicCodable/DefaultEmptyPolymorphicArrayValue.swift @@ -30,7 +30,7 @@ import Foundation 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. public var wrappedValue: [PolymorphicType.ExpectedType] - + /// Tracks the outcome of the decoding process for resilient decoding public let outcome: ResilientDecodingOutcome @@ -38,16 +38,16 @@ public struct DefaultEmptyPolymorphicArrayValue.self).wrappedValue elements.append(value) @@ -90,7 +90,7 @@ extension DefaultEmptyPolymorphicArrayValue: Encodable { extension DefaultEmptyPolymorphicArrayValue: Equatable where PolymorphicType.ExpectedType: Equatable { public static func == (lhs: Self, rhs: Self) -> Bool { - return lhs.wrappedValue == rhs.wrappedValue + lhs.wrappedValue == rhs.wrappedValue } } diff --git a/Sources/KarrotCodableKit/PolymorphicCodable/Extensions/KeyedDecodingContainer+DefaultEmptyPolymorphicArrayValue.swift b/Sources/KarrotCodableKit/PolymorphicCodable/Extensions/KeyedDecodingContainer+DefaultEmptyPolymorphicArrayValue.swift index f810809..25d1cf7 100644 --- a/Sources/KarrotCodableKit/PolymorphicCodable/Extensions/KeyedDecodingContainer+DefaultEmptyPolymorphicArrayValue.swift +++ b/Sources/KarrotCodableKit/PolymorphicCodable/Extensions/KeyedDecodingContainer+DefaultEmptyPolymorphicArrayValue.swift @@ -17,12 +17,12 @@ extension KeyedDecodingContainer { guard contains(key) else { return DefaultEmptyPolymorphicArrayValue(wrappedValue: [], outcome: .keyNotFound) } - + // Check if value is null if try decodeNil(forKey: key) { return DefaultEmptyPolymorphicArrayValue(wrappedValue: [], outcome: .valueWasNil) } - + // Try to decode using the property wrapper's decoder let decoder = try superDecoder(forKey: key) return try DefaultEmptyPolymorphicArrayValue(from: decoder) @@ -36,12 +36,12 @@ extension KeyedDecodingContainer { guard contains(key) else { return nil } - + // Check if value is null if try decodeNil(forKey: key) { return DefaultEmptyPolymorphicArrayValue(wrappedValue: [], outcome: .valueWasNil) } - + // Try to decode using the property wrapper's decoder let decoder = try superDecoder(forKey: key) return try DefaultEmptyPolymorphicArrayValue(from: decoder) diff --git a/Sources/KarrotCodableKit/PolymorphicCodable/Extensions/KeyedDecodingContainer+LossyOptionalPolymorphicValue.swift b/Sources/KarrotCodableKit/PolymorphicCodable/Extensions/KeyedDecodingContainer+LossyOptionalPolymorphicValue.swift index fbc0d87..ec34083 100644 --- a/Sources/KarrotCodableKit/PolymorphicCodable/Extensions/KeyedDecodingContainer+LossyOptionalPolymorphicValue.swift +++ b/Sources/KarrotCodableKit/PolymorphicCodable/Extensions/KeyedDecodingContainer+LossyOptionalPolymorphicValue.swift @@ -14,9 +14,9 @@ extension KeyedDecodingContainer { forKey key: Key ) throws -> LossyOptionalPolymorphicValue where T: PolymorphicCodableStrategy { if let value = try decodeIfPresent(type, forKey: key) { - return value + value } else { - return LossyOptionalPolymorphicValue(wrappedValue: nil, outcome: .keyNotFound) + LossyOptionalPolymorphicValue(wrappedValue: nil, outcome: .keyNotFound) } } @@ -28,12 +28,12 @@ extension KeyedDecodingContainer { guard contains(key) else { return nil } - + // Check if value is null if try decodeNil(forKey: key) { return LossyOptionalPolymorphicValue(wrappedValue: nil, outcome: .valueWasNil) } - + // Try to decode the polymorphic value do { let decoder = try superDecoder(forKey: key) diff --git a/Sources/KarrotCodableKit/PolymorphicCodable/Extensions/KeyedDecodingContainer+OptionalPolymorphicArrayValue.swift b/Sources/KarrotCodableKit/PolymorphicCodable/Extensions/KeyedDecodingContainer+OptionalPolymorphicArrayValue.swift index b664fb8..70b3e5b 100644 --- a/Sources/KarrotCodableKit/PolymorphicCodable/Extensions/KeyedDecodingContainer+OptionalPolymorphicArrayValue.swift +++ b/Sources/KarrotCodableKit/PolymorphicCodable/Extensions/KeyedDecodingContainer+OptionalPolymorphicArrayValue.swift @@ -32,7 +32,7 @@ extension KeyedDecodingContainer { guard contains(key) else { return nil } - + // Check if the value is null if try decodeNil(forKey: key) { #if DEBUG @@ -41,18 +41,18 @@ extension KeyedDecodingContainer { return OptionalPolymorphicArrayValue(wrappedValue: nil) #endif } - + // Try to decode the array do { var container = try nestedUnkeyedContainer(forKey: key) var elements = [T.ExpectedType]() - + while !container.isAtEnd { // Use PolymorphicValue for decoding each element let value = try container.decode(PolymorphicValue.self) elements.append(value.wrappedValue) } - + #if DEBUG return OptionalPolymorphicArrayValue(wrappedValue: elements, outcome: .decodedSuccessfully) #else @@ -65,4 +65,4 @@ extension KeyedDecodingContainer { throw error } } -} \ No newline at end of file +} diff --git a/Sources/KarrotCodableKit/PolymorphicCodable/Extensions/KeyedDecodingContainer+OptionalPolymorphicValue.swift b/Sources/KarrotCodableKit/PolymorphicCodable/Extensions/KeyedDecodingContainer+OptionalPolymorphicValue.swift index bab48df..6f513f8 100644 --- a/Sources/KarrotCodableKit/PolymorphicCodable/Extensions/KeyedDecodingContainer+OptionalPolymorphicValue.swift +++ b/Sources/KarrotCodableKit/PolymorphicCodable/Extensions/KeyedDecodingContainer+OptionalPolymorphicValue.swift @@ -14,9 +14,9 @@ extension KeyedDecodingContainer { forKey key: Key ) throws -> OptionalPolymorphicValue where T: PolymorphicCodableStrategy { if let value = try decodeIfPresent(type, forKey: key) { - return value + value } else { - return OptionalPolymorphicValue(wrappedValue: nil, outcome: .keyNotFound) + OptionalPolymorphicValue(wrappedValue: nil, outcome: .keyNotFound) } } @@ -28,12 +28,12 @@ extension KeyedDecodingContainer { guard contains(key) else { return nil } - + // Check if value is null if try decodeNil(forKey: key) { return OptionalPolymorphicValue(wrappedValue: nil, outcome: .valueWasNil) } - + // Try to decode the polymorphic value do { let decoder = try superDecoder(forKey: key) diff --git a/Sources/KarrotCodableKit/PolymorphicCodable/Extensions/KeyedDecodingContainer+PolymorphicLossyArrayValue.swift b/Sources/KarrotCodableKit/PolymorphicCodable/Extensions/KeyedDecodingContainer+PolymorphicLossyArrayValue.swift index 9f87600..c826dd6 100644 --- a/Sources/KarrotCodableKit/PolymorphicCodable/Extensions/KeyedDecodingContainer+PolymorphicLossyArrayValue.swift +++ b/Sources/KarrotCodableKit/PolymorphicCodable/Extensions/KeyedDecodingContainer+PolymorphicLossyArrayValue.swift @@ -21,7 +21,7 @@ extension KeyedDecodingContainer { return PolymorphicLossyArrayValue(wrappedValue: [], outcome: .keyNotFound) #endif } - + // Check if value is null if try decodeNil(forKey: key) { #if DEBUG @@ -30,7 +30,7 @@ extension KeyedDecodingContainer { return PolymorphicLossyArrayValue(wrappedValue: [], outcome: .valueWasNil) #endif } - + // Try to decode the array do { let decoder = try superDecoder(forKey: key) @@ -38,7 +38,11 @@ extension KeyedDecodingContainer { } catch { // If decoding fails (e.g., not an array), return empty array #if DEBUG - return PolymorphicLossyArrayValue(wrappedValue: [], outcome: .recoveredFrom(error, wasReported: false), results: []) + return PolymorphicLossyArrayValue( + wrappedValue: [], + outcome: .recoveredFrom(error, wasReported: false), + results: [] + ) #else return PolymorphicLossyArrayValue(wrappedValue: [], outcome: .recoveredFrom(error, wasReported: false)) #endif @@ -53,7 +57,7 @@ extension KeyedDecodingContainer { guard contains(key) else { return nil } - + // Check if value is null if try decodeNil(forKey: key) { #if DEBUG @@ -62,7 +66,7 @@ extension KeyedDecodingContainer { return PolymorphicLossyArrayValue(wrappedValue: []) #endif } - + // Try to decode using PolymorphicLossyArrayValue's decoder let decoder = try superDecoder(forKey: key) return try PolymorphicLossyArrayValue(from: decoder) diff --git a/Sources/KarrotCodableKit/PolymorphicCodable/OptionalPolymorphicArrayValue.swift b/Sources/KarrotCodableKit/PolymorphicCodable/OptionalPolymorphicArrayValue.swift index 448e402..8a35a02 100644 --- a/Sources/KarrotCodableKit/PolymorphicCodable/OptionalPolymorphicArrayValue.swift +++ b/Sources/KarrotCodableKit/PolymorphicCodable/OptionalPolymorphicArrayValue.swift @@ -37,7 +37,7 @@ import Foundation public struct OptionalPolymorphicArrayValue { /// The decoded optional array of values conforming to the expected polymorphic type. public var wrappedValue: [PolymorphicType.ExpectedType]? - + /// The outcome of the decoding process public let outcome: ResilientDecodingOutcome @@ -46,17 +46,17 @@ public struct OptionalPolymorphicArrayValue.self) elements.append(value.wrappedValue) } - + // Successfully decoded the array #if DEBUG self.init(wrappedValue: elements, outcome: .decodedSuccessfully) diff --git a/Sources/KarrotCodableKit/PolymorphicCodable/PolymorphicArrayValue.swift b/Sources/KarrotCodableKit/PolymorphicCodable/PolymorphicArrayValue.swift index d889b36..27be64b 100644 --- a/Sources/KarrotCodableKit/PolymorphicCodable/PolymorphicArrayValue.swift +++ b/Sources/KarrotCodableKit/PolymorphicCodable/PolymorphicArrayValue.swift @@ -19,7 +19,7 @@ import Foundation public struct PolymorphicArrayValue { /// The decoded array of values, each conforming to the expected polymorphic type. public var wrappedValue: [PolymorphicType.ExpectedType] - + /// Tracks the outcome of the decoding process for resilient decoding public let outcome: ResilientDecodingOutcome @@ -28,16 +28,16 @@ public struct PolymorphicArrayValue self.wrappedValue = wrappedValue self.outcome = .decodedSuccessfully } - + init(wrappedValue: [PolymorphicType.ExpectedType], outcome: ResilientDecodingOutcome) { self.wrappedValue = wrappedValue self.outcome = outcome } - + #if DEBUG /// The projected value providing access to decoding outcome public var projectedValue: PolymorphicProjectedValue { - return PolymorphicProjectedValue(outcome: outcome) + PolymorphicProjectedValue(outcome: outcome) } #else /// In non-DEBUG builds, accessing projectedValue is a programmer error diff --git a/Sources/KarrotCodableKit/PolymorphicCodable/PolymorphicProjectedValue.swift b/Sources/KarrotCodableKit/PolymorphicCodable/PolymorphicProjectedValue.swift index 81bc381..77db26c 100644 --- a/Sources/KarrotCodableKit/PolymorphicCodable/PolymorphicProjectedValue.swift +++ b/Sources/KarrotCodableKit/PolymorphicCodable/PolymorphicProjectedValue.swift @@ -20,9 +20,9 @@ extension PolymorphicProjectedValueProtocol { public var error: Error? { switch outcome { case .decodedSuccessfully, .keyNotFound, .valueWasNil: - return nil + nil case .recoveredFrom(let error, _): - return error + error } } } @@ -47,10 +47,10 @@ public struct PolymorphicProjectedValue: PolymorphicProjectedValueProtocol { public struct PolymorphicLossyArrayProjectedValue: PolymorphicProjectedValueProtocol { /// The outcome of the decoding process public let outcome: ResilientDecodingOutcome - + /// Results of decoding each element in the array public let results: [Result] - + public init(outcome: ResilientDecodingOutcome, results: [Result]) { self.outcome = outcome self.results = results diff --git a/Sources/KarrotCodableKit/Resilient/ArrayDecodingError.swift b/Sources/KarrotCodableKit/Resilient/ArrayDecodingError.swift index 32c6851..214cba0 100644 --- a/Sources/KarrotCodableKit/Resilient/ArrayDecodingError.swift +++ b/Sources/KarrotCodableKit/Resilient/ArrayDecodingError.swift @@ -9,33 +9,32 @@ import Foundation #if DEBUG extension ResilientDecodingOutcome { + /// A type representing some number of errors encountered while decoding an array public struct ArrayDecodingError: Error { public let results: [Result] public var errors: [Error] { - results.compactMap { result in - switch result { - case .success: - return nil - case .failure(let error): - return error - } - } + results.compactMap(\.failure) } - + public init(results: [Result]) { self.results = results } } - + func arrayDecodingError() -> ResilientDecodingOutcome.ArrayDecodingError { typealias ArrayDecodingError = ResilientDecodingOutcome.ArrayDecodingError switch self { case .decodedSuccessfully, .keyNotFound, .valueWasNil: return .init(results: []) - case let .recoveredFrom(error as ArrayDecodingError, wasReported): + + case .recoveredFrom(let error as ArrayDecodingError, let wasReported): + /// `ArrayDecodingError` should not be reported assert(!wasReported) return error + case .recoveredFrom(let error, _): + /// When recovering from a top level error, we can provide the error value in the array, + /// instead of returning an empty array. We believe this is a win for usability. return .init(results: [.failure(error)]) } } diff --git a/Sources/KarrotCodableKit/Resilient/DictionaryDecodingError.swift b/Sources/KarrotCodableKit/Resilient/DictionaryDecodingError.swift index fa7ed84..3b9928f 100644 --- a/Sources/KarrotCodableKit/Resilient/DictionaryDecodingError.swift +++ b/Sources/KarrotCodableKit/Resilient/DictionaryDecodingError.swift @@ -12,30 +12,27 @@ extension ResilientDecodingOutcome { public struct DictionaryDecodingError: Error { public let results: [Key: Result] public var errors: [Key: Error] { - results.compactMapValues { result in - switch result { - case .success: - return nil - case .failure(let error): - return error - } - } + results.compactMapValues(\.failure) } - + public init(results: [Key: Result]) { self.results = results } } - + func dictionaryDecodingError() -> ResilientDecodingOutcome.DictionaryDecodingError { typealias DictionaryDecodingError = ResilientDecodingOutcome.DictionaryDecodingError switch self { case .decodedSuccessfully, .keyNotFound, .valueWasNil: return .init(results: [:]) - case let .recoveredFrom(error as DictionaryDecodingError, wasReported): + + case .recoveredFrom(let error as DictionaryDecodingError, let wasReported): + /// `DictionaryDecodingError` should not be reported assert(!wasReported) return error - case .recoveredFrom(_, _): + + case .recoveredFrom: + /// Unlike array, we chose not to provide the top level error in the dictionary since there isn't a good way to choose an appropriate key. return .init(results: [:]) } } diff --git a/Sources/KarrotCodableKit/Resilient/ErrorReporting.swift b/Sources/KarrotCodableKit/Resilient/ErrorReporting.swift index b11805c..580decb 100644 --- a/Sources/KarrotCodableKit/Resilient/ErrorReporting.swift +++ b/Sources/KarrotCodableKit/Resilient/ErrorReporting.swift @@ -6,8 +6,6 @@ // import Foundation -// Created by George Leontiev on 3/25/20. -// Copyright © 2020 Airbnb Inc. // MARK: - Enabling Error Reporting @@ -24,12 +22,13 @@ extension [CodingUserInfoKey: Any] { /// - note: May only be called once on a particular `userInfo` dictionary public mutating func enableResilientDecodingErrorReporting() -> ResilientDecodingErrorReporter { let errorReporter = ResilientDecodingErrorReporter() - _ = replaceResilientDecodingErrorReporter(with: errorReporter) + replaceResilientDecodingErrorReporter(with: errorReporter) return errorReporter } /// Replaces the existing error reporter with the provided one /// - returns: The previous value of the `resilientDecodingErrorReporter` key, which can be used to restore this dictionary to its original state. + @discardableResult fileprivate mutating func replaceResilientDecodingErrorReporter( with errorReporter: ResilientDecodingErrorReporter ) -> Any? { @@ -43,7 +42,6 @@ extension [CodingUserInfoKey: Any] { self[.resilientDecodingErrorReporter] = errorReporter return errorReporter } - } extension JSONDecoder { @@ -109,12 +107,11 @@ public struct ErrorDigest { } public func errors(includeUnknownNovelValueErrors: Bool) -> [Error] { - let allErrors: [Error] = - if mayBeMissingReportedErrors { - [MayBeMissingReportedErrors()] + root.errors - } else { - root.errors - } + let allErrors: [Error] = if mayBeMissingReportedErrors { + [MayBeMissingReportedErrors()] + root.errors + } else { + root.errors + } return allErrors.filter { includeUnknownNovelValueErrors || !($0 is UnknownNovelValueError) } } diff --git a/Tests/KarrotCodableKitTests/BetterCodable/DataValue/DataValueResilientTests.swift b/Tests/KarrotCodableKitTests/BetterCodable/DataValue/DataValueResilientTests.swift index f62d68d..331a554 100644 --- a/Tests/KarrotCodableKitTests/BetterCodable/DataValue/DataValueResilientTests.swift +++ b/Tests/KarrotCodableKitTests/BetterCodable/DataValue/DataValueResilientTests.swift @@ -5,8 +5,8 @@ // Created by Elon on 4/9/25. // -import Testing import Foundation +import Testing @testable import KarrotCodableKit @Suite("DataValue Resilient Decoding") @@ -15,43 +15,43 @@ struct DataValueResilientTests { @DataValue var base64Data: Data @DataValue var anotherData: Data } - + @Test("projected value provides error information") - func testProjectedValueProvidesErrorInfo() throws { + func projectedValueProvidesErrorInfo() throws { let json = """ - { - "base64Data": "SGVsbG8gV29ybGQ=", - "anotherData": "VGVzdCBEYXRh" - } - """ - + { + "base64Data": "SGVsbG8gV29ybGQ=", + "anotherData": "VGVzdCBEYXRh" + } + """ + let decoder = JSONDecoder() let data = try #require(json.data(using: .utf8)) let fixture = try decoder.decode(Fixture.self, from: data) - + // Verify default behavior #expect(String(data: fixture.base64Data, encoding: .utf8) == "Hello World") #expect(String(data: fixture.anotherData, encoding: .utf8) == "Test Data") - + #if DEBUG // Access success info through projected value #expect(fixture.$base64Data.outcome == .decodedSuccessfully) #expect(fixture.$anotherData.outcome == .decodedSuccessfully) #endif } - + @Test("invalid base64 format handling") - func testInvalidBase64Format() async throws { + func invalidBase64Format() async throws { let json = """ - { - "base64Data": "Invalid!@#$%^&*()Base64", - "anotherData": "=====" - } - """ - + { + "base64Data": "Invalid!@#$%^&*()Base64", + "anotherData": "=====" + } + """ + let decoder = JSONDecoder() let data = try #require(json.data(using: .utf8)) - + await confirmation(expectedCount: 1) { confirmation in do { _ = try decoder.decode(Fixture.self, from: data) @@ -62,19 +62,19 @@ struct DataValueResilientTests { } } } - + @Test("null values handling") - func testNullValues() async throws { + func nullValues() async throws { let json = """ - { - "base64Data": null, - "anotherData": null - } - """ - + { + "base64Data": null, + "anotherData": null + } + """ + let decoder = JSONDecoder() let data = try #require(json.data(using: .utf8)) - + await confirmation(expectedCount: 1) { confirmation in do { _ = try decoder.decode(Fixture.self, from: data) @@ -85,21 +85,21 @@ struct DataValueResilientTests { } } } - + @Test("error reporting with JSONDecoder") - func testErrorReporting() async throws { + func errorReporting() async throws { let json = """ - { - "base64Data": 12345, - "anotherData": {"key": "value"} - } - """ - + { + "base64Data": 12345, + "anotherData": {"key": "value"} + } + """ + let decoder = JSONDecoder() let errorReporter = decoder.enableResilientDecodingErrorReporting() - + let data = try #require(json.data(using: .utf8)) - + await confirmation(expectedCount: 1) { confirmation in do { _ = try decoder.decode(Fixture.self, from: data) @@ -109,9 +109,9 @@ struct DataValueResilientTests { confirmation() } } - + let errorDigest = errorReporter.flushReportedErrors() - + let digest = try #require(errorDigest) #expect(digest.errors.count >= 1) } diff --git a/Tests/KarrotCodableKitTests/BetterCodable/DateValue/DateValueResilientTests.swift b/Tests/KarrotCodableKitTests/BetterCodable/DateValue/DateValueResilientTests.swift index 5ab6da7..4d6ab41 100644 --- a/Tests/KarrotCodableKitTests/BetterCodable/DateValue/DateValueResilientTests.swift +++ b/Tests/KarrotCodableKitTests/BetterCodable/DateValue/DateValueResilientTests.swift @@ -5,8 +5,8 @@ // Created by Elon on 4/9/25. // -import Testing import Foundation +import Testing @testable import KarrotCodableKit @Suite("DateValue Resilient Decoding") @@ -16,26 +16,26 @@ struct DateValueResilientTests { @DateValue var rfcDate: Date @DateValue var timestampDate: Date } - + @Test("projected value provides error information") - func testProjectedValueProvidesErrorInfo() throws { + func projectedValueProvidesErrorInfo() throws { let json = """ - { - "isoDate": "2025-01-01T12:00:00Z", - "rfcDate": "2025-01-01T12:00:00+00:00", - "timestampDate": 1735728000 - } - """ - + { + "isoDate": "2025-01-01T12:00:00Z", + "rfcDate": "2025-01-01T12:00:00+00:00", + "timestampDate": 1735728000 + } + """ + let decoder = JSONDecoder() let data = try #require(json.data(using: .utf8)) let fixture = try decoder.decode(Fixture.self, from: data) - + // Verify basic functionality #expect(fixture.isoDate.timeIntervalSince1970 > 0) #expect(fixture.rfcDate.timeIntervalSince1970 > 0) #expect(fixture.timestampDate.timeIntervalSince1970 > 0) - + #if DEBUG // Access success info via projected value #expect(fixture.$isoDate.outcome == .decodedSuccessfully) @@ -43,17 +43,17 @@ struct DateValueResilientTests { #expect(fixture.$timestampDate.outcome == .decodedSuccessfully) #endif } - + @Test("invalid date format handling") - func testInvalidDateFormat() async throws { + func invalidDateFormat() async throws { let json = """ - { - "isoDate": "invalid-date", - "rfcDate": "2025-01-01", - "timestampDate": "not a number" - } - """ - + { + "isoDate": "invalid-date", + "rfcDate": "2025-01-01", + "timestampDate": "not a number" + } + """ + let decoder = JSONDecoder() let data = try #require(json.data(using: .utf8)) @@ -62,22 +62,22 @@ struct DateValueResilientTests { _ = try decoder.decode(Fixture.self, from: data) Issue.record("Should have thrown") } catch { - // Invalid Base64 format causes decoding failure + // Invalid date format causes decoding failure confirmation() } } } - + @Test("null values handling") - func testNullValues() async throws { + func nullValues() async throws { let json = """ - { - "isoDate": null, - "rfcDate": null, - "timestampDate": null - } - """ - + { + "isoDate": null, + "rfcDate": null, + "timestampDate": null + } + """ + let decoder = JSONDecoder() let data = try #require(json.data(using: .utf8)) @@ -86,27 +86,27 @@ struct DateValueResilientTests { _ = try decoder.decode(Fixture.self, from: data) Issue.record("Should have thrown") } catch { - // null values cannot be converted to Data + // null values cannot be converted to Date confirmation() } } } - + @Test("error reporting with JSONDecoder") - func testErrorReporting() async throws { + func errorReporting() async throws { let json = """ - { - "isoDate": 12345, - "rfcDate": true, - "timestampDate": ["array"] - } - """ - + { + "isoDate": 12345, + "rfcDate": true, + "timestampDate": ["array"] + } + """ + let decoder = JSONDecoder() let errorReporter = decoder.enableResilientDecodingErrorReporting() - + let data = try #require(json.data(using: .utf8)) - + await confirmation(expectedCount: 1) { confirmation in do { _ = try decoder.decode(Fixture.self, from: data) @@ -116,9 +116,9 @@ struct DateValueResilientTests { confirmation() } } - + let errorDigest = errorReporter.flushReportedErrors() - + let digest = try #require(errorDigest) #expect(digest.errors.count >= 1) } diff --git a/Tests/KarrotCodableKitTests/BetterCodable/Defaults/DefaultCodableResilientTests.swift b/Tests/KarrotCodableKitTests/BetterCodable/Defaults/DefaultCodableResilientTests.swift index bfc38c3..9c324a7 100644 --- a/Tests/KarrotCodableKitTests/BetterCodable/Defaults/DefaultCodableResilientTests.swift +++ b/Tests/KarrotCodableKitTests/BetterCodable/Defaults/DefaultCodableResilientTests.swift @@ -5,9 +5,9 @@ // Created by Elon on 4/9/25. // -import Testing import Foundation import KarrotCodableKit +import Testing @Suite("DefaultCodable Resilient Decoding") struct DefaultCodableResilientTests { @@ -18,30 +18,30 @@ struct DefaultCodableResilientTests { @DefaultEmptyArray var arrayValue: [String] @DefaultEmptyDictionary var dictValue: [String: Int] } - + @Test("projected value provides error information for failed decoding") - func testProjectedValueProvidesErrorInfo() throws { + func projectedValueProvidesErrorInfo() throws { let json = """ - { - "intValue": "not a number", - "stringValue": 123, - "boolValue": "not a bool", - "arrayValue": "not an array", - "dictValue": "not a dict" - } - """ - + { + "intValue": "not a number", + "stringValue": 123, + "boolValue": "not a bool", + "arrayValue": "not an array", + "dictValue": "not a dict" + } + """ + let decoder = JSONDecoder() let data = json.data(using: .utf8)! let fixture = try decoder.decode(Fixture.self, from: data) - + // Verify default behavior - use default value on decoding failure #expect(fixture.intValue == 0) #expect(fixture.stringValue == "") #expect(fixture.boolValue == false) #expect(fixture.arrayValue == []) #expect(fixture.dictValue == [:]) - + #if DEBUG // Access error info through projected value #expect(fixture.$intValue.error != nil) @@ -49,8 +49,8 @@ struct DefaultCodableResilientTests { #expect(fixture.$boolValue.error != nil) #expect(fixture.$arrayValue.error != nil) #expect(fixture.$dictValue.error != nil) - - // Check error type + + /// Check error type let error = try #require(fixture.$intValue.error as? DecodingError) switch error { case .typeMismatch: @@ -61,22 +61,22 @@ struct DefaultCodableResilientTests { } #endif } - + @Test("missing keys use default values without error") - func testMissingKeysUseDefaultValues() throws { + func missingKeysUseDefaultValues() throws { let json = "{}" - + let decoder = JSONDecoder() let data = json.data(using: .utf8)! let fixture = try decoder.decode(Fixture.self, from: data) - + // Check default values #expect(fixture.intValue == 0) #expect(fixture.stringValue == "") #expect(fixture.boolValue == false) #expect(fixture.arrayValue == []) #expect(fixture.dictValue == [:]) - + #if DEBUG // No error when key is missing (default behavior) #expect(fixture.$intValue.error == nil) @@ -86,30 +86,30 @@ struct DefaultCodableResilientTests { #expect(fixture.$dictValue.error == nil) #endif } - + @Test("valid values decode successfully") - func testValidValuesDecodeSuccessfully() throws { + func validValuesDecodeSuccessfully() throws { let json = """ - { - "intValue": 42, - "stringValue": "hello", - "boolValue": true, - "arrayValue": ["a", "b", "c"], - "dictValue": {"key": 123} - } - """ - + { + "intValue": 42, + "stringValue": "hello", + "boolValue": true, + "arrayValue": ["a", "b", "c"], + "dictValue": {"key": 123} + } + """ + let decoder = JSONDecoder() let data = json.data(using: .utf8)! let fixture = try decoder.decode(Fixture.self, from: data) - + // Check normal values #expect(fixture.intValue == 42) #expect(fixture.stringValue == "hello") #expect(fixture.boolValue == true) #expect(fixture.arrayValue == ["a", "b", "c"]) #expect(fixture.dictValue == ["key": 123]) - + #if DEBUG // No error when successfully decoded #expect(fixture.$intValue.error == nil) @@ -119,30 +119,30 @@ struct DefaultCodableResilientTests { #expect(fixture.$dictValue.error == nil) #endif } - + @Test("null values use default values") - func testNullValuesUseDefaultValues() throws { + func nullValuesUseDefaultValues() throws { let json = """ - { - "intValue": null, - "stringValue": null, - "boolValue": null, - "arrayValue": null, - "dictValue": null - } - """ - + { + "intValue": null, + "stringValue": null, + "boolValue": null, + "arrayValue": null, + "dictValue": null + } + """ + let decoder = JSONDecoder() let data = json.data(using: .utf8)! let fixture = try decoder.decode(Fixture.self, from: data) - + // Use default value for null #expect(fixture.intValue == 0) #expect(fixture.stringValue == "") #expect(fixture.boolValue == false) #expect(fixture.arrayValue == []) #expect(fixture.dictValue == [:]) - + #if DEBUG // null is not considered an error #expect(fixture.$intValue.error == nil) @@ -152,25 +152,25 @@ struct DefaultCodableResilientTests { #expect(fixture.$dictValue.error == nil) #endif } - + @Test("error reporting with JSONDecoder") - func testErrorReportingWithDecoder() throws { + func errorReportingWithDecoder() throws { let json = """ - { - "intValue": "invalid", - "stringValue": [], - "boolValue": {} - } - """ - + { + "intValue": "invalid", + "stringValue": [], + "boolValue": {} + } + """ + let decoder = JSONDecoder() let errorReporter = decoder.enableResilientDecodingErrorReporting() - + let data = json.data(using: .utf8)! _ = try decoder.decode(Fixture.self, from: data) let errorDigest = errorReporter.flushReportedErrors() - + let digest = try #require(errorDigest) // At least 3 errors should be reported #expect(digest.errors.count >= 3) @@ -178,32 +178,32 @@ struct DefaultCodableResilientTests { print("Error digest: \(digest.debugDescription)") #endif } - + @Test("LossyOptional behavior") - func testLossyOptional() throws { + func lossyOptional() throws { struct OptionalFixture: Decodable { @LossyOptional var url: URL? @LossyOptional var date: Date? @LossyOptional var number: Int? } - + let json = """ - { - "url": "https://example .com", - "date": "not a date", - "number": "not a number" - } - """ - + { + "url": "https://example .com", + "date": "not a date", + "number": "not a number" + } + """ + let decoder = JSONDecoder() let data = json.data(using: .utf8)! let fixture = try decoder.decode(OptionalFixture.self, from: data) - + // nil on decoding failure #expect(fixture.url == nil) - #expect(fixture.date == nil) + #expect(fixture.date == nil) #expect(fixture.number == nil) - + #if DEBUG // Check error info #expect(fixture.$url.error != nil) @@ -211,50 +211,50 @@ struct DefaultCodableResilientTests { #expect(fixture.$number.error != nil) #endif } - + // MARK: - RawRepresentable Support Tests - + enum TestEnum: String, Decodable, DefaultCodableStrategy { case first case second case unknown - + static var defaultValue: TestEnum { .unknown } } - + enum FrozenTestEnum: String, Decodable, DefaultCodableStrategy { case alpha case beta case fallback - + static var defaultValue: FrozenTestEnum { .fallback } static var isFrozen: Bool { true } } - + struct RawRepresentableFixture: Decodable { @DefaultCodable var normalEnum: TestEnum @DefaultCodable var frozenEnum: FrozenTestEnum } - + @Test("RawRepresentable with valid raw values") - func testRawRepresentableValidValues() throws { + func rawRepresentableValidValues() throws { // given let json = """ - { - "normalEnum": "first", - "frozenEnum": "alpha" - } - """ - + { + "normalEnum": "first", + "frozenEnum": "alpha" + } + """ + // when let decoder = JSONDecoder() let data = json.data(using: .utf8)! let fixture = try decoder.decode(RawRepresentableFixture.self, from: data) - + // then #expect(fixture.normalEnum == .first) #expect(fixture.frozenEnum == .alpha) - + #if DEBUG #expect(fixture.$normalEnum.outcome == .decodedSuccessfully) #expect(fixture.$frozenEnum.outcome == .decodedSuccessfully) @@ -262,61 +262,57 @@ struct DefaultCodableResilientTests { #expect(fixture.$frozenEnum.error == nil) #endif } - + @Test("RawRepresentable with unknown raw values (non-frozen)") - func testRawRepresentableUnknownValueNonFrozen() throws { + func rawRepresentableUnknownValueNonFrozen() throws { // given let json = """ - { - "normalEnum": "third", - "frozenEnum": "beta" - } - """ - + { + "normalEnum": "third", + "frozenEnum": "beta" + } + """ + // when let decoder = JSONDecoder() let data = json.data(using: .utf8)! let fixture = try decoder.decode(RawRepresentableFixture.self, from: data) - + // then #expect(fixture.normalEnum == .unknown) // Should use default value #expect(fixture.frozenEnum == .beta) - + #if DEBUG // Non-frozen enum should recover with UnknownNovelValueError - if case .recoveredFrom(let error, _) = fixture.$normalEnum.outcome { - #expect(error is UnknownNovelValueError) - if let unknownError = error as? UnknownNovelValueError { - #expect(unknownError.novelValue as? String == "third") - } + if case .recoveredFrom(let error as UnknownNovelValueError, _) = fixture.$normalEnum.outcome { + #expect(error.novelValue as? String == "third") } else { - Issue.record("Expected recoveredFrom outcome for non-frozen enum") + Issue.record("Expected recoveredFrom outcome with UnknownNovelValueError") } - #expect(fixture.$frozenEnum.outcome == .decodedSuccessfully) #endif } - + @Test("RawRepresentable with unknown raw values (frozen)") - func testRawRepresentableUnknownValueFrozen() throws { + func rawRepresentableUnknownValueFrozen() throws { // given let json = """ - { - "normalEnum": "first", - "frozenEnum": "gamma" - } - """ - + { + "normalEnum": "first", + "frozenEnum": "gamma" + } + """ + // when let decoder = JSONDecoder() let errorReporter = decoder.enableResilientDecodingErrorReporting() let data = json.data(using: .utf8)! let fixture = try decoder.decode(RawRepresentableFixture.self, from: data) - + // then #expect(fixture.normalEnum == .first) #expect(fixture.frozenEnum == .fallback) // Should use default value due to error - + #if DEBUG // Frozen enum should report DecodingError if case .recoveredFrom(let error, _) = fixture.$frozenEnum.outcome { @@ -329,27 +325,27 @@ struct DefaultCodableResilientTests { } else { Issue.record("Expected recoveredFrom outcome for frozen enum") } - - // Error should be reported to error reporter + + /// Error should be reported to error reporter let errorDigest = errorReporter.flushReportedErrors() #expect(errorDigest != nil) #endif } - + @Test("RawRepresentable with missing keys") - func testRawRepresentableMissingKeys() throws { + func rawRepresentableMissingKeys() throws { // given let json = "{}" - + // when let decoder = JSONDecoder() let data = json.data(using: .utf8)! let fixture = try decoder.decode(RawRepresentableFixture.self, from: data) - + // then #expect(fixture.normalEnum == .unknown) #expect(fixture.frozenEnum == .fallback) - + #if DEBUG #expect(fixture.$normalEnum.outcome == .keyNotFound) #expect(fixture.$frozenEnum.outcome == .keyNotFound) @@ -357,26 +353,26 @@ struct DefaultCodableResilientTests { #expect(fixture.$frozenEnum.error == nil) #endif } - + @Test("RawRepresentable with null values") - func testRawRepresentableNullValues() throws { + func rawRepresentableNullValues() throws { // given let json = """ - { - "normalEnum": null, - "frozenEnum": null - } - """ - + { + "normalEnum": null, + "frozenEnum": null + } + """ + // when let decoder = JSONDecoder() let data = json.data(using: .utf8)! let fixture = try decoder.decode(RawRepresentableFixture.self, from: data) - + // then #expect(fixture.normalEnum == .unknown) #expect(fixture.frozenEnum == .fallback) - + #if DEBUG #expect(fixture.$normalEnum.outcome == .valueWasNil) #expect(fixture.$frozenEnum.outcome == .valueWasNil) @@ -384,26 +380,26 @@ struct DefaultCodableResilientTests { #expect(fixture.$frozenEnum.error == nil) #endif } - + @Test("RawRepresentable with type mismatch") - func testRawRepresentableTypeMismatch() throws { + func rawRepresentableTypeMismatch() throws { // given - enums expect String but we provide numbers let json = """ - { - "normalEnum": 123, - "frozenEnum": true - } - """ - + { + "normalEnum": 123, + "frozenEnum": true + } + """ + // when let decoder = JSONDecoder() let data = json.data(using: .utf8)! let fixture = try decoder.decode(RawRepresentableFixture.self, from: data) - + // then #expect(fixture.normalEnum == .unknown) #expect(fixture.frozenEnum == .fallback) - + #if DEBUG // Both should have type mismatch errors if case .recoveredFrom(let error, _) = fixture.$normalEnum.outcome { diff --git a/Tests/KarrotCodableKitTests/BetterCodable/Defaults/DefaultCodableTests.swift b/Tests/KarrotCodableKitTests/BetterCodable/Defaults/DefaultCodableTests.swift index f4f5e9a..83692f5 100644 --- a/Tests/KarrotCodableKitTests/BetterCodable/Defaults/DefaultCodableTests.swift +++ b/Tests/KarrotCodableKitTests/BetterCodable/Defaults/DefaultCodableTests.swift @@ -59,7 +59,6 @@ final class DefaultCodableTest_DateStrategy: XCTestCase { } } - // MARK: - Nested Property Wrapper final class DefaultCodableTest_NestedPropertyWrapper: XCTestCase { @@ -93,7 +92,6 @@ final class DefaultCodableTest_NestedPropertyWrapper: XCTestCase { } } - // MARK: - Types with Containers final class DefaultCodableTests_TypesWithContainers: XCTestCase { @@ -144,7 +142,6 @@ final class DefaultCodableTests_TypesWithContainers: XCTestCase { } } - // MARK: - Enums with Associated Values final class DefaultCodableTests_EnumWithAssociatedValue: XCTestCase { @@ -180,6 +177,7 @@ final class DefaultCodableTests_EnumWithAssociatedValue: XCTestCase { case .ziz(let i): try c.encode("ziz", forKey: .z) try c.encode(i, forKey: .i) + case .zaz(let i): try c.encode("zaz", forKey: .z) try c.encode(i, forKey: .i) diff --git a/Tests/KarrotCodableKitTests/BetterCodable/Defaults/DefaultEmptyArrayTests.swift b/Tests/KarrotCodableKitTests/BetterCodable/Defaults/DefaultEmptyArrayTests.swift index d2947a7..9225310 100644 --- a/Tests/KarrotCodableKitTests/BetterCodable/Defaults/DefaultEmptyArrayTests.swift +++ b/Tests/KarrotCodableKitTests/BetterCodable/Defaults/DefaultEmptyArrayTests.swift @@ -58,4 +58,3 @@ final class DefaultEmptyArrayTests: XCTestCase { XCTAssertEqual(fixture.nonPrimitiveValues, [Fixture.NestedFixture(one: "one", two: ["key": ["value"]])]) } } - diff --git a/Tests/KarrotCodableKitTests/BetterCodable/LosslessValue/LosslessArrayResilientTests.swift b/Tests/KarrotCodableKitTests/BetterCodable/LosslessValue/LosslessArrayResilientTests.swift index f3ece77..cc10f3a 100644 --- a/Tests/KarrotCodableKitTests/BetterCodable/LosslessValue/LosslessArrayResilientTests.swift +++ b/Tests/KarrotCodableKitTests/BetterCodable/LosslessValue/LosslessArrayResilientTests.swift @@ -5,8 +5,8 @@ // Created by Elon on 4/9/25. // -import Testing import Foundation +import Testing @testable import KarrotCodableKit @Suite("LosslessArray Resilient Decoding") @@ -15,78 +15,79 @@ struct LosslessArrayResilientTests { @LosslessArray var stringArray: [String] @LosslessArray var intArray: [Int] @LosslessArray var doubleArray: [Double] - + struct NestedObject: Decodable, Equatable, LosslessStringConvertible { let id: Int let name: String - + init?(_ description: String) { - return nil // Not convertible from string + nil // Not convertible from string } - + var description: String { "NestedObject(id: \(id), name: \(name))" } } + @LosslessArray var objectArray: [String] // Objects cannot be converted to String } - + @Test("projected value provides error information for each failed element") - func testProjectedValueProvidesErrorInfo() throws { + func projectedValueProvidesErrorInfo() throws { let json = """ - { - "stringArray": [1, "two", true, null, 5.5], - "intArray": ["invalid", 2, 3.14, 4, true], - "doubleArray": [1.5, "2.5", 3, null, "invalid"], - "objectArray": [{"id": 1, "name": "test"}, "string"] - } - """ - + { + "stringArray": [1, "two", true, null, 5.5], + "intArray": ["invalid", 2, 3.14, 4, true], + "doubleArray": [1.5, "2.5", 3, null, "invalid"], + "objectArray": [{"id": 1, "name": "test"}, "string"] + } + """ + let decoder = JSONDecoder() let data = try #require(json.data(using: .utf8)) let fixture = try decoder.decode(Fixture.self, from: data) - + // Verify default behavior - only convertible values included #expect(fixture.stringArray == ["1", "two", "true", "5.5"]) #expect(fixture.intArray == [2, 4]) - #expect(fixture.doubleArray == [1.5, 2.5, 3.0]) // 1.5, "2.5"->2.5, 3->3.0 + #expect(fixture.doubleArray == [1.5, 2.5, 3.0]) // 1.5, "2.5"->2.5, 3->3.0 #expect(fixture.objectArray == ["string"]) - + #if DEBUG // Access error info through projected value #expect(fixture.$stringArray.results.count == 5) #expect(fixture.$stringArray.errors.count == 1) // null - + #expect(fixture.$intArray.results.count == 5) #expect(fixture.$intArray.errors.count == 3) // "invalid", 3.14, true - + #expect(fixture.$doubleArray.results.count == 5) #expect(fixture.$doubleArray.errors.count == 2) // null, "invalid" - + #expect(fixture.$objectArray.results.count == 2) #expect(fixture.$objectArray.errors.count == 1) // Objects cannot be converted to String #endif } - + @Test("error reporting with JSONDecoder") - func testErrorReporting() throws { + func errorReporting() throws { let json = """ - { - "stringArray": [1, null, "three"], - "intArray": ["not a number", 2], - "doubleArray": [1.5, "invalid", null], - "objectArray": [] - } - """ - + { + "stringArray": [1, null, "three"], + "intArray": ["not a number", 2], + "doubleArray": [1.5, "invalid", null], + "objectArray": [] + } + """ + let decoder = JSONDecoder() let errorReporter = decoder.enableResilientDecodingErrorReporting() - + let data = try #require(json.data(using: .utf8)) _ = try decoder.decode(Fixture.self, from: data) - + let errorDigest = errorReporter.flushReportedErrors() - + // Check if errors were reported let digest = try #require(errorDigest) // null and conversion failure errors @@ -95,21 +96,21 @@ struct LosslessArrayResilientTests { print("Error digest: \(digest.debugDescription)") #endif } - + @Test("complete failure results in empty array") - func testCompleteFailure() async throws { + func completeFailure() async throws { let json = """ - { - "stringArray": "not an array", - "intArray": 123, - "doubleArray": null, - "objectArray": true - } - """ - + { + "stringArray": "not an array", + "intArray": 123, + "doubleArray": null, + "objectArray": true + } + """ + let decoder = JSONDecoder() let data = try #require(json.data(using: .utf8)) - + await confirmation(expectedCount: 1) { confirmation in do { _ = try decoder.decode(Fixture.self, from: data) @@ -120,14 +121,14 @@ struct LosslessArrayResilientTests { } } } - + @Test("missing keys result in decoding error") - func testMissingKeys() async throws { + func missingKeys() async throws { let json = "{}" - + let decoder = JSONDecoder() let data = try #require(json.data(using: .utf8)) - + await confirmation(expectedCount: 1) { confirmation in do { _ = try decoder.decode(Fixture.self, from: data) diff --git a/Tests/KarrotCodableKitTests/BetterCodable/LosslessValue/LosslessArrayTests.swift b/Tests/KarrotCodableKitTests/BetterCodable/LosslessValue/LosslessArrayTests.swift index 35fded4..356369c 100644 --- a/Tests/KarrotCodableKitTests/BetterCodable/LosslessValue/LosslessArrayTests.swift +++ b/Tests/KarrotCodableKitTests/BetterCodable/LosslessValue/LosslessArrayTests.swift @@ -56,4 +56,3 @@ final class LosslessArrayTests: XCTestCase { XCTAssertEqual(fixture.values, [1, 2, 3]) } } - diff --git a/Tests/KarrotCodableKitTests/BetterCodable/LosslessValue/LosslessValueResilientTests.swift b/Tests/KarrotCodableKitTests/BetterCodable/LosslessValue/LosslessValueResilientTests.swift index 5b53d0d..305cd1c 100644 --- a/Tests/KarrotCodableKitTests/BetterCodable/LosslessValue/LosslessValueResilientTests.swift +++ b/Tests/KarrotCodableKitTests/BetterCodable/LosslessValue/LosslessValueResilientTests.swift @@ -5,8 +5,8 @@ // Created by Elon on 4/9/25. // -import Testing import Foundation +import Testing @testable import KarrotCodableKit @Suite("LosslessValue Resilient Decoding") @@ -17,28 +17,28 @@ struct LosslessValueResilientTests { @LosslessValue var boolValue: Bool @LosslessValue var doubleValue: Double } - + @Test("projected value provides error information") - func testProjectedValueProvidesErrorInfo() throws { + func projectedValueProvidesErrorInfo() throws { let json = """ - { - "stringValue": 123, - "intValue": "456", - "boolValue": "true", - "doubleValue": "3.14" - } - """ - + { + "stringValue": 123, + "intValue": "456", + "boolValue": "true", + "doubleValue": "3.14" + } + """ + let decoder = JSONDecoder() let data = try #require(json.data(using: .utf8)) let fixture = try decoder.decode(Fixture.self, from: data) - + // Verify default behavior - all values converted #expect(fixture.stringValue == "123") #expect(fixture.intValue == 456) #expect(fixture.boolValue == true) #expect(fixture.doubleValue == 3.14) - + #if DEBUG // Access success info through projected value #expect(fixture.$stringValue.outcome == .decodedSuccessfully) @@ -47,21 +47,21 @@ struct LosslessValueResilientTests { #expect(fixture.$doubleValue.outcome == .decodedSuccessfully) #endif } - + @Test("null values handling") - func testNullValues() async throws { + func nullValues() async throws { let json = """ - { - "stringValue": null, - "intValue": null, - "boolValue": null, - "doubleValue": null - } - """ - + { + "stringValue": null, + "intValue": null, + "boolValue": null, + "doubleValue": null + } + """ + let decoder = JSONDecoder() let data = try #require(json.data(using: .utf8)) - + await confirmation(expectedCount: 1) { confirmation in do { _ = try decoder.decode(Fixture.self, from: data) @@ -72,21 +72,21 @@ struct LosslessValueResilientTests { } } } - + @Test("unconvertible values") - func testUnconvertibleValues() async throws { + func unconvertibleValues() async throws { let json = """ - { - "stringValue": {"key": "value"}, - "intValue": [1, 2, 3], - "boolValue": {"nested": true}, - "doubleValue": ["array"] - } - """ - + { + "stringValue": {"key": "value"}, + "intValue": [1, 2, 3], + "boolValue": {"nested": true}, + "doubleValue": ["array"] + } + """ + let decoder = JSONDecoder() let data = try #require(json.data(using: .utf8)) - + await confirmation(expectedCount: 1) { confirmation in do { _ = try decoder.decode(Fixture.self, from: data) @@ -97,23 +97,23 @@ struct LosslessValueResilientTests { } } } - + @Test("error reporting with JSONDecoder") - func testErrorReporting() async throws { + func errorReporting() async throws { let json = """ - { - "stringValue": {"key": "value"}, - "intValue": [1, 2, 3], - "boolValue": {"nested": true}, - "doubleValue": ["array"] - } - """ - + { + "stringValue": {"key": "value"}, + "intValue": [1, 2, 3], + "boolValue": {"nested": true}, + "doubleValue": ["array"] + } + """ + let decoder = JSONDecoder() let errorReporter = decoder.enableResilientDecodingErrorReporting() - + let data = try #require(json.data(using: .utf8)) - + await confirmation(expectedCount: 1) { confirmation in do { _ = try decoder.decode(Fixture.self, from: data) @@ -123,11 +123,11 @@ struct LosslessValueResilientTests { confirmation() } } - + let errorDigest = errorReporter.flushReportedErrors() - + // Check if errors were reported let digest = try #require(errorDigest) - #expect(digest.errors.count >= 1) // At least 1 error occurred + #expect(digest.errors.count >= 1) // At least 1 error occurred } } diff --git a/Tests/KarrotCodableKitTests/BetterCodable/LossyValue/LossyArrayResilientTests.swift b/Tests/KarrotCodableKitTests/BetterCodable/LossyValue/LossyArrayResilientTests.swift index 776e212..d972c4f 100644 --- a/Tests/KarrotCodableKitTests/BetterCodable/LossyValue/LossyArrayResilientTests.swift +++ b/Tests/KarrotCodableKitTests/BetterCodable/LossyValue/LossyArrayResilientTests.swift @@ -5,139 +5,139 @@ // Created by Elon on 4/9/25. // -import Testing import Foundation +import Testing @testable import KarrotCodableKit @Suite("LossyArray Resilient Decoding") struct LossyArrayResilientTests { struct Fixture: Decodable { - @LossyArray var integers: [Int] - @LossyArray var strings: [String] - struct NestedObject: Decodable, Equatable { let id: Int let name: String } + + @LossyArray var integers: [Int] + @LossyArray var strings: [String] @LossyArray var objects: [NestedObject] } - + @Test("projected value provides error information in DEBUG") - func testProjectedValueProvidesErrorInfo() throws { + func projectedValueProvidesErrorInfo() throws { let json = """ - { - "integers": [1, "invalid", 3, null, 5], - "strings": ["hello", 123, "world", null], - "objects": [ - {"id": 1, "name": "first"}, - {"id": "invalid", "name": "second"}, - {"id": 3, "name": "third"} - ] - } - """ - + { + "integers": [1, "invalid", 3, null, 5], + "strings": ["hello", 123, "world", null], + "objects": [ + {"id": 1, "name": "first"}, + {"id": "invalid", "name": "second"}, + {"id": 3, "name": "third"} + ] + } + """ + let decoder = JSONDecoder() let data = try #require(json.data(using: .utf8)) let fixture = try decoder.decode(Fixture.self, from: data) - + // Verify default behavior #expect(fixture.integers == [1, 3, 5]) #expect(fixture.strings == ["hello", "world"]) #expect(fixture.objects == [ Fixture.NestedObject(id: 1, name: "first"), - Fixture.NestedObject(id: 3, name: "third") + Fixture.NestedObject(id: 3, name: "third"), ]) - + #if DEBUG // Access error info through projected value #expect(fixture.$integers.results.count == 5) #expect(fixture.$integers.errors.count == 2) // "invalid" and null - - // Check success/failure of each element + + /// Check success/failure of each element let intResults = fixture.$integers.results #expect(try intResults[0].get() == 1) #expect(intResults[1].isFailure == true) #expect(try intResults[2].get() == 3) #expect(intResults[3].isFailure == true) #expect(try intResults[4].get() == 5) - + // strings validation #expect(fixture.$strings.results.count == 4) #expect(fixture.$strings.errors.count == 2) // 123 and null - + // objects validation #expect(fixture.$objects.results.count == 3) #expect(fixture.$objects.errors.count == 1) // "invalid" id #endif } - + @Test("error reporting with JSONDecoder") - func testErrorReporting() throws { + func errorReporting() throws { let json = """ - { - "integers": [1, "two", 3], - "strings": ["a", "b", "c"], - "objects": [] - } - """ - + { + "integers": [1, "two", 3], + "strings": ["a", "b", "c"], + "objects": [] + } + """ + let decoder = JSONDecoder() let errorReporter = decoder.enableResilientDecodingErrorReporting() - + let data = try #require(json.data(using: .utf8)) _ = try decoder.decode(Fixture.self, from: data) - + let errorDigest = errorReporter.flushReportedErrors() - + // Check if errors were reported let digest = try #require(errorDigest) #expect(digest.errors.count >= 1) } - + @Test("decode with reportResilientDecodingErrors") - func testDecodeWithReportFlag() throws { + func decodeWithReportFlag() throws { let json = """ - { - "integers": [1, "invalid", 3], - "strings": [], - "objects": [] - } - """ - + { + "integers": [1, "invalid", 3], + "strings": [], + "objects": [] + } + """ + let decoder = JSONDecoder() let data = try #require(json.data(using: .utf8)) - + let (fixture, errorDigest) = try decoder.decode( Fixture.self, from: data, reportResilientDecodingErrors: true ) - + #expect(fixture.integers == [1, 3]) - + #expect(errorDigest != nil) #expect(errorDigest?.errors.count ?? 0 >= 1) } - + @Test("empty array on complete failure") - func testEmptyArrayOnCompleteFailure() throws { + func emptyArrayOnCompleteFailure() throws { let json = """ - { - "integers": "not an array", - "strings": 123, - "objects": null - } - """ - + { + "integers": "not an array", + "strings": 123, + "objects": null + } + """ + let decoder = JSONDecoder() let data = try #require(json.data(using: .utf8)) - + let fixture = try decoder.decode(Fixture.self, from: data) - + #expect(fixture.integers == []) #expect(fixture.strings == []) #expect(fixture.objects == []) - + #if DEBUG // Error info when entire array decoding fails #expect(fixture.$integers.error != nil) @@ -146,13 +146,3 @@ struct LossyArrayResilientTests { #endif } } - -// Result extension for testing -extension Result { - var isFailure: Bool { - switch self { - case .success: return false - case .failure: return true - } - } -} diff --git a/Tests/KarrotCodableKitTests/BetterCodable/LossyValue/LossyArrayTests.swift b/Tests/KarrotCodableKitTests/BetterCodable/LossyValue/LossyArrayTests.swift index 0230c20..0fab230 100644 --- a/Tests/KarrotCodableKitTests/BetterCodable/LossyValue/LossyArrayTests.swift +++ b/Tests/KarrotCodableKitTests/BetterCodable/LossyValue/LossyArrayTests.swift @@ -80,4 +80,3 @@ final class LossyArrayTests: XCTestCase { XCTAssertEqual(fixture2.theValues, [Date(timeIntervalSince1970: 123)]) } } - diff --git a/Tests/KarrotCodableKitTests/BetterCodable/LossyValue/LossyDictionaryResilientTests.swift b/Tests/KarrotCodableKitTests/BetterCodable/LossyValue/LossyDictionaryResilientTests.swift index ac2dd32..08ee5ca 100644 --- a/Tests/KarrotCodableKitTests/BetterCodable/LossyValue/LossyDictionaryResilientTests.swift +++ b/Tests/KarrotCodableKitTests/BetterCodable/LossyValue/LossyDictionaryResilientTests.swift @@ -5,8 +5,8 @@ // Created by Elon on 4/9/25. // -import Testing import Foundation +import Testing @testable import KarrotCodableKit @Suite("LossyDictionary Resilient Decoding") @@ -14,121 +14,116 @@ struct LossyDictionaryResilientTests { struct Fixture: Decodable { @LossyDictionary var stringDict: [String: Int] @LossyDictionary var intDict: [Int: String] - + struct NestedObject: Decodable, Equatable { let id: Int let name: String } + @LossyDictionary var objectDict: [String: NestedObject] } - + @Test("projected value provides error information for each failed key-value pair") - func testProjectedValueProvidesErrorInfo() throws { + func projectedValueProvidesErrorInfo() throws { let json = """ - { - "stringDict": { - "one": 1, - "two": "invalid", - "three": 3, - "four": null - }, - "intDict": { - "1": "first", - "2": "second", - "3": "third", - "invalid": "should be ignored" - }, - "objectDict": { - "obj1": {"id": 1, "name": "first"}, - "obj2": {"id": "invalid", "name": "second"}, - "obj3": {"id": 3, "name": "third"} + { + "stringDict": { + "one": 1, + "two": "invalid", + "three": 3, + "four": null + }, + "intDict": { + "1": "first", + "2": "second", + "3": "third", + "invalid": "should be ignored" + }, + "objectDict": { + "obj1": {"id": 1, "name": "first"}, + "obj2": {"id": "invalid", "name": "second"}, + "obj3": {"id": 3, "name": "third"} + } } - } - """ - + """ + let decoder = JSONDecoder() let data = try #require(json.data(using: .utf8)) let fixture = try decoder.decode(Fixture.self, from: data) - + // Verify default behavior - only valid key-value pairs included #expect(fixture.stringDict == ["one": 1, "three": 3]) #expect(fixture.intDict == [1: "first", 2: "second", 3: "third"]) // All Int keys are valid #expect(fixture.objectDict == [ "obj1": Fixture.NestedObject(id: 1, name: "first"), - "obj3": Fixture.NestedObject(id: 3, name: "third") + "obj3": Fixture.NestedObject(id: 3, name: "third"), ]) - + #if DEBUG // Access error info through projected value #expect(fixture.$stringDict.results.count == 4) #expect(fixture.$stringDict.errors.count == 2) // "invalid" and null - - // Check success/failure of each key + + /// Check success/failure of each key let stringResults = fixture.$stringDict.results - #expect(stringResults["one"] != nil) - if let result = stringResults["one"], case .success(let value) = result { - #expect(value == 1) - } - #expect(stringResults["two"] != nil) - if let result = stringResults["two"], case .failure = result { - // Expected failure - } - - // intDict validation - Int key type + #expect(stringResults["one"]?.success == 1) + #expect(stringResults["two"]?.isFailure == true) + + // intDict validation - Int key type #expect(fixture.$intDict.errors.count == 0) // All keys are valid - + // objectDict validation #expect(fixture.$objectDict.results.count == 3) #expect(fixture.$objectDict.errors.count == 1) // "invalid" id #endif } - + @Test("error reporting with JSONDecoder") - func testErrorReporting() throws { + func errorReporting() throws { let json = """ - { - "stringDict": { - "a": "not a number", - "b": 2, - "c": false - }, - "intDict": {}, - "objectDict": {} - } - """ - + { + "stringDict": { + "a": "not a number", + "b": 2, + "c": false + }, + "intDict": {}, + "objectDict": {} + } + """ + let decoder = JSONDecoder() let errorReporter = decoder.enableResilientDecodingErrorReporting() - + let data = try #require(json.data(using: .utf8)) _ = try decoder.decode(Fixture.self, from: data) - + let errorDigest = errorReporter.flushReportedErrors() - + // Check if errors were reported let digest = try #require(errorDigest) #expect(digest.errors.count >= 2) // Errors for keys "a" and "c" } - + @Test("complete failure results in empty dictionary") - func testCompleteFailure() throws { + func completeFailure() throws { let json = """ - { - "stringDict": "not a dictionary", - "intDict": 123, - "objectDict": null - } - """ - + { + "stringDict": "not a dictionary", + "intDict": 123, + "objectDict": null + } + """ + let decoder = JSONDecoder() let data = try #require(json.data(using: .utf8)) - + let fixture = try decoder.decode(Fixture.self, from: data) - + #expect(fixture.stringDict == [:]) #expect(fixture.intDict == [:]) #expect(fixture.objectDict == [:]) - + #if DEBUG // Error info when entire dictionary decoding fails #expect(fixture.$stringDict.error != nil) @@ -136,21 +131,25 @@ struct LossyDictionaryResilientTests { #expect(fixture.$objectDict.error == nil) // null is not an error #endif } - + @Test("missing keys result in empty dictionary") - func testMissingKeys() throws { + func missingKeys() throws { let json = "{}" - + let decoder = JSONDecoder() let data = try #require(json.data(using: .utf8)) - + let fixture = try decoder.decode(Fixture.self, from: data) - + #expect(fixture.stringDict == [:]) #expect(fixture.intDict == [:]) #expect(fixture.objectDict == [:]) - + #if DEBUG + #expect(fixture.$stringDict.outcome == .keyNotFound) + #expect(fixture.$intDict.outcome == .keyNotFound) + #expect(fixture.$objectDict.outcome == .keyNotFound) + // No error when key is missing #expect(fixture.$stringDict.error == nil) #expect(fixture.$intDict.error == nil) diff --git a/Tests/KarrotCodableKitTests/PolymorphicCodable/ArrayValue/PolymorphicArrayValueResilientTests.swift b/Tests/KarrotCodableKitTests/PolymorphicCodable/ArrayValue/PolymorphicArrayValueResilientTests.swift index e22ead7..d56c2e8 100644 --- a/Tests/KarrotCodableKitTests/PolymorphicCodable/ArrayValue/PolymorphicArrayValueResilientTests.swift +++ b/Tests/KarrotCodableKitTests/PolymorphicCodable/ArrayValue/PolymorphicArrayValueResilientTests.swift @@ -92,7 +92,7 @@ struct PolymorphicArrayValueResilientTests { } """ - // when/Then + // when/then let data = try #require(json.data(using: .utf8)) #expect(throws: Error.self) { _ = try JSONDecoder().decode(Fixture.self, from: data) @@ -106,7 +106,7 @@ struct PolymorphicArrayValueResilientTests { {} """ - // when/Then + // when/then let data = try #require(json.data(using: .utf8)) #expect(throws: Error.self) { _ = try JSONDecoder().decode(Fixture.self, from: data) @@ -122,7 +122,7 @@ struct PolymorphicArrayValueResilientTests { } """ - // when/Then + // when/then let data = try #require(json.data(using: .utf8)) #expect(throws: Error.self) { _ = try JSONDecoder().decode(Fixture.self, from: data) diff --git a/Tests/KarrotCodableKitTests/PolymorphicCodable/ArrayValue/PolymorphicLossyArrayValueResilientTests.swift b/Tests/KarrotCodableKitTests/PolymorphicCodable/ArrayValue/PolymorphicLossyArrayValueResilientTests.swift index 2d09c19..8de0e5f 100644 --- a/Tests/KarrotCodableKitTests/PolymorphicCodable/ArrayValue/PolymorphicLossyArrayValueResilientTests.swift +++ b/Tests/KarrotCodableKitTests/PolymorphicCodable/ArrayValue/PolymorphicLossyArrayValueResilientTests.swift @@ -65,10 +65,7 @@ struct PolymorphicLossyArrayValueResilientTests { #expect(result.$notices.outcome == .decodedSuccessfully) #expect(result.$notices.error == nil) #expect(result.$notices.results.count == 2) - #expect(result.$notices.results.allSatisfy { result in - if case .success = result { return true } - return false - }) + #expect(try result.$notices.results.allSatisfy(\.isSuccess)) #endif } @@ -114,24 +111,12 @@ struct PolymorphicLossyArrayValueResilientTests { #expect(result.$notices.results.count == 6) // Only first and third elements succeed - if case .success = result.$notices.results[0] {} else { - Issue.record("Expected success at index 0") - } - if case .failure = result.$notices.results[1] {} else { - Issue.record("Expected failure at index 1") - } - if case .success = result.$notices.results[2] {} else { - Issue.record("Expected success at index 2") - } - if case .failure = result.$notices.results[3] {} else { - Issue.record("Expected failure at index 3") - } - if case .failure = result.$notices.results[4] {} else { - Issue.record("Expected failure at index 4") - } - if case .failure = result.$notices.results[5] {} else { - Issue.record("Expected failure at index 5") - } + #expect(result.$notices.results[0].isSuccess) + #expect(result.$notices.results[1].isFailure) + #expect(result.$notices.results[2].isSuccess) + #expect(result.$notices.results[3].isFailure) + #expect(result.$notices.results[4].isFailure) + #expect(result.$notices.results[5].isFailure) #endif } diff --git a/Tests/KarrotCodableKitTests/PolymorphicCodable/Enum/PolymorphicEnumDecodableTests.swift b/Tests/KarrotCodableKitTests/PolymorphicCodable/Enum/PolymorphicEnumDecodableTests.swift index 73bf549..55da99f 100644 --- a/Tests/KarrotCodableKitTests/PolymorphicCodable/Enum/PolymorphicEnumDecodableTests.swift +++ b/Tests/KarrotCodableKitTests/PolymorphicCodable/Enum/PolymorphicEnumDecodableTests.swift @@ -72,4 +72,3 @@ class PolymorphicEnumDecodableTests: XCTestCase { } } } - diff --git a/Tests/KarrotCodableKitTests/PolymorphicCodable/OptionalPolymorphicArrayValueResilientTests.swift b/Tests/KarrotCodableKitTests/PolymorphicCodable/OptionalPolymorphicArrayValueResilientTests.swift index 7f93743..c96d1e4 100644 --- a/Tests/KarrotCodableKitTests/PolymorphicCodable/OptionalPolymorphicArrayValueResilientTests.swift +++ b/Tests/KarrotCodableKitTests/PolymorphicCodable/OptionalPolymorphicArrayValueResilientTests.swift @@ -16,23 +16,23 @@ import KarrotCodableKit struct ResilientOptionalPolymorphicArrayDummyResponse { @DummyNotice.OptionalPolymorphicArray var notices: [any DummyNotice]? - + @DummyNotice.OptionalPolymorphicArray var notices2: [any DummyNotice]? - + @DummyNotice.OptionalPolymorphicArray var notices3: [any DummyNotice]? - + @DummyNotice.OptionalPolymorphicArray var notices4: [any DummyNotice]? } struct OptionalPolymorphicArrayValueResilientTests { - + // MARK: - Successful Decoding Tests - + @Test - func testDecodesValidArrayWithoutErrors() throws { + func decodesValidArrayWithoutErrors() throws { // given let jsonData = #""" { @@ -50,93 +50,105 @@ struct OptionalPolymorphicArrayValueResilientTests { ] } """# - + // when - let result = try JSONDecoder().decode(ResilientOptionalPolymorphicArrayDummyResponse.self, from: Data(jsonData.utf8)) - + let result = try JSONDecoder().decode( + ResilientOptionalPolymorphicArrayDummyResponse.self, + from: Data(jsonData.utf8) + ) + // then let notices = try #require(result.notices) #expect(notices.count == 2) - + let firstNotice = try #require(notices[0] as? DummyCallout) #expect(firstNotice.description == "test1") #expect(firstNotice.icon == "test_icon1") #expect(firstNotice.type == .callout) - + let secondNotice = try #require(notices[1] as? DummyActionableCallout) #expect(secondNotice.description == "test2") #expect(secondNotice.action == URL(string: "https://example.com")) #expect(secondNotice.type == .actionableCallout) - + #if DEBUG #expect(result.$notices.error == nil) #endif } - + @Test - func testDecodesEmptyArrayWithoutErrors() throws { + func decodesEmptyArrayWithoutErrors() throws { // given let jsonData = #""" { "notices": [] } """# - + // when - let result = try JSONDecoder().decode(ResilientOptionalPolymorphicArrayDummyResponse.self, from: Data(jsonData.utf8)) - + let result = try JSONDecoder().decode( + ResilientOptionalPolymorphicArrayDummyResponse.self, + from: Data(jsonData.utf8) + ) + // then let notices = try #require(result.notices) #expect(notices.isEmpty) - + #if DEBUG #expect(result.$notices.error == nil) #endif } - + @Test - func testDecodesNullValueWithoutErrors() throws { + func decodesNullValueWithoutErrors() throws { // given let jsonData = #""" { "notices": null } """# - + // when - let result = try JSONDecoder().decode(ResilientOptionalPolymorphicArrayDummyResponse.self, from: Data(jsonData.utf8)) - + let result = try JSONDecoder().decode( + ResilientOptionalPolymorphicArrayDummyResponse.self, + from: Data(jsonData.utf8) + ) + // then #expect(result.notices == nil) - + #if DEBUG #expect(result.$notices.error == nil) #endif } - + @Test - func testDecodesMissingKeyWithoutErrors() throws { + func decodesMissingKeyWithoutErrors() throws { // given let jsonData = #""" { } """# - + // when - let result = try JSONDecoder().decode(ResilientOptionalPolymorphicArrayDummyResponse.self, from: Data(jsonData.utf8)) - + let result = try JSONDecoder().decode( + ResilientOptionalPolymorphicArrayDummyResponse.self, + from: Data(jsonData.utf8) + ) + // then #expect(result.notices == nil) - + #if DEBUG #expect(result.$notices.error == nil) #endif } - + // MARK: - Error Reporting Tests - + @Test - func testReportsErrorWhenInvalidElementInArray() throws { + func reportsErrorWhenInvalidElementInArray() throws { // given - Missing required 'description' field in second element let jsonData = #""" { @@ -151,47 +163,47 @@ struct OptionalPolymorphicArrayValueResilientTests { ] } """# - + let decoder = JSONDecoder() let errorReporter = decoder.enableResilientDecodingErrorReporting() - + // when & then #expect(throws: Error.self) { _ = try decoder.decode(ResilientOptionalPolymorphicArrayDummyResponse.self, from: Data(jsonData.utf8)) } - + // Verify errors were reported let errorDigest = errorReporter.flushReportedErrors() #expect(errorDigest != nil) } - + @Test - func testReportsErrorWhenNotArrayType() throws { + func reportsErrorWhenNotArrayType() throws { // given let jsonData = #""" { "notices": "not an array" } """# - + let decoder = JSONDecoder() let errorReporter = decoder.enableResilientDecodingErrorReporting() - + // when & then #expect(throws: Error.self) { _ = try decoder.decode(ResilientOptionalPolymorphicArrayDummyResponse.self, from: Data(jsonData.utf8)) } - + // Verify errors were reported let errorDigest = errorReporter.flushReportedErrors() #expect(errorDigest != nil) } - + // MARK: - Projected Value Tests - + #if DEBUG @Test - func testProjectedValueReturnsNilErrorForSuccessfulDecoding() throws { + func projectedValueReturnsNilErrorForSuccessfulDecoding() throws { // given let jsonData = #""" { @@ -203,53 +215,62 @@ struct OptionalPolymorphicArrayValueResilientTests { ] } """# - + // when - let result = try JSONDecoder().decode(ResilientOptionalPolymorphicArrayDummyResponse.self, from: Data(jsonData.utf8)) - + let result = try JSONDecoder().decode( + ResilientOptionalPolymorphicArrayDummyResponse.self, + from: Data(jsonData.utf8) + ) + // then #expect(result.$notices.error == nil) #expect(result.$notices.outcome == .decodedSuccessfully) } - + @Test - func testProjectedValueReturnsOutcomeForNilValue() throws { + func projectedValueReturnsOutcomeForNilValue() throws { // given let jsonData = #""" { "notices": null } """# - + // when - let result = try JSONDecoder().decode(ResilientOptionalPolymorphicArrayDummyResponse.self, from: Data(jsonData.utf8)) - + let result = try JSONDecoder().decode( + ResilientOptionalPolymorphicArrayDummyResponse.self, + from: Data(jsonData.utf8) + ) + // then #expect(result.$notices.error == nil) #expect(result.$notices.outcome == .valueWasNil) } - + @Test - func testProjectedValueReturnsOutcomeForMissingKey() throws { + func projectedValueReturnsOutcomeForMissingKey() throws { // given let jsonData = #""" { } """# - + // when - let result = try JSONDecoder().decode(ResilientOptionalPolymorphicArrayDummyResponse.self, from: Data(jsonData.utf8)) - + let result = try JSONDecoder().decode( + ResilientOptionalPolymorphicArrayDummyResponse.self, + from: Data(jsonData.utf8) + ) + // then #expect(result.$notices.error == nil) #expect(result.$notices.outcome == .keyNotFound) } #endif - + // MARK: - Multiple Properties Test - + @Test - func testDecodesMultiplePropertiesCorrectly() throws { + func decodesMultiplePropertiesCorrectly() throws { // given let jsonData = #""" { @@ -263,20 +284,23 @@ struct OptionalPolymorphicArrayValueResilientTests { "notices3": [] } """# - + // when - let result = try JSONDecoder().decode(ResilientOptionalPolymorphicArrayDummyResponse.self, from: Data(jsonData.utf8)) - + let result = try JSONDecoder().decode( + ResilientOptionalPolymorphicArrayDummyResponse.self, + from: Data(jsonData.utf8) + ) + // then let notices = try #require(result.notices) #expect(notices.count == 1) #expect(result.notices2 == nil) - + let notices3 = try #require(result.notices3) #expect(notices3.isEmpty) - + #expect(result.notices4 == nil) // missing key - + #if DEBUG #expect(result.$notices.outcome == .decodedSuccessfully) #expect(result.$notices2.outcome == .valueWasNil) @@ -284,4 +308,4 @@ struct OptionalPolymorphicArrayValueResilientTests { #expect(result.$notices4.outcome == .keyNotFound) #endif } -} \ No newline at end of file +} diff --git a/Tests/KarrotCodableKitTests/PolymorphicCodable/OptionalPolymorphicArrayValueTests.swift b/Tests/KarrotCodableKitTests/PolymorphicCodable/OptionalPolymorphicArrayValueTests.swift index 86a9504..977daf6 100644 --- a/Tests/KarrotCodableKitTests/PolymorphicCodable/OptionalPolymorphicArrayValueTests.swift +++ b/Tests/KarrotCodableKitTests/PolymorphicCodable/OptionalPolymorphicArrayValueTests.swift @@ -13,7 +13,7 @@ import KarrotCodableKit struct OptionalPolymorphicArrayValueTests { @Test - func testDecodingOptionalPolymorphicArrayValue() throws { + func decodingOptionalPolymorphicArrayValue() throws { // given let jsonData = #""" { @@ -39,22 +39,22 @@ struct OptionalPolymorphicArrayValueTests { // then let notices1 = try #require(result.notices1) #expect(notices1.count == 2) - + let firstNotice = try #require(notices1[0] as? DummyCallout) #expect(firstNotice.description == "test1") #expect(firstNotice.icon == "test_icon1") #expect(firstNotice.type == .callout) - + let secondNotice = try #require(notices1[1] as? DummyActionableCallout) #expect(secondNotice.description == "test2") #expect(secondNotice.action == URL(string: "https://example.com")) #expect(secondNotice.type == .actionableCallout) - + #expect(result.notices2 == nil) } @Test - func testEncodingOptionalPolymorphicArrayValue() throws { + func encodingOptionalPolymorphicArrayValue() throws { // given let response = OptionalPolymorphicArrayDummyResponse( notices1: [ @@ -92,7 +92,7 @@ struct OptionalPolymorphicArrayValueTests { } @Test - func testDecodingOptionalPolymorphicArrayValueWithEmptyArray() throws { + func decodingOptionalPolymorphicArrayValueWithEmptyArray() throws { // given let jsonData = #""" { @@ -111,7 +111,7 @@ struct OptionalPolymorphicArrayValueTests { } @Test - func testDecodingOptionalPolymorphicArrayValueWithMissingKey() throws { + func decodingOptionalPolymorphicArrayValueWithMissingKey() throws { // given let jsonData = #""" { @@ -130,7 +130,7 @@ struct OptionalPolymorphicArrayValueTests { // then #expect(result.notices1 == nil) - + let notices2 = try #require(result.notices2) #expect(notices2.count == 1) let notice = try #require(notices2[0] as? DummyCallout) @@ -138,7 +138,7 @@ struct OptionalPolymorphicArrayValueTests { } @Test - func testDecodingOptionalPolymorphicArrayValueWithInvalidElement() throws { + func decodingOptionalPolymorphicArrayValueWithInvalidElement() throws { // given - Array with one invalid element (missing required 'description' property) let jsonData = #""" { @@ -163,7 +163,7 @@ struct OptionalPolymorphicArrayValueTests { } @Test - func testDecodingOptionalPolymorphicArrayValueWhenNotArray() throws { + func decodingOptionalPolymorphicArrayValueWhenNotArray() throws { // given - Value is not an array let jsonData = #""" { @@ -182,7 +182,7 @@ struct OptionalPolymorphicArrayValueTests { } @Test - func testEncodingDecodingNilValues() throws { + func encodingDecodingNilValues() throws { // given let response = OptionalPolymorphicArrayDummyResponse( notices1: nil, @@ -211,4 +211,4 @@ struct OptionalPolymorphicArrayValueTests { #expect(decodedResponse.notices1 == nil) #expect(decodedResponse.notices2 == nil) } -} \ No newline at end of file +} diff --git a/Tests/KarrotCodableKitTests/PolymorphicCodable/OptionalValue/OptionalPolymorphicValueResilientTests.swift b/Tests/KarrotCodableKitTests/PolymorphicCodable/OptionalValue/OptionalPolymorphicValueResilientTests.swift index a32ce8f..032a0a8 100644 --- a/Tests/KarrotCodableKitTests/PolymorphicCodable/OptionalValue/OptionalPolymorphicValueResilientTests.swift +++ b/Tests/KarrotCodableKitTests/PolymorphicCodable/OptionalValue/OptionalPolymorphicValueResilientTests.swift @@ -91,7 +91,7 @@ struct OptionalPolymorphicValueResilientTests { } """ - // when/Then + // when/then let data = try #require(json.data(using: .utf8)) #expect(throws: Error.self) { _ = try JSONDecoder().decode(Fixture.self, from: data) @@ -107,7 +107,7 @@ struct OptionalPolymorphicValueResilientTests { } """ - // when/Then + // when/then let data = try #require(json.data(using: .utf8)) #expect(throws: Error.self) { _ = try JSONDecoder().decode(Fixture.self, from: data) diff --git a/Tests/KarrotCodableKitTests/PolymorphicCodable/TestDoubles/UnnestedPolymorphicCodableDummy.swift b/Tests/KarrotCodableKitTests/PolymorphicCodable/TestDoubles/UnnestedPolymorphicCodableDummy.swift index baf2611..85ea3b9 100644 --- a/Tests/KarrotCodableKitTests/PolymorphicCodable/TestDoubles/UnnestedPolymorphicCodableDummy.swift +++ b/Tests/KarrotCodableKitTests/PolymorphicCodable/TestDoubles/UnnestedPolymorphicCodableDummy.swift @@ -55,7 +55,6 @@ struct OptionalViewItem: ViewItem { let url: URL? } - // MARK: - Edge Case Test Structs @UnnestedPolymorphicCodable( diff --git a/Tests/KarrotCodableKitTests/PolymorphicCodable/Value/PolymorphicValueResilientTests.swift b/Tests/KarrotCodableKitTests/PolymorphicCodable/Value/PolymorphicValueResilientTests.swift index 77ad3c0..0049846 100644 --- a/Tests/KarrotCodableKitTests/PolymorphicCodable/Value/PolymorphicValueResilientTests.swift +++ b/Tests/KarrotCodableKitTests/PolymorphicCodable/Value/PolymorphicValueResilientTests.swift @@ -5,8 +5,8 @@ // Created by Elon on 4/9/25. // -import Testing import Foundation +import Testing @testable import KarrotCodableKit @Suite("PolymorphicValue Resilient Decoding") @@ -14,110 +14,111 @@ struct PolymorphicValueResilientTests { struct Fixture: Decodable { @DummyNotice.Polymorphic var notice: any DummyNotice } - + @Test("projected value provides error information") - func testProjectedValueProvidesErrorInfo() throws { + func projectedValueProvidesErrorInfo() throws { // given let json = """ - { - "notice": { - "type": "callout", - "description": "test description", - "icon": "test_icon" + { + "notice": { + "type": "callout", + "description": "test description", + "icon": "test_icon" + } } - } - """ - + """ + // when let decoder = JSONDecoder() let data = try #require(json.data(using: .utf8)) let fixture = try decoder.decode(Fixture.self, from: data) - + // then // Verify basic behavior #expect(fixture.notice.description == "test description") #expect((fixture.notice as? DummyCallout)?.icon == "test_icon") - + #if DEBUG // Access success info via projected value #expect(fixture.$notice.outcome == .decodedSuccessfully) #endif } - + @Test("unknown type handling with fallback") - func testUnknownType() throws { + func unknownType() throws { // given let json = """ - { - "notice": { - "type": "unknown-type", - "description": "test description" + { + "notice": { + "type": "unknown-type", + "description": "test description" + } } - } - """ - + """ + // when let decoder = JSONDecoder() let data = try #require(json.data(using: .utf8)) - + // DummyNotice has fallback type configured so should succeed let fixture = try decoder.decode(Fixture.self, from: data) - + // then // Verify decoded as fallback type #expect(fixture.notice is DummyUndefinedCallout) #expect(fixture.notice.description == "test description") - + #if DEBUG // Access success info via projected value #expect(fixture.$notice.outcome == .decodedSuccessfully) #endif } - + @Test("null values handling") - func testNullValues() async throws { + func nullValues() async throws { // given let json = """ - { - "notice": null - } - """ - - // when / then + { + "notice": null + } + """ + + // when/then let decoder = JSONDecoder() let data = try #require(json.data(using: .utf8)) - + await confirmation(expectedCount: 1) { confirmation in do { _ = try decoder.decode(Fixture.self, from: data) Issue.record("Should have thrown") } catch { - // Cannot handle null values + // Verify specific error type if possible + // e.g., check for DecodingError.valueNotFound or similar confirmation() } } } - + @Test("error reporting with JSONDecoder") - func testErrorReporting() async throws { + func errorReporting() async throws { // given let json = """ - { - "notice": { - "type": "dismissible-callout", - "description": "test", - "title": "title", - "key": 123 + { + "notice": { + "type": "dismissible-callout", + "description": "test", + "title": "title", + "key": 123 + } } - } - """ - + """ + // when let decoder = JSONDecoder() let errorReporter = decoder.enableResilientDecodingErrorReporting() - + let data = try #require(json.data(using: .utf8)) - + // then await confirmation(expectedCount: 1) { confirmation in do { @@ -128,13 +129,12 @@ struct PolymorphicValueResilientTests { confirmation() } } - + // then let errorDigest = errorReporter.flushReportedErrors() - + // Check if error was reported let digest = try #require(errorDigest) #expect(digest.errors.count >= 1) } } - diff --git a/Tests/KarrotCodableKitTests/PolymorphicCodable/Value/PolymorphicValueTests.swift b/Tests/KarrotCodableKitTests/PolymorphicCodable/Value/PolymorphicValueTests.swift index b12838f..09f4801 100644 --- a/Tests/KarrotCodableKitTests/PolymorphicCodable/Value/PolymorphicValueTests.swift +++ b/Tests/KarrotCodableKitTests/PolymorphicCodable/Value/PolymorphicValueTests.swift @@ -176,4 +176,3 @@ extension PolymorphicValueTests { XCTAssertEqual(result.notices[2].type, .undefinedCallout) } } - diff --git a/Tests/KarrotCodableMacrosTests/CustomCodableMacros/CustomCodableMacroTests.swift b/Tests/KarrotCodableMacrosTests/CustomCodableMacros/CustomCodableMacroTests.swift index 1cc6882..0061c20 100644 --- a/Tests/KarrotCodableMacrosTests/CustomCodableMacros/CustomCodableMacroTests.swift +++ b/Tests/KarrotCodableMacrosTests/CustomCodableMacros/CustomCodableMacroTests.swift @@ -89,7 +89,6 @@ final class CustomCodableMacroTests: XCTestCase { } } - // MARK: - Nested Codable extension CustomCodableMacroTests { @@ -188,7 +187,6 @@ extension CustomCodableMacroTests { } } - // MARK: - CodableKey extension CustomCodableMacroTests { @@ -275,7 +273,6 @@ extension CustomCodableMacroTests { } } - // MARK: - cannotApplyToEnum extension CustomCodableMacroTests { @@ -305,7 +302,7 @@ extension CustomCodableMacroTests { message: "`@CustomCodable`, `@CustomEncodable`, `@CustomDecodable` cannot be applied to enum", line: 1, column: 1 - ) + ), ], macros: testMacros, indentationWidth: .spaces(2) diff --git a/Tests/KarrotCodableMacrosTests/CustomCodableMacros/CustomDecodableMacroTests.swift b/Tests/KarrotCodableMacrosTests/CustomCodableMacros/CustomDecodableMacroTests.swift index 1e28eb8..f804cb0 100644 --- a/Tests/KarrotCodableMacrosTests/CustomCodableMacros/CustomDecodableMacroTests.swift +++ b/Tests/KarrotCodableMacrosTests/CustomCodableMacros/CustomDecodableMacroTests.swift @@ -89,7 +89,6 @@ final class CustomDecodableMacroTests: XCTestCase { } } - // MARK: - Nested Decodable extension CustomDecodableMacroTests { @@ -188,7 +187,6 @@ extension CustomDecodableMacroTests { } } - // MARK: - CodableKey extension CustomDecodableMacroTests { @@ -275,7 +273,6 @@ extension CustomDecodableMacroTests { } } - // MARK: - cannotApplyToEnum extension CustomDecodableMacroTests { @@ -305,7 +302,7 @@ extension CustomDecodableMacroTests { message: "`@CustomCodable`, `@CustomEncodable`, `@CustomDecodable` cannot be applied to enum", line: 1, column: 1 - ) + ), ], macros: testMacros, indentationWidth: .spaces(2) diff --git a/Tests/KarrotCodableMacrosTests/CustomCodableMacros/CustomEncodableMacroTests.swift b/Tests/KarrotCodableMacrosTests/CustomCodableMacros/CustomEncodableMacroTests.swift index 8642829..ac44004 100644 --- a/Tests/KarrotCodableMacrosTests/CustomCodableMacros/CustomEncodableMacroTests.swift +++ b/Tests/KarrotCodableMacrosTests/CustomCodableMacros/CustomEncodableMacroTests.swift @@ -89,7 +89,6 @@ final class CustomEncodableMacroTests: XCTestCase { } } - // MARK: - Nested Encodable extension CustomEncodableMacroTests { @@ -188,7 +187,6 @@ extension CustomEncodableMacroTests { } } - // MARK: - CodableKey extension CustomEncodableMacroTests { @@ -275,7 +273,6 @@ extension CustomEncodableMacroTests { } } - // MARK: - cannotApplyToEnum extension CustomEncodableMacroTests { @@ -305,7 +302,7 @@ extension CustomEncodableMacroTests { message: "`@CustomCodable`, `@CustomEncodable`, `@CustomDecodable` cannot be applied to enum", line: 1, column: 1 - ) + ), ], macros: testMacros, indentationWidth: .spaces(2) diff --git a/Tests/KarrotCodableMacrosTests/PolymorphicCodableMacrosTests/PolymorphicCodableMacroTests.swift b/Tests/KarrotCodableMacrosTests/PolymorphicCodableMacrosTests/PolymorphicCodableMacroTests.swift index 90491b9..d43c24e 100644 --- a/Tests/KarrotCodableMacrosTests/PolymorphicCodableMacrosTests/PolymorphicCodableMacroTests.swift +++ b/Tests/KarrotCodableMacrosTests/PolymorphicCodableMacrosTests/PolymorphicCodableMacroTests.swift @@ -101,7 +101,7 @@ final class PolymorphicCodableMacroTests: XCTestCase { message: "Invalid polymorphic identifier: expected a non-empty string.", line: 1, column: 1 - ) + ), ], macros: testMacros, indentationWidth: .spaces(2) diff --git a/Tests/KarrotCodableMacrosTests/PolymorphicCodableMacrosTests/PolymorphicCodableStrategyProvidingMacroTests.swift b/Tests/KarrotCodableMacrosTests/PolymorphicCodableMacrosTests/PolymorphicCodableStrategyProvidingMacroTests.swift index acf743a..0b611ce 100644 --- a/Tests/KarrotCodableMacrosTests/PolymorphicCodableMacrosTests/PolymorphicCodableStrategyProvidingMacroTests.swift +++ b/Tests/KarrotCodableMacrosTests/PolymorphicCodableMacrosTests/PolymorphicCodableStrategyProvidingMacroTests.swift @@ -132,7 +132,7 @@ final class PolymorphicCodableStrategyProvidingMacroTests: XCTestCase { ) } } - + extension Notice { public typealias Polymorphic = PolymorphicValue public typealias OptionalPolymorphic = OptionalPolymorphicValue diff --git a/Tests/KarrotCodableMacrosTests/PolymorphicCodableMacrosTests/PolymorphicDeodableMacroTests.swift b/Tests/KarrotCodableMacrosTests/PolymorphicCodableMacrosTests/PolymorphicDeodableMacroTests.swift index 5b03e1e..24952e0 100644 --- a/Tests/KarrotCodableMacrosTests/PolymorphicCodableMacrosTests/PolymorphicDeodableMacroTests.swift +++ b/Tests/KarrotCodableMacrosTests/PolymorphicCodableMacrosTests/PolymorphicDeodableMacroTests.swift @@ -104,7 +104,7 @@ final class PolymorphicDecodableMacroTests: XCTestCase { message: "Invalid polymorphic identifier: expected a non-empty string.", line: 1, column: 1 - ) + ), ], macros: testMacros, indentationWidth: .spaces(2) @@ -114,4 +114,3 @@ final class PolymorphicDecodableMacroTests: XCTestCase { #endif } } - diff --git a/Tests/KarrotCodableMacrosTests/PolymorphicCodableMacrosTests/PolymorphicEncodableMacroTests.swift b/Tests/KarrotCodableMacrosTests/PolymorphicCodableMacrosTests/PolymorphicEncodableMacroTests.swift index 7fc49eb..a69bc98 100644 --- a/Tests/KarrotCodableMacrosTests/PolymorphicCodableMacrosTests/PolymorphicEncodableMacroTests.swift +++ b/Tests/KarrotCodableMacrosTests/PolymorphicCodableMacrosTests/PolymorphicEncodableMacroTests.swift @@ -104,7 +104,7 @@ final class PolymorphicEncodableMacroTests: XCTestCase { message: "Invalid polymorphic identifier: expected a non-empty string.", line: 1, column: 1 - ) + ), ], macros: testMacros, indentationWidth: .spaces(2) diff --git a/Tests/KarrotCodableMacrosTests/PolymorphicEnumCodableMacroTests/PolymorphicEnumCodableMacroTests.swift b/Tests/KarrotCodableMacrosTests/PolymorphicEnumCodableMacroTests/PolymorphicEnumCodableMacroTests.swift index 2657221..60e9104 100644 --- a/Tests/KarrotCodableMacrosTests/PolymorphicEnumCodableMacroTests/PolymorphicEnumCodableMacroTests.swift +++ b/Tests/KarrotCodableMacrosTests/PolymorphicEnumCodableMacroTests/PolymorphicEnumCodableMacroTests.swift @@ -165,7 +165,7 @@ final class PolymorphicEnumCodableMacroTests: XCTestCase { message: "`@PolymorphicEnumCodable` can only be attached to enums", line: 1, column: 1 - ) + ), ], macros: testMacros, indentationWidth: .spaces(2) @@ -198,7 +198,7 @@ final class PolymorphicEnumCodableMacroTests: XCTestCase { message: "Invalid polymorphic identifier: expected a non-empty string.", line: 1, column: 1 - ) + ), ], macros: testMacros, indentationWidth: .spaces(2) @@ -231,7 +231,7 @@ final class PolymorphicEnumCodableMacroTests: XCTestCase { message: "Polymorphic Enum cases can only have one associated value", line: 1, column: 1 - ) + ), ], macros: testMacros, indentationWidth: .spaces(2) @@ -264,7 +264,7 @@ final class PolymorphicEnumCodableMacroTests: XCTestCase { message: "Polymorphic Enum cases should have one associated value", line: 1, column: 1 - ) + ), ], macros: testMacros, indentationWidth: .spaces(2) @@ -275,7 +275,6 @@ final class PolymorphicEnumCodableMacroTests: XCTestCase { } } - // MARK: - fallbackCaseName extension PolymorphicEnumCodableMacroTests { @@ -368,7 +367,7 @@ extension PolymorphicEnumCodableMacroTests { message: "Missing fallback case: should be defined as `case undefinedCallout", line: 1, column: 1 - ) + ), ], macros: testMacros, indentationWidth: .spaces(2) @@ -401,7 +400,7 @@ extension PolymorphicEnumCodableMacroTests { message: "Invalid fallback case name: expected a non-empty string.", line: 1, column: 1 - ) + ), ], macros: testMacros, indentationWidth: .spaces(2) diff --git a/Tests/KarrotCodableMacrosTests/PolymorphicEnumCodableMacroTests/PolymorphicEnumDecodableMacroTests.swift b/Tests/KarrotCodableMacrosTests/PolymorphicEnumCodableMacroTests/PolymorphicEnumDecodableMacroTests.swift index 7cfb430..0bb9f5d 100644 --- a/Tests/KarrotCodableMacrosTests/PolymorphicEnumCodableMacroTests/PolymorphicEnumDecodableMacroTests.swift +++ b/Tests/KarrotCodableMacrosTests/PolymorphicEnumCodableMacroTests/PolymorphicEnumDecodableMacroTests.swift @@ -143,7 +143,7 @@ final class PolymorphicEnumDecodableMacroTests: XCTestCase { message: "`@PolymorphicEnumDecodable` can only be attached to enums", line: 1, column: 1 - ) + ), ], macros: testMacros, indentationWidth: .spaces(2) @@ -176,7 +176,7 @@ final class PolymorphicEnumDecodableMacroTests: XCTestCase { message: "Invalid polymorphic identifier: expected a non-empty string.", line: 1, column: 1 - ) + ), ], macros: testMacros, indentationWidth: .spaces(2) @@ -209,7 +209,7 @@ final class PolymorphicEnumDecodableMacroTests: XCTestCase { message: "Polymorphic Enum cases can only have one associated value", line: 1, column: 1 - ) + ), ], macros: testMacros, indentationWidth: .spaces(2) @@ -242,7 +242,7 @@ final class PolymorphicEnumDecodableMacroTests: XCTestCase { message: "Polymorphic Enum cases should have one associated value", line: 1, column: 1 - ) + ), ], macros: testMacros, indentationWidth: .spaces(2) @@ -253,7 +253,6 @@ final class PolymorphicEnumDecodableMacroTests: XCTestCase { } } - // MARK: - fallbackCaseName extension PolymorphicEnumDecodableMacroTests { @@ -333,7 +332,7 @@ extension PolymorphicEnumDecodableMacroTests { message: "Missing fallback case: should be defined as `case undefinedCallout", line: 1, column: 1 - ) + ), ], macros: testMacros, indentationWidth: .spaces(2) @@ -366,7 +365,7 @@ extension PolymorphicEnumDecodableMacroTests { message: "Invalid fallback case name: expected a non-empty string.", line: 1, column: 1 - ) + ), ], macros: testMacros, indentationWidth: .spaces(2) diff --git a/Tests/KarrotCodableMacrosTests/PolymorphicEnumCodableMacroTests/PolymorphicEnumEncodableMacroTests.swift b/Tests/KarrotCodableMacrosTests/PolymorphicEnumCodableMacroTests/PolymorphicEnumEncodableMacroTests.swift index bb8b505..e78a7c7 100644 --- a/Tests/KarrotCodableMacrosTests/PolymorphicEnumCodableMacroTests/PolymorphicEnumEncodableMacroTests.swift +++ b/Tests/KarrotCodableMacrosTests/PolymorphicEnumCodableMacroTests/PolymorphicEnumEncodableMacroTests.swift @@ -125,7 +125,7 @@ final class PolymorphicEnumEncodableMacroTests: XCTestCase { message: "`@PolymorphicEnumEncodable` can only be attached to enums", line: 1, column: 1 - ) + ), ], macros: testMacros, indentationWidth: .spaces(2) @@ -158,7 +158,7 @@ final class PolymorphicEnumEncodableMacroTests: XCTestCase { message: "Invalid polymorphic identifier: expected a non-empty string.", line: 1, column: 1 - ) + ), ], macros: testMacros, indentationWidth: .spaces(2) @@ -191,7 +191,7 @@ final class PolymorphicEnumEncodableMacroTests: XCTestCase { message: "Polymorphic Enum cases can only have one associated value", line: 1, column: 1 - ) + ), ], macros: testMacros, indentationWidth: .spaces(2) @@ -224,7 +224,7 @@ final class PolymorphicEnumEncodableMacroTests: XCTestCase { message: "Polymorphic Enum cases should have one associated value", line: 1, column: 1 - ) + ), ], macros: testMacros, indentationWidth: .spaces(2) From 74f3bbc348339c00ccbbf851001046aa2e6c7a6b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elon=20Park=20=28=E1=84=8B=E1=85=A6=E1=86=AF=E1=84=85?= =?UTF-8?q?=E1=85=A9=E1=86=AB=29?= Date: Tue, 5 Aug 2025 12:41:10 +0900 Subject: [PATCH 21/22] docs: update README.md, PolymorphicCodableStrategyProviding comments --- README.md | 6 +++--- .../Interface/PolymorphicCodableStrategyProviding.swift | 4 +++- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index eec06b8..8429ed0 100644 --- a/README.md +++ b/README.md @@ -120,10 +120,10 @@ extension User: Codable { ### PolymorphicCodable -`PolymorphicCodable` provides functionality to easily decode polymorphic types from JSON. It includes several interfaces like `PolymorphicIdentifiable`, `PolymorphicCodableStrategy`, and property wrappers like `PolymorphicValue` and `PolymorphicArrayValue`. +`PolymorphicCodable` provides functionality to easily decode polymorphic types from JSON. This functionality provides Swift implementation for the OpenAPI Specification's `oneOf` pattern, allowing type-safe handling of multiple possible schemas. It includes several interfaces like `PolymorphicIdentifiable`, `PolymorphicCodableStrategy`, and property wrappers like `PolymorphicValue` and `PolymorphicArrayValue`. **Parameters:** -- `identifierCodingKey`: Specifies the JSON key used to determine the type of object being decoded. Defaults to "type" if not specified, allowing you to omit this parameter when using the default value. +- `identifierCodingKey`: Specifies the JSON key used to determine the type of object being decoded. This parameter corresponds to the `discriminator.propertyName` in OpenAPI Specification's oneOf definition. Defaults to "type" if not specified, allowing you to omit this parameter when using the default value. - `fallbackType`: Defines a default type to use when the identifier in the JSON doesn't match any of the registered types, preventing decoding failures for unknown types. If this parameter is omitted and an unknown type identifier is encountered during decoding, a decoding error will be thrown. The following example demonstrates how to decode dynamic JSON content where the type of object is determined at runtime: @@ -277,4 +277,4 @@ This project is licensed under the MIT. See LICENSE for details. - PolymorphicCodable was inspired by [Encode and decode polymorphic types in Swift](https://nilcoalescing.com/blog/BringingPolymorphismToCodable/). - AnyCodable was adapted from [Flight-School/AnyCodable](https://github.com/Flight-School/AnyCodable). - BetterCodable was adapted from [marksands/BetterCodable](https://github.com/marksands/BetterCodable). -- ResilientDecodingOutcome was adapted from [airbnb/ResilientDecoding](https://github.com/airbnb/ResilientDecoding). +- ResilientDecodingErrorReporter was adapted from [airbnb/ResilientDecoding](https://github.com/airbnb/ResilientDecoding). diff --git a/Sources/KarrotCodableKit/PolymorphicCodable/Interface/PolymorphicCodableStrategyProviding.swift b/Sources/KarrotCodableKit/PolymorphicCodable/Interface/PolymorphicCodableStrategyProviding.swift index 269a5d9..e69c323 100644 --- a/Sources/KarrotCodableKit/PolymorphicCodable/Interface/PolymorphicCodableStrategyProviding.swift +++ b/Sources/KarrotCodableKit/PolymorphicCodable/Interface/PolymorphicCodableStrategyProviding.swift @@ -13,10 +13,12 @@ import Foundation This macro generates a type-safe decoding strategy for polymorphic types based on an identifier field. It creates a strategy that can be used with different polymorphic decodable types - to handle type-based deserialization. + to handle type-based deserialization. This functionality provides Swift implementation for the + OpenAPI Specification's `oneOf` pattern, enabling type-safe polymorphic decoding. - Parameters: - identifierCodingKey: The key name in the JSON used to determine the concrete type. + This corresponds to the `discriminator.propertyName` in OpenAPI Specification's oneOf definition. The default value for this property is `"type"`. - matchingTypes: An array of polymorphic types that this strategy will handle. - fallbackType: Optional type to use when no matching type is found. If nil, decoding will fail From 8884c8b7cae3a82d2a696d4ef4376c5b10976bce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elon=20Park=20=28=E1=84=8B=E1=85=A6=E1=86=AF=E1=84=85?= =?UTF-8?q?=E1=85=A9=E1=86=AB=29?= Date: Tue, 5 Aug 2025 14:05:23 +0900 Subject: [PATCH 22/22] ci: xcode version upgrade --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5db8a84..181ab37 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,7 +19,7 @@ jobs: runs-on: macos-15 strategy: matrix: - xcode: ['16.1'] + xcode: ['16.4'] config: ['debug', 'release'] steps: