Jelajahi Sumber

Add missing import; adjust main chart UI; add unit tests

Deniz Cengiz 2 bulan lalu
induk
melakukan
59fc9d5741

+ 2 - 13
Model/Classes+Properties/GlucoseStored+CoreDataClass.swift

@@ -1,15 +1,4 @@
-//
-//  GlucoseStored+CoreDataClass.swift
-//  Trio
-//
-//  Created by Cengiz Deniz on 24.02.26.
-//
-//
-
-import Foundation
 import CoreData
+import Foundation
 
-@objc(GlucoseStored)
-public class GlucoseStored: NSManagedObject {
-
-}
+@objc(GlucoseStored) public class GlucoseStored: NSManagedObject {}

+ 14 - 27
Model/Classes+Properties/GlucoseStored+CoreDataProperties.swift

@@ -1,33 +1,20 @@
-//
-//  GlucoseStored+CoreDataProperties.swift
-//  Trio
-//
-//  Created by Cengiz Deniz on 24.02.26.
-//
-//
-
-import Foundation
 import CoreData
+import Foundation
 
-
-extension GlucoseStored {
-
-    @nonobjc public class func fetchRequest() -> NSFetchRequest<GlucoseStored> {
-        return NSFetchRequest<GlucoseStored>(entityName: "GlucoseStored")
+public extension GlucoseStored {
+    @nonobjc class func fetchRequest() -> NSFetchRequest<GlucoseStored> {
+        NSFetchRequest<GlucoseStored>(entityName: "GlucoseStored")
     }
 
-    @NSManaged public var date: Date?
-    @NSManaged public var direction: String?
-    @NSManaged public var glucose: Int16
-    @NSManaged public var id: UUID?
-    @NSManaged public var isManual: Bool
-    @NSManaged public var isUploadedToHealth: Bool
-    @NSManaged public var isUploadedToNS: Bool
-    @NSManaged public var isUploadedToTidepool: Bool
-    @NSManaged public var smoothedGlucose: NSDecimalNumber?
-
+    @NSManaged var date: Date?
+    @NSManaged var direction: String?
+    @NSManaged var glucose: Int16
+    @NSManaged var id: UUID?
+    @NSManaged var isManual: Bool
+    @NSManaged var isUploadedToHealth: Bool
+    @NSManaged var isUploadedToNS: Bool
+    @NSManaged var isUploadedToTidepool: Bool
+    @NSManaged var smoothedGlucose: NSDecimalNumber?
 }
 
-extension GlucoseStored : Identifiable {
-
-}
+extension GlucoseStored: Identifiable {}

+ 16 - 0
Trio.xcodeproj/project.pbxproj

@@ -654,6 +654,8 @@
 		DDD78ADA2DC421B500AC63F3 /* suggested.json in Resources */ = {isa = PBXBuildFile; fileRef = DDD78AD82DC421B500AC63F3 /* suggested.json */; };
 		DDD7C8C12F4DB45400E5CF09 /* GlucoseStored+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDD7C8BF2F4DB45400E5CF09 /* GlucoseStored+CoreDataClass.swift */; };
 		DDD7C8C22F4DB45400E5CF09 /* GlucoseStored+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDD7C8C02F4DB45400E5CF09 /* GlucoseStored+CoreDataProperties.swift */; };
+		DDDD0FFB2F4E22C000F9C645 /* GlucoseSmoothingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDDD0FFA2F4E22C000F9C645 /* GlucoseSmoothingTests.swift */; };
+		DDDD0FFF2F4E231B00F9C645 /* MockTDDStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDDD0FFE2F4E231B00F9C645 /* MockTDDStorage.swift */; };
 		DDE179522C910127003CDDB7 /* MealPresetStored+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDE179322C910127003CDDB7 /* MealPresetStored+CoreDataClass.swift */; };
 		DDE179532C910127003CDDB7 /* MealPresetStored+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDE179332C910127003CDDB7 /* MealPresetStored+CoreDataProperties.swift */; };
 		DDE179542C910127003CDDB7 /* LoopStatRecord+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDE179342C910127003CDDB7 /* LoopStatRecord+CoreDataClass.swift */; };
