import CryptoKit import HealthKit import LoopKit import Testing import TidepoolKit @testable import TidepoolServiceKit @testable import Trio // Both Trio and TidepoolServiceKit define mgPerDL, // causing ambiguity. Use HealthKit's native API to avoid the conflict. private let mgPerDL = HKUnit.gramUnit(with: .milli).unitDivided(by: HKUnit.literUnit(with: .deci)) private let mmolPerL = HKUnit.moleUnit(with: .milli, molarMass: HKUnitMolarMassBloodGlucose).unitDivided(by: .liter()) // MARK: - StoredSettings → Tidepool Datum Tests /// Tests that verify Trio's StoredSettings correctly converts to Tidepool's pumpSettings datum. /// These test the REAL TidepoolServiceKit conversion code path. @Suite("StoredSettings Tidepool Format Tests") struct StoredSettingsTidepoolFormatTests { private static let encoder: JSONEncoder = { let encoder = JSONEncoder.tidepool encoder.outputFormatting.insert(.prettyPrinted) encoder.outputFormatting.insert(.sortedKeys) return encoder }() // MARK: - JSON Format @Test("Pump settings JSON contains required fields") func pumpSettingsJSONFormat() { let datum = StoredSettings.test.datumPumpSettings(for: "trio-user-123", hostIdentifier: "Trio", hostVersion: "0.6.0") let data = try! Self.encoder.encode(datum) let json = String(data: data, encoding: .utf8)! let requiredFields = [ "\"type\" : \"pumpSettings\"", "\"activeSchedule\" : \"Default\"", "\"basalSchedules\"", "\"bgTargets\"", "\"carbRatios\"", "\"insulinSensitivities\"", "\"automatedDelivery\"", "\"name\" : \"Trio\"", "\"version\" : \"0.6.0\"" ] for field in requiredFields { #expect(json.contains(field), "Missing required field: \(field)") } } @Test("Pump settings with minimal data") func pumpSettingsWithMinimalData() { let datum = StoredSettings.minimal.datumPumpSettings(for: "test-user", hostIdentifier: "Trio", hostVersion: "0.6.0") #expect(datum.activeScheduleName == "Default") #expect(datum.origin?.name == "Trio") #expect(datum.origin?.version == "0.6.0") } // MARK: - Schedule Naming @Test("All schedules use 'Default' name") func scheduleNaming() { let datum = StoredSettings.test.datumPumpSettings(for: "test", hostIdentifier: "Trio", hostVersion: "0.6.0") #expect(datum.activeScheduleName == "Default") #expect(datum.basalRateSchedules?.keys.count == 1) #expect(datum.basalRateSchedules?["Default"] != nil) #expect(datum.bloodGlucoseTargetSchedules?["Default"] != nil) #expect(datum.carbohydrateRatioSchedules?["Default"] != nil) #expect(datum.insulinSensitivitySchedules?["Default"] != nil) } // MARK: - Device Metadata @Test("Pump device metadata is included") func pumpDeviceMetadata() { let pumpDevice = HKDevice( name: "Omnipod", manufacturer: "Insulet", model: "Dash", hardwareVersion: "1.0", firmwareVersion: "2.9.0", softwareVersion: nil, localIdentifier: "pod-123", udiDeviceIdentifier: nil ) let settings = makeSettings(pumpDevice: pumpDevice) let data = try! Self.encoder.encode( settings.datumPumpSettings(for: "test", hostIdentifier: "Trio", hostVersion: "0.6.0") ) let json = String(data: data, encoding: .utf8)! #expect(json.contains("Omnipod"), "Missing pump device name") #expect(json.contains("Insulet"), "Missing pump manufacturer") } @Test("CGM device metadata is included") func cgmDeviceMetadata() { let cgmDevice = HKDevice( name: "Dexcom G7", manufacturer: "Dexcom", model: "G7", hardwareVersion: nil, firmwareVersion: "1.2.3", softwareVersion: "4.5.6", localIdentifier: "CGM123", udiDeviceIdentifier: nil ) let settings = makeSettings(cgmDevice: cgmDevice) let datum = settings.datumCGMSettings(for: "test", hostIdentifier: "Trio", hostVersion: "0.6.0") let data = try! Self.encoder.encode(datum) let json = String(data: data, encoding: .utf8)! #expect(json.contains("Dexcom G7"), "Missing CGM device name") #expect(json.contains("Dexcom"), "Missing CGM manufacturer") } // MARK: - Suspend Threshold @Test("Suspend threshold value is preserved") func suspendThreshold() { let settings = makeSettings( suspendThreshold: GlucoseThreshold(unit: mgPerDL, value: 70.0) ) let datum = settings.datumPumpSettings(for: "test", hostIdentifier: "Trio", hostVersion: "0.6.0") #expect(datum.bloodGlucoseSafetyLimit == 70, "Suspend threshold value should match") } @Test("Suspend threshold in mg/dL passes through for mmol/L user") func suspendThresholdMmolLUser() { // threshold_setting is always stored in mg/dL even when user displays mmol/L. // The adapter creates GlucoseThreshold in mg/dL; TidepoolServiceKit converts internally. let settings = makeSettings( suspendThreshold: GlucoseThreshold(unit: mgPerDL, value: 70.0), bgUnit: mmolPerL ) let datum = settings.datumPumpSettings(for: "test", hostIdentifier: "Trio", hostVersion: "0.6.0") #expect( datum.bloodGlucoseSafetyLimit == 70, "Threshold in mg/dL should pass through correctly regardless of display unit" ) } // MARK: - Max Basal / Max Bolus @Test("Maximum basal and bolus values are preserved") func maximumValues() { let settings = makeSettings(maxBasal: 30.0, maxBolus: 25.0) let datum = settings.datumPumpSettings(for: "test", hostIdentifier: "Trio", hostVersion: "0.6.0") #expect(datum.basal?.rateMaximum?.value == 30.0, "Max basal should handle high values") #expect(datum.bolus?.amountMaximum?.value == 25.0, "Max bolus should handle high values") } @Test("Minimum basal and bolus values are preserved") func minimumValues() { let settings = makeSettings(maxBasal: 0.5, maxBolus: 1.0) let datum = settings.datumPumpSettings(for: "test", hostIdentifier: "Trio", hostVersion: "0.6.0") #expect(datum.basal?.rateMaximum?.value == 0.5, "Should preserve low max basal") #expect(datum.bolus?.amountMaximum?.value == 1.0, "Should preserve low max bolus") } // MARK: - Automated Delivery Flag @Test("Automated delivery flag reflects dosing state") func automatedDeliveryFlag() { let enabled = makeSettings(dosingEnabled: true) let disabled = makeSettings(dosingEnabled: false) let enabledDatum = enabled.datumPumpSettings(for: "test", hostIdentifier: "Trio", hostVersion: "0.6.0") let disabledDatum = disabled.datumPumpSettings(for: "test", hostIdentifier: "Trio", hostVersion: "0.6.0") #expect(enabledDatum.automatedDelivery == true) #expect(disabledDatum.automatedDelivery == false) } // MARK: - Unit Conversion @Test("mmol/L values are converted to mg/dL by Tidepool") func mmolLUnitConversion() { let targetSchedule = GlucoseRangeSchedule( rangeSchedule: DailyQuantitySchedule( unit: mmolPerL, dailyItems: [RepeatingScheduleValue( startTime: 0, value: DoubleRange(minValue: 5.0, maxValue: 6.0) )], timeZone: .current )!, override: nil ) let isfSchedule = InsulinSensitivitySchedule( unit: mmolPerL, dailyItems: [RepeatingScheduleValue(startTime: 0, value: 3.0)], timeZone: .current ) let settings = makeSettings( glucoseTargetRangeSchedule: targetSchedule, insulinSensitivitySchedule: isfSchedule, bgUnit: mmolPerL ) let datum = settings.datumPumpSettings(for: "test", hostIdentifier: "Trio", hostVersion: "0.6.0") // Tidepool converts to mg/dL (5.0 mmol/L ≈ 90 mg/dL) let target = datum.bloodGlucoseTargetSchedules?["Default"]?.first #expect(abs((target?.low ?? 0) - 90) <= 1) #expect(abs((target?.high ?? 0) - 108) <= 1) let isf = datum.insulinSensitivitySchedules?["Default"]?.first #expect(abs((isf?.amount ?? 0) - 54) <= 1) } // MARK: - Insulin Model @Test("Insulin model preserves DIA and peak time") func insulinModel() { let model = StoredInsulinModel( modelType: .rapidAdult, delay: .minutes(10), actionDuration: .hours(8), peakActivity: .minutes(65) ) let settings = makeSettings(insulinModel: model) let datum = settings.datumPumpSettings(for: "test", hostIdentifier: "Trio", hostVersion: "0.6.0") #expect(datum.insulinModel != nil, "Insulin model should be present") #expect(datum.insulinModel?.actionDuration == .hours(8), "DIA should match user setting") #expect(datum.insulinModel?.actionPeakOffset == .minutes(65), "Peak time should match user setting") } @Test("Fiasp insulin model maps correctly") func fiaspInsulinModel() { let model = StoredInsulinModel( modelType: .fiasp, delay: .minutes(10), actionDuration: .hours(6), peakActivity: .minutes(55) ) let settings = makeSettings(insulinModel: model) let datum = settings.datumPumpSettings(for: "test", hostIdentifier: "Trio", hostVersion: "0.6.0") #expect(datum.insulinModel?.modelType == .fiasp, "Ultra-rapid should map to fiasp") #expect(datum.insulinModel?.actionDuration == .hours(6)) #expect(datum.insulinModel?.actionPeakOffset == .minutes(55)) } // MARK: - Helpers private func makeSettings( dosingEnabled: Bool = true, glucoseTargetRangeSchedule: GlucoseRangeSchedule? = nil, insulinSensitivitySchedule: InsulinSensitivitySchedule? = nil, maxBasal: Double? = 5.0, maxBolus: Double? = 10.0, suspendThreshold: GlucoseThreshold? = nil, insulinModel: StoredInsulinModel? = nil, cgmDevice: HKDevice? = nil, pumpDevice: HKDevice? = nil, bgUnit: HKUnit = mgPerDL ) -> StoredSettings { let tz = TimeZone(secondsFromGMT: 0)! let defaultTarget = GlucoseRangeSchedule( rangeSchedule: DailyQuantitySchedule( unit: mgPerDL, dailyItems: [RepeatingScheduleValue( startTime: 0, value: DoubleRange(minValue: 100.0, maxValue: 110.0) )], timeZone: tz )!, override: nil ) let defaultBasal = BasalRateSchedule( dailyItems: [RepeatingScheduleValue(startTime: 0, value: 1.0)], timeZone: tz )! let defaultISF = InsulinSensitivitySchedule( unit: mgPerDL, dailyItems: [RepeatingScheduleValue(startTime: 0, value: 45.0)], timeZone: tz )! let defaultCarb = CarbRatioSchedule( unit: .gram(), dailyItems: [RepeatingScheduleValue(startTime: 0, value: 15.0)], timeZone: tz )! return StoredSettings( date: Date(), controllerTimeZone: .current, dosingEnabled: dosingEnabled, glucoseTargetRangeSchedule: glucoseTargetRangeSchedule ?? defaultTarget, preMealTargetRange: nil, workoutTargetRange: nil, overridePresets: nil, scheduleOverride: nil, preMealOverride: nil, maximumBasalRatePerHour: maxBasal, maximumBolus: maxBolus, suspendThreshold: suspendThreshold, insulinType: nil, defaultRapidActingModel: insulinModel, basalRateSchedule: defaultBasal, insulinSensitivitySchedule: insulinSensitivitySchedule ?? defaultISF, carbRatioSchedule: defaultCarb, notificationSettings: nil, controllerDevice: nil, cgmDevice: cgmDevice, pumpDevice: pumpDevice, bloodGlucoseUnit: bgUnit, syncIdentifier: UUID() ) } } // MARK: - Conversion Logic Tests /// Tests for the conversion math used in BaseTidepoolManager. /// These verify the patterns used in the real adapter code. @Suite("BaseTidepoolManager Conversion Tests") struct BaseTidepoolManagerTests { // MARK: - Basal Profile Conversion @Test("Basal profile minutes convert to seconds") func basalProfileMinutesToSeconds() { let entries: [(minutes: Int, expectedSeconds: TimeInterval)] = [ (0, 0), (210, 12600), (360, 21600), (720, 43200), (1125, 67500), (1439, 86340) ] for (minutes, expected) in entries { let startTime = TimeInterval(minutes * 60) #expect(startTime == expected, "\(minutes) minutes should be \(expected) seconds") } } @Test("Basal profile uses minutes field for start time") func basalProfileUsesMinutesField() { let entries = [ BasalProfileEntry(start: "00:00:00", minutes: 0, rate: 1.0), BasalProfileEntry(start: "06:00:00", minutes: 360, rate: 1.5), BasalProfileEntry(start: "12:00:00", minutes: 720, rate: 1.25) ] let items = entries.map { entry in RepeatingScheduleValue( startTime: TimeInterval(entry.minutes * 60), value: Double(entry.rate) ) } let schedule = BasalRateSchedule(dailyItems: items, timeZone: .current) #expect(schedule != nil) #expect(schedule?.items[0].startTime == 0) #expect(schedule?.items[1].startTime == 21600) #expect(schedule?.items[2].startTime == 43200) } // MARK: - Carb Ratio Conversion @Test("Carb ratio offset converts to seconds") func carbRatioOffsetToSeconds() { let entries = [ CarbRatioEntry(start: "00:00", offset: 0, ratio: 15.0), CarbRatioEntry(start: "06:00", offset: 360, ratio: 12.0), CarbRatioEntry(start: "12:00", offset: 720, ratio: 10.0) ] let items = entries.map { entry in RepeatingScheduleValue( startTime: TimeInterval(entry.offset * 60), value: Double(entry.ratio) ) } #expect(items[0].startTime == 0) #expect(items[1].startTime == 21600) #expect(items[2].startTime == 43200) } // MARK: - ISF Conversion @Test("ISF offset converts to seconds") func insulinSensitivityOffsetToSeconds() { let entries = [ InsulinSensitivityEntry(sensitivity: 50.0, offset: 0, start: "00:00"), InsulinSensitivityEntry(sensitivity: 45.0, offset: 480, start: "08:00") ] let items = entries.map { entry in RepeatingScheduleValue( startTime: TimeInterval(entry.offset * 60), value: Double(entry.sensitivity) ) } #expect(items[0].startTime == 0) #expect(items[1].startTime == 28800, "480 min = 28800 sec") } // MARK: - BG Target Conversion @Test("BG target offset converts to seconds") func bgTargetOffsetToSeconds() { let entries = [ BGTargetEntry(low: 100, high: 110, start: "00:00", offset: 0), BGTargetEntry(low: 110, high: 120, start: "22:00", offset: 1320) ] #expect(TimeInterval(entries[0].offset * 60) == 0) #expect(TimeInterval(entries[1].offset * 60) == 79200, "1320 min = 79200 sec") } @Test("BG target low and high values are preserved") func bgTargetLowHighValues() { let entry = BGTargetEntry(low: 90, high: 120, start: "00:00", offset: 0) #expect(Double(entry.low) == 90) #expect(Double(entry.high) == 120) } // MARK: - Insulin Model Conversion @Test("Preset peak times match expected values when custom peak disabled") func presetPeakTimes() { // When useCustomPeakTime is false, should use LoopKit preset defaults let rapidAdultPeak = ExponentialInsulinModelPreset.rapidActingAdult.peakActivity let fiaspPeak = ExponentialInsulinModelPreset.fiasp.peakActivity #expect(rapidAdultPeak == .minutes(75), "rapidActingAdult preset peak should be 75 min") #expect(fiaspPeak == .minutes(55), "fiasp preset peak should be 55 min") } @Test("Custom peak time range boundaries") func customPeakTimeRange() { // insulinPeakTime picker: min 35, max 120, step 1 (minutes) let minPeak: TimeInterval = .minutes(35) let maxPeak: TimeInterval = .minutes(120) #expect(minPeak == 2100, "35 minutes = 2100 seconds") #expect(maxPeak == 7200, "120 minutes = 7200 seconds") } @Test("DIA range boundaries") func diaRange() { // insulinActionCurve picker: min 5, max 10, step 0.5 (hours) let minDIA: TimeInterval = .hours(5) let maxDIA: TimeInterval = .hours(10) #expect(minDIA == 18000, "5 hours = 18000 seconds") #expect(maxDIA == 36000, "10 hours = 36000 seconds") } // MARK: - Content-Based Sync Identifier @Test("Same settings produce the same sync identifier") func syncIdentifierDeterminism() { let id1 = computeTestSyncId(maxBasal: "5.0", maxBolus: "10.0", dosingEnabled: true) let id2 = computeTestSyncId(maxBasal: "5.0", maxBolus: "10.0", dosingEnabled: true) #expect(id1 == id2, "Same settings should produce the same sync identifier") } @Test("Different settings produce different sync identifiers") func syncIdentifierChanges() { let baseline = computeTestSyncId(maxBasal: "5.0", maxBolus: "10.0", dosingEnabled: true) let changedBasal = computeTestSyncId(maxBasal: "6.0", maxBolus: "10.0", dosingEnabled: true) let changedDosing = computeTestSyncId(maxBasal: "5.0", maxBolus: "10.0", dosingEnabled: false) #expect(baseline != changedBasal, "Different maxBasal should produce different ID") #expect(baseline != changedDosing, "Different dosingEnabled should produce different ID") #expect(changedBasal != changedDosing, "All three should be unique") } // MARK: - Helpers /// Replicates the SHA-256 hash algorithm from BaseTidepoolManager.contentBasedSyncIdentifier private func computeTestSyncId(maxBasal: String, maxBolus: String, dosingEnabled: Bool) -> UUID { var hasher = SHA256() hasher.update(data: Data("0:1.0".utf8)) // basal entry hasher.update(data: Data("0:15".utf8)) // carb ratio hasher.update(data: Data("0:50".utf8)) // ISF hasher.update(data: Data("0:100:110".utf8)) // BG target hasher.update(data: Data("maxBasal:\(maxBasal)".utf8)) hasher.update(data: Data("maxBolus:\(maxBolus)".utf8)) hasher.update(data: Data("threshold:100".utf8)) hasher.update(data: Data("dosingEnabled:\(dosingEnabled)".utf8)) let digest = hasher.finalize() let bytes = Array(digest.prefix(16)) return UUID(uuid: ( bytes[0], bytes[1], bytes[2], bytes[3], bytes[4], bytes[5], bytes[6], bytes[7], bytes[8], bytes[9], bytes[10], bytes[11], bytes[12], bytes[13], bytes[14], bytes[15] )) } } // MARK: - Test Fixtures private extension StoredSettings { static var test: StoredSettings { let tz = TimeZone(secondsFromGMT: 0)! let pumpDevice = HKDevice( name: "Omnipod", manufacturer: "Insulet", model: "Dash", hardwareVersion: "1.0", firmwareVersion: "2.9.0", softwareVersion: nil, localIdentifier: "pod-serial-123", udiDeviceIdentifier: nil ) return StoredSettings( date: Date(), controllerTimeZone: TimeZone(identifier: "America/Los_Angeles")!, dosingEnabled: true, glucoseTargetRangeSchedule: GlucoseRangeSchedule( rangeSchedule: DailyQuantitySchedule( unit: mgPerDL, dailyItems: [RepeatingScheduleValue(startTime: 0, value: DoubleRange(minValue: 100.0, maxValue: 110.0))], timeZone: tz )!, override: nil ), preMealTargetRange: nil, workoutTargetRange: nil, overridePresets: nil, scheduleOverride: nil, preMealOverride: nil, maximumBasalRatePerHour: 5.0, maximumBolus: 10.0, suspendThreshold: nil, insulinType: .humalog, defaultRapidActingModel: nil, basalRateSchedule: BasalRateSchedule(dailyItems: [ RepeatingScheduleValue(startTime: 0, value: 1.0), RepeatingScheduleValue(startTime: 21600, value: 1.5), RepeatingScheduleValue(startTime: 43200, value: 1.25), RepeatingScheduleValue(startTime: 64800, value: 1.0) ], timeZone: tz)!, insulinSensitivitySchedule: InsulinSensitivitySchedule( unit: mgPerDL, dailyItems: [RepeatingScheduleValue(startTime: 0, value: 45.0)], timeZone: tz )!, carbRatioSchedule: CarbRatioSchedule( unit: .gram(), dailyItems: [RepeatingScheduleValue(startTime: 0, value: 15.0)], timeZone: tz )!, notificationSettings: nil, controllerDevice: nil, cgmDevice: nil, pumpDevice: pumpDevice, bloodGlucoseUnit: mgPerDL, syncIdentifier: UUID() ) } static var minimal: StoredSettings { let tz = TimeZone(secondsFromGMT: 0)! return StoredSettings( date: Date(), controllerTimeZone: .current, dosingEnabled: true, glucoseTargetRangeSchedule: GlucoseRangeSchedule( rangeSchedule: DailyQuantitySchedule( unit: mgPerDL, dailyItems: [RepeatingScheduleValue(startTime: 0, value: DoubleRange(minValue: 100.0, maxValue: 110.0))], timeZone: tz )!, override: nil ), preMealTargetRange: nil, workoutTargetRange: nil, overridePresets: nil, scheduleOverride: nil, preMealOverride: nil, maximumBasalRatePerHour: nil, maximumBolus: nil, suspendThreshold: nil, insulinType: nil, defaultRapidActingModel: nil, basalRateSchedule: BasalRateSchedule( dailyItems: [RepeatingScheduleValue(startTime: 0, value: 1.0)], timeZone: tz )!, insulinSensitivitySchedule: InsulinSensitivitySchedule( unit: mgPerDL, dailyItems: [RepeatingScheduleValue(startTime: 0, value: 45.0)], timeZone: tz )!, carbRatioSchedule: CarbRatioSchedule( unit: .gram(), dailyItems: [RepeatingScheduleValue(startTime: 0, value: 15.0)], timeZone: tz )!, notificationSettings: nil, controllerDevice: nil, cgmDevice: nil, pumpDevice: nil, bloodGlucoseUnit: mgPerDL, syncIdentifier: UUID() ) } }