| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317 |
- import CoreData
- import Foundation
- import Testing
- @testable import Trio
- /// Golden tests certifying that the native `PumpEventStored` → `[PumpHistoryEvent]` mapping
- /// (`OpenAPS.nativePumpHistory` / `PumpEventStored.toPumpHistoryEvents`) reproduces, field for
- /// field, the pump history the algorithm used to receive through the old JSON round-trip
- /// (`PumpEventStored` → `[PumpEventDTO]` → JSON → `JSONBridge.pumpHistory`) — with one deliberate
- /// change: a nil `tempType` becomes `temp = nil` instead of throwing during decode.
- @Suite("Pump History Native Conversion Tests", .serialized) struct PumpHistoryNativeConversionTests {
- 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("A bolus maps identically") func testBolus() async throws {
- let ids = [
- try await insertBolus(id: uuid(1), date: fixedDate(0), amount: 2.5, isSMB: true, isExternal: false)
- ]
- try await assertNativeMatchesGolden(ids, golden: [
- PumpHistoryEvent(
- id: uuid(1),
- type: .bolus,
- timestamp: fixedDate(0),
- amount: dec("2.5"),
- duration: 0,
- isSMB: true,
- isExternal: false
- )
- ])
- }
- @Test("An external, non-SMB bolus maps identically") func testExternalBolus() async throws {
- let ids = [
- try await insertBolus(id: uuid(1), date: fixedDate(0), amount: 0.88, isSMB: false, isExternal: true)
- ]
- // 0.88 stored as a Core Data Decimal round-trips through the lossy Double hop to
- // 0.8800000000000001; the golden freezes that coerced value.
- try await assertNativeMatchesGolden(ids, golden: [
- PumpHistoryEvent(
- id: uuid(1),
- type: .bolus,
- timestamp: fixedDate(0),
- amount: dec("0.8800000000000001"),
- duration: 0,
- isSMB: false,
- isExternal: true
- )
- ])
- }
- @Test("A temp basal emits a duration entry then a rate entry, both identical") func testTempBasal() async throws {
- let ids = [
- try await insertTempBasal(id: uuid(1), date: fixedDate(0), rate: 0.85, durationMinutes: 30, tempType: "absolute")
- ]
- let native = try await nativeEvents(ids)
- // Exactly two entries, ordered duration-then-rate.
- #expect(native.count == 2)
- #expect(native.first?.type == .tempBasalDuration)
- #expect(native.first?.durationMin == 30)
- #expect(native.first?.duration == nil, "the duration entry uses durationMin, never duration")
- #expect(native.last?.type == .tempBasal)
- #expect(native.last?.id == "_\(uuid(1))", "the rate entry id is prefixed with an underscore")
- #expect(native.last?.temp == .absolute)
- try await assertNativeMatchesGolden(ids, golden: [
- PumpHistoryEvent(id: uuid(1), type: .tempBasalDuration, timestamp: fixedDate(0), durationMin: 30),
- PumpHistoryEvent(id: "_\(uuid(1))", type: .tempBasal, timestamp: fixedDate(0), rate: dec("0.85"), temp: .absolute)
- ])
- }
- @Test("A percent temp basal maps identically") func testPercentTempBasal() async throws {
- let ids = [
- try await insertTempBasal(id: uuid(1), date: fixedDate(0), rate: 1.5, durationMinutes: 45, tempType: "percent")
- ]
- try await assertNativeMatchesGolden(ids, golden: [
- PumpHistoryEvent(id: uuid(1), type: .tempBasalDuration, timestamp: fixedDate(0), durationMin: 45),
- PumpHistoryEvent(id: "_\(uuid(1))", type: .tempBasal, timestamp: fixedDate(0), rate: dec("1.5"), temp: .percent)
- ])
- }
- @Test("A temp basal with a nil rate emits only the duration entry") func testTempBasalNilRate() async throws {
- let ids = [
- try await insertTempBasal(id: uuid(1), date: fixedDate(0), rate: nil, durationMinutes: 30, tempType: "absolute")
- ]
- try await assertNativeMatchesGolden(ids, golden: [
- PumpHistoryEvent(id: uuid(1), type: .tempBasalDuration, timestamp: fixedDate(0), durationMin: 30)
- ])
- }
- @Test("Suspend, resume, rewind and prime map identically") func testStatusEvents() async throws {
- let ids = [
- try await insertStatusEvent(id: uuid(1), date: fixedDate(0), type: .pumpSuspend),
- try await insertStatusEvent(id: uuid(2), date: fixedDate(1), type: .pumpResume),
- try await insertStatusEvent(id: uuid(3), date: fixedDate(2), type: .rewind),
- try await insertStatusEvent(id: uuid(4), date: fixedDate(3), type: .prime)
- ]
- try await assertNativeMatchesGolden(ids, golden: [
- PumpHistoryEvent(id: uuid(1), type: .pumpSuspend, timestamp: fixedDate(0)),
- PumpHistoryEvent(id: uuid(2), type: .pumpResume, timestamp: fixedDate(1)),
- PumpHistoryEvent(id: uuid(3), type: .rewind, timestamp: fixedDate(2)),
- PumpHistoryEvent(id: uuid(4), type: .prime, timestamp: fixedDate(3))
- ])
- }
- @Test("A mixed sequence maps identically") func testMixedSequence() async throws {
- let ids = [
- try await insertBolus(id: uuid(1), date: fixedDate(0), amount: 1.0, isSMB: true, isExternal: false),
- try await insertTempBasal(id: uuid(2), date: fixedDate(1), rate: 0.7, durationMinutes: 30, tempType: "absolute"),
- try await insertStatusEvent(id: uuid(3), date: fixedDate(2), type: .pumpSuspend),
- try await insertStatusEvent(id: uuid(4), date: fixedDate(3), type: .pumpResume),
- try await insertBolus(id: uuid(5), date: fixedDate(4), amount: 0.05, isSMB: true, isExternal: false)
- ]
- try await assertNativeMatchesGolden(ids, golden: [
- PumpHistoryEvent(
- id: uuid(1),
- type: .bolus,
- timestamp: fixedDate(0),
- amount: dec("1.0"),
- duration: 0,
- isSMB: true,
- isExternal: false
- ),
- PumpHistoryEvent(id: uuid(2), type: .tempBasalDuration, timestamp: fixedDate(1), durationMin: 30),
- PumpHistoryEvent(id: "_\(uuid(2))", type: .tempBasal, timestamp: fixedDate(1), rate: dec("0.7"), temp: .absolute),
- PumpHistoryEvent(id: uuid(3), type: .pumpSuspend, timestamp: fixedDate(2)),
- PumpHistoryEvent(id: uuid(4), type: .pumpResume, timestamp: fixedDate(3)),
- PumpHistoryEvent(
- id: uuid(5),
- type: .bolus,
- timestamp: fixedDate(4),
- amount: dec("0.05"),
- duration: 0,
- isSMB: true,
- isExternal: false
- )
- ])
- }
- @Test("Orphaned resumes are filtered") func testOrphanedResumeFiltering() async throws {
- let bolus = try await insertBolus(id: uuid(1), date: fixedDate(0), amount: 1.0, isSMB: true, isExternal: false)
- let orphanedResume = try await insertStatusEvent(id: uuid(2), date: fixedDate(1), type: .pumpResume)
- let ids = [bolus, orphanedResume]
- try await assertNativeMatchesGolden(ids, orphanedResumes: [orphanedResume], golden: [
- PumpHistoryEvent(
- id: uuid(1),
- type: .bolus,
- timestamp: fixedDate(0),
- amount: dec("1.0"),
- duration: 0,
- isSMB: true,
- isExternal: false
- )
- ])
- }
- @Test("Empty history maps to an empty array") func testEmpty() async throws {
- try await assertNativeMatchesGolden([], golden: [])
- }
- // MARK: - The one deliberate behavioral change
- @Test("A nil tempType becomes temp = nil instead of throwing") func testNilTempTypeIsRobust() async throws {
- let ids = [
- try await insertTempBasal(id: uuid(1), date: fixedDate(0), rate: 0.85, durationMinutes: 30, tempType: nil)
- ]
- // The old JSON path emitted the string "unknown" for a nil tempType, which threw while
- // decoding. Native emits both entries and leaves temp nil instead.
- try await assertNativeMatchesGolden(ids, golden: [
- PumpHistoryEvent(id: uuid(1), type: .tempBasalDuration, timestamp: fixedDate(0), durationMin: 30),
- PumpHistoryEvent(id: "_\(uuid(1))", type: .tempBasal, timestamp: fixedDate(0), rate: dec("0.85"), temp: nil)
- ])
- }
- // MARK: - Comparison helpers
- /// Asserts the native mapping reproduces the frozen golden `[PumpHistoryEvent]`.
- private func assertNativeMatchesGolden(
- _ objectIDs: [NSManagedObjectID],
- orphanedResumes: [NSManagedObjectID] = [],
- golden: [PumpHistoryEvent]
- ) async throws {
- let native = try await nativeEvents(objectIDs, orphanedResumes: orphanedResumes)
- #expect(native.count == golden.count, "native produced \(native.count) events, golden has \(golden.count)")
- for (index, pair) in zip(native, golden).enumerated() {
- expectFieldsEqual(pair.0, pair.1, event: index)
- }
- }
- private func nativeEvents(
- _ objectIDs: [NSManagedObjectID],
- orphanedResumes: [NSManagedObjectID] = []
- ) async throws -> [PumpHistoryEvent] {
- try await testContext.perform {
- OpenAPS.nativePumpHistory(objectIDs, orphanedResumes: orphanedResumes, from: self.testContext)
- }
- }
- /// Field-by-field comparison, comparing `timestamp` at millisecond resolution.
- private func expectFieldsEqual(_ actual: PumpHistoryEvent, _ expected: PumpHistoryEvent, event index: Int) {
- #expect(actual.id == expected.id, "event \(index): id \(actual.id) != \(expected.id)")
- #expect(actual.type == expected.type, "event \(index): type \(actual.type) != \(expected.type)")
- #expect(
- Self.millisecondString(actual.timestamp) == Self.millisecondString(expected.timestamp),
- "event \(index): timestamp mismatch"
- )
- #expect(actual.amount == expected.amount, "event \(index): amount \(desc(actual.amount)) != \(desc(expected.amount))")
- #expect(
- actual.duration == expected.duration,
- "event \(index): duration \(desc(actual.duration)) != \(desc(expected.duration))"
- )
- #expect(
- actual.durationMin == expected.durationMin,
- "event \(index): durationMin \(desc(actual.durationMin)) != \(desc(expected.durationMin))"
- )
- #expect(actual.rate == expected.rate, "event \(index): rate \(desc(actual.rate)) != \(desc(expected.rate))")
- #expect(actual.temp == expected.temp, "event \(index): temp \(desc(actual.temp)) != \(desc(expected.temp))")
- #expect(actual.isSMB == expected.isSMB, "event \(index): isSMB \(desc(actual.isSMB)) != \(desc(expected.isSMB))")
- #expect(
- actual.isExternal == expected.isExternal,
- "event \(index): isExternal \(desc(actual.isExternal)) != \(desc(expected.isExternal))"
- )
- }
- private func desc<T>(_ value: T?) -> String { value.map { "\($0)" } ?? "nil" }
- /// Builds a `Decimal` from its string form, matching `Decimal(algorithmValue:)`.
- private func dec(_ string: String) -> Decimal { Decimal(string: string) ?? .zero }
- private static func millisecondString(_ date: Date) -> String {
- Formatter.iso8601withFractionalSeconds.string(from: date)
- }
- // MARK: - Fixture helpers
- private func insertBolus(
- id: String,
- date: Date,
- amount: Double,
- isSMB: Bool,
- isExternal: Bool
- ) async throws -> NSManagedObjectID {
- try await testContext.perform {
- let event = PumpEventStored(context: self.testContext)
- event.id = id
- event.timestamp = date
- event.type = PumpEventStored.EventType.bolus.rawValue
- let bolus = BolusStored(context: self.testContext)
- bolus.amount = NSDecimalNumber(value: amount)
- bolus.isSMB = isSMB
- bolus.isExternal = isExternal
- bolus.pumpEvent = event
- try self.testContext.save()
- return event.objectID
- }
- }
- private func insertTempBasal(
- id: String,
- date: Date,
- rate: Double?,
- durationMinutes: Int,
- tempType: String?
- ) async throws -> NSManagedObjectID {
- try await testContext.perform {
- let event = PumpEventStored(context: self.testContext)
- event.id = id
- event.timestamp = date
- event.type = PumpEventStored.EventType.tempBasal.rawValue
- let tempBasal = TempBasalStored(context: self.testContext)
- tempBasal.rate = rate.map { NSDecimalNumber(value: $0) }
- tempBasal.duration = Int16(durationMinutes)
- tempBasal.tempType = tempType
- tempBasal.pumpEvent = event
- try self.testContext.save()
- return event.objectID
- }
- }
- private func insertStatusEvent(
- id: String,
- date: Date,
- type: PumpEventStored.EventType
- ) async throws -> NSManagedObjectID {
- try await testContext.perform {
- let event = PumpEventStored(context: self.testContext)
- event.id = id
- event.timestamp = date
- event.type = type.rawValue
- try self.testContext.save()
- return event.objectID
- }
- }
- /// A fixed base timestamp on whole seconds so fixtures are deterministic.
- private func fixedDate(_ minutesAgo: Double) -> Date {
- Date(timeIntervalSince1970: 1_700_000_000 - minutesAgo * 60)
- }
- private func uuid(_ n: Int) -> String {
- String(format: "00000000-0000-0000-0000-%012d", n)
- }
- }
|