Просмотр исходного кода

Merge pull request #1294 from nightscout/remove-carbs-json

[Part 4 of 6] Remove JSON encoding for carbs
Sam King 2 дней назад
Родитель
Сommit
35914e7a50

+ 0 - 35
Model/Helper/CarbEntryStored+helper.swift

@@ -69,38 +69,3 @@ extension CarbEntryStored {
         return request
     }
 }
-
-extension CarbEntryStored: Encodable {
-    enum CodingKeys: String, CodingKey {
-        case actualDate
-        case created_at
-        case carbs
-        case fat
-        case id
-        case isFPU
-        case note
-        case protein
-        case enteredBy
-    }
-
-    public func encode(to encoder: Encoder) throws {
-        var container = encoder.container(keyedBy: CodingKeys.self)
-
-        let dateFormatter = ISO8601DateFormatter()
-        dateFormatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
-
-        let formattedDate = dateFormatter.string(from: date ?? Date())
-        try container.encode(formattedDate, forKey: .actualDate)
-        try container.encode(formattedDate, forKey: .created_at)
-
-        // TODO: handle this conditionally; pass in the enteredBy string (manual entry or via NS or Apple Health)
-        try container.encode("Trio", forKey: .enteredBy)
-
-        try container.encode(carbs, forKey: .carbs)
-        try container.encode(fat, forKey: .fat)
-        try container.encode(isFPU, forKey: .isFPU)
-        try container.encode(note, forKey: .note)
-        try container.encode(protein, forKey: .protein)
-        try container.encode(id, forKey: .id)
-    }
-}

+ 4 - 0
Trio.xcodeproj/project.pbxproj

@@ -830,6 +830,7 @@
 		DDD7C8C22F4DB45400E5CF09 /* GlucoseStored+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDD7C8C02F4DB45400E5CF09 /* GlucoseStored+CoreDataProperties.swift */; };
 		DDDD0FFB2F4E22C000F9C645 /* GlucoseSmoothingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDDD0FFA2F4E22C000F9C645 /* GlucoseSmoothingTests.swift */; };
 		DDDD0FFD2F4E22C000F9C645 /* GlucoseNativeConversionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDDD0FFC2F4E22C000F9C645 /* GlucoseNativeConversionTests.swift */; };
+		DDDD10A12F4E22C000F9C645 /* CarbsNativeConversionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDDD10A02F4E22C000F9C645 /* CarbsNativeConversionTests.swift */; };
 		DDDD0FFF2F4E231B00F9C645 /* MockTDDStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDDD0FFE2F4E231B00F9C645 /* MockTDDStorage.swift */; };
 		DDE179522C910127003CDDB7 /* MealPresetStored+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDE179322C910127003CDDB7 /* MealPresetStored+CoreDataClass.swift */; };
 		DDE179532C910127003CDDB7 /* MealPresetStored+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDE179332C910127003CDDB7 /* MealPresetStored+CoreDataProperties.swift */; };
@@ -1842,6 +1843,7 @@
 		DDD7C8C02F4DB45400E5CF09 /* GlucoseStored+CoreDataProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "GlucoseStored+CoreDataProperties.swift"; sourceTree = "<group>"; };
 		DDDD0FFA2F4E22C000F9C645 /* GlucoseSmoothingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlucoseSmoothingTests.swift; sourceTree = "<group>"; };
 		DDDD0FFC2F4E22C000F9C645 /* GlucoseNativeConversionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlucoseNativeConversionTests.swift; sourceTree = "<group>"; };
+		DDDD10A02F4E22C000F9C645 /* CarbsNativeConversionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CarbsNativeConversionTests.swift; sourceTree = "<group>"; };
 		DDDD0FFE2F4E231B00F9C645 /* MockTDDStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockTDDStorage.swift; sourceTree = "<group>"; };
 		DDE179322C910127003CDDB7 /* MealPresetStored+CoreDataClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MealPresetStored+CoreDataClass.swift"; sourceTree = "<group>"; };
 		DDE179332C910127003CDDB7 /* MealPresetStored+CoreDataProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MealPresetStored+CoreDataProperties.swift"; sourceTree = "<group>"; };
@@ -2977,6 +2979,7 @@
 				DDDD0FFD2F4E231600F9C645 /* Mocks */,
 				DDDD0FFA2F4E22C000F9C645 /* GlucoseSmoothingTests.swift */,
 				DDDD0FFC2F4E22C000F9C645 /* GlucoseNativeConversionTests.swift */,
