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

WIP tests ported from JS to Swift

Sam King 1 год назад
Родитель
Сommit
7c4a568970

+ 17 - 4
Trio.xcodeproj/project.pbxproj

@@ -290,8 +290,6 @@
 		3BC0AA412DA8B900000DF7B7 /* iob-history-prepare.js in Resources */ = {isa = PBXBuildFile; fileRef = 3BC0AA402DA8B8F7000DF7B7 /* iob-history-prepare.js */; };
 		3BC26E552D7418830066ACD6 /* IobSuspendTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3BC26E542D7418830066ACD6 /* IobSuspendTests.swift */; };
 		3BC4053B2D931620006A03E9 /* IobJsonTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3BC4053A2D931620006A03E9 /* IobJsonTests.swift */; };
-		3BAD36B22D7CDC1A00CC298D /* MainLoadingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3BAD36B12D7CDC1400CC298D /* MainLoadingView.swift */; };
-		3BAD36CC2D7D420E00CC298D /* CoreDataInitializationCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3BAD36CB2D7D420500CC298D /* CoreDataInitializationCoordinator.swift */; };
 		3BCE75B32D4B38AE009E9453 /* InsulinSensitivities+Convert.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3BCE75B22D4B38A0009E9453 /* InsulinSensitivities+Convert.swift */; };
 		3BCE75B52D4B391F009E9453 /* Decimal+rounding.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3BCE75B42D4B3917009E9453 /* Decimal+rounding.swift */; };
 		3BCA5F7C2DC7B16400A7EAC7 /* pumphistory-with-external.json in Resources */ = {isa = PBXBuildFile; fileRef = 3BCA5F7B2DC7B15400A7EAC7 /* pumphistory-with-external.json */; };
@@ -302,6 +300,10 @@
 		3BEA3AE12D58F79700A67A1D /* AlgorithmComparison.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3BEA3ADB2D58F79700A67A1D /* AlgorithmComparison.swift */; };
 		3BEA3AE22D58F79700A67A1D /* JsSwiftOrefComparisonLogger.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3BEA3ADD2D58F79700A67A1D /* JsSwiftOrefComparisonLogger.swift */; };
 		3BEA3AE32D58F79700A67A1D /* JSONCompare.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3BEA3ADC2D58F79700A67A1D /* JSONCompare.swift */; };
+		3BEF6AB12D9731660076089D /* MealHistoryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3BEF6AB02D9731530076089D /* MealHistoryTests.swift */; };
+		3BEF6AB32D97316F0076089D /* MealTotalTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3BEF6AB22D97316A0076089D /* MealTotalTests.swift */; };
+		3BEF6AB52D9750330076089D /* meal-input-sim.json in Resources */ = {isa = PBXBuildFile; fileRef = 3BEF6AB42D9750330076089D /* meal-input-sim.json */; };
+		3BEF6AB72D9750780076089D /* MealJsonTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3BEF6AB62D9750710076089D /* MealJsonTests.swift */; };
 		3BF8D0C12D5175BE001B3F84 /* ProfileJsNativeCompareTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3BF8D0C02D5175B3001B3F84 /* ProfileJsNativeCompareTests.swift */; };
 		3BF8D14B2D530397001B3F84 /* JSONCompareTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3BF8D14A2D530397001B3F84 /* JSONCompareTests.swift */; };
 		3BF92F2D2D86DEE9006B545A /* autosens.js in Resources */ = {isa = PBXBuildFile; fileRef = 3BF92F212D86DEE9006B545A /* autosens.js */; };
