| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462 |
- import CoreData
- import Foundation
- import Testing
- @testable import Trio
- /// Golden tests certifying that the native `GlucoseStored` → `BloodGlucose` mapping
- /// (`BaseGlucoseStorage.mapToBloodGlucose`) reproduces, byte for byte, the glucose the algorithm used to
- /// receive through the old JSON round-trip (`GlucoseStored` → `AlgorithmGlucose` → JSON →
- /// `JSONBridge.glucose`). The golden literals below were captured from that old path while it
- /// still existed (via a temporary differential run), so a match proves the algorithm still sees
- /// identical inputs now that the JSON round-trip is gone.
- ///
- /// The comparison is field by field (see `expectFieldsEqual`), NOT `==`: `BloodGlucose.==` only
- /// compares `dateString`, so a plain array comparison would pass even if
- /// `glucose`/`sgv`/`direction`/`id`/`type` differed — exactly the coerced fields this migration
- /// must preserve.
- ///
- /// Fixtures use fixed dates and ids so the mapping is fully deterministic and independent of the
- /// wall-clock-relative time-window fetch (which is unchanged by this migration and stays covered
- /// by `GlucoseSmoothingTests`).
- @Suite("Glucose Native Conversion Tests", .serialized) struct GlucoseNativeConversionTests {
- var coreDataStack: CoreDataStack!
- var testContext: NSManagedObjectContext!
- init() async throws {
- coreDataStack = try await CoreDataStack.createForTests()
- testContext = coreDataStack.newTaskContext()
- }
- // MARK: - Golden tests (native mapping vs frozen old-path output)
- @Test("CGM readings without smoothing map identically") func testCGMNoSmoothing() async throws {
- await insertGlucose(glucose: 120, isManual: false, date: fixedDate(minutesAgo: 0), direction: "Flat", id: uuid(1))
- await insertGlucose(glucose: 95, isManual: false, date: fixedDate(minutesAgo: 5), direction: "FortyFiveDown", id: uuid(2))
- await insertGlucose(glucose: 150, isManual: true, date: fixedDate(minutesAgo: 10), direction: nil, id: uuid(3))
- try await assertNativeMatchesGolden(shouldSmoothGlucose: false, [
- BloodGlucose(
- id: uuid(1).uuidString,
- sgv: 120,
- direction: .flat,
- date: 1_700_000_000_000,
- dateString: Date(timeIntervalSince1970: 1_700_000_000),
- type: "sgv"
- ),
- BloodGlucose(
- id: uuid(2).uuidString,
- sgv: 95,
- direction: .fortyFiveDown,
- date: 1_699_999_700_000,
- dateString: Date(timeIntervalSince1970: 1_699_999_700),
- type: "sgv"
- ),
- BloodGlucose(
- id: uuid(3).uuidString,
- date: 1_699_999_400_000,
- dateString: Date(timeIntervalSince1970: 1_699_999_400),
- glucose: 150,
- type: "sgv"
- )
- ])
- }
- @Test("Smoothed CGM value is used and rounded identically") func testSmoothedValueUsed() async throws {
- await insertGlucose(
- glucose: 120,
- isManual: false,
- date: fixedDate(minutesAgo: 0),
- smoothed: Decimal(string: "118.6"),
- direction: "Flat",
- id: uuid(1)
- )
- try await assertNativeMatchesGolden(shouldSmoothGlucose: true, [
- BloodGlucose(
- id: uuid(1).uuidString,
- sgv: 119,
- direction: .flat,
- date: 1_700_000_000_000,
- dateString: Date(timeIntervalSince1970: 1_700_000_000),
- type: "sgv"
- )
- ])
- let native = try await nativeBloodGlucose(shouldSmoothGlucose: true)
- #expect(native.first?.sgv == 119, "118.6 should round to 119 in the sgv field")
- #expect(native.first?.glucose == nil, "CGM readings must not populate the manual `glucose` field")
- }
- @Test("Zero smoothed value falls back to the raw value") func testSmoothedZeroFallsBackToRaw() async throws {
- await insertGlucose(
- glucose: 120,
- isManual: false,
- date: fixedDate(minutesAgo: 0),
- smoothed: 0,
- direction: "Flat",
- id: uuid(1)
- )
- try await assertNativeMatchesGolden(shouldSmoothGlucose: true, [
- BloodGlucose(
- id: uuid(1).uuidString,
- sgv: 120,
- direction: .flat,
- date: 1_700_000_000_000,
- dateString: Date(timeIntervalSince1970: 1_700_000_000),
- type: "sgv"
- )
- ])
- let native = try await nativeBloodGlucose(shouldSmoothGlucose: true)
- #expect(native.first?.sgv == 120, "A zero smoothed value must fall back to the raw 120")
- }
- @Test("Nil smoothed value falls back to the raw value") func testSmoothedNilFallsBackToRaw() async throws {
- await insertGlucose(
- glucose: 120,
- isManual: false,
- date: fixedDate(minutesAgo: 0),
- smoothed: nil,
- direction: "Flat",
- id: uuid(1)
- )
- try await assertNativeMatchesGolden(shouldSmoothGlucose: true, [
- BloodGlucose(
- id: uuid(1).uuidString,
- sgv: 120,
- direction: .flat,
- date: 1_700_000_000_000,
- dateString: Date(timeIntervalSince1970: 1_700_000_000),
- type: "sgv"
- )
- ])
- let native = try await nativeBloodGlucose(shouldSmoothGlucose: true)
- #expect(native.first?.sgv == 120, "A nil smoothed value must fall back to the raw 120")
- }
- @Test("Manual entries ignore smoothing and populate the glucose field") func testManualIgnoresSmoothing() async throws {
- await insertGlucose(
- glucose: 150,
- isManual: true,
- date: fixedDate(minutesAgo: 0),
- smoothed: 140,
- direction: nil,
- id: uuid(1)
- )
- try await assertNativeMatchesGolden(shouldSmoothGlucose: true, [
- BloodGlucose(
- id: uuid(1).uuidString,
- date: 1_700_000_000_000,
- dateString: Date(timeIntervalSince1970: 1_700_000_000),
- glucose: 150,
- type: "sgv"
- )
- ])
- let native = try await nativeBloodGlucose(shouldSmoothGlucose: true)
- #expect(native.first?.glucose == 150, "Manual entries must use the raw value in the `glucose` field")
- #expect(native.first?.sgv == nil, "Manual entries must not populate the `sgv` field")
- }
- @Test("Direction variants and nil map identically") func testDirectionVariants() async throws {
- await insertGlucose(glucose: 110, isManual: false, date: fixedDate(minutesAgo: 0), direction: "TripleUp", id: uuid(1))
- await insertGlucose(glucose: 111, isManual: false, date: fixedDate(minutesAgo: 5), direction: "FortyFiveUp", id: uuid(2))
- await insertGlucose(glucose: 112, isManual: false, date: fixedDate(minutesAgo: 10), direction: "NONE", id: uuid(3))
- await insertGlucose(glucose: 113, isManual: false, date: fixedDate(minutesAgo: 15), direction: nil, id: uuid(4))
- try await assertNativeMatchesGolden(shouldSmoothGlucose: false, [
- BloodGlucose(
- id: uuid(1).uuidString,
- sgv: 110,
- direction: .tripleUp,
- date: 1_700_000_000_000,
- dateString: Date(timeIntervalSince1970: 1_700_000_000),
- type: "sgv"
- ),
- BloodGlucose(
- id: uuid(2).uuidString,
- sgv: 111,
- direction: .fortyFiveUp,
- date: 1_699_999_700_000,
- dateString: Date(timeIntervalSince1970: 1_699_999_700),
- type: "sgv"
- ),
- // `.none` here is BloodGlucose.Direction.none ("NONE"), not Optional.none — spell it out.
- BloodGlucose(
- id: uuid(3).uuidString,
- sgv: 112,
- direction: BloodGlucose.Direction.none,
- date: 1_699_999_400_000,
- dateString: Date(timeIntervalSince1970: 1_699_999_400),
- type: "sgv"
- ),
- BloodGlucose(
- id: uuid(4).uuidString,
- sgv: 113,
- date: 1_699_999_100_000,
- dateString: Date(timeIntervalSince1970: 1_699_999_100),
- type: "sgv"
- )
- ])
- }
- @Test("Sub-millisecond dates are truncated identically") func testMillisecondTruncation() async throws {
- // A date with sub-millisecond precision: the old path round-trips it through an ISO8601
- // fractional-seconds string (millisecond precision), so both fields must be ms-truncated.
- await insertGlucose(
- glucose: 100,
- isManual: false,
- date: fixedDate(minutesAgo: 0, plusSeconds: 0.123_456),
- direction: "Flat",
- id: uuid(1)
- )
- try await assertNativeMatchesGolden(shouldSmoothGlucose: false, [
- BloodGlucose(
- id: uuid(1).uuidString,
- sgv: 100,
- direction: .flat,
- date: 1_700_000_000_123,
- dateString: Date(timeIntervalSince1970: 1_700_000_000.123),
- type: "sgv"
- )
- ])
- }
- @Test("A descending multi-entry sequence maps identically") func testDescendingSequence() async throws {
- for i in 0 ..< 6 {
- await insertGlucose(
- glucose: Int16(100 + i),
- isManual: false,
- date: fixedDate(minutesAgo: Double(i) * 5),
- direction: "Flat",
- id: uuid(i + 1)
- )
- }
- try await assertNativeMatchesGolden(shouldSmoothGlucose: false, [
- BloodGlucose(
- id: uuid(1).uuidString,
- sgv: 100,
- direction: .flat,
- date: 1_700_000_000_000,
- dateString: Date(timeIntervalSince1970: 1_700_000_000),
- type: "sgv"
- ),
- BloodGlucose(
- id: uuid(2).uuidString,
- sgv: 101,
- direction: .flat,
- date: 1_699_999_700_000,
- dateString: Date(timeIntervalSince1970: 1_699_999_700),
- type: "sgv"
- ),
- BloodGlucose(
- id: uuid(3).uuidString,
- sgv: 102,
- direction: .flat,
- date: 1_699_999_400_000,
- dateString: Date(timeIntervalSince1970: 1_699_999_400),
- type: "sgv"
- ),
- BloodGlucose(
- id: uuid(4).uuidString,
- sgv: 103,
- direction: .flat,
- date: 1_699_999_100_000,
- dateString: Date(timeIntervalSince1970: 1_699_999_100),
- type: "sgv"
- ),
- BloodGlucose(
- id: uuid(5).uuidString,
- sgv: 104,
- direction: .flat,
- date: 1_699_998_800_000,
- dateString: Date(timeIntervalSince1970: 1_699_998_800),
- type: "sgv"
- ),
- BloodGlucose(
- id: uuid(6).uuidString,
- sgv: 105,
- direction: .flat,
- date: 1_699_998_500_000,
- dateString: Date(timeIntervalSince1970: 1_699_998_500),
- type: "sgv"
- )
- ])
- }
- @Test("Smoothing rounding boundaries map identically") func testRoundingBoundaries() async throws {
- await insertGlucose(
- glucose: 100,
- isManual: false,
- date: fixedDate(minutesAgo: 0),
- smoothed: Decimal(string: "118.5"),
- id: uuid(1)
- )
- await insertGlucose(
- glucose: 100,
- isManual: false,
- date: fixedDate(minutesAgo: 5),
- smoothed: Decimal(string: "118.4"),
- id: uuid(2)
- )
- await insertGlucose(
- glucose: 100,
- isManual: false,
- date: fixedDate(minutesAgo: 10),
- smoothed: Decimal(string: "119.5"),
- id: uuid(3)
- )
- try await assertNativeMatchesGolden(shouldSmoothGlucose: true, [
- BloodGlucose(
- id: uuid(1).uuidString,
- sgv: 119,
- date: 1_700_000_000_000,
- dateString: Date(timeIntervalSince1970: 1_700_000_000),
- type: "sgv"
- ),
- BloodGlucose(
- id: uuid(2).uuidString,
- sgv: 118,
- date: 1_699_999_700_000,
- dateString: Date(timeIntervalSince1970: 1_699_999_700),
- type: "sgv"
- ),
- BloodGlucose(
- id: uuid(3).uuidString,
- sgv: 120,
- date: 1_699_999_400_000,
- dateString: Date(timeIntervalSince1970: 1_699_999_400),
- type: "sgv"
- )
- ])
- let native = try await nativeBloodGlucose(shouldSmoothGlucose: true)
- #expect(native.map(\.sgv) == [119, 118, 120], ".plain scale-0 rounding: 118.5→119, 118.4→118, 119.5→120")
- }
- // MARK: - Comparison helpers
- /// Asserts the native mapping reproduces the frozen golden `BloodGlucose` values. The goldens
- /// were captured from the old `AlgorithmGlucose` → JSON → `JSONBridge.glucose` path (see the
- /// differential run in this migration's history), so a match proves the algorithm still receives
- /// identical glucose after the JSON round-trip was removed.
- private func assertNativeMatchesGolden(shouldSmoothGlucose: Bool, _ golden: [BloodGlucose]) async throws {
- let native = try await nativeBloodGlucose(shouldSmoothGlucose: shouldSmoothGlucose)
- #expect(native.count == golden.count, "native produced \(native.count) entries, golden has \(golden.count)")
- for (index, pair) in zip(native, golden).enumerated() {
- expectFieldsEqual(pair.0, pair.1, entry: index)
- }
- }
- /// Field-by-field comparison. We can't use `==`: `BloodGlucose.==` only compares `dateString`,
- /// so a direct comparison would pass even if `sgv`/`glucose`/`direction`/`id`/`type` differed —
- /// exactly the coerced fields we must pin.
- ///
- /// `dateString` is compared at millisecond resolution rather than as an exact `Date`. The mapping
- /// keeps the reading's full-precision date in memory, but only millisecond precision is ever
- /// observable — it's what `date` pins and what the value serializes to — and Core Data's `Double`
- /// round-trip perturbs sub-millisecond bits anyway, so an exact `Date` comparison would be flaky.
- private func expectFieldsEqual(_ actual: BloodGlucose, _ expected: BloodGlucose, entry index: Int) {
- #expect(actual.id == expected.id, "entry \(index): id \(actual.id) != \(expected.id)")
- #expect(actual.legacyId == expected.legacyId, "entry \(index): legacyId")
- #expect(
- actual.sgv == expected.sgv,
- "entry \(index): sgv \(actual.sgv.map(String.init) ?? "nil") != \(expected.sgv.map(String.init) ?? "nil")"
- )
- #expect(
- actual.glucose == expected.glucose,
- "entry \(index): glucose \(actual.glucose.map(String.init) ?? "nil") != \(expected.glucose.map(String.init) ?? "nil")"
- )
- #expect(actual.mbg == expected.mbg, "entry \(index): mbg")
- #expect(
- actual.direction == expected.direction,
- "entry \(index): direction \(actual.direction?.rawValue ?? "nil") != \(expected.direction?.rawValue ?? "nil")"
- )
- #expect(actual.date == expected.date, "entry \(index): date \(actual.date) != \(expected.date)")
- #expect(
- Self.millisecondString(actual.dateString) == Self.millisecondString(expected.dateString),
- "entry \(index): dateString \(Self.millisecondString(actual.dateString)) != \(Self.millisecondString(expected.dateString))"
- )
- #expect(actual.type == expected.type, "entry \(index): type \(actual.type ?? "nil") != \(expected.type ?? "nil")")
- #expect(actual.unfiltered == expected.unfiltered, "entry \(index): unfiltered")
- #expect(actual.filtered == expected.filtered, "entry \(index): filtered")
- #expect(actual.noise == expected.noise, "entry \(index): noise")
- #expect(actual.activationDate == expected.activationDate, "entry \(index): activationDate")
- #expect(actual.sessionStartDate == expected.sessionStartDate, "entry \(index): sessionStartDate")
- #expect(actual.transmitterID == expected.transmitterID, "entry \(index): transmitterID")
- }
- private static func millisecondString(_ date: Date) -> String {
- Formatter.iso8601withFractionalSeconds.string(from: date)
- }
- private func nativeBloodGlucose(shouldSmoothGlucose: Bool) async throws -> [BloodGlucose] {
- try await testContext.perform {
- try self.fetchRowsNewestFirst().map {
- BaseGlucoseStorage.mapToBloodGlucose(
- $0,
- shouldSmoothGlucose: shouldSmoothGlucose,
- roundingBehavior: Self.roundingBehavior
- )
- }
- }
- }
- /// Must be called from within `testContext.perform`.
- private func fetchRowsNewestFirst() throws -> [GlucoseStored] {
- let request = GlucoseStored.fetchRequest()
- request.sortDescriptors = [NSSortDescriptor(key: "date", ascending: false)]
- return try testContext.fetch(request)
- }
- private static let roundingBehavior = NSDecimalNumberHandler(
- roundingMode: .plain,
- scale: 0,
- raiseOnExactness: false,
- raiseOnOverflow: false,
- raiseOnUnderflow: false,
- raiseOnDivideByZero: false
- )
- // MARK: - Fixture helpers
- private func insertGlucose(
- glucose: Int16,
- isManual: Bool,
- date: Date,
- smoothed: Decimal? = nil,
- direction: String? = nil,
- id: UUID
- ) async {
- await testContext.perform {
- let object = GlucoseStored(context: self.testContext)
- object.glucose = glucose
- object.isManual = isManual
- object.date = date
- object.smoothedGlucose = smoothed.map { NSDecimalNumber(decimal: $0) }
- object.direction = direction
- object.id = id
- try! self.testContext.save()
- }
- }
- /// A fixed base timestamp (2023-11-14T22:13:20Z) so fixtures are deterministic and reproducible.
- private func fixedDate(minutesAgo: Double, plusSeconds: Double = 0) -> Date {
- Date(timeIntervalSince1970: 1_700_000_000 + plusSeconds - minutesAgo * 60)
- }
- private func uuid(_ n: Int) -> UUID {
- UUID(uuidString: String(format: "00000000-0000-0000-0000-%012d", n))!
- }
- }
|