@@ -1491,6 +1493,8 @@
 		DDD78AD82DC421B500AC63F3 /* suggested.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = suggested.json; sourceTree = "<group>"; };
 		DDD7C8BF2F4DB45400E5CF09 /* GlucoseStored+CoreDataClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "GlucoseStored+CoreDataClass.swift"; sourceTree = "<group>"; };
 		DDD7C8C02F4DB45400E5CF09 /* GlucoseStored+CoreDataProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "GlucoseStored+CoreDataProperties.swift"; sourceTree = "<group>"; };
+		DDDD0FFA2F4E22C000F9C645 /* GlucoseSmoothingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlucoseSmoothingTests.swift; sourceTree = "<group>"; };
+		DDDD0FFE2F4E231B00F9C645 /* MockTDDStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockTDDStorage.swift; sourceTree = "<group>"; };
 		DDE179322C910127003CDDB7 /* MealPresetStored+CoreDataClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MealPresetStored+CoreDataClass.swift"; sourceTree = "<group>"; };
 		DDE179332C910127003CDDB7 /* MealPresetStored+CoreDataProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MealPresetStored+CoreDataProperties.swift"; sourceTree = "<group>"; };
 		DDE179342C910127003CDDB7 /* LoopStatRecord+CoreDataClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "LoopStatRecord+CoreDataClass.swift"; sourceTree = "<group>"; };
@@ -2608,6 +2612,8 @@
 		38FCF3EE25E9028E0078B0D1 /* TrioTests */ = {
 			isa = PBXGroup;
 			children = (
+				DDDD0FFD2F4E231600F9C645 /* Mocks */,
+				DDDD0FFA2F4E22C000F9C645 /* GlucoseSmoothingTests.swift */,
 				DDC6CA6C2DD90A2A0060EE25 /* LocalizationTests.swift */,
 				3B997DD22DC02AEF006B6BB2 /* JSONImporterData */,
 				BD8FC05C2D6618BE00B95AED /* BolusCalculatorTests */,
@@ -3577,6 +3583,14 @@
 			path = View;
 			sourceTree = "<group>";
 		};
+		DDDD0FFD2F4E231600F9C645 /* Mocks */ = {
+			isa = PBXGroup;
+			children = (
+				DDDD0FFE2F4E231B00F9C645 /* MockTDDStorage.swift */,
+			);
+			path = Mocks;
+			sourceTree = "<group>";
+		};
 		DDE179112C9100FA003CDDB7 /* Classes+Properties */ = {
 			isa = PBXGroup;
 			children = (
@@ -4747,12 +4761,14 @@
 				BD8FC0572D66188700B95AED /* PumpHistoryStorageTests.swift in Sources */,
 				BD8FC0642D6619EF00B95AED /* TempTargetStorageTests.swift in Sources */,
 				BD8FC0542D66186000B95AED /* TestError.swift in Sources */,
+				DDDD0FFB2F4E22C000F9C645 /* GlucoseSmoothingTests.swift in Sources */,
 				CEE9A65E2BBC9F6500EB5194 /* CalibrationsTests.swift in Sources */,
 				BD8FC0622D6619E600B95AED /* OverrideStorageTests.swift in Sources */,
 				BD8FC0592D66189700B95AED /* TestAssembly.swift in Sources */,
 				DDC6CA6D2DD90A2A0060EE25 /* LocalizationTests.swift in Sources */,
 				3B997DCF2DC00A3A006B6BB2 /* JSONImporterTests.swift in Sources */,
 				BD8FC0662D661A0000B95AED /* GlucoseStorageTests.swift in Sources */,
+				DDDD0FFF2F4E231B00F9C645 /* MockTDDStorage.swift in Sources */,
 				BD8FC05B2D6618AF00B95AED /* DeterminationStorageTests.swift in Sources */,
 				3BAAE60C2DE7766C0049589B /* DynamicISFEnableTests.swift in Sources */,
 				CE1F6DD92BADF4620064EB8D /* PluginManagerTests.swift in Sources */,

+ 1 - 5
Trio/Sources/APS/FetchGlucoseManager.swift

@@ -379,11 +379,7 @@ extension BaseFetchGlucoseManager {
             // Keep only entries with dates
             let cgmValuesNewestFirst: [GlucoseStored] = glucoseStored
                 .filter { !$0.isManual }
-                .compactMap { obj -> GlucoseStored? in
-                    guard obj.date != nil else { return nil }
-                    return obj
-                }
-                .sorted { $0.date! > $1.date! } // newest first (AAPS expectation)
+                .filter { $0.date != nil } // order preserved
 
             guard !cgmValuesNewestFirst.isEmpty else { return }
 

+ 39 - 39
Trio/Sources/APS/OpenAPS/OpenAPS.swift

@@ -99,51 +99,51 @@ final class OpenAPS {
 
     // fetch glucose to pass it to the meal function and to determine basal
     func fetchAndProcessGlucose(
-            context: NSManagedObjectContext,
-            shouldSmoothGlucose: Bool,
-            fetchLimit: Int?
-        ) async throws -> String {
-            let results = try CoreDataStack.shared.fetchEntities(
-                ofType: GlucoseStored.self,
-                onContext: context,
-                predicate: NSPredicate.predicateForOneDayAgoInMinutes,
-                key: "date",
-                ascending: false,
-                fetchLimit: fetchLimit,
-                batchSize: 48
-            )
+        context: NSManagedObjectContext,
+        shouldSmoothGlucose: Bool,
+        fetchLimit: Int?
+    ) async throws -> String {
+        let results = try CoreDataStack.shared.fetchEntities(
+            ofType: GlucoseStored.self,
+            onContext: context,
+            predicate: NSPredicate.predicateForOneDayAgoInMinutes,
+            key: "date",
+            ascending: false,
+            fetchLimit: fetchLimit,
+            batchSize: 48
+        )
 
-            return try await context.perform {
-                guard let glucoseResults = results as? [GlucoseStored] else {
-                    throw CoreDataError.fetchError(function: #function, file: #file)
-                }
+        return try await context.perform {
+            guard let glucoseResults = results as? [GlucoseStored] else {
+                throw CoreDataError.fetchError(function: #function, file: #file)
+            }
 
-                let algorithmGlucose = glucoseResults.map { glucose -> AlgorithmGlucose in
-                    let glucoseValue: Int16
-                    if shouldSmoothGlucose, !glucose.isManual, let smoothedGlucose = glucose.smoothedGlucose {
-                        let roundingBehavior = NSDecimalNumberHandler(
-                            roundingMode: .plain,
-                            scale: 0,
-                            raiseOnExactness: false,
-                            raiseOnOverflow: false,
-                            raiseOnUnderflow: false,
-                            raiseOnDivideByZero: false
-                        )
-                        glucoseValue = smoothedGlucose.rounding(accordingToBehavior: roundingBehavior).int16Value
-                    } else {
-                        glucoseValue = glucose.glucose
-                    }
-                    return AlgorithmGlucose(
-                        date: glucose.date,
-                        direction: glucose.direction,
-                        glucose: glucoseValue,
-                        id: glucose.id,
-                        isManual: glucose.isManual
+            let algorithmGlucose = glucoseResults.map { glucose -> AlgorithmGlucose in
+                let glucoseValue: Int16
+                if shouldSmoothGlucose, !glucose.isManual, let smoothedGlucose = glucose.smoothedGlucose {
+                    let roundingBehavior = NSDecimalNumberHandler(
+                        roundingMode: .plain,
+                        scale: 0,
+                        raiseOnExactness: false,
+                        raiseOnOverflow: false,
+                        raiseOnUnderflow: false,
+                        raiseOnDivideByZero: false
                     )
+                    glucoseValue = smoothedGlucose.rounding(accordingToBehavior: roundingBehavior).int16Value
+                } else {
+                    glucoseValue = glucose.glucose
                 }
-                return self.jsonConverter.convertToJSON(algorithmGlucose)
+                return AlgorithmGlucose(
+                    date: glucose.date,
+                    direction: glucose.direction,
+                    glucose: glucoseValue,
+                    id: glucose.id,
+                    isManual: glucose.isManual
+                )
             }
+            return self.jsonConverter.convertToJSON(algorithmGlucose)
         }
+    }
 
     private func fetchAndProcessCarbs(additionalCarbs: Decimal? = nil, carbsDate: Date? = nil) async throws -> String {
         let results = try await CoreDataStack.shared.fetchEntitiesAsync(

+ 2 - 0
Trio/Sources/Models/AlgorithmGlucose.swift

@@ -1,3 +1,5 @@
+import Foundation
+
 /// Helper class so that we can have a plain Swift object to serialize GlucoseStorage
 struct AlgorithmGlucose: Codable {
     var date: Date?

+ 25 - 37
Trio/Sources/Modules/Home/View/Chart/ChartElements/GlucoseChartView.swift

@@ -32,46 +32,34 @@ struct GlucoseChartView: ChartContent {
                 glucoseColorScheme: glucoseColorScheme
             )
 
-            if !isSmoothingEnabled {
-                PointMark(
-                    x: .value("Time", item.date ?? Date(), unit: .second),
-                    y: .value("Value", glucoseToDisplay)
-                )
-                .foregroundStyle(pointMarkColor)
-                .symbolSize(20)
-                .symbol {
-                    if item.isManual {
-                        Image(systemName: "drop.fill")
-                            .font(.caption2)
-                            .symbolRenderingMode(.monochrome)
-                            .bold()
-                            .foregroundStyle(.red)
-                    } else {
-                        Image(systemName: "circle.fill")
-                            .font(.system(size: 5))
-                            .bold()
-                            .foregroundStyle(pointMarkColor)
-                    }
+            PointMark(
+                x: .value("Time", item.date ?? Date(), unit: .second),
+                y: .value("Value", glucoseToDisplay)
+            )
+            .foregroundStyle(pointMarkColor)
+            .symbolSize(20)
+            .symbol {
+                if item.isManual {
+                    Image(systemName: "drop.fill")
+                        .font(.caption2)
+                        .symbolRenderingMode(.monochrome)
+                        .bold()
+                        .foregroundStyle(.red)
+                } else {
+                    Image(systemName: "circle.fill")
+                        .font(.system(size: 5))
+                        .bold()
+                        .foregroundStyle(pointMarkColor)
                 }
-            } else {
-                PointMark(
+            }
+
+            if isSmoothingEnabled, let smoothedGlucose = item.smoothedGlucose {
+                LineMark(
                     x: .value("Time", item.date ?? Date(), unit: .second),
-                    y: .value("Value", glucoseToDisplay)
+                    y: .value("Value", smoothedGlucose as Decimal),
+                    series: .value("Type", "Smoothed")
                 )
-                .symbol {
-                    if item.isManual {
-                        Image(systemName: "drop.fill")
-                            .font(.caption2)
-                            .symbolRenderingMode(.monochrome)
-                            .bold()
-                            .foregroundStyle(.red)
-                    } else {
-                        Image(systemName: "record.circle.fill")
-                            .font(.system(size: 8))
-                            .bold()
-                            .foregroundStyle(pointMarkColor)
-                    }
-                }
+                .foregroundStyle(Color.secondary)
             }
         }
     }

+ 316 - 0
TrioTests/GlucoseSmoothingTests.swift

@@ -0,0 +1,316 @@
+import CoreData
+import Foundation
+import LoopKitUI
+import Swinject
+import Testing
+
+@testable import Trio
+
+@Suite("Glucose Smoothing Tests", .serialized) struct GlucoseSmoothingTests: Injectable {
+    let resolver: Resolver
+    var coreDataStack: CoreDataStack!
+    var testContext: NSManagedObjectContext!
+    var fetchGlucoseManager: BaseFetchGlucoseManager!
+    var openAPS: OpenAPS!
+
+    init() async throws {
+        coreDataStack = try await CoreDataStack.createForTests()
+        testContext = coreDataStack.newTaskContext()
+
+        let assembler = Assembler([
+            StorageAssembly(),
+            ServiceAssembly(),
+            APSAssembly(),
+            NetworkAssembly(),
+            UIAssembly(),
+            SecurityAssembly(),
+            TestAssembly(testContext: testContext)
+        ])
+
+        resolver = assembler.resolver
+        injectServices(resolver)
+
+        fetchGlucoseManager = resolver.resolve(FetchGlucoseManager.self)! as? BaseFetchGlucoseManager
+
+        let fileStorage = resolver.resolve(FileStorage.self)!
+        openAPS = OpenAPS(storage: fileStorage, tddStorage: MockTDDStorage())
+    }
+
+    // MARK: - Exponential Smoothing Tests
+
+    @Test(
+        "Exponential smoothing writes smoothed glucose for CGM values when enough data exists"
+    ) func testExponentialSmoothingStoresSmoothedValues() async throws {
+        // GIVEN: 6 CGM values at 5-minute intervals (enough for minimumWindowSize = 4)
+        let glucoseValues: [Int16] = [100, 105, 110, 115, 120, 125]
+        await createGlucoseSequence(values: glucoseValues, interval: 5 * 60, isManual: false)
+
+        // WHEN
+        await fetchGlucoseManager.exponentialSmoothingGlucose(context: testContext)
+
+        // THEN
+        let fetchedAscending = try await fetchAndSortGlucose() // ascending by date (oldest -> newest)
+
+        await testContext.perform {
+            // We expect at least the most recent few values to get smoothed values written.
+            // The Kotlin/port writes to data[i] for i in 0..<limit, where data is newest-first.
+            // With 6 values:
+            // - recordCount = 6
+            // - validWindowCount starts at 5, no gap => remains 5
+            // - smoothing produces blended.count == 5
+            // - apply limit = min(5, 6) = 5 => most recent 5 entries get smoothedGlucose
+            //
+            // In ascending order, "most recent 5" are indices 1...5. Oldest (index 0) is not guaranteed to be updated.
+            #expect(fetchedAscending.count == 6)
+
+            let oldest = fetchedAscending[0]
+            let updatedRange = fetchedAscending[1...]
+
+            // Oldest may or may not be updated depending on window math; with current implementation it should be nil.
+            // We assert the important part: most recent values have smoothed stored.
+            #expect(oldest.smoothedGlucose == nil, "Oldest value should not be smoothed with current window/apply behavior.")
+
+            for (i, obj) in updatedRange.enumerated() {
+                let actual = obj.smoothedGlucose as? Decimal
+                #expect(actual != nil, "Expected smoothedGlucose to be set for recent value at ascending index \(i + 1).")
+            }
+        }
+    }
+
+    @Test("Exponential smoothing does not smooth manual glucose entries") func testExponentialSmoothingIgnoresManual() async throws {
+        // GIVEN: Mixed manual + CGM values
+        await createGlucoseSequence(values: [100, 105, 110, 115, 120].map(Int16.init), interval: 5 * 60, isManual: false)
+        await createGlucose(glucose: 130, smoothed: nil, isManual: true, date: Date().addingTimeInterval(6 * 5 * 60))
+
+        // WHEN
+        await fetchGlucoseManager.exponentialSmoothingGlucose(context: testContext)
+
+        // THEN
+        let allAscending = try await fetchAndSortGlucose()
+
+        await testContext.perform {
+            let manual = allAscending.first(where: { $0.isManual })
+            #expect(manual != nil, "Expected a manual glucose entry.")
+            #expect(manual?.smoothedGlucose == nil, "Manual entries must not be smoothed/stored.")
+        }
+    }
+
+    @Test(
+        "Exponential smoothing clamps smoothed glucose to >= 39 and rounds to integer"
+    ) func testExponentialSmoothingClampAndRounding() async throws {
+        // GIVEN: Values near the clamp boundary (include a 39/40 region)
+        // Note: AAPS also treats 38 as error, but clamp applies to results; we just ensure clamp/round semantics.
+        let glucoseValues: [Int16] = [40, 39, 41, 42, 43, 44]
+        await createGlucoseSequence(values: glucoseValues, interval: 5 * 60, isManual: false)
+
+        // WHEN
+        await fetchGlucoseManager.exponentialSmoothingGlucose(context: testContext)
+
+        // THEN
+        let fetchedAscending = try await fetchAndSortGlucose()
+
+        await testContext.perform {
+            for obj in fetchedAscending {
+                guard let smoothed = obj.smoothedGlucose as? Decimal else { continue }
+
+                // clamp
+                #expect(smoothed >= 39, "Smoothed glucose must be clamped to >= 39, got \(smoothed).")
+
+                // integer rounding (no fractional part)
+                // Decimal doesn't have mod easily; compare to its rounded value.
+                #expect(smoothed == smoothed.rounded(toPlaces: 0), "Smoothed glucose must be an integer value, got \(smoothed).")
+            }
+        }
+    }
+
+    @Test(
+        "Exponential smoothing stops window at gaps >= 12 minutes; older values past gap remain unchanged"
+    ) func testExponentialSmoothingGapStopsWindow() async throws {
+        // GIVEN:
+        // Create 6 CGM values, but introduce a 15-minute gap between one pair.
+        // We construct dates explicitly to control the gap.
+        let now = Date()
+        let dates: [Date] = [
+            now.addingTimeInterval(0), // oldest
+            now.addingTimeInterval(5 * 60),
+            now.addingTimeInterval(10 * 60),
+            now.addingTimeInterval(25 * 60), // <-- gap from previous is 15 minutes (rounded 15)
+            now.addingTimeInterval(30 * 60),
+            now.addingTimeInterval(35 * 60) // newest
+        ]
+        let values: [Int16] = [100, 105, 110, 115, 120, 125]
+        await createGlucoseSequence(values: values, dates: dates, isManual: false)
+
+        // WHEN
+        await fetchGlucoseManager.exponentialSmoothingGlucose(context: testContext)
+
+        // THEN
+        let ascending = try await fetchAndSortGlucose()
+
+        await testContext.perform {
+            // In newest-first view, the gap is between the reading at 25min (older side of the recent group)
+            // and 10min (older group). Window should include only the more recent contiguous section.
+            //
+            // With the above timeline, the contiguous (no big gaps) most-recent block is: 35, 30, 25
+            // That's only 3 readings => below minimumWindowSize (4) => fallback should copy raw into smoothed
+            // BUT only for the values we pass into `data` (which is the filtered cgm list) in the fallback branch.
+            //
+            // However, because we trim windowSize to i+1 at gap, windowCount becomes 3 -> insufficient => fallback
+            // sets smoothedGlucose for *all passed objects* in current implementation.
+            //
+            // Therefore the key assertion here becomes:
+            // - smoothedGlucose is set for all CGM entries (fallback path)
+            // - AND values are clamped >= 39 (implicitly true here)
+            for obj in ascending {
+                guard !obj.isManual else { continue }
+                #expect(
+                    obj.smoothedGlucose != nil,
+                    "Fallback path should fill smoothedGlucose when window is insufficient due to gaps."
+                )
+            }
+        }
+    }
+
+    @Test(
+        "Exponential smoothing treats 38 mg/dL as xDrip error and stops window excluding that reading"
+    ) func testExponentialSmoothingXDrip38StopsWindow() async throws {
+        // GIVEN: Insert a 38 in the sequence (newest-first window should cut before it).
+        // Dates 5-min apart, newest last.
+        let values: [Int16] = [100, 105, 110, 38, 120, 125]
+        await createGlucoseSequence(values: values, interval: 5 * 60, isManual: false)
+
+        // WHEN
+        await fetchGlucoseManager.exponentialSmoothingGlucose(context: testContext)
+
+        // THEN
+        let ascending = try await fetchAndSortGlucose()
+
+        await testContext.perform {
+            // With a 38 present, window gets cut. Often this will also push us into fallback mode
+            // depending on where the 38 sits relative to the newest values.
+            //
+            // We assert two safety/semantic properties:
+            // 1) No stored smoothed value is < 39
+            // 2) 38 itself should end up with smoothedGlucose >= 39 if it got touched (fallback fills all),
+            //    but algorithm path excludes it from window; either way min clamp should hold.
+            for obj in ascending {
+                if let smoothed = obj.smoothedGlucose as? Decimal {
+                    #expect(smoothed >= 39, "Smoothed glucose must be clamped to >= 39 even around xDrip 38.")
+                }
+            }
+        }
+    }
+
+    // MARK: - OpenAPS Glucose Selection Tests (kept from previous suite)
+
+    @Test("Algorithm uses smoothed glucose when enabled") func testAlgorithmUsesSmoothedGlucose() async throws {
+        await createGlucose(glucose: 150, smoothed: 140, isManual: false, date: Date())
+
+        let algorithmInput = try await runFetchAndProcessGlucose(smoothGlucose: true)
+
+        #expect(algorithmInput.count == 1, "Expected to process one glucose entry.")
+        #expect(
+            algorithmInput.first?.glucose == 140,
+            "Algorithm should have used the smoothed glucose value (140), but used \(algorithmInput.first?.glucose ?? 0)."
+        )
+    }
+
+    @Test("Algorithm uses raw glucose when smoothing is disabled") func testAlgorithmUsesRawGlucose() async throws {
+        await createGlucose(glucose: 150, smoothed: 140, isManual: false, date: Date())
+
+        let algorithmInput = try await runFetchAndProcessGlucose(smoothGlucose: false)
+
+        #expect(algorithmInput.count == 1, "Expected to process one glucose entry.")
+        #expect(
+            algorithmInput.first?.glucose == 150,
+            "Algorithm should have used the raw glucose value (150), but used \(algorithmInput.first?.glucose ?? 0)."
+        )
+    }
+
+    @Test("Algorithm falls back to raw glucose if smoothed value is missing") func testAlgorithmFallbackToRawGlucose() async throws {
+        await createGlucose(glucose: 150, smoothed: nil, isManual: false, date: Date())
+
+        let algorithmInput = try await runFetchAndProcessGlucose(smoothGlucose: true)
+
+        #expect(algorithmInput.count == 1, "Expected to process one glucose entry.")
+        #expect(
+            algorithmInput.first?.glucose == 150,
+            "Algorithm should have fallen back to the raw glucose value (150), but used \(algorithmInput.first?.glucose ?? 0)."
+        )
+    }
+
+    @Test("Algorithm ignores smoothed value for manual glucose entries") func testAlgorithmIgnoresSmoothedManualGlucose() async throws {
+        await createGlucose(glucose: 150, smoothed: 140, isManual: true, date: Date())
+
+        let algorithmInput = try await runFetchAndProcessGlucose(smoothGlucose: true)
+
+        #expect(algorithmInput.count == 1, "Expected to process one glucose entry.")
+        #expect(
+            algorithmInput.first?.glucose == 150,
+            "Algorithm should have ignored smoothing for a manual entry and used the raw value (150), but used \(algorithmInput.first?.glucose ?? 0)."
+        )
+    }
+
+    // MARK: - Helpers
+
+    private func runFetchAndProcessGlucose(smoothGlucose: Bool) async throws -> [AlgorithmGlucose] {
+        let jsonString = try await openAPS.fetchAndProcessGlucose(
+            context: testContext,
+            shouldSmoothGlucose: smoothGlucose,
+            fetchLimit: 10
+        )
+
+        let data = jsonString.data(using: .utf8)!
+        let decoder = JSONDecoder()
+        decoder.dateDecodingStrategy = .custom { decoder in
+            let container = try decoder.singleValueContainer()
+            let dateDouble = try container.decode(Double.self)
+            return Date(timeIntervalSince1970: dateDouble / 1000)
+        }
+
+        return try decoder.decode([AlgorithmGlucose].self, from: data)
+    }
+
+    private func createGlucose(glucose: Int16, smoothed: Decimal?, isManual: Bool, date: Date) async {
+        await testContext.perform {
+            let object = GlucoseStored(context: self.testContext)
+            object.date = date
+            object.glucose = glucose
+            object.smoothedGlucose = smoothed as NSDecimalNumber?
+            object.isManual = isManual
+            object.id = UUID()
+            try! self.testContext.save()
+        }
+    }
+
+    private func createGlucoseSequence(values: [Int16], interval: TimeInterval, isManual: Bool) async {
+        let now = Date()
+        let dates = values.indices.map { now.addingTimeInterval(Double($0) * interval) }
+        await createGlucoseSequence(values: values, dates: dates, isManual: isManual)
+    }
+
+    private func createGlucoseSequence(values: [Int16], dates: [Date], isManual: Bool) async {
+        precondition(values.count == dates.count)
+
+        await testContext.perform {
+            for (i, value) in values.enumerated() {
+                let object = GlucoseStored(context: self.testContext)
+                object.date = dates[i]
+                object.glucose = value
+                object.isManual = isManual
+                object.id = UUID()
+            }
+            try! self.testContext.save()
+        }
+    }
+
+    private func fetchAndSortGlucose() async throws -> [GlucoseStored] {
+        try await coreDataStack.fetchEntitiesAsync(
+            ofType: GlucoseStored.self,
+            onContext: testContext,
+            predicate: .all,
+            key: "date",
+            ascending: true
+        ) as? [GlucoseStored] ?? []
+    }
+}

+ 15 - 0
TrioTests/Mocks/MockTDDStorage.swift

@@ -0,0 +1,15 @@
+import LoopKitUI
+@testable import Trio
+
+struct MockTDDStorage: TDDStorage {
+    func calculateTDD(
+        pumpManager _: any LoopKitUI.PumpManagerUI,
+        pumpHistory _: [Trio.PumpHistoryEvent],
+        basalProfile _: [Trio.BasalProfileEntry]
+    ) async throws -> Trio.TDDResult {
+        TDDResult(total: 0, bolus: 0, tempBasal: 0, scheduledBasal: 0, weightedAverage: 0, hoursOfData: 0)
+    }
+
+    func storeTDD(_: Trio.TDDResult) async { /* skip */ }
+    func hasSufficientTDD() async throws -> Bool { true }
+}