فهرست منبع

Unit Tests for PumpHistoryStorage, DeterminationStorage wip,BolusCalculator; update old Tests to use new Testing API

polscm32 1 سال پیش
والد
کامیت
eeb8b8c2e7

+ 5 - 0
Model/CoreDataStack.swift

@@ -41,6 +41,11 @@ class CoreDataStack: ObservableObject {
         }
     }
 
+    // Factory method for tests
+    static func createForTests() -> CoreDataStack {
+        CoreDataStack(inMemory: true)
+    }
+
     /// A persistent container to set up the Core Data Stack
     lazy var persistentContainer: NSPersistentContainer = {
         let container = NSPersistentContainer(name: "TrioCoreDataPersistentContainer")

+ 40 - 0
Trio.xcodeproj/project.pbxproj

@@ -323,6 +323,12 @@
 		BDA25F1E2D26D5DD00035F34 /* GlucoseChartView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BDA25F1D2D26D5D800035F34 /* GlucoseChartView.swift */; };
 		BDA25F202D26D5FE00035F34 /* CarbsInputView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BDA25F1F2D26D5FB00035F34 /* CarbsInputView.swift */; };
 		BDA25F222D26D62800035F34 /* BolusInputView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BDA25F212D26D62200035F34 /* BolusInputView.swift */; };
+		BDA28BD72D5D5C5500549AFB /* TestError.swift in Sources */ = {isa = PBXBuildFile; fileRef = BDA28BD62D5D5C5500549AFB /* TestError.swift */; };
+		BDA28BD92D5D5C5600549AFB /* TestError 2.swift in Sources */ = {isa = PBXBuildFile; fileRef = BDA28BD82D5D5C5600549AFB /* TestError 2.swift */; };
+		BDA28BDC2D5D5C7500549AFB /* BolusCalculatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BDA28BDB2D5D5C7500549AFB /* BolusCalculatorTests.swift */; };
+		BDA28BDF2D5D5C9600549AFB /* TestAssembly.swift in Sources */ = {isa = PBXBuildFile; fileRef = BDA28BDE2D5D5C9600549AFB /* TestAssembly.swift */; };
+		BDA28BE12D5D5CB300549AFB /* DeterminationStorageTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BDA28BE02D5D5CB300549AFB /* DeterminationStorageTests.swift */; };
+		BDA28BE32D5D5CCB00549AFB /* PumpHistoryStorageTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BDA28BE22D5D5CCB00549AFB /* PumpHistoryStorageTests.swift */; };
 		BDA6CC882CAF219B00F942F9 /* TempTargetSetup.swift in Sources */ = {isa = PBXBuildFile; fileRef = BDA6CC872CAF219800F942F9 /* TempTargetSetup.swift */; };
 		BDA7593E2D37CFC400E649A4 /* CarbEntryEditorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BDA7593D2D37CFC000E649A4 /* CarbEntryEditorView.swift */; };
 		BDAE40002D372BAD009C12B1 /* WatchState+Requests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BDAE3FFF2D372BA8009C12B1 /* WatchState+Requests.swift */; };
@@ -1046,6 +1052,12 @@
 		BDA25F1D2D26D5D800035F34 /* GlucoseChartView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlucoseChartView.swift; sourceTree = "<group>"; };
 		BDA25F1F2D26D5FB00035F34 /* CarbsInputView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CarbsInputView.swift; sourceTree = "<group>"; };
 		BDA25F212D26D62200035F34 /* BolusInputView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BolusInputView.swift; sourceTree = "<group>"; };
+		BDA28BD62D5D5C5500549AFB /* TestError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestError.swift; sourceTree = "<group>"; };
+		BDA28BD82D5D5C5600549AFB /* TestError 2.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TestError 2.swift"; sourceTree = "<group>"; };
+		BDA28BDB2D5D5C7500549AFB /* BolusCalculatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BolusCalculatorTests.swift; sourceTree = "<group>"; };
+		BDA28BDE2D5D5C9600549AFB /* TestAssembly.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestAssembly.swift; sourceTree = "<group>"; };
+		BDA28BE02D5D5CB300549AFB /* DeterminationStorageTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeterminationStorageTests.swift; sourceTree = "<group>"; };
+		BDA28BE22D5D5CCB00549AFB /* PumpHistoryStorageTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PumpHistoryStorageTests.swift; sourceTree = "<group>"; };
 		BDA6CC872CAF219800F942F9 /* TempTargetSetup.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TempTargetSetup.swift; sourceTree = "<group>"; };
 		BDA7593D2D37CFC000E649A4 /* CarbEntryEditorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CarbEntryEditorView.swift; sourceTree = "<group>"; };
 		BDAE3FFF2D372BA8009C12B1 /* WatchState+Requests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "WatchState+Requests.swift"; sourceTree = "<group>"; };
@@ -2288,10 +2300,14 @@
 		38FCF3EE25E9028E0078B0D1 /* TrioTests */ = {
 			isa = PBXGroup;
 			children = (
+				BDA28BDD2D5D5C8100549AFB /* CoreDataTests */,
+				BDA28BDA2D5D5C6900549AFB /* BolusCalculatorTests */,
 				38FCF3F125E9028E0078B0D1 /* Info.plist */,
 				38FCF3F825E902C20078B0D1 /* FileStorageTests.swift */,
 				CE1F6DD82BADF4620064EB8D /* PluginManagerTests.swift */,
 				CEE9A65D2BBC9F6500EB5194 /* CalibrationsTests.swift */,
+				BDA28BD62D5D5C5500549AFB /* TestError.swift */,
+				BDA28BD82D5D5C5600549AFB /* TestError 2.swift */,
 			);
 			path = TrioTests;
 			sourceTree = "<group>";
@@ -2553,6 +2569,24 @@
 			path = Views;
 			sourceTree = "<group>";
 		};
