瀏覽代碼

Working on Bucketing tests

Sam King 11 月之前
父節點
當前提交
1b6944313b

+ 8 - 0
Trio.xcodeproj/project.pbxproj

@@ -296,6 +296,8 @@
 		3BD6CE262DC24CFD00FA0472 /* pumphistory-24h-zoned.json in Resources */ = {isa = PBXBuildFile; fileRef = 3BD6CE252DC24CFD00FA0472 /* pumphistory-24h-zoned.json */; };
 		3BD6CE262DC24CFD00FA0472 /* pumphistory-24h-zoned.json in Resources */ = {isa = PBXBuildFile; fileRef = 3BD6CE252DC24CFD00FA0472 /* pumphistory-24h-zoned.json */; };
 		3BD9687C2D8DDD4600899469 /* SlideButton in Frameworks */ = {isa = PBXBuildFile; productRef = 3BD9687B2D8DDD4600899469 /* SlideButton */; };
 		3BD9687C2D8DDD4600899469 /* SlideButton in Frameworks */ = {isa = PBXBuildFile; productRef = 3BD9687B2D8DDD4600899469 /* SlideButton */; };
 		3BD9687F2D8DDD8800899469 /* CryptoSwift in Frameworks */ = {isa = PBXBuildFile; productRef = 3BD9687E2D8DDD8800899469 /* CryptoSwift */; };
 		3BD9687F2D8DDD8800899469 /* CryptoSwift in Frameworks */ = {isa = PBXBuildFile; productRef = 3BD9687E2D8DDD8800899469 /* CryptoSwift */; };
+		3BE2F1E82E030E2F009E2900 /* MealCobTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3BE2F1E72E030E2F009E2900 /* MealCobTests.swift */; };
+		3BE2F1EA2E031951009E2900 /* MealCobBucketingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3BE2F1E92E031951009E2900 /* MealCobBucketingTests.swift */; };
 		3BEA3AE02D58F79700A67A1D /* OrefFunction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3BEA3ADE2D58F79700A67A1D /* OrefFunction.swift */; };
 		3BEA3AE02D58F79700A67A1D /* OrefFunction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3BEA3ADE2D58F79700A67A1D /* OrefFunction.swift */; };
 		3BEA3AE12D58F79700A67A1D /* AlgorithmComparison.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3BEA3ADB2D58F79700A67A1D /* AlgorithmComparison.swift */; };
 		3BEA3AE12D58F79700A67A1D /* AlgorithmComparison.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3BEA3ADB2D58F79700A67A1D /* AlgorithmComparison.swift */; };
 		3BEA3AE22D58F79700A67A1D /* JsSwiftOrefComparisonLogger.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3BEA3ADD2D58F79700A67A1D /* JsSwiftOrefComparisonLogger.swift */; };
 		3BEA3AE22D58F79700A67A1D /* JsSwiftOrefComparisonLogger.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3BEA3ADD2D58F79700A67A1D /* JsSwiftOrefComparisonLogger.swift */; };
