|
|
@@ -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=50g -> 450 kcal
|
|
|
+ // protein=100g -> 400 kcal
|
|
|
+ // kcal total = 850
|
|
|
+ // (kcal/10) = 85
|
|
|
+ // 85 * 0.5 = 42.5
|
|
|
+ // Int(42.5) = 42 equivalents -> two FPU entries: 21g each
|
|
|
+ let mealEntry = CarbsEntry(
|
|
|
+ id: UUID().uuidString,
|
|
|
+ createdAt: baseDate,
|
|
|
+ actualDate: baseDate,
|
|
|
+ carbs: 30,
|
|
|
+ fat: 50,
|
|
|
+ protein: 100,
|
|
|
+ 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 == 50, "Original fat should match")
|
|
|
+ #expect(originalCarbEntry?.protein == 100, "Original protein should match")
|
|
|
+
|
|
|
+ let fpuEntries = storedEntries.filter { $0.isFPU == true }
|
|
|
+ #expect(fpuEntries.count == 2, "Expected exactly one FPU entry under default settings")
|
|
|
+ #expect(Int(fpuEntries[0].carbs) == 21, "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 == 2, "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(
|