diff --git a/.github/workflows/swift.yml b/.github/workflows/swift.yml index 21ae770f..37fcc62c 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,3 +25,21 @@ 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: + + 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 + # 覆盖独立 decoder 并发回退默认值的路径;使用精确过滤器,确保日志能证明目标用例实际执行。 + - name: Run Thread Sanitizer decoding context concurrency test + run: swift test --sanitize=thread --filter DecodingContextTests.testConcurrentIndependentDecodersFallbackDefaultsStayIsolated diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 4a3d2b5e..e11b699e 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,9 +48,9 @@ swift build ### 代码规范 - **不破坏公共 API**:`SmartDecodable`、`SmartEncodable`、属性包装器的公开接口不能改签名 -- **向后兼容**:保持 Swift 5.0+ / iOS 13+ 的最低版本要求 +- **向后兼容**:保持 Swift 5.9+ / iOS 13+ 的最低版本要求(以 `Package.swift` 的 `swift-tools-version` 为准) - **不新增 SwiftSyntax 依赖**:核心模块(`SmartCodable` target)不能依赖 SwiftSyntax -- **DecodingCache 快照必须成对调用**:`cacheSnapshot()` 和 `removeSnapshot()` 必须配对,注意异常路径 +- **解码上下文通过类型入口统一建立**:`unwrap(as:)` / `decodeInPlace(_:)` 内部经 `decoderForEntry` 为每次可观察的模型初始化准备局部 `DecodingSnapshot`;容器在创建时固定绑定所属模型,不要在调用方手工构造、缓存或跨调用复用上下文 - 修改核心解码逻辑后,至少手动验证:简单模型、嵌套模型、数组模型、类型不匹配、缺失字段 ### Commit 规范 diff --git a/Document/QA/QA3.md b/Document/QA/QA3.md index bd5b221a..0737e4da 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) // 我是初始值 } @@ -126,22 +126,28 @@ if let model = NameModel.deserialize(dict: dict) { 为了实现该功能,SmartCodable 重新实现了完整的 JSON 解码器(`SmartJSONDecoder`),而非仅重写 `JSONKeyedDecodingContainer` 的协议方法。 -核心实现在 `DecodingCache` 中,通过快照机制记录当前正在解析的 Model 的初始值: +核心实现在 `DecodingSnapshot`(解码上下文显式绑定)中,为每一次模型解码记录初始值与映射元数据: ```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) - snapshots.append(snapshot) +// 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 反射获取(含父类递归) + // 提取所有属性的初始值后缓存,同一上下文内复用同一份引用 + } } -// 懒加载:首次需要默认值时才通过 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) } ``` -当某个属性解码失败时,从快照中找到该属性的初始值进行填充。这种懒加载设计避免了每次解码都进行反射,只有在真正需要回退默认值时才会触发。 \ No newline at end of file +当某个属性解码失败时,从当前容器固定绑定的模型上下文中取回该属性的初始值进行填充。容器在创建时绑定所属模型,子模型解码期间父容器的归属不变;这种懒加载设计也避免了每次解码都进行反射,只有在真正需要回退默认值时才会触发。 diff --git a/Document/TechnicalGuide.md b/Document/TechnicalGuide.md index ff8b645c..3d4aeefb 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,19 +51,20 @@ 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() // 清理快照 + └── decoderForEntry(type) // 为本次类型入口准备局部上下文 + ├── modelSnapshot = DecodingSnapshot(objectType:) // Smart 模型每次入口新建 + └── type.init(from: entry) // 触发 Codable 标准流程 + ↓ + KeyedContainer 初始化 // JSONDecoderImpl+KeyedContainer.swift + ├── snapshot = impl.modelSnapshot // 容器创建时固定绑定所属模型 + ├── _convertDictionary() // 应用 Key Mapping(owner 来自固定 snapshot) + │ ├── SmartKeyDecodingStrategy // snake_case → camelCase 等 + │ └── KeysMapper.convertFrom() // 自定义 mappingForKey() + └── 逐属性解码: + ├── 1. 检查 ValueTransformer // mappingForValue() 自定义转换(读自身 snapshot) + ├── 2. 尝试标准解码 + ├── 3. 类型转换 Patcher // Int↔String, Bool↔Int 等 + └── 4. 默认值回退 snapshot // Mirror 反射获取的初始值 ↓ didFinishMapping() // 用户回调,可做后处理 ``` @@ -73,53 +74,95 @@ didFinishMapping() // 用户回调,可做后处 与原生 Codable 最大的区别在于错误处理策略。当某个属性解码失败时: 1. **先尝试类型转换**(Patcher):比如 JSON 传了 `"123"` 但属性类型是 `Int`,自动转换 -2. **再回退到默认值**(DecodingCache):使用属性声明时的初始值 +2. **再回退到默认值**(DecodingSnapshot):使用属性声明时的初始值 3. **最后记录日志**(SmartSentinel):不抛异常,不中断解析,但记录问题 这个策略是整个项目的核心设计意图。 --- -## 四、默认值机制(DecodingCache) +## 四、默认值机制(解码上下文显式绑定) 这是 SmartCodable 最核心的机制,也是最需要理解的部分。 ### 工作原理 +默认值与映射元数据由 `DecodingSnapshot` 承载:**某一次模型解码的上下文**。 +每个新的、框架可观察的 Smart 模型初始化入口都会创建自己的实例,不按类型、 +路径或作用域去重;`objectType` 构造后不可变。 + ``` -解码开始 - ↓ -cacheSnapshot(for: Model.self) // 记录类型,但不立即反射 +unwrap(as: Model.self) / decodeInPlace / singleValue decode ↓ -某属性解码失败 +decoderForEntry(type) // 为本次入口准备局部 decoder 视图 ↓ -initialValueIfPresent(forKey: "name") // 首次访问时触发 Mirror 反射 +modelSnapshot = DecodingSnapshot(objectType: Model.self) // 记录类型,但不立即反射 ↓ -populateInitialValues() // 创建 Model.init(),用 Mirror 提取所有属性初始值 +执行 Model.init(from: entry) ↓ -返回 snapshot.initialValues["name"] // 即用户声明的 var name: String = "默认值" 中的 "默认值" +某属性解码失败 ↓ -解码结束 +snapshot.initialValueIfPresent(forKey:) // 首次访问时触发 Mirror 反射(含父类递归) ↓ -removeSnapshot(for: Model.self) // 清理 +返回声明初始值 // 即 var name: String = "默认值" 中的值 ``` -### 快照栈机制 +解码完成后该上下文随 decoder 视图一起释放,框架不持有全局注册表。 + +### 容器固定绑定 + +KeyedContainer 在创建时绑定 `impl.modelSnapshot`,此后不再变化:子模型正在 +解码、抛错、或宿主在回调中同步回读已持有的容器,父容器的归属都不变。 +因此异常路径无需任何“恢复 owner”的动作,也不会出现 SmartFlat 平铺期间 +子模型默认值污染父容器的问题。 + +`codingPath` 仍只表示 JSON 解码位置(用于错误路径与既有结构分支),不参与 +模型归属判断。 + +### 属性边(PropertyDecodingContext) -嵌套模型解码时,快照按栈(数组)管理。查找时通过 `codingPath` 匹配: +模型容器的每个属性值视图携带一条明确的属性边:`(宿主模型上下文, 规范属性 key)`。 +它只服务“当前整属性”的合法消费者(恢复完整包装器声明、当前属性的整体 +Value Transformer),不提供向父级链式搜索的能力,也不能当作子对象的字段表: + +- 数组元素、字典数据键、手写 `nestedContainer` / `superDecoder` 进入的原始 + 子结构,属性边被清空,不得借用宿主字段表。 +- 未知的普通 `Codable` 子对象不继承上层模型上下文(否则同名字段会串值); + 其缺失字段按 Patcher 类型兜值处理。 + +### 属性包装器的特殊处理 + +属性包装器在 Swift 中存储为 `_propertyName`(下划线前缀)。snapshot 会同时 +检查 `key` 和 `_key`,并通过 `extractWrappedValue()` 提取包装器内的实际值; +`declaredWrapper(forKey:as:)` 则返回完整的包装器声明,保留 `SmartIgnored.isEncodable` +等自身配置。 + +双协议包装器(同时遵循 `PropertyWrapperable` 与 `SmartDecodable`)经 +`unwrap` 入口先绑定包装器自身的上下文;内层模型必须通过协作接口获得自己的 +新上下文: ```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"] +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 则派生 +一个绑定到内层模型的新解码视图。旧包装器继续兼容;但若仍直接调用 +`Value(from:)`,wrapper 与 inner 共享同一个解码视图,框架没有可观察信息区分 +这次调用,表现为确定性的 wrapper-first 语义。需要严格内外隔离的自定义包装器 +必须使用 `decodeWrappedValue(from:)` 或容器 decode 入口。 + +普通第三方包装器(仅 `PropertyWrapperable`)直接执行 `Value(from: decoder)` +时,入口会为内层模型建立局部兼容上下文,保证内层声明默认值可用;该绑定只 +服务这一次直接初始化,下一次显式 `decode(Value.self)` 仍会新建上下文, +两次独立初始化不会共享可变默认对象。 -属性包装器在 Swift 中存储为 `_propertyName`(下划线前缀)。DecodingCache 会同时检查 `key` 和 `_key`,并通过 `extractWrappedValue()` 提取包装器内的实际值。 +需要恢复包装器自身配置(例如 `SmartIgnored.isEncodable`)时,必须从属性边 +指向的宿主声明恢复完整包装器,不能只提取 `wrappedValue`,也不能从其他 +嵌套模型的同名属性推断状态。 --- @@ -177,7 +220,7 @@ struct Model: SmartCodable { 1. ValueTransformer(mappingForValue) ← 最高优先级 2. 标准 Codable 解码 3. Patcher 类型转换(Int↔String 等) -4. DecodingCache 默认值回退 ← 最低优先级 +4. DecodingSnapshot 默认值回退 ← 最低优先级 ``` ### 内置 Transformer @@ -284,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 的快照必须成对调用**:`cacheSnapshot()` 和 `removeSnapshot()` 必须配对,否则快照栈会泄漏。当前在 `unwrap()` 方法中管理,修改时注意异常路径 +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()` 只执行一次**:在容器初始化时调用,之后的属性解码都基于转换后的字典 ### 测试相关 @@ -301,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/Cache/Cachable.swift b/Sources/SmartCodable/Core/Cache/Cachable.swift index 5764a6de..88af477a 100644 --- a/Sources/SmartCodable/Core/Cache/Cachable.swift +++ b/Sources/SmartCodable/Core/Cache/Cachable.swift @@ -15,39 +15,21 @@ 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 } } -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 { @@ -69,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 deleted file mode 100644 index acdf629c..00000000 --- a/Sources/SmartCodable/Core/JSONDecoder/Decoder/DecodingCache.swift +++ /dev/null @@ -1,201 +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 - - /// Stack of decoding snapshots - 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 - } - - guard let object = smartType else { return } - - 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) - } - - /// 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() - } - } -} - -// MARK: - 获取属性初始值 -extension DecodingCache { - /// 查找指定解码路径下容器中某个字段的初始值。 - /// - /// 该方法会根据传入的 `codingPath`(代表某个解码容器的位置), - /// 在缓存的快照中查找对应容器,并尝试获取该容器中 `key` 对应字段的初始值。 - /// 如果该容器尚未初始化初始值,则会延迟初始化一次(通过反射等方式)。 - 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 - if snapshot.initialValues.isEmpty { - populateInitialValues(snapshot: snapshot) - } - - guard let cacheValue = snapshot.initialValues[key.stringValue] else { - // Handle @propertyWrapper cases (prefixed with underscore) - return handlePropertyWrapperCases(for: key, snapshot: snapshot) - } - - if let value = cacheValue as? T { - return value - } else if let caseValue = cacheValue 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`。 - /// - 匹配逻辑基于 key 的 `stringValue`。 - 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: { - $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) - } - - /// Extracts wrapped value from potential property wrapper types - 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) - } -} - - - -/// Snapshot of decoding state for a particular model -class DecodingSnapshot: Snapshot { - - typealias ObjectType = SmartDecodable.Type - - var objectType: (any SmartDecodable.Type)? - - var codingPath: [any CodingKey] = [] - - lazy var transformers: [SmartValueTransformer]? = { - objectType?.mappingForValue() - }() - - /// Dictionary storing initial values of properties - /// Key: Property name, Value: Initial 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 00000000..49d78a28 --- /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 243aa684..db25ad9c 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,8 +535,9 @@ 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 = 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 1497be3c..be0796f1 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 c1a10b37..7d8896b8 100644 --- a/Sources/SmartCodable/Core/JSONDecoder/Decoder/Impl/JSONDecoderImpl+Unwrap.swift +++ b/Sources/SmartCodable/Core/JSONDecoder/Decoder/Impl/JSONDecoderImpl+Unwrap.swift @@ -17,46 +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 entry.unwrapDictionary(as: type) } - - cache.cacheSnapshot(for: type, codingPath: codingPath) - let decoded = try type.init(from: self) - cache.removeSnapshot(for: type) - return decoded + + return try type.init(from: entry) } - + + /// 在当前解码位置直接初始化类型 T(codingPath 不前进)。 + /// + /// 供 `@SmartFlat` 等平铺语义的属性包装器使用: + /// codingPath 不前进不代表模型上下文不变——即使平铺在同一个 JSON 对象上, + /// 一个新的模型入口也取得自己的新上下文。不包含 `unwrap(as:)` 的 + /// 特殊类型提前返回,保持包装器原有的直接初始化语义。 + func decodeInPlace(_ type: T.Type) throws -> T { + 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 { @@ -70,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 @@ -123,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 } @@ -300,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 @@ -311,12 +352,7 @@ 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.decodeInPlace(Self.self) } /// createByDirectlyUnwrapping 的 Self 是静态绑定的(一个真正的类型), @@ -344,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 c0aba4ce..df8c53d7 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 + ) } } @@ -136,4 +164,3 @@ internal struct _JSONKey: CodingKey { internal static let `super` = _JSONKey(stringValue: "super")! } - diff --git a/Sources/SmartCodable/Core/PropertyWrapper/PropertyWrapperProtocol.swift b/Sources/SmartCodable/Core/PropertyWrapper/PropertyWrapperProtocol.swift index da6c531c..53205793 100644 --- a/Sources/SmartCodable/Core/PropertyWrapper/PropertyWrapperProtocol.swift +++ b/Sources/SmartCodable/Core/PropertyWrapper/PropertyWrapperProtocol.swift @@ -54,6 +54,21 @@ public extension PropertyWrapperable { } } +public extension PropertyWrapperable where WrappedValue: Decodable { + /// 解码属性包装器的内层值;在 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) + } + return try WrappedValue(from: decoder) + } +} + protocol _OptionalType { static var wrappedType: Any.Type { get } } diff --git a/Sources/SmartCodable/Core/PropertyWrapper/SmartAny/SmartAny.swift b/Sources/SmartCodable/Core/PropertyWrapper/SmartAny/SmartAny.swift index 1961ff5d..33e01988 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/SmartAny/SmartAnyImpl.swift b/Sources/SmartCodable/Core/PropertyWrapper/SmartAny/SmartAnyImpl.swift index bec2ec3c..3ae15776 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/SmartFlat.swift b/Sources/SmartCodable/Core/PropertyWrapper/SmartFlat.swift index 3a123539..2c1cdb89 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/SmartHexColor.swift b/Sources/SmartCodable/Core/PropertyWrapper/SmartHexColor.swift index 5c8bff19..5cbcffd4 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 fcc5a888..c06b4580 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 } @@ -75,8 +75,14 @@ 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 完整保留。 + // 根级 wrapper 没有宿主声明可读时,沿用 Patcher 兜底。 + if let declared: Self = impl.propertyContext?.declaredWrapper(as: Self.self) { + self = declared + } else { + wrappedValue = try Patcher.defaultForType() + } } public func encode(to encoder: Encoder) throws { @@ -92,10 +98,3 @@ extension SmartIgnored: Codable { } } } - - -extension JSONDecoderImpl { - fileprivate func smartDecode(type: T.Type) throws -> T { - try cache.initialValue(forKey: codingPath.last, codingPath: codingPath) - } -} diff --git a/Sources/SmartCodable/Core/Sentinel/SmartSentinel.swift b/Sources/SmartCodable/Core/Sentinel/SmartSentinel.swift index 87fe0842..b965e0b4 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.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 ed6f6767..26bc8d26 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/DecodeEdgeCaseTests.swift b/Tests/DecodeEdgeCaseTests.swift index bfaa5880..795a622b 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 520daeda..9a3d6ac9 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/DecodingContextTests.swift b/Tests/DecodingContextTests.swift new file mode 100644 index 00000000..599673fc --- /dev/null +++ b/Tests/DecodingContextTests.swift @@ -0,0 +1,1748 @@ +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)的契约测试。 +/// +/// 由旧 `DecodingCacheLifecycleTests` 迁移:保留全部行为断言(默认值、映射、 +/// 包装器配置、异常后宿主字段、编码输出),把 snapshots.count / activeOwnerDepth / +/// scopeIdentifier 等旧状态机白盒探针替换为“容器固定绑定正确 owner”的语义断言。 +final class DecodingContextTests: XCTestCase { + + // MARK: - 上下文对象单元契约 + + /// 同一个 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()") + } + + /// 不同 snapshot 是两次独立构造,默认值对象不得意外别名 + func testDistinctSnapshotsProvideIndependentDefaultObjects() throws { + let snapshotA = DecodingSnapshot(objectType: ContextBoxModel.self) + let snapshotB = DecodingSnapshot(objectType: ContextBoxModel.self) + + 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) + ) + + XCTAssertTrue(declared.isEncodable) + XCTAssertEqual(declared.wrappedValue.level, 7) + } + + /// “已反射且没有存储字段”与“尚未反射”必须可区分:空表只执行一次 provider + func testEmptyFieldTableIsLoadedExactlyOnce() throws { + ContextEmptyModel.providerCount = 0 + let snapshot = DecodingSnapshot(objectType: ContextEmptyModel.self) + + let missing: ContextBox? = snapshot.initialValueIfPresent(forKey: ContextKeys.box) + XCTAssertNil(missing) + let stillMissing: ContextBox? = snapshot.initialValueIfPresent(forKey: ContextKeys.box) + XCTAssertNil(stillMissing) + + XCTAssertEqual(ContextEmptyModel.providerCount, 1, + "已加载的空表不应被误判成未加载而反复执行 provider") + } + + /// 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 impl = JSONDecoderImpl( + userInfo: [:], + from: .object([:]), + codingPath: [], + options: smartDecoder.options + ) + + XCTAssertThrowsError(try impl.unwrap(as: ThrowingLifecycleModel.self)) + + let next = try impl.unwrap(as: ContextBoxModel.self) + XCTAssertEqual(next.box.value, 10, + "上一次抛错的解码不得影响下一次入口的默认值") + } + + // MARK: - 独立调用与稳定归属 + + /// 同一次模型 init 内多次请求 keyed / single 容器:从两个容器读取同一缺失字段, + /// 复用本次初始化的同一个引用类型默认对象,且默认值 provider 只执行一次(T32) + func testRepeatedContainerRequestsKeepSameContextInOneInit() throws { + RepeatedContainerHost.resetDefaultProviderCallCount() + + let model = try XCTUnwrap(RepeatedContainerHost.deserialize(from: [:])) + + XCTAssertTrue(model.firstBox === model.secondBox, + "同一次 init 内两个容器读取同一缺失字段,应取得同一个默认对象") + XCTAssertEqual(model.firstBox?.value, 10, + "默认对象内容应来自宿主声明的初始值") + XCTAssertEqual(RepeatedContainerHost.defaultProviderCallCount, 1, + "默认值 provider(objectType.init() 反射构造)在同一次初始化内只执行一次") + } + + /// 不同宿主的同名 _settings 声明只能恢复属性边精确指向的那一个(T33) + func testPropertyEdgeRestoresPreciseHostDeclaration() throws { + let host = try XCTUnwrap(EdgeParentHost.deserialize(from: ["child": [:]])) + + XCTAssertEqual(host.settings.level, 7) + XCTAssertFalse(host.settingsIsEncodable) + XCTAssertEqual(host.child.settings.level, 9) + XCTAssertTrue(host.child.settingsIsEncodable) + } + + /// 用户在 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: - 字典值直接解包与 SmartAny 兜底 + + /// 字典值元素缺 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 时应命中元素自身上下文的声明默认值") + } + + /// @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: - 第三方包装器与双协议包装器 + + /// 第三方包装器两条路径(直接初始化与 single-value 重入)都保留内层声明默认值 + func testThirdPartyWrappersPreserveWrappedModelDefaults() throws { + let direct = try XCTUnwrap(DirectWrapperHost.deserialize(from: ["payload": [:]])) + XCTAssertEqual(direct.payload.count, 99) + + let container = try XCTUnwrap(ContainerWrapperHost.deserialize(from: ["payload": [:]])) + XCTAssertEqual(container.payload.count, 99) + } + + /// 双协议包装器与内层模型共享 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 归属,不依赖解码顺序 + 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 未使用协作入口时保持确定性的 wrapper-first 兼容语义 + func testLegacyDualWrapperWithoutOwnerTransitionUsesWrapperOwner() throws { + let host = try XCTUnwrap(LegacySameNameHost.deserialize(from: ["payload": [:]])) + + XCTAssertEqual(host.$payload.shared, 7) + XCTAssertEqual(host.payload.shared, 7) + } + + /// 无 parsingMark 时,嵌套包装器内的完整属性包装器声明从包装器自己的属性表恢复 + 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) + } + + /// 双层双协议 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) + XCTAssertEqual(NestedDualLeaf.observedOwnerTypeName, "NestedDualLeaf") + + 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) + XCTAssertEqual(NestedDualLeaf.observedOwnerTypeName, "NestedDualLeaf") + + 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) + XCTAssertEqual(NestedDualLeaf.observedOwnerTypeName, "NestedDualLeaf") + } + + // MARK: - 异常路径 + + /// 双层 wrapper 最内层抛错时无需任何恢复动作,宿主回退声明值并继续后续字段 + func testThrowingNestedDualWrappersRestoreHostDefaults() 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) + } + + /// 第三方包装器内层抛错:宿主取得声明回退,后续字段继续命中宿主默认值 + func testThrowingThirdPartyWrapperRestoresHostDefaults() throws { + let legacyHost = try XCTUnwrap(ThrowingWrapperHost.deserialize(from: [ + "payload": [:], + "trailing": "not-an-int" + ])) + + XCTAssertEqual(legacyHost.payload.marker, 41) + XCTAssertEqual(legacyHost.trailing, 77) + + 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) + } + + /// 包装器经 singleValueContainer 重入后抛错:宿主后续字段继续命中声明默认值 + func testThrowingContainerWrapperReentryRestoresHostDefaults() throws { + let host = try XCTUnwrap(ThrowingContainerWrapperHost.deserialize(from: [ + "payload": [:], + "trailing": "not-an-int" + ])) + + XCTAssertEqual(host.payload.marker, 41) + XCTAssertEqual(host.trailing, 77) + } + + // MARK: - 日志归属 + + /// 哨兵日志的模型名来自容器固定所属模型,与解码顺序无关 + func testSentinelLogsContainerOwningModel() 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) + } + } + + // 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") + } + + // 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.testConcurrentIndependentDecodersFallbackDefaultsStayIsolated + 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 alphaModels: [ConcurrentDefaultAlpha] = [] + var betaModels: [ConcurrentDefaultBeta] = [] + + for index in 0.. [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 ContextTestError.boom + } +} + +private enum ContextTestError: Error { + case boom +} + +// MARK: - 独立调用与归属 fixture + +/// 重复容器请求用例的引用类型默认值。 +/// 遵循 Codable 只为满足 decode(_:forKey:) 的约束;键缺失时不会触发其自身解码。 +private final class RepeatedContainerBox: Codable { + var value = 10 +} + +private struct RepeatedContainerHost: SmartCodableX { + private(set) static var defaultProviderCallCount = 0 + + private enum CodingKeys: String, CodingKey { case box } + + /// 声明的引用类型默认属性:只应由默认值反射构造一次 + 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 { + 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 {} +} + +private struct EdgeSettings: SmartCodableX { + var level: Int = 0 + + init() {} + init(level: Int) { + self.level = level + } +} + +private struct EdgeChild: SmartCodableX { + @SmartIgnored(wrappedValue: EdgeSettings(level: 9), isEncodable: true) + var settings: EdgeSettings + + var settingsIsEncodable: Bool { _settings.isEncodable } +} + +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" +} + +/// @SmartAny 兜底解码的目标模型 +private struct SmartAnyTargetModel: SmartCodableX { + var score: Int = 11 +} + +// MARK: - 包装器 fixture(自旧 DecodingCacheLifecycleTests 迁移) + +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 { + var count: Int = 99 + + private enum CodingKeys: String, CodingKey { + case count + } + + init() {} + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + count = try container.decode(Int.self, forKey: .count) + } +} + +private struct ThrowingWrapperProbeModel: SmartCodableX { + var marker: Int = 41 + + init() {} + + init(from decoder: Decoder) throws { + throw ContextTestError.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 observedOwnerTypeName: String? + + var value = 99 + + private enum CodingKeys: String, CodingKey { + case value + } + + init() {} + + init(from decoder: Decoder) throws { + if let impl = decoder as? JSONDecoderImpl { + NestedDualLeaf.observedOwnerTypeName = impl.modelSnapshot.map { String(describing: $0.objectType) } + } + + let container = try decoder.container(keyedBy: CodingKeys.self) + value = try container.decode(Int.self, forKey: .value) + } + + static func resetObservations() { + observedOwnerTypeName = nil + } +} + +private struct ThrowingNestedDualLeaf: SmartCodableX { + var value = 99 + + init() {} + + init(from decoder: Decoder) throws { + throw ContextTestError.boom + } + + static func resetObservations() {} +} + +/// 无 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 + } +} + +// 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) + }) + ] + } +} + +// MARK: - 审查补充 fixture + +/// 第三方包装器包装 Optional 内层模型(T14) +@propertyWrapper +private struct OptionalDirectWrapper: 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) + }) + ] + } +} diff --git a/Tests/EncodeTests.swift b/Tests/EncodeTests.swift index 176bed90..fed3b912 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 字符串应包含且仅包含预期映射字段") } } diff --git a/Tests/GlobalOptionsConcurrencyTests.swift b/Tests/GlobalOptionsConcurrencyTests.swift index 4ea4dbf2..e26c24e7 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() { // 每次测试后恢复默认值,防止污染其他测试 diff --git a/Tests/RefactorPlanRegressionTests.swift b/Tests/RefactorPlanRegressionTests.swift new file mode 100644 index 00000000..44c65871 --- /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 } + })] + } +} diff --git a/Tests/SmartFlatTests.swift b/Tests/SmartFlatTests.swift new file mode 100644 index 00000000..b649d149 --- /dev/null +++ b/Tests/SmartFlatTests.swift @@ -0,0 +1,253 @@ +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,内层同名字段声明默认值各不相同,互不串值(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 { + 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 +} + +/// 连续 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 + typealias JSON = String + + func transformFromJSON(_ value: Any) -> String? { + (value as? String)?.uppercased() + } + + func transformToJSON(_ value: String) -> String? { + value + } +} diff --git a/Tests/SmartIgnoredTests.swift b/Tests/SmartIgnoredTests.swift new file mode 100644 index 00000000..dcc6f370 --- /dev/null +++ b/Tests/SmartIgnoredTests.swift @@ -0,0 +1,114 @@ +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) + } + + /// 无 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 的宿主模型 +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 + } +} + +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 + } +}