@@ -1166,8 +1168,6 @@
 		3BC0AA402DA8B8F7000DF7B7 /* iob-history-prepare.js */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.javascript; path = "iob-history-prepare.js"; sourceTree = "<group>"; };
 		3BC26E542D7418830066ACD6 /* IobSuspendTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IobSuspendTests.swift; sourceTree = "<group>"; };
 		3BC4053A2D931620006A03E9 /* IobJsonTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IobJsonTests.swift; sourceTree = "<group>"; };
-		3BAD36B12D7CDC1400CC298D /* MainLoadingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainLoadingView.swift; sourceTree = "<group>"; };
-		3BAD36CB2D7D420500CC298D /* CoreDataInitializationCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoreDataInitializationCoordinator.swift; sourceTree = "<group>"; };
 		3BCE75B22D4B38A0009E9453 /* InsulinSensitivities+Convert.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "InsulinSensitivities+Convert.swift"; sourceTree = "<group>"; };
 		3BCE75B42D4B3917009E9453 /* Decimal+rounding.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Decimal+rounding.swift"; sourceTree = "<group>"; };
 		3BCA5F7B2DC7B15400A7EAC7 /* pumphistory-with-external.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "pumphistory-with-external.json"; sourceTree = "<group>"; };
@@ -1177,6 +1177,10 @@
 		3BEA3ADC2D58F79700A67A1D /* JSONCompare.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JSONCompare.swift; sourceTree = "<group>"; };
 		3BEA3ADD2D58F79700A67A1D /* JsSwiftOrefComparisonLogger.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JsSwiftOrefComparisonLogger.swift; sourceTree = "<group>"; };
 		3BEA3ADE2D58F79700A67A1D /* OrefFunction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OrefFunction.swift; sourceTree = "<group>"; };
+		3BEF6AB02D9731530076089D /* MealHistoryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MealHistoryTests.swift; sourceTree = "<group>"; };
+		3BEF6AB22D97316A0076089D /* MealTotalTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MealTotalTests.swift; sourceTree = "<group>"; };
+		3BEF6AB42D9750330076089D /* meal-input-sim.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "meal-input-sim.json"; sourceTree = "<group>"; };
+		3BEF6AB62D9750710076089D /* MealJsonTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MealJsonTests.swift; sourceTree = "<group>"; };
 		3BF768BD6264FF7D71D66767 /* NightscoutConfigProvider.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = NightscoutConfigProvider.swift; sourceTree = "<group>"; };
 		3BF8D0C02D5175B3001B3F84 /* ProfileJsNativeCompareTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileJsNativeCompareTests.swift; sourceTree = "<group>"; };
 		3BF8D14A2D530397001B3F84 /* JSONCompareTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JSONCompareTests.swift; sourceTree = "<group>"; };
@@ -2727,6 +2731,7 @@
 			isa = PBXGroup;
 			children = (
 				3BF92F392D86F1AA006B545A /* iob-error-log.json */,
+				3BEF6AB42D9750330076089D /* meal-input-sim.json */,
 			);
 			path = json;
 			sourceTree = "<group>";
@@ -2817,6 +2822,9 @@
 				3BC26E542D7418830066ACD6 /* IobSuspendTests.swift */,
 				3B1C5C382D68E269004E9273 /* IobTotalTests.swift */,
 				3BF8D14A2D530397001B3F84 /* JSONCompareTests.swift */,
+				3BEF6AB02D9731530076089D /* MealHistoryTests.swift */,
+				3BEF6AB62D9750710076089D /* MealJsonTests.swift */,
+				3BEF6AB22D97316A0076089D /* MealTotalTests.swift */,
 				3BF8D0C02D5175B3001B3F84 /* ProfileJsNativeCompareTests.swift */,
 				3B5CD2C12D4AECD500CE213C /* ProfileBasalTests.swift */,
 				3B5CD2C32D4AECD500CE213C /* ProfileCarbsTests.swift */,
@@ -4256,6 +4264,8 @@
 				3BF92F312D86DEE9006B545A /* meal.js in Resources */,
 				3BF92F322D86DEE9006B545A /* glucose-get-last.js in Resources */,
 				3BF92F332D86DEE9006B545A /* iob.js in Resources */,