+		BDA28BDA2D5D5C6900549AFB /* BolusCalculatorTests */ = {
+			isa = PBXGroup;
+			children = (
+				BDA28BDB2D5D5C7500549AFB /* BolusCalculatorTests.swift */,
+			);
+			path = BolusCalculatorTests;
+			sourceTree = "<group>";
+		};
+		BDA28BDD2D5D5C8100549AFB /* CoreDataTests */ = {
+			isa = PBXGroup;
+			children = (
+				BDA28BDE2D5D5C9600549AFB /* TestAssembly.swift */,
+				BDA28BE02D5D5CB300549AFB /* DeterminationStorageTests.swift */,
+				BDA28BE22D5D5CCB00549AFB /* PumpHistoryStorageTests.swift */,
+			);
+			path = CoreDataTests;
+			sourceTree = "<group>";
+		};
 		BDDAF9F12D0055CC00B34E7A /* ChartElements */ = {
 			isa = PBXGroup;
 			children = (
@@ -4046,9 +4080,15 @@
 			isa = PBXSourcesBuildPhase;
 			buildActionMask = 2147483647;
 			files = (
+				BDA28BD72D5D5C5500549AFB /* TestError.swift in Sources */,
 				CEE9A65E2BBC9F6500EB5194 /* CalibrationsTests.swift in Sources */,
+				BDA28BE32D5D5CCB00549AFB /* PumpHistoryStorageTests.swift in Sources */,
+				BDA28BD92D5D5C5600549AFB /* TestError 2.swift in Sources */,
+				BDA28BDC2D5D5C7500549AFB /* BolusCalculatorTests.swift in Sources */,
 				CE1F6DD92BADF4620064EB8D /* PluginManagerTests.swift in Sources */,
+				BDA28BE12D5D5CB300549AFB /* DeterminationStorageTests.swift in Sources */,
 				38FCF3F925E902C20078B0D1 /* FileStorageTests.swift in Sources */,
+				BDA28BDF2D5D5C9600549AFB /* TestAssembly.swift in Sources */,
 			);
 			runOnlyForDeploymentPostprocessing = 0;
 		};

+ 54 - 0
Trio.xcodeproj/xcshareddata/xcschemes/Trio Tests.xcscheme

@@ -0,0 +1,54 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<Scheme
+   LastUpgradeVersion = "1620"
+   version = "1.7">
+   <BuildAction
+      parallelizeBuildables = "YES"
+      buildImplicitDependencies = "YES"
+      buildArchitectures = "Automatic">
+   </BuildAction>
+   <TestAction
+      buildConfiguration = "Debug"
+      selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
+      selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
+      shouldUseLaunchSchemeArgsEnv = "YES"
+      shouldAutocreateTestPlan = "YES">
+      <Testables>
+         <TestableReference
+            skipped = "NO">
+            <BuildableReference
+               BuildableIdentifier = "primary"
+               BlueprintIdentifier = "38FCF3EC25E9028E0078B0D1"
+               BuildableName = "TrioTests.xctest"
+               BlueprintName = "TrioTests"
+               ReferencedContainer = "container:Trio.xcodeproj">
+            </BuildableReference>
+         </TestableReference>
+      </Testables>
+   </TestAction>
+   <LaunchAction
+      buildConfiguration = "Debug"
+      selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
+      selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
+      launchStyle = "0"
+      useCustomWorkingDirectory = "NO"
+      ignoresPersistentStateOnLaunch = "NO"
+      debugDocumentVersioning = "YES"
+      debugServiceExtension = "internal"
+      allowLocationSimulation = "YES">
+   </LaunchAction>
+   <ProfileAction
+      buildConfiguration = "Release"
+      shouldUseLaunchSchemeArgsEnv = "YES"
+      savedToolIdentifier = ""
+      useCustomWorkingDirectory = "NO"
+      debugDocumentVersioning = "YES">
+   </ProfileAction>
+   <AnalyzeAction
+      buildConfiguration = "Debug">
+   </AnalyzeAction>
+   <ArchiveAction
+      buildConfiguration = "Release"
+      revealArchiveInOrganizer = "YES">
+   </ArchiveAction>
+</Scheme>

+ 10 - 8
Trio/Sources/APS/DeviceDataManager.swift

@@ -516,15 +516,17 @@ extension BaseDeviceDataManager: PumpManagerDelegate {
     ) {
         dispatchPrecondition(condition: .onQueue(processQueue))
 
-        // filter buggy TBRs > maxBasal from MDT
-        let events = events.filter {
-            // type is optional...
-            guard let type = $0.type, type == .tempBasal else { return true }
-            return $0.dose?.unitsPerHour ?? 0 <= Double(settingsManager.pumpSettings.maxBasal)
+        Task {
+            // filter buggy TBRs > maxBasal from MDT
+            let events = events.filter {
+                // type is optional...
+                guard let type = $0.type, type == .tempBasal else { return true }
+                return $0.dose?.unitsPerHour ?? 0 <= Double(settingsManager.pumpSettings.maxBasal)
+            }
+            await pumpHistoryStorage.storePumpEvents(events)
+            lastEventDate = events.last?.date
+            completion(nil)
         }
-        pumpHistoryStorage.storePumpEvents(events)
-        lastEventDate = events.last?.date
-        completion(nil)
     }
 
     func pumpManager(

+ 11 - 10
Trio/Sources/APS/Storage/DeterminationStorage.swift

@@ -16,23 +16,24 @@ protocol DeterminationStorage {
 
 final class BaseDeterminationStorage: DeterminationStorage, Injectable {
     private let viewContext = CoreDataStack.shared.persistentContainer.viewContext
-    private let backgroundContext = CoreDataStack.shared.newTaskContext()
+    private let context: NSManagedObjectContext
 
-    init(resolver: Resolver) {
+    init(resolver: Resolver, context: NSManagedObjectContext? = nil) {
+        self.context = context ?? CoreDataStack.shared.newTaskContext()
         injectServices(resolver)
     }
 
     func fetchLastDeterminationObjectID(predicate: NSPredicate) async -> [NSManagedObjectID] {
         let results = await CoreDataStack.shared.fetchEntitiesAsync(
             ofType: OrefDetermination.self,
-            onContext: backgroundContext,
+            onContext: context,
             predicate: predicate,
             key: "deliverAt",
             ascending: false,
             fetchLimit: 1
         )
 
-        return await backgroundContext.perform {
+        return await context.perform {
             guard let fetchedResults = results as? [OrefDetermination] else { return [] }
 
             return fetchedResults.map(\.objectID)
@@ -112,19 +113,19 @@ final class BaseDeterminationStorage: DeterminationStorage, Injectable {
 
     // Convert NSSet to array of Ints for Predictions
     func parseForecastValues(ofType type: String, from determinationID: NSManagedObjectID) async -> [Int]? {
-        let forecastIDs = await getForecastIDs(for: determinationID, in: backgroundContext)
+        let forecastIDs = await getForecastIDs(for: determinationID, in: context)
 
         var forecastValuesList: [Int] = []
 
         for forecastID in forecastIDs {
-            await backgroundContext.perform {
-                if let forecast = try? self.backgroundContext.existingObject(with: forecastID) as? Forecast {
+            await context.perform {
+                if let forecast = try? self.context.existingObject(with: forecastID) as? Forecast {
                     // Filter the forecast based on the type
                     if forecast.type == type {
                         let forecastValueIDs = forecast.forecastValues?.sorted(by: { $0.index < $1.index }).map(\.objectID) ?? []
 
                         for forecastValueID in forecastValueIDs {
-                            if let forecastValue = try? self.backgroundContext
+                            if let forecastValue = try? self.context
                                 .existingObject(with: forecastValueID) as? ForecastValue
                             {
                                 let forecastValueInt = Int(forecastValue.value)
@@ -153,9 +154,9 @@ final class BaseDeterminationStorage: DeterminationStorage, Injectable {
             uam: await parseForecastValues(ofType: "uam", from: determinationId)
         )
 
-        return await backgroundContext.perform {
+        return await context.perform {
             do {
-                let orefDetermination = try self.backgroundContext.existingObject(with: determinationId) as? OrefDetermination
+                let orefDetermination = try self.context.existingObject(with: determinationId) as? OrefDetermination
 
                 // Check if the fetched object is of the expected type
                 if let orefDetermination = orefDetermination {

+ 164 - 169
Trio/Sources/APS/Storage/PumpHistoryStorage.swift

@@ -11,7 +11,7 @@ protocol PumpHistoryObserver {
 
 protocol PumpHistoryStorage {
     var updatePublisher: AnyPublisher<Void, Never> { get }
-    func storePumpEvents(_ events: [NewPumpEvent])
+    func storePumpEvents(_ events: [NewPumpEvent]) async
     func storeExternalInsulinEvent(amount: Decimal, timestamp: Date) async
     func recent() -> [PumpHistoryEvent]
     func getPumpHistoryNotYetUploadedToNightscout() async -> [NightscoutTreatment]
@@ -27,201 +27,196 @@ final class BasePumpHistoryStorage: PumpHistoryStorage, Injectable {
     @Injected() private var settings: SettingsManager!
 
     private let updateSubject = PassthroughSubject<Void, Never>()
+    private let context: NSManagedObjectContext
 
     var updatePublisher: AnyPublisher<Void, Never> {
         updateSubject.eraseToAnyPublisher()
     }
 
-    init(resolver: Resolver) {
+    init(resolver: Resolver, context: NSManagedObjectContext? = nil) {
+        self.context = context ?? CoreDataStack.shared.newTaskContext()
         injectServices(resolver)
     }
 
     typealias PumpEvent = PumpEventStored.EventType
     typealias TempType = PumpEventStored.TempType
 
-    private let context = CoreDataStack.shared.newTaskContext()
-
     private func roundDose(_ dose: Double, toIncrement increment: Double) -> Decimal {
         let roundedValue = (dose / increment).rounded() * increment
         return Decimal(roundedValue)
     }
 
-    func storePumpEvents(_ events: [NewPumpEvent]) {
-        processQueue.async {
-            self.context.perform {
-                for event in events {
-                    // Fetch to filter out duplicates
-                    // TODO: - move this to the Core Data Class
-
-                    let existingEvents: [PumpEventStored] = CoreDataStack.shared.fetchEntities(
-                        ofType: PumpEventStored.self,
-                        onContext: self.context,
-                        predicate: NSPredicate.duplicateInLastHour(event.date),
-                        key: "timestamp",
-                        ascending: false,
-                        batchSize: 50
-                    ) as? [PumpEventStored] ?? []
-
-                    switch event.type {
-                    case .bolus:
-
-                        guard let dose = event.dose else { continue }
-                        let amount = self.roundDose(
-                            dose.unitsInDeliverableIncrements,
-                            toIncrement: Double(self.settings.preferences.bolusIncrement)
-                        )
+    func storePumpEvents(_ events: [NewPumpEvent]) async {
+        await context.perform {
+            for event in events {
+                let existingEvents: [PumpEventStored] = CoreDataStack.shared.fetchEntities(
+                    ofType: PumpEventStored.self,
+                    onContext: self.context,
+                    predicate: NSPredicate.duplicateInLastHour(event.date),
+                    key: "timestamp",
+                    ascending: false,
+                    batchSize: 50
+                ) as? [PumpEventStored] ?? []
+
+                switch event.type {
+                case .bolus:
 
-                        guard existingEvents.isEmpty else {
-                            // Duplicate found, do not store the event
-                            print("Duplicate event found with timestamp: \(event.date)")
-
-                            if let existingEvent = existingEvents.first(where: { $0.type == EventType.bolus.rawValue }) {
-                                if existingEvent.timestamp == event.date {
-                                    if let existingAmount = existingEvent.bolus?.amount, amount < existingAmount as Decimal {
-                                        // Update existing event with new smaller value
-                                        existingEvent.bolus?.amount = amount as NSDecimalNumber
-                                        existingEvent.bolus?.isSMB = dose.automatic ?? true
-                                        existingEvent.isUploadedToNS = false
-                                        existingEvent.isUploadedToHealth = false
-                                        existingEvent.isUploadedToTidepool = false
-
-                                        print("Updated existing event with smaller value: \(amount)")
-                                    }
+                    guard let dose = event.dose else { continue }
+                    let amount = self.roundDose(
+                        dose.unitsInDeliverableIncrements,
+                        toIncrement: Double(self.settings.preferences.bolusIncrement)
+                    )
+
+                    guard existingEvents.isEmpty else {
+                        // Duplicate found, do not store the event
+                        print("Duplicate event found with timestamp: \(event.date)")
+
+                        if let existingEvent = existingEvents.first(where: { $0.type == EventType.bolus.rawValue }) {
+                            if existingEvent.timestamp == event.date {
+                                if let existingAmount = existingEvent.bolus?.amount, amount < existingAmount as Decimal {
+                                    // Update existing event with new smaller value
+                                    existingEvent.bolus?.amount = amount as NSDecimalNumber
+                                    existingEvent.bolus?.isSMB = dose.automatic ?? true
+                                    existingEvent.isUploadedToNS = false
+                                    existingEvent.isUploadedToHealth = false
+                                    existingEvent.isUploadedToTidepool = false
+
+                                    print("Updated existing event with smaller value: \(amount)")
                                 }
                             }
-                            continue
                         }
+                        continue
+                    }
 
-                        let newPumpEvent = PumpEventStored(context: self.context)
-                        newPumpEvent.id = UUID().uuidString
-                        newPumpEvent.timestamp = event.date
-                        newPumpEvent.type = PumpEvent.bolus.rawValue
-                        newPumpEvent.isUploadedToNS = false
-                        newPumpEvent.isUploadedToHealth = false
-                        newPumpEvent.isUploadedToTidepool = false
-
-                        let newBolusEntry = BolusStored(context: self.context)
-                        newBolusEntry.pumpEvent = newPumpEvent
-                        newBolusEntry.amount = NSDecimalNumber(decimal: amount)
-                        newBolusEntry.isExternal = dose.manuallyEntered
-                        newBolusEntry.isSMB = dose.automatic ?? true
-
-                    case .tempBasal:
-                        guard let dose = event.dose else { continue }
-
-                        guard existingEvents.isEmpty else {
-                            // Duplicate found, do not store the event
-                            print("Duplicate event found with timestamp: \(event.date)")
-                            continue
-                        }
+                    let newPumpEvent = PumpEventStored(context: self.context)
+                    newPumpEvent.id = UUID().uuidString
+                    newPumpEvent.timestamp = event.date
+                    newPumpEvent.type = PumpEvent.bolus.rawValue
+                    newPumpEvent.isUploadedToNS = false
+                    newPumpEvent.isUploadedToHealth = false
+                    newPumpEvent.isUploadedToTidepool = false
+
+                    let newBolusEntry = BolusStored(context: self.context)
+                    newBolusEntry.pumpEvent = newPumpEvent
+                    newBolusEntry.amount = NSDecimalNumber(decimal: amount)
+                    newBolusEntry.isExternal = dose.manuallyEntered
+                    newBolusEntry.isSMB = dose.automatic ?? true
+
+                case .tempBasal:
+                    guard let dose = event.dose else { continue }
+
+                    guard existingEvents.isEmpty else {
+                        // Duplicate found, do not store the event
+                        print("Duplicate event found with timestamp: \(event.date)")
+                        continue
+                    }
 
-                        let rate = Decimal(dose.unitsPerHour)
-                        let minutes = (dose.endDate - dose.startDate).timeInterval / 60
-                        let delivered = dose.deliveredUnits
-                        let date = event.date
-
-                        let isCancel = delivered != nil
-                        guard !isCancel else { continue }
-
-                        let newPumpEvent = PumpEventStored(context: self.context)
-                        newPumpEvent.id = UUID().uuidString
-                        newPumpEvent.timestamp = date
-                        newPumpEvent.type = PumpEvent.tempBasal.rawValue
-                        newPumpEvent.isUploadedToNS = false
-                        newPumpEvent.isUploadedToHealth = false
-                        newPumpEvent.isUploadedToTidepool = false
-
-                        let newTempBasal = TempBasalStored(context: self.context)
-                        newTempBasal.pumpEvent = newPumpEvent
-                        newTempBasal.duration = Int16(round(minutes))
-                        newTempBasal.rate = rate as NSDecimalNumber
-                        newTempBasal.tempType = TempType.absolute.rawValue
-
-                    case .suspend:
-                        guard existingEvents.isEmpty else {
-                            // Duplicate found, do not store the event
-                            print("Duplicate event found with timestamp: \(event.date)")
-                            continue
-                        }
-                        let newPumpEvent = PumpEventStored(context: self.context)
-                        newPumpEvent.id = UUID().uuidString
-                        newPumpEvent.timestamp = event.date
-                        newPumpEvent.type = PumpEvent.pumpSuspend.rawValue
-                        newPumpEvent.isUploadedToNS = false
-                        newPumpEvent.isUploadedToHealth = false
-                        newPumpEvent.isUploadedToTidepool = false
-
-                    case .resume:
-                        guard existingEvents.isEmpty else {
-                            // Duplicate found, do not store the event
-                            print("Duplicate event found with timestamp: \(event.date)")
-                            continue
-                        }
-                        let newPumpEvent = PumpEventStored(context: self.context)
-                        newPumpEvent.id = UUID().uuidString
-                        newPumpEvent.timestamp = event.date
-                        newPumpEvent.type = PumpEvent.pumpResume.rawValue
-                        newPumpEvent.isUploadedToNS = false
-                        newPumpEvent.isUploadedToHealth = false
-                        newPumpEvent.isUploadedToTidepool = false
-
-                    case .rewind:
-                        guard existingEvents.isEmpty else {
-                            // Duplicate found, do not store the event
-                            print("Duplicate event found with timestamp: \(event.date)")
-                            continue
-                        }
-                        let newPumpEvent = PumpEventStored(context: self.context)
-                        newPumpEvent.id = UUID().uuidString
-                        newPumpEvent.timestamp = event.date
-                        newPumpEvent.type = PumpEvent.rewind.rawValue
-                        newPumpEvent.isUploadedToNS = false
-                        newPumpEvent.isUploadedToHealth = false
-                        newPumpEvent.isUploadedToTidepool = false
-
-                    case .prime:
-                        guard existingEvents.isEmpty else {
-                            // Duplicate found, do not store the event
-                            print("Duplicate event found with timestamp: \(event.date)")
-                            continue
-                        }
-                        let newPumpEvent = PumpEventStored(context: self.context)
-                        newPumpEvent.id = UUID().uuidString
-                        newPumpEvent.timestamp = event.date
-                        newPumpEvent.type = PumpEvent.prime.rawValue
-                        newPumpEvent.isUploadedToNS = false
-                        newPumpEvent.isUploadedToHealth = false
-                        newPumpEvent.isUploadedToTidepool = false
-
-                    case .alarm:
-                        guard existingEvents.isEmpty else {
-                            // Duplicate found, do not store the event
-                            print("Duplicate event found with timestamp: \(event.date)")
-                            continue
-                        }
-                        let newPumpEvent = PumpEventStored(context: self.context)
-                        newPumpEvent.id = UUID().uuidString
-                        newPumpEvent.timestamp = event.date
-                        newPumpEvent.type = PumpEvent.pumpAlarm.rawValue
-                        newPumpEvent.isUploadedToNS = false
-                        newPumpEvent.isUploadedToHealth = false
-                        newPumpEvent.isUploadedToTidepool = false
-                        newPumpEvent.note = event.title
-
-                    default:
+                    let rate = Decimal(dose.unitsPerHour)
+                    let minutes = (dose.endDate - dose.startDate).timeInterval / 60
+                    let delivered = dose.deliveredUnits
+                    let date = event.date
+
+                    let isCancel = delivered != nil
+                    guard !isCancel else { continue }
+
+                    let newPumpEvent = PumpEventStored(context: self.context)
+                    newPumpEvent.id = UUID().uuidString
+                    newPumpEvent.timestamp = date
+                    newPumpEvent.type = PumpEvent.tempBasal.rawValue
+                    newPumpEvent.isUploadedToNS = false
+                    newPumpEvent.isUploadedToHealth = false
+                    newPumpEvent.isUploadedToTidepool = false
+
+                    let newTempBasal = TempBasalStored(context: self.context)
+                    newTempBasal.pumpEvent = newPumpEvent
+                    newTempBasal.duration = Int16(round(minutes))
+                    newTempBasal.rate = rate as NSDecimalNumber
+                    newTempBasal.tempType = TempType.absolute.rawValue
+
+                case .suspend:
+                    guard existingEvents.isEmpty else {
+                        // Duplicate found, do not store the event
+                        print("Duplicate event found with timestamp: \(event.date)")
                         continue
                     }
+                    let newPumpEvent = PumpEventStored(context: self.context)
+                    newPumpEvent.id = UUID().uuidString
+                    newPumpEvent.timestamp = event.date
+                    newPumpEvent.type = PumpEvent.pumpSuspend.rawValue
+                    newPumpEvent.isUploadedToNS = false
+                    newPumpEvent.isUploadedToHealth = false
+                    newPumpEvent.isUploadedToTidepool = false
+
+                case .resume:
+                    guard existingEvents.isEmpty else {
+                        // Duplicate found, do not store the event
+                        print("Duplicate event found with timestamp: \(event.date)")
+                        continue
+                    }
+                    let newPumpEvent = PumpEventStored(context: self.context)
+                    newPumpEvent.id = UUID().uuidString
+                    newPumpEvent.timestamp = event.date
+                    newPumpEvent.type = PumpEvent.pumpResume.rawValue
+                    newPumpEvent.isUploadedToNS = false
+                    newPumpEvent.isUploadedToHealth = false
+                    newPumpEvent.isUploadedToTidepool = false
+
+                case .rewind:
+                    guard existingEvents.isEmpty else {
+                        // Duplicate found, do not store the event
+                        print("Duplicate event found with timestamp: \(event.date)")
+                        continue
+                    }
+                    let newPumpEvent = PumpEventStored(context: self.context)
+                    newPumpEvent.id = UUID().uuidString
+                    newPumpEvent.timestamp = event.date
+                    newPumpEvent.type = PumpEvent.rewind.rawValue
+                    newPumpEvent.isUploadedToNS = false
+                    newPumpEvent.isUploadedToHealth = false
+                    newPumpEvent.isUploadedToTidepool = false
+
+                case .prime:
+                    guard existingEvents.isEmpty else {
+                        // Duplicate found, do not store the event
+                        print("Duplicate event found with timestamp: \(event.date)")
+                        continue
+                    }
+                    let newPumpEvent = PumpEventStored(context: self.context)
+                    newPumpEvent.id = UUID().uuidString
+                    newPumpEvent.timestamp = event.date
+                    newPumpEvent.type = PumpEvent.prime.rawValue
+                    newPumpEvent.isUploadedToNS = false
+                    newPumpEvent.isUploadedToHealth = false
+                    newPumpEvent.isUploadedToTidepool = false
+
+                case .alarm:
+                    guard existingEvents.isEmpty else {
+                        // Duplicate found, do not store the event
+                        print("Duplicate event found with timestamp: \(event.date)")
+                        continue
+                    }
+                    let newPumpEvent = PumpEventStored(context: self.context)
+                    newPumpEvent.id = UUID().uuidString
+                    newPumpEvent.timestamp = event.date
+                    newPumpEvent.type = PumpEvent.pumpAlarm.rawValue
+                    newPumpEvent.isUploadedToNS = false
+                    newPumpEvent.isUploadedToHealth = false
+                    newPumpEvent.isUploadedToTidepool = false
+                    newPumpEvent.note = event.title
+
+                default:
+                    continue
                 }
+            }
 
-                do {
-                    guard self.context.hasChanges else { return }
-                    try self.context.save()
+            do {
+                guard self.context.hasChanges else { return }
+                try self.context.save()
 
-                    self.updateSubject.send(())
-                    debugPrint("\(DebuggingIdentifiers.succeeded) stored pump events in Core Data")
-                } catch let error as NSError {
-                    debugPrint("\(DebuggingIdentifiers.failed) failed to store pump events with error: \(error.userInfo)")
-                }
+                self.updateSubject.send(())
+                debugPrint("\(DebuggingIdentifiers.succeeded) stored pump events in Core Data")
+            } catch let error as NSError {
+                debugPrint("\(DebuggingIdentifiers.failed) failed to store pump events with error: \(error.userInfo)")
             }
         }
     }

+ 588 - 0
TrioTests/BolusCalculatorTests/BolusCalculatorTests.swift

@@ -0,0 +1,588 @@
+import Foundation
+import Testing
+
+@testable import Trio
+
+@Suite(.serialized) struct BolusCalculatorTests: Injectable {
+    @Injected() var calculator: BolusCalculationManager!
+    @Injected() var settingsManager: SettingsManager!
+    @Injected() var fileStorage: FileStorage!
+    @Injected() var apsManager: APSManager!
+    let resolver = TrioApp().resolver
+
+    init() {
+        injectServices(resolver)
+    }
+
+    @Test("Calculator is correctly initialized") func testCalculatorInitialization() {
+        #expect(calculator != nil, "BolusCalculationManager should be injected")
+        #expect(calculator is BaseBolusCalculationManager, "Calculator should be of type BaseBolusCalculationManager")
+    }
+
+    @Test("Calculate insulin for standard meal") func testStandardMealCalculation() async throws {
+        // STEP 1: Setup test scenario
+        // We need to provide a CalculationInput struct
+        let carbs: Decimal = 80
+        let currentBG: Decimal = 180 // 80 points above target, should result in 2U correction
+        let deltaBG: Decimal = 5 // Rising trend, should add small correction
+        let target: Decimal = 100
+        let isf: Decimal = 40
+        let carbRatio: Decimal = 10 // Should result in 8U for carbs
+        let iob: Decimal = 1.0 // Should subtract from final result
+        let cob: Int16 = 20
+        let useFattyMealCorrectionFactor: Bool = false
+        let useSuperBolus: Bool = false
+        let fattyMealFactor: Decimal = 0.8
+        let sweetMealFactor: Decimal = 2
+        let basal: Decimal = 1.5
+        let fraction: Decimal = 0.8
+        let maxBolus: Decimal = 10
+
+        // STEP 2: Create calculation input
+        let input = CalculationInput(
+            carbs: carbs,
+            currentBG: currentBG,
+            deltaBG: deltaBG,
+            target: target,
+            isf: isf,
+            carbRatio: carbRatio,
+            iob: iob,
+            cob: cob,
+            useFattyMealCorrectionFactor: useFattyMealCorrectionFactor,
+            fattyMealFactor: fattyMealFactor,
+            useSuperBolus: useSuperBolus,
+            sweetMealFactor: sweetMealFactor,
+            basal: basal,
+            fraction: fraction,
+            maxBolus: maxBolus
+        )
+
+        // STEP 3: Calculate insulin
+        let result = await calculator.calculateInsulin(input: input)
+
+        // STEP 4: Verify results
+        // Expected calculation breakdown:
+        // wholeCob = 80g + 20g COB = 100g
+        // wholeCobInsulin = 100g ÷ 10 g/U = 10U
+        // targetDifference = currentBG - target = 180 - 100 = 80 mg/dL
+        // targetDifferenceInsulin = 80 mg/dL ÷ 40 mg/dL/U = 2U
+        // fifteenMinutesInsulin = 5 mg/dL ÷ 40 mg/dL/U = 0.125U
+        // correctionInsulin = targetDifferenceInsulin = 2U
+        // iobInsulinReduction = 1U
+        // superBolusInsulin = 0U (disabled)
+        // no adjustment for fatty meals (disabled)
+        // wholeCalc = round(wholeCobInsulin + correctionInsulin + fifteenMinutesInsulin - iobInsulinReduction, 3) = 11.125U
+        // insulinCalculated = round(wholeCalc × fraction, 3) = 8.9U
+
+        // Calculate expected values with proper rounding using roundBolus method from the apsManager
+        let wholeCobInsulin = apsManager.roundBolus(amount: Decimal(100) / Decimal(10)) // 10U
+        let targetDifferenceInsulin = apsManager.roundBolus(amount: Decimal(80) / Decimal(40)) // 2U
+        let fifteenMinutesInsulin = apsManager.roundBolus(amount: Decimal(5) / Decimal(40)) // 0.125U
+        let wholeCalc = wholeCobInsulin + targetDifferenceInsulin + fifteenMinutesInsulin - Decimal(1) // 11.125U
+        let expectedInsulinCalculated = apsManager.roundBolus(amount: wholeCalc * fraction) // 8.9U
+
+        #expect(
+            result.insulinCalculated == expectedInsulinCalculated,
+            """
+            Incorrect insulin calculation
+            Expected: \(expectedInsulinCalculated)U
+            Actual: \(result.insulinCalculated)U
+            Components from CalculationResult:
+            - insulinCalculated: \(result.insulinCalculated)U (expected: \(expectedInsulinCalculated)U)
+            - wholeCalc: \(result.wholeCalc)U (expected: \(wholeCalc)U)
+            - correctionInsulin: \(result.correctionInsulin)U (expected: \(targetDifferenceInsulin)U)
+            - iobInsulinReduction: \(result.iobInsulinReduction)U (expected: 1U)
+            - superBolusInsulin: \(result.superBolusInsulin)U (expected: 0U)
+            - targetDifference: \(result.targetDifference) mg/dL (expected: 80 mg/dL)
+            - targetDifferenceInsulin: \(result.targetDifferenceInsulin)U (expected: \(targetDifferenceInsulin)U)
+            - fifteenMinutesInsulin: \(result.fifteenMinutesInsulin)U (expected: \(fifteenMinutesInsulin)U)
+            - wholeCob: \(result.wholeCob)g (expected: 100g)
+            - wholeCobInsulin: \(result.wholeCobInsulin)U (expected: \(wholeCobInsulin)U)
+            """
+        )
+
+        // Verify each component from CalculationResult struct with rounded values
+        #expect(
+            result.insulinCalculated == expectedInsulinCalculated,
+            "Final calculated insulin amount should be \(expectedInsulinCalculated)U"
+        )
+        #expect(result.wholeCalc == wholeCalc, "Total calculation before fraction should be \(wholeCalc)U")
+        #expect(
+            result.correctionInsulin == targetDifferenceInsulin,
+            "Insulin for BG correction should be \(targetDifferenceInsulin)U"
+        )
+        #expect(result.iobInsulinReduction == -1.0, "Absolute IOB reduction amount should be 1U, hence -1U")
+        #expect(result.superBolusInsulin == 0, "Additional insulin for super bolus should be 0U")
+        #expect(result.targetDifference == 80, "Difference from target BG should be 80 mg/dL")
+        #expect(
+            result.targetDifferenceInsulin == targetDifferenceInsulin,
+            "Insulin needed for target difference should be \(targetDifferenceInsulin)U"
+        )
+        #expect(
+            result.fifteenMinutesInsulin == fifteenMinutesInsulin,
+            "Trend-based insulin adjustment should be \(fifteenMinutesInsulin)U"
+        )
+        #expect(result.wholeCob == 100, "Total carbs (COB + new carbs) should be 100g")
+        #expect(result.wholeCobInsulin == wholeCobInsulin, "Insulin for total carbs should be \(wholeCobInsulin)U")
+    }
+
+    @Test("Calculate insulin for fatty meal") func testFattyMealCalculation() async throws {
+        // STEP 1: Setup test scenario
+        // We need to provide a CalculationInput struct
+        let carbs: Decimal = 80
+        let currentBG: Decimal = 180 // 80 points above target, should result in 2U correction
+        let deltaBG: Decimal = 5 // Rising trend, should add small correction
+        let target: Decimal = 100
+        let isf: Decimal = 40
+        let carbRatio: Decimal = 10 // Should result in 8U for carbs
+        let iob: Decimal = 1.0 // Should subtract from final result
+        let cob: Int16 = 20
+        let useFattyMealCorrectionFactor: Bool = true // now set to true
+        let useSuperBolus: Bool = false
+        let fattyMealFactor: Decimal = 0.8
+        let sweetMealFactor: Decimal = 2
+        let basal: Decimal = 1.5
+        let fraction: Decimal = 0.8
+        let maxBolus: Decimal = 10
+
+        // STEP 2: Create calculation input
+        let input = CalculationInput(
+            carbs: carbs,
+            currentBG: currentBG,
+            deltaBG: deltaBG,
+            target: target,
+            isf: isf,
+            carbRatio: carbRatio,
+            iob: iob,
+            cob: cob,
+            useFattyMealCorrectionFactor: useFattyMealCorrectionFactor,
+            fattyMealFactor: fattyMealFactor,
+            useSuperBolus: useSuperBolus,
+            sweetMealFactor: sweetMealFactor,
+            basal: basal,
+            fraction: fraction,
+            maxBolus: maxBolus
+        )
+
+        // STEP 3: Calculate insulin with fatty meal enabled
+        let fattyMealResult = await calculator.calculateInsulin(input: input)
+
+        // STEP 4: Calculate insulin with fatty meal disabled for comparison
+        let standardInput = CalculationInput(
+            carbs: carbs,
+            currentBG: currentBG,
+            deltaBG: deltaBG,
+            target: target,
+            isf: isf,
+            carbRatio: carbRatio,
+            iob: iob,
+            cob: cob,
+            useFattyMealCorrectionFactor: false, // Disabled for comparison
+            fattyMealFactor: fattyMealFactor,
+            useSuperBolus: useSuperBolus,
+            sweetMealFactor: sweetMealFactor,
+            basal: basal,
+            fraction: fraction,
+            maxBolus: maxBolus
+        )
+        let standardResult = await calculator.calculateInsulin(input: standardInput)
+
+        // STEP 5: Verify results
+        // Fatty meal should reduce the insulin amount by the fatty meal factor (0.8)
+        let expectedReduction = fattyMealFactor
+        let actualReduction = Decimal(
+            (Double(fattyMealResult.insulinCalculated) / Double(standardResult.insulinCalculated) * 10.0).rounded() / 10.0
+        )
+
+        #expect(
+            actualReduction == expectedReduction,
+            """
+            Fatty meal calculation incorrect
+            Expected reduction factor: \(expectedReduction)
+            Actual reduction factor: \(actualReduction)
+            Standard calculation: \(standardResult.insulinCalculated)U
+            Fatty meal calculation: \(fattyMealResult.insulinCalculated)U
+            """
+        )
+    }
+
+    @Test("Calculate insulin with super bolus") func testSuperBolusCalculation() async throws {
+        // STEP 1: Setup test scenario
+        // We need to provide a CalculationInput struct
+        let carbs: Decimal = 80
+        let currentBG: Decimal = 180 // 80 points above target, should result in 2U correction
+        let deltaBG: Decimal = 5 // Rising trend, should add small correction
+        let target: Decimal = 100
+        let isf: Decimal = 40
+        let carbRatio: Decimal = 10 // Should result in 8U for carbs
+        let iob: Decimal = 1.0 // Should subtract from final result
+        let cob: Int16 = 20
+        let useFattyMealCorrectionFactor: Bool = false
+        let useSuperBolus: Bool = true // Super bolus enabled
+        let fattyMealFactor: Decimal = 0.8
+        let sweetMealFactor: Decimal = 2
+        let basal: Decimal = 1.5 // Will be added to insulin calculation when super bolus is enabled
+        let fraction: Decimal = 0.8
+        let maxBolus: Decimal = 10
+
+        // STEP 2: Create calculation input with super bolus enabled
+        let input = CalculationInput(
+            carbs: carbs,
+            currentBG: currentBG,
+            deltaBG: deltaBG,
+            target: target,
+            isf: isf,
+            carbRatio: carbRatio,
+            iob: iob,
+            cob: cob,
+            useFattyMealCorrectionFactor: useFattyMealCorrectionFactor,
+            fattyMealFactor: fattyMealFactor,
+            useSuperBolus: useSuperBolus,
+            sweetMealFactor: sweetMealFactor,
+            basal: basal,
+            fraction: fraction,
+            maxBolus: maxBolus
+        )
+
+        // STEP 3: Calculate insulin with super bolus enabled
+        let superBolusResult = await calculator.calculateInsulin(input: input)
+
+        // STEP 4: Calculate insulin with super bolus disabled for comparison
+        let standardInput = CalculationInput(
+            carbs: carbs,
+            currentBG: currentBG,
+            deltaBG: deltaBG,
+            target: target,
+            isf: isf,
+            carbRatio: carbRatio,
+            iob: iob,
+            cob: cob,
+            useFattyMealCorrectionFactor: useFattyMealCorrectionFactor,
+            fattyMealFactor: fattyMealFactor,
+            useSuperBolus: false, // Disabled for comparison
+            sweetMealFactor: sweetMealFactor,
+            basal: basal,
+            fraction: fraction,
+            maxBolus: maxBolus
+        )
+        let standardResult = await calculator.calculateInsulin(input: standardInput)
+
+        // STEP 5: Verify results
+        // Super bolus should add basal rate * sweetMealFactor to the insulin calculation
+        let expectedSuperBolusInsulin = basal * sweetMealFactor
+        #expect(
+            superBolusResult.superBolusInsulin == expectedSuperBolusInsulin,
+            """
+            Super bolus insulin incorrect
+            Expected: \(expectedSuperBolusInsulin)U (basal \(basal)U × sweetMealFactor \(sweetMealFactor))
+            Actual: \(superBolusResult.superBolusInsulin)U
+            """
+        )
+
+        #expect(
+            superBolusResult.insulinCalculated > standardResult.insulinCalculated,
+            """
+            Super bolus calculation incorrect
+            Expected super bolus calculation to be higher than standard
+            Super bolus: \(superBolusResult.insulinCalculated)U
+            Standard: \(standardResult.insulinCalculated)U
+            Difference: \(superBolusResult.insulinCalculated - standardResult.insulinCalculated)U
+            """
+        )
+
+        // The difference should be exactly the basal rate * sweetMealFactor
+        let actualDifference = (superBolusResult.insulinCalculated - standardResult.insulinCalculated)
+        #expect(
+            actualDifference == expectedSuperBolusInsulin,
+            """
+            Super bolus difference incorrect
+            Expected difference: \(expectedSuperBolusInsulin)U (basal \(basal)U × sweetMealFactor \(sweetMealFactor))
+            Actual difference: \(actualDifference)U
+            Standard result: \(standardResult)
+            SuperBolus result: \(superBolusResult)
+            """
+        )
+    }
+
+    @Test("Calculate insulin with zero carbs") func testZeroCarbsCalculation() async throws {
+        // Given
+        let carbs: Decimal = 0
+
+        // When
+        let result = await calculator.handleBolusCalculation(
+            carbs: carbs,
+            useFattyMealCorrection: false,
+            useSuperBolus: false
+        )
+
+        // Then
+        #expect(result.wholeCobInsulin == 0, "Zero carbs should require no insulin for carbs")
+    }
+
+    @Test("Verify settings retrieval") func testGetSettings() async throws {
+        // Given - Save original settings to restore later
+        let originalSettings = settingsManager.settings
+
+        // Setup test settings
+        let expectedUnits = GlucoseUnits.mgdL
+        let expectedFraction: Decimal = 0.7
+        let expectedFattyMealFactor: Decimal = 0.8
+        let expectedSweetMealFactor: Decimal = 2
+        let expectedMaxCarbs: Decimal = 150
+
+        // Update settings through settings manager
+        settingsManager.settings.units = expectedUnits
+        settingsManager.settings.overrideFactor = expectedFraction
+        settingsManager.settings.fattyMealFactor = expectedFattyMealFactor
+        settingsManager.settings.sweetMealFactor = expectedSweetMealFactor
+        settingsManager.settings.maxCarbs = expectedMaxCarbs
+
+        // Save settings to storage
+        fileStorage.save(settingsManager.settings, as: OpenAPS.Settings.settings)
+
+        // When
+        let (units, fraction, fattyMealFactor, sweetMealFactor, maxCarbs) = await getSettings()
+
+        // Then
+        #expect(units == expectedUnits, "Units should match settings")
+        #expect(fraction == expectedFraction, "Override factor should match settings")
+        #expect(fattyMealFactor == expectedFattyMealFactor, "Fatty meal factor should match settings")
+        #expect(sweetMealFactor == expectedSweetMealFactor, "Sweet meal factor should match settings")
+        #expect(maxCarbs == expectedMaxCarbs, "Max carbs should match settings")
+
+        // Cleanup - Restore original settings
+        settingsManager.settings = originalSettings
+        fileStorage.save(originalSettings, as: OpenAPS.Settings.settings)
+    }
+
+    @Test("Verify getCurrentSettingValue returns correct values based on time") func testGetCurrentSettingValue() async throws {
+        // STEP 1: Backup current settings
+        let originalBasalProfile = await fileStorage.retrieveAsync(OpenAPS.Settings.basalProfile, as: [BasalProfileEntry].self)
+        let originalCarbRatios = await fileStorage.retrieveAsync(OpenAPS.Settings.carbRatios, as: CarbRatios.self)
+        let originalBGTargets = await fileStorage.retrieveAsync(OpenAPS.Settings.bgTargets, as: BGTargets.self)
+        let originalISFValues = await fileStorage.retrieveAsync(
+            OpenAPS.Settings.insulinSensitivities,
+            as: InsulinSensitivities.self
+        )
+
+        // STEP 2: Setup test data with known values
+        // Note: Entries must be sorted by time for the algorithm to work correctly
+        let basalProfile = [
+            BasalProfileEntry(start: "00:00", minutes: 0, rate: 1.0), // 12:00 AM - 6:00 AM: 1.0
+            BasalProfileEntry(start: "06:00", minutes: 360, rate: 1.2), // 6:00 AM - 12:00 PM: 1.2
+            BasalProfileEntry(start: "12:00", minutes: 720, rate: 1.1), // 12:00 PM - 6:00 PM: 1.1
+            BasalProfileEntry(start: "18:00", minutes: 1080, rate: 0.9) // 6:00 PM - 12:00 AM: 0.9
+        ]
+
+        let carbRatios = CarbRatios(
+            units: .grams,
+            schedule: [
+                CarbRatioEntry(start: "00:00", offset: 0, ratio: 10), // 12:00 AM - 12:00 PM: 10
+                CarbRatioEntry(start: "12:00", offset: 720, ratio: 12) // 12:00 PM - 12:00 AM: 12
+            ]
+        )
+
+        let bgTargets = BGTargets(
+            units: .mgdL,
+            userPreferredUnits: .mgdL,
+            targets: [
+                BGTargetEntry(low: 100, high: 120, start: "00:00", offset: 0), // 12:00 AM - 8:00 AM: 100
+                BGTargetEntry(low: 90, high: 110, start: "08:00", offset: 480) // 8:00 AM - 12:00 AM: 90
+            ]
+        )
+
+        let isfValues = InsulinSensitivities(
+            units: .mgdL,
+            userPreferredUnits: .mgdL,
+            sensitivities: [
+                InsulinSensitivityEntry(sensitivity: 40, offset: 0, start: "00:00"), // 12:00 AM - 2:00 PM: 40
+                InsulinSensitivityEntry(sensitivity: 45, offset: 840, start: "14:00") // 2:00 PM - 12:00 AM: 45
+            ]
+        )
+
+        // STEP 3: Store test data
+        fileStorage.save(basalProfile, as: OpenAPS.Settings.basalProfile)
+        fileStorage.save(carbRatios, as: OpenAPS.Settings.carbRatios)
+        fileStorage.save(bgTargets, as: OpenAPS.Settings.bgTargets)
+        fileStorage.save(isfValues, as: OpenAPS.Settings.insulinSensitivities)
+
+        // STEP 4: Define test cases with specific times and expected values
+        // Format: (hour, minute, [setting type: expected value])
+        let testTimes: [(hour: Int, minute: Int, expected: [SettingType: Decimal])] = [
+            // Test midnight values (00:00)
+            (
+                hour: 0, minute: 0,
+                expected: [
+                    .basal: 1.0, // First basal rate
+                    .carbRatio: 10, // First carb ratio
+                    .bgTarget: 100, // First target
+                    .isf: 40 // First ISF
+                ]
+            ),
+            // Test mid-morning values (7:00)
+            (
+                hour: 7, minute: 0,
+                expected: [
+                    .basal: 1.2, // Second basal rate (after 6:00)
+                    .carbRatio: 10, // Still first carb ratio
+                    .bgTarget: 100, // Still first target
+                    .isf: 40 // Still first ISF
+                ]
+            ),
+            // Test afternoon values (15:00)
+            (
+                hour: 15, minute: 0,
+                expected: [
+                    .basal: 1.1, // Third basal rate (after 12:00)
+                    .carbRatio: 12, // Second carb ratio (after 12:00)
+                    .bgTarget: 90, // Second target
+                    .isf: 45 // Second ISF (after 14:00)
+                ]
+            )
+        ]
+
+        // STEP 5: Test each time point
+        for testTime in testTimes {
+            // Create a date object for the test time
+            let calendar = Calendar.current
+            var components = calendar.dateComponents([.year, .month, .day], from: Date())
+            components.hour = testTime.hour
+            components.minute = testTime.minute
+            components.second = 0
+            guard let testDate = calendar.date(from: components) else {
+                throw TestError("Failed to create test date")
+            }
+
+            // Test each setting type at this time
+            for (type, expectedValue) in testTime.expected {
+                // Get the actual value for this setting at the test time
+                let value = await getCurrentSettingValue(for: type, at: testDate)
+
+                // Compare with expected value
+                #expect(
+                    value == expectedValue,
+                    """
+                    Failed at \(testTime.hour):\(String(format: "%02d", testTime.minute))
+                    Setting: \(type)
+                    Expected: \(expectedValue)
+                    Actual: \(value)
+                    """
+                )
+            }
+        }
+
+        // STEP 6: Cleanup - Restore original settings
+        if let originalBasalProfile = originalBasalProfile {
+            fileStorage.save(originalBasalProfile, as: OpenAPS.Settings.basalProfile)
+        }
+        if let originalCarbRatios = originalCarbRatios {
+            fileStorage.save(originalCarbRatios, as: OpenAPS.Settings.carbRatios)
+        }
+        if let originalBGTargets = originalBGTargets {
+            fileStorage.save(originalBGTargets, as: OpenAPS.Settings.bgTargets)
+        }
+        if let originalISFValues = originalISFValues {
+            fileStorage.save(originalISFValues, as: OpenAPS.Settings.insulinSensitivities)
+        }
+    }
+}
+
+// Copied over from BolusCalculationManager as they are not included in the protocol definition (and I don´t want them to be included)
+
+extension BolusCalculatorTests {
+    private enum SettingType {
+        case basal
+        case carbRatio
+        case bgTarget
+        case isf
+    }
+
+    /// Retrieves current settings from the SettingsManager
+    /// - Returns: Tuple containing units, fraction, fattyMealFactor, sweetMealFactor, and maxCarbs settings
+    private func getSettings() async -> (
+        units: GlucoseUnits,
+        fraction: Decimal,
+        fattyMealFactor: Decimal,
+        sweetMealFactor: Decimal,
+        maxCarbs: Decimal
+    ) {
+        return (
+            units: settingsManager.settings.units,
+            fraction: settingsManager.settings.overrideFactor,
+            fattyMealFactor: settingsManager.settings.fattyMealFactor,
+            sweetMealFactor: settingsManager.settings.sweetMealFactor,
+            maxCarbs: settingsManager.settings.maxCarbs
+        )
+    }
+
+    /// Gets the current setting value for a specific setting type based on the time of day
+    /// - Parameter type: The type of setting to retrieve (basal, carbRatio, bgTarget, or isf)
+    /// - Returns: The current decimal value for the specified setting type
+    private func getCurrentSettingValue(for type: SettingType, at date: Date) async -> Decimal {
+        let calendar = Calendar.current
+        let midnight = calendar.startOfDay(for: date)
+        let minutesSinceMidnight = calendar.dateComponents([.minute], from: midnight, to: date).minute ?? 0
+
+        switch type {
+        case .basal:
+            let profile = await getBasalProfile()
+            return profile.last { $0.minutes <= minutesSinceMidnight }?.rate ?? 0
+
+        case .carbRatio:
+            let ratios = await getCarbRatios()
+            return ratios.schedule.last { $0.offset <= minutesSinceMidnight }?.ratio ?? 0
+
+        case .bgTarget:
+            let targets = await getBGTargets()
+            return targets.targets.last { $0.offset <= minutesSinceMidnight }?.low ?? 0
+
+        case .isf:
+            let sensitivities = await getISFValues()
+            return sensitivities.sensitivities.last { $0.offset <= minutesSinceMidnight }?.sensitivity ?? 0
+        }
+    }
+
+    /// Retrieves the pump settings from storage
+    /// - Returns: PumpSettings object containing pump configuration
+    private func getPumpSettings() async -> PumpSettings {
+        await fileStorage.retrieveAsync(OpenAPS.Settings.settings, as: PumpSettings.self)
+            ?? PumpSettings(from: OpenAPS.defaults(for: OpenAPS.Settings.settings))
+            ?? PumpSettings(insulinActionCurve: 10, maxBolus: 10, maxBasal: 2)
+    }
+
+    /// Retrieves the basal profile from storage
+    /// - Returns: Array of BasalProfileEntry objects
+    private func getBasalProfile() async -> [BasalProfileEntry] {
+        await fileStorage.retrieveAsync(OpenAPS.Settings.basalProfile, as: [BasalProfileEntry].self)
+            ?? [BasalProfileEntry](from: OpenAPS.defaults(for: OpenAPS.Settings.basalProfile))
+            ?? []
+    }
+
+    /// Retrieves carb ratios from storage
+    /// - Returns: CarbRatios object containing carb ratio schedule
+    private func getCarbRatios() async -> CarbRatios {
+        await fileStorage.retrieveAsync(OpenAPS.Settings.carbRatios, as: CarbRatios.self)
+            ?? CarbRatios(from: OpenAPS.defaults(for: OpenAPS.Settings.carbRatios))
+            ?? CarbRatios(units: .grams, schedule: [])
+    }
+
+    /// Retrieves blood glucose targets from storage
+    /// - Returns: BGTargets object containing target schedule
+    private func getBGTargets() async -> BGTargets {
+        await fileStorage.retrieveAsync(OpenAPS.Settings.bgTargets, as: BGTargets.self)
+            ?? BGTargets(from: OpenAPS.defaults(for: OpenAPS.Settings.bgTargets))
+            ?? BGTargets(units: .mgdL, userPreferredUnits: .mgdL, targets: [])
+    }
+
+    /// Retrieves insulin sensitivity factors from storage
+    /// - Returns: InsulinSensitivities object containing sensitivity schedule
+    private func getISFValues() async -> InsulinSensitivities {
+        await fileStorage.retrieveAsync(OpenAPS.Settings.insulinSensitivities, as: InsulinSensitivities.self)
+            ?? InsulinSensitivities(from: OpenAPS.defaults(for: OpenAPS.Settings.insulinSensitivities))
+            ?? InsulinSensitivities(
+                units: .mgdL,
+                userPreferredUnits: .mgdL,
+                sensitivities: []
+            )
+    }
+}

+ 106 - 29
TrioTests/CalibrationsTests.swift

@@ -1,59 +1,136 @@
+import Foundation
 import Swinject
+import Testing
+
 @testable import Trio
-import XCTest
 
-class CalibrationsTests: XCTestCase, Injectable {
+@Suite("Calibration Service Tests") struct CalibrationTests: Injectable {
     let fileStorage = BaseFileStorage()
     @Injected() var calibrationService: CalibrationService!
     let resolver = TrioApp().resolver
 
-    override func setUp() {
+    init() {
         injectServices(resolver)
     }
 
-    func testCreateSimpleCalibration() {
-        // restore state so each test is independent
+    @Test("Can create simple calibration") func testCreateSimpleCalibration() {
+        // Given
         calibrationService.removeAllCalibrations()
-
         let calibration = Calibration(x: 100.0, y: 102.0)
-        calibrationService.addCalibration(calibration)
-
-        XCTAssertTrue(calibrationService.calibrations.isNotEmpty)
-
-        XCTAssertTrue(calibrationService.slope == 1)
 
-        XCTAssertTrue(calibrationService.intercept == 2)
+        // When
+        calibrationService.addCalibration(calibration)
 
-        XCTAssertTrue(calibrationService.calibrate(value: 104) == 106)
+        // Then
+        #expect(calibrationService.calibrations.isNotEmpty)
+        #expect(calibrationService.slope == 1)
+        #expect(calibrationService.intercept == 2)
+        #expect(calibrationService.calibrate(value: 104) == 106)
     }
 
-    func testCreateMultipleCalibration() {
-        // restore state so each test is independent
+    @Test("Can handle multiple calibrations") func testCreateMultipleCalibration() {
+        // Given
         calibrationService.removeAllCalibrations()
-
         let calibration = Calibration(x: 100.0, y: 120)
-        calibrationService.addCalibration(calibration)
-
         let calibration2 = Calibration(x: 120.0, y: 130.0)
+
+        // When
+        calibrationService.addCalibration(calibration)
         calibrationService.addCalibration(calibration2)
 
-        XCTAssertEqual(calibrationService.slope, 0.8, accuracy: 0.0001)
-        XCTAssertEqual(calibrationService.intercept, 37, accuracy: 0.0001)
-        XCTAssertEqual(calibrationService.calibrate(value: 80), 101, accuracy: 0.0001)
+        // Then
+        #expect(abs(calibrationService.slope - 0.8) < 0.0001)
+        #expect(abs(calibrationService.intercept - 37) < 0.0001)
+        #expect(abs(calibrationService.calibrate(value: 80) - 101) < 0.0001)
 
+        // When removing last
         calibrationService.removeLast()
+        #expect(calibrationService.calibrations.count == 1)
 
-        XCTAssertTrue(calibrationService.calibrations.count == 1)
-
+        // When removing all
         calibrationService.removeAllCalibrations()
-        XCTAssertTrue(calibrationService.calibrations.isEmpty)
+        #expect(calibrationService.calibrations.isEmpty)
     }
 
-    override func setUpWithError() throws {
-        // Put setup code here. This method is called before the invocation of each test method in the class.
-    }
+    @Test("Handles calibration bounds correctly") func testCalibrationBounds() {
+        // Given
+        calibrationService.removeAllCalibrations()
 
-    override func tearDownWithError() throws {
-        // Put teardown code here. This method is called after the invocation of each test method in the class.
+        // When no calibrations exist
+        #expect(calibrationService.slope == 1, "Default slope should be 1")
+        #expect(calibrationService.intercept == 0, "Default intercept should be 0")
+
+        // When adding extreme values
+        let extremeCalibration1 = Calibration(x: 0.0, y: 1000.0) // Should be clamped
+        let extremeCalibration2 = Calibration(x: 1000.0, y: 0.0) // Should be clamped
+
+        calibrationService.addCalibration(extremeCalibration1)
+        calibrationService.addCalibration(extremeCalibration2)
+
+        // Then check bounds
+        #expect(calibrationService.slope >= 0.8, "Slope should not be less than minimum")
+        #expect(calibrationService.slope <= 1.25, "Slope should not be more than maximum")
+        #expect(calibrationService.intercept >= -100, "Intercept should not be less than minimum")
+        #expect(calibrationService.intercept <= 100, "Intercept should not be more than maximum")
     }
 }
+
+// import Swinject
+// @testable import Trio
+// import XCTest
+//
+// class CalibrationsTests: XCTestCase, Injectable {
+//    let fileStorage = BaseFileStorage()
+//    @Injected() var calibrationService: CalibrationService!
+//    let resolver = TrioApp().resolver
+//
+//    override func setUp() {
+//        injectServices(resolver)
+//    }
+//
+//    func testCreateSimpleCalibration() {
+//        // restore state so each test is independent
+//        calibrationService.removeAllCalibrations()
+//
+//        let calibration = Calibration(x: 100.0, y: 102.0)
+//        calibrationService.addCalibration(calibration)
+//
+//        XCTAssertTrue(calibrationService.calibrations.isNotEmpty)
+//
+//        XCTAssertTrue(calibrationService.slope == 1)
+//
+//        XCTAssertTrue(calibrationService.intercept == 2)
+//
+//        XCTAssertTrue(calibrationService.calibrate(value: 104) == 106)
+//    }
+//
+//    func testCreateMultipleCalibration() {
+//        // restore state so each test is independent
+//        calibrationService.removeAllCalibrations()
+//
+//        let calibration = Calibration(x: 100.0, y: 120)
+//        calibrationService.addCalibration(calibration)
+//
+//        let calibration2 = Calibration(x: 120.0, y: 130.0)
+//        calibrationService.addCalibration(calibration2)
+//
+//        XCTAssertEqual(calibrationService.slope, 0.8, accuracy: 0.0001)
+//        XCTAssertEqual(calibrationService.intercept, 37, accuracy: 0.0001)
+//        XCTAssertEqual(calibrationService.calibrate(value: 80), 101, accuracy: 0.0001)
+//
+//        calibrationService.removeLast()
+//
+//        XCTAssertTrue(calibrationService.calibrations.count == 1)
+//
+//        calibrationService.removeAllCalibrations()
+//        XCTAssertTrue(calibrationService.calibrations.isEmpty)
+//    }
+//
+//    override func setUpWithError() throws {
+//        // Put setup code here. This method is called before the invocation of each test method in the class.
+//    }
+//
+//    override func tearDownWithError() throws {
+//        // Put teardown code here. This method is called after the invocation of each test method in the class.
+//    }
+// }

+ 246 - 0
TrioTests/CoreDataTests/DeterminationStorageTests.swift

@@ -0,0 +1,246 @@
+import CoreData
+import Foundation
+import Swinject
+import Testing
+
+@testable import Trio
+
+@Suite(.serialized) struct DeterminationStorageTests: Injectable {
+    @Injected() var storage: DeterminationStorage!
+    let resolver: Resolver
+    let coreDataStack = CoreDataStack.createForTests()
+    let testContext: NSManagedObjectContext
+
+    init() {
+        // Create test context
+        // As we are only using this single test context to initialize our in-memory DeterminationStorage we need to perform the Unit Tests serialized
+        testContext = coreDataStack.newTaskContext()
+
+        // Create assembler with test assembly
+        let assembler = Assembler([
+            StorageAssembly(),
+            ServiceAssembly(),
+            APSAssembly(),
+            NetworkAssembly(),
+            UIAssembly(),
+            SecurityAssembly(),
+            TestAssembly(testContext: testContext) // Add our test assembly last to override Storage
+        ])
+
+        resolver = assembler.resolver
+        injectServices(resolver)
+    }
+
+    @Test("Storage is correctly initialized") func testStorageInitialization() {
+        // Verify storage exists
+        #expect(storage != nil, "DeterminationStorage should be injected")
+
+        // Verify it's the correct type
+        #expect(storage is BaseDeterminationStorage, "Storage should be of type BaseDeterminationStorage")
+    }
+
+    @Test("Test fetchLastDeterminationObjectID with different predicates") func testFetchLastDeterminationWithPredicates() async throws {
+        // Given
+        let date = Date()
+        let id = UUID()
+
+        // Create a mock determination
+        await testContext.perform {
+            let determination = OrefDetermination(context: testContext)
+            determination.id = id
+            determination.deliverAt = date
+            determination.timestamp = date
+            determination.enacted = true
+            determination.isUploadedToNS = true
+            try? testContext.save()
+        }
+
+        // Tests with predicates that we use the most for this function
+        // 1. Test within 30 minutes
+        let results = await storage.fetchLastDeterminationObjectID(predicate: NSPredicate.predicateFor30MinAgoForDetermination)
+        #expect(results.count == 1, "Should find 1 determination within 30 minutes")
+        // Get NSManagedObjectID from exactDateResults
+        try await testContext.perform {
+            do {
+                guard let results = results.first,
+                      let object = try testContext.existingObject(with: results) as? OrefDetermination
+                else {
+                    throw TestError("Failed to fetch determination")
+                }
+                #expect(object.timestamp == date, "Determination within 30 minutes should have the same timestamp as date")
+                #expect(object.deliverAt == date, "Determination within 30 minutes should have the same deliverAt as date")
+                #expect(object.enacted == true, "Determination within 30 minutes should be enacted")
+                #expect(object.isUploadedToNS == true, "Determination within 30 minutes should be uploaded to NS")
+                #expect(object.id == id, "Determination within 30 minutes should have the same id")
+            } catch {
+                throw TestError("Failed to fetch determination")
+            }
+        }
+
+        // 2. Test enacted determinations
+        let enactedPredicate = NSPredicate.enactedDetermination
+        let enactedResults = await storage.fetchLastDeterminationObjectID(predicate: enactedPredicate)
+        #expect(enactedResults.count == 1, "Should find 1 enacted determination")
+        // Get NSManagedObjectID from enactedResults
+        try await testContext.perform {
+            do {
+                guard let results = enactedResults.first,
+                      let object = try testContext.existingObject(with: results) as? OrefDetermination
+                else {
+                    throw TestError("Failed to fetch determination")
+                }
+                #expect(object.enacted == true, "Enacted determination should be enacted")
+                #expect(object.isUploadedToNS == true, "Enacted determination should be uploaded to NS")
+                #expect(object.id == id, "Enacted determination should have the same id")
+                #expect(object.timestamp == date, "Enacted determination should have the same timestamp")
+                #expect(object.deliverAt == date, "Enacted determination should have the same deliverAt")
+
+                // Delete the determination
+                testContext.delete(object)
+                try testContext.save()
+            } catch {
+                throw TestError("Failed to fetch determination")
+            }
+        }
+    }
+
+//    @Test("Test complete forecast hierarchy prefetching") func testForecastHierarchyPrefetching() async throws {
+//        // Given
+//        let date = Date()
+//        let backgroundContext = CoreDataStack.shared.newTaskContext()
+//        let forecastTypes = ["iob", "cob", "zt", "uam"]
+//
+//        // Create test determination with complete forecast hierarchy
+//        let determination = OrefDetermination(context: backgroundContext)
+//        determination.id = UUID()
+//        determination.deliverAt = date
+//
+//        // Create all forecast types with values
+//        for type in forecastTypes {
+//            let forecast = Forecast(context: backgroundContext)
+//            forecast.id = UUID()
+//            forecast.date = date
+//            forecast.type = type
+//            forecast.orefDetermination = determination
+//
+//            // Add test values
+//            for i in 0 ..< 5 {
+//                let value = ForecastValue(context: backgroundContext)
+//                value.index = Int32(i)
+//                value.value = Int32(100 + (i * 10))
+//                value.forecast = forecast
+//            }
+//        }
+//
+//        try await backgroundContext.save()
+//
+//        // When - Fetch complete hierarchy
+//        let request = NSFetchRequest<OrefDetermination>(entityName: "OrefDetermination")
+//        request.predicate = NSPredicate(format: "SELF = %@", determination.objectID)
+//        request.relationshipKeyPathsForPrefetching = ["forecasts", "forecasts.forecastValues"]
+//
+//        let fetchedDetermination = try await backgroundContext.perform {
+//            try request.execute().first
+//        }
+//
+//        // Then
+//        #expect(fetchedDetermination != nil)
+//        #expect(fetchedDetermination?.forecasts?.count == 4)
+//
+//        let forecasts = fetchedDetermination?.forecasts?.allObjects as? [Forecast] ?? []
+//        for forecast in forecasts {
+//            #expect(forecastTypes.contains(forecast.type ?? ""))
+//            #expect(forecast.forecastValues?.count == 5)
+//
+//            let values = forecast.forecastValuesArray
+//            #expect(values.count == 5)
+//            for (index, value) in values.enumerated() {
+//                #expect(value.value == Int32(100 + (index * 10)))
+//            }
+//        }
+//
+//        // Cleanup
+//        await backgroundContext.perform {
+//            backgroundContext.delete(determination)
+//            try? backgroundContext.save()
+//        }
+//    }
+
+//    @Test("Test forecast handling with multiple forecast types") func testMultipleForecastTypes() async throws {
+//        // Given
+//        let date = Date()
+//        let backgroundContext = CoreDataStack.shared.newTaskContext()
+//        var determinationId: NSManagedObjectID?
+//        let forecastTypes = ["iob", "cob", "zt", "uam"]
+//        var forecastIds: [NSManagedObjectID] = []
+//
+//        // Create test data with multiple forecast types
+//        await backgroundContext.perform {
+//            let determination = OrefDetermination(context: backgroundContext)
+//            determination.id = UUID()
+//            determination.deliverAt = date
+//
+//            // Create forecasts for each type
+//            for type in forecastTypes {
+//                let forecast = Forecast(context: backgroundContext)
+//                forecast.id = UUID()
+//                forecast.date = date
+//                forecast.type = type
+//                forecast.orefDetermination = determination
+//
+//                // Add some values
+//                for i in 0 ..< 3 {
+//                    let value = ForecastValue(context: backgroundContext)
+//                    value.index = Int32(i)
+//                    value.value = Int32(100 + i * 10)
+//                    value.forecast = forecast
+//                }
+//
+//                forecastIds.append(forecast.objectID)
+//            }
+//
+//            try? backgroundContext.save()
+//            determinationId = determination.objectID
+//        }
+//
+//        guard let determinationId = determinationId else {
+//            throw TestError("Failed to create test data")
+//        }
+//
+//        // When - Fetch all forecasts
+//        let allForecastIds = await storage.getForecastIDs(for: determinationId, in: backgroundContext)
+//
+//        // Then
+//        #expect(allForecastIds.count == forecastTypes.count, "Should have found all forecast types")
+//
+//        // Test each forecast type
+//        for forecastId in forecastIds {
+//            // When - Fetch values for this forecast
+//            let valueIds = await storage.getForecastValueIDs(for: forecastId, in: backgroundContext)
+//
+//            // Then
+//            #expect(valueIds.count == 3, "Each forecast should have 3 values")
+//
+//            // When - Fetch complete objects
+//            let (_, forecast, values) = await storage.fetchForecastObjects(
+//                for: (UUID(), forecastId, valueIds),
+//                in: backgroundContext
+//            )
+//
+//            // Then
+//            #expect(forecast != nil, "Should have found the forecast")
+//            #expect(forecastTypes.contains(forecast?.type ?? ""), "Should have valid forecast type")
+//            #expect(values.count == 3, "Should have all forecast values")
+//        }
+//
+//        // Test Nightscout format with multiple forecasts
+//        let nsFormat = await storage.getOrefDeterminationNotYetUploadedToNightscout([determinationId])
+//        #expect(nsFormat?.predictions?.iob?.count == 3, "Should have IOB predictions")
+//        #expect(nsFormat?.predictions?.cob?.count == 3, "Should have COB predictions")
+//        #expect(nsFormat?.predictions?.zt?.count == 3, "Should have ZT predictions")
+//        #expect(nsFormat?.predictions?.uam?.count == 3, "Should have UAM predictions")
+//
+//        // Cleanup
+//        await CoreDataStack.shared.deleteObject(identifiedBy: determinationId)
+//    }
+}

+ 296 - 0
TrioTests/CoreDataTests/PumpHistoryStorageTests.swift

@@ -0,0 +1,296 @@
+import CoreData
+import Foundation
+import Swinject
+import Testing
+
+@testable import LoopKit
+@testable import Trio
+
+@Suite(.serialized) struct PumpHistoryStorageTests: Injectable {
+    @Injected() var storage: PumpHistoryStorage!
+    let resolver: Resolver
+    let coreDataStack = CoreDataStack.createForTests()
+    let testContext: NSManagedObjectContext
+    typealias PumpEvent = PumpEventStored.EventType
+
+    init() {
+        // Create test context
+        // As we are only using this single test context to initialize our in-memory PumpHistoryStorage we need to perform the Unit Tests serialized
+        // TODO: is this really correct or does PersistentHistoryTracking also work in this in memory Coredata stack. This would allow me to use a single test context per test and perform the tests in parallel!
+        testContext = coreDataStack.newTaskContext()
+
+        // Create assembler with test assembly
+        let assembler = Assembler([
+            StorageAssembly(),
+            ServiceAssembly(),
+            APSAssembly(),
+            NetworkAssembly(),
+            UIAssembly(),
+            SecurityAssembly(),
+            TestAssembly(testContext: testContext) // Add our test assembly last to override PumpHistoryStorage
+        ])
+
+        resolver = assembler.resolver
+        injectServices(resolver)
+    }
+
+    @Test("Storage is correctly initialized") func testStorageInitialization() {
+        // Verify storage exists
+        #expect(storage != nil, "PumpHistoryStorage should be injected")
+
+        // Verify it's the correct type
+        #expect(
+            storage is BasePumpHistoryStorage, "Storage should be of type BasePumpHistoryStorage"
+        )
+
+        // Verify we can access the update publisher
+        #expect(storage.updatePublisher != nil, "Update publisher should be available")
+    }
+
+    @Test("Test read and delete using generic CoreDataStack functions") func testFetchAndDeletePumpEvents() async throws {
+        // Given
+        let date = Date()
+
+        // Insert mock entry
+        let events: [LoopKit.NewPumpEvent] = [
+            LoopKit.NewPumpEvent(
+                date: date,
+                dose: LoopKit.DoseEntry(
+                    type: .bolus,
+                    startDate: date,
+                    value: 0.5,
+                    unit: .units,
+                    deliveredUnits: nil,
+                    description: nil,
+                    syncIdentifier: nil,
+                    scheduledBasalRate: nil,
+                    insulinType: .lyumjev,
+                    automatic: false,
+                    manuallyEntered: false,
+                    isMutable: false
+                ),
+                raw: Data(),
+                title: "Test Bolus for Fetch",
+                type: .bolus
+            )
+        ]
+
+        // Store test event and wait for storage to complete the task
+        await storage.storePumpEvents(events)
+//        try await Task.sleep(nanoseconds: 1_000_000_000)
+
+        // When - Fetch events with our generic fetch function
+        let fetchedEvents = await coreDataStack.fetchEntitiesAsync(
+            ofType: PumpEventStored.self,
+            onContext: testContext,
+            predicate: NSPredicate(
+                format: "type == %@ AND timestamp == %@",
+                PumpEvent.bolus.rawValue,
+                date as NSDate
+            ),
+            key: "timestamp",
+            ascending: false
+        )
+
+        guard let fetchedEvents = fetchedEvents as? [PumpEventStored] else { return }
+
+        // Then
+        #expect(fetchedEvents.count == 1, "Should have found exactly one event")
+        let fetchedEvent = fetchedEvents.first
+        #expect(fetchedEvent?.type == PumpEvent.bolus.rawValue, "Should be a bolus event")
+        #expect(fetchedEvent?.bolus?.amount as? Decimal == 0.5, "Bolus amount should be 0.5")
+        #expect(
+            abs(fetchedEvent?.timestamp?.timeIntervalSince(date) ?? 1) < 1,
+            "Timestamp should match"
+        )
+
+        // When - Delete event
+        if let fetchedEvent = fetchedEvent {
+            await coreDataStack.deleteObject(identifiedBy: fetchedEvent.objectID)
+        }
+
+        // Then - Verify deletion
+        let eventsAfterDeletion = await coreDataStack.fetchEntitiesAsync(
+            ofType: PumpEventStored.self,
+            onContext: testContext,
+            predicate: NSPredicate(
+                format: "type == %@ AND timestamp == %@",
+                PumpEvent.bolus.rawValue,
+                date as NSDate
+            ),
+            key: "timestamp",
+            ascending: false
+        )
+
+        guard let eventsAfterDeletion = eventsAfterDeletion as? [PumpEventStored] else { return }
+
+        #expect(eventsAfterDeletion.isEmpty, "Should have no events after deletion")
+    }
+
+    @Test("Test store function in PumpHistoryStorage") func testStorePumpEvents() async throws {
+        // Given
+        let date = Date()
+        let tenMinAgo = date.addingTimeInterval(-10.minutes.timeInterval)
+        let halfHourInFuture = date.addingTimeInterval(30.minutes.timeInterval)
+
+        // Get initial entries to compare to final entries later
+        let initialEntries = try await testContext.perform {
+            try testContext.fetch(PumpEventStored.fetchRequest())
+        }
+
+        // Create 2 test events, 1 bolus + 1 temp basal event
+        let events: [LoopKit.NewPumpEvent] = [
+            // SMB
+            LoopKit.NewPumpEvent(
+                date: tenMinAgo,
+                dose: LoopKit.DoseEntry(
+                    type: .bolus,
+                    startDate: tenMinAgo,
+                    value: 0.4,
+                    unit: .units,
+                    deliveredUnits: nil,
+                    description: nil,
+                    syncIdentifier: nil,
+                    scheduledBasalRate: nil,
+                    insulinType: .lyumjev,
+                    automatic: true,
+                    manuallyEntered: false,
+                    isMutable: false
+                ),
+                raw: Data(),
+                title: "Test Bolus",
+                type: .bolus
+            ),
+            // Temp Basal event
+            LoopKit.NewPumpEvent(
+                date: date,
+                dose: LoopKit.DoseEntry(
+                    type: .tempBasal,
+                    startDate: date,
+                    endDate: halfHourInFuture,
+                    value: 1.2,
+                    unit: .unitsPerHour,
+                    deliveredUnits: nil,
+                    description: nil,
+                    syncIdentifier: nil,
+                    scheduledBasalRate: nil,
+                    insulinType: .lyumjev,
+                    automatic: true,
+                    manuallyEntered: false,
+                    isMutable: true
+                ),
+                raw: Data(),
+                title: "Test Temp Basal",
+                type: .tempBasal
+            )
+        ]
+
+        // When
+        // Store in our in-memory PumphistoryStorage
+        await storage.storePumpEvents(events)
+
+        // Wait for the events to be stored
+//        try await Task.sleep(nanoseconds: 1_000_000_000)
+
+        // Then
+        // Fetch all events after storing
+        let finalEntries = try await testContext.perform {
+            try testContext.fetch(PumpEventStored.fetchRequest())
+        }
+
+        // Verify there were no initial entries
+        #expect(initialEntries.isEmpty, "There should be no initial entries")
+
+        // Verify count increased by 2
+        #expect(finalEntries.count == initialEntries.count + 2, "Should have added 2 new events")
+
+        // Verify bolus event
+        let bolusEvent = finalEntries.first {
+            $0.type == PumpEvent.bolus.rawValue &&
+                abs($0.timestamp?.timeIntervalSince(tenMinAgo) ?? 1) < 1
+        }
+        #expect(bolusEvent != nil, "Should have found bolus event")
+        #expect(bolusEvent?.bolus?.amount as? Decimal == 0.4, "Bolus amount should be 0.4")
+        #expect(bolusEvent?.isUploadedToNS == false, "Should not be uploaded to NS")
+        #expect(bolusEvent?.isUploadedToHealth == false, "Should not be uploaded to Health")
+        #expect(bolusEvent?.isUploadedToTidepool == false, "Should not be uploaded to Tidepool")
+        #expect(bolusEvent?.bolus?.isSMB == true, "Should be a SMB")
+        #expect(bolusEvent?.bolus?.isExternal == false, "Should not be external insulin")
+
+        // Verify temp basal event
+        let tempBasalEvent = finalEntries.first {
+            $0.type == PumpEvent.tempBasal.rawValue &&
+                abs($0.timestamp?.timeIntervalSince(date) ?? 1) < 1
+        }
+        #expect(tempBasalEvent != nil, "Should have found temp basal event")
+        #expect(tempBasalEvent?.tempBasal?.rate as? Decimal == 1.2, "Temp basal rate should be 1.2")
+        #expect(tempBasalEvent?.tempBasal?.duration == 30, "Temp basal duration should be 30 minutes")
+        #expect(tempBasalEvent?.isUploadedToNS == false, "Should not be uploaded to NS")
+        #expect(tempBasalEvent?.isUploadedToHealth == false, "Should not be uploaded to Health")
+        #expect(bolusEvent?.isUploadedToTidepool == false, "Should not be uploaded to Tidepool")
+    }
+
+    @Test("Test store function for manual boluses") func testStorePumpEventsWithManualBoluses() async throws {
+        // Given
+        let date = Date()
+
+        // Insert mock entry
+        let events: [LoopKit.NewPumpEvent] = [
+            LoopKit.NewPumpEvent(
+                date: date,
+                dose: LoopKit.DoseEntry(
+                    type: .bolus,
+                    startDate: date,
+                    value: 4,
+                    unit: .units,
+                    deliveredUnits: nil,
+                    description: nil,
+                    syncIdentifier: nil,
+                    scheduledBasalRate: nil,
+                    insulinType: .lyumjev,
+                    automatic: false,
+                    manuallyEntered: true,
+                    isMutable: false
+                ),
+                raw: Data(),
+                title: "Test Bolus",
+                type: .bolus
+            )
+        ]
+
+        // Store test event and wait for storage to complete the task
+        await storage.storePumpEvents(events)
+//        try await Task.sleep(nanoseconds: 1_000_000_000)
+
+        // When - Fetch events with our generic fetch function
+        let fetchedEvents = await coreDataStack.fetchEntitiesAsync(
+            ofType: PumpEventStored.self,
+            onContext: testContext,
+            predicate: NSPredicate(
+                format: "type == %@ AND timestamp == %@",
+                PumpEvent.bolus.rawValue,
+                date as NSDate
+            ),
+            key: "timestamp",
+            ascending: false
+        )
+
+        guard let fetchedEvents = fetchedEvents as? [PumpEventStored] else { return }
+
+        // Then
+        #expect(fetchedEvents.count == 1, "Should have found exactly one event")
+        let fetchedEvent = fetchedEvents.first
+        #expect(fetchedEvent?.type == PumpEvent.bolus.rawValue, "Should be a bolus event")
+        #expect(fetchedEvent?.bolus?.amount as? Decimal == 4, "Bolus amount should be 4 U")
+        #expect(
+            abs(fetchedEvent?.timestamp?.timeIntervalSince(date) ?? 1) < 1,
+            "Timestamp should match"
+        )
+        #expect(fetchedEvent?.bolus?.isSMB == false, "Should not be a SMB")
+        // TODO: - check this
+//        #expect(fetchedEvent?.bolus?.isExternal == false, "Should not be external Insulin")
+        #expect(fetchedEvent?.isUploadedToNS == false, "Should not be uploaded to NS")
+        #expect(fetchedEvent?.isUploadedToHealth == false, "Should not be uploaded to Health")
+        #expect(fetchedEvent?.isUploadedToTidepool == false, "Should not be uploaded to Tidepool")
+    }
+}

+ 24 - 0
TrioTests/CoreDataTests/TestAssembly.swift

@@ -0,0 +1,24 @@
+import CoreData
+import Foundation
+import Swinject
+@testable import Trio
+
+class TestAssembly: Assembly {
+    private let testContext: NSManagedObjectContext
+
+    init(testContext: NSManagedObjectContext) {
+        self.testContext = testContext
+    }
+
+    func assemble(container: Container) {
+        // Override PumpHistoryStorage registration for tests
+        container.register(PumpHistoryStorage.self) { r in
+            BasePumpHistoryStorage(resolver: r, context: self.testContext)
+        }.inObjectScope(.container)
+
+        // Override DeterminationStorage registration for tests
+        container.register(DeterminationStorage.self) { r in
+            BaseDeterminationStorage(resolver: r, context: self.testContext)
+        }.inObjectScope(.container)
+    }
+}

+ 128 - 12
TrioTests/FileStorageTests.swift

@@ -1,26 +1,142 @@
+import Foundation
+import Testing
+
 @testable import Trio
-import XCTest
 
-class FileStorageTests: XCTestCase {
-    let fileStorage = BaseFileStorage()
+@Suite("File Storage Tests") struct FileStorageTests {
+    let storage = BaseFileStorage()
 
     struct DummyObject: JSON, Equatable {
         let id: String
         let value: Decimal
     }
 
-    func testFileStorageTrio() {
-        let dummyObject = DummyObject(id: "21342Z", value: 78.2)
-        fileStorage.save(dummyObject, as: "dummyObject")
-        let dummyObjectRetrieve = fileStorage.retrieve("dummyObject", as: DummyObject.self)
-        XCTAssertTrue(dummyObject == dummyObjectRetrieve)
+    @Test("Can save and retrieve object") func testSaveAndRetrieve() {
+        // Given
+        let dummy = DummyObject(id: "123", value: 78.2)
+
+        // When
+        storage.save(dummy, as: "dummy")
+        let retrieved = storage.retrieve("dummy", as: DummyObject.self)
+
+        // Then
+        #expect(retrieved == dummy)
+    }
+
+    @Test("Can save and retrieve async") func testAsyncSaveAndRetrieve() async {
+        // Given
+        let dummy = DummyObject(id: "123", value: 78.2)
+
+        // When
+        await storage.saveAsync(dummy, as: "dummy_async")
+        let retrieved = await storage.retrieveAsync("dummy_async", as: DummyObject.self)
+
+        // Then
+        #expect(retrieved == dummy)
+    }
+
+    @Test("Can append single value") func testAppendSingleValue() {
+        // Given
+        let dummy1 = DummyObject(id: "1", value: 10.0)
+        let dummy2 = DummyObject(id: "2", value: 20.0)
+
+        // When
+        storage.save([dummy1], as: "dummies")
+        storage.append(dummy2, to: "dummies")
+
+        // Then
+        let retrieved = storage.retrieve("dummies", as: [DummyObject].self)
+        #expect(retrieved?.count == 2)
+        #expect(retrieved?.contains(dummy1) == true)
+        #expect(retrieved?.contains(dummy2) == true)
+    }
+
+    @Test("Can append multiple values") func testAppendMultipleValues() {
+        // Given
+        let dummy1 = DummyObject(id: "1", value: 10.0)
+        let newDummies = [
+            DummyObject(id: "2", value: 20.0),
+            DummyObject(id: "3", value: 30.0)
+        ]
+
+        // When
+        storage.save([dummy1], as: "dummies_multiple")
+        storage.append(newDummies, to: "dummies_multiple")
+
+        // Then
+        let retrieved = storage.retrieve("dummies_multiple", as: [DummyObject].self)
+        #expect(retrieved?.count == 3)
+    }
+
+    @Test("Can append unique values by key path") func testAppendUniqueByKeyPath() {
+        // Given
+        let dummy1 = DummyObject(id: "1", value: 10.0)
+        let dummy2 = DummyObject(id: "1", value: 20.0) // Same id
+
+        // When
+        storage.save([dummy1], as: "unique_dummies")
+        storage.append(dummy2, to: "unique_dummies", uniqBy: \.id)
+
+        // Then
+        let retrieved = storage.retrieve("unique_dummies", as: [DummyObject].self)
+        #expect(retrieved?.count == 1, "Should not append duplicate id")
+    }
+
+    @Test("Can remove file") func testRemoveFile() {
+        // Given
+        let dummy = DummyObject(id: "123", value: 78.2)
+        storage.save(dummy, as: "to_delete")
+
+        // When
+        storage.remove("to_delete")
+
+        // Then
+        let retrieved = storage.retrieve("to_delete", as: DummyObject.self)
+        #expect(retrieved == nil)
+    }
+
+    @Test("Can rename file") func testRenameFile() {
+        // Given
+        let dummy = DummyObject(id: "123", value: 78.2)
+        storage.save(dummy, as: "old_name")
+
+        // When
+        storage.rename("old_name", to: "new_name")
+
+        // Then
+        let oldRetrieved = storage.retrieve("old_name", as: DummyObject.self)
+        let newRetrieved = storage.retrieve("new_name", as: DummyObject.self)
+
+        #expect(oldRetrieved == nil)
+        #expect(newRetrieved == dummy)
     }
 
-    override func setUpWithError() throws {
-        // Put setup code here. This method is called before the invocation of each test method in the class.
+    @Test("Can execute transaction") func testTransaction() {
+        // Given
+        let dummy = DummyObject(id: "123", value: 78.2)
+
+        // When
+        storage.transaction { storage in
+            storage.save(dummy, as: "transaction_test")
+        }
+
+        // Then
+        let retrieved = storage.retrieve("transaction_test", as: DummyObject.self)
+        #expect(retrieved == dummy)
     }
 
-    override func tearDownWithError() throws {
-        // Put teardown code here. This method is called after the invocation of each test method in the class.
+    @Test("Can parse mmol/L settings to mg/dL") func testParseSettingsToMgdL() {
+        // Given
+        var preferences = Preferences()
+        preferences.threshold_setting = 5.5 // mmol/L
+        storage.save(preferences, as: OpenAPS.Settings.preferences)
+
+        // When
+        let wasParsed = storage.parseOnFileSettingsToMgdL()
+
+        // Then
+        #expect(wasParsed == true)
+        let parsed = storage.retrieve(OpenAPS.Settings.preferences, as: Preferences.self)
+        #expect(parsed?.threshold_setting == 100) // default mg/dL value
     }
 }

+ 56 - 36
TrioTests/PluginManagerTests.swift

@@ -1,71 +1,91 @@
+import Foundation
 import Swinject
+import Testing
 @testable import Trio
-import XCTest
 
-class PluginManagerTests: XCTestCase, Injectable {
+@Suite("Plugin Manager Tests") struct PluginManagerTests: Injectable {
     let fileStorage = BaseFileStorage()
     @Injected() var pluginManager: PluginManager!
     let resolver = TrioApp().resolver
 
-    override func setUp() {
+    init() {
         injectServices(resolver)
     }
 
-    func testCGMManagerLoad() {
+    @Test("Can load CGM managers") func testCGMManagerLoad() {
+        // Given
         let cgmLoopManagers = pluginManager.availableCGMManagers
-        XCTAssertNotNil(cgmLoopManagers)
-        XCTAssertTrue(!cgmLoopManagers.isEmpty)
+
+        // Then
+        #expect(!cgmLoopManagers.isEmpty, "Should have available CGM managers")
+
+        // When loading valid CGM manager
         if let cgmLoop = cgmLoopManagers.first {
             let cgmLoopManager = pluginManager.getCGMManagerTypeByIdentifier(cgmLoop.identifier)
-            XCTAssertNotNil(cgmLoopManager)
-        } else {
-            XCTFail("Not found CGM loop manager")
+            #expect(cgmLoopManager != nil, "Should load valid CGM manager")
         }
-        /// try to load a Pump manager with a CGM identifier
+
+        // When trying to load CGM manager with pump identifier
         if let cgmLoop = cgmLoopManagers.last {
-            let cgmLoopManager = pluginManager.getPumpManagerTypeByIdentifier(cgmLoop.identifier)
-            XCTAssertNil(cgmLoopManager)
-        } else {
-            XCTFail("Not found CGM loop manager")
+            let invalidManager = pluginManager.getPumpManagerTypeByIdentifier(cgmLoop.identifier)
+            #expect(invalidManager == nil, "Should not load CGM manager with pump identifier")
         }
     }
 
-    func testPumpManagerLoad() {
+    @Test("Can load pump managers") func testPumpManagerLoad() {
+        // Given
         let pumpLoopManagers = pluginManager.availablePumpManagers
-        XCTAssertNotNil(pumpLoopManagers)
-        XCTAssertTrue(!pumpLoopManagers.isEmpty)
+
+        // Then
+        #expect(!pumpLoopManagers.isEmpty, "Should have available pump managers")
+
+        // When loading valid pump manager
         if let pumpLoop = pumpLoopManagers.first {
             let pumpLoopManager = pluginManager.getPumpManagerTypeByIdentifier(pumpLoop.identifier)
-            XCTAssertNotNil(pumpLoopManager)
-        } else {
-            XCTFail("Not found pump loop manager")
+            #expect(pumpLoopManager != nil, "Should load valid pump manager")
         }
-        /// try to load a CGM manager with a pump identifier
+
+        // When trying to load pump manager with CGM identifier
         if let pumpLoop = pumpLoopManagers.last {
-            let pumpLoopManager = pluginManager.getCGMManagerTypeByIdentifier(pumpLoop.identifier)
-            XCTAssertNil(pumpLoopManager)
-        } else {
-            XCTFail("Not found pump loop manager")
+            let invalidManager = pluginManager.getCGMManagerTypeByIdentifier(pumpLoop.identifier)
+            #expect(invalidManager == nil, "Should not load pump manager with CGM identifier")
         }
     }
 
-    func testServiceManagerLoad() {
+    @Test("Can load service managers") func testServiceManagerLoad() {
+        // Given
         let serviceManagers = pluginManager.availableServices
-        XCTAssertNotNil(serviceManagers)
-        XCTAssertTrue(!serviceManagers.isEmpty)
+
+        // Then
+        #expect(!serviceManagers.isEmpty, "Should have available services")
+
+        // When
         if let serviceLoop = serviceManagers.first {
             let serviceManager = pluginManager.getServiceTypeByIdentifier(serviceLoop.identifier)
-            XCTAssertNotNil(serviceManager)
-        } else {
-            XCTFail("Not found Service loop manager")
+            #expect(serviceManager != nil, "Should load valid service manager")
         }
     }
 
-    override func setUpWithError() throws {
-        // Put setup code here. This method is called before the invocation of each test method in the class.
-    }
+    @Test("Available managers have valid descriptors") func testManagerDescriptors() {
+        // Given/When
+        let pumpManagers = pluginManager.availablePumpManagers
+        let cgmManagers = pluginManager.availableCGMManagers
+        let serviceManagers = pluginManager.availableServices
+
+        // Then
+        for manager in pumpManagers {
+            #expect(!manager.identifier.isEmpty, "Pump manager should have non-empty identifier")
+            #expect(!manager.localizedTitle.isEmpty, "Pump manager should have non-empty title")
+        }
 
-    override func tearDownWithError() throws {
-        // Put teardown code here. This method is called after the invocation of each test method in the class.
+        for manager in cgmManagers {
+            #expect(!manager.identifier.isEmpty, "CGM manager should have non-empty identifier")
+            #expect(!manager.localizedTitle.isEmpty, "CGM manager should have non-empty title")
+        }
+
+        for manager in serviceManagers {
+            #expect(!manager.identifier.isEmpty, "Service should have non-empty identifier")
+            #expect(!manager.localizedTitle.isEmpty, "Service should have non-empty title")
+        }
     }
 }

+ 10 - 0
TrioTests/TestError 2.swift

@@ -0,0 +1,10 @@
+import Foundation
+
+// Custom error type for test failures
+struct TestError: Error {
+    let message: String
+
+    init(_ message: String) {
+        self.message = message
+    }
+}

+ 1 - 0
TrioTests/TestError.swift

@@ -0,0 +1 @@
+import Foundation