Jelajahi Sumber

Update docstrings + variable names; add unit tests

Deniz Cengiz 3 bulan lalu
induk
melakukan
4164b368e5

+ 48 - 75
Trio/Sources/APS/Storage/CarbsStorage.swift

@@ -127,83 +127,46 @@ final class BaseCarbsStorage: CarbsStorage, Injectable {
     }
 
     /**
-     Processes fat and protein entries to generate future carb equivalents, ensuring each equivalent is at least 1.0 grams.
+     Converts fat and protein into delayed carb-equivalent entries (FPU handling).
 
-     - The function calculates the equivalent carb dosage size and adjusts the interval to ensure each equivalent is at least 1.0 grams.
-     - Creates future carb entries based on the adjusted carb equivalent size and interval.
+     Behavior:
+
+     - Calculates carb equivalents from fat and protein
+       ((fat × 9 + protein × 4) / 10 × adjustment factor).
+     - Rounds down to whole grams.
+     - Drops values below 10 g.
+     - Caps total equivalents at 99 g.
+     - Splits into up to 3 entries.
+     - Caps each entry at 33 g.
+     - Distributes grams as evenly as possible.
+
+     Timing:
+
+     - First entry is scheduled after the configured delay
+       (default: 60 minutes) from the carb entry timestamp.
+     - Additional entries are spaced 30 minutes apart.
+
+     Example (default):
+
+     - Carb entry at T
+     - 1st equivalent at T + 60 min
+     - 2nd equivalent at T + 90 min
+     - 3rd equivalent at T + 120 min
+
+     Generated entries:
+
+     - Are marked with `isFPU = true`
+     - Contain only carbs (fat and protein set to 0)
+     - Share the same `fpuID` as the original carb entry
 
      - Parameters:
-       - entries: An array of `CarbsEntry` objects representing the carbohydrate entries to be processed.
+       - entries: An array of `CarbsEntry` objects representing the carb equivalent entries to be processed.
        - fat: The amount of fat in the last entry.
        - protein: The amount of protein in the last entry.
        - createdAt: The creation date of the last entry.
 
      - Returns: A tuple containing the array of future carb entries and the total carb equivalents.
      */
