|
|
@@ -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)
|
|
|
+ }
|
|
|
+}
|