@@ -1174,6 +1176,8 @@
 		3BCE75B42D4B3917009E9453 /* Decimal+rounding.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Decimal+rounding.swift"; sourceTree = "<group>"; };
 		3BCE75B42D4B3917009E9453 /* Decimal+rounding.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Decimal+rounding.swift"; sourceTree = "<group>"; };
 		3BD6CE252DC24CFD00FA0472 /* pumphistory-24h-zoned.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "pumphistory-24h-zoned.json"; sourceTree = "<group>"; };
 		3BD6CE252DC24CFD00FA0472 /* pumphistory-24h-zoned.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "pumphistory-24h-zoned.json"; sourceTree = "<group>"; };
 		3BDEA2DC60EDE0A3CA54DC73 /* TargetsEditorProvider.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TargetsEditorProvider.swift; sourceTree = "<group>"; };
 		3BDEA2DC60EDE0A3CA54DC73 /* TargetsEditorProvider.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TargetsEditorProvider.swift; sourceTree = "<group>"; };
+		3BE2F1E72E030E2F009E2900 /* MealCobTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MealCobTests.swift; sourceTree = "<group>"; };
+		3BE2F1E92E031951009E2900 /* MealCobBucketingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MealCobBucketingTests.swift; sourceTree = "<group>"; };
 		3BEA3ADB2D58F79700A67A1D /* AlgorithmComparison.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlgorithmComparison.swift; sourceTree = "<group>"; };
 		3BEA3ADB2D58F79700A67A1D /* AlgorithmComparison.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlgorithmComparison.swift; sourceTree = "<group>"; };
 		3BEA3ADC2D58F79700A67A1D /* JSONCompare.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JSONCompare.swift; sourceTree = "<group>"; };
 		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>"; };
 		3BEA3ADD2D58F79700A67A1D /* JsSwiftOrefComparisonLogger.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JsSwiftOrefComparisonLogger.swift; sourceTree = "<group>"; };
@@ -2821,6 +2825,8 @@
 				3BC26E542D7418830066ACD6 /* IobSuspendTests.swift */,
 				3BC26E542D7418830066ACD6 /* IobSuspendTests.swift */,
 				3B1C5C382D68E269004E9273 /* IobTotalTests.swift */,
 				3B1C5C382D68E269004E9273 /* IobTotalTests.swift */,
 				3BF8D14A2D530397001B3F84 /* JSONCompareTests.swift */,
 				3BF8D14A2D530397001B3F84 /* JSONCompareTests.swift */,
+				3BE2F1E92E031951009E2900 /* MealCobBucketingTests.swift */,
+				3BE2F1E72E030E2F009E2900 /* MealCobTests.swift */,
 				3BEF6AB02D9731530076089D /* MealHistoryTests.swift */,
 				3BEF6AB02D9731530076089D /* MealHistoryTests.swift */,
 				3BEF6AB62D9750710076089D /* MealJsonTests.swift */,
 				3BEF6AB62D9750710076089D /* MealJsonTests.swift */,
 				3BEF6AB22D97316A0076089D /* MealTotalTests.swift */,
 				3BEF6AB22D97316A0076089D /* MealTotalTests.swift */,
@@ -5012,6 +5018,7 @@
 				3BEF6AB12D9731660076089D /* MealHistoryTests.swift in Sources */,
 				3BEF6AB12D9731660076089D /* MealHistoryTests.swift in Sources */,
 				BD8FC0572D66188700B95AED /* PumpHistoryStorageTests.swift in Sources */,
 				BD8FC0572D66188700B95AED /* PumpHistoryStorageTests.swift in Sources */,
 				BD8FC0642D6619EF00B95AED /* TempTargetStorageTests.swift in Sources */,
 				BD8FC0642D6619EF00B95AED /* TempTargetStorageTests.swift in Sources */,
+				3BE2F1E82E030E2F009E2900 /* MealCobTests.swift in Sources */,
 				BD8FC0542D66186000B95AED /* TestError.swift in Sources */,
 				BD8FC0542D66186000B95AED /* TestError.swift in Sources */,
 				CEE9A65E2BBC9F6500EB5194 /* CalibrationsTests.swift in Sources */,
 				CEE9A65E2BBC9F6500EB5194 /* CalibrationsTests.swift in Sources */,
 				BD8FC0622D6619E600B95AED /* OverrideStorageTests.swift in Sources */,
 				BD8FC0622D6619E600B95AED /* OverrideStorageTests.swift in Sources */,
@@ -5026,6 +5033,7 @@
 				3BF92F382D86E10B006B545A /* OpenAPSFixed.swift in Sources */,
 				3BF92F382D86E10B006B545A /* OpenAPSFixed.swift in Sources */,
 				3B1C5C442D68E269004E9273 /* IobJsonTypes.swift in Sources */,
 				3B1C5C442D68E269004E9273 /* IobJsonTypes.swift in Sources */,
 				3B1C5C452D68E269004E9273 /* IobTotalTests.swift in Sources */,
 				3B1C5C452D68E269004E9273 /* IobTotalTests.swift in Sources */,
