From 664a055fca706b4bf7a5753969c3fb64d5236398 Mon Sep 17 00:00:00 2001 From: Rock-Connotation <787661104@qq.com> Date: Thu, 17 Sep 2026 10:02:51 +0800 Subject: [PATCH 01/17] =?UTF-8?q?fix(decoder):=20=E5=BC=95=E5=85=A5=20with?= =?UTF-8?q?Snapshot=20=E4=BD=9C=E7=94=A8=E5=9F=9F=E7=AE=A1=E7=90=86?= =?UTF-8?q?=E5=BF=AB=E7=85=A7=E7=94=9F=E5=91=BD=E5=91=A8=E6=9C=9F=E5=B9=B6?= =?UTF-8?q?=E6=94=B6=E5=8F=A3=E5=86=99=E6=9D=83=E9=99=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - DecodingCache 提供 withSnapshot 高阶函数,集中管理快照的创建、压栈和出栈 - 使用 defer 确保正常返回与异常抛错均能正确清理当前作用域快照 - 出栈时增加引用相等性校验(===),防止栈错位误删外层快照 - snapshots 属性收窄为 private(set),Cachable 协议仅暴露只读 { get },防止外部随意篡改 - JSONDecoderImpl+Unwrap 中全面接入 withSnapshot,并新增 decodeInPlace 供就地解码复用 --- .../SmartCodable/Core/Cache/Cachable.swift | 17 +---- .../JSONDecoder/Decoder/DecodingCache.swift | 66 ++++++++++--------- .../Decoder/Impl/JSONDecoderImpl+Unwrap.swift | 28 +++++--- 3 files changed, 55 insertions(+), 56 deletions(-) diff --git a/Sources/SmartCodable/Core/Cache/Cachable.swift b/Sources/SmartCodable/Core/Cache/Cachable.swift index 5764a6d..dea38d3 100644 --- a/Sources/SmartCodable/Core/Cache/Cachable.swift +++ b/Sources/SmartCodable/Core/Cache/Cachable.swift @@ -15,22 +15,7 @@ protocol Cachable { /// Array of snapshots representing the current parsing stack /// - Note: Using an array prevents confusion with multi-level nested models - var snapshots: [SomeSnapshot] { set get } - - - /// Caches a new snapshot for the given type at the specified decoding path. - /// - /// This method records a snapshot of the decoding context for a specific model type. - /// - /// - Parameters: - /// - type: The model type being decoded. The snapshot will be associated with this type. - /// - codingPath: The current decoding path, used to identify where in the JSON hierarchy this snapshot applies. - /// This allows later lookup of initial values or metadata by matching decoding paths. - func cacheSnapshot(for type: T.Type, codingPath: [CodingKey]) - - /// Removes the snapshot for the given type - /// - Parameter type: The model type to remove from cache - mutating func removeSnapshot(for type: T.Type) + var snapshots: [SomeSnapshot] { get } } diff --git a/Sources/SmartCodable/Core/JSONDecoder/Decoder/DecodingCache.swift b/Sources/SmartCodable/Core/JSONDecoder/Decoder/DecodingCache.swift index acdf629..f0ae8e7 100644 --- a/Sources/SmartCodable/Core/JSONDecoder/Decoder/DecodingCache.swift +++ b/Sources/SmartCodable/Core/JSONDecoder/Decoder/DecodingCache.swift @@ -15,31 +15,23 @@ class DecodingCache: Cachable { typealias SomeSnapshot = DecodingSnapshot /// Stack of decoding snapshots - var snapshots: [DecodingSnapshot] = [] + private(set) var snapshots: [DecodingSnapshot] = [] - /// Creates and stores a snapshot of initial values for a Decodable type - /// - Parameter type: The Decodable type to cache - func cacheSnapshot(for type: T.Type, codingPath: [CodingKey]) { - - - - let smartType: SmartDecodable.Type? - - /** 缓存条件 - * 1. 直接是 SmartDecodable - * 2. 是属性包装器,且 WrappedValue 是 SmartDecodable - * 3. 其它情况,不关心 - */ - if let objectType = type as? SmartDecodable.Type { - smartType = objectType - } else if let wrapperType = type as? any PropertyWrapperable.Type { - smartType = wrapperType.wrappedSmartDecodableType - } else { - return + /// 在指定类型的快照作用域内执行解码。 + /// + /// 快照的资格判断、创建与清理全部收口在这里: + /// 调用方只表达“在该类型的快照作用域内解码”, + /// 不再手工配对压栈与出栈,抛错路径也会清理本作用域的快照。 + func withSnapshot( + for type: T.Type, + codingPath: [CodingKey], + _ body: () throws -> Result + ) rethrows -> Result { + + guard let object = cachedSmartDecodableType(for: type) else { + return try body() } - guard let object = smartType else { return } - let snapshot = DecodingSnapshot() snapshot.codingPath = codingPath // [initialValues] Lazy initialization: @@ -47,15 +39,29 @@ class DecodingCache: Cachable { // using the recorded objectType to optimize parsing performance. snapshot.objectType = object snapshots.append(snapshot) - } - - /// Removes the most recent snapshot for the given type - /// - Parameter type: The type to remove from cache - func removeSnapshot(for type: T.Type) { - guard T.self is SmartDecodable.Type else { return } - if !snapshots.isEmpty { - snapshots.removeLast() + + defer { + // 栈顶必须是本作用域创建的快照;身份不匹配时不盲目弹出其他作用域的快照 + assert(snapshots.last === snapshot, + "Decoding snapshot stack top is not the one created by this scope") + if let last = snapshots.last, last === snapshot { + snapshots.removeLast() + } } + + return try body() + } + + /// 解析需要缓存的 `SmartDecodable` 类型 + /// + /// 快照的意义是“某个 SmartDecodable 的 `init(from:)` 正在当前作用域内执行”, + /// 因此资格只有一个判据:即将执行 `init(from:)` 的类型本身是 SmartDecodable。 + /// + /// 属性包装器类型不在此列:包装器的 `init(from:)` 运行时内层模型尚未初始化, + /// 快照由内层模型自己的解码作用域(`unwrap(as:)` / `decodeInPlace(_:)`)建立, + /// 避免同一模型在嵌套解码时被重复压栈。 + private func cachedSmartDecodableType(for type: T.Type) -> SmartDecodable.Type? { + return type as? SmartDecodable.Type } } diff --git a/Sources/SmartCodable/Core/JSONDecoder/Decoder/Impl/JSONDecoderImpl+Unwrap.swift b/Sources/SmartCodable/Core/JSONDecoder/Decoder/Impl/JSONDecoderImpl+Unwrap.swift index c1a10b3..35009fd 100644 --- a/Sources/SmartCodable/Core/JSONDecoder/Decoder/Impl/JSONDecoderImpl+Unwrap.swift +++ b/Sources/SmartCodable/Core/JSONDecoder/Decoder/Impl/JSONDecoderImpl+Unwrap.swift @@ -38,10 +38,21 @@ extension JSONDecoderImpl { return try self.unwrapDictionary(as: type) } - cache.cacheSnapshot(for: type, codingPath: codingPath) - let decoded = try type.init(from: self) - cache.removeSnapshot(for: type) - return decoded + return try cache.withSnapshot(for: type, codingPath: codingPath) { + try type.init(from: self) + } + } + + /// 在当前解码上下文中初始化类型 T(codingPath 不前进)。 + /// + /// 供 `@SmartFlat` 等平铺语义的属性包装器使用:包装器自身不建立快照, + /// 由本方法为内层值建立与 `unwrap(as:)` 同源的快照作用域—— + /// 仅当 T 是 SmartDecodable 时入栈,且作用域精确覆盖 `T.init(from:)` 的执行窗口。 + /// 不包含 `unwrap(as:)` 的特殊类型提前返回,保持包装器原有的直接初始化语义。 + func decodeInPlace(_ type: T.Type) throws -> T { + return try cache.withSnapshot(for: type, codingPath: codingPath) { + try type.init(from: self) + } } func unwrapFloatingPoint( @@ -311,12 +322,9 @@ extension Decodable { { return try decoder.unwrap(as: Self.self) } - decoder.cache.cacheSnapshot(for: type, codingPath: decoder.codingPath) - let decoded = try Self.init(from: decoder) - decoder.cache.removeSnapshot(for: type) - - - return decoded + return try decoder.cache.withSnapshot(for: type, codingPath: decoder.codingPath) { + try Self.init(from: decoder) + } } /// createByDirectlyUnwrapping 的 Self 是静态绑定的(一个真正的类型), From 72365a4bf59928cf4b45435ab7934b340c0d9443 Mon Sep 17 00:00:00 2001 From: Rock-Connotation <787661104@qq.com> Date: Thu, 17 Sep 2026 10:03:00 +0800 Subject: [PATCH 02/17] =?UTF-8?q?fix(wrapper):=20SmartFlat/SmartAny=20?= =?UTF-8?q?=E6=8E=A5=E5=85=A5=20decodeInPlace=20=E5=B9=B6=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=20SmartIgnored=20=E5=88=9D=E5=A7=8B=E5=80=BC=E8=B7=AF?= =?UTF-8?q?=E5=BE=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SmartFlat 解码时通过 decodeInPlace 为内层模型建立快照作用域,消除快照残留与后续属性污染 - SmartAny 模型兜底解码路径同样接入 decodeInPlace,保持模型声明默认值 - SmartIgnored 在无 parsingMark 路径下修正 codingPath 层级,准确获取宿主声明的初始值 --- .../Core/PropertyWrapper/SmartAny/SmartAny.swift | 3 ++- .../SmartCodable/Core/PropertyWrapper/SmartFlat.swift | 9 +++++++-- .../SmartCodable/Core/PropertyWrapper/SmartIgnored.swift | 6 +++++- 3 files changed, 14 insertions(+), 4 deletions(-) diff --git a/Sources/SmartCodable/Core/PropertyWrapper/SmartAny/SmartAny.swift b/Sources/SmartCodable/Core/PropertyWrapper/SmartAny/SmartAny.swift index 1961ff5..33e0198 100644 --- a/Sources/SmartCodable/Core/PropertyWrapper/SmartAny/SmartAny.swift +++ b/Sources/SmartCodable/Core/PropertyWrapper/SmartAny/SmartAny.swift @@ -69,7 +69,8 @@ extension SmartAny: Codable { // 类型检查 if let _type = T.self as? Decodable.Type { - if let decoded = try _type.init(from: decoder) as? T { + // 模型兜底解码同样需要快照作用域,由 decodeInPlace 统一建立 + if let decoded = try decoder.decodeInPlace(_type) as? T { self = .init(wrappedValue: decoded) return } diff --git a/Sources/SmartCodable/Core/PropertyWrapper/SmartFlat.swift b/Sources/SmartCodable/Core/PropertyWrapper/SmartFlat.swift index 3a12353..2c1cdb8 100644 --- a/Sources/SmartCodable/Core/PropertyWrapper/SmartFlat.swift +++ b/Sources/SmartCodable/Core/PropertyWrapper/SmartFlat.swift @@ -42,10 +42,15 @@ public struct SmartFlat: PropertyWrapperable { extension SmartFlat: Codable { - + public init(from decoder: Decoder) throws { do { - wrappedValue = try T(from: decoder) + if let impl = decoder as? JSONDecoderImpl { + // 平铺语义下 codingPath 不前进,内层模型的快照作用域由 decodeInPlace 建立 + wrappedValue = try impl.decodeInPlace(T.self) + } else { + wrappedValue = try T(from: decoder) + } } catch { wrappedValue = try Patcher.defaultForType() } diff --git a/Sources/SmartCodable/Core/PropertyWrapper/SmartIgnored.swift b/Sources/SmartCodable/Core/PropertyWrapper/SmartIgnored.swift index fcc5a88..c96917d 100644 --- a/Sources/SmartCodable/Core/PropertyWrapper/SmartIgnored.swift +++ b/Sources/SmartCodable/Core/PropertyWrapper/SmartIgnored.swift @@ -96,6 +96,10 @@ extension SmartIgnored: Codable { extension JSONDecoderImpl { fileprivate func smartDecode(type: T.Type) throws -> T { - try cache.initialValue(forKey: codingPath.last, codingPath: codingPath) + // 取“宿主为当前属性声明的初始值”:宿主快照位于父级容器路径上, + // 当前 impl 的 codingPath 末位即该属性的 key。 + var hostPath = codingPath + let key = hostPath.popLast() + return try cache.initialValue(forKey: key, codingPath: hostPath) } } From 751b0c46c64a5c3ace692245176e44f30eba7215 Mon Sep 17 00:00:00 2001 From: Rock-Connotation <787661104@qq.com> Date: Thu, 17 Sep 2026 10:03:08 +0800 Subject: [PATCH 03/17] =?UTF-8?q?test:=20=E5=A2=9E=E5=8A=A0=E5=BF=AB?= =?UTF-8?q?=E7=85=A7=E7=94=9F=E5=91=BD=E5=91=A8=E6=9C=9F=E5=A5=91=E7=BA=A6?= =?UTF-8?q?=E6=B5=8B=E8=AF=95=E4=B8=8E=20SmartFlat=20=E9=98=B2=E6=B1=A1?= =?UTF-8?q?=E6=9F=93=E5=9B=9E=E5=BD=92=E7=94=A8=E4=BE=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 DecodingCacheLifecycleTests:覆盖正常返回、抛错恢复、类型过滤、嵌套作用域、平铺栈深度、字典直接解包及无 mark SmartIgnored 等契约断言 - 新增 SmartFlatTests:覆盖 @SmartFlat 后的普通属性默认值、枚举默认值、SmartIgnored、SmartAny、数组元素、以及 mappingForValue 转换器等防污染场景 --- Tests/DecodingCacheLifecycleTests.swift | 246 ++++++++++++++++++++++++ Tests/SmartFlatTests.swift | 180 +++++++++++++++++ 2 files changed, 426 insertions(+) create mode 100644 Tests/DecodingCacheLifecycleTests.swift create mode 100644 Tests/SmartFlatTests.swift diff --git a/Tests/DecodingCacheLifecycleTests.swift b/Tests/DecodingCacheLifecycleTests.swift new file mode 100644 index 0000000..5ca3b09 --- /dev/null +++ b/Tests/DecodingCacheLifecycleTests.swift @@ -0,0 +1,246 @@ +import XCTest +@testable import SmartCodable + +/// `DecodingCache.withSnapshot` 的生命周期契约测试 +final class DecodingCacheLifecycleTests: XCTestCase { + + private var cache: DecodingCache! + + override func setUp() { + super.setUp() + cache = DecodingCache() + } + + /// 符合资格的模型在 body 执行期间有一个快照,正常返回后恢复原栈深度 + func testEligibleModelHasSnapshotDuringBodyAndRestoresStackAfterReturn() { + let before = cache.snapshots.count + var duringBody = 0 + + let result = cache.withSnapshot(for: LifecycleModel.self, codingPath: []) { + duringBody = self.cache.snapshots.count + return 42 + } + + XCTAssertEqual(result, 42) + XCTAssertEqual(duringBody, before + 1) + XCTAssertEqual(cache.snapshots.count, before) + } + + /// body 抛错后仍恢复原栈深度 + func testThrowingBodyStillRestoresStackDepth() { + let before = cache.snapshots.count + + XCTAssertThrowsError(try cache.withSnapshot(for: LifecycleModel.self, codingPath: []) { + throw LifecycleTestError.boom + }) + + XCTAssertEqual(cache.snapshots.count, before) + } + + /// 真实 unwrap 调用抛错后,也必须清理该调用创建的快照 + func testUnwrapThrowingModelRestoresSnapshotStack() { + let smartDecoder = SmartJSONDecoder() + let decoder = JSONDecoderImpl( + userInfo: [:], + from: .object([:]), + codingPath: [], + options: smartDecoder.options + ) + + XCTAssertThrowsError(try decoder.unwrap(as: ThrowingLifecycleModel.self)) + XCTAssertTrue(decoder.cache.snapshots.isEmpty) + } + + /// 不符合资格的普通类型不改变栈 + func testIneligibleTypeDoesNotChangeStack() { + let before = cache.snapshots.count + var duringBody = before + + let result = cache.withSnapshot(for: String.self, codingPath: []) { + duringBody = self.cache.snapshots.count + return "ok" + } + + XCTAssertEqual(result, "ok") + XCTAssertEqual(duringBody, before) + XCTAssertEqual(cache.snapshots.count, before) + } + + /// 嵌套作用域内层抛错并被捕获后,外层快照仍是栈顶;外层结束后栈为空 + func testNestedThrowingScopeKeepsOuterSnapshotOnTopAndDrainsStack() throws { + let outerResult = try cache.withSnapshot( + for: LifecycleModel.self, + codingPath: [LifecycleCodingKey.outer] + ) { + let outerSnapshot = try XCTUnwrap(self.cache.snapshots.last) + let innerBefore = self.cache.snapshots.count + + XCTAssertThrowsError( + try self.cache.withSnapshot( + for: LifecycleModel.self, + codingPath: [LifecycleCodingKey.outer, LifecycleCodingKey.inner] + ) { + throw LifecycleTestError.boom + } + ) + + XCTAssertEqual(self.cache.snapshots.count, innerBefore) + XCTAssertEqual(self.cache.snapshots.count, 1) + XCTAssertTrue(self.cache.snapshots.last === outerSnapshot) + XCTAssertEqual( + self.cache.snapshots.last?.codingPath.map(\.stringValue), + [LifecycleCodingKey.outer.rawValue] + ) + + return "outer" + } + + XCTAssertEqual(outerResult, "outer") + XCTAssertTrue(cache.snapshots.isEmpty) + } + + // MARK: - 快照栈深度不变量(每个活动中的模型 init 恰好持有一个快照) + + /// @SmartFlat(Optional 与非 Optional)解码内层模型期间, + /// 快照栈深度必须恰好为“宿主 + 内层”两层:包装器自身不得额外压栈 + func testSmartFlatDecodingMaintainsHostPlusInnerSnapshotDepthOnly() throws { + SnapshotDepthProbe.lastObservedDepth = nil + _ = try XCTUnwrap(OptionalFlatProbeHost.deserialize(from: [:])) + XCTAssertEqual(SnapshotDepthProbe.lastObservedDepth, 2, + "Optional 平铺期间应为 宿主+内层 两层快照,包装器不得重复压栈") + + SnapshotDepthProbe.lastObservedDepth = nil + _ = try XCTUnwrap(NonOptionalFlatProbeHost.deserialize(from: [:])) + XCTAssertEqual(SnapshotDepthProbe.lastObservedDepth, 2, + "非 Optional 平铺期间应为 宿主+内层 两层快照,包装器不得重复压栈") + } + + /// 普通嵌套模型属性使用独立缓存,内层模型 init 期间栈深度为 1 + func testNestedModelPropertyDecodesWithIsolatedSnapshotStack() throws { + SnapshotDepthProbe.lastObservedDepth = nil + _ = try XCTUnwrap(NestedProbeHost.deserialize(from: ["user": [:]])) + XCTAssertEqual(SnapshotDepthProbe.lastObservedDepth, 1) + } + + // MARK: - createByDirectlyUnwrapping 逃逸通道(unwrapDictionary 场景) + + /// 字典值模型的解码必须经过 createByDirectlyUnwrapping 的快照作用域: + /// 元素字段缺 key 时保留元素声明的默认值,而不是退化为类型零值 + func testDictionaryValuedModelsPreserveElementDefaultsThroughDirectUnwrap() throws { + struct Host: SmartCodableX { + var slots: [String: DictEntry] = [:] + } + + let host = try XCTUnwrap(Host.deserialize(from: [ + "slots": ["a": ["name": "A"], "b": [:]] + ])) + + XCTAssertEqual(host.slots["a"]?.name, "A") + XCTAssertEqual(host.slots["b"]?.name, "inner-default", + "字典元素缺 key 时应命中元素自身快照的声明默认值") + } + + // MARK: - SmartAny 模型兜底路径 + + /// @SmartAny 包装模型时,兜底解码路径同样要建立快照作用域,保留模型声明默认值 + func testSmartAnyModelBackedValueKeepsDeclaredDefaultsWhenKeysMissing() throws { + struct Model: SmartCodableX { + @SmartAny var payload: SmartAnyTargetModel = .init() + } + + let model = try XCTUnwrap(Model.deserialize(from: ["payload": [:]])) + + XCTAssertEqual(model.payload.score, 11) + } + + // MARK: - SmartIgnored 无 parsingMark 路径 + + /// 未设置 parsingMark 时(第三方直接触发解码),@SmartIgnored 应取宿主声明的初始值, + /// 而不是退化为 Patcher 的全新默认实例 + func testSmartIgnoredWithoutParsingMarkKeepsHostDeclaredInitialValue() throws { + let smartDecoder = SmartJSONDecoder() + let impl = JSONDecoderImpl( + userInfo: [:], + from: .object(["settings": .object(["level": .number("999")])]), + codingPath: [], + options: smartDecoder.options + ) + + let host = try impl.unwrap(as: MarklessHost.self) + + XCTAssertEqual(host.settings.level, 7, + "应保留宿主声明的初始值 7,而非 JSON 的 999 或 Patcher 的 0") + } +} + +private final class LifecycleModel: SmartCodableX { + var value: String = "" + required init() {} +} + +private final class ThrowingLifecycleModel: SmartCodableX { + required init() {} + + required init(from decoder: Decoder) throws { + throw LifecycleTestError.boom + } +} + +private enum LifecycleTestError: Error { + case boom +} + +private enum LifecycleCodingKey: String, CodingKey { + case outer + case inner +} + +/// 在 init(from:) 内记录当时快照栈深度的探针模型 +private final class SnapshotDepthProbe: SmartCodableX { + static var lastObservedDepth: Int? + + required init() {} + + required init(from decoder: Decoder) throws { + if let impl = decoder as? JSONDecoderImpl { + Self.lastObservedDepth = impl.cache.snapshots.count + } + } +} + +private struct OptionalFlatProbeHost: SmartCodableX { + @SmartFlat var user: SnapshotDepthProbe? +} + +private struct NonOptionalFlatProbeHost: SmartCodableX { + @SmartFlat var user: SnapshotDepthProbe = SnapshotDepthProbe() +} + +private struct NestedProbeHost: SmartCodableX { + var user: SnapshotDepthProbe? +} + +/// 字典值元素模型(默认值与类型零值不同,用于断言敏感度) +private struct DictEntry: SmartCodableX { + var name: String = "inner-default" +} + +/// @SmartAny 兜底解码的目标模型 +private struct SmartAnyTargetModel: SmartCodableX { + var score: Int = 11 +} + +/// 无 parsingMark 场景的宿主:声明初始值与 init() 默认值不同,用于区分取值来源 +private final class MarklessHost: SmartCodableX { + @SmartIgnored var settings: MarklessSettings = .init(level: 7) + required init() {} +} + +private struct MarklessSettings: SmartCodableX { + var level: Int = 0 + + init() {} + init(level: Int) { + self.level = level + } +} diff --git a/Tests/SmartFlatTests.swift b/Tests/SmartFlatTests.swift new file mode 100644 index 0000000..9d7c633 --- /dev/null +++ b/Tests/SmartFlatTests.swift @@ -0,0 +1,180 @@ +import XCTest +@testable import SmartCodable + +/// @SmartFlat 快照污染:Flat 属性声明在前时,后续属性默认值 / 包装器 / mappingForValue 被污染 +final class SmartFlatTests: XCTestCase { + + /// @SmartFlat 之后的普通属性:JSON 缺 key 时应保留模型默认值,而不是类型零值 + func testSmartFlatBeforePlainPropertiesKeepsCustomDefaultsWhenKeysMissing() { + struct Model: SmartCodableX { + @SmartFlat var user: FlatUser? + var mikeNo: String = "我是默认值" + var num: Int = 999 + } + + let model = Model.deserialize(from: ["accid": "a1"]) + + XCTAssertNotNil(model) + XCTAssertEqual(model?.user?.accid, "a1") + XCTAssertEqual(model?.mikeNo, "我是默认值") + XCTAssertEqual(model?.num, 999) + } + + /// @SmartFlat 之后跟 @SmartIgnored:整模型应成功解码,而不是返回 nil + func testSmartFlatBeforeSmartIgnoredStillDeserializesModel() { + struct Model: SmartCodableX { + @SmartFlat var user: FlatUser? + var mikeNo: String = "我是默认值" + var num: Int = 999 + @SmartIgnored var extra: Bool = true + } + + let model = Model.deserialize(from: ["accid": "a1"]) + + XCTAssertNotNil(model) + XCTAssertEqual(model?.user?.accid, "a1") + XCTAssertEqual(model?.mikeNo, "我是默认值") + XCTAssertEqual(model?.num, 999) + XCTAssertEqual(model?.extra, true) + } + + /// @SmartFlat 之后带 mappingForValue 的属性:自定义转换器应生效 + func testSmartFlatBeforeMappingForValueAppliesTransformer() { + struct Model: SmartCodableX { + @SmartFlat var user: FlatUser? + var name: String = "" + + static func mappingForValue() -> [SmartValueTransformer]? { + [CodingKeys.name <--- UpperTransformer()] + } + } + + let model = Model.deserialize(from: ["accid": "a1", "name": "mccc"]) + + XCTAssertNotNil(model) + XCTAssertEqual(model?.user?.accid, "a1") + XCTAssertEqual(model?.name, "MCCC") + } + + /// @SmartFlat 之后的枚举:JSON 缺 key 时应保留声明的默认 case + func testSmartFlatBeforeEnumKeepsDeclaredDefaultCaseWhenKeyMissing() throws { + struct Model: SmartCodableX { + @SmartFlat var user: FlatUser? + var kind: FlatDefaultKind = .preferred + } + + let model = try XCTUnwrap(Model.deserialize(from: ["accid": "a1"])) + + XCTAssertEqual(model.user?.accid, "a1") + XCTAssertEqual(model.kind, .preferred) + } + + /// @SmartFlat 之后跟包装模型的 @SmartIgnored:应保留包装模型的默认值 + func testSmartFlatBeforeModelBackedSmartIgnoredStillDeserializesModel() throws { + struct Model: SmartCodableX { + @SmartFlat var user: FlatUser? + @SmartIgnored var settings: IgnoredSettings = .init() + } + + let model = try XCTUnwrap(Model.deserialize(from: ["accid": "a1"])) + + XCTAssertEqual(model.user?.accid, "a1") + XCTAssertTrue(model.settings.isEnabled) + } + + /// @SmartFlat 之后跟 @SmartAny:应保留动态属性的默认值 + func testSmartFlatBeforeSmartAnyStillDeserializesModel() throws { + struct Model: SmartCodableX { + @SmartFlat var user: FlatUser? + @SmartAny var metadata: [String: Any] = ["source": "default"] + } + + let model = try XCTUnwrap(Model.deserialize(from: ["accid": "a1"])) + + XCTAssertEqual(model.user?.accid, "a1") + XCTAssertEqual(model.metadata["source"] as? String, "default") + } + + /// 含 @SmartFlat 污染场景的模型作为数组元素时,应保留每个元素解码出的数据 + func testSmartFlatSnapshotPreservesArrayElementValues() throws { + struct Model: SmartCodableX { + @SmartFlat var user: FlatUser? + @SmartIgnored var extra: Bool = true + } + + let models = try XCTUnwrap([Model].deserialize(from: [ + ["accid": "a1"], + ["accid": "a2"], + ])) + + XCTAssertEqual(models.count, 2) + XCTAssertEqual(models.compactMap(\.user?.accid), ["a1", "a2"]) + XCTAssertTrue(models.allSatisfy(\.extra)) + } + + /// @SmartFlat 之前的属性不受后续平铺解码影响 + func testPropertyBeforeSmartFlatKeepsDeclaredDefaultWhenKeyMissing() throws { + struct Model: SmartCodableX { + var title: String = "默认标题" + @SmartFlat var user: FlatUser? + } + + let model = try XCTUnwrap(Model.deserialize(from: ["accid": "a1"])) + + XCTAssertEqual(model.title, "默认标题") + XCTAssertEqual(model.user?.accid, "a1") + } + + /// @SmartFlat 包装非 Optional 模型:内层字段缺 key 时应保留内层声明的默认值, + /// 而不是退化为类型零值(内层模型不经 unwrap 直接初始化的路径) + func testNonOptionalSmartFlatKeepsInnerModelDefaultsWhenKeysMissing() throws { + struct Model: SmartCodableX { + @SmartFlat var user: NonOptionalFlatUser = NonOptionalFlatUser() + var tail: Int = 3 + } + + let model = try XCTUnwrap(Model.deserialize(from: ["accid": "a1"])) + + XCTAssertEqual(model.user.accid, "a1") + XCTAssertEqual(model.user.level, 42) + XCTAssertEqual(model.tail, 3) + } +} + +/// 被 @SmartFlat 平铺的内层模型(issue 最小复现) +private final class FlatUser: SmartCodableX { + var accid: String = "" + required init() {} +} + +/// 非 Optional 形式的平铺内层模型(默认值与类型零值不同,用于断言敏感度) +private final class NonOptionalFlatUser: SmartCodableX { + var accid: String = "" + var level: Int = 42 + required init() {} +} + +/// 用于验证枚举默认值不会退化为首个 case +private enum FlatDefaultKind: Int, SmartCaseDefaultable { + case fallback + case preferred +} + +/// 用于验证 @SmartIgnored 包装 SmartDecodable 模型的路径 +private struct IgnoredSettings: SmartCodableX { + var isEnabled: Bool = true +} + +/// 将字符串转为大写的转换器(用于验证 mappingForValue) +private struct UpperTransformer: ValueTransformable { + typealias Object = String + typealias JSON = String + + func transformFromJSON(_ value: Any) -> String? { + (value as? String)?.uppercased() + } + + func transformToJSON(_ value: String) -> String? { + value + } +} From 224eae6811d79edf4a7426eb6d8d3e3abe60c2c1 Mon Sep 17 00:00:00 2001 From: Rock-Connotation <787661104@qq.com> Date: Thu, 17 Sep 2026 10:03:20 +0800 Subject: [PATCH 04/17] =?UTF-8?q?docs:=20=E6=9B=B4=E6=96=B0=E5=BF=AB?= =?UTF-8?q?=E7=85=A7=E4=BD=9C=E7=94=A8=E5=9F=9F=E4=BD=BF=E7=94=A8=E8=A7=84?= =?UTF-8?q?=E8=8C=83=E4=B8=8E=E6=8A=80=E6=9C=AF=E6=9E=B6=E6=9E=84=E6=8C=87?= =?UTF-8?q?=E5=8D=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 更新 CONTRIBUTING.md 中的 DecodingCache 约定为 withSnapshot 作用域接口 - 更新 TechnicalGuide.md 中的解码流程架构图与快照作用域机制 - 更新 QA3.md 中的示例代码与快照原理示意 --- CONTRIBUTING.md | 2 +- Document/QA/QA3.md | 25 +++++++++++++++++++----- Document/TechnicalGuide.md | 39 +++++++++++++++++++------------------- 3 files changed, 40 insertions(+), 26 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 4a3d2b5..873a6ce 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -50,7 +50,7 @@ swift build - **不破坏公共 API**:`SmartDecodable`、`SmartEncodable`、属性包装器的公开接口不能改签名 - **向后兼容**:保持 Swift 5.0+ / iOS 13+ 的最低版本要求 - **不新增 SwiftSyntax 依赖**:核心模块(`SmartCodable` target)不能依赖 SwiftSyntax -- **DecodingCache 快照必须成对调用**:`cacheSnapshot()` 和 `removeSnapshot()` 必须配对,注意异常路径 +- **DecodingCache 快照使用作用域接口**:解码入口统一通过 `withSnapshot(for:codingPath:_:)` 管理快照,不要在调用方手工压栈或出栈;该接口负责正常返回和抛错路径的清理 - 修改核心解码逻辑后,至少手动验证:简单模型、嵌套模型、数组模型、类型不匹配、缺失字段 ### Commit 规范 diff --git a/Document/QA/QA3.md b/Document/QA/QA3.md index bd5b221..7bacb9c 100644 --- a/Document/QA/QA3.md +++ b/Document/QA/QA3.md @@ -118,7 +118,7 @@ struct NameModel: SmartCodable { } let dict: [String: String] = [ : ] -if let model = NameModel.deserialize(dict: dict) { +if let model = NameModel.deserialize(from: dict) { print(model.name) // 我是初始值 } @@ -130,10 +130,25 @@ if let model = NameModel.deserialize(dict: dict) { ```swift // DecodingCache.swift 简化示意 -func cacheSnapshot(for type: T.Type, codingPath: [CodingKey]) { - guard let smartType = type as? SmartDecodable.Type else { return } - let snapshot = DecodingSnapshot(objectType: smartType, codingPath: codingPath) +func withSnapshot( + for type: T.Type, + codingPath: [CodingKey], + _ body: () throws -> Result +) rethrows -> Result { + guard let smartType = cachedSmartDecodableType(for: type) else { + return try body() + } + + let snapshot = DecodingSnapshot() + snapshot.objectType = smartType + snapshot.codingPath = codingPath snapshots.append(snapshot) + defer { + if snapshots.last === snapshot { + snapshots.removeLast() + } + } + return try body() } // 懒加载:首次需要默认值时才通过 Mirror 反射获取 @@ -144,4 +159,4 @@ private func populateInitialValues(snapshot: DecodingSnapshot) { } ``` -当某个属性解码失败时,从快照中找到该属性的初始值进行填充。这种懒加载设计避免了每次解码都进行反射,只有在真正需要回退默认值时才会触发。 \ No newline at end of file +当某个属性解码失败时,从快照中找到该属性的初始值进行填充。这种懒加载设计避免了每次解码都进行反射,只有在真正需要回退默认值时才会触发。 diff --git a/Document/TechnicalGuide.md b/Document/TechnicalGuide.md index ff8b645..02f5595 100644 --- a/Document/TechnicalGuide.md +++ b/Document/TechnicalGuide.md @@ -51,19 +51,18 @@ SmartJSONDecoder.smartDecode(type, from: data) // SmartJSONDecoder.swift JSONDecoderImpl.unwrap(as: type) // JSONDecoderImpl+Unwrap.swift ├── 特殊类型直接处理:Date, Data, URL, Decimal, CGFloat, Dictionary └── 普通类型: - ├── cache.cacheSnapshot() // 创建快照,记录类型信息 - ├── type.init(from: self) // 触发 Codable 标准流程 - │ ↓ - │ KeyedContainer 初始化 // JSONDecoderImpl+KeyedContainer.swift - │ ├── _convertDictionary() // 应用 Key Mapping - │ │ ├── SmartKeyDecodingStrategy // snake_case → camelCase 等 - │ │ └── KeysMapper.convertFrom() // 自定义 mappingForKey() - │ └── 逐属性解码: - │ ├── 1. 检查 ValueTransformer // mappingForValue() 自定义转换 - │ ├── 2. 尝试标准解码 - │ ├── 3. 类型转换 Patcher // Int↔String, Bool↔Int 等 - │ └── 4. 默认值回退 Cache // Mirror 反射获取的初始值 - └── cache.removeSnapshot() // 清理快照 + └── cache.withSnapshot(for:codingPath:) // 建立异常安全的快照作用域 + └── type.init(from: self) // 触发 Codable 标准流程 + ↓ + KeyedContainer 初始化 // JSONDecoderImpl+KeyedContainer.swift + ├── _convertDictionary() // 应用 Key Mapping + │ ├── SmartKeyDecodingStrategy // snake_case → camelCase 等 + │ └── KeysMapper.convertFrom() // 自定义 mappingForKey() + └── 逐属性解码: + ├── 1. 检查 ValueTransformer // mappingForValue() 自定义转换 + ├── 2. 尝试标准解码 + ├── 3. 类型转换 Patcher // Int↔String, Bool↔Int 等 + └── 4. 默认值回退 Cache // Mirror 反射获取的初始值 ↓ didFinishMapping() // 用户回调,可做后处理 ``` @@ -89,19 +88,19 @@ didFinishMapping() // 用户回调,可做后处 ``` 解码开始 ↓ -cacheSnapshot(for: Model.self) // 记录类型,但不立即反射 +withSnapshot(for: Model.self) // 记录类型,但不立即反射 + ↓ +执行 Model.init(from:) ↓ 某属性解码失败 ↓ initialValueIfPresent(forKey: "name") // 首次访问时触发 Mirror 反射 ↓ -populateInitialValues() // 创建 Model.init(),用 Mirror 提取所有属性初始值 - ↓ -返回 snapshot.initialValues["name"] // 即用户声明的 var name: String = "默认值" 中的 "默认值" +populateInitialValues() // 创建 Model.init(),用 Mirror 提取所有属性初始值 ↓ -解码结束 +返回 snapshot.initialValues["name"] // 即用户声明的 var name: String = "默认值" 中的 "默认值" ↓ -removeSnapshot(for: Model.self) // 清理 +作用域结束后自动清理快照 // 正常返回和抛错路径都会执行 ``` ### 快照栈机制 @@ -289,7 +288,7 @@ SmartCodableOptions.ignoreNull = false // 将 null 作为值传递给 A ### 代码约定 -4. **DecodingCache 的快照必须成对调用**:`cacheSnapshot()` 和 `removeSnapshot()` 必须配对,否则快照栈会泄漏。当前在 `unwrap()` 方法中管理,修改时注意异常路径 +4. **DecodingCache 的快照必须通过作用域接口管理**:解码入口统一调用 `withSnapshot(for:codingPath:_:)`,不要复制快照资格判断或在调用方手工出栈;正常返回和抛错路径都由该接口清理 5. **Patcher 中的类型转换要双向安全**:比如 String → Int,必须验证字符串确实是合法数字,不能静默返回 0 6. **属性包装器的存储名有下划线前缀**:Swift 编译器将 `@SmartAny var name` 存储为 `_name`,DecodingCache 中需要处理这个映射 7. **KeyedContainer 中的 `_convertDictionary()` 只执行一次**:在容器初始化时调用,之后的属性解码都基于转换后的字典 From 346103b0a240c7d46ba758d72988eae543eaa1cd Mon Sep 17 00:00:00 2001 From: Rock-Connotation <787661104@qq.com> Date: Thu, 17 Sep 2026 11:50:03 +0800 Subject: [PATCH 05/17] =?UTF-8?q?fix(wrapper):=20=E4=BF=AE=E5=A4=8D=20Smar?= =?UTF-8?q?tIgnored=20=E5=9C=A8=20didFinishMapping=20=E9=87=8D=E5=BB=BA?= =?UTF-8?q?=E6=97=B6=E4=B8=A2=E5=A4=B1=20isEncodable=20=E6=A0=87=E8=AE=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Core/PropertyWrapper/SmartIgnored.swift | 2 +- Tests/SmartIgnoredTests.swift | 59 +++++++++++++++++++ 2 files changed, 60 insertions(+), 1 deletion(-) create mode 100644 Tests/SmartIgnoredTests.swift diff --git a/Sources/SmartCodable/Core/PropertyWrapper/SmartIgnored.swift b/Sources/SmartCodable/Core/PropertyWrapper/SmartIgnored.swift index c96917d..4d99ecf 100644 --- a/Sources/SmartCodable/Core/PropertyWrapper/SmartIgnored.swift +++ b/Sources/SmartCodable/Core/PropertyWrapper/SmartIgnored.swift @@ -31,7 +31,7 @@ public struct SmartIgnored: PropertyWrapperable { public func wrappedValueDidFinishMapping() -> SmartIgnored? { if var temp = wrappedValue as? SmartDecodable { temp.didFinishMapping() - return SmartIgnored(wrappedValue: temp as! T) + return SmartIgnored(wrappedValue: temp as! T, isEncodable: isEncodable) } return nil } diff --git a/Tests/SmartIgnoredTests.swift b/Tests/SmartIgnoredTests.swift new file mode 100644 index 0000000..f3102eb --- /dev/null +++ b/Tests/SmartIgnoredTests.swift @@ -0,0 +1,59 @@ +import XCTest +@testable import SmartCodable + +/// @SmartIgnored isEncodable 状态保持:mapping 回调重建包装器后,编码标记不能丢失 +final class SmartIgnoredTests: XCTestCase { + + /// isEncodable: true 的 @SmartIgnored 属性:经过 didFinishMapping 回调后 + /// 标记依然为 true,且该属性正常参与编码输出 + func testIsEncodableSurvivesDidFinishMappingAndEncodes() throws { + let model = try XCTUnwrap(IgnoredEncodableHost.deserialize(from: ["other": 1])) + + // mapping 回调已在内层模型上执行(证明走了 wrappedValueDidFinishMapping 重建路径) + XCTAssertTrue(model.settings.didMappingRun) + + // 编码输出应包含被修饰属性;若重建时丢失 isEncodable,该字段会缺失。 + let encoded = try XCTUnwrap(model.toDictionary()) + let settings = try XCTUnwrap(encoded["settings"] as? [String: Any]) + XCTAssertEqual(settings["didMappingRun"] as? Bool, true) + } + + /// isEncodable: false 的 @SmartIgnored 属性:mapping 回调后仍不参与编码 + func testNotEncodableStaysExcludedAfterMapping() throws { + let model = try XCTUnwrap(IgnoredPlainHost.deserialize(from: ["other": 1])) + + XCTAssertTrue(model.settings.didMappingRun) + let encoded = try XCTUnwrap(model.toDictionary()) + XCTAssertNil(encoded["settings"]) + } + + /// 直接验证 wrappedValueDidFinishMapping 重建时透传 isEncodable + func testWrappedValueDidFinishMappingCarriesIsEncodableFlag() { + let encodableWrapper = SmartIgnored(wrappedValue: IgnoredModel(), isEncodable: true) + XCTAssertEqual(encodableWrapper.wrappedValueDidFinishMapping()?.isEncodable, true) + + let plainWrapper = SmartIgnored(wrappedValue: IgnoredModel(), isEncodable: false) + XCTAssertEqual(plainWrapper.wrappedValueDidFinishMapping()?.isEncodable, false) + } +} + +/// 声明 isEncodable: true 的宿主模型 +private struct IgnoredEncodableHost: SmartCodableX { + @SmartIgnored(wrappedValue: IgnoredModel(), isEncodable: true) + var settings: IgnoredModel +} + +/// 声明 isEncodable: false 的宿主模型 +private struct IgnoredPlainHost: SmartCodableX { + @SmartIgnored(wrappedValue: IgnoredModel(), isEncodable: false) + var settings: IgnoredModel +} + +/// 内层模型:记录 didFinishMapping 是否被调用 +private struct IgnoredModel: SmartCodableX { + var didMappingRun = false + + mutating func didFinishMapping() { + didMappingRun = true + } +} From f0ae795d704eefaa359ca5fd2502796820d76618 Mon Sep 17 00:00:00 2001 From: Rock-Connotation <787661104@qq.com> Date: Thu, 17 Sep 2026 11:50:05 +0800 Subject: [PATCH 06/17] =?UTF-8?q?test:=20=E5=AE=8C=E5=96=84=E8=A7=A3?= =?UTF-8?q?=E7=A0=81=E6=96=AD=E8=A8=80=E4=B8=8E=20toJSONString=20=E6=98=A0?= =?UTF-8?q?=E5=B0=84=E5=AD=97=E6=AE=B5=E6=96=AD=E8=A8=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Tests/DecodeEdgeCaseTests.swift | 4 ++-- Tests/DecodeTests.swift | 2 ++ Tests/EncodeTests.swift | 26 +++++++++++++++++++------- 3 files changed, 23 insertions(+), 9 deletions(-) diff --git a/Tests/DecodeEdgeCaseTests.swift b/Tests/DecodeEdgeCaseTests.swift index bfaa588..795a622 100644 --- a/Tests/DecodeEdgeCaseTests.swift +++ b/Tests/DecodeEdgeCaseTests.swift @@ -43,8 +43,8 @@ final class DecodeEdgeCaseTests: XCTestCase { XCTAssertEqual(model?.values, ["a", "b", "c"]) } - /// 数组越界时回退默认值,不影响后续字段解码 - func testDecodeStringOutOfBoundsFallsBackToDefaultAndKeepsFollowingValueAligned() { + /// 数组越界时回退 String 的默认值 + func testDecodeStringOutOfBoundsFallsBackToDefault() { struct Probe: Decodable { let first: String let second: String diff --git a/Tests/DecodeTests.swift b/Tests/DecodeTests.swift index 520daed..9a3d6ac 100644 --- a/Tests/DecodeTests.swift +++ b/Tests/DecodeTests.swift @@ -40,6 +40,8 @@ final class DecodeTests: XCTestCase { XCTAssertEqual(model?.id, 563) XCTAssertEqual(model?.ownerId, 264) XCTAssertEqual(model?.title, "langwang004+82 ワークスペース") + XCTAssertEqual(model?.icon, "") + XCTAssertEqual(model?.type, 2) XCTAssertEqual(model?.usedSeat, 1) XCTAssertEqual(model?.createdAt, "2025-07-25T02:58:35Z") XCTAssertEqual(model?.subscription?.cancelAtPeriodEnd, true) diff --git a/Tests/EncodeTests.swift b/Tests/EncodeTests.swift index 176bed9..fed3b91 100644 --- a/Tests/EncodeTests.swift +++ b/Tests/EncodeTests.swift @@ -1,3 +1,4 @@ +import Foundation import XCTest @testable import SmartCodable @@ -38,21 +39,32 @@ final class EncodeTests: XCTestCase { XCTAssertEqual(subscription?["current_period_end_at"] as? String, "2025-07-30T03:37:03Z") XCTAssertEqual(subscription?["price_id"] as? String, "personal_plan_annual_trial") XCTAssertEqual(subscription?["status"] as? String, "past_due") + + XCTAssertTrue(TestSupport.deepEqualDict(original, encoded ?? [:]), + "编码结果应与原始载荷完全一致,不能混入未映射的 camelCase 键") } - /// toJSONString(useMappedKeys:):JSON字符串输出中包含映射后的原始字段名 - func testToJSONStringIncludesMappedKeysWhenRequested() { + /// toJSONString(useMappedKeys:):JSON 字符串应完整使用映射后的字段名 + func testToJSONStringIncludesMappedKeysWhenRequested() throws { var model = WorkspaceSubscription() model.cancelAtPeriodEnd = true model.currentPeriodEndAt = "2025-07-30T03:37:03Z" model.priceId = "personal_plan_annual_trial" model.status = "past_due" - let json = model.toJSONString(useMappedKeys: true) + let json = try XCTUnwrap(model.toJSONString(useMappedKeys: true)) + let data = try XCTUnwrap(json.data(using: .utf8)) + let encoded = try XCTUnwrap( + JSONSerialization.jsonObject(with: data) as? [String: Any] + ) + let expected: [String: Any] = [ + "cancel_at_period_end": true, + "current_period_end_at": "2025-07-30T03:37:03Z", + "price_id": "personal_plan_annual_trial", + "status": "past_due", + ] - XCTAssertNotNil(json) - XCTAssertTrue(json?.contains("\"cancel_at_period_end\":true") == true) - XCTAssertTrue(json?.contains("\"current_period_end_at\":\"2025-07-30T03:37:03Z\"") == true) - XCTAssertTrue(json?.contains("\"price_id\":\"personal_plan_annual_trial\"") == true) + XCTAssertTrue(TestSupport.deepEqualDict(expected, encoded), + "JSON 字符串应包含且仅包含预期映射字段") } } From 1c001292994a0f39ab2af58478f41357671dc2be Mon Sep 17 00:00:00 2001 From: Rock-Connotation <787661104@qq.com> Date: Thu, 17 Sep 2026 11:50:08 +0800 Subject: [PATCH 07/17] =?UTF-8?q?ci:=20=E5=A2=9E=E5=8A=A0=20Thread=20Sanit?= =?UTF-8?q?izer=20=E5=B9=B6=E5=8F=91=E6=A3=80=E6=B5=8B=E5=B7=A5=E4=BD=9C?= =?UTF-8?q?=E6=B5=81=E5=8F=8A=E6=B5=8B=E8=AF=95=E6=B3=A8=E9=87=8A=E8=AF=B4?= =?UTF-8?q?=E6=98=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/swift.yml | 10 ++++++++++ Tests/GlobalOptionsConcurrencyTests.swift | 3 ++- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/.github/workflows/swift.yml b/.github/workflows/swift.yml index 21ae770..389557a 100644 --- a/.github/workflows/swift.yml +++ b/.github/workflows/swift.yml @@ -20,3 +20,13 @@ jobs: run: swift build -v - name: Run tests run: swift test -v + + thread-sanitizer: + + runs-on: macos-latest + timeout-minutes: 15 + + steps: + - uses: actions/checkout@v4 + - name: Run Thread Sanitizer concurrency tests + run: swift test --sanitize=thread --filter GlobalOptionsConcurrencyTests diff --git a/Tests/GlobalOptionsConcurrencyTests.swift b/Tests/GlobalOptionsConcurrencyTests.swift index 4ea4dbf..e26c24e 100644 --- a/Tests/GlobalOptionsConcurrencyTests.swift +++ b/Tests/GlobalOptionsConcurrencyTests.swift @@ -1,7 +1,8 @@ import XCTest @testable import SmartCodable -/// 全局配置并发安全测试:验证 SmartCodableOptions 和 SmartSentinel 在多线程并发读写下的稳定性 +/// 全局配置并发测试。普通运行验证任务完成且无死锁;数据竞争必须配合 Thread Sanitizer 检测: +/// `swift test --sanitize=thread --filter GlobalOptionsConcurrencyTests` final class GlobalOptionsConcurrencyTests: XCTestCase { override func tearDown() { // 每次测试后恢复默认值,防止污染其他测试 From c5489b3dbd2300983cf7bc412968ff51a2c74775 Mon Sep 17 00:00:00 2001 From: Rock-Connotation <787661104@qq.com> Date: Thu, 17 Sep 2026 17:18:47 +0800 Subject: [PATCH 08/17] =?UTF-8?q?fix(cache):=20=E5=BF=AB=E7=85=A7=E5=BC=95?= =?UTF-8?q?=E5=85=A5=E4=BD=9C=E7=94=A8=E5=9F=9F=E4=B8=8E=E6=B4=BB=E8=B7=83?= =?UTF-8?q?=E6=89=80=E6=9C=89=E8=80=85=EF=BC=8C=E4=BF=AE=E5=A4=8D=E8=B7=A8?= =?UTF-8?q?=E4=BD=9C=E7=94=A8=E5=9F=9F=E5=BF=AB=E7=85=A7=E6=B1=A1=E6=9F=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../SmartCodable/Core/Cache/Cachable.swift | 29 +- .../JSONDecoder/Decoder/DecodingCache.swift | 259 ++++++++++++++---- .../Impl/JSONDecoderImpl+KeyedContainer.swift | 3 +- .../Decoder/Impl/JSONDecoderImpl.swift | 1 - .../Core/Sentinel/SmartSentinel.swift | 3 +- 5 files changed, 212 insertions(+), 83 deletions(-) diff --git a/Sources/SmartCodable/Core/Cache/Cachable.swift b/Sources/SmartCodable/Core/Cache/Cachable.swift index dea38d3..88af477 100644 --- a/Sources/SmartCodable/Core/Cache/Cachable.swift +++ b/Sources/SmartCodable/Core/Cache/Cachable.swift @@ -19,20 +19,17 @@ protocol Cachable { } -extension Cachable { +extension Cachable where SomeSnapshot == EncodingSnapshot { - /// 根据解码路径查找对应的快照容器。 - /// - /// 该方法用于在内部缓存的快照列表中,查找与传入 `codingPath` 精确匹配的 `DecodingSnapshot`。 - /// 快照用于缓存某一解码路径下的初始值或上下文信息,便于后续访问或懒加载。 - /// - /// - Parameter codingPath: 当前字段或容器所在的完整解码路径。 - /// - Returns: 匹配路径的快照对象,若不存在则返回 `nil`。 + /// 查找匹配当前编码路径的最新快照(仅用于编码;解码必须使用活跃所有者快照)。 func findSnapShot(with codingPath: [CodingKey]) -> SomeSnapshot? { return snapshots.last { codingPathEquals($0.codingPath, codingPath) } } - - private func codingPathEquals(_ lhs: [CodingKey], _ rhs: [CodingKey]) -> Bool { +} + +extension Cachable { + /// 比较两个 codingPath 的层级与键值是否完全一致。 + func codingPathEquals(_ lhs: [CodingKey], _ rhs: [CodingKey]) -> Bool { guard lhs.count == rhs.count else { return false } for (l, r) in zip(lhs, rhs) { if l.stringValue != r.stringValue || l.intValue != r.intValue { @@ -54,18 +51,6 @@ protocol Snapshot { var codingPath: [CodingKey] { get set } - /// String representation of the object type - var objectTypeName: String? { get } - /// Records the custom transformer for properties var transformers: [SmartValueTransformer]? { set get } } - -extension Snapshot { - var objectTypeName: String? { - if let t = objectType { - return String(describing: t) - } - return nil - } -} diff --git a/Sources/SmartCodable/Core/JSONDecoder/Decoder/DecodingCache.swift b/Sources/SmartCodable/Core/JSONDecoder/Decoder/DecodingCache.swift index f0ae8e7..51f1740 100644 --- a/Sources/SmartCodable/Core/JSONDecoder/Decoder/DecodingCache.swift +++ b/Sources/SmartCodable/Core/JSONDecoder/Decoder/DecodingCache.swift @@ -14,9 +14,17 @@ class DecodingCache: Cachable { typealias SomeSnapshot = DecodingSnapshot - /// Stack of decoding snapshots + /// 解码快照栈 private(set) var snapshots: [DecodingSnapshot] = [] + /// 作用域唯一标识递增计数器 + private var nextScopeIdentifier = 0 + /// 当前正在执行解码的活跃所有者栈 + private var activeOwners: [ActiveOwner] = [] + + /// 当前活跃所有者栈深度 + var activeOwnerDepth: Int { activeOwners.count } + /// 在指定类型的快照作用域内执行解码。 /// /// 快照的资格判断、创建与清理全部收口在这里: @@ -28,74 +36,218 @@ class DecodingCache: Cachable { _ body: () throws -> Result ) rethrows -> Result { - guard let object = cachedSmartDecodableType(for: type) else { + guard let request = snapshotRequest(for: type) else { return try body() } - let snapshot = DecodingSnapshot() - snapshot.codingPath = codingPath - // [initialValues] Lazy initialization: - // Generate initial values via reflection only when first accessed, - // using the recorded objectType to optimize parsing performance. - snapshot.objectType = object - snapshots.append(snapshot) + // 同一路径下若该类型尚未在当前作用域执行,则复用作用域;否则分配新作用域 + let currentScope = activeOwners.last(where: { + codingPathEquals($0.snapshot.codingPath, codingPath) + })?.scopeIdentifier + let scopeIdentifier: Int + if let currentScope, + !isExecuting( + request.activeType, + as: request.mode, + in: currentScope + ) { + scopeIdentifier = currentScope + } else { + nextScopeIdentifier += 1 + scopeIdentifier = nextScopeIdentifier + } + + var createdSnapshots: [DecodingSnapshot] = [] + // 保证同一 (scope, codingPath, objectType) 下快照唯一 + func ownerSnapshot(for objectType: SmartDecodable.Type) -> DecodingSnapshot { + if let existing = snapshot( + for: objectType, + codingPath: codingPath, + scopeIdentifier: scopeIdentifier + ) { + return existing + } + + let snapshot = DecodingSnapshot() + snapshot.codingPath = codingPath + snapshot.objectType = objectType + snapshot.scopeIdentifier = scopeIdentifier + snapshots.append(snapshot) + createdSnapshots.append(snapshot) + return snapshot + } + + let activeSnapshot = ownerSnapshot(for: request.activeType) + // 预声明包装器内层模型的快照(同一作用域),便于后续所有者切换 + if let wrappedType = request.wrappedType { + _ = ownerSnapshot(for: wrappedType) + } + activeOwners.append(ActiveOwner( + mode: request.mode, + scopeIdentifier: scopeIdentifier, + snapshot: activeSnapshot + )) defer { - // 栈顶必须是本作用域创建的快照;身份不匹配时不盲目弹出其他作用域的快照 - assert(snapshots.last === snapshot, - "Decoding snapshot stack top is not the one created by this scope") - if let last = snapshots.last, last === snapshot { - snapshots.removeLast() + assert(activeOwners.last?.snapshot === activeSnapshot, + "Active decoding owner is not the one created by this scope") + if activeOwners.last?.snapshot === activeSnapshot { + activeOwners.removeLast() + } + + for created in createdSnapshots.reversed() { + assert(snapshots.last === created, + "Decoding snapshot stack top is not the owner created by this entry") + if snapshots.last === created { + snapshots.removeLast() + } } } return try body() } - /// 解析需要缓存的 `SmartDecodable` 类型 - /// - /// 快照的意义是“某个 SmartDecodable 的 `init(from:)` 正在当前作用域内执行”, - /// 因此资格只有一个判据:即将执行 `init(from:)` 的类型本身是 SmartDecodable。 - /// - /// 属性包装器类型不在此列:包装器的 `init(from:)` 运行时内层模型尚未初始化, - /// 快照由内层模型自己的解码作用域(`unwrap(as:)` / `decodeInPlace(_:)`)建立, - /// 避免同一模型在嵌套解码时被重复压栈。 - private func cachedSmartDecodableType(for type: T.Type) -> SmartDecodable.Type? { - return type as? SmartDecodable.Type + /// 解析目标类型的快照请求,提取活跃所有者类型与内层包装模型类型 + private func snapshotRequest(for type: T.Type) -> SnapshotRequest? { + let ownerType = type as? SmartDecodable.Type + let wrappedType = (type as? any PropertyWrapperable.Type)?.wrappedSmartDecodableType + guard let activeType = ownerType ?? wrappedType else { return nil } + + let distinctWrappedType = wrappedType.flatMap { wrappedType in + ObjectIdentifier(wrappedType) == ObjectIdentifier(activeType) ? nil : wrappedType + } + return SnapshotRequest( + activeType: activeType, + wrappedType: distinctWrappedType, + mode: ownerType == nil ? .wrapperScope : .owner + ) + } + + /// 判断指定类型是否已经在该作用域内以相同模式处于执行中 + private func isExecuting( + _ objectType: SmartDecodable.Type, + as mode: ActiveOwner.Mode, + in scopeIdentifier: Int + ) -> Bool { + activeOwners.contains(where: { active in + guard active.scopeIdentifier == scopeIdentifier, + active.mode == mode, + let activeType = active.snapshot.objectType else { + return false + } + return ObjectIdentifier(activeType) == ObjectIdentifier(objectType) + }) } + + /// 查找指定作用域、路径与类型的快照 + private func snapshot( + for objectType: SmartDecodable.Type, + codingPath: [CodingKey], + scopeIdentifier: Int + ) -> DecodingSnapshot? { + snapshots.last(where: { snapshot in + guard snapshot.scopeIdentifier == scopeIdentifier, + codingPathEquals(snapshot.codingPath, codingPath), + let snapshotType = snapshot.objectType else { + return false + } + return ObjectIdentifier(snapshotType) == ObjectIdentifier(objectType) + }) + } + + /// 获取指定路径下当前活跃所有者的快照 + private func activeSnapshot(at codingPath: [CodingKey]) -> DecodingSnapshot? { + activeOwners.last(where: { + codingPathEquals($0.snapshot.codingPath, codingPath) + })?.snapshot + } + + /// 获取指定路径下当前正在执行解码的活跃所有者类型(排除预声明但尚未激活的内层包装类型) + func activeOwner(at codingPath: [CodingKey]) -> SmartDecodable.Type? { + activeSnapshot(at: codingPath)?.objectType + } +} + +/// 快照构建请求参数 +private struct SnapshotRequest { + /// 待激活的所有者类型 + let activeType: SmartDecodable.Type + /// 包装器内部嵌套的 SmartDecodable 类型(若存在) + let wrappedType: SmartDecodable.Type? + /// 所有者执行模式 + let mode: ActiveOwner.Mode +} + +/// 正在执行解码的活跃所有者记录 +private struct ActiveOwner { + enum Mode { + /// 包装器作用域模式(包装器本身非 SmartDecodable) + case wrapperScope + /// 独立所有者模式(模型自身或遵循 SmartDecodable 的双协议包装器) + case owner + } + + let mode: Mode + let scopeIdentifier: Int + let snapshot: DecodingSnapshot } // MARK: - 获取属性初始值 extension DecodingCache { + + /// 恢复宿主属性声明时的完整包装器实例。 + /// + /// 与 `initialValueIfPresent` 的 wrappedValue 兜底不同,这个接口保留包装器 + /// 自身的配置状态(例如 `SmartIgnored.isEncodable`),并且只读取精确匹配 + /// 宿主路径的快照,不从其他嵌套模型的同名属性猜测状态。 + func initialPropertyWrapperIfPresent( + forKey key: CodingKey?, + codingPath: [CodingKey], + as type: Wrapper.Type + ) -> Wrapper? { + guard let key = key else { + return nil + } + + guard let snapshot = activeSnapshot(at: codingPath) else { + return nil + } + + if snapshot.initialValues.isEmpty { + populateInitialValues(snapshot: snapshot) + } + + return snapshot.initialValues["_" + key.stringValue] as? Wrapper + } /// 查找指定解码路径下容器中某个字段的初始值。 /// /// 该方法会根据传入的 `codingPath`(代表某个解码容器的位置), - /// 在缓存的快照中查找对应容器,并尝试获取该容器中 `key` 对应字段的初始值。 - /// 如果该容器尚未初始化初始值,则会延迟初始化一次(通过反射等方式)。 + /// 从当前活动 owner 获取 `key` 对应字段的初始值。 + /// 如果该 owner 尚未初始化初始值,则会延迟初始化一次(通过反射等方式)。 func initialValueIfPresent(forKey key: CodingKey?, codingPath: [CodingKey]) -> T? { guard let key = key else { return nil } - - // 查找匹配当前路径的快照 - guard let snapshot = findSnapShot(with: codingPath) else { return nil } - // Lazy initialization: Generate initial values via reflection only when first accessed, - // using the recorded objectType to optimize parsing performance + guard let snapshot = activeSnapshot(at: codingPath) else { return nil } + if snapshot.initialValues.isEmpty { populateInitialValues(snapshot: snapshot) } - - guard let cacheValue = snapshot.initialValues[key.stringValue] else { - // Handle @propertyWrapper cases (prefixed with underscore) + + return initialValue(for: key, in: snapshot) + } + + /// 从指定快照中读取字段初始值(支持属性包装器与枚举默认值) + private func initialValue(for key: CodingKey, in snapshot: DecodingSnapshot) -> T? { + guard let cached = snapshot.initialValues[key.stringValue] else { return handlePropertyWrapperCases(for: key, snapshot: snapshot) } - - if let value = cacheValue as? T { + if let value = cached as? T { return value - } else if let caseValue = cacheValue as? any SmartCaseDefaultable { + } + if let caseValue = cached as? any SmartCaseDefaultable { return caseValue.rawValue as? T } - return nil } @@ -122,39 +274,29 @@ extension DecodingCache { /// - Note: /// - 此方法依赖于容器路径 `codingPath` 查找快照(snapshot),快照中包含该容器注册的所有转换器列表。 /// - 若 key 为 `nil` 或找不到快照,或快照中未注册转换器,均返回 `nil`。 - /// - 匹配逻辑基于 key 的 `stringValue`。 + /// - 转换器只从当前活动 owner 读取。 func valueTransformer(for key: CodingKey?, in containerPath: [CodingKey]) -> SmartValueTransformer? { guard let lastKey = key else { return nil } - - guard let snapshot = findSnapShot(with: containerPath) else { return nil } - - // Initialize transformers only once - if snapshot.transformers?.isEmpty ?? true { - return nil - } - - let transformer = snapshot.transformers?.first(where: { + + return activeSnapshot(at: containerPath)?.transformers?.first(where: { $0.location.stringValue == lastKey.stringValue }) - return transformer } } extension DecodingCache { - /// Handles property wrapper cases (properties prefixed with underscore) + /// 处理属性包装器字段(以下划线 `_` 为前缀存储)的初始值提取 private func handlePropertyWrapperCases(for key: CodingKey, snapshot: DecodingSnapshot) -> T? { if let cached = snapshot.initialValues["_" + key.stringValue] { return extractWrappedValue(from: cached) } - return snapshots.reversed().lazy.compactMap { - $0.initialValues["_" + key.stringValue] - }.first.flatMap(extractWrappedValue) + return nil } - /// Extracts wrapped value from potential property wrapper types + /// 从属性包装器实例中解包出实际的 wrappedValue private func extractWrappedValue(from value: Any) -> T? { if let wrapper = value as? SmartIgnored { return wrapper.wrappedValue @@ -188,20 +330,21 @@ extension DecodingCache { -/// Snapshot of decoding state for a particular model +/// 单个模型的解码状态快照 class DecodingSnapshot: Snapshot { - typealias ObjectType = SmartDecodable.Type var objectType: (any SmartDecodable.Type)? var codingPath: [any CodingKey] = [] + + /// 所属作用域的唯一标识 + var scopeIdentifier = 0 lazy var transformers: [SmartValueTransformer]? = { objectType?.mappingForValue() }() - /// Dictionary storing initial values of properties - /// Key: Property name, Value: Initial value + /// 存储属性初始值的字典(Key: 属性名, Value: 初始值) var initialValues: [String : Any] = [:] } diff --git a/Sources/SmartCodable/Core/JSONDecoder/Decoder/Impl/JSONDecoderImpl+KeyedContainer.swift b/Sources/SmartCodable/Core/JSONDecoder/Decoder/Impl/JSONDecoderImpl+KeyedContainer.swift index 243aa68..0647aef 100644 --- a/Sources/SmartCodable/Core/JSONDecoder/Decoder/Impl/JSONDecoderImpl+KeyedContainer.swift +++ b/Sources/SmartCodable/Core/JSONDecoder/Decoder/Impl/JSONDecoderImpl+KeyedContainer.swift @@ -498,7 +498,8 @@ fileprivate func _convertDictionary(_ dictionary: [String: JSONValue], impl: JSO }, uniquingKeysWith: { (first, _) in first }) } - guard let type = impl.cache.findSnapShot(with: impl.codingPath)?.objectType else { return dictionary } + // 仅基于当前路径活跃的解码所有者进行键名映射转换 + guard let type = impl.cache.activeOwner(at: impl.codingPath) else { return dictionary } if let tempValue = KeysMapper.convertFrom(JSONValue.object(dictionary), type: type), let dict = tempValue.object { return dict diff --git a/Sources/SmartCodable/Core/JSONDecoder/Decoder/Impl/JSONDecoderImpl.swift b/Sources/SmartCodable/Core/JSONDecoder/Decoder/Impl/JSONDecoderImpl.swift index c0aba4c..c3044b5 100644 --- a/Sources/SmartCodable/Core/JSONDecoder/Decoder/Impl/JSONDecoderImpl.swift +++ b/Sources/SmartCodable/Core/JSONDecoder/Decoder/Impl/JSONDecoderImpl.swift @@ -136,4 +136,3 @@ internal struct _JSONKey: CodingKey { internal static let `super` = _JSONKey(stringValue: "super")! } - diff --git a/Sources/SmartCodable/Core/Sentinel/SmartSentinel.swift b/Sources/SmartCodable/Core/Sentinel/SmartSentinel.swift index 87fe084..361ff2b 100644 --- a/Sources/SmartCodable/Core/Sentinel/SmartSentinel.swift +++ b/Sources/SmartCodable/Core/Sentinel/SmartSentinel.swift @@ -74,7 +74,8 @@ extension SmartSentinel { let typeString = String(describing: T.self) guard !typeString.starts(with: "SmartIgnored<") else { return } - let className = impl.cache.findSnapShot(with: impl.codingPath)?.objectTypeName ?? "" + // 获取当前路径活跃所有者的类型名称,用于哨兵日志定位 + let className = impl.cache.activeOwner(at: impl.codingPath).map { String(describing: $0) } ?? "" var path = impl.codingPath path.append(key) From 6daf1ef0a24301b079923a6efeba6d23538ea06a Mon Sep 17 00:00:00 2001 From: Rock-Connotation <787661104@qq.com> Date: Thu, 17 Sep 2026 17:18:49 +0800 Subject: [PATCH 09/17] =?UTF-8?q?fix(wrapper):=20SmartIgnored=20=E6=97=A0?= =?UTF-8?q?=E6=A0=87=E8=AE=B0=E8=A7=A3=E7=A0=81=E8=B7=AF=E5=BE=84=E6=81=A2?= =?UTF-8?q?=E5=A4=8D=E5=AE=8C=E6=95=B4=E5=8C=85=E8=A3=85=E5=99=A8=E5=A3=B0?= =?UTF-8?q?=E6=98=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Core/PropertyWrapper/SmartIgnored.swift | 26 +++++++++---------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/Sources/SmartCodable/Core/PropertyWrapper/SmartIgnored.swift b/Sources/SmartCodable/Core/PropertyWrapper/SmartIgnored.swift index 4d99ecf..630acd9 100644 --- a/Sources/SmartCodable/Core/PropertyWrapper/SmartIgnored.swift +++ b/Sources/SmartCodable/Core/PropertyWrapper/SmartIgnored.swift @@ -75,8 +75,19 @@ extension SmartIgnored: Codable { ) } - /// The resolution triggered by the other three parties may be resolved here. - wrappedValue = try impl.smartDecode(type: T.self) + // 第三方解码路径(无 parsingMark)下,从宿主快照中恢复完整的包装器声明, + // 确保包装器自身的配置状态(如 isEncodable)及 wrappedValue 完整保留。 + var hostPath = impl.codingPath + let key = hostPath.popLast() + if let declared = impl.cache.initialPropertyWrapperIfPresent( + forKey: key, + codingPath: hostPath, + as: Self.self + ) { + self = declared + } else { + wrappedValue = try Patcher.defaultForType() + } } public func encode(to encoder: Encoder) throws { @@ -92,14 +103,3 @@ extension SmartIgnored: Codable { } } } - - -extension JSONDecoderImpl { - fileprivate func smartDecode(type: T.Type) throws -> T { - // 取“宿主为当前属性声明的初始值”:宿主快照位于父级容器路径上, - // 当前 impl 的 codingPath 末位即该属性的 key。 - var hostPath = codingPath - let key = hostPath.popLast() - return try cache.initialValue(forKey: key, codingPath: hostPath) - } -} From c38aea53909a25db7e0e4b54ff44a9ce97758d48 Mon Sep 17 00:00:00 2001 From: Rock-Connotation <787661104@qq.com> Date: Thu, 17 Sep 2026 17:18:52 +0800 Subject: [PATCH 10/17] =?UTF-8?q?feat(wrapper):=20=E6=96=B0=E5=A2=9E=20dec?= =?UTF-8?q?odeWrappedValue=20=E5=86=85=E5=B1=82=E6=89=80=E6=9C=89=E8=80=85?= =?UTF-8?q?=E5=88=87=E6=8D=A2=E6=8E=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../PropertyWrapper/PropertyWrapperProtocol.swift | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/Sources/SmartCodable/Core/PropertyWrapper/PropertyWrapperProtocol.swift b/Sources/SmartCodable/Core/PropertyWrapper/PropertyWrapperProtocol.swift index da6c531..feefd54 100644 --- a/Sources/SmartCodable/Core/PropertyWrapper/PropertyWrapperProtocol.swift +++ b/Sources/SmartCodable/Core/PropertyWrapper/PropertyWrapperProtocol.swift @@ -54,6 +54,19 @@ public extension PropertyWrapperable { } } +public extension PropertyWrapperable where WrappedValue: Decodable { + /// 解码属性包装器的内层值,并在 SmartCodable 中显式切换到内层解码所有者。 + /// + /// 同时遵循 `PropertyWrapperable` 与 `SmartDecodable` 的双协议包装器应使用此方法, + /// 替代直接调用 `WrappedValue(from: decoder)`,以保证正确应用内层模型的默认值与键值映射。 + static func decodeWrappedValue(from decoder: Decoder) throws -> WrappedValue { + if let decoder = decoder as? JSONDecoderImpl { + return try decoder.unwrap(as: WrappedValue.self) + } + return try WrappedValue(from: decoder) + } +} + protocol _OptionalType { static var wrappedType: Any.Type { get } } From 521aa5b3a5e1f9ddfa52d8af43d8e2bdffca2bd5 Mon Sep 17 00:00:00 2001 From: Rock-Connotation <787661104@qq.com> Date: Thu, 17 Sep 2026 17:18:54 +0800 Subject: [PATCH 11/17] =?UTF-8?q?test:=20=E8=A1=A5=E5=85=85=E8=A7=A3?= =?UTF-8?q?=E7=A0=81=E7=BC=93=E5=AD=98=E7=94=9F=E5=91=BD=E5=91=A8=E6=9C=9F?= =?UTF-8?q?=E4=B8=8E=20SmartIgnored=20=E6=81=A2=E5=A4=8D=E8=A1=8C=E4=B8=BA?= =?UTF-8?q?=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Tests/DecodingCacheLifecycleTests.swift | 748 ++++++++++++++++++++++++ Tests/SmartIgnoredTests.swift | 55 ++ 2 files changed, 803 insertions(+) diff --git a/Tests/DecodingCacheLifecycleTests.swift b/Tests/DecodingCacheLifecycleTests.swift index 5ca3b09..4cc2eb7 100644 --- a/Tests/DecodingCacheLifecycleTests.swift +++ b/Tests/DecodingCacheLifecycleTests.swift @@ -153,8 +153,245 @@ final class DecodingCacheLifecycleTests: XCTestCase { XCTAssertEqual(model.payload.score, 11) } + // MARK: - 第三方属性包装器的模型快照作用域 + + /// 第三方包装器直接调用 `Value(from:)` 时,内层 SmartDecodable 仍应拥有自己的快照, + /// 并与通过 SingleValueDecodingContainer 重新进入 unwrap 的包装器保持一致。 + func testThirdPartyWrappersPreserveWrappedModelDefaultsWithoutDuplicateSnapshots() throws { + WrapperProbeModel.lastObservedDepth = nil + let direct = try XCTUnwrap(DirectWrapperHost.deserialize(from: ["payload": [:]])) + + XCTAssertEqual(direct.payload.count, 99) + XCTAssertEqual(WrapperProbeModel.lastObservedDepth, 2, + "直接初始化路径应只有宿主与内层模型两个活动快照") + + WrapperProbeModel.lastObservedDepth = nil + let container = try XCTUnwrap(ContainerWrapperHost.deserialize(from: ["payload": [:]])) + + XCTAssertEqual(container.payload.count, 99) + XCTAssertEqual(WrapperProbeModel.lastObservedDepth, 2, + "容器解码路径不应为同一个内层模型重复建立快照") + } + + /// 双协议包装器与内层模型共享 codingPath 时,两者都必须保留各自声明的默认值。 + func testDualConformingWrapperPreservesWrapperAndWrappedModelDefaults() throws { + let direct = try XCTUnwrap(DualDirectWrapperHost.deserialize(from: ["payload": [:]])) + XCTAssertEqual(direct.payload.count, 99) + XCTAssertEqual(direct.$payload.marker, 7) + } + + /// 包装器自己的 key/value mapping 必须归属于包装器 owner,不能被同路径的内层模型覆盖。 + func testDualConformingWrapperKeepsItsOwnKeyAndValueMappings() throws { + let host = try XCTUnwrap(DualDirectWrapperHost.deserialize(from: [ + "payload": [ + "wrapper_marker": "41", + "wrapped_count": "2" + ] + ])) + + XCTAssertEqual(host.$payload.marker, 42) + XCTAssertEqual(host.payload.count, 102) + } + + /// 同名、无 mapping 的字段也必须按 CodingKeys owner 归属,不能固定取 owner 组末尾。 + func testDualConformingWrapperAndWrappedModelKeepSameNamedDefaults() throws { + let direct = try XCTUnwrap(SameNameDirectHost.deserialize(from: ["payload": [:]])) + XCTAssertEqual(direct.$payload.shared, 7) + XCTAssertEqual(direct.payload.shared, 99) + + let container = try XCTUnwrap(SameNameContainerHost.deserialize(from: ["payload": [:]])) + XCTAssertEqual(container.$payload.shared, 7) + XCTAssertEqual(container.payload.shared, 99) + + let innerFirst = try XCTUnwrap(SameNameInnerFirstHost.deserialize(from: ["payload": [:]])) + XCTAssertEqual(innerFirst.$payload.shared, 7) + XCTAssertEqual(innerFirst.payload.shared, 99) + } + + /// 旧 wrapper 没有 owner transition 时保持确定性的 wrapper-first 兼容语义。 + func testLegacyDualWrapperWithoutOwnerTransitionUsesWrapperOwner() throws { + let host = try XCTUnwrap(LegacySameNameHost.deserialize(from: ["payload": [:]])) + + XCTAssertEqual(host.$payload.shared, 7) + XCTAssertEqual(host.payload.shared, 7) + } + + /// 无 parsingMark 时,完整属性包装器恢复也必须使用当前活动的 wrapper owner。 + func testDualOwnerRestoresCompleteNestedPropertyWrapperFromWrapperOwner() throws { + let smartDecoder = SmartJSONDecoder() + let impl = JSONDecoderImpl( + userInfo: [:], + from: .object(["settings": .object([:])]), + codingPath: [], + options: smartDecoder.options + ) + + let wrapper = try impl.unwrap(as: DualIgnoredOwnerWrapper.self) + + XCTAssertEqual(wrapper.settings.level, 7) + XCTAssertTrue(wrapper.settingsIsEncodable) + XCTAssertEqual(wrapper.wrappedValue.shared, 99) + XCTAssertTrue(impl.cache.snapshots.isEmpty) + } + + /// 双层双协议 wrapper 必须在同一 scope 内逐级提升预声明 owner,不能重复创建内层 wrapper 快照。 + func testNestedDualWrappersReusePredeclaredOwnersForDirectAndContainerDecoding() throws { + NestedDualLeaf.resetObservations() + let defaults = try XCTUnwrap(NestedDirectWrapperHost.deserialize(from: ["payload": [:]])) + + XCTAssertEqual(defaults.$payload.outerMarker, 11) + XCTAssertEqual(defaults.$payload.wrappedValue.innerMarker, 22) + XCTAssertEqual(defaults.payload.value, 99) + assertNestedDualSnapshotObservations() + XCTAssertTrue(try XCTUnwrap(NestedDualLeaf.observedCache).snapshots.isEmpty) + XCTAssertEqual(try XCTUnwrap(NestedDualLeaf.observedCache).activeOwnerDepth, 0) + + NestedDualLeaf.resetObservations() + let direct = try XCTUnwrap(NestedDirectWrapperHost.deserialize(from: [ + "payload": [ + "outer_marker": "40", + "inner_marker": "50" + ] + ])) + + XCTAssertEqual(direct.$payload.outerMarker, 41) + XCTAssertEqual(direct.$payload.wrappedValue.innerMarker, 52) + XCTAssertEqual(direct.payload.value, 99) + assertNestedDualSnapshotObservations() + XCTAssertTrue(try XCTUnwrap(NestedDualLeaf.observedCache).snapshots.isEmpty) + XCTAssertEqual(try XCTUnwrap(NestedDualLeaf.observedCache).activeOwnerDepth, 0) + + NestedDualLeaf.resetObservations() + let container = try XCTUnwrap(NestedContainerWrapperHost.deserialize(from: [ + "payload": [ + "outer_marker": "40", + "inner_marker": "50" + ] + ])) + + XCTAssertEqual(container.$payload.outerMarker, 41) + XCTAssertEqual(container.$payload.wrappedValue.innerMarker, 52) + XCTAssertEqual(container.payload.value, 99) + assertNestedDualSnapshotObservations() + XCTAssertTrue(try XCTUnwrap(NestedDualLeaf.observedCache).snapshots.isEmpty) + XCTAssertEqual(try XCTUnwrap(NestedDualLeaf.observedCache).activeOwnerDepth, 0) + } + + /// 双层 owner transition 的最内层抛错时,本次 entry 创建的所有 owner 都必须按逆序清理。 + func testThrowingNestedDualWrappersDrainSnapshotsAndRestoreHostDefaults() throws { + ThrowingNestedDualLeaf.resetObservations() + + let host = try XCTUnwrap(ThrowingNestedWrapperHost.deserialize(from: [ + "payload": [:], + "trailing": "not-an-int" + ])) + + XCTAssertEqual(host.$payload.outerMarker, 11) + XCTAssertEqual(host.$payload.wrappedValue.innerMarker, 22) + XCTAssertEqual(host.payload.value, 99) + XCTAssertEqual(host.trailing, 77) + XCTAssertEqual(ThrowingNestedDualLeaf.observedSnapshotDepth, 3) + XCTAssertEqual(ThrowingNestedDualLeaf.observedActiveOwnerDepth, 3) + XCTAssertTrue(try XCTUnwrap(ThrowingNestedDualLeaf.observedCache).snapshots.isEmpty) + XCTAssertEqual(try XCTUnwrap(ThrowingNestedDualLeaf.observedCache).activeOwnerDepth, 0) + } + + private func assertNestedDualSnapshotObservations( + file: StaticString = #filePath, + line: UInt = #line + ) { + XCTAssertEqual(NestedDualLeaf.observedSnapshotDepth, 3, file: file, line: line) + XCTAssertEqual(NestedDualLeaf.observedActiveOwnerDepth, 3, file: file, line: line) + XCTAssertEqual(NestedDualLeaf.observedObjectTypeCount, 3, file: file, line: line) + XCTAssertEqual(NestedDualLeaf.observedScopeCount, 1, file: file, line: line) + } + + /// 第三方包装器的内层模型抛错时,包装器提供的快照必须随作用域清理; + /// 宿主应回退到声明值,并继续恢复后续字段的声明默认值。 + func testThrowingThirdPartyWrapperRestoresSnapshotStackAndHostDefaults() throws { + ThrowingWrapperProbeModel.observedCache = nil + + let legacyHost = try XCTUnwrap(ThrowingWrapperHost.deserialize(from: [ + "payload": [:], + "trailing": "not-an-int" + ])) + + XCTAssertEqual(legacyHost.payload.marker, 41) + XCTAssertEqual(legacyHost.trailing, 77) + XCTAssertTrue(try XCTUnwrap(ThrowingWrapperProbeModel.observedCache).snapshots.isEmpty) + + ThrowingWrapperProbeModel.observedCache = nil + let dualHost = try XCTUnwrap(ThrowingDualWrapperHost.deserialize(from: [ + "payload": [:], + "trailing": "not-an-int" + ])) + + XCTAssertEqual(dualHost.payload.marker, 41) + XCTAssertEqual(dualHost.$payload.marker, 7) + XCTAssertEqual(dualHost.trailing, 77) + XCTAssertTrue(try XCTUnwrap(ThrowingWrapperProbeModel.observedCache).snapshots.isEmpty) + } + + /// 包装器通过 singleValueContainer 重入内层模型后即使抛错,也必须清空该作用域, + /// 并让宿主后续字段继续命中宿主声明的默认值。 + func testThrowingContainerWrapperReentryCleansSnapshotsAndRestoresHostDefaults() throws { + ThrowingWrapperProbeModel.observedCache = nil + + let host = try XCTUnwrap(ThrowingContainerWrapperHost.deserialize(from: [ + "payload": [:], + "trailing": "not-an-int" + ])) + + XCTAssertEqual(host.payload.marker, 41) + XCTAssertEqual(host.trailing, 77) + XCTAssertTrue(try XCTUnwrap(ThrowingWrapperProbeModel.observedCache).snapshots.isEmpty) + } + // MARK: - SmartIgnored 无 parsingMark 路径 + func testSentinelUsesExecutingWrapperAndWrappedOwner() throws { + let previousMode = SmartSentinel.debugMode + SmartSentinel.debugMode = .verbose + defer { + SmartSentinel.debugMode = previousMode + SmartSentinel.onLogGenerated { _ in } + } + + for (payload, expectedOwner) in [ + (["wrapped_count": "2"], "DualDirectInitWrapper"), + (["wrapper_marker": "bad", "wrapped_count": "2"], "DualDirectInitWrapper"), + (["wrapper_marker": "41"], "DualWrapperProbeModel") + ] { + let logged = expectation(description: expectedOwner) + SmartSentinel.onLogGenerated { message in + XCTAssertTrue(message.contains("payload: " + expectedOwner + "\n"), message) + logged.fulfill() + } + _ = try XCTUnwrap(DualDirectWrapperHost.deserialize(from: ["payload": payload])) + wait(for: [logged], timeout: 1) + } + } + + func testActiveOwnerRestoresAfterWrappedReturnAndThrow() throws { + typealias Wrapper = DualDirectInitWrapper + try cache.withSnapshot(for: Wrapper.self, codingPath: []) { + XCTAssertTrue(self.cache.activeOwner(at: []) == Wrapper.self) + self.cache.withSnapshot(for: DualWrapperProbeModel.self, codingPath: []) { + XCTAssertTrue(self.cache.activeOwner(at: []) == DualWrapperProbeModel.self) + XCTAssertEqual(self.cache.snapshots.count, 2) + } + XCTAssertTrue(self.cache.activeOwner(at: []) == Wrapper.self) + XCTAssertThrowsError(try self.cache.withSnapshot(for: DualWrapperProbeModel.self, codingPath: []) { + XCTAssertTrue(self.cache.activeOwner(at: []) == DualWrapperProbeModel.self) + throw LifecycleTestError.boom + }) + XCTAssertTrue(self.cache.activeOwner(at: []) == Wrapper.self) + } + XCTAssertNil(cache.activeOwner(at: [])) + XCTAssertEqual(cache.activeOwnerDepth, 0) + XCTAssertTrue(cache.snapshots.isEmpty) + } + /// 未设置 parsingMark 时(第三方直接触发解码),@SmartIgnored 应取宿主声明的初始值, /// 而不是退化为 Patcher 的全新默认实例 func testSmartIgnoredWithoutParsingMarkKeepsHostDeclaredInitialValue() throws { @@ -230,6 +467,517 @@ private struct SmartAnyTargetModel: SmartCodableX { var score: Int = 11 } +private protocol ModelWrapper: PropertyWrapperable, Codable +where WrappedValue: SmartDecodable & SmartEncodable {} + +private extension ModelWrapper { + func encode(to encoder: Encoder) throws { + try wrappedValue.encode(to: encoder) + } + + static func createInstance(with value: Any) -> Self? { + guard let value = value as? WrappedValue else { return nil } + return Self(wrappedValue: value) + } + + func wrappedValueDidFinishMapping() -> Self? { + var value = wrappedValue + value.didFinishMapping() + return Self(wrappedValue: value) + } +} + +@propertyWrapper +private struct DirectInitWrapper: ModelWrapper { + var wrappedValue: Value + + init(wrappedValue: Value) { + self.wrappedValue = wrappedValue + } + + init(from decoder: Decoder) throws { + wrappedValue = try Value(from: decoder) + } + +} + +@propertyWrapper +private struct ContainerDecodeWrapper: ModelWrapper { + var wrappedValue: Value + + init(wrappedValue: Value) { + self.wrappedValue = wrappedValue + } + + init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + wrappedValue = try container.decode(Value.self) + } + +} + +@propertyWrapper +private struct DualDirectInitWrapper: ModelWrapper, SmartCodableX { + var wrappedValue: Value + var marker: Int = 7 + + var projectedValue: Self { self } + + private enum CodingKeys: String, CodingKey { + case marker + } + + init() { + wrappedValue = Value() + } + + init(wrappedValue: Value) { + self.wrappedValue = wrappedValue + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + marker = try container.decode(Int.self, forKey: .marker) + wrappedValue = try Self.decodeWrappedValue(from: decoder) + } + + static func mappingForKey() -> [SmartKeyTransformer]? { + [CodingKeys.marker <--- "wrapper_marker"] + } + + static func mappingForValue() -> [SmartValueTransformer]? { + [ + CodingKeys.marker <--- FastTransformer(fromJSON: { value in + value.flatMap(Int.init).map { $0 + 1 } + }) + ] + } + +} + +private struct DirectWrapperHost: SmartCodableX { + @DirectInitWrapper var payload = WrapperProbeModel() +} + +private struct ContainerWrapperHost: SmartCodableX { + @ContainerDecodeWrapper var payload = WrapperProbeModel() +} + +private struct DualDirectWrapperHost: SmartCodableX { + @DualDirectInitWrapper var payload = DualWrapperProbeModel() +} + +private enum NestedOuterWrapperKeys: String, CodingKey { + case outerMarker +} + +private enum NestedInnerWrapperKeys: String, CodingKey { + case innerMarker +} + +@propertyWrapper +private struct NestedOuterWrapper: ModelWrapper, SmartCodableX { + var wrappedValue: Value + var outerMarker = 11 + + var projectedValue: Self { self } + + init() { + wrappedValue = Value() + } + + init(wrappedValue: Value) { + self.wrappedValue = wrappedValue + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: NestedOuterWrapperKeys.self) + outerMarker = try container.decode(Int.self, forKey: .outerMarker) + wrappedValue = try Self.decodeWrappedValue(from: decoder) + } + + static func mappingForKey() -> [SmartKeyTransformer]? { + [NestedOuterWrapperKeys.outerMarker <--- "outer_marker"] + } + + static func mappingForValue() -> [SmartValueTransformer]? { + [ + NestedOuterWrapperKeys.outerMarker <--- FastTransformer(fromJSON: { value in + value.flatMap(Int.init).map { $0 + 1 } + }) + ] + } +} + +@propertyWrapper +private struct NestedDirectInnerWrapper: ModelWrapper, SmartCodableX { + var wrappedValue: Value + var innerMarker = 22 + + var projectedValue: Self { self } + + init() { + wrappedValue = Value() + } + + init(wrappedValue: Value) { + self.wrappedValue = wrappedValue + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: NestedInnerWrapperKeys.self) + innerMarker = try container.decode(Int.self, forKey: .innerMarker) + wrappedValue = try Self.decodeWrappedValue(from: decoder) + } + + static func mappingForKey() -> [SmartKeyTransformer]? { + [NestedInnerWrapperKeys.innerMarker <--- "inner_marker"] + } + + static func mappingForValue() -> [SmartValueTransformer]? { + [ + NestedInnerWrapperKeys.innerMarker <--- FastTransformer(fromJSON: { value in + value.flatMap(Int.init).map { $0 + 2 } + }) + ] + } +} + +@propertyWrapper +private struct NestedContainerInnerWrapper: ModelWrapper, SmartCodableX { + var wrappedValue: Value + var innerMarker = 22 + + var projectedValue: Self { self } + + init() { + wrappedValue = Value() + } + + init(wrappedValue: Value) { + self.wrappedValue = wrappedValue + } + + init(from decoder: Decoder) throws { + let keyed = try decoder.container(keyedBy: NestedInnerWrapperKeys.self) + innerMarker = try keyed.decode(Int.self, forKey: .innerMarker) + let single = try decoder.singleValueContainer() + wrappedValue = try single.decode(Value.self) + } + + static func mappingForKey() -> [SmartKeyTransformer]? { + [NestedInnerWrapperKeys.innerMarker <--- "inner_marker"] + } + + static func mappingForValue() -> [SmartValueTransformer]? { + [ + NestedInnerWrapperKeys.innerMarker <--- FastTransformer(fromJSON: { value in + value.flatMap(Int.init).map { $0 + 2 } + }) + ] + } +} + +private struct NestedDirectWrapperHost: SmartCodableX { + @NestedOuterWrapper @NestedDirectInnerWrapper var payload = NestedDualLeaf() +} + +private struct NestedContainerWrapperHost: SmartCodableX { + @NestedOuterWrapper @NestedContainerInnerWrapper var payload = NestedDualLeaf() +} + +private struct ThrowingNestedWrapperHost: SmartCodableX { + @NestedOuterWrapper @NestedDirectInnerWrapper var payload = ThrowingNestedDualLeaf() + var trailing = 77 +} + +private struct ThrowingWrapperHost: SmartCodableX { + @DirectInitWrapper var payload = ThrowingWrapperProbeModel() + var trailing: Int = 77 +} + +private struct ThrowingContainerWrapperHost: SmartCodableX { + @ContainerDecodeWrapper var payload = ThrowingWrapperProbeModel() + var trailing: Int = 77 +} + +private struct ThrowingDualWrapperHost: SmartCodableX { + @DualDirectInitWrapper var payload = ThrowingWrapperProbeModel() + var trailing: Int = 77 +} + +@propertyWrapper +private struct SameNameDirectWrapper: ModelWrapper, SmartCodableX { + var wrappedValue: Value + var shared: Int = 7 + + var projectedValue: Self { self } + + init() { + wrappedValue = Value() + } + + init(wrappedValue: Value) { + self.wrappedValue = wrappedValue + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: SharedOwnerKeys.self) + shared = try container.decode(Int.self, forKey: .shared) + wrappedValue = try Self.decodeWrappedValue(from: decoder) + } + +} + +@propertyWrapper +private struct SameNameContainerWrapper: ModelWrapper, SmartCodableX { + var wrappedValue: Value + var shared: Int = 7 + + var projectedValue: Self { self } + + init() { + wrappedValue = Value() + } + + init(wrappedValue: Value) { + self.wrappedValue = wrappedValue + } + + init(from decoder: Decoder) throws { + let keyed = try decoder.container(keyedBy: SharedOwnerKeys.self) + shared = try keyed.decode(Int.self, forKey: .shared) + let single = try decoder.singleValueContainer() + wrappedValue = try single.decode(Value.self) + } + +} + +private struct SameNameDirectHost: SmartCodableX { + @SameNameDirectWrapper var payload = SameNameModel() +} + +private struct SameNameContainerHost: SmartCodableX { + @SameNameContainerWrapper var payload = SameNameModel() +} + +@propertyWrapper +private struct SameNameInnerFirstWrapper: ModelWrapper, SmartCodableX { + var wrappedValue: Value + var shared: Int = 7 + + var projectedValue: Self { self } + + init() { + wrappedValue = Value() + } + + init(wrappedValue: Value) { + self.wrappedValue = wrappedValue + } + + init(from decoder: Decoder) throws { + wrappedValue = try Self.decodeWrappedValue(from: decoder) + let container = try decoder.container(keyedBy: SharedOwnerKeys.self) + shared = try container.decode(Int.self, forKey: .shared) + } + +} + +private struct SameNameInnerFirstHost: SmartCodableX { + @SameNameInnerFirstWrapper var payload = SameNameModel() +} + +@propertyWrapper +private struct LegacySameNameWrapper: ModelWrapper, SmartCodableX { + var wrappedValue: Value + var shared: Int = 7 + var projectedValue: Self { self } + + init() { wrappedValue = Value() } + init(wrappedValue: Value) { self.wrappedValue = wrappedValue } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: SharedOwnerKeys.self) + shared = try container.decode(Int.self, forKey: .shared) + wrappedValue = try Value(from: decoder) + } +} + +private struct LegacySameNameHost: SmartCodableX { + @LegacySameNameWrapper var payload = SameNameModel() +} + +private struct SameNameModel: SmartCodableX { + var shared: Int = 99 + + init() {} + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: SharedOwnerKeys.self) + shared = try container.decode(Int.self, forKey: .shared) + } +} + +private enum SharedOwnerKeys: String, CodingKey { + case shared +} + +@propertyWrapper +private struct DualIgnoredOwnerWrapper: PropertyWrapperable, SmartCodableX { + var wrappedValue = SameNameModel() + + @SmartIgnored(wrappedValue: DualIgnoredSettings(level: 7), isEncodable: true) + var settings: DualIgnoredSettings + + var settingsIsEncodable: Bool { _settings.isEncodable } + + private enum CodingKeys: String, CodingKey { + case settings + } + + init() {} + + init(wrappedValue: SameNameModel) { + self.wrappedValue = wrappedValue + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + _settings = try container.decode( + SmartIgnored.self, + forKey: .settings + ) + wrappedValue = try Self.decodeWrappedValue(from: decoder) + } + + func encode(to encoder: Encoder) throws { + try wrappedValue.encode(to: encoder) + } + + static func createInstance(with value: Any) -> Self? { + guard let value = value as? SameNameModel else { return nil } + return Self(wrappedValue: value) + } + + func wrappedValueDidFinishMapping() -> Self? { self } +} + +private struct DualIgnoredSettings: SmartCodableX { + var level: Int = 0 + + init() {} + + init(level: Int) { + self.level = level + } +} + +private struct WrapperProbeModel: SmartCodableX { + static var lastObservedDepth: Int? + var count: Int = 99 + + init() {} + + init(from decoder: Decoder) throws { + Self.lastObservedDepth = (decoder as? JSONDecoderImpl)?.cache.snapshots.count + let container = try decoder.container(keyedBy: CodingKeys.self) + count = try container.decode(Int.self, forKey: .count) + } +} + +private struct ThrowingWrapperProbeModel: SmartCodableX { + static var observedCache: DecodingCache? + var marker: Int = 41 + + init() {} + + init(from decoder: Decoder) throws { + Self.observedCache = (decoder as? JSONDecoderImpl)?.cache + throw LifecycleTestError.boom + } +} + +private struct DualWrapperProbeModel: SmartCodableX { + var count: Int = 99 + + private enum CodingKeys: String, CodingKey { + case count + } + + static func mappingForKey() -> [SmartKeyTransformer]? { + [CodingKeys.count <--- "wrapped_count"] + } + + static func mappingForValue() -> [SmartValueTransformer]? { + [ + CodingKeys.count <--- FastTransformer(fromJSON: { value in + value.flatMap(Int.init).map { $0 + 100 } + }) + ] + } +} + +private struct NestedDualLeaf: SmartCodableX { + static var observedCache: DecodingCache? + static var observedSnapshotDepth: Int? + static var observedActiveOwnerDepth: Int? + static var observedObjectTypeCount: Int? + static var observedScopeCount: Int? + + var value = 99 + + init() {} + + init(from decoder: Decoder) throws { + if let cache = (decoder as? JSONDecoderImpl)?.cache { + Self.observedCache = cache + Self.observedSnapshotDepth = cache.snapshots.count + Self.observedActiveOwnerDepth = cache.activeOwnerDepth + Self.observedObjectTypeCount = Set( + cache.snapshots.compactMap(\.objectType).map(ObjectIdentifier.init) + ).count + Self.observedScopeCount = Set(cache.snapshots.map(\.scopeIdentifier)).count + } + + let container = try decoder.container(keyedBy: CodingKeys.self) + value = try container.decode(Int.self, forKey: .value) + } + + static func resetObservations() { + observedCache = nil + observedSnapshotDepth = nil + observedActiveOwnerDepth = nil + observedObjectTypeCount = nil + observedScopeCount = nil + } +} + +private struct ThrowingNestedDualLeaf: SmartCodableX { + static var observedCache: DecodingCache? + static var observedSnapshotDepth: Int? + static var observedActiveOwnerDepth: Int? + + var value = 99 + + init() {} + + init(from decoder: Decoder) throws { + if let cache = (decoder as? JSONDecoderImpl)?.cache { + Self.observedCache = cache + Self.observedSnapshotDepth = cache.snapshots.count + Self.observedActiveOwnerDepth = cache.activeOwnerDepth + } + throw LifecycleTestError.boom + } + + static func resetObservations() { + observedCache = nil + observedSnapshotDepth = nil + observedActiveOwnerDepth = nil + } +} + /// 无 parsingMark 场景的宿主:声明初始值与 init() 默认值不同,用于区分取值来源 private final class MarklessHost: SmartCodableX { @SmartIgnored var settings: MarklessSettings = .init(level: 7) diff --git a/Tests/SmartIgnoredTests.swift b/Tests/SmartIgnoredTests.swift index f3102eb..dcc6f37 100644 --- a/Tests/SmartIgnoredTests.swift +++ b/Tests/SmartIgnoredTests.swift @@ -35,6 +35,37 @@ final class SmartIgnoredTests: XCTestCase { let plainWrapper = SmartIgnored(wrappedValue: IgnoredModel(), isEncodable: false) XCTAssertEqual(plainWrapper.wrappedValueDidFinishMapping()?.isEncodable, false) } + + /// 无 parsingMark 的第三方解码路径应恢复宿主声明的完整包装器状态: + /// 既保留 wrappedValue,也保留 isEncodable: true。 + func testWithoutParsingMarkRestoresEncodableWrapperDeclaration() throws { + let host = try decodeWithoutParsingMark(MarklessEncodableHost.self) + + XCTAssertEqual(host.settings.level, 7) + let encoded = try XCTUnwrap(host.toDictionary()) + let settings = try XCTUnwrap(encoded["settings"] as? [String: Any]) + XCTAssertEqual(settings["level"] as? Int, 7) + } + + /// 同一恢复路径必须保留 isEncodable: false,不能把所有 SmartIgnored 一律设为可编码。 + func testWithoutParsingMarkKeepsNonEncodableWrapperExcluded() throws { + let host = try decodeWithoutParsingMark(MarklessNonEncodableHost.self) + + XCTAssertEqual(host.settings.level, 7) + let encoded = try XCTUnwrap(host.toDictionary()) + XCTAssertNil(encoded["settings"]) + } + + private func decodeWithoutParsingMark(_ type: T.Type) throws -> T { + let smartDecoder = SmartJSONDecoder() + let impl = JSONDecoderImpl( + userInfo: [:], + from: .object(["settings": .object(["level": .number("999")])]), + codingPath: [], + options: smartDecoder.options + ) + return try impl.unwrap(as: type) + } } /// 声明 isEncodable: true 的宿主模型 @@ -57,3 +88,27 @@ private struct IgnoredModel: SmartCodableX { didMappingRun = true } } + +private final class MarklessEncodableHost: SmartCodableX { + @SmartIgnored(wrappedValue: MarklessIgnoredSettings(level: 7), isEncodable: true) + var settings: MarklessIgnoredSettings + + required init() {} +} + +private final class MarklessNonEncodableHost: SmartCodableX { + @SmartIgnored(wrappedValue: MarklessIgnoredSettings(level: 7), isEncodable: false) + var settings: MarklessIgnoredSettings + + required init() {} +} + +private struct MarklessIgnoredSettings: SmartCodableX { + var level: Int = 0 + + init() {} + + init(level: Int) { + self.level = level + } +} From 16f3203b4cd24fe72e3ccf49786593a92cf441fa Mon Sep 17 00:00:00 2001 From: Rock-Connotation <787661104@qq.com> Date: Thu, 17 Sep 2026 17:18:56 +0800 Subject: [PATCH 12/17] =?UTF-8?q?docs:=20=E8=A1=A5=E5=85=85=E6=B4=BB?= =?UTF-8?q?=E8=B7=83=E6=89=80=E6=9C=89=E8=80=85=E6=9C=BA=E5=88=B6=E4=B8=8E?= =?UTF-8?q?=20decodeWrappedValue=20=E4=BD=BF=E7=94=A8=E8=AF=B4=E6=98=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Document/TechnicalGuide.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/Document/TechnicalGuide.md b/Document/TechnicalGuide.md index 02f5595..9fe4016 100644 --- a/Document/TechnicalGuide.md +++ b/Document/TechnicalGuide.md @@ -120,6 +120,22 @@ struct B: SmartCodable { 属性包装器在 Swift 中存储为 `_propertyName`(下划线前缀)。DecodingCache 会同时检查 `key` 和 `_key`,并通过 `extractWrappedValue()` 提取包装器内的实际值。 +当 `PropertyWrapperable.wrappedSmartDecodableType` 指向内层模型时,`withSnapshot` 会在包装器的解码作用域内为该模型建立快照。若包装器自身也遵循 `SmartDecodable`,包装器与内层模型会成为同一作用域、同一 `codingPath` 下的两个合法 owner;默认值、完整属性包装器恢复、Key Mapping 与 Value Transformer 都只读取当前活动 owner,不根据 key 名、类型名或快照顺序猜测归属。每个 `(scope, codingPath, objectType)` 最多只有一个快照;嵌套双协议包装器进入时会提升已预声明的 owner,并且只补建下一层尚不存在的 owner。 + +通过 `singleValueContainer().decode(Value.self)` 重入时,框架会自动切换到内层 owner。双协议包装器若要直接初始化内层值,应使用公开协作接口: + +```swift +init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + marker = try container.decode(Int.self, forKey: .marker) + wrappedValue = try Self.decodeWrappedValue(from: decoder) +} +``` + +该接口对普通 Decoder 仍等价于 `Value(from: decoder)`;对 SmartCodable 则在调用期间显式切换到内层 owner,并在正常返回或抛错时恢复包装器 owner。旧包装器继续兼容;但若仍直接调用 `Value(from:)`,wrapper 与 inner 又共享同一外置 CodingKey 和同名字段,Decoder 没有可观察信息区分两次调用,框架采用确定性的 wrapper-first 语义。 + +需要恢复包装器自身配置(例如 `SmartIgnored.isEncodable`)时,必须从精确宿主路径的 `_propertyName` 初始值恢复完整包装器,不能只提取 `wrappedValue`,也不能从其他嵌套模型的同名属性推断状态。 + --- ## 五、Key Mapping 系统 From 6b642ac374ba145243e4f3d29d707cf75c9de51e Mon Sep 17 00:00:00 2001 From: Rock-Connotation <787661104@qq.com> Date: Thu, 17 Sep 2026 18:58:20 +0800 Subject: [PATCH 13/17] =?UTF-8?q?refactor(decoder):=20=E8=A7=A3=E7=A0=81?= =?UTF-8?q?=E4=B8=8A=E4=B8=8B=E6=96=87=E6=98=BE=E5=BC=8F=E7=BB=91=E5=AE=9A?= =?UTF-8?q?=EF=BC=8C=E7=A7=BB=E9=99=A4=E8=A7=A3=E7=A0=81=E4=BE=A7=E5=8F=8C?= =?UTF-8?q?=E6=A0=88=E4=B8=8E=E6=8C=89=E8=B7=AF=E5=BE=84=E5=9B=9E=E6=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 DecodingSnapshot(模型上下文,objectType 不可变,懒加载默认值与转换器) 与 PropertyDecodingContext(宿主属性边);JSONDecoderImpl 携带固定上下文成员, unwrap/decodeInPlace 经 decoderForEntry 为每次可观察模型入口新建上下文 - KeyedContainer 创建时固定绑定所属模型;默认值、Key Mapping、transformer 均读自身 snapshot,不再查询活动 owner 栈 - 数组元素、字典数据键、nestedContainer/superDecoder 原始结构清空属性边; 普通 Codable 子对象不继承宿主字段表 - SmartIgnored/SmartHexColor 经属性边恢复完整包装器声明; SmartAssociatedEnumerable/SmartAny 经属性边取当前属性 transformer - 删除 DecodingCache、双栈、scopeIdentifier、activeOwner 与跨路径查找 - 修正 transformer 路径 didFinishMapping 双重通知(计数测试证明 2→1) - 测试迁移:DecodingCacheLifecycleTests → DecodingContextTests 语义契约; 新增 RefactorPlanRegressionTests(R01/R02 等);CI 增加 Release 与触发分支 --- .github/workflows/swift.yml | 13 +- CONTRIBUTING.md | 2 +- Document/QA/QA3.md | 43 +- Document/TechnicalGuide.md | 106 ++- .../JSONDecoder/Decoder/DecodingCache.swift | 350 -------- .../Decoder/DecodingSnapshot.swift | 130 +++ .../Impl/JSONDecoderImpl+KeyedContainer.swift | 113 ++- .../JSONDecoderImpl+UnkeyedContainer.swift | 6 +- .../Decoder/Impl/JSONDecoderImpl+Unwrap.swift | 108 ++- .../Decoder/Impl/JSONDecoderImpl.swift | 44 +- .../PropertyWrapperProtocol.swift | 4 +- .../SmartAny/SmartAnyImpl.swift | 2 +- .../Core/PropertyWrapper/SmartHexColor.swift | 9 +- .../Core/PropertyWrapper/SmartIgnored.swift | 11 +- .../Core/Sentinel/SmartSentinel.swift | 4 +- .../Core/SmartType/SmartCaseDefaultable.swift | 2 +- ...Tests.swift => DecodingContextTests.swift} | 772 ++++++++++++------ Tests/RefactorPlanRegressionTests.swift | 346 ++++++++ 18 files changed, 1299 insertions(+), 766 deletions(-) delete mode 100644 Sources/SmartCodable/Core/JSONDecoder/Decoder/DecodingCache.swift create mode 100644 Sources/SmartCodable/Core/JSONDecoder/Decoder/DecodingSnapshot.swift rename Tests/{DecodingCacheLifecycleTests.swift => DecodingContextTests.swift} (50%) create mode 100644 Tests/RefactorPlanRegressionTests.swift diff --git a/.github/workflows/swift.yml b/.github/workflows/swift.yml index 389557a..d205a87 100644 --- a/.github/workflows/swift.yml +++ b/.github/workflows/swift.yml @@ -1,18 +1,23 @@ # This workflow will build a Swift project -# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-swift +# For more information: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-swift name: Swift on: push: - branches: [ "main" ] + branches: + - "main" + - "fix/smartflat-snapshot-pollution" + - "refactor/decoder-context" pull_request: branches: [ "main" ] + workflow_dispatch: jobs: build: runs-on: macos-latest + timeout-minutes: 30 steps: - uses: actions/checkout@v4 @@ -20,6 +25,10 @@ jobs: run: swift build -v - name: Run tests run: swift test -v + - name: Build Release + run: swift build -c release + - name: Test Release + run: swift test -c release thread-sanitizer: diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 873a6ce..8753d2f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -50,7 +50,7 @@ swift build - **不破坏公共 API**:`SmartDecodable`、`SmartEncodable`、属性包装器的公开接口不能改签名 - **向后兼容**:保持 Swift 5.0+ / iOS 13+ 的最低版本要求 - **不新增 SwiftSyntax 依赖**:核心模块(`SmartCodable` target)不能依赖 SwiftSyntax -- **DecodingCache 快照使用作用域接口**:解码入口统一通过 `withSnapshot(for:codingPath:_:)` 管理快照,不要在调用方手工压栈或出栈;该接口负责正常返回和抛错路径的清理 +- **解码上下文通过类型入口统一建立**:`unwrap(as:)` / `decodeInPlace(_:)` 内部经 `decoderForEntry` 为每次可观察的模型初始化准备局部 `DecodingSnapshot`;容器在创建时固定绑定所属模型,不要在调用方手工构造、缓存或跨调用复用上下文 - 修改核心解码逻辑后,至少手动验证:简单模型、嵌套模型、数组模型、类型不匹配、缺失字段 ### Commit 规范 diff --git a/Document/QA/QA3.md b/Document/QA/QA3.md index 7bacb9c..0737e4d 100644 --- a/Document/QA/QA3.md +++ b/Document/QA/QA3.md @@ -126,37 +126,28 @@ if let model = NameModel.deserialize(from: dict) { 为了实现该功能,SmartCodable 重新实现了完整的 JSON 解码器(`SmartJSONDecoder`),而非仅重写 `JSONKeyedDecodingContainer` 的协议方法。 -核心实现在 `DecodingCache` 中,通过快照机制记录当前正在解析的 Model 的初始值: +核心实现在 `DecodingSnapshot`(解码上下文显式绑定)中,为每一次模型解码记录初始值与映射元数据: ```swift -// DecodingCache.swift 简化示意 -func withSnapshot( - for type: T.Type, - codingPath: [CodingKey], - _ body: () throws -> Result -) rethrows -> Result { - guard let smartType = cachedSmartDecodableType(for: type) else { - return try body() +// DecodingSnapshot.swift 简化示意 +final class DecodingSnapshot { + let objectType: any SmartDecodable.Type // 构造后不可变 + + private var cachedInitialValues: [String: Any]? // nil=未反射;[:]=已反射无字段 + private lazy var cachedTransformers: [SmartValueTransformer]? = { + objectType.mappingForValue() + }() + + private func initialValues() -> [String: Any] { + // 懒加载:首次需要默认值时才通过 Mirror 反射获取(含父类递归) + // 提取所有属性的初始值后缓存,同一上下文内复用同一份引用 } - - let snapshot = DecodingSnapshot() - snapshot.objectType = smartType - snapshot.codingPath = codingPath - snapshots.append(snapshot) - defer { - if snapshots.last === snapshot { - snapshots.removeLast() - } - } - return try body() } -// 懒加载:首次需要默认值时才通过 Mirror 反射获取 -private func populateInitialValues(snapshot: DecodingSnapshot) { - guard let type = snapshot.objectType else { return } - let mirror = Mirror(reflecting: type.init()) - // 提取所有属性的初始值存入 snapshot.initialValues +// 每个新的、可观察的模型初始化入口都创建自己的上下文(JSONDecoderImpl+Unwrap.swift) +func decoderForEntry(_ type: T.Type) -> JSONDecoderImpl { + replacingContexts(model: makeSnapshotForEntry(type), property: propertyContext) } ``` -当某个属性解码失败时,从快照中找到该属性的初始值进行填充。这种懒加载设计避免了每次解码都进行反射,只有在真正需要回退默认值时才会触发。 +当某个属性解码失败时,从当前容器固定绑定的模型上下文中取回该属性的初始值进行填充。容器在创建时绑定所属模型,子模型解码期间父容器的归属不变;这种懒加载设计也避免了每次解码都进行反射,只有在真正需要回退默认值时才会触发。 diff --git a/Document/TechnicalGuide.md b/Document/TechnicalGuide.md index 9fe4016..3d4aeef 100644 --- a/Document/TechnicalGuide.md +++ b/Document/TechnicalGuide.md @@ -19,10 +19,10 @@ SmartCodable (主模块) ├── Core/ │ ├── SmartCodable/ # 核心协议:SmartDecodable, SmartEncodable │ ├── JSONDecoder/ # 自定义解码器(完整实现,非包装系统 JSONDecoder) -│ │ ├── Decoder/ # SmartJSONDecoder 入口 + JSONDecoderImpl 核心 +│ │ ├── Decoder/ # SmartJSONDecoder 入口 + JSONDecoderImpl 核心 + DecodingSnapshot(模型上下文) │ │ ├── Impl/ # KeyedContainer / UnkeyedContainer / SingleValueContainer │ │ ├── Patcher/ # 类型转换 + 默认值提供 -│ │ └── Cache/ # 解码缓存(快照机制) +│ │ └── Cache/ # 编码缓存(编码侧快照机制) │ ├── JSONEncoder/ # 自定义编码器 │ ├── PropertyWrapper/ # 属性包装器(SmartAny, SmartIgnored, SmartFlat 等) │ ├── Transformer/ # 值转换器(日期、颜色、URL 等) @@ -51,18 +51,20 @@ SmartJSONDecoder.smartDecode(type, from: data) // SmartJSONDecoder.swift JSONDecoderImpl.unwrap(as: type) // JSONDecoderImpl+Unwrap.swift ├── 特殊类型直接处理:Date, Data, URL, Decimal, CGFloat, Dictionary └── 普通类型: - └── cache.withSnapshot(for:codingPath:) // 建立异常安全的快照作用域 - └── type.init(from: self) // 触发 Codable 标准流程 + └── decoderForEntry(type) // 为本次类型入口准备局部上下文 + ├── modelSnapshot = DecodingSnapshot(objectType:) // Smart 模型每次入口新建 + └── type.init(from: entry) // 触发 Codable 标准流程 ↓ KeyedContainer 初始化 // JSONDecoderImpl+KeyedContainer.swift - ├── _convertDictionary() // 应用 Key Mapping + ├── snapshot = impl.modelSnapshot // 容器创建时固定绑定所属模型 + ├── _convertDictionary() // 应用 Key Mapping(owner 来自固定 snapshot) │ ├── SmartKeyDecodingStrategy // snake_case → camelCase 等 │ └── KeysMapper.convertFrom() // 自定义 mappingForKey() └── 逐属性解码: - ├── 1. 检查 ValueTransformer // mappingForValue() 自定义转换 + ├── 1. 检查 ValueTransformer // mappingForValue() 自定义转换(读自身 snapshot) ├── 2. 尝试标准解码 ├── 3. 类型转换 Patcher // Int↔String, Bool↔Int 等 - └── 4. 默认值回退 Cache // Mirror 反射获取的初始值 + └── 4. 默认值回退 snapshot // Mirror 反射获取的初始值 ↓ didFinishMapping() // 用户回调,可做后处理 ``` @@ -72,57 +74,72 @@ didFinishMapping() // 用户回调,可做后处 与原生 Codable 最大的区别在于错误处理策略。当某个属性解码失败时: 1. **先尝试类型转换**(Patcher):比如 JSON 传了 `"123"` 但属性类型是 `Int`,自动转换 -2. **再回退到默认值**(DecodingCache):使用属性声明时的初始值 +2. **再回退到默认值**(DecodingSnapshot):使用属性声明时的初始值 3. **最后记录日志**(SmartSentinel):不抛异常,不中断解析,但记录问题 这个策略是整个项目的核心设计意图。 --- -## 四、默认值机制(DecodingCache) +## 四、默认值机制(解码上下文显式绑定) 这是 SmartCodable 最核心的机制,也是最需要理解的部分。 ### 工作原理 +默认值与映射元数据由 `DecodingSnapshot` 承载:**某一次模型解码的上下文**。 +每个新的、框架可观察的 Smart 模型初始化入口都会创建自己的实例,不按类型、 +路径或作用域去重;`objectType` 构造后不可变。 + ``` -解码开始 - ↓ -withSnapshot(for: Model.self) // 记录类型,但不立即反射 +unwrap(as: Model.self) / decodeInPlace / singleValue decode ↓ -执行 Model.init(from:) +decoderForEntry(type) // 为本次入口准备局部 decoder 视图 ↓ -某属性解码失败 +modelSnapshot = DecodingSnapshot(objectType: Model.self) // 记录类型,但不立即反射 ↓ -initialValueIfPresent(forKey: "name") // 首次访问时触发 Mirror 反射 +执行 Model.init(from: entry) ↓ -populateInitialValues() // 创建 Model.init(),用 Mirror 提取所有属性初始值 +某属性解码失败 ↓ -返回 snapshot.initialValues["name"] // 即用户声明的 var name: String = "默认值" 中的 "默认值" +snapshot.initialValueIfPresent(forKey:) // 首次访问时触发 Mirror 反射(含父类递归) ↓ -作用域结束后自动清理快照 // 正常返回和抛错路径都会执行 +返回声明初始值 // 即 var name: String = "默认值" 中的值 ``` -### 快照栈机制 +解码完成后该上下文随 decoder 视图一起释放,框架不持有全局注册表。 -嵌套模型解码时,快照按栈(数组)管理。查找时通过 `codingPath` 匹配: +### 容器固定绑定 -```swift -struct A: SmartCodable { // snapshot[0]: codingPath = [] - var b: B = B() // snapshot[1]: codingPath = ["b"] -} -struct B: SmartCodable { - var name: String = "hello" // 查找 snapshot where codingPath == ["b"] -} -``` +KeyedContainer 在创建时绑定 `impl.modelSnapshot`,此后不再变化:子模型正在 +解码、抛错、或宿主在回调中同步回读已持有的容器,父容器的归属都不变。 +因此异常路径无需任何“恢复 owner”的动作,也不会出现 SmartFlat 平铺期间 +子模型默认值污染父容器的问题。 -### 属性包装器的特殊处理 +`codingPath` 仍只表示 JSON 解码位置(用于错误路径与既有结构分支),不参与 +模型归属判断。 -属性包装器在 Swift 中存储为 `_propertyName`(下划线前缀)。DecodingCache 会同时检查 `key` 和 `_key`,并通过 `extractWrappedValue()` 提取包装器内的实际值。 +### 属性边(PropertyDecodingContext) -当 `PropertyWrapperable.wrappedSmartDecodableType` 指向内层模型时,`withSnapshot` 会在包装器的解码作用域内为该模型建立快照。若包装器自身也遵循 `SmartDecodable`,包装器与内层模型会成为同一作用域、同一 `codingPath` 下的两个合法 owner;默认值、完整属性包装器恢复、Key Mapping 与 Value Transformer 都只读取当前活动 owner,不根据 key 名、类型名或快照顺序猜测归属。每个 `(scope, codingPath, objectType)` 最多只有一个快照;嵌套双协议包装器进入时会提升已预声明的 owner,并且只补建下一层尚不存在的 owner。 +模型容器的每个属性值视图携带一条明确的属性边:`(宿主模型上下文, 规范属性 key)`。 +它只服务“当前整属性”的合法消费者(恢复完整包装器声明、当前属性的整体 +Value Transformer),不提供向父级链式搜索的能力,也不能当作子对象的字段表: -通过 `singleValueContainer().decode(Value.self)` 重入时,框架会自动切换到内层 owner。双协议包装器若要直接初始化内层值,应使用公开协作接口: +- 数组元素、字典数据键、手写 `nestedContainer` / `superDecoder` 进入的原始 + 子结构,属性边被清空,不得借用宿主字段表。 +- 未知的普通 `Codable` 子对象不继承上层模型上下文(否则同名字段会串值); + 其缺失字段按 Patcher 类型兜值处理。 + +### 属性包装器的特殊处理 + +属性包装器在 Swift 中存储为 `_propertyName`(下划线前缀)。snapshot 会同时 +检查 `key` 和 `_key`,并通过 `extractWrappedValue()` 提取包装器内的实际值; +`declaredWrapper(forKey:as:)` 则返回完整的包装器声明,保留 `SmartIgnored.isEncodable` +等自身配置。 + +双协议包装器(同时遵循 `PropertyWrapperable` 与 `SmartDecodable`)经 +`unwrap` 入口先绑定包装器自身的上下文;内层模型必须通过协作接口获得自己的 +新上下文: ```swift init(from decoder: Decoder) throws { @@ -132,9 +149,20 @@ init(from decoder: Decoder) throws { } ``` -该接口对普通 Decoder 仍等价于 `Value(from: decoder)`;对 SmartCodable 则在调用期间显式切换到内层 owner,并在正常返回或抛错时恢复包装器 owner。旧包装器继续兼容;但若仍直接调用 `Value(from:)`,wrapper 与 inner 又共享同一外置 CodingKey 和同名字段,Decoder 没有可观察信息区分两次调用,框架采用确定性的 wrapper-first 语义。 +该接口对普通 Decoder 仍等价于 `Value(from: decoder)`;对 SmartCodable 则派生 +一个绑定到内层模型的新解码视图。旧包装器继续兼容;但若仍直接调用 +`Value(from:)`,wrapper 与 inner 共享同一个解码视图,框架没有可观察信息区分 +这次调用,表现为确定性的 wrapper-first 语义。需要严格内外隔离的自定义包装器 +必须使用 `decodeWrappedValue(from:)` 或容器 decode 入口。 + +普通第三方包装器(仅 `PropertyWrapperable`)直接执行 `Value(from: decoder)` +时,入口会为内层模型建立局部兼容上下文,保证内层声明默认值可用;该绑定只 +服务这一次直接初始化,下一次显式 `decode(Value.self)` 仍会新建上下文, +两次独立初始化不会共享可变默认对象。 -需要恢复包装器自身配置(例如 `SmartIgnored.isEncodable`)时,必须从精确宿主路径的 `_propertyName` 初始值恢复完整包装器,不能只提取 `wrappedValue`,也不能从其他嵌套模型的同名属性推断状态。 +需要恢复包装器自身配置(例如 `SmartIgnored.isEncodable`)时,必须从属性边 +指向的宿主声明恢复完整包装器,不能只提取 `wrappedValue`,也不能从其他 +嵌套模型的同名属性推断状态。 --- @@ -192,7 +220,7 @@ struct Model: SmartCodable { 1. ValueTransformer(mappingForValue) ← 最高优先级 2. 标准 Codable 解码 3. Patcher 类型转换(Int↔String 等) -4. DecodingCache 默认值回退 ← 最低优先级 +4. DecodingSnapshot 默认值回退 ← 最低优先级 ``` ### 内置 Transformer @@ -299,14 +327,14 @@ SmartCodableOptions.ignoreNull = false // 将 null 作为值传递给 A ### 必须遵守 1. **不破坏公共 API**:`SmartDecodable`、`SmartEncodable`、所有属性包装器的公开接口不能改签名 -2. **向后兼容**:最低支持 Swift 5.0 / iOS 13+,不能使用高版本独占的 API +2. **向后兼容**:包声明为 Swift tools 5.9,最低部署目标 iOS 13+ / macOS 10.15+,不能使用更高版本独占的 API 3. **不新增 SwiftSyntax 依赖**:宏功能已隔离到独立 target,核心模块不能依赖 SwiftSyntax ### 代码约定 -4. **DecodingCache 的快照必须通过作用域接口管理**:解码入口统一调用 `withSnapshot(for:codingPath:_:)`,不要复制快照资格判断或在调用方手工出栈;正常返回和抛错路径都由该接口清理 +4. **解码上下文通过类型入口统一建立**:`unwrap(as:)` / `decodeInPlace(_:)` 内部经 `decoderForEntry` 为每次可观察初始化准备局部上下文;不要在调用方手工构造或复用 `DecodingSnapshot`,也不要按 `(类型, codingPath)` 缓存上下文 5. **Patcher 中的类型转换要双向安全**:比如 String → Int,必须验证字符串确实是合法数字,不能静默返回 0 -6. **属性包装器的存储名有下划线前缀**:Swift 编译器将 `@SmartAny var name` 存储为 `_name`,DecodingCache 中需要处理这个映射 +6. **属性包装器的存储名有下划线前缀**:Swift 编译器将 `@SmartAny var name` 存储为 `_name`,DecodingSnapshot 中需要处理这个映射 7. **KeyedContainer 中的 `_convertDictionary()` 只执行一次**:在容器初始化时调用,之后的属性解码都基于转换后的字典 ### 测试相关 @@ -316,6 +344,6 @@ SmartCodableOptions.ignoreNull = false // 将 null 作为值传递给 A ### 性能相关 -10. **Mirror 反射是懒加载的**:`DecodingCache` 只在首次需要默认值时才反射,不是每次解码都反射 +10. **Mirror 反射是懒加载的**:`DecodingSnapshot` 只在首次需要默认值时才反射,不是每次解码都反射;同一上下文内重复查询复用同一份引用,跨上下文互不共享 11. **SafeDictionary 使用 NSLock**:Sentinel 的日志字典有锁保护,在 `debugMode == .none` 时不会触碰 12. **SmartSentinel 的日志守卫**:所有日志方法入口都有 `guard debugMode != .none else { return }`,Release 环境零开销(前提是 debugMode 保持默认的 `.none`) diff --git a/Sources/SmartCodable/Core/JSONDecoder/Decoder/DecodingCache.swift b/Sources/SmartCodable/Core/JSONDecoder/Decoder/DecodingCache.swift deleted file mode 100644 index 51f1740..0000000 --- a/Sources/SmartCodable/Core/JSONDecoder/Decoder/DecodingCache.swift +++ /dev/null @@ -1,350 +0,0 @@ -// -// DecodingCache.swift -// SmartCodable -// -// Created by Mccc on 2024/3/5. -// - -import Foundation - - -/// Caches default values during decoding operations -/// Used to provide fallback values when decoding fails -class DecodingCache: Cachable { - - typealias SomeSnapshot = DecodingSnapshot - - /// 解码快照栈 - private(set) var snapshots: [DecodingSnapshot] = [] - - /// 作用域唯一标识递增计数器 - private var nextScopeIdentifier = 0 - /// 当前正在执行解码的活跃所有者栈 - private var activeOwners: [ActiveOwner] = [] - - /// 当前活跃所有者栈深度 - var activeOwnerDepth: Int { activeOwners.count } - - /// 在指定类型的快照作用域内执行解码。 - /// - /// 快照的资格判断、创建与清理全部收口在这里: - /// 调用方只表达“在该类型的快照作用域内解码”, - /// 不再手工配对压栈与出栈,抛错路径也会清理本作用域的快照。 - func withSnapshot( - for type: T.Type, - codingPath: [CodingKey], - _ body: () throws -> Result - ) rethrows -> Result { - - guard let request = snapshotRequest(for: type) else { - return try body() - } - - // 同一路径下若该类型尚未在当前作用域执行,则复用作用域;否则分配新作用域 - let currentScope = activeOwners.last(where: { - codingPathEquals($0.snapshot.codingPath, codingPath) - })?.scopeIdentifier - let scopeIdentifier: Int - if let currentScope, - !isExecuting( - request.activeType, - as: request.mode, - in: currentScope - ) { - scopeIdentifier = currentScope - } else { - nextScopeIdentifier += 1 - scopeIdentifier = nextScopeIdentifier - } - - var createdSnapshots: [DecodingSnapshot] = [] - // 保证同一 (scope, codingPath, objectType) 下快照唯一 - func ownerSnapshot(for objectType: SmartDecodable.Type) -> DecodingSnapshot { - if let existing = snapshot( - for: objectType, - codingPath: codingPath, - scopeIdentifier: scopeIdentifier - ) { - return existing - } - - let snapshot = DecodingSnapshot() - snapshot.codingPath = codingPath - snapshot.objectType = objectType - snapshot.scopeIdentifier = scopeIdentifier - snapshots.append(snapshot) - createdSnapshots.append(snapshot) - return snapshot - } - - let activeSnapshot = ownerSnapshot(for: request.activeType) - // 预声明包装器内层模型的快照(同一作用域),便于后续所有者切换 - if let wrappedType = request.wrappedType { - _ = ownerSnapshot(for: wrappedType) - } - activeOwners.append(ActiveOwner( - mode: request.mode, - scopeIdentifier: scopeIdentifier, - snapshot: activeSnapshot - )) - - defer { - assert(activeOwners.last?.snapshot === activeSnapshot, - "Active decoding owner is not the one created by this scope") - if activeOwners.last?.snapshot === activeSnapshot { - activeOwners.removeLast() - } - - for created in createdSnapshots.reversed() { - assert(snapshots.last === created, - "Decoding snapshot stack top is not the owner created by this entry") - if snapshots.last === created { - snapshots.removeLast() - } - } - } - - return try body() - } - - /// 解析目标类型的快照请求,提取活跃所有者类型与内层包装模型类型 - private func snapshotRequest(for type: T.Type) -> SnapshotRequest? { - let ownerType = type as? SmartDecodable.Type - let wrappedType = (type as? any PropertyWrapperable.Type)?.wrappedSmartDecodableType - guard let activeType = ownerType ?? wrappedType else { return nil } - - let distinctWrappedType = wrappedType.flatMap { wrappedType in - ObjectIdentifier(wrappedType) == ObjectIdentifier(activeType) ? nil : wrappedType - } - return SnapshotRequest( - activeType: activeType, - wrappedType: distinctWrappedType, - mode: ownerType == nil ? .wrapperScope : .owner - ) - } - - /// 判断指定类型是否已经在该作用域内以相同模式处于执行中 - private func isExecuting( - _ objectType: SmartDecodable.Type, - as mode: ActiveOwner.Mode, - in scopeIdentifier: Int - ) -> Bool { - activeOwners.contains(where: { active in - guard active.scopeIdentifier == scopeIdentifier, - active.mode == mode, - let activeType = active.snapshot.objectType else { - return false - } - return ObjectIdentifier(activeType) == ObjectIdentifier(objectType) - }) - } - - /// 查找指定作用域、路径与类型的快照 - private func snapshot( - for objectType: SmartDecodable.Type, - codingPath: [CodingKey], - scopeIdentifier: Int - ) -> DecodingSnapshot? { - snapshots.last(where: { snapshot in - guard snapshot.scopeIdentifier == scopeIdentifier, - codingPathEquals(snapshot.codingPath, codingPath), - let snapshotType = snapshot.objectType else { - return false - } - return ObjectIdentifier(snapshotType) == ObjectIdentifier(objectType) - }) - } - - /// 获取指定路径下当前活跃所有者的快照 - private func activeSnapshot(at codingPath: [CodingKey]) -> DecodingSnapshot? { - activeOwners.last(where: { - codingPathEquals($0.snapshot.codingPath, codingPath) - })?.snapshot - } - - /// 获取指定路径下当前正在执行解码的活跃所有者类型(排除预声明但尚未激活的内层包装类型) - func activeOwner(at codingPath: [CodingKey]) -> SmartDecodable.Type? { - activeSnapshot(at: codingPath)?.objectType - } -} - -/// 快照构建请求参数 -private struct SnapshotRequest { - /// 待激活的所有者类型 - let activeType: SmartDecodable.Type - /// 包装器内部嵌套的 SmartDecodable 类型(若存在) - let wrappedType: SmartDecodable.Type? - /// 所有者执行模式 - let mode: ActiveOwner.Mode -} - -/// 正在执行解码的活跃所有者记录 -private struct ActiveOwner { - enum Mode { - /// 包装器作用域模式(包装器本身非 SmartDecodable) - case wrapperScope - /// 独立所有者模式(模型自身或遵循 SmartDecodable 的双协议包装器) - case owner - } - - let mode: Mode - let scopeIdentifier: Int - let snapshot: DecodingSnapshot -} - -// MARK: - 获取属性初始值 -extension DecodingCache { - - /// 恢复宿主属性声明时的完整包装器实例。 - /// - /// 与 `initialValueIfPresent` 的 wrappedValue 兜底不同,这个接口保留包装器 - /// 自身的配置状态(例如 `SmartIgnored.isEncodable`),并且只读取精确匹配 - /// 宿主路径的快照,不从其他嵌套模型的同名属性猜测状态。 - func initialPropertyWrapperIfPresent( - forKey key: CodingKey?, - codingPath: [CodingKey], - as type: Wrapper.Type - ) -> Wrapper? { - guard let key = key else { - return nil - } - - guard let snapshot = activeSnapshot(at: codingPath) else { - return nil - } - - if snapshot.initialValues.isEmpty { - populateInitialValues(snapshot: snapshot) - } - - return snapshot.initialValues["_" + key.stringValue] as? Wrapper - } - /// 查找指定解码路径下容器中某个字段的初始值。 - /// - /// 该方法会根据传入的 `codingPath`(代表某个解码容器的位置), - /// 从当前活动 owner 获取 `key` 对应字段的初始值。 - /// 如果该 owner 尚未初始化初始值,则会延迟初始化一次(通过反射等方式)。 - func initialValueIfPresent(forKey key: CodingKey?, codingPath: [CodingKey]) -> T? { - - guard let key = key else { return nil } - - guard let snapshot = activeSnapshot(at: codingPath) else { return nil } - - if snapshot.initialValues.isEmpty { - populateInitialValues(snapshot: snapshot) - } - - return initialValue(for: key, in: snapshot) - } - - /// 从指定快照中读取字段初始值(支持属性包装器与枚举默认值) - private func initialValue(for key: CodingKey, in snapshot: DecodingSnapshot) -> T? { - guard let cached = snapshot.initialValues[key.stringValue] else { - return handlePropertyWrapperCases(for: key, snapshot: snapshot) - } - if let value = cached as? T { - return value - } - if let caseValue = cached as? any SmartCaseDefaultable { - return caseValue.rawValue as? T - } - return nil - } - - func initialValue(forKey key: CodingKey?, codingPath: [CodingKey]) throws -> T { - guard let value: T = initialValueIfPresent(forKey: key, codingPath: codingPath) else { - return try Patcher.defaultForType() - } - return value - } -} - - -// MARK: - 获取属性对应的值转换器 -extension DecodingCache { - - /// 根据属性 key 和其所在容器路径,查找对应的值转换器(SmartValueTransformer) - /// - /// - Parameters: - /// - key: 当前正在解码的属性名(CodingKey),即字段名。可能为 `nil`,表示缺失或无法识别的字段。 - /// - containerPath: 当前属性所在容器的完整路径(不含当前 key)。 - /// - /// - Returns: 匹配到的 `SmartValueTransformer`,如果未找到则返回 `nil`。 - /// - /// - Note: - /// - 此方法依赖于容器路径 `codingPath` 查找快照(snapshot),快照中包含该容器注册的所有转换器列表。 - /// - 若 key 为 `nil` 或找不到快照,或快照中未注册转换器,均返回 `nil`。 - /// - 转换器只从当前活动 owner 读取。 - func valueTransformer(for key: CodingKey?, in containerPath: [CodingKey]) -> SmartValueTransformer? { - guard let lastKey = key else { return nil } - - return activeSnapshot(at: containerPath)?.transformers?.first(where: { - $0.location.stringValue == lastKey.stringValue - }) - } -} - -extension DecodingCache { - - - /// 处理属性包装器字段(以下划线 `_` 为前缀存储)的初始值提取 - private func handlePropertyWrapperCases(for key: CodingKey, snapshot: DecodingSnapshot) -> T? { - if let cached = snapshot.initialValues["_" + key.stringValue] { - return extractWrappedValue(from: cached) - } - - return nil - } - - /// 从属性包装器实例中解包出实际的 wrappedValue - private func extractWrappedValue(from value: Any) -> T? { - if let wrapper = value as? SmartIgnored { - return wrapper.wrappedValue - } else if let wrapper = value as? SmartAny { - return wrapper.wrappedValue - } else if let value = value as? T { - return value - } - return nil - } - - private func populateInitialValues(snapshot: DecodingSnapshot) { - guard let type = snapshot.objectType else { return } - - // Recursively captures initial values from a type and its superclasses - func captureInitialValues(from mirror: Mirror) { - mirror.children.forEach { child in - if let key = child.label { - snapshot.initialValues[key] = child.value - } - } - if let superclassMirror = mirror.superclassMirror { - captureInitialValues(from: superclassMirror) - } - } - - let mirror = Mirror(reflecting: type.init()) - captureInitialValues(from: mirror) - } -} - - - -/// 单个模型的解码状态快照 -class DecodingSnapshot: Snapshot { - typealias ObjectType = SmartDecodable.Type - - var objectType: (any SmartDecodable.Type)? - - var codingPath: [any CodingKey] = [] - - /// 所属作用域的唯一标识 - var scopeIdentifier = 0 - - lazy var transformers: [SmartValueTransformer]? = { - objectType?.mappingForValue() - }() - - /// 存储属性初始值的字典(Key: 属性名, Value: 初始值) - var initialValues: [String : Any] = [:] -} diff --git a/Sources/SmartCodable/Core/JSONDecoder/Decoder/DecodingSnapshot.swift b/Sources/SmartCodable/Core/JSONDecoder/Decoder/DecodingSnapshot.swift new file mode 100644 index 0000000..49d78a2 --- /dev/null +++ b/Sources/SmartCodable/Core/JSONDecoder/Decoder/DecodingSnapshot.swift @@ -0,0 +1,130 @@ +// +// DecodingSnapshot.swift +// SmartCodable +// +// 解码上下文显式绑定:某一次模型解码的默认值与映射上下文。 +// + +import Foundation + +/// 某一次模型解码的默认值及映射上下文。 +/// +/// 每个新的、框架可观察的 Smart 模型初始化入口都会创建自己的实例: +/// snapshot 只服务这一次 `init(from:)`,其中的反射默认值与值转换器缓存 +/// 不与其他初始化调用共享,也不按类型、路径或作用域去重。 +/// `objectType` 在构造后不可修改。 +final class DecodingSnapshot { + + /// 该上下文归属的模型类型(构造后不可变) + let objectType: any SmartDecodable.Type + + /// nil 表示尚未反射;[:] 表示已经反射且没有存储字段。 + private var cachedInitialValues: [String: Any]? + + private lazy var cachedTransformers: [SmartValueTransformer]? = { + objectType.mappingForValue() + }() + + init(objectType: any SmartDecodable.Type) { + self.objectType = objectType + } + + func transformer(forKey key: any CodingKey) -> SmartValueTransformer? { + cachedTransformers?.first { + $0.location.stringValue == key.stringValue + } + } + + private func initialValues() -> [String: Any] { + if let cachedInitialValues { + return cachedInitialValues + } + var values: [String: Any] = [:] + func capture(_ mirror: Mirror) { + for child in mirror.children { + if let label = child.label { + values[label] = child.value + } + } + if let superclass = mirror.superclassMirror { + capture(superclass) + } + } + capture(Mirror(reflecting: objectType.init())) + cachedInitialValues = values + return values + } +} + +extension DecodingSnapshot { + + /// 供模型容器读取本模型某字段的默认值(返回的 T 可能本身就是包装器)。 + /// + /// 查询顺序与解码侧旧实现的 initialValueIfPresent 一致: + /// 普通存储名优先(含 SmartCaseDefaultable rawValue 转换), + /// 未命中再查 `_` 前缀的包装器存储并解包 wrappedValue。 + /// 仅访问当前对象的字段表,不从其他模型上下文兜底。 + func initialValueIfPresent(forKey key: any CodingKey) -> T? { + let values = initialValues() + if let cached = values[key.stringValue] { + if let value = cached as? T { + return value + } + if let caseValue = cached as? any SmartCaseDefaultable { + return caseValue.rawValue as? T + } + return nil + } + guard let cached = values["_" + key.stringValue] else { + return nil + } + return extractWrappedValue(from: cached) + } + + /// 带类型兜底的默认值读取(找不到声明默认值时走 Patcher) + func initialValue(forKey key: any CodingKey) throws -> T { + if let value: T = initialValueIfPresent(forKey: key) { + return value + } + return try Patcher.defaultForType() + } + + /// 供宿主属性上下文恢复精确类型的完整包装器声明。 + /// + /// 必须返回包装器自身的实例(保留如 `SmartIgnored.isEncodable` 的配置), + /// 不能只取 `wrappedValue` 再重新构造包装器。 + func declaredWrapper( + forKey key: any CodingKey, + as type: W.Type + ) -> W? { + initialValues()["_" + key.stringValue] as? W + } + + private func extractWrappedValue(from value: Any) -> T? { + if let wrapper = value as? SmartIgnored { + return wrapper.wrappedValue + } + if let wrapper = value as? SmartAny { + return wrapper.wrappedValue + } + return value as? T + } +} + +/// 宿主某一条属性的解码上下文。 +/// +/// 只记录“声明该属性的宿主模型上下文”与“该属性的规范 CodingKey”这一条边, +/// 不提供向父级链式搜索的能力;一次属性处理结束后, +/// 其子字段必须以子模型为宿主重新建立自己的属性上下文。 +struct PropertyDecodingContext { + let owner: DecodingSnapshot + let key: any CodingKey + + func declaredWrapper(as type: W.Type) -> W? { + owner.declaredWrapper(forKey: key, as: type) + } + + func transformer() -> SmartValueTransformer? { + owner.transformer(forKey: key) + } +} diff --git a/Sources/SmartCodable/Core/JSONDecoder/Decoder/Impl/JSONDecoderImpl+KeyedContainer.swift b/Sources/SmartCodable/Core/JSONDecoder/Decoder/Impl/JSONDecoderImpl+KeyedContainer.swift index 0647aef..db25ad9 100644 --- a/Sources/SmartCodable/Core/JSONDecoder/Decoder/Impl/JSONDecoderImpl+KeyedContainer.swift +++ b/Sources/SmartCodable/Core/JSONDecoder/Decoder/Impl/JSONDecoderImpl+KeyedContainer.swift @@ -10,16 +10,26 @@ extension JSONDecoderImpl { /// A container that provides a view into a JSON dictionary and decodes values from it struct KeyedContainer: KeyedDecodingContainerProtocol { typealias Key = K - + let impl: JSONDecoderImpl let codingPath: [CodingKey] let dictionary: [String: JSONValue] - + + /// 容器创建时固定绑定的模型上下文;子模型的活动不会改变它 + let snapshot: DecodingSnapshot? + init(impl: JSONDecoderImpl, codingPath: [CodingKey], dictionary: [String: JSONValue]) { - + self.codingPath = codingPath - - self.dictionary = _convertDictionary(dictionary, impl: impl) + + // impl 中的上下文引用与容器成员必须来自同一视图 + self.snapshot = impl.modelSnapshot + + self.dictionary = _convertDictionary( + dictionary, + snapshot: impl.modelSnapshot, + options: impl.options + ) // The transformation of the dictionary does not affect the structure, // but only adds a new field to the data corresponding to the current container. // No impl changes are required @@ -75,22 +85,25 @@ extension JSONDecoderImpl { } - private func decoderForKeyCompatibleForJson(_ key: LocalKey, type: T.Type) throws -> JSONDecoderImpl { + /// 属性值视图:模型上下文暂为 nil,由紧随其后的 typed entry 构造; + /// 这里只携带“宿主模型 + 当前属性”这一条边,不预先按 T 分配任何字段表。 + private func decoderForKeyCompatibleForJson(_ key: LocalKey) throws -> JSONDecoderImpl { guard let value = getValue(forKey: key) else { throw DecodingError._keyNotFound(key: key, codingPath: self.codingPath) } var newPath = self.codingPath newPath.append(key) - - var newImpl = JSONDecoderImpl(userInfo: self.impl.userInfo, from: value, codingPath: newPath, options: self.impl.options) - - // If the new parser is not a parse Model, - // it inherits the cache from the previous one. - if !(type is SmartDecodable.Type) { - newImpl.cache = impl.cache - } - - return newImpl + + let property = snapshot.map { PropertyDecodingContext(owner: $0, key: key) } + + return JSONDecoderImpl( + userInfo: self.impl.userInfo, + from: value, + codingPath: newPath, + options: self.impl.options, + modelSnapshot: nil, + propertyContext: property + ) } @@ -246,22 +259,22 @@ extension JSONDecoderImpl.KeyedContainer { extension JSONDecoderImpl.KeyedContainer { fileprivate func _compatibleDecode(forKey key: Key, logIfKeyMissing: Bool = true, needConvert: Bool = true) -> T? { - + guard let value = getValue(forKey: key) else { if logIfKeyMissing { SmartSentinel.monitorLog(impl: impl, forKey: key, value: nil, type: T.self) } - return impl.cache.initialValueIfPresent(forKey: key, codingPath: codingPath) + return snapshot?.initialValueIfPresent(forKey: key) } - + SmartSentinel.monitorLog(impl: impl, forKey: key, value: value, type: T.self) - + if needConvert { if let decoded = Patcher.convertToType(from: value, impl: impl) { return decoded } } - return impl.cache.initialValueIfPresent(forKey: key, codingPath: codingPath) + return snapshot?.initialValueIfPresent(forKey: key) } @@ -280,24 +293,29 @@ extension JSONDecoderImpl.KeyedContainer { return decodeValue } + /// 尝试用转换器解码当前属性。 + /// + /// 这是“尝试解码/转换”的内部函数,只返回候选值,不承担完成通知: + /// 完成通知(didFinishMapping)统一由该属性的最终返回层执行一次, + /// 避免与外层 `_decodeDecodable...` 的包装重复触发。 private func decodeWithTransformer(_ transformer: SmartValueTransformer, type: T.Type, key: K) -> T? where T: Decodable { // 处理属性包装类型 if let propertyWrapperType = T.self as? any PropertyWrapperable.Type { let value: JSONValue? = (type is FlatType.Type) ? impl.json : getValue(forKey: key) - + if let value = value, let decoded = transformer.transformFromJSON(value), let wrapperValue = propertyWrapperType.createInstance(with: decoded) as? T { - return didFinishMapping(wrapperValue) + return wrapperValue } } - + // 处理普通类型转换 if let value = getValue(forKey: key), let decoded = transformer.transformFromJSON(value) as? T { - return didFinishMapping(decoded) + return decoded } return nil } @@ -439,7 +457,7 @@ extension JSONDecoderImpl.KeyedContainer { /// 总结: /// 除基本数据类型之外,都会进入该方法`_decodeDecodableIfPresentCore`.因此在此处进行统一的value解析的拦截实现即可。 /// 不需要分散在各个类型中逐一处理。 - if let transformer = impl.cache.valueTransformer(for: key, in: codingPath) { + if let transformer = snapshot?.transformer(forKey: key) { if let decoded = decodeWithTransformer(transformer, type: type, key: key) { return decoded } @@ -448,19 +466,34 @@ extension JSONDecoderImpl.KeyedContainer { } return nil } - + /// @SmartFlat的处理 /// 关于SmartFlat的解析,是往前一层解析,codingPath不应该增加。 if let type = type as? FlatType.Type { + // Flat 属性的整属性边:宿主模型 + Flat key + let flatProperty = snapshot.map { PropertyDecodingContext(owner: $0, key: key) } if type.isArray { - return try? T(from: superDecoder(forKey: key)) + // 数组 Flat:保留原 key 子结构与空值语义;元素入口随后清空该边 + let value = getValue(forKey: key) ?? .null + var newPath = self.codingPath + newPath.append(key) + let decoder = JSONDecoderImpl( + userInfo: self.impl.userInfo, + from: value, + codingPath: newPath, + options: self.impl.options, + modelSnapshot: nil, + propertyContext: flatProperty + ) + return try? T(from: decoder) } else { - // 这里需要走unwrap,需要cache。 - return try? impl.unwrap(as: T.self) + // 非 数组Flat:同 JSON、同路径,但携带 Flat 属性边的视图进入 wrapper 入口 + let flatDecoder = impl.replacingContexts(model: nil, property: flatProperty) + return try? flatDecoder.unwrap(as: T.self) } } - guard let newDecoder = try? decoderForKeyCompatibleForJson(key, type: type) else { + guard let newDecoder = try? decoderForKeyCompatibleForJson(key) else { return nil } @@ -475,11 +508,15 @@ extension JSONDecoderImpl.KeyedContainer { /// Handles correspondence between field names that need to be parsed. -fileprivate func _convertDictionary(_ dictionary: [String: JSONValue], impl: JSONDecoderImpl) -> [String: JSONValue] { - +fileprivate func _convertDictionary( + _ dictionary: [String: JSONValue], + snapshot: DecodingSnapshot?, + options: SmartJSONDecoder._Options +) -> [String: JSONValue] { + var dictionary = dictionary - - switch impl.options.keyDecodingStrategy { + + switch options.keyDecodingStrategy { case .useDefaultKeys: break case .fromSnakeCase: @@ -498,9 +535,9 @@ fileprivate func _convertDictionary(_ dictionary: [String: JSONValue], impl: JSO }, uniquingKeysWith: { (first, _) in first }) } - // 仅基于当前路径活跃的解码所有者进行键名映射转换 - guard let type = impl.cache.activeOwner(at: impl.codingPath) else { return dictionary } - + // 键名映射只来自容器创建时固定绑定的模型上下文 + guard let type = snapshot?.objectType else { return dictionary } + if let tempValue = KeysMapper.convertFrom(JSONValue.object(dictionary), type: type), let dict = tempValue.object { return dict } diff --git a/Sources/SmartCodable/Core/JSONDecoder/Decoder/Impl/JSONDecoderImpl+UnkeyedContainer.swift b/Sources/SmartCodable/Core/JSONDecoder/Decoder/Impl/JSONDecoderImpl+UnkeyedContainer.swift index 1497be3..be0796f 100644 --- a/Sources/SmartCodable/Core/JSONDecoder/Decoder/Impl/JSONDecoderImpl+UnkeyedContainer.swift +++ b/Sources/SmartCodable/Core/JSONDecoder/Decoder/Impl/JSONDecoderImpl+UnkeyedContainer.swift @@ -213,7 +213,8 @@ extension JSONDecoderImpl.UnkeyedContainer { let key = _JSONKey(index: currentIndex) guard let value = try? self.getNextValue(ofType: T.self) else { - let decoded: T = try impl.cache.initialValue(forKey: key, codingPath: codingPath) + let decoded: T = try impl.modelSnapshot?.initialValue(forKey: key) + ?? Patcher.defaultForType() SmartSentinel.monitorLog(impl: impl, forKey: key, value: nil, type: T.self) self.currentIndex += 1 return decoded @@ -226,7 +227,8 @@ extension JSONDecoderImpl.UnkeyedContainer { self.currentIndex += 1 return decoded } else { - let decoded: T = try impl.cache.initialValue(forKey: key, codingPath: codingPath) + let decoded: T = try impl.modelSnapshot?.initialValue(forKey: key) + ?? Patcher.defaultForType() self.currentIndex += 1 return decoded } diff --git a/Sources/SmartCodable/Core/JSONDecoder/Decoder/Impl/JSONDecoderImpl+Unwrap.swift b/Sources/SmartCodable/Core/JSONDecoder/Decoder/Impl/JSONDecoderImpl+Unwrap.swift index 35009fd..7d8896b 100644 --- a/Sources/SmartCodable/Core/JSONDecoder/Decoder/Impl/JSONDecoderImpl+Unwrap.swift +++ b/Sources/SmartCodable/Core/JSONDecoder/Decoder/Impl/JSONDecoderImpl+Unwrap.swift @@ -17,57 +17,82 @@ extension Dictionary: _JSONStringDictionaryDecodableMarker where Key == String, extension JSONDecoderImpl { // MARK: Special case handling - + + /// 为一次即将开始的类型初始化入口准备局部模型上下文。 + /// + /// 不做任何全局或作用域内去重:每次可观察的模型入口都取得新上下文。 + /// 同时遵循 `PropertyWrapperable` 与 `SmartDecodable` 的包装器先绑定包装器自身; + /// 普通第三方包装器返回绑定内层模型的局部兼容上下文, + /// 它只服务包装器内的直接初始化,不代表下一次显式 decode 可以复用这一份。 + private func makeSnapshotForEntry(_ type: T.Type) -> DecodingSnapshot? { + if let model = type as? any SmartDecodable.Type { + return DecodingSnapshot(objectType: model) + } + if let wrapper = type as? any PropertyWrapperable.Type, + let wrappedModel = wrapper.wrappedSmartDecodableType { + return DecodingSnapshot(objectType: wrappedModel) + } + return nil + } + + /// 每次调用代表一次新的可观察类型入口;不要用它“在同一模型中再取一次容器”。 + private func decoderForEntry(_ type: T.Type) -> JSONDecoderImpl { + replacingContexts( + model: makeSnapshotForEntry(type), + property: propertyContext + ) + } + func unwrap(as type: T.Type) throws -> T { + let entry = decoderForEntry(type) if type == Date.self { - return try self.unwrapDate() as! T + return try entry.unwrapDate() as! T } if type == Data.self { - return try self.unwrapData() as! T + return try entry.unwrapData() as! T } if type == URL.self { - return try self.unwrapURL() as! T + return try entry.unwrapURL() as! T } if type == Decimal.self { - return try self.unwrapDecimal() as! T + return try entry.unwrapDecimal() as! T } if type == CGFloat.self { - return try unwrapCGFloat() as! T + return try entry.unwrapCGFloat() as! T } if type is _JSONStringDictionaryDecodableMarker.Type { - return try self.unwrapDictionary(as: type) - } - - return try cache.withSnapshot(for: type, codingPath: codingPath) { - try type.init(from: self) + return try entry.unwrapDictionary(as: type) } + + return try type.init(from: entry) } - /// 在当前解码上下文中初始化类型 T(codingPath 不前进)。 + /// 在当前解码位置直接初始化类型 T(codingPath 不前进)。 /// - /// 供 `@SmartFlat` 等平铺语义的属性包装器使用:包装器自身不建立快照, - /// 由本方法为内层值建立与 `unwrap(as:)` 同源的快照作用域—— - /// 仅当 T 是 SmartDecodable 时入栈,且作用域精确覆盖 `T.init(from:)` 的执行窗口。 - /// 不包含 `unwrap(as:)` 的特殊类型提前返回,保持包装器原有的直接初始化语义。 + /// 供 `@SmartFlat` 等平铺语义的属性包装器使用: + /// codingPath 不前进不代表模型上下文不变——即使平铺在同一个 JSON 对象上, + /// 一个新的模型入口也取得自己的新上下文。不包含 `unwrap(as:)` 的 + /// 特殊类型提前返回,保持包装器原有的直接初始化语义。 func decodeInPlace(_ type: T.Type) throws -> T { - return try cache.withSnapshot(for: type, codingPath: codingPath) { - try type.init(from: self) - } + let entry = decoderForEntry(type) + return try type.init(from: entry) } - + func unwrapFloatingPoint( from value: JSONValue, for additionalKey: CodingKey? = nil, as type: T.Type) -> T? { - - if let transformer = cache.valueTransformer(for: additionalKey, in: codingPath) { + + // 转换器只从本视图固定绑定的模型上下文读取,且只在有明确 key 时查询 + if let additionalKey, + let transformer = modelSnapshot?.transformer(forKey: additionalKey) { guard let decoded = transformer.transformFromJSON(value) as? T else { return nil } return decoded } - + if case .number(let number) = value { guard let floatingPoint = T(number), floatingPoint.isFinite else { return nil } return floatingPoint } - + if case .string(let string) = value, case .convertFromString(let posInfString, let negInfString, let nanString) = self.options.nonConformingFloatDecodingStrategy { if string == posInfString { @@ -81,14 +106,15 @@ extension JSONDecoderImpl { return nil } - + func unwrapFixedWidthInteger( from value: JSONValue, for additionalKey: CodingKey? = nil, as type: T.Type) -> T? { - - if let transformer = cache.valueTransformer(for: additionalKey, in: codingPath) { + + if let additionalKey, + let transformer = modelSnapshot?.transformer(forKey: additionalKey) { return transformer.transformFromJSON(value) as? T } - + guard case .number(let number) = value else { return nil } // this is the fast pass. Number directly convertible to Integer @@ -134,21 +160,23 @@ extension JSONDecoderImpl { } func unwrapBoolValue(from value: JSONValue, for additionalKey: CodingKey? = nil) -> Bool? { - - if let transformer = cache.valueTransformer(for: additionalKey, in: codingPath) { + + if let additionalKey, + let transformer = modelSnapshot?.transformer(forKey: additionalKey) { return transformer.transformFromJSON(value) as? Bool } - + guard case .bool(let bool) = value else { return nil } return bool } - + func unwrapStringValue(from value: JSONValue, for additionalKey: CodingKey? = nil) -> String? { - - if let transformer = cache.valueTransformer(for: additionalKey, in: codingPath) { + + if let additionalKey, + let transformer = modelSnapshot?.transformer(forKey: additionalKey) { return transformer.transformFromJSON(value) as? String } - + guard case .string(let string) = value else { return nil } return string } @@ -311,7 +339,9 @@ extension JSONDecoderImpl { extension Decodable { - fileprivate static func createByDirectlyUnwrapping(from decoder: JSONDecoderImpl, type: T.Type) throws -> Self { + /// 字典值直接解包入口:每个字典数据键进入的 decoder 都已清空集合属性边, + /// 这里只为当前实际类型建立一次明确入口。 + fileprivate static func createByDirectlyUnwrapping(from decoder: JSONDecoderImpl) throws -> Self { if Self.self == URL.self || Self.self == Date.self || Self.self == Data.self @@ -322,9 +352,7 @@ extension Decodable { { return try decoder.unwrap(as: Self.self) } - return try decoder.cache.withSnapshot(for: type, codingPath: decoder.codingPath) { - try Self.init(from: decoder) - } + return try decoder.decodeInPlace(Self.self) } /// createByDirectlyUnwrapping 的 Self 是静态绑定的(一个真正的类型), @@ -352,7 +380,7 @@ extension Decodable { /// /// 本质上,这是一个「存在类型调用协议扩展 static 方法」的逃逸通道。 static func _eraseCreateByDirectUnwrap(from decoder: JSONDecoderImpl) throws -> Any { - return try self.createByDirectlyUnwrapping(from: decoder, type: self) + return try self.createByDirectlyUnwrapping(from: decoder) } } diff --git a/Sources/SmartCodable/Core/JSONDecoder/Decoder/Impl/JSONDecoderImpl.swift b/Sources/SmartCodable/Core/JSONDecoder/Decoder/Impl/JSONDecoderImpl.swift index c3044b5..df8c53d 100644 --- a/Sources/SmartCodable/Core/JSONDecoder/Decoder/Impl/JSONDecoderImpl.swift +++ b/Sources/SmartCodable/Core/JSONDecoder/Decoder/Impl/JSONDecoderImpl.swift @@ -11,20 +11,48 @@ import Foundation struct JSONDecoderImpl { let codingPath: [CodingKey] let userInfo: [CodingUserInfoKey: Any] - + let json: JSONValue let options: SmartJSONDecoder._Options - - - /// Records the initialization values of the properties in the keyed container. - var cache: DecodingCache - - init(userInfo: [CodingUserInfoKey: Any], from json: JSONValue, codingPath: [CodingKey], options: SmartJSONDecoder._Options) { + + /// 当前视图绑定的模型上下文;nil 表示该视图不拥有任何模型的字段表。 + /// 子模型的活动不会改变父视图的归属。 + let modelSnapshot: DecodingSnapshot? + + /// 当前视图对应的宿主属性边(宿主模型上下文 + 规范属性 key); + /// nil 表示没有明确的宿主属性(顶层、数组元素、字典数据键、原始嵌套结构等)。 + let propertyContext: PropertyDecodingContext? + + init(userInfo: [CodingUserInfoKey: Any], + from json: JSONValue, + codingPath: [CodingKey], + options: SmartJSONDecoder._Options, + modelSnapshot: DecodingSnapshot? = nil, + propertyContext: PropertyDecodingContext? = nil) { self.userInfo = userInfo self.codingPath = codingPath self.json = json self.options = options - self.cache = DecodingCache() + self.modelSnapshot = modelSnapshot + self.propertyContext = propertyContext + } + + /// 保持数据位置(json / codingPath / userInfo / options),只替换上下文的新视图。 + /// + /// 通过构造新 struct 完成,不修改任何共享对象上的指针; + /// 传入 nil 是明确的隔离结果,不是“再从父级找一份”的信号。 + func replacingContexts( + model: DecodingSnapshot?, + property: PropertyDecodingContext? + ) -> JSONDecoderImpl { + JSONDecoderImpl( + userInfo: userInfo, + from: json, + codingPath: codingPath, + options: options, + modelSnapshot: model, + propertyContext: property + ) } } diff --git a/Sources/SmartCodable/Core/PropertyWrapper/PropertyWrapperProtocol.swift b/Sources/SmartCodable/Core/PropertyWrapper/PropertyWrapperProtocol.swift index feefd54..5320579 100644 --- a/Sources/SmartCodable/Core/PropertyWrapper/PropertyWrapperProtocol.swift +++ b/Sources/SmartCodable/Core/PropertyWrapper/PropertyWrapperProtocol.swift @@ -55,10 +55,12 @@ public extension PropertyWrapperable { } public extension PropertyWrapperable where WrappedValue: Decodable { - /// 解码属性包装器的内层值,并在 SmartCodable 中显式切换到内层解码所有者。 + /// 解码属性包装器的内层值;在 SmartCodable 中会派生一个绑定到内层模型的新解码视图。 /// /// 同时遵循 `PropertyWrapperable` 与 `SmartDecodable` 的双协议包装器应使用此方法, /// 替代直接调用 `WrappedValue(from: decoder)`,以保证正确应用内层模型的默认值与键值映射。 + /// 直接调用 `WrappedValue(from: decoder)` 无法被框架观察,内层模型将继续使用 + /// 包装器自己的解码视图,表现为 wrapper-first 语义。 static func decodeWrappedValue(from decoder: Decoder) throws -> WrappedValue { if let decoder = decoder as? JSONDecoderImpl { return try decoder.unwrap(as: WrappedValue.self) diff --git a/Sources/SmartCodable/Core/PropertyWrapper/SmartAny/SmartAnyImpl.swift b/Sources/SmartCodable/Core/PropertyWrapper/SmartAny/SmartAnyImpl.swift index bec2ec3..3ae1577 100644 --- a/Sources/SmartCodable/Core/PropertyWrapper/SmartAny/SmartAnyImpl.swift +++ b/Sources/SmartCodable/Core/PropertyWrapper/SmartAny/SmartAnyImpl.swift @@ -230,7 +230,7 @@ extension SmartAnyImpl { extension JSONDecoderImpl { fileprivate func unwrapSmartAny() throws -> SmartAnyImpl { - if let tranformer = cache.valueTransformer(for: codingPath.last, in: codingPath.dropLast()) { + if let tranformer = propertyContext?.transformer() { if let decoded = tranformer.transformFromJSON(json) as? SmartAnyImpl { return decoded } else { diff --git a/Sources/SmartCodable/Core/PropertyWrapper/SmartHexColor.swift b/Sources/SmartCodable/Core/PropertyWrapper/SmartHexColor.swift index 5c8bff1..5cbcffd 100644 --- a/Sources/SmartCodable/Core/PropertyWrapper/SmartHexColor.swift +++ b/Sources/SmartCodable/Core/PropertyWrapper/SmartHexColor.swift @@ -86,12 +86,11 @@ extension SmartHexColor: Codable { /** * 虽然初始化赋值时候`public init(wrappedValue: ColorObject?, encodeHexFormat: HexFormat? = nil)` 提供了 `encodeHexFormat`,但是在 `encode` 解析时重新初始化了对象导致赋值的 `encodeHexFormat` 没了。 - * 通过缓存 `Cache` 获取使用者设置的该值。 - * 再赋值到新对象的属性上。 + * 通过当前属性边上的宿主声明恢复该配置,且只恢复配置, + * 不用声明中的默认颜色覆盖刚刚解码得到的颜色。 */ - if let arr = impl.codingPath.removeFromEnd(1), - let hexColor: SmartHexColor = try? impl.cache.initialValue(forKey: impl.codingPath.last, codingPath: arr) { - self.encodeHexFormat = hexColor.encodeHexFormat + if let declared: SmartHexColor = impl.propertyContext?.declaredWrapper(as: SmartHexColor.self) { + self.encodeHexFormat = declared.encodeHexFormat } } diff --git a/Sources/SmartCodable/Core/PropertyWrapper/SmartIgnored.swift b/Sources/SmartCodable/Core/PropertyWrapper/SmartIgnored.swift index 630acd9..c06b458 100644 --- a/Sources/SmartCodable/Core/PropertyWrapper/SmartIgnored.swift +++ b/Sources/SmartCodable/Core/PropertyWrapper/SmartIgnored.swift @@ -75,15 +75,10 @@ extension SmartIgnored: Codable { ) } - // 第三方解码路径(无 parsingMark)下,从宿主快照中恢复完整的包装器声明, + // 第三方解码路径(无 parsingMark)下,经当前属性边从宿主上下文恢复完整的包装器声明, // 确保包装器自身的配置状态(如 isEncodable)及 wrappedValue 完整保留。 - var hostPath = impl.codingPath - let key = hostPath.popLast() - if let declared = impl.cache.initialPropertyWrapperIfPresent( - forKey: key, - codingPath: hostPath, - as: Self.self - ) { + // 根级 wrapper 没有宿主声明可读时,沿用 Patcher 兜底。 + if let declared: Self = impl.propertyContext?.declaredWrapper(as: Self.self) { self = declared } else { wrappedValue = try Patcher.defaultForType() diff --git a/Sources/SmartCodable/Core/Sentinel/SmartSentinel.swift b/Sources/SmartCodable/Core/Sentinel/SmartSentinel.swift index 361ff2b..b965e0b 100644 --- a/Sources/SmartCodable/Core/Sentinel/SmartSentinel.swift +++ b/Sources/SmartCodable/Core/Sentinel/SmartSentinel.swift @@ -74,8 +74,8 @@ extension SmartSentinel { let typeString = String(describing: T.self) guard !typeString.starts(with: "SmartIgnored<") else { return } - // 获取当前路径活跃所有者的类型名称,用于哨兵日志定位 - let className = impl.cache.activeOwner(at: impl.codingPath).map { String(describing: $0) } ?? "" + // 字段日志使用该容器固定所属的模型类型,不查询任何活动状态 + let className = impl.modelSnapshot.map { String(describing: $0.objectType) } ?? "" var path = impl.codingPath path.append(key) diff --git a/Sources/SmartCodable/Core/SmartType/SmartCaseDefaultable.swift b/Sources/SmartCodable/Core/SmartType/SmartCaseDefaultable.swift index ed6f676..26bc8d2 100644 --- a/Sources/SmartCodable/Core/SmartType/SmartCaseDefaultable.swift +++ b/Sources/SmartCodable/Core/SmartType/SmartCaseDefaultable.swift @@ -42,7 +42,7 @@ public extension SmartAssociatedEnumerable { throw DecodingError.dataCorrupted(DecodingError.Context(codingPath: decoder.codingPath, debugDescription: des)) } - guard let tranformer = _decoder.cache.valueTransformer(for: _decoder.codingPath.last, in: _decoder.codingPath.dropLast()), + guard let tranformer = _decoder.propertyContext?.transformer(), let decoded = tranformer.transformFromJSON(_decoder.json) as? Self else { throw DecodingError.valueNotFound(Self.self, DecodingError.Context.init(codingPath: _decoder.codingPath, debugDescription: "No custom parsing policy is implemented for associated value enumerations")) } diff --git a/Tests/DecodingCacheLifecycleTests.swift b/Tests/DecodingContextTests.swift similarity index 50% rename from Tests/DecodingCacheLifecycleTests.swift rename to Tests/DecodingContextTests.swift index 4cc2eb7..3f39fc8 100644 --- a/Tests/DecodingCacheLifecycleTests.swift +++ b/Tests/DecodingContextTests.swift @@ -1,131 +1,141 @@ import XCTest @testable import SmartCodable -/// `DecodingCache.withSnapshot` 的生命周期契约测试 -final class DecodingCacheLifecycleTests: XCTestCase { +/// 解码上下文(DecodingSnapshot / PropertyDecodingContext)的契约测试。 +/// +/// 由旧 `DecodingCacheLifecycleTests` 迁移:保留全部行为断言(默认值、映射、 +/// 包装器配置、异常后宿主字段、编码输出),把 snapshots.count / activeOwnerDepth / +/// scopeIdentifier 等旧状态机白盒探针替换为“容器固定绑定正确 owner”的语义断言。 +final class DecodingContextTests: XCTestCase { - private var cache: DecodingCache! + // MARK: - 上下文对象单元契约 - override func setUp() { - super.setUp() - cache = DecodingCache() + /// 同一个 snapshot 内的懒加载默认值只反射一次,重复查询复用同一份引用 + func testSameSnapshotReusesLazilyCapturedDefaultReference() throws { + ContextBoxModel.mirrorInitCount = 0 + let snapshot = DecodingSnapshot(objectType: ContextBoxModel.self) + + let first: ContextBox = try XCTUnwrap(snapshot.initialValueIfPresent(forKey: ContextKeys.box)) + let second: ContextBox = try XCTUnwrap(snapshot.initialValueIfPresent(forKey: ContextKeys.box)) + + XCTAssertTrue(first === second) + XCTAssertEqual(ContextBoxModel.mirrorInitCount, 1, + "同一 snapshot 内重复查询不应重新执行 objectType.init()") } - /// 符合资格的模型在 body 执行期间有一个快照,正常返回后恢复原栈深度 - func testEligibleModelHasSnapshotDuringBodyAndRestoresStackAfterReturn() { - let before = cache.snapshots.count - var duringBody = 0 + /// 不同 snapshot 是两次独立构造,默认值对象不得意外别名 + func testDistinctSnapshotsProvideIndependentDefaultObjects() throws { + let snapshotA = DecodingSnapshot(objectType: ContextBoxModel.self) + let snapshotB = DecodingSnapshot(objectType: ContextBoxModel.self) - let result = cache.withSnapshot(for: LifecycleModel.self, codingPath: []) { - duringBody = self.cache.snapshots.count - return 42 - } + let boxA: ContextBox = try XCTUnwrap(snapshotA.initialValueIfPresent(forKey: ContextKeys.box)) + let boxB: ContextBox = try XCTUnwrap(snapshotB.initialValueIfPresent(forKey: ContextKeys.box)) + + XCTAssertFalse(boxA === boxB) + XCTAssertEqual(boxA.value, 10) + XCTAssertEqual(boxB.value, 10) + } + + /// declaredWrapper 返回完整包装器声明,保留自身配置(isEncodable),而非仅 wrappedValue + func testDeclaredWrapperPreservesWrapperConfiguration() throws { + let snapshot = DecodingSnapshot(objectType: ContextIgnoredHost.self) + + let declared: SmartIgnored = try XCTUnwrap( + snapshot.declaredWrapper(forKey: ContextKeys.settings, as: SmartIgnored.self) + ) - XCTAssertEqual(result, 42) - XCTAssertEqual(duringBody, before + 1) - XCTAssertEqual(cache.snapshots.count, before) + XCTAssertTrue(declared.isEncodable) + XCTAssertEqual(declared.wrappedValue.level, 7) } - /// body 抛错后仍恢复原栈深度 - func testThrowingBodyStillRestoresStackDepth() { - let before = cache.snapshots.count + /// “已反射且没有存储字段”与“尚未反射”必须可区分:空表只执行一次 provider + func testEmptyFieldTableIsLoadedExactlyOnce() throws { + ContextEmptyModel.providerCount = 0 + let snapshot = DecodingSnapshot(objectType: ContextEmptyModel.self) - XCTAssertThrowsError(try cache.withSnapshot(for: LifecycleModel.self, codingPath: []) { - throw LifecycleTestError.boom - }) + let missing: ContextBox? = snapshot.initialValueIfPresent(forKey: ContextKeys.box) + XCTAssertNil(missing) + let stillMissing: ContextBox? = snapshot.initialValueIfPresent(forKey: ContextKeys.box) + XCTAssertNil(stillMissing) - XCTAssertEqual(cache.snapshots.count, before) + XCTAssertEqual(ContextEmptyModel.providerCount, 1, + "已加载的空表不应被误判成未加载而反复执行 provider") } - /// 真实 unwrap 调用抛错后,也必须清理该调用创建的快照 - func testUnwrapThrowingModelRestoresSnapshotStack() { + /// transformer 查询命中当前模型声明的转换器,未声明的 key 返回 nil + func testTransformerLookupIsScopedToOwningModel() throws { + let snapshot = DecodingSnapshot(objectType: ContextMappedModel.self) + let transformer = try XCTUnwrap(snapshot.transformer(forKey: ContextKeys.name)) + let decoded = try XCTUnwrap(transformer.transformFromJSON(.string("mccc")) as? String) + XCTAssertEqual(decoded, "MCCC") + XCTAssertNil(snapshot.transformer(forKey: ContextKeys.other)) + } + + // MARK: - 容器固定绑定(替代旧快照栈深度断言) + + /// @SmartFlat 内层模型解码期间,内层容器必须绑定内层模型,宿主容器不被替换 + func testSmartFlatInnerContainerBindsInnerModelOwner() throws { + OwnerProbeModel.reset() + _ = try XCTUnwrap(FlatProbeHost.deserialize(from: [:])) + XCTAssertEqual(OwnerProbeModel.observedOwnerTypeName, "OwnerProbeModel", + "平铺内层模型的容器必须绑定内层模型自己的上下文") + } + + /// 普通嵌套模型属性解码期间,模型绑定自身(不再依赖共享栈) + func testNestedModelPropertyContainerBindsNestedModelOwner() throws { + OwnerProbeModel.reset() + _ = try XCTUnwrap(NestedProbeHost.deserialize(from: ["user": [:]])) + XCTAssertEqual(OwnerProbeModel.observedOwnerTypeName, "OwnerProbeModel") + } + + /// unwrap 抛错后不得残留影响后续解码的状态:下一次解码仍取得正确默认值 + func testUnwrapThrowingModelLeavesNoResidualStateForNextDecode() throws { let smartDecoder = SmartJSONDecoder() - let decoder = JSONDecoderImpl( + let impl = JSONDecoderImpl( userInfo: [:], from: .object([:]), codingPath: [], options: smartDecoder.options ) - XCTAssertThrowsError(try decoder.unwrap(as: ThrowingLifecycleModel.self)) - XCTAssertTrue(decoder.cache.snapshots.isEmpty) - } - - /// 不符合资格的普通类型不改变栈 - func testIneligibleTypeDoesNotChangeStack() { - let before = cache.snapshots.count - var duringBody = before + XCTAssertThrowsError(try impl.unwrap(as: ThrowingLifecycleModel.self)) - let result = cache.withSnapshot(for: String.self, codingPath: []) { - duringBody = self.cache.snapshots.count - return "ok" - } + let next = try impl.unwrap(as: ContextBoxModel.self) + XCTAssertEqual(next.box.value, 10, + "上一次抛错的解码不得影响下一次入口的默认值") + } - XCTAssertEqual(result, "ok") - XCTAssertEqual(duringBody, before) - XCTAssertEqual(cache.snapshots.count, before) - } - - /// 嵌套作用域内层抛错并被捕获后,外层快照仍是栈顶;外层结束后栈为空 - func testNestedThrowingScopeKeepsOuterSnapshotOnTopAndDrainsStack() throws { - let outerResult = try cache.withSnapshot( - for: LifecycleModel.self, - codingPath: [LifecycleCodingKey.outer] - ) { - let outerSnapshot = try XCTUnwrap(self.cache.snapshots.last) - let innerBefore = self.cache.snapshots.count - - XCTAssertThrowsError( - try self.cache.withSnapshot( - for: LifecycleModel.self, - codingPath: [LifecycleCodingKey.outer, LifecycleCodingKey.inner] - ) { - throw LifecycleTestError.boom - } - ) - - XCTAssertEqual(self.cache.snapshots.count, innerBefore) - XCTAssertEqual(self.cache.snapshots.count, 1) - XCTAssertTrue(self.cache.snapshots.last === outerSnapshot) - XCTAssertEqual( - self.cache.snapshots.last?.codingPath.map(\.stringValue), - [LifecycleCodingKey.outer.rawValue] - ) - - return "outer" - } + // MARK: - 独立调用与稳定归属 - XCTAssertEqual(outerResult, "outer") - XCTAssertTrue(cache.snapshots.isEmpty) + /// 同一次模型 init 内多次请求 keyed / single 容器,模型上下文不被重建(T32) + func testRepeatedContainerRequestsKeepSameContextInOneInit() throws { + let model = try XCTUnwrap(RepeatedContainerHost.deserialize(from: [:])) + XCTAssertTrue(model.sameContextAcrossRequests, + "同一次 init 内重复取容器应复用同一份模型上下文") } - // MARK: - 快照栈深度不变量(每个活动中的模型 init 恰好持有一个快照) + /// 不同宿主的同名 _settings 声明只能恢复属性边精确指向的那一个(T33) + func testPropertyEdgeRestoresPreciseHostDeclaration() throws { + let host = try XCTUnwrap(EdgeParentHost.deserialize(from: ["child": [:]])) - /// @SmartFlat(Optional 与非 Optional)解码内层模型期间, - /// 快照栈深度必须恰好为“宿主 + 内层”两层:包装器自身不得额外压栈 - func testSmartFlatDecodingMaintainsHostPlusInnerSnapshotDepthOnly() throws { - SnapshotDepthProbe.lastObservedDepth = nil - _ = try XCTUnwrap(OptionalFlatProbeHost.deserialize(from: [:])) - XCTAssertEqual(SnapshotDepthProbe.lastObservedDepth, 2, - "Optional 平铺期间应为 宿主+内层 两层快照,包装器不得重复压栈") - - SnapshotDepthProbe.lastObservedDepth = nil - _ = try XCTUnwrap(NonOptionalFlatProbeHost.deserialize(from: [:])) - XCTAssertEqual(SnapshotDepthProbe.lastObservedDepth, 2, - "非 Optional 平铺期间应为 宿主+内层 两层快照,包装器不得重复压栈") + XCTAssertEqual(host.settings.level, 7) + XCTAssertFalse(host.settingsIsEncodable) + XCTAssertEqual(host.child.settings.level, 9) + XCTAssertTrue(host.child.settingsIsEncodable) } - /// 普通嵌套模型属性使用独立缓存,内层模型 init 期间栈深度为 1 - func testNestedModelPropertyDecodesWithIsolatedSnapshotStack() throws { - SnapshotDepthProbe.lastObservedDepth = nil - _ = try XCTUnwrap(NestedProbeHost.deserialize(from: ["user": [:]])) - XCTAssertEqual(SnapshotDepthProbe.lastObservedDepth, 1) + /// 用户在 init 中主动引用静态单例属于用户指定的共享,框架不得擅自深拷贝(T35) + func testStaticSingletonDefaultIsNotCopied() throws { + let first = try XCTUnwrap(SingletonDefaultModel.deserialize(from: [:])) + let second = try XCTUnwrap(SingletonDefaultModel.deserialize(from: [:])) + + XCTAssertTrue(first.box === SingletonBox.shared) + XCTAssertTrue(second.box === SingletonBox.shared) } - // MARK: - createByDirectlyUnwrapping 逃逸通道(unwrapDictionary 场景) + // MARK: - 字典值直接解包与 SmartAny 兜底 - /// 字典值模型的解码必须经过 createByDirectlyUnwrapping 的快照作用域: - /// 元素字段缺 key 时保留元素声明的默认值,而不是退化为类型零值 + /// 字典值元素缺 key 时保留元素声明的默认值,而不是退化为类型零值 func testDictionaryValuedModelsPreserveElementDefaultsThroughDirectUnwrap() throws { struct Host: SmartCodableX { var slots: [String: DictEntry] = [:] @@ -137,12 +147,10 @@ final class DecodingCacheLifecycleTests: XCTestCase { XCTAssertEqual(host.slots["a"]?.name, "A") XCTAssertEqual(host.slots["b"]?.name, "inner-default", - "字典元素缺 key 时应命中元素自身快照的声明默认值") + "字典元素缺 key 时应命中元素自身上下文的声明默认值") } - // MARK: - SmartAny 模型兜底路径 - - /// @SmartAny 包装模型时,兜底解码路径同样要建立快照作用域,保留模型声明默认值 + /// @SmartAny 包装模型时,兜底解码路径保留模型声明默认值 func testSmartAnyModelBackedValueKeepsDeclaredDefaultsWhenKeysMissing() throws { struct Model: SmartCodableX { @SmartAny var payload: SmartAnyTargetModel = .init() @@ -153,34 +161,25 @@ final class DecodingCacheLifecycleTests: XCTestCase { XCTAssertEqual(model.payload.score, 11) } - // MARK: - 第三方属性包装器的模型快照作用域 + // MARK: - 第三方包装器与双协议包装器 - /// 第三方包装器直接调用 `Value(from:)` 时,内层 SmartDecodable 仍应拥有自己的快照, - /// 并与通过 SingleValueDecodingContainer 重新进入 unwrap 的包装器保持一致。 - func testThirdPartyWrappersPreserveWrappedModelDefaultsWithoutDuplicateSnapshots() throws { - WrapperProbeModel.lastObservedDepth = nil + /// 第三方包装器两条路径(直接初始化与 single-value 重入)都保留内层声明默认值 + func testThirdPartyWrappersPreserveWrappedModelDefaults() throws { let direct = try XCTUnwrap(DirectWrapperHost.deserialize(from: ["payload": [:]])) - XCTAssertEqual(direct.payload.count, 99) - XCTAssertEqual(WrapperProbeModel.lastObservedDepth, 2, - "直接初始化路径应只有宿主与内层模型两个活动快照") - WrapperProbeModel.lastObservedDepth = nil let container = try XCTUnwrap(ContainerWrapperHost.deserialize(from: ["payload": [:]])) - XCTAssertEqual(container.payload.count, 99) - XCTAssertEqual(WrapperProbeModel.lastObservedDepth, 2, - "容器解码路径不应为同一个内层模型重复建立快照") } - /// 双协议包装器与内层模型共享 codingPath 时,两者都必须保留各自声明的默认值。 + /// 双协议包装器与内层模型共享 codingPath 时,两者都保留各自声明的默认值 func testDualConformingWrapperPreservesWrapperAndWrappedModelDefaults() throws { let direct = try XCTUnwrap(DualDirectWrapperHost.deserialize(from: ["payload": [:]])) XCTAssertEqual(direct.payload.count, 99) XCTAssertEqual(direct.$payload.marker, 7) } - /// 包装器自己的 key/value mapping 必须归属于包装器 owner,不能被同路径的内层模型覆盖。 + /// 包装器自己的 key/value mapping 归属包装器 owner,不被同路径内层模型覆盖 func testDualConformingWrapperKeepsItsOwnKeyAndValueMappings() throws { let host = try XCTUnwrap(DualDirectWrapperHost.deserialize(from: [ "payload": [ @@ -193,7 +192,7 @@ final class DecodingCacheLifecycleTests: XCTestCase { XCTAssertEqual(host.payload.count, 102) } - /// 同名、无 mapping 的字段也必须按 CodingKeys owner 归属,不能固定取 owner 组末尾。 + /// 同名、无 mapping 的字段按各自 CodingKeys owner 归属,不依赖解码顺序 func testDualConformingWrapperAndWrappedModelKeepSameNamedDefaults() throws { let direct = try XCTUnwrap(SameNameDirectHost.deserialize(from: ["payload": [:]])) XCTAssertEqual(direct.$payload.shared, 7) @@ -208,7 +207,7 @@ final class DecodingCacheLifecycleTests: XCTestCase { XCTAssertEqual(innerFirst.payload.shared, 99) } - /// 旧 wrapper 没有 owner transition 时保持确定性的 wrapper-first 兼容语义。 + /// 旧 wrapper 未使用协作入口时保持确定性的 wrapper-first 兼容语义 func testLegacyDualWrapperWithoutOwnerTransitionUsesWrapperOwner() throws { let host = try XCTUnwrap(LegacySameNameHost.deserialize(from: ["payload": [:]])) @@ -216,7 +215,7 @@ final class DecodingCacheLifecycleTests: XCTestCase { XCTAssertEqual(host.payload.shared, 7) } - /// 无 parsingMark 时,完整属性包装器恢复也必须使用当前活动的 wrapper owner。 + /// 无 parsingMark 时,嵌套包装器内的完整属性包装器声明从包装器自己的属性表恢复 func testDualOwnerRestoresCompleteNestedPropertyWrapperFromWrapperOwner() throws { let smartDecoder = SmartJSONDecoder() let impl = JSONDecoderImpl( @@ -231,20 +230,17 @@ final class DecodingCacheLifecycleTests: XCTestCase { XCTAssertEqual(wrapper.settings.level, 7) XCTAssertTrue(wrapper.settingsIsEncodable) XCTAssertEqual(wrapper.wrappedValue.shared, 99) - XCTAssertTrue(impl.cache.snapshots.isEmpty) } - /// 双层双协议 wrapper 必须在同一 scope 内逐级提升预声明 owner,不能重复创建内层 wrapper 快照。 - func testNestedDualWrappersReusePredeclaredOwnersForDirectAndContainerDecoding() throws { + /// 双层双协议 wrapper:逐级建立自己的上下文,直接初始化与容器路径一致 + func testNestedDualWrappersKeepPerLevelOwnersForDirectAndContainerDecoding() throws { NestedDualLeaf.resetObservations() let defaults = try XCTUnwrap(NestedDirectWrapperHost.deserialize(from: ["payload": [:]])) XCTAssertEqual(defaults.$payload.outerMarker, 11) XCTAssertEqual(defaults.$payload.wrappedValue.innerMarker, 22) XCTAssertEqual(defaults.payload.value, 99) - assertNestedDualSnapshotObservations() - XCTAssertTrue(try XCTUnwrap(NestedDualLeaf.observedCache).snapshots.isEmpty) - XCTAssertEqual(try XCTUnwrap(NestedDualLeaf.observedCache).activeOwnerDepth, 0) + XCTAssertEqual(NestedDualLeaf.observedOwnerTypeName, "NestedDualLeaf") NestedDualLeaf.resetObservations() let direct = try XCTUnwrap(NestedDirectWrapperHost.deserialize(from: [ @@ -257,9 +253,7 @@ final class DecodingCacheLifecycleTests: XCTestCase { XCTAssertEqual(direct.$payload.outerMarker, 41) XCTAssertEqual(direct.$payload.wrappedValue.innerMarker, 52) XCTAssertEqual(direct.payload.value, 99) - assertNestedDualSnapshotObservations() - XCTAssertTrue(try XCTUnwrap(NestedDualLeaf.observedCache).snapshots.isEmpty) - XCTAssertEqual(try XCTUnwrap(NestedDualLeaf.observedCache).activeOwnerDepth, 0) + XCTAssertEqual(NestedDualLeaf.observedOwnerTypeName, "NestedDualLeaf") NestedDualLeaf.resetObservations() let container = try XCTUnwrap(NestedContainerWrapperHost.deserialize(from: [ @@ -272,13 +266,13 @@ final class DecodingCacheLifecycleTests: XCTestCase { XCTAssertEqual(container.$payload.outerMarker, 41) XCTAssertEqual(container.$payload.wrappedValue.innerMarker, 52) XCTAssertEqual(container.payload.value, 99) - assertNestedDualSnapshotObservations() - XCTAssertTrue(try XCTUnwrap(NestedDualLeaf.observedCache).snapshots.isEmpty) - XCTAssertEqual(try XCTUnwrap(NestedDualLeaf.observedCache).activeOwnerDepth, 0) + XCTAssertEqual(NestedDualLeaf.observedOwnerTypeName, "NestedDualLeaf") } - /// 双层 owner transition 的最内层抛错时,本次 entry 创建的所有 owner 都必须按逆序清理。 - func testThrowingNestedDualWrappersDrainSnapshotsAndRestoreHostDefaults() throws { + // MARK: - 异常路径 + + /// 双层 wrapper 最内层抛错时无需任何恢复动作,宿主回退声明值并继续后续字段 + func testThrowingNestedDualWrappersRestoreHostDefaults() throws { ThrowingNestedDualLeaf.resetObservations() let host = try XCTUnwrap(ThrowingNestedWrapperHost.deserialize(from: [ @@ -290,27 +284,10 @@ final class DecodingCacheLifecycleTests: XCTestCase { XCTAssertEqual(host.$payload.wrappedValue.innerMarker, 22) XCTAssertEqual(host.payload.value, 99) XCTAssertEqual(host.trailing, 77) - XCTAssertEqual(ThrowingNestedDualLeaf.observedSnapshotDepth, 3) - XCTAssertEqual(ThrowingNestedDualLeaf.observedActiveOwnerDepth, 3) - XCTAssertTrue(try XCTUnwrap(ThrowingNestedDualLeaf.observedCache).snapshots.isEmpty) - XCTAssertEqual(try XCTUnwrap(ThrowingNestedDualLeaf.observedCache).activeOwnerDepth, 0) } - private func assertNestedDualSnapshotObservations( - file: StaticString = #filePath, - line: UInt = #line - ) { - XCTAssertEqual(NestedDualLeaf.observedSnapshotDepth, 3, file: file, line: line) - XCTAssertEqual(NestedDualLeaf.observedActiveOwnerDepth, 3, file: file, line: line) - XCTAssertEqual(NestedDualLeaf.observedObjectTypeCount, 3, file: file, line: line) - XCTAssertEqual(NestedDualLeaf.observedScopeCount, 1, file: file, line: line) - } - - /// 第三方包装器的内层模型抛错时,包装器提供的快照必须随作用域清理; - /// 宿主应回退到声明值,并继续恢复后续字段的声明默认值。 - func testThrowingThirdPartyWrapperRestoresSnapshotStackAndHostDefaults() throws { - ThrowingWrapperProbeModel.observedCache = nil - + /// 第三方包装器内层抛错:宿主取得声明回退,后续字段继续命中宿主默认值 + func testThrowingThirdPartyWrapperRestoresHostDefaults() throws { let legacyHost = try XCTUnwrap(ThrowingWrapperHost.deserialize(from: [ "payload": [:], "trailing": "not-an-int" @@ -318,9 +295,7 @@ final class DecodingCacheLifecycleTests: XCTestCase { XCTAssertEqual(legacyHost.payload.marker, 41) XCTAssertEqual(legacyHost.trailing, 77) - XCTAssertTrue(try XCTUnwrap(ThrowingWrapperProbeModel.observedCache).snapshots.isEmpty) - ThrowingWrapperProbeModel.observedCache = nil let dualHost = try XCTUnwrap(ThrowingDualWrapperHost.deserialize(from: [ "payload": [:], "trailing": "not-an-int" @@ -329,14 +304,10 @@ final class DecodingCacheLifecycleTests: XCTestCase { XCTAssertEqual(dualHost.payload.marker, 41) XCTAssertEqual(dualHost.$payload.marker, 7) XCTAssertEqual(dualHost.trailing, 77) - XCTAssertTrue(try XCTUnwrap(ThrowingWrapperProbeModel.observedCache).snapshots.isEmpty) } - /// 包装器通过 singleValueContainer 重入内层模型后即使抛错,也必须清空该作用域, - /// 并让宿主后续字段继续命中宿主声明的默认值。 - func testThrowingContainerWrapperReentryCleansSnapshotsAndRestoresHostDefaults() throws { - ThrowingWrapperProbeModel.observedCache = nil - + /// 包装器经 singleValueContainer 重入后抛错:宿主后续字段继续命中声明默认值 + func testThrowingContainerWrapperReentryRestoresHostDefaults() throws { let host = try XCTUnwrap(ThrowingContainerWrapperHost.deserialize(from: [ "payload": [:], "trailing": "not-an-int" @@ -344,12 +315,12 @@ final class DecodingCacheLifecycleTests: XCTestCase { XCTAssertEqual(host.payload.marker, 41) XCTAssertEqual(host.trailing, 77) - XCTAssertTrue(try XCTUnwrap(ThrowingWrapperProbeModel.observedCache).snapshots.isEmpty) } - // MARK: - SmartIgnored 无 parsingMark 路径 + // MARK: - 日志归属 - func testSentinelUsesExecutingWrapperAndWrappedOwner() throws { + /// 哨兵日志的模型名来自容器固定所属模型,与解码顺序无关 + func testSentinelLogsContainerOwningModel() throws { let previousMode = SmartSentinel.debugMode SmartSentinel.debugMode = .verbose defer { @@ -372,28 +343,9 @@ final class DecodingCacheLifecycleTests: XCTestCase { } } - func testActiveOwnerRestoresAfterWrappedReturnAndThrow() throws { - typealias Wrapper = DualDirectInitWrapper - try cache.withSnapshot(for: Wrapper.self, codingPath: []) { - XCTAssertTrue(self.cache.activeOwner(at: []) == Wrapper.self) - self.cache.withSnapshot(for: DualWrapperProbeModel.self, codingPath: []) { - XCTAssertTrue(self.cache.activeOwner(at: []) == DualWrapperProbeModel.self) - XCTAssertEqual(self.cache.snapshots.count, 2) - } - XCTAssertTrue(self.cache.activeOwner(at: []) == Wrapper.self) - XCTAssertThrowsError(try self.cache.withSnapshot(for: DualWrapperProbeModel.self, codingPath: []) { - XCTAssertTrue(self.cache.activeOwner(at: []) == DualWrapperProbeModel.self) - throw LifecycleTestError.boom - }) - XCTAssertTrue(self.cache.activeOwner(at: []) == Wrapper.self) - } - XCTAssertNil(cache.activeOwner(at: [])) - XCTAssertEqual(cache.activeOwnerDepth, 0) - XCTAssertTrue(cache.snapshots.isEmpty) - } + // MARK: - SmartIgnored 无 parsingMark 路径 - /// 未设置 parsingMark 时(第三方直接触发解码),@SmartIgnored 应取宿主声明的初始值, - /// 而不是退化为 Patcher 的全新默认实例 + /// 未设置 parsingMark 时,@SmartIgnored 取宿主声明的初始值,而非 Patcher 全新实例 func testSmartIgnoredWithoutParsingMarkKeepsHostDeclaredInitialValue() throws { let smartDecoder = SmartJSONDecoder() let impl = JSONDecoderImpl( @@ -408,55 +360,304 @@ final class DecodingCacheLifecycleTests: XCTestCase { XCTAssertEqual(host.settings.level, 7, "应保留宿主声明的初始值 7,而非 JSON 的 999 或 Patcher 的 0") } + + // MARK: - 集合与结构下钻 + + /// 根数组与嵌套数组中的 Smart 模型每个元素独立取得默认值(T38) + func testRootAndNestedArraysGiveElementsIndependentDefaults() throws { + let roots = try XCTUnwrap([ArrayElementChild].deserialize(from: [ + ["tag": "a"], + [:] as [String: Any] + ])) + XCTAssertEqual(roots.map(\.tag), ["a", "inner"]) + + let host = try XCTUnwrap(ArrayElementHost.deserialize(from: [ + "models": [[:], ["tag": "b"]] as [Any] + ])) + XCTAssertEqual(host.models.map(\.tag), ["inner", "b"]) + } + + /// 宿主字段整体 transformer 只对整个字段生效一次,不泄漏到内部元素(T39) + func testWholeFieldTransformerAppliesOncePerField() throws { + CountingListTransformer.applyCount = 0 + let host = try XCTUnwrap(TransformerListHost.deserialize(from: [ + "scores": ["1", "2"] + ])) + + XCTAssertEqual(host.scores, [11, 12]) + XCTAssertEqual(CountingListTransformer.applyCount, 1, + "整字段 transformer 应在字段层生效一次,而不是逐元素生效") + } + + /// JSON 字符串形式的对象输入同样绑定正确的容器上下文(T41) + func testJSONStringObjectInputBindsContainerContext() throws { + let model = try XCTUnwrap(StringInputHost.deserialize(from: "{\"name\":\"mccc\"}")) + XCTAssertEqual(model.name, "mccc") + XCTAssertEqual(model.mikeNo, "我是默认值") + } + + /// 手写 nestedContainer 取得的原始子结构不继承宿主字段表(T45/T12) + func testHandWrittenNestedContainerStaysIsolated() throws { + let host = try XCTUnwrap(RawNestedHost.deserialize(from: [ + "plain": [:] as [String: Any] + ])) + + XCTAssertEqual(host.count, 99, "宿主自己的字段仍取宿主声明默认值") + XCTAssertEqual(host.plainCount, 0, "原始嵌套结构不得借用宿主字段表") + } + + // MARK: - 回调次数 + + /// didFinishMapping 在各完成节点恰好执行一次(T46) + func testDidFinishMappingExecutesExactlyOncePerCompletionPoint() throws { + MappingCountedModel.mappingCount = 0 + _ = try XCTUnwrap(MappingCountedModel.deserialize(from: [:])) + XCTAssertEqual(MappingCountedModel.mappingCount, 1, "根模型入口") + + MappingCountedModel.mappingCount = 0 + _ = try XCTUnwrap(MappingCountHost.deserialize(from: ["child": [:]])) + XCTAssertEqual(MappingCountedModel.mappingCount, 1, "普通嵌套属性") + + MappingCountedModel.mappingCount = 0 + _ = try XCTUnwrap(MappingCountHost.deserialize(from: [:])) + XCTAssertEqual(MappingCountedModel.mappingCount, 1, "缺失字段回退后的嵌套属性") + + MappingCountedModel.mappingCount = 0 + _ = try XCTUnwrap(MappingCountArrayHost.deserialize(from: [ + "items": [[:], [:]] as [Any] + ])) + XCTAssertEqual(MappingCountedModel.mappingCount, 2, "数组元素各一次") + + MappingCountedModel.mappingCount = 0 + let transformed = try XCTUnwrap(MappingTransformerHost.deserialize(from: [ + "child": "5" + ])) + XCTAssertEqual(transformed.child.level, 5, "必须真的走了 transformer 转换路径") + XCTAssertEqual(MappingCountedModel.mappingCount, 1, "transformer 返回值路径") + } + + // MARK: - 并发解码 + + /// 多个独立 decoder 并发解码:结果各自独立,框架不引入跨调用的默认值共享(T49)。 + /// 数据竞争检测配合 TSan:swift test --sanitize=thread --filter DecodingContextTests + func testConcurrentIndependentDecodersProduceIndependentResults() throws { + final class ConcurrentLeaf: SmartCodableX { + var tag = "inner" + required init() {} + } + + let group = DispatchGroup() + let queue = DispatchQueue(label: "smartcodable.tests.decode", attributes: .concurrent) + let lock = NSLock() + var results: [String] = [] + + for index in 0..<40 { + group.enter() + queue.async { + defer { group.leave() } + let tag = "n\(index)" + let decoded = ConcurrentLeaf.deserialize(from: ["tag": tag] as [String: Any]) + lock.lock() + results.append(decoded?.tag ?? "nil") + lock.unlock() + } + } + + XCTAssertEqual(group.wait(timeout: .now() + 10), .success) + XCTAssertEqual(results.count, 40) + XCTAssertEqual(Set(results).count, 40, "并发解码结果不得串值") + } + + // MARK: - 上下文释放 + + /// 正常解码结束后,框架不再持有本次解码的模型上下文(T47 弱引用探针) + func testDecoderContextIsReleasedAfterDecodeCompletes() throws { + final class ReleaseProbe: SmartCodableX { + static weak var observedContext: DecodingSnapshot? + + var value = 1 + + required init() {} + required init(from decoder: Decoder) throws { + ReleaseProbe.observedContext = (decoder as? JSONDecoderImpl)?.modelSnapshot + } + } + + var result: ReleaseProbe? + autoreleasepool { + result = try? ReleaseProbe.deserialize(from: [:] as [String: Any]) + } + + XCTAssertNotNil(result) + XCTAssertNil(ReleaseProbe.observedContext, + "解码完成后框架不应继续持有模型上下文(引用环或全局注册都会导致此断言失败)") + } +} + +// MARK: - 单元契约 fixture + +private enum ContextKeys: String, CodingKey { + case box + case settings + case name + case other +} + +private final class ContextBox: Codable { + var value = 10 } -private final class LifecycleModel: SmartCodableX { - var value: String = "" +private final class ContextBoxModel: SmartCodableX { + static var mirrorInitCount = 0 + + var box = ContextBox() + + required init() { + ContextBoxModel.mirrorInitCount += 1 + } +} + +private struct ContextIgnoredHost: SmartCodableX { + @SmartIgnored(wrappedValue: ContextIgnoredSettings(level: 7), isEncodable: true) + var settings: ContextIgnoredSettings +} + +private struct ContextIgnoredSettings: SmartCodableX { + var level: Int = 0 + + init() {} + init(level: Int) { + self.level = level + } +} + +/// 没有存储字段的模型:用于验证空表与未加载的区分 +private final class ContextEmptyModel: SmartDecodable, SmartEncodable { + static var providerCount = 0 + + required init() { + ContextEmptyModel.providerCount += 1 + } +} + +private struct ContextMappedModel: SmartCodableX { + var name: String = "" + var other: String = "" + + static func mappingForValue() -> [SmartValueTransformer]? { + [ContextKeys.name <--- ContextUpperTransformer()] + } +} + +private struct ContextUpperTransformer: ValueTransformable { + typealias Object = String + typealias JSON = String + + func transformFromJSON(_ value: Any) -> String? { + (value as? String)?.uppercased() + } + + func transformToJSON(_ value: String) -> String? { + value + } +} + +// MARK: - 容器归属探针 + +private final class OwnerProbeModel: SmartCodableX { + static var observedOwnerTypeName: String? + + static func reset() { + observedOwnerTypeName = nil + } + required init() {} + required init(from decoder: Decoder) throws { + if let impl = decoder as? JSONDecoderImpl { + OwnerProbeModel.observedOwnerTypeName = impl.modelSnapshot.map { String(describing: $0.objectType) } + } + } +} + +private struct FlatProbeHost: SmartCodableX { + @SmartFlat var user: OwnerProbeModel? +} + +private struct NestedProbeHost: SmartCodableX { + var user: OwnerProbeModel? } private final class ThrowingLifecycleModel: SmartCodableX { required init() {} required init(from decoder: Decoder) throws { - throw LifecycleTestError.boom + throw ContextTestError.boom } } -private enum LifecycleTestError: Error { +private enum ContextTestError: Error { case boom } -private enum LifecycleCodingKey: String, CodingKey { - case outer - case inner -} +// MARK: - 独立调用与归属 fixture -/// 在 init(from:) 内记录当时快照栈深度的探针模型 -private final class SnapshotDepthProbe: SmartCodableX { - static var lastObservedDepth: Int? +private struct RepeatedContainerHost: SmartCodableX { + var sameContextAcrossRequests = false - required init() {} + private enum CodingKeys: String, CodingKey { case box } - required init(from decoder: Decoder) throws { - if let impl = decoder as? JSONDecoderImpl { - Self.lastObservedDepth = impl.cache.snapshots.count - } + init() {} + init(from decoder: Decoder) throws { + guard let impl = decoder as? JSONDecoderImpl else { return } + _ = try impl.container(keyedBy: CodingKeys.self) + _ = try impl.singleValueContainer() + _ = try impl.container(keyedBy: CodingKeys.self) + _ = try impl.singleValueContainer() + let first = impl.modelSnapshot.map(ObjectIdentifier.init) + let second = impl.modelSnapshot.map(ObjectIdentifier.init) + sameContextAcrossRequests = first == second && first != nil } + + func encode(to encoder: Encoder) throws {} } -private struct OptionalFlatProbeHost: SmartCodableX { - @SmartFlat var user: SnapshotDepthProbe? +private struct EdgeSettings: SmartCodableX { + var level: Int = 0 + + init() {} + init(level: Int) { + self.level = level + } } -private struct NonOptionalFlatProbeHost: SmartCodableX { - @SmartFlat var user: SnapshotDepthProbe = SnapshotDepthProbe() +private struct EdgeChild: SmartCodableX { + @SmartIgnored(wrappedValue: EdgeSettings(level: 9), isEncodable: true) + var settings: EdgeSettings + + var settingsIsEncodable: Bool { _settings.isEncodable } } -private struct NestedProbeHost: SmartCodableX { - var user: SnapshotDepthProbe? +private struct EdgeParentHost: SmartCodableX { + @SmartIgnored(wrappedValue: EdgeSettings(level: 7), isEncodable: false) + var settings: EdgeSettings + + var child = EdgeChild() + + var settingsIsEncodable: Bool { _settings.isEncodable } +} + +private final class SingletonBox: Codable { + static let shared = SingletonBox() + var value = 5 + private init() {} +} + +private struct SingletonDefaultModel: SmartCodableX { + var box = SingletonBox.shared } +// MARK: - 字典与 SmartAny fixture + /// 字典值元素模型(默认值与类型零值不同,用于断言敏感度) private struct DictEntry: SmartCodableX { var name: String = "inner-default" @@ -467,6 +668,8 @@ private struct SmartAnyTargetModel: SmartCodableX { var score: Int = 11 } +// MARK: - 包装器 fixture(自旧 DecodingCacheLifecycleTests 迁移) + private protocol ModelWrapper: PropertyWrapperable, Codable where WrappedValue: SmartDecodable & SmartEncodable {} @@ -867,34 +1070,33 @@ private struct DualIgnoredSettings: SmartCodableX { var level: Int = 0 init() {} - init(level: Int) { self.level = level } } private struct WrapperProbeModel: SmartCodableX { - static var lastObservedDepth: Int? var count: Int = 99 + private enum CodingKeys: String, CodingKey { + case count + } + init() {} init(from decoder: Decoder) throws { - Self.lastObservedDepth = (decoder as? JSONDecoderImpl)?.cache.snapshots.count let container = try decoder.container(keyedBy: CodingKeys.self) count = try container.decode(Int.self, forKey: .count) } } private struct ThrowingWrapperProbeModel: SmartCodableX { - static var observedCache: DecodingCache? var marker: Int = 41 init() {} init(from decoder: Decoder) throws { - Self.observedCache = (decoder as? JSONDecoderImpl)?.cache - throw LifecycleTestError.boom + throw ContextTestError.boom } } @@ -919,25 +1121,19 @@ private struct DualWrapperProbeModel: SmartCodableX { } private struct NestedDualLeaf: SmartCodableX { - static var observedCache: DecodingCache? - static var observedSnapshotDepth: Int? - static var observedActiveOwnerDepth: Int? - static var observedObjectTypeCount: Int? - static var observedScopeCount: Int? + static var observedOwnerTypeName: String? var value = 99 + private enum CodingKeys: String, CodingKey { + case value + } + init() {} init(from decoder: Decoder) throws { - if let cache = (decoder as? JSONDecoderImpl)?.cache { - Self.observedCache = cache - Self.observedSnapshotDepth = cache.snapshots.count - Self.observedActiveOwnerDepth = cache.activeOwnerDepth - Self.observedObjectTypeCount = Set( - cache.snapshots.compactMap(\.objectType).map(ObjectIdentifier.init) - ).count - Self.observedScopeCount = Set(cache.snapshots.map(\.scopeIdentifier)).count + if let impl = decoder as? JSONDecoderImpl { + NestedDualLeaf.observedOwnerTypeName = impl.modelSnapshot.map { String(describing: $0.objectType) } } let container = try decoder.container(keyedBy: CodingKeys.self) @@ -945,37 +1141,20 @@ private struct NestedDualLeaf: SmartCodableX { } static func resetObservations() { - observedCache = nil - observedSnapshotDepth = nil - observedActiveOwnerDepth = nil - observedObjectTypeCount = nil - observedScopeCount = nil + observedOwnerTypeName = nil } } private struct ThrowingNestedDualLeaf: SmartCodableX { - static var observedCache: DecodingCache? - static var observedSnapshotDepth: Int? - static var observedActiveOwnerDepth: Int? - var value = 99 init() {} init(from decoder: Decoder) throws { - if let cache = (decoder as? JSONDecoderImpl)?.cache { - Self.observedCache = cache - Self.observedSnapshotDepth = cache.snapshots.count - Self.observedActiveOwnerDepth = cache.activeOwnerDepth - } - throw LifecycleTestError.boom + throw ContextTestError.boom } - static func resetObservations() { - observedCache = nil - observedSnapshotDepth = nil - observedActiveOwnerDepth = nil - } + static func resetObservations() {} } /// 无 parsingMark 场景的宿主:声明初始值与 init() 默认值不同,用于区分取值来源 @@ -992,3 +1171,112 @@ private struct MarklessSettings: SmartCodableX { self.level = level } } + +// MARK: - 集合与结构下钻 fixture + +private struct ArrayElementChild: SmartCodableX { + var tag: String = "inner" +} + +private struct ArrayElementHost: SmartCodableX { + var models: [ArrayElementChild] = [] +} + +private struct CountingListTransformer: ValueTransformable { + static var applyCount = 0 + + typealias Object = [Int] + typealias JSON = [String] + + func transformFromJSON(_ value: Any) -> [Int]? { + CountingListTransformer.applyCount += 1 + guard let strings = value as? [String] else { return nil } + return strings.compactMap { Int($0).map { $0 + 10 } } + } + + func transformToJSON(_ value: [Int]) -> [String]? { + value.map(String.init) + } +} + +private struct TransformerListHost: SmartCodableX { + var scores: [Int] = [] + + private enum CodingKeys: String, CodingKey { + case scores + } + + static func mappingForValue() -> [SmartValueTransformer]? { + [CodingKeys.scores <--- CountingListTransformer()] + } +} + +private struct StringInputHost: SmartCodableX { + var name: String = "" + var mikeNo: String = "我是默认值" +} + +private struct RawNestedHost: SmartCodableX { + var count = 99 + var plainCount = 0 + + private enum CodingKeys: String, CodingKey { + case count + case plain + } + + init() {} + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + count = try container.decode(Int.self, forKey: .count) + let nested = try container.nestedContainer(keyedBy: CodingKeys.self, forKey: .plain) + plainCount = try nested.decode(Int.self, forKey: .count) + } + + func encode(to encoder: Encoder) throws {} +} + +// MARK: - 回调计数 fixture + +private final class MappingCountedModel: SmartCodableX { + static var mappingCount = 0 + + var level = 1 + + required init() {} + + func didFinishMapping() { + MappingCountedModel.mappingCount += 1 + } + + static func makeResolved(level: Int) -> MappingCountedModel { + let model = MappingCountedModel() + model.level = level + return model + } +} + +private struct MappingCountHost: SmartCodableX { + var child: MappingCountedModel? = MappingCountedModel() +} + +private struct MappingCountArrayHost: SmartCodableX { + var items: [MappingCountedModel] = [] +} + +private struct MappingTransformerHost: SmartCodableX { + var child = MappingCountedModel() + + private enum CodingKeys: String, CodingKey { + case child + } + + static func mappingForValue() -> [SmartValueTransformer]? { + [ + CodingKeys.child <--- FastTransformer(fromJSON: { value in + guard let level = value.flatMap({ Int($0) }) else { return nil } + return MappingCountedModel.makeResolved(level: level) + }) + ] + } +} diff --git a/Tests/RefactorPlanRegressionTests.swift b/Tests/RefactorPlanRegressionTests.swift new file mode 100644 index 0000000..44c6587 --- /dev/null +++ b/Tests/RefactorPlanRegressionTests.swift @@ -0,0 +1,346 @@ +import Foundation +import XCTest +@testable import SmartCodable + +/// Review target: fix/smartflat-snapshot-pollution @ 16f3203b4cd24fe72e3ccf49786593a92cf441fa. +/// +/// 本文件是解码上下文显式绑定重构(refactor/decoder-context)的行为契约测试: +/// 前两项来自上一轮审查(R01/R02),后续补充隔离、回退、继承与 transformer 对照用例。 +/// +/// Run: +/// swift test --filter LatestReviewRegressionTests +final class LatestReviewRegressionTests: XCTestCase { + /// Candidate regression: two separate model decodes within a wrapper must not reuse a + /// mutable default object solely because scope, codingPath and model type are equal. + func testSequentialWrappedDecodesDoNotShareMutableDefaults() throws { + let impl = makeDecoder() + let wrapper = try impl.unwrap(as: ReviewDecodeTwice.self) + + XCTAssertEqual(wrapper.first.box.value, 123) + XCTAssertEqual(wrapper.wrappedValue.box.value, 10, + "The second decode should get a fresh default, not the first decode's mutation") + XCTAssertFalse(wrapper.first.box === wrapper.wrappedValue.box, + "Independent model decodes must not unexpectedly alias a mutable default") + } + + /// Refactor contract, not a claim that this behavior was introduced by the latest commit: + /// a parent's already-created container should stay bound to the parent while a child is + /// being decoded at the same JSON path. A callback exercises the retained parent container + /// before either init(from:) has returned; this does not use a decoder after its lifetime. + func testCapturedParentContainerKeepsItsOwnerDuringChildDecode() throws { + let bridge = ReviewContainerBridge() + let impl = makeDecoder(userInfo: [reviewBridgeKey: bridge]) + let parent = try impl.unwrap(as: ReviewBoundParent.self) + + XCTAssertEqual(parent.shared, 7) + XCTAssertEqual(bridge.childDefault, 99) + XCTAssertEqual(bridge.parentDefaultDuringChild, 7, + "A retained parent container must not consult the active child's defaults") + } + + private func makeDecoder( + userInfo: [CodingUserInfoKey: Any] = [:] + ) -> JSONDecoderImpl { + let decoder = SmartJSONDecoder() + return JSONDecoderImpl( + userInfo: userInfo, + from: .object([:]), + codingPath: [], + options: decoder.options + ) + } +} + +private final class ReviewDefaultBox: Codable { + var value = 10 +} + +private struct ReviewDefaultLeaf: SmartCodableX { + var box = ReviewDefaultBox() +} + +/// Deliberately decodes the same JSON value twice. Single-value decoding does not advance +/// an unkeyed-container cursor; these are two independent model initialization calls. +private struct ReviewDecodeTwice: PropertyWrapperable, Codable { + var wrappedValue: ReviewDefaultLeaf + var first: ReviewDefaultLeaf + + init(wrappedValue: ReviewDefaultLeaf) { + self.wrappedValue = wrappedValue + self.first = wrappedValue + } + + init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + first = try container.decode(ReviewDefaultLeaf.self) + first.box.value = 123 + wrappedValue = try container.decode(ReviewDefaultLeaf.self) + } + + func encode(to encoder: Encoder) throws { + try wrappedValue.encode(to: encoder) + } + + static func createInstance(with value: Any) -> Self? { + guard let value = value as? ReviewDefaultLeaf else { return nil } + return Self(wrappedValue: value) + } + + func wrappedValueDidFinishMapping() -> Self? { self } +} + +private var reviewBridgeKey: CodingUserInfoKey { + CodingUserInfoKey(rawValue: "SmartCodable.LatestReview.ContainerBridge")! +} + +private final class ReviewContainerBridge { + var readParent: (() throws -> Int)? + var childDefault: Int? + var parentDefaultDuringChild: Int? +} + +private enum ReviewSharedKeys: String, CodingKey { + case shared +} + +private struct ReviewBoundParent: SmartCodableX { + var shared = 7 + + init() {} + + init(from decoder: Decoder) throws { + let bridge = try XCTUnwrap(decoder.userInfo[reviewBridgeKey] as? ReviewContainerBridge) + let parentContainer = try decoder.container(keyedBy: ReviewSharedKeys.self) + shared = try parentContainer.decode(Int.self, forKey: .shared) + bridge.readParent = { + try parentContainer.decode(Int.self, forKey: .shared) + } + defer { bridge.readParent = nil } + let single = try decoder.singleValueContainer() + _ = try single.decode(ReviewBoundChild.self) + } +} + +private struct ReviewBoundChild: SmartCodableX { + var shared = 99 + + init() {} + + init(from decoder: Decoder) throws { + let bridge = try XCTUnwrap(decoder.userInfo[reviewBridgeKey] as? ReviewContainerBridge) + let childContainer = try decoder.container(keyedBy: ReviewSharedKeys.self) + shared = try childContainer.decode(Int.self, forKey: .shared) + bridge.childDefault = shared + let readParent = try XCTUnwrap(bridge.readParent) + bridge.parentDefaultDuringChild = try readParent() + } +} + + +extension LatestReviewRegressionTests { + func testPlainCodableChildDoesNotBorrowHostDefaultTable() throws { + let host = try XCTUnwrap(ReviewPlainHost.deserialize(from: [ + "plain": [:] as [String: Any] + ])) + XCTAssertEqual(host.count, 99) + XCTAssertEqual(host.plain.count, 0) + } + + func testWholePropertyFallbackAndInnerFieldDefaultAreDifferent() throws { + let missing = try XCTUnwrap( + ReviewDeclaredHost.deserialize(from: [:] as [String: Any]) + ) + let present = try XCTUnwrap(ReviewDeclaredHost.deserialize(from: [ + "child": [:] as [String: Any] + ])) + XCTAssertEqual(missing.child.count, 7) + XCTAssertEqual(present.child.count, 11) + } + + func testIndependentRootEntriesDoNotShareFreshReferenceDefaults() throws { + let impl = makeDecoder() + let first = try impl.unwrap(as: ReviewDefaultLeaf.self) + first.box.value = 123 + let second = try impl.unwrap(as: ReviewDefaultLeaf.self) + XCTAssertFalse(first.box === second.box) + XCTAssertEqual(second.box.value, 10) + } + + func testRepeatedLookupWithinOneModelInitKeepsDeclaredReference() throws { + let model = try makeDecoder().unwrap(as: ReviewSameInitModel.self) + XCTAssertTrue(model.sameDefaultReference) + XCTAssertEqual(model.box.value, 10) + } + + func testThrowingInnerEntryDoesNotChangeParentContainer() throws { + let model = try makeDecoder().unwrap(as: ReviewRecoveringModel.self) + XCTAssertEqual(model.before, 7) + XCTAssertEqual(model.after, 7) + } + + func testSharedDecoderInheritanceKeepsBaseAndChildDefaults() throws { + let missing = try XCTUnwrap( + ReviewDerivedModel.deserialize(from: [:] as [String: Any]) + ) + XCTAssertEqual(missing.baseValue, 11) + XCTAssertEqual(missing.childValue, 22) + + let partial = try XCTUnwrap(ReviewDerivedModel.deserialize(from: [ + "baseValue": 101 + ])) + XCTAssertEqual(partial.baseValue, 101) + XCTAssertEqual(partial.childValue, 22) + let encoded = try XCTUnwrap(partial.toDictionary()) + XCTAssertEqual(encoded["baseValue"] as? Int, 101) + XCTAssertEqual(encoded["childValue"] as? Int, 22) + } + + func testCapturedParentContainerKeepsItsTransformerDuringChildDecode() throws { + let bridge = ReviewContainerBridge() + let settings = SmartJSONDecoder() + let impl = JSONDecoderImpl( + userInfo: [reviewBridgeKey: bridge], + from: .object(["shared": .string("5")]), + codingPath: [], + options: settings.options + ) + let parent = try impl.unwrap(as: ReviewMappedParent.self) + XCTAssertEqual(parent.shared, 15) + XCTAssertEqual(bridge.childDefault, 105) + XCTAssertEqual(bridge.parentDefaultDuringChild, 15) + } +} + +private struct ReviewPlain: Codable { + var count = 0 +} + +private struct ReviewPlainHost: SmartCodableX { + var count = 99 + var plain = ReviewPlain() +} + +private struct ReviewDeclaredChild: SmartCodableX { + var count = 11 + init() {} + init(count: Int) { self.count = count } +} + +private struct ReviewDeclaredHost: SmartCodableX { + var child = ReviewDeclaredChild(count: 7) +} + +private struct ReviewSameInitModel: SmartCodableX { + var box = ReviewDefaultBox() + var sameDefaultReference = false + + private enum CodingKeys: String, CodingKey { case box } + init() {} + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let first = try container.decode(ReviewDefaultBox.self, forKey: .box) + let second = try container.decode(ReviewDefaultBox.self, forKey: .box) + sameDefaultReference = first === second + box = second + } +} + +private enum ReviewPlanError: Error { + case intentional + case unexpectedlySucceeded +} + +private struct ReviewThrowingModel: SmartCodableX { + var shared = 99 + init() {} + init(from decoder: Decoder) throws { + throw ReviewPlanError.intentional + } +} + +private struct ReviewRecoveringModel: SmartCodableX { + var shared = 7 + var before = 0 + var after = 0 + init() {} + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: ReviewSharedKeys.self) + before = try container.decode(Int.self, forKey: .shared) + let single = try decoder.singleValueContainer() + do { + _ = try single.decode(ReviewThrowingModel.self) + throw ReviewPlanError.unexpectedlySucceeded + } catch ReviewPlanError.intentional { + // Catch only the deliberate error. Any unrelated error must fail the test. + } + after = try container.decode(Int.self, forKey: .shared) + } +} + +private enum ReviewBaseKeys: String, CodingKey { case baseValue } +private enum ReviewDerivedKeys: String, CodingKey { case childValue } + +private class ReviewBaseModel: SmartCodableX { + var baseValue = 11 + required init() {} + required init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: ReviewBaseKeys.self) + baseValue = try container.decode(Int.self, forKey: .baseValue) + } + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: ReviewBaseKeys.self) + try container.encode(baseValue, forKey: .baseValue) + } +} + +private final class ReviewDerivedModel: ReviewBaseModel { + var childValue = 22 + required init() { super.init() } + required init(from decoder: Decoder) throws { + try super.init(from: decoder) + let container = try decoder.container(keyedBy: ReviewDerivedKeys.self) + childValue = try container.decode(Int.self, forKey: .childValue) + } + override func encode(to encoder: Encoder) throws { + try super.encode(to: encoder) + var container = encoder.container(keyedBy: ReviewDerivedKeys.self) + try container.encode(childValue, forKey: .childValue) + } +} + +private struct ReviewMappedParent: SmartCodableX { + var shared = 7 + init() {} + init(from decoder: Decoder) throws { + let bridge = try XCTUnwrap(decoder.userInfo[reviewBridgeKey] as? ReviewContainerBridge) + let parentContainer = try decoder.container(keyedBy: ReviewSharedKeys.self) + shared = try parentContainer.decode(Int.self, forKey: .shared) + bridge.readParent = { try parentContainer.decode(Int.self, forKey: .shared) } + defer { bridge.readParent = nil } + let single = try decoder.singleValueContainer() + _ = try single.decode(ReviewMappedChild.self) + } + static func mappingForValue() -> [SmartValueTransformer]? { + [ReviewSharedKeys.shared <--- FastTransformer(fromJSON: { value in + value.flatMap(Int.init).map { $0 + 10 } + })] + } +} + +private struct ReviewMappedChild: SmartCodableX { + var shared = 99 + init() {} + init(from decoder: Decoder) throws { + let bridge = try XCTUnwrap(decoder.userInfo[reviewBridgeKey] as? ReviewContainerBridge) + let container = try decoder.container(keyedBy: ReviewSharedKeys.self) + shared = try container.decode(Int.self, forKey: .shared) + bridge.childDefault = shared + let readParent = try XCTUnwrap(bridge.readParent) + bridge.parentDefaultDuringChild = try readParent() + } + static func mappingForValue() -> [SmartValueTransformer]? { + [ReviewSharedKeys.shared <--- FastTransformer(fromJSON: { value in + value.flatMap(Int.init).map { $0 + 100 } + })] + } +} From 3df436a7b0e6456aee6864f2ab6e4112f97d97ef Mon Sep 17 00:00:00 2001 From: Rock-Connotation <787661104@qq.com> Date: Thu, 17 Sep 2026 21:07:04 +0800 Subject: [PATCH 14/17] =?UTF-8?q?[=E4=BC=98=E5=8C=96]=20=E8=A1=A5=E9=BD=90?= =?UTF-8?q?=E8=A7=A3=E7=A0=81=E4=B8=8A=E4=B8=8B=E6=96=87=E8=B7=A8=E8=B7=AF?= =?UTF-8?q?=E5=BE=84=E5=9B=9E=E5=BD=92=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Tests/DecodingContextTests.swift | 449 ++++++++++++++++++++++++++++++- 1 file changed, 435 insertions(+), 14 deletions(-) diff --git a/Tests/DecodingContextTests.swift b/Tests/DecodingContextTests.swift index 3f39fc8..74b381e 100644 --- a/Tests/DecodingContextTests.swift +++ b/Tests/DecodingContextTests.swift @@ -1,4 +1,7 @@ import XCTest +#if canImport(Combine) +import Combine +#endif @testable import SmartCodable /// 解码上下文(DecodingSnapshot / PropertyDecodingContext)的契约测试。 @@ -438,34 +441,268 @@ final class DecodingContextTests: XCTestCase { // MARK: - 并发解码 - /// 多个独立 decoder 并发解码:结果各自独立,框架不引入跨调用的默认值共享(T49)。 + /// 多个独立 decoder 并发解码:空输入真实触发默认值回退, + /// 不同模型的默认上下文互不串扰,跨调用不共享可变默认对象(T49)。 + /// 结果全部保留存活后再比较对象身份,避免对象释放后的地址复用干扰判断。 /// 数据竞争检测配合 TSan:swift test --sanitize=thread --filter DecodingContextTests - func testConcurrentIndependentDecodersProduceIndependentResults() throws { - final class ConcurrentLeaf: SmartCodableX { - var tag = "inner" + func testConcurrentIndependentDecodersFallbackDefaultsStayIsolated() throws { + final class ConcurrentRefBox: Codable { + // var + 初值:合成的 Decodable 可以解码该属性(本用例中键缺失,保持初值 10), + // 避免对 let+初值属性产生 "immutable property will not be decoded" 告警 + var stamp = 10 + } + + final class ConcurrentDefaultAlpha: SmartCodableX { + var tag = "alpha" + var box = ConcurrentRefBox() + required init() {} + } + + final class ConcurrentDefaultBeta: SmartCodableX { + var tag = "beta" required init() {} } + let iterations = 80 let group = DispatchGroup() let queue = DispatchQueue(label: "smartcodable.tests.decode", attributes: .concurrent) let lock = NSLock() - var results: [String] = [] + var alphaModels: [ConcurrentDefaultAlpha] = [] + var betaModels: [ConcurrentDefaultBeta] = [] - for index in 0..<40 { + for index in 0..: PropertyWrapperable, Codable { + var wrappedValue: Value? + + init(wrappedValue: Value?) { + self.wrappedValue = wrappedValue + } + + init(from decoder: Decoder) throws { + wrappedValue = try Value(from: decoder) + } + + func encode(to encoder: Encoder) throws { + guard let wrappedValue else { + var container = encoder.singleValueContainer() + try container.encodeNil() + return + } + try wrappedValue.encode(to: encoder) + } + + static func createInstance(with value: Any) -> Self? { + guard let value = value as? Value else { return nil } + return Self(wrappedValue: value) + } + + func wrappedValueDidFinishMapping() -> Self? { + guard var value = wrappedValue else { return nil } + value.didFinishMapping() + return Self(wrappedValue: value) + } +} + +private struct OptionalWrapperHost: SmartCodableX { + @OptionalDirectWrapper var payload = WrapperProbeModel() +} + +/// SmartPublished 的内层模型(T26) +private struct PublishedChild: SmartCodableX { + var level = 11 +} + +/// JSON 字符串数组输入宿主(T41) +private struct StringArrayInputHost: SmartCodableX { + var models: [ArrayElementChild] = [] +} + +/// 继承映射用例的父类(T44) +private enum MappedBaseKeys: String, CodingKey { + case baseValue +} + +private class MappedBaseModel: SmartCodableX { + var baseValue = 11 + + required init() {} + + required init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: MappedBaseKeys.self) + baseValue = try container.decode(Int.self, forKey: .baseValue) + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: MappedBaseKeys.self) + try container.encode(baseValue, forKey: .baseValue) + } + + // class func 同样满足协议的 static 要求,并允许子类按声明覆写 + class func mappingForKey() -> [SmartKeyTransformer]? { + [MappedBaseKeys.baseValue <--- "base_value"] + } + + class func mappingForValue() -> [SmartValueTransformer]? { + [ + MappedBaseKeys.baseValue <--- FastTransformer(fromJSON: { value in + value.flatMap(Int.init).map { $0 + 100 } + }) + ] + } +} + +/// 继承映射用例的子类:声明自己的映射与转换器,共享同一个传入 decoder(T44) +private enum MappedDerivedKeys: String, CodingKey { + case childValue +} + +private final class MappedDerivedModel: MappedBaseModel { + var childValue = 22 + + required init() { super.init() } + + required init(from decoder: Decoder) throws { + try super.init(from: decoder) + let container = try decoder.container(keyedBy: MappedDerivedKeys.self) + childValue = try container.decode(Int.self, forKey: .childValue) + } + + override func encode(to encoder: Encoder) throws { + try super.encode(to: encoder) + var container = encoder.container(keyedBy: MappedDerivedKeys.self) + try container.encode(childValue, forKey: .childValue) + } + + // 覆写时显式组合 super 的声明:框架不自动发现父类类型, + // 由模型自己保证同一次子类解码中父/子映射同时生效 + override class func mappingForKey() -> [SmartKeyTransformer]? { + (super.mappingForKey() ?? []) + [ + MappedDerivedKeys.childValue <--- "child_value" + ] + } + + override class func mappingForValue() -> [SmartValueTransformer]? { + (super.mappingForValue() ?? []) + [ + MappedDerivedKeys.childValue <--- FastTransformer(fromJSON: { value in + value.flatMap(Int.init).map { $0 + 200 } + }) + ] + } +} + +/// 手写 superDecoder 用例(T45) +private struct SuperDecoderHost: SmartCodableX { + var count = 99 + var plainCount = 0 + + private enum Keys: String, CodingKey { + case count + case plain + } + + init() {} + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: Keys.self) + count = try container.decode(Int.self, forKey: .count) + let superView = try container.superDecoder(forKey: .plain) + let nested = try superView.container(keyedBy: Keys.self) + plainCount = try nested.decode(Int.self, forKey: .count) + } + + func encode(to encoder: Encoder) throws {} +} + +/// 包装器回调计数模型(T46) +private final class WrapperCountedModel: SmartCodableX { + static var mappingCount = 0 + + var level = 1 + + required init() {} + + func didFinishMapping() { + WrapperCountedModel.mappingCount += 1 + } + + static func make(level: Int) -> WrapperCountedModel { + let model = WrapperCountedModel() + model.level = level + return model + } +} + +private struct WrapperCountHost: SmartCodableX { + @DirectInitWrapper var payload = WrapperCountedModel() +} + +private struct WrapperTransformerHost: SmartCodableX { + @DirectInitWrapper var payload = WrapperCountedModel() + + private enum CodingKeys: String, CodingKey { + case payload + } + + static func mappingForValue() -> [SmartValueTransformer]? { + [ + CodingKeys.payload <--- FastTransformer(fromJSON: { value in + value.flatMap(Int.init).map(WrapperCountedModel.make) + }) + ] + } +} From 453699f6ee2a1d7539f96971098bc70188a520ae Mon Sep 17 00:00:00 2001 From: Rock-Connotation <787661104@qq.com> Date: Thu, 17 Sep 2026 21:07:04 +0800 Subject: [PATCH 15/17] =?UTF-8?q?[=E4=BC=98=E5=8C=96]=20=E8=A1=A5=E9=BD=90?= =?UTF-8?q?=20SmartFlat=20=E9=BB=98=E8=AE=A4=E5=80=BC=E4=B8=8E=E7=BC=96?= =?UTF-8?q?=E7=A0=81=E5=9B=9E=E5=BD=92=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Tests/SmartFlatTests.swift | 73 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 73 insertions(+) diff --git a/Tests/SmartFlatTests.swift b/Tests/SmartFlatTests.swift index 9d7c633..b649d14 100644 --- a/Tests/SmartFlatTests.swift +++ b/Tests/SmartFlatTests.swift @@ -112,6 +112,50 @@ final class SmartFlatTests: XCTestCase { XCTAssertTrue(models.allSatisfy(\.extra)) } + /// 同一宿主连续两个 @SmartFlat,内层同名字段声明默认值各不相同,互不串值(T09) + func testConsecutiveSmartFlatsKeepTheirOwnInnerDefaultsForSameNamedField() throws { + struct Model: SmartCodableX { + @SmartFlat var first: FlatSharedA? + @SmartFlat var second: FlatSharedB? + var tail = "host-tail" + } + + let model = try XCTUnwrap(Model.deserialize(from: [:])) + + XCTAssertEqual(model.first?.shared, "a-default", "第一个 Flat 取自己的内层默认") + XCTAssertEqual(model.second?.shared, "b-default", "第二个 Flat 取自己的内层默认,不串第一个的值") + XCTAssertEqual(model.tail, "host-tail", "宿主尾字段不受平铺影响") + } + + /// @SmartFlat 嵌套 @SmartFlat:各层取各自默认值,最外层宿主不受影响(T10) + func testNestedSmartFlatKeepsPerLevelDefaults() throws { + struct Model: SmartCodableX { + @SmartFlat var middle: FlatMiddleProbe? + var host = "host-default" + } + + let model = try XCTUnwrap(Model.deserialize(from: [:])) + + XCTAssertEqual(model.middle?.middle, "middle-default") + XCTAssertEqual(model.middle?.inner?.leaf, "leaf-default") + XCTAssertEqual(model.host, "host-default") + } + + /// Flat 编码:内层字段平铺回宿主层级输出,不产生内层包装键(T48) + func testSmartFlatEncodeFlattensInnerFieldsToHostLevel() throws { + struct Model: SmartCodableX { + @SmartFlat var inner: FlatEncodeInner = FlatEncodeInner() + var title = "host" + } + + let model = try XCTUnwrap(Model.deserialize(from: ["tag": "a", "title": "t"])) + let encoded = try XCTUnwrap(model.toDictionary()) + + XCTAssertEqual(encoded["tag"] as? String, "a", "内层字段应平铺到宿主层级") + XCTAssertEqual(encoded["title"] as? String, "t") + XCTAssertNil(encoded["inner"], "平铺编码不应输出包装层键名") + } + /// @SmartFlat 之前的属性不受后续平铺解码影响 func testPropertyBeforeSmartFlatKeepsDeclaredDefaultWhenKeyMissing() throws { struct Model: SmartCodableX { @@ -165,6 +209,35 @@ private struct IgnoredSettings: SmartCodableX { var isEnabled: Bool = true } +/// 连续 Flat 用例:同名字段、不同默认值(T09) +private final class FlatSharedA: SmartCodableX { + var shared = "a-default" + required init() {} +} + +/// 连续 Flat 用例:同名字段、不同默认值(T09) +private final class FlatSharedB: SmartCodableX { + var shared = "b-default" + required init() {} +} + +/// 嵌套 Flat 用例:最内层模型(T10) +private final class FlatLeafProbe: SmartCodableX { + var leaf = "leaf-default" + required init() {} +} + +/// 嵌套 Flat 用例:中间层自身也是 Flat(T10) +private struct FlatMiddleProbe: SmartCodableX { + @SmartFlat var inner: FlatLeafProbe? + var middle = "middle-default" +} + +/// Flat 编码用例的内层模型(T48) +private struct FlatEncodeInner: SmartCodableX { + var tag = "inner-default" +} + /// 将字符串转为大写的转换器(用于验证 mappingForValue) private struct UpperTransformer: ValueTransformable { typealias Object = String From 9972b0772b12b2f5c55aaf4df6950a812997db65 Mon Sep 17 00:00:00 2001 From: Rock-Connotation <787661104@qq.com> Date: Thu, 17 Sep 2026 21:07:04 +0800 Subject: [PATCH 16/17] =?UTF-8?q?[=E6=96=87=E6=A1=A3]=20=E5=AF=B9=E9=BD=90?= =?UTF-8?q?=20Swift=20=E4=B8=8E=20Xcode=20=E6=9C=80=E4=BD=8E=E7=89=88?= =?UTF-8?q?=E6=9C=AC=E8=A6=81=E6=B1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CONTRIBUTING.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8753d2f..e11b699 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -35,8 +35,8 @@ cd SmartCodable swift build ``` -- 最低要求:Swift 5.0、Xcode 14 -- 宏相关功能需要:Swift 5.9、Xcode 15 +- 最低要求:Swift 5.9、Xcode 15(与 `Package.swift` 的 `swift-tools-version: 5.9` 一致) +- 宏相关功能在配套库 SmartCodableMacro 中,同样要求 Swift 5.9、Xcode 15 ### 开发流程 @@ -48,7 +48,7 @@ swift build ### 代码规范 - **不破坏公共 API**:`SmartDecodable`、`SmartEncodable`、属性包装器的公开接口不能改签名 -- **向后兼容**:保持 Swift 5.0+ / iOS 13+ 的最低版本要求 +- **向后兼容**:保持 Swift 5.9+ / iOS 13+ 的最低版本要求(以 `Package.swift` 的 `swift-tools-version` 为准) - **不新增 SwiftSyntax 依赖**:核心模块(`SmartCodable` target)不能依赖 SwiftSyntax - **解码上下文通过类型入口统一建立**:`unwrap(as:)` / `decodeInPlace(_:)` 内部经 `decoderForEntry` 为每次可观察的模型初始化准备局部 `DecodingSnapshot`;容器在创建时固定绑定所属模型,不要在调用方手工构造、缓存或跨调用复用上下文 - 修改核心解码逻辑后,至少手动验证:简单模型、嵌套模型、数组模型、类型不匹配、缺失字段 From 9b286ad57ca2743b3887d2adcacf15b73c066848 Mon Sep 17 00:00:00 2001 From: Rock-Connotation <787661104@qq.com> Date: Thu, 17 Sep 2026 22:08:56 +0800 Subject: [PATCH 17/17] =?UTF-8?q?[=E4=BC=98=E5=8C=96]=20=E5=AE=8C=E5=96=84?= =?UTF-8?q?=E8=A7=A3=E7=A0=81=E4=B8=8A=E4=B8=8B=E6=96=87=E6=B5=8B=E8=AF=95?= =?UTF-8?q?=E4=B8=8E=20TSan=20=E8=A6=86=E7=9B=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/swift.yml | 4 ++ Tests/DecodingContextTests.swift | 77 +++++++++++++++++++++++++------- 2 files changed, 65 insertions(+), 16 deletions(-) diff --git a/.github/workflows/swift.yml b/.github/workflows/swift.yml index d205a87..37fcc62 100644 --- a/.github/workflows/swift.yml +++ b/.github/workflows/swift.yml @@ -37,5 +37,9 @@ jobs: steps: - uses: actions/checkout@v4 + # 保留全局选项与日志哨兵共享状态的并发回归覆盖。 - name: Run Thread Sanitizer concurrency tests run: swift test --sanitize=thread --filter GlobalOptionsConcurrencyTests + # 覆盖独立 decoder 并发回退默认值的路径;使用精确过滤器,确保日志能证明目标用例实际执行。 + - name: Run Thread Sanitizer decoding context concurrency test + run: swift test --sanitize=thread --filter DecodingContextTests.testConcurrentIndependentDecodersFallbackDefaultsStayIsolated diff --git a/Tests/DecodingContextTests.swift b/Tests/DecodingContextTests.swift index 74b381e..599673f 100644 --- a/Tests/DecodingContextTests.swift +++ b/Tests/DecodingContextTests.swift @@ -2,6 +2,12 @@ import XCTest #if canImport(Combine) import Combine #endif +// ColorObject 在 UIKit 平台是 UIColor、macOS 是 NSColor;按平台声明测试所需的颜色框架 +#if os(iOS) || os(tvOS) || os(watchOS) || os(visionOS) +import UIKit +#elseif os(macOS) +import AppKit +#endif @testable import SmartCodable /// 解码上下文(DecodingSnapshot / PropertyDecodingContext)的契约测试。 @@ -110,11 +116,19 @@ final class DecodingContextTests: XCTestCase { // MARK: - 独立调用与稳定归属 - /// 同一次模型 init 内多次请求 keyed / single 容器,模型上下文不被重建(T32) + /// 同一次模型 init 内多次请求 keyed / single 容器:从两个容器读取同一缺失字段, + /// 复用本次初始化的同一个引用类型默认对象,且默认值 provider 只执行一次(T32) func testRepeatedContainerRequestsKeepSameContextInOneInit() throws { + RepeatedContainerHost.resetDefaultProviderCallCount() + let model = try XCTUnwrap(RepeatedContainerHost.deserialize(from: [:])) - XCTAssertTrue(model.sameContextAcrossRequests, - "同一次 init 内重复取容器应复用同一份模型上下文") + + XCTAssertTrue(model.firstBox === model.secondBox, + "同一次 init 内两个容器读取同一缺失字段,应取得同一个默认对象") + XCTAssertEqual(model.firstBox?.value, 10, + "默认对象内容应来自宿主声明的初始值") + XCTAssertEqual(RepeatedContainerHost.defaultProviderCallCount, 1, + "默认值 provider(objectType.init() 反射构造)在同一次初始化内只执行一次") } /// 不同宿主的同名 _settings 声明只能恢复属性边精确指向的那一个(T33) @@ -444,7 +458,8 @@ final class DecodingContextTests: XCTestCase { /// 多个独立 decoder 并发解码:空输入真实触发默认值回退, /// 不同模型的默认上下文互不串扰,跨调用不共享可变默认对象(T49)。 /// 结果全部保留存活后再比较对象身份,避免对象释放后的地址复用干扰判断。 - /// 数据竞争检测配合 TSan:swift test --sanitize=thread --filter DecodingContextTests + /// 数据竞争检测配合 TSan: + /// swift test --sanitize=thread --filter DecodingContextTests.testConcurrentIndependentDecodersFallbackDefaultsStayIsolated func testConcurrentIndependentDecodersFallbackDefaultsStayIsolated() throws { final class ConcurrentRefBox: Codable { // var + 初值:合成的 Decodable 可以解码该属性(本用例中键缺失,保持初值 10), @@ -690,9 +705,18 @@ final class DecodingContextTests: XCTestCase { "plain": "00FF00", ])) - XCTAssertEqual(host.hashed, NSColor(calibratedRed: 1, green: 0, blue: 0, alpha: 1), + // 预期颜色由平台颜色 API 独立构造,不依赖被测试的 SmartHexColor 解析函数 +#if os(macOS) + let expectedHashed = NSColor(calibratedRed: 1, green: 0, blue: 0, alpha: 1) + let expectedPlain = NSColor(calibratedRed: 0, green: 1, blue: 0, alpha: 1) +#else + let expectedHashed = UIColor(red: 1, green: 0, blue: 0, alpha: 1) + let expectedPlain = UIColor(red: 0, green: 1, blue: 0, alpha: 1) +#endif + + XCTAssertEqual(host.hashed, expectedHashed, "颜色值按 JSON 解码,不被声明默认覆盖") - XCTAssertEqual(host.plain, NSColor(calibratedRed: 0, green: 1, blue: 0, alpha: 1)) + XCTAssertEqual(host.plain, expectedPlain) let encoded = try XCTUnwrap(host.toDictionary()) let hashedHex = try XCTUnwrap(encoded["hashed"] as? String) @@ -838,21 +862,42 @@ private enum ContextTestError: Error { // MARK: - 独立调用与归属 fixture +/// 重复容器请求用例的引用类型默认值。 +/// 遵循 Codable 只为满足 decode(_:forKey:) 的约束;键缺失时不会触发其自身解码。 +private final class RepeatedContainerBox: Codable { + var value = 10 +} + private struct RepeatedContainerHost: SmartCodableX { - var sameContextAcrossRequests = false + private(set) static var defaultProviderCallCount = 0 private enum CodingKeys: String, CodingKey { case box } - init() {} + /// 声明的引用类型默认属性:只应由默认值反射构造一次 + var box = RepeatedContainerBox() + + /// 同一次 init(from:) 中两个容器分别读取同一缺失字段的结果,供测试断言身份 + var firstBox: RepeatedContainerBox? + var secondBox: RepeatedContainerBox? + + static func resetDefaultProviderCallCount() { + defaultProviderCallCount = 0 + } + + /// DecodingSnapshot 通过 objectType.init() 获取声明默认值时调用; + /// init(from:) 的属性初始化不会进入此计数。 + init() { + Self.defaultProviderCallCount += 1 + } init(from decoder: Decoder) throws { - guard let impl = decoder as? JSONDecoderImpl else { return } - _ = try impl.container(keyedBy: CodingKeys.self) - _ = try impl.singleValueContainer() - _ = try impl.container(keyedBy: CodingKeys.self) - _ = try impl.singleValueContainer() - let first = impl.modelSnapshot.map(ObjectIdentifier.init) - let second = impl.modelSnapshot.map(ObjectIdentifier.init) - sameContextAcrossRequests = first == second && first != nil + let first = try decoder.container(keyedBy: CodingKeys.self) + _ = try decoder.singleValueContainer() + let second = try decoder.container(keyedBy: CodingKeys.self) + _ = try decoder.singleValueContainer() + + // 键缺失,真实走默认值回退:两个容器分别读取同一个字段 + firstBox = try first.decode(RepeatedContainerBox.self, forKey: .box) + secondBox = try second.decode(RepeatedContainerBox.self, forKey: .box) } func encode(to encoder: Encoder) throws {}