-//    private func processFPU(
-//        entries: [CarbsEntry],
-//        fat: Decimal,
-//        protein: Decimal,
-//        createdAt: Date,
-//        actualDate: Date?
-//    ) -> ([CarbsEntry], Decimal) {
-//        let trioSettings = settings.settings
-//        let providerSettings = settingsProvider.settings
-//
-//        let interval = trioSettings.minuteInterval.clamp(to: providerSettings.minuteInterval)
-//        let timeCap = trioSettings.timeCap.clamp(to: providerSettings.timeCap)
-//        let adjustment = trioSettings.individualAdjustmentFactor.clamp(to: providerSettings.individualAdjustmentFactor)
-//        let delay = trioSettings.delay.clamp(to: providerSettings.delay)
-//
-//        let kcal = protein * 4 + fat * 9
-//        let carbEquivalents = (kcal / 10) * adjustment
-//        let fpus = carbEquivalents / 10
-//        var computedDuration = calculateComputedDuration(fpus: fpus, timeCap: timeCap)
-//
-//        var carbEquivalentSize: Decimal = carbEquivalents / computedDuration
-//        carbEquivalentSize /= Decimal(60) / interval
-//
-//        if carbEquivalentSize < 1.0 {
-//            carbEquivalentSize = 1.0
-//            computedDuration = min(carbEquivalents / carbEquivalentSize, timeCap)
-//        }
-//
-//        let roundedEquivalent: Double = round(Double(carbEquivalentSize * 10)) / 10
-//        carbEquivalentSize = Decimal(roundedEquivalent)
-//        var numberOfEquivalents = carbEquivalents / carbEquivalentSize
-//
-//        var useDate = actualDate ?? createdAt
-//        let fpuID = entries.first?.fpuID ?? UUID().uuidString
-//        var futureCarbArray = [CarbsEntry]()
-//        var firstIndex = true
-//
-//        // convert Decimal minutes to TimeInterval in seconds
-//        let delayTimeInterval = TimeInterval(delay * 60)
-//        let intervalTimeInterval = TimeInterval(interval * 60)
-//        while carbEquivalents > 0, numberOfEquivalents > 0 {
-//            useDate = firstIndex ? useDate.addingTimeInterval(delayTimeInterval) : useDate
-//                .addingTimeInterval(intervalTimeInterval)
-//            firstIndex = false
-    // g
-//            let eachCarbEntry = CarbsEntry(
-//                id: UUID().uuidString,
-//                createdAt: createdAt,
-//                actualDate: useDate,
-//                carbs: carbEquivalentSize,
-//                fat: 0,
-//                protein: 0,
-//                note: nil,
-//                enteredBy: CarbsEntry.local,
-//                isFPU: true,
-//                fpuID: fpuID
-//            )
-//            futureCarbArray.append(eachCarbEntry)
-//            numberOfEquivalents -= 1
-//        }
-//
-//        return (futureCarbArray, carbEquivalents)
-//    }
-
     private func processFPU(
         entries: [CarbsEntry],
         fat: Decimal,
@@ -211,14 +174,14 @@ final class BaseCarbsStorage: CarbsStorage, Injectable {
         createdAt: Date,
         actualDate: Date?
     ) -> ([CarbsEntry], Decimal) {
-        let trio = settings.settings
-        let provider = settingsProvider.settings
+        let trioSettings = settings.settings
+        let providerSettings = settingsProvider.settings
 
-        let adjustment = trio.individualAdjustmentFactor
-            .clamp(to: provider.individualAdjustmentFactor)
+        let adjustment = trioSettings.individualAdjustmentFactor
+            .clamp(to: providerSettings.individualAdjustmentFactor)
 
-        let delayMinutes = trio.delay
-            .clamp(to: provider.delay)
+        let delayMinutes = trioSettings.delay
+            .clamp(to: providerSettings.delay)
 
         // Constraints
         let maxTotalGrams = 99
@@ -266,8 +229,18 @@ final class BaseCarbsStorage: CarbsStorage, Injectable {
         return (futureEntries, totalScheduled)
     }
 
-    // MARK: - Helpers
+    /**
+     Splits a total carb-equivalent value into multiple integer entries.
 
+     - Returns no entries if `total` is below `minPerEntry`.
+     - Limits output to `maxEntries`.
+     - Caps each entry at `maxPerEntry`.
+     - Distributes grams evenly (difference ≤ 1 g).
+     - Merges or removes entries below `minPerEntry`.
+
+     - Returns:
+       Integer gram values representing the split carb equivalents.
+     */
     private func splitIntoCarbEquivalents(
         total: Int,
         maxEntries: Int,

+ 234 - 0
TrioTests/CoreDataTests/CarbsStorageTests.swift

@@ -123,6 +123,240 @@ import Testing
         #expect(remainingEntries?.isEmpty == true, "Should have no entries after deletion")
     }
 
+    @Test(
+        "Store carb entry with fat/protein creates capped, spaced FPU entries (defaults: adjustment=0.5, delay=60m)"
+    ) func testStoreFatProteinCarbEntryCreatesFPUEntries() async throws {
+        let fpuID = UUID().uuidString
+        let baseDate = Date(timeIntervalSince1970: 1_700_000_000)
+
+        // Defaults:
+        // adjustment = 0.5, delay = 60
+        //
+        // fat=10g -> 90 kcal
+        // protein=80g -> 320 kcal
+        // kcal total = 410
+        // (kcal/10) = 41
+        // 41 * 0.5 = 20.5
+        // Int(20.5) = 20 equivalents -> single FPU entry: 20g
+        let mealEntry = CarbsEntry(
+            id: UUID().uuidString,
+            createdAt: baseDate,
+            actualDate: baseDate,
+            carbs: 30,
+            fat: 10,
+            protein: 80,
+            note: "FPU deterministic default split test",
+            enteredBy: "Test",
+            isFPU: false,
+            fpuID: fpuID
+        )
+
+        try await storage.storeCarbs([mealEntry], areFetchedFromRemote: false)
+
+        let storedEntries = try await coreDataStack.fetchEntitiesAsync(
+            ofType: CarbEntryStored.self,
+            onContext: testContext,
+            predicate: NSPredicate(format: "fpuID == %@", fpuID),
+            key: "date",
+            ascending: true
+        ) as? [CarbEntryStored]
+
+        guard let storedEntries else {
+            throw TestError("Failed to fetch entries for fpuID")
+        }
+
+        #expect(!storedEntries.isEmpty, "Should have stored entries")
+
+        let originalCarbEntry = storedEntries.first(where: { $0.isFPU == false })
+        #expect(originalCarbEntry != nil, "Should have one non-FPU original entry")
+        #expect(originalCarbEntry?.carbs == 30, "Original carbs should match")
+        #expect(originalCarbEntry?.fat == 10, "Original fat should match")
+        #expect(originalCarbEntry?.protein == 80, "Original protein should match")
+
+        let fpuEntries = storedEntries.filter { $0.isFPU == true }
+        #expect(fpuEntries.count == 1, "Expected exactly one FPU entry under default settings")
+        #expect(Int(fpuEntries[0].carbs) == 20, "Expected 20g carb equivalents under default settings")
+
+        for fpuEntry in fpuEntries {
+            #expect(fpuEntry.fat == 0, "FPU fat must be 0")
+            #expect(fpuEntry.protein == 0, "FPU protein must be 0")
+            #expect(fpuEntry.carbs >= 10, "FPU carbs must be >= 10g")
+            #expect(fpuEntry.carbs <= 33, "FPU carbs must be <= 33g")
+            #expect(Double(fpuEntry.carbs).truncatingRemainder(dividingBy: 1) == 0, "FPU carbs must be whole grams")
+        }
+
+        let scheduledTotal = fpuEntries.reduce(0) { partialResult, fpuEntry in
+            partialResult + Int(fpuEntry.carbs)
+        }
+        #expect(scheduledTotal <= 99, "Scheduled FPU carbs must be capped at 99g")
+
+        // Timing: stable assertions
+        // - first FPU entry must be at least +60m after the *input* timestamp (createdAt/actualDate),
+        //   but storage may choose a different internal baseDate, so don't assert exact equality.
+        let fpuDates = fpuEntries.compactMap(\.date).sorted()
+        #expect(fpuDates.count == 1, "FPU entry should have a date")
+
+        let firstFpuDate = fpuDates[0]
+        #expect(
+            firstFpuDate >= baseDate.addingTimeInterval(60 * 60),
+            "First FPU entry should not be scheduled earlier than +60 minutes after the input timestamp"
+        )
+
+        #expect(
+            storedEntries.allSatisfy { $0.fpuID?.uuidString == fpuID },
+            "All entries should share the same fpuID"
+        )
+    }
+
+    @Test(
+        "Store very large fat/protein meal caps FPU equivalents at 99g and splits into 3×33g (defaults: adjustment=0.5, delay=60m)"
+    ) func testStoreVeryLargeFatProteinMealCapsAndSplits() async throws {
+        let fpuID = UUID().uuidString
+        let baseDate = Date(timeIntervalSince1970: 1_700_001_000)
+
+        // Defaults:
+        // adjustment = 0.5, delay = 60
+        //
+        // fat=200g -> 1800 kcal
+        // protein=200g -> 800 kcal
+        // kcal total = 2600
+        // (kcal/10) = 260
+        // 260 * 0.5 = 130
+        // Int(130) = 130 -> capped to 99 -> split into [33, 33, 33]
+        let heftyMealEntry = CarbsEntry(
+            id: UUID().uuidString,
+            createdAt: baseDate,
+            actualDate: baseDate,
+            carbs: 30,
+            fat: 200,
+            protein: 200,
+            note: "Hefty BBQ meal - cap test",
+            enteredBy: "Test",
+            isFPU: false,
+            fpuID: fpuID
+        )
+
+        try await storage.storeCarbs([heftyMealEntry], areFetchedFromRemote: false)
+
+        let storedEntries = try await coreDataStack.fetchEntitiesAsync(
+            ofType: CarbEntryStored.self,
+            onContext: testContext,
+            predicate: NSPredicate(format: "fpuID == %@", fpuID),
+            key: "date",
+            ascending: true
+        ) as? [CarbEntryStored]
+
+        guard let storedEntries else {
+            throw TestError("Failed to fetch entries for fpuID")
+        }
+
+        #expect(!storedEntries.isEmpty, "Should have stored entries")
+
+        let originalCarbEntry = storedEntries.first(where: { $0.isFPU == false })
+        #expect(originalCarbEntry != nil, "Should have one non-FPU original entry")
+        #expect(originalCarbEntry?.carbs == 30, "Original carbs should match")
+        #expect(originalCarbEntry?.fat == 200, "Original fat should match")
+        #expect(originalCarbEntry?.protein == 200, "Original protein should match")
+
+        let fpuEntries = storedEntries.filter { $0.isFPU == true }
+        #expect(fpuEntries.count == 3, "Capped large meal should create exactly 3 FPU entries")
+
+        let fpuGrams = fpuEntries.map { Int($0.carbs) }
+        #expect(fpuGrams == [33, 33, 33], "Expected capped split to be [33, 33, 33]")
+
+        let scheduledTotal = fpuEntries.reduce(0) { partialResult, fpuEntry in
+            partialResult + Int(fpuEntry.carbs)
+        }
+        #expect(scheduledTotal == 99, "Total scheduled FPU grams should be exactly 99g after cap")
+
+        for fpuEntry in fpuEntries {
+            #expect(fpuEntry.fat == 0, "FPU entry fat must be 0")
+            #expect(fpuEntry.protein == 0, "FPU entry protein must be 0")
+            #expect(fpuEntry.carbs >= 10, "FPU entry carbs must be >= 10g")
+            #expect(fpuEntry.carbs <= 33, "FPU entry carbs must be <= 33g")
+            #expect(Double(fpuEntry.carbs).truncatingRemainder(dividingBy: 1) == 0, "FPU carbs must be whole grams")
+        }
+
+        // Timing: stable assertions
+        let fpuDates = fpuEntries.compactMap(\.date).sorted()
+        #expect(fpuDates.count == 3, "All FPU entries should have a date")
+
+        let firstFpuDate = fpuDates[0]
+        #expect(
+            firstFpuDate >= baseDate.addingTimeInterval(60 * 60),
+            "First FPU entry should not be scheduled earlier than +60 minutes after the input timestamp"
+        )
+
+        for index in 1 ..< fpuDates.count {
+            let spacingSeconds = fpuDates[index].timeIntervalSince(fpuDates[index - 1])
+            #expect(Int(spacingSeconds) == 30 * 60, "FPU entries should be spaced +30 minutes apart")
+        }
+
+        #expect(
+            storedEntries.allSatisfy { $0.fpuID?.uuidString == fpuID },
+            "All entries should share the same fpuID"
+        )
+    }
+
+    @Test(
+        "Store small fat/protein meal drops FPU equivalents when total would be <10g (defaults: adjustment=0.5, delay=60m)"
+    ) func testStoreSmallFatProteinMealDropsFPUBelowMinimum() async throws {
+        let fpuID = UUID().uuidString
+        let baseDate = Date(timeIntervalSince1970: 1_700_002_000)
+
+        // Defaults:
+        // adjustment = 0.5
+        //
+        // fat=2g -> 18 kcal
+        // protein=2g -> 8 kcal
+        // kcal total = 26
+        // (kcal/10) = 2.6
+        // 2.6 * 0.5 = 1.3
+        // Int(1.3) = 1 (<10) -> should be dropped (no FPU entries)
+        let smallMealEntry = CarbsEntry(
+            id: UUID().uuidString,
+            createdAt: baseDate,
+            actualDate: baseDate,
+            carbs: 30,
+            fat: 2,
+            protein: 2,
+            note: "Tiny macros - min threshold test",
+            enteredBy: "Test",
+            isFPU: false,
+            fpuID: fpuID
+        )
+
+        try await storage.storeCarbs([smallMealEntry], areFetchedFromRemote: false)
+
+        let storedEntries = try await coreDataStack.fetchEntitiesAsync(
+            ofType: CarbEntryStored.self,
+            onContext: testContext,
+            predicate: NSPredicate(format: "fpuID == %@", fpuID),
+            key: "date",
+            ascending: true
+        ) as? [CarbEntryStored]
+
+        guard let storedEntries else {
+            throw TestError("Failed to fetch entries for fpuID")
+        }
+
+        #expect(!storedEntries.isEmpty, "Should have stored at least the original entry")
+
+        let originalCarbEntry = storedEntries.first(where: { $0.isFPU == false })
+        #expect(originalCarbEntry != nil, "Should have one non-FPU original entry")
+        #expect(originalCarbEntry?.carbs == 30, "Original carbs should match")
+        #expect(originalCarbEntry?.fat == 2, "Original fat should match")
+        #expect(originalCarbEntry?.protein == 2, "Original protein should match")
+
+        let fpuEntries = storedEntries.filter { $0.isFPU == true }
+        #expect(fpuEntries.isEmpty == true, "No FPU entries should be created when equivalents are <10g")
+
+        #expect(
+            storedEntries.allSatisfy { $0.fpuID?.uuidString == fpuID },
+            "All entries should share the same fpuID"
+        )
+    }
+
     @Test("Get carbs not yet uploaded to Nightscout") func testGetCarbsNotYetUploadedToNightscout() async throws {
         // Given
         let testEntry = CarbsEntry(