+				DDDD10A02F4E22C000F9C645 /* CarbsNativeConversionTests.swift */,
 				DDC6CA6C2DD90A2A0060EE25 /* LocalizationTests.swift */,
 				3B997DD22DC02AEF006B6BB2 /* JSONImporterData */,
 				BD8FC05C2D6618BE00B95AED /* BolusCalculatorTests */,
@@ -5641,6 +5644,7 @@
 				BD8FC0542D66186000B95AED /* TestError.swift in Sources */,
 				DDDD0FFB2F4E22C000F9C645 /* GlucoseSmoothingTests.swift in Sources */,
 				DDDD0FFD2F4E22C000F9C645 /* GlucoseNativeConversionTests.swift in Sources */,
+				DDDD10A12F4E22C000F9C645 /* CarbsNativeConversionTests.swift in Sources */,
 				CEE9A65E2BBC9F6500EB5194 /* CalibrationsTests.swift in Sources */,
 				CA02000000000000000010C2 /* DeliveryLimitsSyncTests.swift in Sources */,
 				BD8FC0622D6619E600B95AED /* OverrideStorageTests.swift in Sources */,

+ 1 - 1
Trio/Sources/APS/APSManager.swift

@@ -170,7 +170,7 @@ final class BaseAPSManager: APSManager, Injectable {
 
     init(resolver: Resolver) {
         injectServices(resolver)
-        openAPS = OpenAPS(storage: storage, tddStorage: tddStorage, glucoseStorage: glucoseStorage)
+        openAPS = OpenAPS(storage: storage, tddStorage: tddStorage, glucoseStorage: glucoseStorage, carbsStorage: carbsStorage)
         subscribe()
         lastLoopDateSubject.send(lastLoopDate)
 

+ 16 - 63
Trio/Sources/APS/OpenAPS/OpenAPS.swift

@@ -8,15 +8,17 @@ final class OpenAPS {
     private let storage: FileStorage
     private let tddStorage: TDDStorage
     private let glucoseStorage: GlucoseStorage
+    private let carbsStorage: CarbsStorage
 
     let context = CoreDataStack.shared.newTaskContext()
 
     let jsonConverter = JSONConverter()
 
-    init(storage: FileStorage, tddStorage: TDDStorage, glucoseStorage: GlucoseStorage) {
+    init(storage: FileStorage, tddStorage: TDDStorage, glucoseStorage: GlucoseStorage, carbsStorage: CarbsStorage) {
         self.storage = storage
         self.tddStorage = tddStorage
         self.glucoseStorage = glucoseStorage
+        self.carbsStorage = carbsStorage
     }
 
     static let dateFormatter: ISO8601DateFormatter = {
@@ -95,58 +97,6 @@ final class OpenAPS {
         }
     }
 
-    private func fetchAndProcessCarbs(additionalCarbs: Decimal? = nil, carbsDate: Date? = nil) async throws -> String {
-        let results = try await CoreDataStack.shared.fetchEntitiesAsync(
-            ofType: CarbEntryStored.self,
-            onContext: context,
-            predicate: NSPredicate.predicateForOneDayAgo,
-            key: "date",
-            ascending: false
-        )
-
-        let json = try await context.perform {
-            guard let carbResults = results as? [CarbEntryStored] else {
-                throw CoreDataError.fetchError(function: #function, file: #file)
-            }
-
-            var jsonArray = self.jsonConverter.convertToJSON(carbResults)
-
-            if let additionalCarbs = additionalCarbs {
-                let formattedDate = carbsDate.map { ISO8601DateFormatter().string(from: $0) } ?? ISO8601DateFormatter()
-                    .string(from: Date())
-
-                let additionalEntry = [
-                    "carbs": Double(additionalCarbs),
-                    "actualDate": formattedDate,
-                    "id": UUID().uuidString,
-                    "note": NSNull(),
-                    "protein": 0,
-                    "created_at": formattedDate,
-                    "isFPU": false,
-                    "fat": 0,
-                    "enteredBy": "Trio"
-                ] as [String: Any]
-
-                // Assuming jsonArray is a String, convert it to a list of dictionaries first
-                if let jsonData = jsonArray.data(using: .utf8) {
-                    var jsonList = try? JSONSerialization.jsonObject(with: jsonData, options: []) as? [[String: Any]]
-                    jsonList?.append(additionalEntry)
-
-                    // Convert back to JSON string
-                    if let updatedJsonData = try? JSONSerialization
-                        .data(withJSONObject: jsonList ?? [], options: .prettyPrinted)
-                    {
-                        jsonArray = String(data: updatedJsonData, encoding: .utf8) ?? jsonArray
-                    }
-                }
-            }
-
-            return jsonArray
-        }
-
-        return json
-    }
-
     private func fetchPumpHistoryObjectIDs() async throws -> [NSManagedObjectID]? {
         let results = try await CoreDataStack.shared.fetchEntitiesAsync(
             ofType: PumpEventStored.self,
@@ -332,7 +282,10 @@ final class OpenAPS {
 
         // Perform asynchronous calls in parallel
         async let pumpHistoryObjectIDs = fetchPumpHistoryObjectIDs() ?? []
-        async let carbs = fetchAndProcessCarbs(additionalCarbs: simulatedCarbsAmount ?? 0, carbsDate: simulatedCarbsDate)
+        async let carbsFetch = carbsStorage.getCarbsForAlgorithm(
+            additionalCarbs: simulatedCarbsAmount ?? 0,
+            carbsDate: simulatedCarbsDate
+        )
 
         var preferences = await storage.retrieveAsync(OpenAPS.Settings.preferences, as: Preferences.self) ?? Preferences()
         let glucoseFetchHours = preferences.maxMealAbsorptionTime + 0.5 // MMAT + half hour buffer
@@ -351,7 +304,7 @@ final class OpenAPS {
         // Await the results of asynchronous tasks
         let (
             pumpHistoryJSON,
-            carbsAsJSON,
+            carbs,
             glucose,
             trioCustomOrefVariables,
             profile,
@@ -361,7 +314,7 @@ final class OpenAPS {
             hasSufficientTdd
         ) = await (
             try parsePumpHistory(await pumpHistoryObjectIDs, simulatedBolusAmount: simulatedBolusAmount),
-            try carbs,
+            try carbsFetch,
             try glucoseFetch,
             try prepareTrioCustomOrefVariables,
             profileAsync,
@@ -377,7 +330,7 @@ final class OpenAPS {
             profile: profile,
             basalProfile: basalProfile,
             clock: clock,
-            carbs: carbsAsJSON,
+            carbs: carbs,
             glucose: glucose
         )
 
@@ -511,7 +464,7 @@ final class OpenAPS {
 
         // Perform asynchronous calls in parallel
         async let pumpHistoryObjectIDs = fetchPumpHistoryObjectIDs() ?? []
-        async let carbs = fetchAndProcessCarbs()
+        async let carbsFetch = carbsStorage.getCarbsForAlgorithm(additionalCarbs: nil, carbsDate: nil)
         // Autosens needs the full 24h window for its sensitivity algorithm.
         async let glucoseFetch = glucoseStorage.getGlucoseForAlgorithm(
             shouldSmoothGlucose: shouldSmoothGlucose,
@@ -522,9 +475,9 @@ final class OpenAPS {
         async let getTempTargets = loadFileFromStorageAsync(name: Settings.tempTargets)
 
         // Await the results of asynchronous tasks
-        let (pumpHistoryJSON, carbsAsJSON, glucose, profile, basalProfile, tempTargets) = await (
+        let (pumpHistoryJSON, carbs, glucose, profile, basalProfile, tempTargets) = await (
             try parsePumpHistory(await pumpHistoryObjectIDs),
-            try carbs,
+            try carbsFetch,
             try glucoseFetch,
             getProfile,
             getBasalProfile,
@@ -537,7 +490,7 @@ final class OpenAPS {
             pumpHistory: pumpHistoryJSON,
             basalprofile: basalProfile,
             profile: profile,
-            carbs: carbsAsJSON,
+            carbs: carbs,
             temptargets: tempTargets
         )
 
@@ -684,7 +637,7 @@ final class OpenAPS {
         profile: JSON,
         basalProfile: JSON,
         clock: JSON,
-        carbs: JSON,
+        carbs: [CarbsEntry],
         glucose: [BloodGlucose]
     ) throws -> RawJSON {
         let swiftResult = OpenAPSSwift
@@ -704,7 +657,7 @@ final class OpenAPS {
         pumpHistory: JSON,
         basalprofile: JSON,
         profile: JSON,
-        carbs: JSON,
+        carbs: [CarbsEntry],
         temptargets: JSON
     ) throws -> RawJSON {
         let swiftResult = OpenAPSSwift

+ 0 - 4
Trio/Sources/APS/OpenAPSSwift/JSONBridge.swift

@@ -44,10 +44,6 @@ enum JSONBridge {
         try JSONBridge.from(string: from.rawJSON)
     }
 
-    static func carbs(from: JSON) throws -> [CarbsEntry] {
-        try JSONBridge.from(string: from.rawJSON)
-    }
-
     static func iobResult(from: JSON) throws -> [IobResult] {
         try JSONBridge.from(string: from.rawJSON)
     }

+ 2 - 4
Trio/Sources/APS/OpenAPSSwift/OpenAPSSwift.swift

@@ -92,7 +92,7 @@ struct OpenAPSSwift {
         profile: JSON,
         basalProfile: JSON,
         clock: JSON,
-        carbs: JSON,
+        carbs: [CarbsEntry],
         glucose: [BloodGlucose]
     ) -> (OrefFunctionResult) {
         do {
@@ -100,7 +100,6 @@ struct OpenAPSSwift {
             let profile = try JSONBridge.profile(from: profile)
             let basalProfile = try JSONBridge.basalProfile(from: basalProfile)
             let clock = try JSONBridge.clock(from: clock)
-            let carbs = try JSONBridge.carbs(from: carbs)
 
             let mealResult = try MealGenerator.generate(
                 pumpHistory: pumpHistory,
@@ -142,7 +141,7 @@ struct OpenAPSSwift {
         pumpHistory: JSON,
         basalProfile: JSON,
         profile: JSON,
-        carbs: JSON,
+        carbs: [CarbsEntry],
         tempTargets: JSON,
         clock: JSON,
         includeDeviationsForTesting: Bool = false
@@ -151,7 +150,6 @@ struct OpenAPSSwift {
             let pumpHistory = try JSONBridge.pumpHistory(from: pumpHistory)
             let basalProfile = try JSONBridge.basalProfile(from: basalProfile)
             let profile = try JSONBridge.profile(from: profile)
-            let carbs = try JSONBridge.carbs(from: carbs)
             let tempTargets = try JSONBridge.tempTargets(from: tempTargets)
             let clock = try JSONBridge.clock(from: clock)
 

+ 67 - 0
Trio/Sources/APS/Storage/CarbsStorage.swift

@@ -13,6 +13,7 @@ protocol CarbsStorage {
     func storeCarbs(_ carbs: [CarbsEntry], areFetchedFromRemote: Bool) async throws
     func deleteCarbsEntryStored(_ treatmentObjectID: NSManagedObjectID) async
     func syncDate() -> Date
+    func getCarbsForAlgorithm(additionalCarbs: Decimal?, carbsDate: Date?) async throws -> [CarbsEntry]
     func getCarbsNotYetUploadedToNightscout() async throws -> [NightscoutTreatment]
     func getFPUsNotYetUploadedToNightscout() async throws -> [NightscoutTreatment]
     func getCarbsNotYetUploadedToHealth() async throws -> [CarbsEntry]
@@ -344,6 +345,72 @@ final class BaseCarbsStorage: CarbsStorage, Injectable {
         Date().addingTimeInterval(-1.days.timeInterval)
     }
 
+    /// Fetches the last day of carbs and converts them into the `CarbsEntry` values the oref algorithm
+    /// consumes, optionally appending a synthetic "additional carbs" entry for bolus simulation.
+    func getCarbsForAlgorithm(additionalCarbs: Decimal? = nil, carbsDate: Date? = nil) async throws -> [CarbsEntry] {
+        let results = try await CoreDataStack.shared.fetchEntitiesAsync(
+            ofType: CarbEntryStored.self,
+            onContext: context,
+            predicate: NSPredicate.predicateForOneDayAgo,
+            key: "date",
+            ascending: false
+        )
+
+        return try await context.perform {
+            guard let carbResults = results as? [CarbEntryStored] else {
+                throw CoreDataError.fetchError(function: #function, file: #file)
+            }
+
+            var entries = carbResults.map { Self.mapToCarbsEntry($0) }
+
+            if let additionalCarbs = additionalCarbs {
+                entries.append(Self.additionalCarbsEntry(carbs: additionalCarbs, date: carbsDate ?? Date()))
+            }
+
+            return entries
+        }
+    }
+
+    /// Converts a `Double` to a `Decimal` using JSON style conversion
+    static func algorithmDecimal(_ value: Double) -> Decimal {
+        Decimal(string: value.description) ?? Decimal(value)
+    }
+
+    /// Converts CoreData stored carb entries into a struct that the oref algorithm can use
+    static func mapToCarbsEntry(_ carbEntry: CarbEntryStored) -> CarbsEntry {
+        // The old encode used `date ?? Date()` for both created_at and actualDate.
+        let date = carbEntry.date ?? Date()
+        return CarbsEntry(
+            id: carbEntry.id?.uuidString,
+            createdAt: date,
+            actualDate: date,
+            carbs: algorithmDecimal(carbEntry.carbs),
+            fat: algorithmDecimal(carbEntry.fat),
+            protein: algorithmDecimal(carbEntry.protein),
+            note: nil,
+            enteredBy: CarbsEntry.local,
+            isFPU: carbEntry.isFPU,
+            fpuID: nil
+        )
+    }
+
+    /// Builds the synthetic "additional carbs" entry that the determine-basal flow appends for bolus
+    /// simulation.
+    static func additionalCarbsEntry(carbs: Decimal, date: Date, id: String = UUID().uuidString) -> CarbsEntry {
+        CarbsEntry(
+            id: id,
+            createdAt: date,
+            actualDate: date,
+            carbs: carbs,
+            fat: 0,
+            protein: 0,
+            note: nil,
+            enteredBy: CarbsEntry.local,
+            isFPU: false,
+            fpuID: nil
+        )
+    }
+
     func deleteCarbsEntryStored(_ treatmentObjectID: NSManagedObjectID) async {
         // Use injected context if available, otherwise create new task context
         let taskContext = context != CoreDataStack.shared.newTaskContext()

+ 292 - 0
TrioTests/CarbsNativeConversionTests.swift

@@ -0,0 +1,292 @@
+import CoreData
+import Foundation
+import Testing
+
+@testable import Trio
+
+/// Golden tests certifying that the native `CarbEntryStored` → `CarbsEntry` mapping
+/// (`BaseCarbsStorage.mapToCarbsEntry`) reproduces, field for field, the carbs the algorithm used to receive
+/// through the old JSON round-trip (`CarbEntryStored` → JSON → `JSONBridge.carbs`) — with one
+/// deliberate, documented change: `id`.
+@Suite("Carbs Native Conversion Tests", .serialized) struct CarbsNativeConversionTests {
+    var coreDataStack: CoreDataStack!
+    var testContext: NSManagedObjectContext!
+
+    init() async throws {
+        coreDataStack = try await CoreDataStack.createForTests()
+        testContext = coreDataStack.newTaskContext()
+    }
+
+    // MARK: - Golden tests (native mapping vs frozen old-path output)
+
+    @Test("A basic carb entry maps identically (id now populated)") func testBasicEntry() async throws {
+        await insertCarb(carbs: 30, isFPU: false, date: fixedDate(minutesAgo: 0), note: "breakfast", id: uuid(1))
+
+        try await assertNativeMatchesGolden([
+            CarbsEntry(
+                id: uuid(1).uuidString,
+                createdAt: Date(timeIntervalSince1970: 1_700_000_000),
+                actualDate: Date(timeIntervalSince1970: 1_700_000_000),
+                carbs: 30,
+                fat: 0,
+                protein: 0,
+                note: nil, // old path decoded "note" as "notes" → nil; preserved
+                enteredBy: CarbsEntry.local,
+                isFPU: false,
+                fpuID: nil
+            )
+        ])
+    }
+
+    @Test("Fractional carbs/fat/protein keep clean decimal values") func testFractionalDecimals() async throws {
+        // 33.33 as a Double is 33.32999999999999488; the old JSON round-trip recovered the clean
+        // 33.33 (JSONEncoder writes the shortest round-trippable string). `Decimal(Double)` would
+        // leak the binary expansion, so `algorithmDecimal` must reproduce the clean value.
+        await insertCarb(carbs: 33.33, isFPU: true, date: fixedDate(minutesAgo: 0), fat: 5.5, protein: 3.2, id: uuid(1))
+        await insertCarb(carbs: 12.5, isFPU: false, date: fixedDate(minutesAgo: 5), id: uuid(2))
+
+        try await assertNativeMatchesGolden([
+            CarbsEntry(
+                id: uuid(1).uuidString,
+                createdAt: Date(timeIntervalSince1970: 1_700_000_000),
+                actualDate: Date(timeIntervalSince1970: 1_700_000_000),
+                carbs: Decimal(string: "33.33")!,
+                fat: Decimal(string: "5.5")!,
+                protein: Decimal(string: "3.2")!,
+                note: nil,
+                enteredBy: CarbsEntry.local,
+                isFPU: true,
+                fpuID: nil
+            ),
+            CarbsEntry(
+                id: uuid(2).uuidString,
+                createdAt: Date(timeIntervalSince1970: 1_699_999_700),
+                actualDate: Date(timeIntervalSince1970: 1_699_999_700),
+                carbs: Decimal(string: "12.5")!,
+                fat: 0,
+                protein: 0,
+                note: nil,
+                enteredBy: CarbsEntry.local,
+                isFPU: false,
+                fpuID: nil
+            )
+        ])
+
+        let native = try await nativeCarbsEntries()
+        #expect(native.first?.carbs == Decimal(string: "33.33")!, "33.33 must stay clean, not the Double expansion")
+    }
+
+    @Test("A zero-carb entry (nil stored id) maps identically") func testZeroCarbNilId() async throws {
+        // The old path always produced id == nil, so a stored entry with no id is indistinguishable
+        // there. Natively, a nil stored id still maps to a nil `CarbsEntry.id`.
+        await insertCarb(carbs: 0, isFPU: false, date: fixedDate(minutesAgo: 0), id: nil)
+
+        try await assertNativeMatchesGolden([
+            CarbsEntry(
+                id: nil,
+                createdAt: Date(timeIntervalSince1970: 1_700_000_000),
+                actualDate: Date(timeIntervalSince1970: 1_700_000_000),
+                carbs: 0,
+                fat: 0,
+                protein: 0,
+                note: nil,
+                enteredBy: CarbsEntry.local,
+                isFPU: false,
+                fpuID: nil
+            )
+        ])
+    }
+
+    @Test("A multi-entry sequence maps identically") func testMultiEntrySequence() async throws {
+        for i in 0 ..< 5 {
+            await insertCarb(
+                carbs: Double(10 * (i + 1)),
+                isFPU: false,
+                date: fixedDate(minutesAgo: Double(i) * 5),
+                id: uuid(i + 1)
+            )
+        }
+
+        try await assertNativeMatchesGolden((0 ..< 5).map { i in
+            CarbsEntry(
+                id: uuid(i + 1).uuidString,
+                createdAt: Date(timeIntervalSince1970: 1_700_000_000 - Double(i) * 300),
+                actualDate: Date(timeIntervalSince1970: 1_700_000_000 - Double(i) * 300),
+                carbs: Decimal(10 * (i + 1)),
+                fat: 0,
+                protein: 0,
+                note: nil,
+                enteredBy: CarbsEntry.local,
+                isFPU: false,
+                fpuID: nil
+            )
+        })
+    }
+
+    @Test("Sub-millisecond dates are truncated to millisecond resolution") func testMillisecondTruncation() async throws {
+        // The old path round-tripped the stored date through an ISO8601 fractional-seconds string
+        // (millisecond precision). The native path keeps the full-precision `Date`, but only
+        // millisecond precision is ever observable, so the comparison is at ms resolution.
+        await insertCarb(carbs: 25, isFPU: false, date: fixedDate(minutesAgo: 0, plusSeconds: 0.123_456), id: uuid(1))
+
+        try await assertNativeMatchesGolden([
+            CarbsEntry(
+                id: uuid(1).uuidString,
+                createdAt: Date(timeIntervalSince1970: 1_700_000_000.123),
+                actualDate: Date(timeIntervalSince1970: 1_700_000_000.123),
+                carbs: 25,
+                fat: 0,
+                protein: 0,
+                note: nil,
+                enteredBy: CarbsEntry.local,
+                isFPU: false,
+                fpuID: nil
+            )
+        ])
+    }
+
+    // MARK: - The one deliberate behavioral change
+
+    @Test("id is now populated from Core Data (old path always produced nil)") func testIdIsNowPopulatedFromCoreData() async throws {
+        await insertCarb(carbs: 40, isFPU: false, date: fixedDate(minutesAgo: 0), id: uuid(7))
+
+        let native = try await nativeCarbsEntries()
+        // Old JSON path: `id` was encoded under "id" but decoded from "_id", so this was always nil.
+        // The native mapping carries the real Core Data id instead.
+        #expect(native.first?.id == uuid(7).uuidString, "native mapping must carry the Core Data id")
+    }
+
+    // MARK: - Additional (synthetic) carbs entry
+
+    @Test("The synthetic additional-carbs entry matches the old spliced dictionary") func testAdditionalCarbsEntry() {
+        let date = Date(timeIntervalSince1970: 1_700_000_000)
+        let id = uuid(99).uuidString
+        let entry = BaseCarbsStorage.additionalCarbsEntry(carbs: 15, date: date, id: id)
+
+        // Old path spliced a dictionary that decoded to: id=nil (encoded "id", decoded "_id"),
+        // note=nil, fat=0, protein=0, isFPU=false, enteredBy="Trio", both dates == the passed date.
+        // The only intended difference is `id`, which we now carry (see `additionalCarbsEntry`).
+        let expected = CarbsEntry(
+            id: id,
+            createdAt: date,
+            actualDate: date,
+            carbs: 15,
+            fat: 0,
+            protein: 0,
+            note: nil,
+            enteredBy: CarbsEntry.local,
+            isFPU: false,
+            fpuID: nil
+        )
+        expectFieldsEqual(entry, expected, entry: 0)
+    }
+
+    @Test("A zero additional-carbs entry (the normal loop case) is well formed") func testAdditionalCarbsZero() {
+        // In the normal determine-basal loop `additionalCarbs` is `simulatedCarbsAmount ?? 0`, so a
+        // carbs=0 entry is always appended. MealHistory/AutosensGenerator drop carbs <= 0, so it is
+        // inert, but we still reproduce the old shape exactly.
+        let date = Date(timeIntervalSince1970: 1_700_000_000)
+        let entry = BaseCarbsStorage.additionalCarbsEntry(carbs: 0, date: date, id: uuid(1).uuidString)
+        #expect(entry.carbs == 0)
+        #expect(entry.isFPU == false)
+        #expect(entry.enteredBy == CarbsEntry.local)
+    }
+
+    // MARK: - Comparison helpers
+
+    /// Asserts the native mapping reproduces the frozen golden `CarbsEntry` values.
+    private func assertNativeMatchesGolden(_ golden: [CarbsEntry]) async throws {
+        let native = try await nativeCarbsEntries()
+
+        #expect(native.count == golden.count, "native produced \(native.count) entries, golden has \(golden.count)")
+
+        for (index, pair) in zip(native, golden).enumerated() {
+            expectFieldsEqual(pair.0, pair.1, entry: index)
+        }
+    }
+
+    /// Field-by-field comparison
+    private func expectFieldsEqual(_ actual: CarbsEntry, _ expected: CarbsEntry, entry index: Int) {
+        #expect(actual.id == expected.id, "entry \(index): id \(actual.id ?? "nil") != \(expected.id ?? "nil")")
+        #expect(
+            Self.millisecondString(actual.createdAt) == Self.millisecondString(expected.createdAt),
+            "entry \(index): createdAt \(Self.millisecondString(actual.createdAt)) != \(Self.millisecondString(expected.createdAt))"
+        )
+        #expect(
+            actual.actualDate.map(Self.millisecondString) == expected.actualDate.map(Self.millisecondString),
+            "entry \(index): actualDate mismatch"
+        )
+        #expect(actual.carbs == expected.carbs, "entry \(index): carbs \(actual.carbs) != \(expected.carbs)")
+        #expect(
+            actual.fat == expected.fat,
+            "entry \(index): fat \(String(describing: actual.fat)) != \(String(describing: expected.fat))"
+        )
+        #expect(
+            actual.protein == expected.protein,
+            "entry \(index): protein \(String(describing: actual.protein)) != \(String(describing: expected.protein))"
+        )
+        #expect(actual.note == expected.note, "entry \(index): note \(actual.note ?? "nil") != \(expected.note ?? "nil")")
+        #expect(
+            actual.enteredBy == expected.enteredBy,
+            "entry \(index): enteredBy \(actual.enteredBy ?? "nil") != \(expected.enteredBy ?? "nil")"
+        )
+        #expect(
+            actual.isFPU == expected.isFPU,
+            "entry \(index): isFPU \(String(describing: actual.isFPU)) != \(String(describing: expected.isFPU))"
+        )
+        #expect(actual.fpuID == expected.fpuID, "entry \(index): fpuID \(actual.fpuID ?? "nil") != \(expected.fpuID ?? "nil")")
+    }
+
+    private static func millisecondString(_ date: Date) -> String {
+        Formatter.iso8601withFractionalSeconds.string(from: date)
+    }
+
+    private func nativeCarbsEntries() async throws -> [CarbsEntry] {
+        try await testContext.perform {
+            try self.fetchRowsNewestFirst().map { BaseCarbsStorage.mapToCarbsEntry($0) }
+        }
+    }
+
+    /// Must be called from within `testContext.perform`. Mirrors the `date`-descending order the
+    /// production fetch uses (`BaseCarbsStorage.getCarbsForAlgorithm`).
+    private func fetchRowsNewestFirst() throws -> [CarbEntryStored] {
+        let request = CarbEntryStored.fetchRequest()
+        request.sortDescriptors = [NSSortDescriptor(key: "date", ascending: false)]
+        return try testContext.fetch(request)
+    }
+
+    // MARK: - Fixture helpers
+
+    private func insertCarb(
+        carbs: Double,
+        isFPU: Bool,
+        date: Date,
+        fat: Double = 0,
+        protein: Double = 0,
+        note: String? = nil,
+        id: UUID?,
+        fpuID: UUID? = nil
+    ) async {
+        await testContext.perform {
+            let object = CarbEntryStored(context: self.testContext)
+            object.carbs = carbs
+            object.isFPU = isFPU
+            object.date = date
+            object.fat = fat
+            object.protein = protein
+            object.note = note
+            object.id = id
+            object.fpuID = fpuID
+            try! self.testContext.save()
+        }
+    }
+
+    /// A fixed base timestamp (2023-11-14T22:13:20Z) so fixtures are deterministic and reproducible.
+    private func fixedDate(minutesAgo: Double, plusSeconds: Double = 0) -> Date {
+        Date(timeIntervalSince1970: 1_700_000_000 + plusSeconds - minutesAgo * 60)
+    }
+
+    private func uuid(_ n: Int) -> UUID {
+        UUID(uuidString: String(format: "00000000-0000-0000-0000-%012d", n))!
+    }
+}