+				3BE2F1EA2E031951009E2900 /* MealCobBucketingTests.swift in Sources */,
 				3B1C5C472D68E269004E9273 /* IobCalculateTests.swift in Sources */,
 				3B1C5C472D68E269004E9273 /* IobCalculateTests.swift in Sources */,
 				3B1C5C482D68E269004E9273 /* IobHistoryTests.swift in Sources */,
 				3B1C5C482D68E269004E9273 /* IobHistoryTests.swift in Sources */,
 				3BC4053B2D931620006A03E9 /* IobJsonTests.swift in Sources */,
 				3BC4053B2D931620006A03E9 /* IobJsonTests.swift in Sources */,

+ 7 - 4
Trio/Sources/APS/OpenAPSSwift/Meal/MealCob.swift

@@ -55,7 +55,8 @@ struct MealCob {
     }
     }
 
 
     /// Groups glucose readings into time buckets with interpolation for missing data points
     /// Groups glucose readings into time buckets with interpolation for missing data points
-    private static func bucketGlucoseForCob(
+    /// Make this non-private to expose for test cases
+    static func bucketGlucoseForCob(
         glucose: [BloodGlucose],
         glucose: [BloodGlucose],
         profile: Profile,
         profile: Profile,
         mealDate: Date,
         mealDate: Date,
@@ -120,8 +121,10 @@ struct MealCob {
 
 
                 while remainingMinutes > 5 {
                 while remainingMinutes > 5 {
                     let previousBgTime = interpolationTime.addingTimeInterval(-5 * 60)
                     let previousBgTime = interpolationTime.addingTimeInterval(-5 * 60)
-                    let gapDelta = currentGlucose.glucose - lastBg
-                    let previousBg = interpolationBg + (5 / cappedElapsedMinutes * gapDelta)
+
+                    // Recalculate gapDelta using the current interpolationBg (like JS updates lastbg)
+                    let gapDelta = currentGlucose.glucose - interpolationBg
+                    let previousBg = interpolationBg + (5 / remainingMinutes * gapDelta)
 
 
                     bucketedData.append(BucketedGlucose(
                     bucketedData.append(BucketedGlucose(
                         glucose: previousBg.rounded(),
                         glucose: previousBg.rounded(),
@@ -129,7 +132,7 @@ struct MealCob {
                     ))
                     ))
 
 
                     remainingMinutes -= 5
                     remainingMinutes -= 5
-                    interpolationBg = previousBg
+                    interpolationBg = previousBg // Update reference point for next iteration
                     interpolationTime = previousBgTime
                     interpolationTime = previousBgTime
                 }
                 }
             } else if abs(elapsedMinutes) > 2 {
             } else if abs(elapsedMinutes) > 2 {

+ 254 - 0
TrioTests/OpenAPSSwiftTests/MealCobBucketingTests.swift

@@ -0,0 +1,254 @@
+import Foundation
+import Testing
+@testable import Trio
+
+@Suite("bucketGlucoseData") struct MealCobBucketingTests {
+    // Default test profile - matches JS exactly
+    func createDefaultProfile() -> Profile {
+        var profile = Profile()
+        profile.dia = 4
+        profile.maxMealAbsorptionTime = 6
+        profile.min5mCarbImpact = 3
+        profile.carbRatio = 10
+        return profile
+    }
+
+    // Helper to create glucose entry - matches JS structure
+    func createGlucoseEntry(glucose: Int, timeMs: Double) -> BloodGlucose {
+        let date = Date(timeIntervalSince1970: timeMs / 1000)
+        return BloodGlucose(
+            sgv: glucose,
+            date: Decimal(timeMs),
+            dateString: date,
+            glucose: glucose
+        )
+    }
+
+    // Note: glucose_data is expected in reverse chronological order (newest first)
+    // The bucketGlucoseData function maintains this order in its output
+
+    @Test(
+        "should handle normal 5-minute interval data without modification"
+    ) func shouldHandleNormal5MinuteIntervalDataWithoutModification() async throws {
+        let mealTime = Date.from(isoString: "2024-01-01T12:00:00-05:00")
+        let mealTimeMs = mealTime.timeIntervalSince1970 * 1000
+
+        // Create regular 5-minute interval data (chronological order)
+        var glucose_data = [
+            createGlucoseEntry(glucose: 100, timeMs: mealTimeMs),
+            createGlucoseEntry(glucose: 105, timeMs: mealTimeMs + 5 * 60 * 1000),
+            createGlucoseEntry(glucose: 110, timeMs: mealTimeMs + 10 * 60 * 1000),
+            createGlucoseEntry(glucose: 115, timeMs: mealTimeMs + 15 * 60 * 1000)
+        ]
+        glucose_data.reverse() // Convert to reverse chronological order
+
+        let result = try MealCob.bucketGlucoseForCob(
+            glucose: glucose_data,
+            profile: createDefaultProfile(),
+            mealDate: mealTime,
+            ciDate: nil
+        )
+
+        // Should return same number of entries
+        #expect(result.count == 4)
+        // Values should be unchanged (in reverse chronological order)
+        #expect(result[0].glucose == 115)
+        #expect(result[1].glucose == 110)
+        #expect(result[2].glucose == 105)
+        #expect(result[3].glucose == 100)
+    }
+
+    @Test("should interpolate missing data when gap > 8 minutes") func shouldInterpolateMissingDataWhenGapGreaterThan8Minutes(
+    ) async throws {
+        let mealTime = Date.from(isoString: "2024-01-01T12:00:00-05:00")
+        let mealTimeMs = mealTime.timeIntervalSince1970 * 1000
+
+        // Create data with a 21-minute gap (chronological order)
+        var glucose_data = [
+            createGlucoseEntry(glucose: 99, timeMs: mealTimeMs),
+            createGlucoseEntry(glucose: 120, timeMs: mealTimeMs + 21 * 60 * 1000) // 21 min gap
+        ]
+        glucose_data.reverse() // Convert to reverse chronological order
+
+        let result = try MealCob.bucketGlucoseForCob(
+            glucose: glucose_data,
+            profile: createDefaultProfile(),
+            mealDate: mealTime,
+            ciDate: nil
+        )
+
+        // Should have interpolated 4 additional points (5, 10, 15, 20 minutes)
+        #expect(result.count == 5)
+
+        // Check interpolated values (in reverse chronological order)
+        #expect(result[0].glucose == 120) // original (newest)
+        #expect(result[1].glucose == 115) // interpolated
+        #expect(result[2].glucose == 110) // interpolated
+        #expect(result[3].glucose == 105) // interpolated
+        #expect(result[4].glucose == 100) // interpolated
+
+        // Check that dates are properly set
+        #expect(result[1].date == mealTime.addingTimeInterval(16 * 60))
+        #expect(result[2].date == mealTime.addingTimeInterval(11 * 60))
+        #expect(result[3].date == mealTime.addingTimeInterval(6 * 60))
+        #expect(result[4].date == mealTime.addingTimeInterval(1 * 60))
+    }
+
+    @Test("should stop processing after maxMealAbsorptionTime") func shouldStopProcessingAfterMaxMealAbsorptionTime() async throws {
+        let mealTime = Date.from(isoString: "2024-01-01T12:00:00-05:00")
+        let mealTimeMs = mealTime.timeIntervalSince1970 * 1000
+
+        // Create data spanning 8 hours (chronological order)
+        var glucose_data: [BloodGlucose] = []
+        for i in 0 ... 96 { // 96 * 5 min = 8 hours
+            glucose_data.append(createGlucoseEntry(
+                glucose: 100 + i,
+                timeMs: mealTimeMs + Double(i) * 5 * 60 * 1000
+            ))
+        }
+        glucose_data.reverse() // Convert to reverse chronological order
+
+        // Set maxMealAbsorptionTime to 2 hours
+        var profile = createDefaultProfile()
+        profile.maxMealAbsorptionTime = 2
+
+        let result = try MealCob.bucketGlucoseForCob(
+            glucose: glucose_data,
+            profile: profile,
+            mealDate: mealTime,
+            ciDate: nil
+        )
+
+        // Should only process up to 2 hours of data (24 entries + 1 initial = 25)
+        // but it keeps the original time as the first entry of the
+        // bucket and interpolates, which is broken.
+        #expect(result.count == 72)
+
+        #expect(result[0].glucose == 196)
+        #expect(result[result.count - 1].glucose == 100)
+    }
+
+    @Test("should only process data within 45 minutes in CI mode") func shouldOnlyProcessDataWithin45MinutesInCIMode() async throws {
+        let mealTime = Date.from(isoString: "2024-01-01T12:00:00-05:00")
+        let ciTime = Date.from(isoString: "2024-01-01T14:00:00-05:00") // 2 hours after meal
+        let mealTimeMs = mealTime.timeIntervalSince1970 * 1000
+
+        // Create data spanning 3 hours (chronological order)
+        var glucose_data: [BloodGlucose] = []
+        for i in 0 ... 36 { // 36 * 5 min = 3 hours
+            glucose_data.append(createGlucoseEntry(
+                glucose: 100 + i,
+                timeMs: mealTimeMs + Double(i) * 5 * 60 * 1000
+            ))
+        }
+        glucose_data.reverse() // Convert to reverse chronological order
+
+        let result = try MealCob.bucketGlucoseForCob(
+            glucose: glucose_data,
+            profile: createDefaultProfile(),
+            mealDate: mealTime,
+            ciDate: ciTime
+        )
+
+        // Should only include data within 45 minutes of ciTime
+        // but it keeps the first bucket value and interpolates
+        for entry in result {
+            let minutesFromCI = abs(ciTime.timeIntervalSince(entry.date)) / 60
+            #expect(minutesFromCI <= 120)
+        }
+
+        #expect(result.count == 21)
+    }
+
+    @Test("should stop processing when pre-meal BG is found") func shouldStopProcessingWhenPreMealBGIsFound() async throws {
+        let mealTime = Date.from(isoString: "2024-01-01T12:00:00-05:00")
+        let mealTimeMs = mealTime.timeIntervalSince1970 * 1000
+
+        // Create data that includes pre-meal values (chronological order)
+        var glucose_data = [
+            createGlucoseEntry(glucose: 90, timeMs: mealTimeMs - 10 * 60 * 1000), // 30 min before meal
+            createGlucoseEntry(glucose: 95, timeMs: mealTimeMs - 5 * 60 * 1000), // 15 min before meal
+            createGlucoseEntry(glucose: 100, timeMs: mealTimeMs),
+            createGlucoseEntry(glucose: 105, timeMs: mealTimeMs + 5 * 60 * 1000),
+            createGlucoseEntry(glucose: 110, timeMs: mealTimeMs + 10 * 60 * 1000),
+            createGlucoseEntry(glucose: 115, timeMs: mealTimeMs + 15 * 60 * 1000) // 15 min after
+        ]
+        glucose_data.reverse() // Convert to reverse chronological order
+
+        let result = try MealCob.bucketGlucoseForCob(
+            glucose: glucose_data,
+            profile: createDefaultProfile(),
+            mealDate: mealTime,
+            ciDate: nil
+        )
+
+        // Should only process from meal time forward (in reverse chronological order)
+        // The logic will capture one entry pre meal before
+        // it starts filtering (probably a bug)
+        #expect(result.count == 5)
+        // Values should be unchanged (in reverse chronological order)
+        #expect(result[0].glucose == 115)
+        #expect(result[1].glucose == 110)
+        #expect(result[2].glucose == 105)
+        #expect(result[3].glucose == 100)
+        #expect(result[4].glucose == 95)
+    }
+
+    @Test(
+        "should average glucose values when readings are very close (≤ 2 minutes)"
+    ) func shouldAverageGlucoseValuesWhenReadingsAreVeryClose() async throws {
+        let mealTime = Date.from(isoString: "2024-01-01T12:00:00-05:00")
+        let mealTimeMs = mealTime.timeIntervalSince1970 * 1000
+
+        // Create data with readings 1 minute apart (chronological order)
+        var glucose_data = [
+            createGlucoseEntry(glucose: 100, timeMs: mealTimeMs),
+            createGlucoseEntry(glucose: 102, timeMs: mealTimeMs + 1 * 60 * 1000), // 1 min later
+            createGlucoseEntry(glucose: 104, timeMs: mealTimeMs + 2 * 60 * 1000), // 2 min later
+            createGlucoseEntry(glucose: 110, timeMs: mealTimeMs + 5 * 60 * 1000) // 5 min later
+        ]
+        glucose_data.reverse() // Convert to reverse chronological order
+
+        let result = try MealCob.bucketGlucoseForCob(
+            glucose: glucose_data,
+            profile: createDefaultProfile(),
+            mealDate: mealTime,
+            ciDate: nil
+        )
+
+        // Close readings should be averaged (in reverse chronological order)
+        #expect(result.count == 2)
+        #expect(result[0].glucose == 110)
+        // it averages incorrectly, this should be 102 but it's not
+        #expect(result[1].glucose == 101.5)
+    }
+
+    @Test("should cap interpolation at 240 minutes for very large gaps") func shouldCapInterpolationAt240MinutesForVeryLargeGaps(
+    ) async throws {
+        let mealTime = Date.from(isoString: "2024-01-01T12:00:00-05:00")
+        let mealTimeMs = mealTime.timeIntervalSince1970 * 1000
+
+        // Create data with a 6-hour (360 minute) gap (chronological order)
+        var glucose_data = [
+            createGlucoseEntry(glucose: 100, timeMs: mealTimeMs),
+            createGlucoseEntry(glucose: 200, timeMs: mealTimeMs + 360 * 60 * 1000) // 6 hour gap
+        ]
+        glucose_data.reverse() // Convert to reverse chronological order
+
+        let result = try MealCob.bucketGlucoseForCob(
+            glucose: glucose_data,
+            profile: createDefaultProfile(),
+            mealDate: mealTime,
+            ciDate: nil
+        )
+
+        // Should interpolate up to 240 minutes only
+        // 240 / 5 = 48 interpolated points + 2 original = 50
+        // But the logic is a bit off
+        #expect(result.count == 48)
+
+        // Check that interpolation stopped at 240 minutes
+        let gapMinutes = result[0].date.timeIntervalSince(result[result.count - 1].date) / 60
+        #expect(gapMinutes == 235)
+    }
+}

+ 221 - 0
TrioTests/OpenAPSSwiftTests/MealCobTests.swift

@@ -0,0 +1,221 @@
+import Foundation
+import Testing
+@testable import Trio
+
+@Suite("MealCob Tests") struct MealCobTests {
+    // Helper function to create basic profile for testing
+    func createBasicProfile() -> Profile {
+        var profile = Profile()
+        profile.dia = 4
+        profile.maxMealAbsorptionTime = 6
+        profile.min5mCarbImpact = 3
+        profile.carbRatio = 10
+        profile.currentBasal = 1.0
+        profile.isfProfile = ComputedInsulinSensitivities(
+            units: .mgdL,
+            userPreferredUnits: .mgdL,
+            sensitivities: [ComputedInsulinSensitivityEntry(sensitivity: 40, offset: 0, start: "00:00:00")]
+        )
+        return profile
+    }
+
+    // Helper function to create basal profile
+    func createBasalProfile() -> [BasalProfileEntry] {
+        [BasalProfileEntry(start: "00:00:00", minutes: 0, rate: 1.0)]
+    }
+
+    // Helper function to create glucose data from values and timestamps
+    func createGlucoseData(startTime: Date, values: [Int], intervalMinutes: Int = 5) -> [BloodGlucose] {
+        values.enumerated().map { i, glucose in
+            let timestamp = startTime.addingTimeInterval(TimeInterval(i * intervalMinutes * 60))
+            return BloodGlucose(
+                sgv: glucose,
+                date: Decimal(timestamp.timeIntervalSince1970 * 1000), // JS uses ms
+                dateString: timestamp
+            )
+        }.reversed()
+    }
+
+    @Test("should detect carb absorption with rising glucose") func detectCarbAbsorptionWithRisingGlucose() async throws {
+        let mealTime = Date.from(isoString: "2016-06-19T12:00:00-04:00")
+        let ciTime = Date.from(isoString: "2016-06-19T13:00:00-04:00")
+
+        // Create glucose data showing significant rise after meal
+        let glucoseValues = [100, 105, 110, 115, 120, 130, 140, 150, 155, 160, 160, 160, 160]
+        let glucoseData = createGlucoseData(startTime: mealTime, values: glucoseValues)
+
+        let profile = createBasicProfile()
+        let basalProfile = createBasalProfile()
+        let pumpHistory: [PumpHistoryEvent] = []
+
+        // Test with ciTime
+        var result = try MealCob.detectCarbAbsorption(
+            glucose: glucoseData,
+            pumpHistory: pumpHistory,
+            basalProfile: basalProfile,
+            profile: profile,
+            mealDate: mealTime,
+            ciDate: ciTime
+        )
+
+        #expect(result.carbsAbsorbed.isWithin(0.01, of: 9.75))
+
+        // Test without ciTime
+        result = try MealCob.detectCarbAbsorption(
+            glucose: glucoseData,
+            pumpHistory: pumpHistory,
+            basalProfile: basalProfile,
+            profile: profile,
+            mealDate: mealTime,
+            ciDate: nil
+        )
+
+        #expect(result.carbsAbsorbed.isWithin(0.01, of: 14.75))
+    }
+
+    @Test("should handle stable glucose (no carb absorption)") func handleStableGlucose() async throws {
+        let mealTime = Date.from(isoString: "2016-06-19T12:00:00-04:00")
+        let ciTime = Date.from(isoString: "2016-06-19T13:00:00-04:00")
+
+        // Create stable glucose data
+        let glucoseValues = [100, 100, 100, 100, 100, 100]
+        let glucoseData = createGlucoseData(startTime: mealTime, values: glucoseValues)
+
+        let profile = createBasicProfile()
+        let basalProfile = createBasalProfile()
+        let pumpHistory: [PumpHistoryEvent] = []
+
+        let result = try MealCob.detectCarbAbsorption(
+            glucose: glucoseData,
+            pumpHistory: pumpHistory,
+            basalProfile: basalProfile,
+            profile: profile,
+            mealDate: mealTime,
+            ciDate: ciTime
+        )
+
+        #expect(result.carbsAbsorbed == 0)
+    }
+
+    @Test("should handle falling glucose (negative deviation)") func handleFallingGlucose() async throws {
+        let mealTime = Date.from(isoString: "2016-06-19T12:00:00-04:00")
+        let ciTime = Date.from(isoString: "2016-06-19T13:00:00-04:00")
+
+        // Create falling glucose data: 150 -> 125
+        let glucoseValues = [150, 145, 140, 135, 130, 125]
+        let glucoseData = createGlucoseData(startTime: mealTime, values: glucoseValues)
+
+        let profile = createBasicProfile()
+        let basalProfile = createBasalProfile()
+        let pumpHistory: [PumpHistoryEvent] = []
+
+        let result = try MealCob.detectCarbAbsorption(
+            glucose: glucoseData,
+            pumpHistory: pumpHistory,
+            basalProfile: basalProfile,
+            profile: profile,
+            mealDate: mealTime,
+            ciDate: ciTime
+        )
+
+        #expect(result.carbsAbsorbed == 0) // No carbs absorbed when glucose is falling
+    }
+
+    @Test("should stop processing when pre-meal BG is found") func stopProcessingWhenPreMealBGFound() async throws {
+        let mealTime = Date.from(isoString: "2016-06-19T12:00:00-04:00")
+        let ciTime = Date.from(isoString: "2016-06-19T13:00:00-04:00")
+
+        // Include glucose data from before meal time
+        let glucoseData = [
+            BloodGlucose(
+                sgv: 150,
+                date: Decimal(mealTime.addingTimeInterval(60 * 60).timeIntervalSince1970 * 1000), // 1 hour after meal
+                dateString: mealTime.addingTimeInterval(60 * 60)
+            ),
+            BloodGlucose(
+                sgv: 120,
+                date: Decimal(mealTime.addingTimeInterval(30 * 60).timeIntervalSince1970 * 1000), // 30 minutes after meal
+                dateString: mealTime.addingTimeInterval(30 * 60)
+            ),
+            BloodGlucose(
+                sgv: 100,
+                date: Decimal(mealTime.addingTimeInterval(-30 * 60).timeIntervalSince1970 * 1000),
+                // 30 minutes before meal (pre-meal)
+                dateString: mealTime.addingTimeInterval(-30 * 60)
+            )
+        ]
+
+        let profile = createBasicProfile()
+        let basalProfile = createBasalProfile()
+        let pumpHistory: [PumpHistoryEvent] = []
+
+        let result = try MealCob.detectCarbAbsorption(
+            glucose: glucoseData,
+            pumpHistory: pumpHistory,
+            basalProfile: basalProfile,
+            profile: profile,
+            mealDate: mealTime,
+            ciDate: ciTime
+        )
+
+        #expect(result.carbsAbsorbed.isWithin(0.01, of: 3.75))
+    }
+
+    @Test("should respect maxMealAbsorptionTime") func respectMaxMealAbsorptionTime() async throws {
+        let mealTime = Date.from(isoString: "2016-06-19T12:00:00-04:00")
+        let ciTime = Date.from(isoString: "2016-06-19T13:00:00-04:00")
+
+        // Create glucose data spanning longer than maxMealAbsorptionTime
+        var glucoseValues: [Int] = []
+        for i in 0 ..< 100 { // 100 * 5 minutes = ~8 hours
+            let value = Int(100 + sin(Double(i) * 0.1) * 20) // Sinusoidal pattern
+            glucoseValues.append(value)
+        }
+
+        let glucoseData = createGlucoseData(
+            startTime: mealTime.addingTimeInterval(-2 * 60 * 60), // Start 2 hours before meal
+            values: glucoseValues
+        )
+
+        var profile = createBasicProfile()
+        profile.maxMealAbsorptionTime = 2 // Only 2 hours
+        let basalProfile = createBasalProfile()
+        let pumpHistory: [PumpHistoryEvent] = []
+
+        let result = try MealCob.detectCarbAbsorption(
+            glucose: glucoseData,
+            pumpHistory: pumpHistory,
+            basalProfile: basalProfile,
+            profile: profile,
+            mealDate: mealTime,
+            ciDate: ciTime
+        )
+
+        #expect(result.carbsAbsorbed.isWithin(0.01, of: 40.5))
+    }
+
+    @Test("should handle minimum carb impact from profile") func handleMinimumCarbImpactFromProfile() async throws {
+        let mealTime = Date.from(isoString: "2016-06-19T12:00:00-04:00")
+        let ciTime: Date? = nil
+
+        // Create glucose data with slight rise to trigger carb absorption
+        let glucoseValues = [100, 101, 102, 103, 104, 105]
+        let glucoseData = createGlucoseData(startTime: mealTime, values: glucoseValues)
+
+        var profile = createBasicProfile()
+        profile.min5mCarbImpact = 5 // Higher minimum impact
+        let basalProfile = createBasalProfile()
+        let pumpHistory: [PumpHistoryEvent] = []
+
+        let result = try MealCob.detectCarbAbsorption(
+            glucose: glucoseData,
+            pumpHistory: pumpHistory,
+            basalProfile: basalProfile,
+            profile: profile,
+            mealDate: mealTime,
+            ciDate: ciTime
+        )
+
+        #expect(result.carbsAbsorbed.isWithin(0.01, of: 3.75))
+    }
+}

+ 1 - 1
TrioTests/OpenAPSSwiftTests/MealJsonTests.swift

@@ -41,7 +41,7 @@ import Testing
         #expect(mealResult?.currentDeviation == mealResultFromJs.currentDeviation)
         #expect(mealResult?.currentDeviation == mealResultFromJs.currentDeviation)
         // https://github.com/nightscout/Trio-dev/issues/539
         // https://github.com/nightscout/Trio-dev/issues/539
         // Ignore this check due to Issue 539
         // Ignore this check due to Issue 539
-        //#expect(mealResult?.allDeviations == mealResultFromJs.allDeviations)
+        // #expect(mealResult?.allDeviations == mealResultFromJs.allDeviations)
         #expect(mealResult?.maxDeviation == mealResultFromJs.maxDeviation)
         #expect(mealResult?.maxDeviation == mealResultFromJs.maxDeviation)
         #expect(mealResult?.minDeviation == mealResultFromJs.minDeviation)
         #expect(mealResult?.minDeviation == mealResultFromJs.minDeviation)
     }
     }