+				3BEF6AB52D9750330076089D /* meal-input-sim.json in Resources */,
+				3BF92F342D86DEE9006B545A /* iob-history.js in Resources */,
 				3BF92F352D86DEE9006B545A /* basal-set-temp.js in Resources */,
 				3BF92F362D86DEE9006B545A /* autotune-core.js in Resources */,
 				3BC0AA3B2DA74C87000DF7B7 /* iob-total.js in Resources */,
@@ -4997,7 +5007,9 @@
 				3BFA5BF92D989F510072B082 /* MockTDDStorage.swift in Sources */,
 				CEE9A65E2BBC9F6500EB5194 /* CalibrationsTests.swift in Sources */,
 				3BF8D14B2D530397001B3F84 /* JSONCompareTests.swift in Sources */,
+				3BEF6AB32D97316F0076089D /* MealTotalTests.swift in Sources */,
 				3BF8D0C12D5175BE001B3F84 /* ProfileJsNativeCompareTests.swift in Sources */,
+				3BEF6AB12D9731660076089D /* MealHistoryTests.swift in Sources */,
 				BD8FC0572D66188700B95AED /* PumpHistoryStorageTests.swift in Sources */,
 				BD8FC0642D6619EF00B95AED /* TempTargetStorageTests.swift in Sources */,
 				BD8FC0542D66186000B95AED /* TestError.swift in Sources */,
@@ -5017,6 +5029,7 @@
 				3B1C5C472D68E269004E9273 /* IobCalculateTests.swift in Sources */,
 				3B1C5C482D68E269004E9273 /* IobHistoryTests.swift in Sources */,
 				3BC4053B2D931620006A03E9 /* IobJsonTests.swift in Sources */,
+				3BEF6AB72D9750780076089D /* MealJsonTests.swift in Sources */,
 				38FCF3F925E902C20078B0D1 /* FileStorageTests.swift in Sources */,
 				BD8FC0602D6619DB00B95AED /* CarbsStorageTests.swift in Sources */,
 				BD8FC05E2D6618CE00B95AED /* BolusCalculatorTests.swift in Sources */,

+ 1 - 1
Trio/Sources/APS/OpenAPSSwift/Meal/MealTotal.swift

@@ -1,6 +1,6 @@
 import Foundation
 
-struct ComputedCarbs {
+struct ComputedCarbs: Codable {
     var carbs: Decimal
     var nsCarbs: Decimal
     var bwCarbs: Decimal

+ 150 - 0
TrioTests/OpenAPSSwiftTests/MealHistoryTests.swift

@@ -0,0 +1,150 @@
+import Foundation
+import Testing
+@testable import Trio
+
+@Suite("MealHistory Tests") struct MealHistoryTests {
+    @Test("should process carbs from carbHistory") func processCarbsFromCarbHistory() async {
+        let carbHistory = [
+            CarbsEntry.forTest(
+                createdAt: Date.from(isoString: "2016-06-19T12:00:00-04:00"),
+                carbs: 20
+            )
+        ]
+
+        let output = MealHistory.findMealInputs(
+            pumpHistory: [],
+            carbHistory: carbHistory
+        )
+
+        #expect(output.count == 1)
+        #expect(output[0].carbs == 20)
+        #expect(output[0].nsCarbs == 20)
+        #expect(output[0].timestamp == Date.from(isoString: "2016-06-19T12:00:00-04:00"))
+    }
+
+    @Test("should process bolus events from pumpHistory") func processBolusEventsFromPumpHistory() async {
+        let pumpHistory = [
+            PumpHistoryEvent(
+                id: UUID().uuidString,
+                type: .bolus,
+                timestamp: Date.from(isoString: "2016-06-19T12:00:00-04:00"),
+                amount: 2.5
+            )
+        ]
+
+        let output = MealHistory.findMealInputs(
+            pumpHistory: pumpHistory,
+            carbHistory: []
+        )
+
+        #expect(output.count == 1)
+        #expect(output[0].bolus == 2.5)
+        #expect(output[0].timestamp == Date.from(isoString: "2016-06-19T12:00:00-04:00"))
+    }
+
+    @Test("should handle both carbs and bolus entries") func handleBothCarbsAndBolusEntries() async {
+        let pumpHistory = [
+            PumpHistoryEvent(
+                id: UUID().uuidString,
+                type: .bolus,
+                timestamp: Date.from(isoString: "2016-06-19T12:00:00-04:00"),
+                amount: 2.5
+            )
+        ]
+
+        let carbHistory = [
+            CarbsEntry.forTest(
+                createdAt: Date.from(isoString: "2016-06-19T12:30:00-04:00"),
+                carbs: 20
+            )
+        ]
+
+        let output = MealHistory.findMealInputs(
+            pumpHistory: pumpHistory,
+            carbHistory: carbHistory
+        )
+
+        #expect(output.count == 2)
+
+        // Find the carb entry
+        let carbEntry = output.first { $0.carbs != nil }
+        #expect(carbEntry != nil)
+        #expect(carbEntry?.carbs == 20)
+
+        // Find the bolus entry
+        let bolusEntry = output.first { $0.bolus != nil }
+        #expect(bolusEntry != nil)
+        #expect(bolusEntry?.bolus == 2.5)
+    }
+
+    @Test("should dedupe carb entries with same timestamp") func dedupeCarbs() async {
+        let carbHistory = [
+            CarbsEntry.forTest(
+                createdAt: Date.from(isoString: "2016-06-19T12:00:00-04:00"),
+                carbs: 20
+            ),
+            CarbsEntry.forTest(
+                createdAt: Date.from(isoString: "2016-06-19T12:00:00-04:00"),
+                carbs: 30
+            )
+        ]
+
+        let output = MealHistory.findMealInputs(
+            pumpHistory: [],
+            carbHistory: carbHistory
+        )
+
+        #expect(output.count == 1)
+        #expect(output[0].carbs == 20)
+    }
+
+    @Test("should dedupe bolus entries with same timestamp") func dedupeBolusEntries() async {
+        let pumpHistory = [
+            PumpHistoryEvent(
+                id: UUID().uuidString,
+                type: .bolus,
+                timestamp: Date.from(isoString: "2016-06-19T12:00:00-04:00"),
+                amount: 2.5
+            ),
+            PumpHistoryEvent(
+                id: UUID().uuidString,
+                type: .bolus,
+                timestamp: Date.from(isoString: "2016-06-19T12:00:00-04:00"),
+                amount: 3.0
+            )
+        ]
+
+        let output = MealHistory.findMealInputs(
+            pumpHistory: pumpHistory,
+            carbHistory: []
+        )
+
+        #expect(output.count == 1)
+        #expect(output[0].bolus == 2.5)
+    }
+
+    @Test("should consider timestamps within 2 seconds as duplicates") func timestampNearlyDuplicates() async {
+        let pumpHistory = [
+            PumpHistoryEvent(
+                id: UUID().uuidString,
+                type: .bolus,
+                timestamp: Date.from(isoString: "2016-06-19T12:00:00-04:00"),
+                amount: 2.5
+            ),
+            PumpHistoryEvent(
+                id: UUID().uuidString,
+                type: .bolus,
+                timestamp: Date.from(isoString: "2016-06-19T12:00:01-04:00"),
+                amount: 3.0
+            )
+        ]
+
+        let output = MealHistory.findMealInputs(
+            pumpHistory: pumpHistory,
+            carbHistory: []
+        )
+
+        #expect(output.count == 1)
+        #expect(output[0].bolus == 2.5)
+    }
+}

+ 41 - 0
TrioTests/OpenAPSSwiftTests/MealJsonTests.swift

@@ -0,0 +1,41 @@
+import Foundation
+import Testing
+@testable import Trio
+
+@Suite("Testing meal using JSON inputs") struct MealJsonTests {
+    @Test("Test against simulator inputs") func simulatorInputs() throws {
+        let testBundle = Bundle(for: BundleReference.self)
+        let path = testBundle.path(forResource: "meal-input-sim", ofType: "json")!
+        let data = try Data(contentsOf: URL(fileURLWithPath: path))
+
+        // this file stores an object with JSON encoded strings (so double encoded)
+        let jsonInputs = try JSONSerialization.jsonObject(with: data) as! [String: Any]
+
+        let pumpHistory = try JSONBridge.pumpHistory(from: jsonInputs["pumpHistory"] as! String)
+        let profile = try JSONBridge.profile(from: jsonInputs["profile"] as! String)
+        let basalProfile = try JSONBridge.basalProfile(from: jsonInputs["basalProfile"] as! String)
+        let clock = try JSONBridge.clock(from: jsonInputs["clock"] as! String)
+
+        let decoder = JSONCoding.decoder
+        var jsonData = (jsonInputs["carbs"] as! String).data(using: .utf8)!
+        let carbHistory: [CarbsEntry] = try decoder.decode([CarbsEntry].self, from: jsonData)
+
+        jsonData = (jsonInputs["glucose"] as! String).data(using: .utf8)!
+        let glucoseHistory: [BloodGlucose] = try decoder.decode([BloodGlucose].self, from: jsonData)
+
+        jsonData = (jsonInputs["meal"] as! String).data(using: .utf8)!
+        let mealResultFromJs = try decoder.decode(ComputedCarbs.self, from: jsonData)
+
+        let mealResult = MealGeneratorError.generate(
+            pumpHistory: pumpHistory,
+            profile: profile,
+            basalProfile: basalProfile,
+            clock: clock,
+            carbHistory: carbHistory,
+            glucoseHistory: glucoseHistory
+        )
+
+        // we need something like this
+        // #expect(mealResult == mealResultFromJs)
+    }
+}

+ 343 - 0
TrioTests/OpenAPSSwiftTests/MealTotalTests.swift

@@ -0,0 +1,343 @@
+import Foundation
+import Testing
+@testable import Trio
+
+@Suite("MealTotal Tests") struct MealTotalTests {
+    // Helper methods for testing
+    func createBasicProfile() -> Profile {
+        var profile = Profile()
+        profile.dia = 4
+        profile.maxMealAbsorptionTime = 6
+        profile.maxCOB = 120
+        // profile.carbsAbsorptionRate = 30
+        profile.min5mCarbImpact = 3
+        profile.carbRatio = 10
+        profile.currentBasal = 1.0
+        // Note: In Swift we need to set sensitivities differently than in JS
+        profile
+            .isfProfile = ComputedInsulinSensitivities(
+                units: .mgdL,
+                userPreferredUnits: .mgdL,
+                sensitivities: [ComputedInsulinSensitivityEntry(sensitivity: 40, offset: 0, start: "00:00:00")]
+            )
+        return profile
+    }
+
+    func createBasicBasalProfile() -> [BasalProfileEntry] {
+        [BasalProfileEntry(start: "00:00:00", minutes: 0, rate: 1.0)]
+    }
+
+    func createGlucoseData(baseTime: Date, pattern: [Int]) -> [BloodGlucose] {
+        var result: [BloodGlucose] = []
+
+        for (i, bg) in pattern.enumerated() {
+            let timestamp = baseTime.addingTimeInterval(TimeInterval(i * 5 * 60))
+
+            result.append(BloodGlucose(
+                sgv: bg,
+                date: Decimal(timestamp.timeIntervalSince1970 * 1000), // JS uses ms
+                dateString: timestamp
+            ))
+        }
+
+        return result.reversed()
+    }
+
+    @Test("should calculate carb absorption correctly") func calculateCarbAbsorption() async {
+        let baseTime = Date.from(isoString: "2016-06-19T12:00:00-04:00")
+        let mealTime = Date.from(isoString: "2016-06-19T12:00:00-04:00")
+        let testTime = Date.from(isoString: "2016-06-19T13:00:00-04:00") // 1 hour after meal
+
+        // Create glucose data showing rise after carbs
+        var bgValues = Array(repeating: 100, count: 13)
+        for i in 3 ..< 8 {
+            bgValues[i] = 100 + ((i - 2) * 10) // 100, 110, 120, 130, 140
+        }
+        for i in 8 ..< 13 {
+            bgValues[i] = 150 // plateau
+        }
+
+        let glucoseData = createGlucoseData(baseTime: baseTime, pattern: bgValues)
+
+        // Create insulin data - bolus at same time as carbs
+        let pumpHistory = [
+            PumpHistoryEvent(
+                id: UUID().uuidString,
+                type: .bolus,
+                timestamp: mealTime,
+                amount: 3.0
+            )
+        ]
+
+        // Carb treatment
+        let treatments = [
+            MealInput(
+                timestamp: mealTime,
+                carbs: 30,
+                nsCarbs: 30,
+                bolus: nil,
+                journalCarbs: nil,
+                bwCarbs: nil
+            ),
+            MealInput(
+                timestamp: mealTime,
+                carbs: nil,
+                nsCarbs: nil,
+                bolus: 3,
+                journalCarbs: nil,
+                bwCarbs: nil
+            )
+        ]
+
+        let profile = createBasicProfile()
+        let basalProfile = createBasicBasalProfile()
+
+        let result = MealTotal.recentCarbs(
+            treatments: treatments,
+            pumpHistory: pumpHistory,
+            profile: profile,
+            basalProfile: basalProfile,
+            glucose: glucoseData,
+            time: testTime
+        )
+
+        // After 1 hour, we should see partial carb absorption
+        #expect(result != nil)
+        #expect(result?.mealCOB == 12)
+        #expect(result?.currentDeviation.isWithin(0.1, of: 3) == true)
+    }
+
+    @Test("should return nil when no treatments provided") func emptyObjectWhenNoTreatments() async {
+        let time = Date.from(isoString: "2016-06-19T13:00:00-04:00")
+        let glucoseData = [
+            BloodGlucose(
+                sgv: 100,
+                date: Decimal(time.timeIntervalSince1970 * 1000),
+                dateString: time
+            )
+        ]
+
+        let profile = createBasicProfile()
+        let basalProfile = createBasicBasalProfile()
+
+        let result = MealTotal.recentCarbs(
+            treatments: [],
+            pumpHistory: [],
+            profile: profile,
+            basalProfile: basalProfile,
+            glucose: glucoseData,
+            time: time
+        )
+
+        #expect(result == nil)
+    }
+
+    @Test("should calculate carbs correctly for treatments within the meal window") func calcCarbsWithinMealWindow() async {
+        let baseTime = Date.from(isoString: "2016-06-19T12:00:00-04:00")
+        let testTime = Date.from(isoString: "2016-06-19T13:00:00-04:00")
+
+        let treatments = [
+            MealInput(
+                timestamp: baseTime,
+                carbs: 20,
+                nsCarbs: 20,
+                bolus: nil,
+                journalCarbs: nil,
+                bwCarbs: nil
+            )
+        ]
+
+        // Create glucose pattern with slight rise
+        let glucoseData = [
+            BloodGlucose(
+                sgv: 110,
+                date: Decimal(baseTime.addingTimeInterval(60 * 60).timeIntervalSince1970 * 1000),
+                dateString: baseTime.addingTimeInterval(60 * 60)
+            ),
+            BloodGlucose(
+                sgv: 105,
+                date: Decimal(baseTime.addingTimeInterval(30 * 60).timeIntervalSince1970 * 1000),
+                dateString: baseTime.addingTimeInterval(30 * 60)
+            ),
+            BloodGlucose(
+                sgv: 100,
+                date: Decimal(baseTime.timeIntervalSince1970 * 1000),
+                dateString: baseTime
+            )
+        ]
+
+        let profile = createBasicProfile()
+        let basalProfile = createBasicBasalProfile()
+
+        let result = MealTotal.recentCarbs(
+            treatments: treatments,
+            pumpHistory: [],
+            profile: profile,
+            basalProfile: basalProfile,
+            glucose: glucoseData,
+            time: testTime
+        )
+
+        #expect(result != nil)
+        #expect(result?.carbs == 20)
+        #expect(result?.nsCarbs == 20)
+        #expect(result?.currentDeviation.isWithin(0.1, of: 0.67) == true)
+        #expect(result?.mealCOB == 14)
+    }
+
+    @Test("should ignore treatments outside the meal window") func ignoreTreatmentsOutsideMealWindow() async {
+        let baseTime = Date.from(isoString: "2016-06-19T12:00:00-04:00")
+        let treatmentTime = Date.from(isoString: "2016-06-19T06:00:00-04:00") // 6 hours before
+        let testTime = Date.from(isoString: "2016-06-19T13:00:00-04:00")
+
+        let treatments = [
+            MealInput(
+                timestamp: treatmentTime,
+                carbs: 20,
+                nsCarbs: 20,
+                bolus: nil,
+                journalCarbs: nil,
+                bwCarbs: nil
+            )
+        ]
+
+        // Create glucose pattern with slight rise
+        let glucoseData = [
+            BloodGlucose(
+                sgv: 110,
+                date: Decimal(baseTime.addingTimeInterval(60 * 60).timeIntervalSince1970 * 1000),
+                dateString: baseTime.addingTimeInterval(60 * 60)
+            ),
+            BloodGlucose(
+                sgv: 105,
+                date: Decimal(baseTime.addingTimeInterval(30 * 60).timeIntervalSince1970 * 1000),
+                dateString: baseTime.addingTimeInterval(30 * 60)
+            ),
+            BloodGlucose(
+                sgv: 100,
+                date: Decimal(baseTime.timeIntervalSince1970 * 1000),
+                dateString: baseTime
+            )
+        ]
+
+        let profile = createBasicProfile()
+        let basalProfile = createBasicBasalProfile()
+
+        let result = MealTotal.recentCarbs(
+            treatments: treatments,
+            pumpHistory: [],
+            profile: profile,
+            basalProfile: basalProfile,
+            glucose: glucoseData,
+            time: testTime
+        )
+
+        #expect(result != nil)
+        #expect(result?.carbs == 0)
+        #expect(result?.mealCOB == 0)
+        #expect(result?.currentDeviation.isWithin(0.1, of: 0.67) == true)
+    }
+
+    @Test("should respect maxMealAbsorptionTime from profile") func respectMaxMealAbsorptionTime() async {
+        let baseTime = Date.from(isoString: "2016-06-19T12:00:00-04:00")
+        let treatmentTime = Date.from(isoString: "2016-06-19T10:00:00-04:00") // 2 hours before
+        let testTime = Date.from(isoString: "2016-06-19T13:00:00-04:00")
+
+        let treatments = [
+            MealInput(
+                timestamp: treatmentTime,
+                carbs: 20,
+                nsCarbs: 20,
+                bolus: nil,
+                journalCarbs: nil,
+                bwCarbs: nil
+            )
+        ]
+
+        // Create glucose pattern with slight rise
+        let glucoseData = [
+            BloodGlucose(
+                sgv: 110,
+                date: Decimal(baseTime.addingTimeInterval(60 * 60).timeIntervalSince1970 * 1000),
+                dateString: baseTime.addingTimeInterval(60 * 60)
+            ),
+            BloodGlucose(
+                sgv: 105,
+                date: Decimal(baseTime.addingTimeInterval(30 * 60).timeIntervalSince1970 * 1000),
+                dateString: baseTime.addingTimeInterval(30 * 60)
+            ),
+            BloodGlucose(
+                sgv: 100,
+                date: Decimal(baseTime.timeIntervalSince1970 * 1000),
+                dateString: baseTime
+            )
+        ]
+
+        var profile = createBasicProfile()
+        profile.maxMealAbsorptionTime = 2 // 2 hour window
+        let basalProfile = createBasicBasalProfile()
+
+        let result = MealTotal.recentCarbs(
+            treatments: treatments,
+            pumpHistory: [],
+            profile: profile,
+            basalProfile: basalProfile,
+            glucose: glucoseData,
+            time: testTime
+        )
+
+        #expect(result != nil)
+        #expect(result?.carbs == 0)
+        #expect(result?.mealCOB == 0)
+    }
+
+    @Test("should respect maxCOB from profile") func respectMaxCOB() async {
+        let baseTime = Date.from(isoString: "2016-06-19T12:00:00-04:00")
+        let testTime = Date.from(isoString: "2016-06-19T13:00:00-04:00")
+
+        let treatments = [
+            MealInput(
+                timestamp: baseTime,
+                carbs: 200,
+                nsCarbs: 200,
+                bolus: nil,
+                journalCarbs: nil,
+                bwCarbs: nil
+            )
+        ]
+
+        // Create glucose pattern with slight rise
+        let glucoseData = [
+            BloodGlucose(
+                sgv: 110,
+                date: Decimal(baseTime.addingTimeInterval(60 * 60).timeIntervalSince1970 * 1000),
+                dateString: baseTime.addingTimeInterval(60 * 60)
+            ),
+            BloodGlucose(
+                sgv: 105,
+                date: Decimal(baseTime.addingTimeInterval(30 * 60).timeIntervalSince1970 * 1000),
+                dateString: baseTime.addingTimeInterval(30 * 60)
+            ),
+            BloodGlucose(
+                sgv: 100,
+                date: Decimal(baseTime.timeIntervalSince1970 * 1000),
+                dateString: baseTime
+            )
+        ]
+
+        let profile = createBasicProfile()
+        let basalProfile = createBasicBasalProfile()
+
+        let result = MealTotal.recentCarbs(
+            treatments: treatments,
+            pumpHistory: [],
+            profile: profile,
+            basalProfile: basalProfile,
+            glucose: glucoseData,
+            time: testTime
+        )
+
+        #expect(result != nil)
+        #expect(result!.mealCOB <= 120)
+    }
+}

Разница между файлами не показана из-за своего большого размера
+ 16 - 0
TrioTests/OpenAPSSwiftTests/json/meal-input-sim.json


+ 38 - 0
TrioTests/OpenAPSSwiftTests/utils/Extensions.swift

@@ -1,6 +1,44 @@
 import Foundation
 @testable import Trio
 
+// Helper extension for Date from ISO string
+extension Date {
+    static func from(isoString: String) -> Date {
+        let formatter = ISO8601DateFormatter()
+        formatter.formatOptions = [.withInternetDateTime, .withDashSeparatorInDate, .withColonSeparatorInTime, .withTimeZone]
+        return formatter.date(from: isoString)!
+    }
+
+    var iso8601String: String {
+        let formatter = ISO8601DateFormatter()
+        formatter.formatOptions = [.withInternetDateTime, .withDashSeparatorInDate, .withColonSeparatorInTime, .withTimeZone]
+        return formatter.string(from: self)
+    }
+}
+
+extension CarbsEntry {
+    static func forTest(createdAt: Date, carbs: Decimal) -> CarbsEntry {
+        CarbsEntry(
+            id: nil,
+            createdAt: createdAt,
+            actualDate: nil,
+            carbs: carbs,
+            fat: nil,
+            protein: nil,
+            note: nil,
+            enteredBy: nil,
+            isFPU: nil,
+            fpuID: nil
+        )
+    }
+}
+
+extension TimeInterval {
+    static func hours(_ hours: Double) -> TimeInterval {
+        hours * 60 * 60
+    }
+}
+
 extension [ComputedPumpHistoryEvent] {
     func netInsulin() -> Decimal { compactMap(\.insulin).reduce(0, +) }
 }