From 6c26ffb95d5ed3fb236268bc5cb21186213280b7 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 22 Aug 2026 15:36:34 +0000 Subject: [PATCH 1/2] Add tests for settings backup export/import - SettingsBackupCodableTests: encode/decode round trip, partial and future-schema backups, strict schemaVersion rejection, manager-state base64 plist round trip - SettingsImportApplierTests: mapping totality (every TrioSettings/ Preferences field mapped or explicitly excluded - fails when a new field ships uncategorized), exclusions, guardrail clamping with warnings, category filtering, diff/apply parity, therapy validation and normalization, pump-rate snapping, preset conflict strategies - SettingsImportPresetTests: all conflict strategies against in-memory Core Data, running-preset protection, override attribute fidelity - SettingsBackupE2ETests: full backup -> JSON -> decode -> merge -> real FileStorage/SettingsManager/Core Data -> read-back equality, plus partial-category isolation - Remove dead SettingsExportTests.swift (was never in the test target and referenced since-moved types) - Register the new test files in the TrioTests target Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Y9XRFomNerbiR4p6D8T7hh --- Trio.xcodeproj/project.pbxproj | 20 + .../SettingsImportPresetTests.swift | 269 +++++++++++++ TrioTests/SettingsBackupCodableTests.swift | 105 +++++ TrioTests/SettingsBackupE2ETests.swift | 186 +++++++++ TrioTests/SettingsBackupTestFixtures.swift | 222 +++++++++++ TrioTests/SettingsExportTests.swift | 59 --- TrioTests/SettingsImportApplierTests.swift | 370 ++++++++++++++++++ 7 files changed, 1172 insertions(+), 59 deletions(-) create mode 100644 TrioTests/CoreDataTests/SettingsImportPresetTests.swift create mode 100644 TrioTests/SettingsBackupCodableTests.swift create mode 100644 TrioTests/SettingsBackupE2ETests.swift create mode 100644 TrioTests/SettingsBackupTestFixtures.swift delete mode 100644 TrioTests/SettingsExportTests.swift create mode 100644 TrioTests/SettingsImportApplierTests.swift diff --git a/Trio.xcodeproj/project.pbxproj b/Trio.xcodeproj/project.pbxproj index fe97260f8a7..a63779871bd 100644 --- a/Trio.xcodeproj/project.pbxproj +++ b/Trio.xcodeproj/project.pbxproj @@ -314,6 +314,11 @@ 3EF667132FE48509009FB31A /* BasalDeliveryState+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3EF667122FE48502009FB31A /* BasalDeliveryState+Extension.swift */; }; 3F23E18680094E6DA98628E4 /* QuickPickBolusesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A54068ABDAE4898B243DF14 /* QuickPickBolusesView.swift */; }; 41740E936552456AAC0EDAC3 /* SettingsSearchTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3919BBB515547118D684CA2 /* SettingsSearchTests.swift */; }; + A15B00312F30AA0100AB0C31 /* SettingsBackupTestFixtures.swift in Sources */ = {isa = PBXBuildFile; fileRef = A15B00412F30AA0100AB0C41 /* SettingsBackupTestFixtures.swift */; }; + A15B00322F30AA0100AB0C32 /* SettingsBackupCodableTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A15B00422F30AA0100AB0C42 /* SettingsBackupCodableTests.swift */; }; + A15B00332F30AA0100AB0C33 /* SettingsImportApplierTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A15B00432F30AA0100AB0C43 /* SettingsImportApplierTests.swift */; }; + A15B00342F30AA0100AB0C34 /* SettingsBackupE2ETests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A15B00442F30AA0100AB0C44 /* SettingsBackupE2ETests.swift */; }; + A15B00352F30AA0100AB0C35 /* SettingsImportPresetTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A15B00452F30AA0100AB0C45 /* SettingsImportPresetTests.swift */; }; 45252C95D220E796FDB3B022 /* ConfigEditorDataFlow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F8A87AA037BD079BA3528BA /* ConfigEditorDataFlow.swift */; }; 45717281F743594AA9D87191 /* ConfigEditorRootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 920DDB21E5D0EB813197500D /* ConfigEditorRootView.swift */; }; 49090A8D2E9FE8D200D0F5DB /* GarminWatchSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 49090A8C2E9FE8D200D0F5DB /* GarminWatchSettings.swift */; }; @@ -1428,6 +1433,11 @@ B015AFE12E500000000D7351 /* BolusSafetyValidator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BolusSafetyValidator.swift; sourceTree = ""; }; B015AFE42E500000000D7351 /* BolusSafetyValidatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BolusSafetyValidatorTests.swift; sourceTree = ""; }; B3919BBB515547118D684CA2 /* SettingsSearchTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SettingsSearchTests.swift; sourceTree = ""; }; + A15B00412F30AA0100AB0C41 /* SettingsBackupTestFixtures.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsBackupTestFixtures.swift; sourceTree = ""; }; + A15B00422F30AA0100AB0C42 /* SettingsBackupCodableTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsBackupCodableTests.swift; sourceTree = ""; }; + A15B00432F30AA0100AB0C43 /* SettingsImportApplierTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsImportApplierTests.swift; sourceTree = ""; }; + A15B00442F30AA0100AB0C44 /* SettingsBackupE2ETests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsBackupE2ETests.swift; sourceTree = ""; }; + A15B00452F30AA0100AB0C45 /* SettingsImportPresetTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsImportPresetTests.swift; sourceTree = ""; }; B6E925122EB3932A0076D719 /* OmnipodKit.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = OmnipodKit.framework; sourceTree = BUILT_PRODUCTS_DIR; }; B9B5C0607505A38F256BF99A /* CGMSettingsDataFlow.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CGMSettingsDataFlow.swift; sourceTree = ""; }; BA49538D56989D8DA6FCF538 /* TargetsEditorDataFlow.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TargetsEditorDataFlow.swift; sourceTree = ""; }; @@ -3043,6 +3053,10 @@ CE1F6DD82BADF4620064EB8D /* PluginManagerTests.swift */, 3B5CD2C72D4AECD500CE213C /* OpenAPSSwiftTests */, B3919BBB515547118D684CA2 /* SettingsSearchTests.swift */, + A15B00412F30AA0100AB0C41 /* SettingsBackupTestFixtures.swift */, + A15B00422F30AA0100AB0C42 /* SettingsBackupCodableTests.swift */, + A15B00432F30AA0100AB0C43 /* SettingsImportApplierTests.swift */, + A15B00442F30AA0100AB0C44 /* SettingsBackupE2ETests.swift */, BD8FC0532D66186000B95AED /* TestError.swift */, C39FF1EC12CF4B1EBB13BE26 /* NightscoutUploadSerializerTests.swift */, BD8FC0702D661B0000B95AED /* TidepoolTherapySettingsTests.swift */, @@ -3694,6 +3708,7 @@ BD8FC05A2D6618AF00B95AED /* DeterminationStorageTests.swift */, BD8FC05F2D6619DB00B95AED /* CarbsStorageTests.swift */, BD8FC0612D6619E600B95AED /* OverrideStorageTests.swift */, + A15B00452F30AA0100AB0C45 /* SettingsImportPresetTests.swift */, BD8FC0632D6619EF00B95AED /* TempTargetStorageTests.swift */, BD8FC0652D661A0000B95AED /* GlucoseStorageTests.swift */, ); @@ -5762,6 +5777,11 @@ DD30BA002E0745C400DA677C /* DetermineBasalDeltaCalculationTests.swift in Sources */, BD8FC05E2D6618CE00B95AED /* BolusCalculatorTests.swift in Sources */, 41740E936552456AAC0EDAC3 /* SettingsSearchTests.swift in Sources */, + A15B00312F30AA0100AB0C31 /* SettingsBackupTestFixtures.swift in Sources */, + A15B00322F30AA0100AB0C32 /* SettingsBackupCodableTests.swift in Sources */, + A15B00332F30AA0100AB0C33 /* SettingsImportApplierTests.swift in Sources */, + A15B00342F30AA0100AB0C34 /* SettingsBackupE2ETests.swift in Sources */, + A15B00352F30AA0100AB0C35 /* SettingsImportPresetTests.swift in Sources */, B015AFE52E500000000D7351 /* BolusSafetyValidatorTests.swift in Sources */, BD8FC0712D661B0000B95AED /* TidepoolTherapySettingsTests.swift in Sources */, CA03000000000000000010C2 /* AlertCatalogRegistryOmniFaultTests.swift in Sources */, diff --git a/TrioTests/CoreDataTests/SettingsImportPresetTests.swift b/TrioTests/CoreDataTests/SettingsImportPresetTests.swift new file mode 100644 index 00000000000..400509b416a --- /dev/null +++ b/TrioTests/CoreDataTests/SettingsImportPresetTests.swift @@ -0,0 +1,269 @@ +import CoreData +import Foundation +import Swinject +import Testing + +@testable import Trio + +@Suite("Settings Import Preset Tests", .serialized) struct SettingsImportPresetTests: Injectable { + @Injected() var tempTargetsStorage: TempTargetsStorage! + @Injected() var overrideStorage: OverrideStorage! + let resolver: Resolver + var coreDataStack: CoreDataStack! + var testContext: NSManagedObjectContext! + + init() async throws { + coreDataStack = try await CoreDataStack.createForTests() + testContext = coreDataStack.newTaskContext() + + let assembler = Assembler([ + StorageAssembly(), + ServiceAssembly(), + APSAssembly(), + NetworkAssembly(), + UIAssembly(), + SecurityAssembly(), + TestAssembly(testContext: testContext) + ]) + + resolver = assembler.resolver + injectServices(resolver) + } + + private func seedTempTargetPreset(name: String, target: Decimal, enabled: Bool = false) async throws { + try await tempTargetsStorage.storeTempTarget(tempTarget: TempTarget( + name: name, + createdAt: Date(), + targetTop: target, + targetBottom: target, + duration: 60, + enteredBy: TempTarget.local, + reason: nil, + isPreset: true, + enabled: enabled, + halfBasalTarget: nil + )) + } + + private func loadedPresets() async throws -> SettingsBackupPresetLoader.LoadedPresets { + try await SettingsBackupPresetLoader.load( + tempTargetsStorage: tempTargetsStorage, + overrideStorage: overrideStorage, + context: testContext + ) + } + + @Test("Replace same-named replaces duplicates and keeps others") func testReplaceSameNamed() async throws { + try await seedTempTargetPreset(name: "Sport", target: 140) + try await seedTempTargetPreset(name: "Night", target: 110) + + let imported = SettingsBackup.Presets( + tempTargets: [ + SettingsBackup.TempTargetPreset(name: "Sport", target: 150, duration: 90, halfBasalTarget: nil, orderPosition: nil), + SettingsBackup.TempTargetPreset(name: "New", target: 130, duration: 30, halfBasalTarget: nil, orderPosition: nil) + ], + overrides: nil, + meals: nil + ) + + await SettingsBackupPresetApplier.apply( + imported, + categories: [.tempTargetPresets], + strategy: .replaceSameNamed, + tempTargetsStorage: tempTargetsStorage, + overrideStorage: overrideStorage, + context: testContext + ) + + let result = try await loadedPresets() + let byName = Dictionary( + (result.presets.tempTargets ?? []).map { ($0.name, $0) }, + uniquingKeysWith: { first, _ in first } + ) + + #expect(byName.count == 3) + #expect(byName["Sport"]?.target == 150) + #expect(byName["Sport"]?.duration == 90) + #expect(byName["Night"]?.target == 110) + #expect(byName["New"]?.target == 130) + } + + @Test("Keep existing never touches presets with known names") func testKeepExisting() async throws { + try await seedTempTargetPreset(name: "Sport", target: 140) + + let imported = SettingsBackup.Presets( + tempTargets: [ + SettingsBackup.TempTargetPreset(name: "Sport", target: 150, duration: 90, halfBasalTarget: nil, orderPosition: nil), + SettingsBackup.TempTargetPreset(name: "New", target: 130, duration: 30, halfBasalTarget: nil, orderPosition: nil) + ], + overrides: nil, + meals: nil + ) + + await SettingsBackupPresetApplier.apply( + imported, + categories: [.tempTargetPresets], + strategy: .keepExisting, + tempTargetsStorage: tempTargetsStorage, + overrideStorage: overrideStorage, + context: testContext + ) + + let result = try await loadedPresets() + let byName = Dictionary( + (result.presets.tempTargets ?? []).map { ($0.name, $0) }, + uniquingKeysWith: { first, _ in first } + ) + + #expect(byName.count == 2) + #expect(byName["Sport"]?.target == 140) + #expect(byName["New"]?.target == 130) + } + + @Test("Replace all removes presets missing from the file") func testReplaceAll() async throws { + try await seedTempTargetPreset(name: "Sport", target: 140) + try await seedTempTargetPreset(name: "Night", target: 110) + + let imported = SettingsBackup.Presets( + tempTargets: [ + SettingsBackup.TempTargetPreset(name: "Sport", target: 150, duration: 90, halfBasalTarget: nil, orderPosition: nil) + ], + overrides: nil, + meals: nil + ) + + await SettingsBackupPresetApplier.apply( + imported, + categories: [.tempTargetPresets], + strategy: .replaceAll, + tempTargetsStorage: tempTargetsStorage, + overrideStorage: overrideStorage, + context: testContext + ) + + let result = try await loadedPresets() + let tempTargets = result.presets.tempTargets ?? [] + + #expect(tempTargets.count == 1) + #expect(tempTargets.first?.name == "Sport") + #expect(tempTargets.first?.target == 150) + } + + @Test("A running preset survives replace-all untouched") func testRunningPresetIsProtected() async throws { + try await seedTempTargetPreset(name: "Sport", target: 140, enabled: true) + + let imported = SettingsBackup.Presets( + tempTargets: [ + SettingsBackup.TempTargetPreset(name: "Sport", target: 150, duration: 90, halfBasalTarget: nil, orderPosition: nil) + ], + overrides: nil, + meals: nil + ) + + let warnings = await SettingsBackupPresetApplier.apply( + imported, + categories: [.tempTargetPresets], + strategy: .replaceAll, + tempTargetsStorage: tempTargetsStorage, + overrideStorage: overrideStorage, + context: testContext + ) + + let result = try await loadedPresets() + let tempTargets = result.presets.tempTargets ?? [] + + #expect(tempTargets.count == 1) + #expect(tempTargets.first?.target == 140) + #expect(result.activeTempTargetNames.contains("Sport")) + #expect(warnings.contains { $0.contains("Sport") }) + } + + @Test("Override presets round-trip all attributes") func testOverridePresetFidelity() async throws { + let importedOverride = SettingsBackup.OverridePreset( + name: "Lazy Sunday", + percentage: 80, + indefinite: false, + duration: 120, + target: 120, + advancedSettings: true, + smbIsOff: false, + smbIsScheduledOff: true, + start: 8, + end: 20, + isfAndCr: true, + isf: true, + cr: true, + smbMinutes: 45, + uamMinutes: 45, + orderPosition: nil + ) + + await SettingsBackupPresetApplier.apply( + SettingsBackup.Presets(tempTargets: nil, overrides: [importedOverride], meals: nil), + categories: [.overridePresets], + strategy: .replaceSameNamed, + tempTargetsStorage: tempTargetsStorage, + overrideStorage: overrideStorage, + context: testContext + ) + + let result = try await loadedPresets() + let stored = try #require(result.presets.overrides?.first) + + #expect(stored.name == importedOverride.name) + #expect(stored.percentage == importedOverride.percentage) + #expect(stored.indefinite == importedOverride.indefinite) + #expect(stored.duration == importedOverride.duration) + #expect(stored.target == importedOverride.target) + #expect(stored.advancedSettings == importedOverride.advancedSettings) + #expect(stored.smbIsScheduledOff == importedOverride.smbIsScheduledOff) + #expect(stored.start == importedOverride.start) + #expect(stored.end == importedOverride.end) + #expect(stored.isfAndCr == importedOverride.isfAndCr) + #expect(stored.smbMinutes == importedOverride.smbMinutes) + #expect(stored.uamMinutes == importedOverride.uamMinutes) + } + + @Test("Meal presets import and replace by dish name") func testMealPresets() async throws { + let firstImport = SettingsBackup.Presets( + tempTargets: nil, + overrides: nil, + meals: [SettingsBackup.MealPreset(dish: "Pizza", carbs: 80, fat: 30, protein: 25)] + ) + await SettingsBackupPresetApplier.apply( + firstImport, + categories: [.mealPresets], + strategy: .replaceSameNamed, + tempTargetsStorage: tempTargetsStorage, + overrideStorage: overrideStorage, + context: testContext + ) + + let secondImport = SettingsBackup.Presets( + tempTargets: nil, + overrides: nil, + meals: [ + SettingsBackup.MealPreset(dish: "Pizza", carbs: 90, fat: 35, protein: 30), + SettingsBackup.MealPreset(dish: "Salad", carbs: 10, fat: 5, protein: 5) + ] + ) + await SettingsBackupPresetApplier.apply( + secondImport, + categories: [.mealPresets], + strategy: .replaceSameNamed, + tempTargetsStorage: tempTargetsStorage, + overrideStorage: overrideStorage, + context: testContext + ) + + let result = try await loadedPresets() + let byDish = Dictionary( + (result.presets.meals ?? []).map { ($0.dish, $0) }, + uniquingKeysWith: { first, _ in first } + ) + + #expect(byDish.count == 2) + #expect(byDish["Pizza"]?.carbs == 90) + #expect(byDish["Salad"]?.carbs == 10) + } +} diff --git a/TrioTests/SettingsBackupCodableTests.swift b/TrioTests/SettingsBackupCodableTests.swift new file mode 100644 index 00000000000..12507cce98a --- /dev/null +++ b/TrioTests/SettingsBackupCodableTests.swift @@ -0,0 +1,105 @@ +import Foundation +import Testing + +@testable import Trio + +@Suite("Settings Backup Codable Tests") struct SettingsBackupCodableTests { + @Test("Full backup survives an encode/decode round trip") func testRoundTrip() throws { + let backup = SettingsBackupTestFixtures.fullBackup() + + let data = try JSONCoding.encoder.encode(backup) + let decoded = try JSONCoding.decoder.decode(SettingsBackup.self, from: data) + + #expect(decoded == backup) + } + + @Test("Partial backup decodes with missing sections as nil") func testPartialBackup() throws { + let json = """ + { + "schemaVersion": 1, + "therapy": { + "basalProfile": [ + { "start": "00:00:00", "minutes": 0, "rate": 0.8 } + ] + } + } + """ + let decoded = try JSONCoding.decoder.decode(SettingsBackup.self, from: Data(json.utf8)) + + #expect(decoded.schemaVersion == 1) + #expect(decoded.therapy?.basalProfile?.count == 1) + #expect(decoded.therapy?.basalProfile?.first?.rate == 0.8) + #expect(decoded.trioSettings == nil) + #expect(decoded.preferences == nil) + #expect(decoded.presets == nil) + #expect(decoded.credentials == nil) + #expect(decoded.exportDate == nil) + } + + @Test("Backup from a newer schema decodes, ignoring unknown keys") func testFutureSchema() throws { + let json = """ + { + "schemaVersion": 99, + "someFutureSection": { "flag": true }, + "trioSettings": { + "units": "mmol/L", + "someFutureSetting": 42 + } + } + """ + let decoded = try JSONCoding.decoder.decode(SettingsBackup.self, from: Data(json.utf8)) + + #expect(decoded.schemaVersion == 99) + #expect(decoded.schemaVersion > SettingsBackup.currentSchemaVersion) + #expect(decoded.trioSettings?.units == .mmolL) + } + + @Test("File without schemaVersion is rejected") func testMissingSchemaVersion() { + let json = """ + { "trioSettings": { "units": "mg/dL" } } + """ + #expect(throws: (any Error).self) { + _ = try JSONCoding.decoder.decode(SettingsBackup.self, from: Data(json.utf8)) + } + } + + @Test("Garbage data is rejected") func testGarbage() { + #expect(throws: (any Error).self) { + _ = try JSONCoding.decoder.decode(SettingsBackup.self, from: Data("not json at all".utf8)) + } + } + + @Test("Credentials are only encoded when present") func testCredentialsAbsentByDefault() throws { + var backup = SettingsBackupTestFixtures.fullBackup() + backup.credentials = nil + backup.devices?.pumpState = nil + backup.devices?.cgmState = nil + + let data = try JSONCoding.encoder.encode(backup) + let json = String(decoding: data, as: UTF8.self) + + #expect(!json.contains("credentials")) + #expect(!json.contains("supersecret")) + #expect(!json.contains("pumpState")) + } + + @Test("Manager state survives a base64 plist round trip") func testManagerStateRoundTrip() throws { + let rawValue: [String: Any] = [ + "managerIdentifier": "Omnipod", + "state": ["address": 123_456, "nested": ["a": true]] + ] + + let encoded = try #require(SettingsBackup.encodeManagerState(rawValue)) + let decoded = try #require(SettingsBackup.decodeManagerState(encoded)) + + #expect(decoded["managerIdentifier"] as? String == "Omnipod") + let state = try #require(decoded["state"] as? [String: Any]) + #expect(state["address"] as? Int == 123_456) + #expect((state["nested"] as? [String: Any])?["a"] as? Bool == true) + } + + @Test("Invalid base64 manager state decodes to nil") func testInvalidManagerState() { + #expect(SettingsBackup.decodeManagerState("not-base64-!!!") == nil) + #expect(SettingsBackup.decodeManagerState(Data("plain text".utf8).base64EncodedString()) == nil) + } +} diff --git a/TrioTests/SettingsBackupE2ETests.swift b/TrioTests/SettingsBackupE2ETests.swift new file mode 100644 index 00000000000..4627999e5e5 --- /dev/null +++ b/TrioTests/SettingsBackupE2ETests.swift @@ -0,0 +1,186 @@ +import CoreData +import Foundation +import Swinject +import Testing + +@testable import Trio + +/// End-to-end round trip through the real persistence layer: build a backup → encode to JSON → +/// decode → run the applier merges → persist via the real `SettingsManager`/`FileStorage` and +/// Core Data → read everything back and compare. Only pump/CGM hardware interaction is out of +/// scope. The host app's files are snapshotted and restored around each test. +@Suite("Settings Backup E2E Tests", .serialized) struct SettingsBackupE2ETests: Injectable { + @Injected() var settingsManager: SettingsManager! + @Injected() var fileStorage: FileStorage! + @Injected() var tempTargetsStorage: TempTargetsStorage! + @Injected() var overrideStorage: OverrideStorage! + let resolver: Resolver + var coreDataStack: CoreDataStack! + var testContext: NSManagedObjectContext! + + init() async throws { + coreDataStack = try await CoreDataStack.createForTests() + testContext = coreDataStack.newTaskContext() + + let assembler = Assembler([ + StorageAssembly(), + ServiceAssembly(), + APSAssembly(), + NetworkAssembly(), + UIAssembly(), + SecurityAssembly(), + TestAssembly(testContext: testContext) + ]) + + resolver = assembler.resolver + injectServices(resolver) + } + + private struct PersistedSnapshot { + let settings: TrioSettings + let preferences: Preferences + let basalProfile: [BasalProfileEntry]? + let sensitivities: InsulinSensitivities? + let carbRatios: CarbRatios? + let bgTargets: BGTargets? + } + + private func takeSnapshot() -> PersistedSnapshot { + PersistedSnapshot( + settings: settingsManager.settings, + preferences: settingsManager.preferences, + basalProfile: fileStorage.retrieve(OpenAPS.Settings.basalProfile, as: [BasalProfileEntry].self), + sensitivities: fileStorage.retrieve(OpenAPS.Settings.insulinSensitivities, as: InsulinSensitivities.self), + carbRatios: fileStorage.retrieve(OpenAPS.Settings.carbRatios, as: CarbRatios.self), + bgTargets: fileStorage.retrieve(OpenAPS.Settings.bgTargets, as: BGTargets.self) + ) + } + + private func restore(_ snapshot: PersistedSnapshot) { + settingsManager.settings = snapshot.settings + settingsManager.preferences = snapshot.preferences + if let basalProfile = snapshot.basalProfile { + fileStorage.save(basalProfile, as: OpenAPS.Settings.basalProfile) + } else { + fileStorage.remove(OpenAPS.Settings.basalProfile) + } + if let sensitivities = snapshot.sensitivities { + fileStorage.save(sensitivities, as: OpenAPS.Settings.insulinSensitivities) + } else { + fileStorage.remove(OpenAPS.Settings.insulinSensitivities) + } + if let carbRatios = snapshot.carbRatios { + fileStorage.save(carbRatios, as: OpenAPS.Settings.carbRatios) + } else { + fileStorage.remove(OpenAPS.Settings.carbRatios) + } + if let bgTargets = snapshot.bgTargets { + fileStorage.save(bgTargets, as: OpenAPS.Settings.bgTargets) + } else { + fileStorage.remove(OpenAPS.Settings.bgTargets) + } + } + + @Test("Full backup round-trips through JSON and real persistence") func testFullRoundTrip() async throws { + let snapshot = takeSnapshot() + defer { restore(snapshot) } + + // Export side: build and encode the backup exactly like the export does. + let backup = SettingsBackupTestFixtures.fullBackup() + let data = try JSONCoding.encoder.encode(backup) + + // Import side: decode, merge, persist. + let decoded = try JSONCoding.decoder.decode(SettingsBackup.self, from: data) + let importedSettings = try #require(decoded.trioSettings) + let importedPreferences = try #require(decoded.preferences) + let allCategories = Set(SettingsBackupCategory.allCases) + + let mergedSettings = SettingsImportApplier.merge( + SettingsImportApplier.trioSettingsFields, + current: settingsManager.settings, + imported: importedSettings, + categories: allCategories, + units: importedSettings.units + ).result + settingsManager.settings = mergedSettings + + let mergedPreferences = SettingsImportApplier.merge( + SettingsImportApplier.preferencesFields, + current: settingsManager.preferences, + imported: importedPreferences, + categories: allCategories, + units: importedSettings.units + ).result + settingsManager.preferences = mergedPreferences + + let therapy = try #require(decoded.therapy) + let normalized = try SettingsImportApplier.validateTherapy(therapy) + if let targets = normalized.bgTargets { fileStorage.save(targets, as: OpenAPS.Settings.bgTargets) } + if let sensitivities = normalized.insulinSensitivities { + fileStorage.save(sensitivities, as: OpenAPS.Settings.insulinSensitivities) + } + if let carbRatios = normalized.carbRatios { fileStorage.save(carbRatios, as: OpenAPS.Settings.carbRatios) } + if let basalProfile = normalized.basalProfile { fileStorage.save(basalProfile, as: OpenAPS.Settings.basalProfile) } + + // Verify: what is on disk equals what the backup carried (modulo exclusions). + let persistedSettings = try #require(fileStorage.retrieve(OpenAPS.Trio.settings, as: TrioSettings.self)) + var expectedSettings = importedSettings + expectedSettings.closedLoop = snapshot.settings.closedLoop + #expect(persistedSettings == expectedSettings) + + let persistedPreferences = try #require(fileStorage.retrieve(OpenAPS.Settings.preferences, as: Preferences.self)) + var expectedPreferences = importedPreferences + expectedPreferences.bolusIncrement = snapshot.preferences.bolusIncrement + expectedPreferences.timestamp = persistedPreferences.timestamp + #expect(persistedPreferences == expectedPreferences) + + let persistedBasal = try #require(fileStorage.retrieve(OpenAPS.Settings.basalProfile, as: [BasalProfileEntry].self)) + #expect(persistedBasal == normalized.basalProfile) + + let persistedTargets = try #require(fileStorage.retrieve(OpenAPS.Settings.bgTargets, as: BGTargets.self)) + #expect(persistedTargets.units == .mgdL) + #expect(persistedTargets.targets == normalized.bgTargets?.targets) + + // Presets go through Core Data and come back identical. + let presets = try #require(decoded.presets) + await SettingsBackupPresetApplier.apply( + presets, + strategy: .replaceSameNamed, + tempTargetsStorage: tempTargetsStorage, + overrideStorage: overrideStorage, + context: testContext + ) + let loaded = try await SettingsBackupPresetLoader.load( + tempTargetsStorage: tempTargetsStorage, + overrideStorage: overrideStorage, + context: testContext + ) + #expect(loaded.presets.tempTargets?.map(\.name) == presets.tempTargets?.map(\.name)) + #expect(loaded.presets.overrides?.map(\.name) == presets.overrides?.map(\.name)) + #expect(loaded.presets.meals?.map(\.dish) == presets.meals?.map(\.dish)) + } + + @Test("Partial category import leaves the other categories untouched") func testPartialCategoryImport() throws { + let snapshot = takeSnapshot() + defer { restore(snapshot) } + + let backup = SettingsBackupTestFixtures.fullBackup() + let importedSettings = try #require(backup.trioSettings) + + let merged = SettingsImportApplier.merge( + SettingsImportApplier.trioSettingsFields, + current: settingsManager.settings, + imported: importedSettings, + categories: [.notifications], + units: settingsManager.settings.units + ).result + settingsManager.settings = merged + + let persisted = try #require(fileStorage.retrieve(OpenAPS.Trio.settings, as: TrioSettings.self)) + #expect(persisted.useLiveActivity == importedSettings.useLiveActivity) + #expect(persisted.glucoseBadge == importedSettings.glucoseBadge) + #expect(persisted.units == snapshot.settings.units) + #expect(persisted.maxCarbs == snapshot.settings.maxCarbs) + #expect(persisted.cgm == snapshot.settings.cgm) + } +} diff --git a/TrioTests/SettingsBackupTestFixtures.swift b/TrioTests/SettingsBackupTestFixtures.swift new file mode 100644 index 00000000000..f9f0e3f291d --- /dev/null +++ b/TrioTests/SettingsBackupTestFixtures.swift @@ -0,0 +1,222 @@ +import Foundation +@testable import Trio + +/// Shared builders for settings-backup tests. The "maximally different" structs flip or change +/// EVERY stored property relative to the defaults, with all numeric values inside the guardrail +/// bounds — the totality tests rely on that. +enum SettingsBackupTestFixtures { + static func maximallyDifferentTrioSettings() -> TrioSettings { + var settings = TrioSettings() + settings.units = .mmolL + settings.closedLoop = true + settings.isUploadEnabled = true + settings.isDownloadEnabled = true + settings.useLocalGlucoseSource = true + settings.localGlucosePort = 9090 + settings.debugOptions = true + settings.cgm = .nightscout + settings.cgmPluginIdentifier = "some-plugin" + settings.uploadGlucose = false + settings.useCalendar = true + settings.displayCalendarIOBandCOB = true + settings.displayCalendarEmojis = true + settings.glucoseBadge = true + settings.carbsRequiredThreshold = 20 + settings.showCarbsRequiredBadge = false + settings.useFPUconversion = true + settings.individualAdjustmentFactor = 0.6 + settings.minuteInterval = 45 + settings.delay = 90 + settings.useAppleHealth = true + settings.smoothGlucose = true + settings.eA1cDisplayUnit = .mmolMol + settings.high = 200 + settings.low = 80 + settings.glucoseColorScheme = .dynamicColor + settings.xGridLines = false + settings.yGridLines = false + settings.hideInsulinBadge = true + settings.allowDilution = true + settings.insulinConcentration = 2 + settings.showCobIobChart = false + settings.rulerMarks = false + settings.bolusDisplayThreshold = .oneUnit + settings.forecastDisplayType = .lines + settings.maxCarbs = 200 + settings.maxFat = 150 + settings.maxProtein = 100 + settings.confirmBolusFaster = true + settings.overrideFactor = 0.7 + settings.fattyMeals = true + settings.fattyMealFactor = 0.5 + settings.sweetMeals = true + settings.sweetMealFactor = 1.5 + settings.displayPresets = false + settings.confirmBolus = true + settings.enableQuickBolus = true + settings.useLiveActivity = true + settings.lockScreenView = .detailed + settings.smartStackView = .detailed + settings.displayGlucoseForecasts = true + settings.bolusShortcut = .limitWithSafetyChecks + settings.timeInRangeType = .timeInNormoglycemia + settings.requireAdjustmentsConfirmation = true + settings.garminWatchface = .swissalpine + settings.garminDatafield = .trio + settings.primaryAttributeChoice = .isf + settings.secondaryAttributeChoice = .eventualBG + settings.isWatchfaceDataEnabled = true + return settings + } + + static func maximallyDifferentPreferences() -> Preferences { + var preferences = Preferences() + preferences.maxIOB = 5 + preferences.maxDailySafetyMultiplier = 2 + preferences.currentBasalSafetyMultiplier = 3 + preferences.autosensMax = 1.5 + preferences.autosensMin = 0.8 + preferences.smbDeliveryRatio = 0.6 + preferences.rewindResetsAutosens = false + preferences.highTemptargetRaisesSensitivity = true + preferences.lowTemptargetLowersSensitivity = true + preferences.sensitivityRaisesTarget = true + preferences.resistanceLowersTarget = true + preferences.advTargetAdjustments = true + preferences.exerciseMode = true + preferences.halfBasalExerciseTarget = 180 + preferences.maxCOB = 200 + preferences.maxMealAbsorptionTime = 8 + preferences.wideBGTargetRange = true + preferences.skipNeutralTemps = true + preferences.unsuspendIfNoTemp = true + preferences.min5mCarbimpact = 10 + preferences.remainingCarbsFraction = 0.8 + preferences.remainingCarbsCap = 100 + preferences.enableUAM = true + preferences.a52RiskEnable = true + preferences.enableSMBWithCOB = true + preferences.enableSMBWithTemptarget = true + preferences.enableSMBAlways = true + preferences.enableSMBAfterCarbs = true + preferences.allowSMBWithHighTemptarget = true + preferences.maxSMBBasalMinutes = 60 + preferences.maxUAMSMBBasalMinutes = 90 + preferences.smbInterval = 5 + preferences.bolusIncrement = 0.05 + preferences.curve = .ultraRapid + preferences.useCustomPeakTime = true + preferences.insulinPeakTime = 60 + preferences.carbsReqThreshold = 2 + preferences.noisyCGMTargetMultiplier = 1.5 + preferences.suspendZerosIOB = false + preferences.timestamp = Date(timeIntervalSince1970: 1_000_000) + preferences.maxDeltaBGthreshold = 0.3 + preferences.adjustmentFactor = 1.0 + preferences.adjustmentFactorSigmoid = 0.7 + preferences.sigmoid = true + preferences.useNewFormula = true + preferences.useWeightedAverage = true + preferences.weightPercentage = 0.5 + preferences.tddAdjBasal = true + preferences.enableSMB_high_bg = true + preferences.enableSMB_high_bg_target = 150 + preferences.threshold_setting = 80 + preferences.updateInterval = 30 + return preferences + } + + static func fullBackup() -> SettingsBackup { + var backup = SettingsBackup() + backup.exportDate = Date(timeIntervalSince1970: 1_750_000_000) + backup.appVersion = "0.8.4" + backup.buildNumber = "123" + backup.branch = "dev abc1234" + + backup.devices = SettingsBackup.DeviceInfo( + pumpType: "Omnipod DASH", + insulinType: "Novolog", + cgmDisplayName: "Nightscout as CGM", + pumpState: nil, + cgmState: nil + ) + backup.trioSettings = maximallyDifferentTrioSettings() + backup.preferences = maximallyDifferentPreferences() + backup.pumpSettings = PumpSettings(insulinActionCurve: 9, maxBolus: 8, maxBasal: 3.5) + backup.therapy = SettingsBackup.Therapy( + basalProfile: [ + BasalProfileEntry(start: "00:00:00", minutes: 0, rate: 0.8), + BasalProfileEntry(start: "06:00:00", minutes: 360, rate: 1.2) + ], + insulinSensitivities: InsulinSensitivities( + units: .mgdL, + userPreferredUnits: .mgdL, + sensitivities: [ + InsulinSensitivityEntry(sensitivity: 45, offset: 0, start: "00:00:00"), + InsulinSensitivityEntry(sensitivity: 55, offset: 720, start: "12:00:00") + ] + ), + carbRatios: CarbRatios( + units: .grams, + schedule: [ + CarbRatioEntry(start: "00:00:00", offset: 0, ratio: 10), + CarbRatioEntry(start: "18:00:00", offset: 1080, ratio: 8) + ] + ), + bgTargets: BGTargets( + units: .mgdL, + userPreferredUnits: .mgdL, + targets: [BGTargetEntry(low: 100, high: 100, start: "00:00:00", offset: 0)] + ) + ) + backup.presets = SettingsBackup.Presets( + tempTargets: [ + SettingsBackup.TempTargetPreset(name: "Sport", target: 140, duration: 60, halfBasalTarget: 160, orderPosition: 1) + ], + overrides: [ + SettingsBackup.OverridePreset( + name: "Lazy Sunday", + percentage: 80, + indefinite: false, + duration: 120, + target: 120, + advancedSettings: true, + smbIsOff: false, + smbIsScheduledOff: true, + start: 8, + end: 20, + isfAndCr: true, + isf: true, + cr: true, + smbMinutes: 45, + uamMinutes: 45, + orderPosition: 1 + ) + ], + meals: [SettingsBackup.MealPreset(dish: "Pizza", carbs: 80, fat: 30, protein: 25)] + ) + backup.userDefaults = SettingsBackup.UserDefaultsValues( + colorSchemePreference: "dark", + isTrioRemoteControlEnabled: true + ) + backup.credentials = SettingsBackup.Credentials( + nightscoutURL: "https://example.nightscout.test", + nightscoutSecret: "supersecret", + remoteControlSharedSecret: "sharedsecret" + ) + return backup + } + + /// Counts stored properties whose values differ between two instances of the same type. + static func differingFieldLabels(_ a: T, _ b: T) -> [String] { + let childrenA = Array(Mirror(reflecting: a).children) + let childrenB = Array(Mirror(reflecting: b).children) + return zip(childrenA, childrenB).compactMap { childA, childB in + "\(childA.value)" != "\(childB.value)" ? (childA.label ?? "?") : nil + } + } + + static func storedPropertyCount(_ value: T) -> Int { + Mirror(reflecting: value).children.count + } +} diff --git a/TrioTests/SettingsExportTests.swift b/TrioTests/SettingsExportTests.swift deleted file mode 100644 index f9bfd2b939c..00000000000 --- a/TrioTests/SettingsExportTests.swift +++ /dev/null @@ -1,59 +0,0 @@ -@testable import Trio -import XCTest - -final class SettingsExportTests: XCTestCase { - func testCSVEscaping() { - // Test CSV escaping functionality - let testValue = "Test,Value\"With\nSpecial Characters" - let escaped = csvEscape(testValue) - let expected = "\"Test,Value\"\"With\nSpecial Characters\"" - XCTAssertEqual(escaped, expected, "CSV escaping should handle commas, quotes, and newlines") - } - - func testCSVEscapingSimple() { - // Test simple values don't get escaped - let testValue = "SimpleValue" - let escaped = csvEscape(testValue) - XCTAssertEqual(escaped, testValue, "Simple values should not be escaped") - } - - func testExportCSVStructure() { - // Test that the CSV has the expected header structure - let expectedHeader = "Setting Category,Subcategory,Setting Name,Value,Unit" - // This test would require mocking the settings manager and file storage - // For now, we verify the header format is correct - XCTAssertEqual(expectedHeader.components(separatedBy: ",").count, 5, "CSV header should have 5 columns") - } - - func testExportErrorTypes() { - // Test that our export error types are properly defined - let documentError = Settings.StateModel.ExportError.documentsDirectoryNotFound - XCTAssertNotNil(documentError.errorDescription, "Document error should have description") - - let writeError = Settings.StateModel.ExportError.fileWriteError(TestError.testError) - XCTAssertNotNil(writeError.errorDescription, "Write error should have description") - - let unknownError = Settings.StateModel.ExportError.unknown("Test message") - XCTAssertNotNil(unknownError.errorDescription, "Unknown error should have description") - } - - func testExportFileNaming() { - // Test that export files have the correct naming pattern - let formatter = DateFormatter() - formatter.dateFormat = "yyyyMMdd_HHmmss" - let timestamp = formatter.string(from: Date()) - let fileName = "TrioSettings_\(timestamp).csv" - - XCTAssertTrue(fileName.hasPrefix("TrioSettings_"), "File name should start with TrioSettings_") - XCTAssertTrue(fileName.hasSuffix(".csv"), "File name should end with .csv") - XCTAssertEqual(fileName.components(separatedBy: "_").count, 2, "File name should have one underscore") - } - - // Helper function to test CSV escaping (extracted from Settings.StateModel) - private func csvEscape(_ value: String) -> String { - if value.contains(",") || value.contains("\"") || value.contains("\n") { - return "\"\(value.replacingOccurrences(of: "\"", with: "\"\""))\"" - } - return value - } -} diff --git a/TrioTests/SettingsImportApplierTests.swift b/TrioTests/SettingsImportApplierTests.swift new file mode 100644 index 00000000000..ff220f63d0d --- /dev/null +++ b/TrioTests/SettingsImportApplierTests.swift @@ -0,0 +1,370 @@ +import Foundation +import Testing + +@testable import Trio + +@Suite("Settings Import Applier Tests") struct SettingsImportApplierTests { + // MARK: - Mapping totality + + @Test("Every TrioSettings field is mapped or explicitly excluded") func testTrioSettingsTotality() { + let a = TrioSettings() + let b = SettingsBackupTestFixtures.maximallyDifferentTrioSettings() + + // Guard for the test itself: the fixture must differ on EVERY stored property, otherwise + // a newly added field slips through unnoticed. Fails when TrioSettings gains a field the + // fixture does not vary yet. + #expect( + SettingsBackupTestFixtures.differingFieldLabels(a, b).count == + SettingsBackupTestFixtures.storedPropertyCount(a) + ) + + // The table plus documented exclusions must cover every stored property. + #expect( + SettingsImportApplier.trioSettingsFields.count + SettingsImportApplier.excludedTrioSettingsFieldCount == + SettingsBackupTestFixtures.storedPropertyCount(a) + ) + + // Merging every category copies every field except the exclusions. + let outcome = SettingsImportApplier.merge( + SettingsImportApplier.trioSettingsFields, + current: a, + imported: b, + categories: Set(SettingsBackupCategory.allCases), + units: .mgdL + ) + var expected = b + expected.closedLoop = a.closedLoop + #expect(outcome.result == expected) + #expect(outcome.warnings.isEmpty) + } + + @Test("Every Preferences field is mapped or explicitly excluded") func testPreferencesTotality() { + let a = Preferences() + let b = SettingsBackupTestFixtures.maximallyDifferentPreferences() + + #expect( + SettingsBackupTestFixtures.differingFieldLabels(a, b).count == + SettingsBackupTestFixtures.storedPropertyCount(a) + ) + #expect( + SettingsImportApplier.preferencesFields.count + SettingsImportApplier.excludedPreferencesFieldCount == + SettingsBackupTestFixtures.storedPropertyCount(a) + ) + + let outcome = SettingsImportApplier.merge( + SettingsImportApplier.preferencesFields, + current: a, + imported: b, + categories: Set(SettingsBackupCategory.allCases), + units: .mgdL + ) + var expected = b + expected.bolusIncrement = a.bolusIncrement + expected.timestamp = a.timestamp + #expect(outcome.result == expected) + #expect(outcome.warnings.isEmpty) + } + + @Test("Closed loop and bolus increment are never imported") func testExclusions() { + var importedSettings = TrioSettings() + importedSettings.closedLoop = true + let settingsOutcome = SettingsImportApplier.merge( + SettingsImportApplier.trioSettingsFields, + current: TrioSettings(), + imported: importedSettings, + categories: Set(SettingsBackupCategory.allCases), + units: .mgdL + ) + #expect(settingsOutcome.result.closedLoop == false) + + var importedPreferences = Preferences() + importedPreferences.bolusIncrement = 0.5 + let preferencesOutcome = SettingsImportApplier.merge( + SettingsImportApplier.preferencesFields, + current: Preferences(), + imported: importedPreferences, + categories: Set(SettingsBackupCategory.allCases), + units: .mgdL + ) + #expect(preferencesOutcome.result.bolusIncrement == Preferences().bolusIncrement) + } + + // MARK: - Guardrails + + @Test("Out-of-range values clamp to the guardrail bounds with warnings") func testClamping() { + let guardrails = PickerSettingsProvider.shared.settings + + var imported = Preferences() + imported.autosensMax = 100 + imported.maxSMBBasalMinutes = 1 + + let outcome = SettingsImportApplier.merge( + SettingsImportApplier.preferencesFields, + current: Preferences(), + imported: imported, + categories: [.algorithm], + units: .mgdL + ) + + #expect(outcome.result.autosensMax == guardrails.autosensMax.max) + #expect(outcome.result.maxSMBBasalMinutes == guardrails.maxSMBBasalMinutes.min) + #expect(outcome.warnings.count == 2) + } + + @Test("In-range values are preserved exactly") func testInRangeValuesPreserved() { + var imported = Preferences() + imported.autosensMax = 1.85 // between picker steps but inside the bounds + + let outcome = SettingsImportApplier.merge( + SettingsImportApplier.preferencesFields, + current: Preferences(), + imported: imported, + categories: [.algorithm], + units: .mgdL + ) + + #expect(outcome.result.autosensMax == 1.85) + #expect(outcome.warnings.isEmpty) + } + + // MARK: - Category filtering + + @Test("Only fields of the selected categories are merged") func testCategoryFiltering() { + let a = TrioSettings() + let b = SettingsBackupTestFixtures.maximallyDifferentTrioSettings() + + let outcome = SettingsImportApplier.merge( + SettingsImportApplier.trioSettingsFields, + current: a, + imported: b, + categories: [.features], + units: .mgdL + ) + + // features moved + #expect(outcome.result.maxCarbs == b.maxCarbs) + #expect(outcome.result.displayPresets == b.displayPresets) + // therapy + devices + notifications + services untouched + #expect(outcome.result.units == a.units) + #expect(outcome.result.cgm == a.cgm) + #expect(outcome.result.glucoseBadge == a.glucoseBadge) + #expect(outcome.result.isUploadEnabled == a.isUploadEnabled) + } + + // MARK: - Diff parity + + @Test("Change overview matches exactly what merge changes") func testDiffParity() { + let a = TrioSettings() + let b = SettingsBackupTestFixtures.maximallyDifferentTrioSettings() + + let changes = SettingsImportApplier.settingChanges( + SettingsImportApplier.trioSettingsFields, + current: a, + imported: b, + units: .mgdL + ) + let totalChanges = changes.values.reduce(0) { $0 + $1.count } + + // Every mapped field differs in the fixture, so every mapped field must be listed. + #expect(totalChanges == SettingsImportApplier.trioSettingsFields.count) + + // Identical values produce no changes at all. + let noChanges = SettingsImportApplier.settingChanges( + SettingsImportApplier.trioSettingsFields, + current: b, + imported: b, + units: .mgdL + ) + #expect(noChanges.isEmpty) + } + + @Test("Pump settings merge clamps and reports changes") func testPumpSettingsMerge() { + let guardrails = PickerSettingsProvider.shared.settings + let current = PumpSettings(insulinActionCurve: 10, maxBolus: 10, maxBasal: 2) + let imported = PumpSettings(insulinActionCurve: 3, maxBolus: 8, maxBasal: 100) + + let result = SettingsImportApplier.mergePumpSettings(current: current, imported: imported, units: .mgdL) + + #expect(result.result.insulinActionCurve == guardrails.dia.min) // 3 clamps up + #expect(result.result.maxBolus == 8) + #expect(result.result.maxBasal == guardrails.maxBasal.max) // 100 clamps down + #expect(result.warnings.count == 2) + #expect(result.changes.count == 3) + } + + // MARK: - Therapy validation + + @Test("Therapy schedules are normalized onto the 30-minute grid") func testTherapyNormalization() throws { + let therapy = SettingsBackup.Therapy( + basalProfile: [ + BasalProfileEntry(start: "12:00:00", minutes: 720, rate: 1.0), + BasalProfileEntry(start: "00:45:00", minutes: 45, rate: 0.8), // off-grid → 00:30 + BasalProfileEntry(start: "12:00:00", minutes: 720, rate: 1.2) // duplicate slot, last wins + ], + insulinSensitivities: nil, + carbRatios: nil, + bgTargets: nil + ) + + let normalized = try SettingsImportApplier.validateTherapy(therapy) + let basal = try #require(normalized.basalProfile) + + #expect(basal.count == 2) + #expect(basal[0].minutes == 0) // first entry forced to midnight + #expect(basal[0].rate == 0.8) + #expect(basal[0].start == "00:00:00") + #expect(basal[1].minutes == 720) + #expect(basal[1].rate == 1.2) + } + + @Test("Invalid therapy values are rejected") func testTherapyValidationErrors() { + func therapy(basal: [BasalProfileEntry]? = nil, isf: [InsulinSensitivityEntry]? = nil, + targets: [BGTargetEntry]? = nil) -> SettingsBackup.Therapy + { + SettingsBackup.Therapy( + basalProfile: basal, + insulinSensitivities: isf + .map { InsulinSensitivities(units: .mgdL, userPreferredUnits: .mgdL, sensitivities: $0) }, + carbRatios: nil, + bgTargets: targets.map { BGTargets(units: .mgdL, userPreferredUnits: .mgdL, targets: $0) } + ) + } + + #expect(throws: TherapyValidationError.self) { + _ = try SettingsImportApplier.validateTherapy(therapy(basal: [])) + } + #expect(throws: TherapyValidationError.self) { + _ = try SettingsImportApplier.validateTherapy( + therapy(basal: [BasalProfileEntry(start: "00:00:00", minutes: 0, rate: 0)]) + ) + } + // mmol/L-looking ISF + #expect(throws: TherapyValidationError.self) { + _ = try SettingsImportApplier.validateTherapy( + therapy(isf: [InsulinSensitivityEntry(sensitivity: 2.5, offset: 0, start: "00:00:00")]) + ) + } + // mmol/L-looking target + #expect(throws: TherapyValidationError.self) { + _ = try SettingsImportApplier.validateTherapy( + therapy(targets: [BGTargetEntry(low: 5.5, high: 5.5, start: "00:00:00", offset: 0)]) + ) + } + } + + @Test("Targets normalize to a single value with mg/dL units") func testTargetNormalization() throws { + let therapy = SettingsBackup.Therapy( + basalProfile: nil, + insulinSensitivities: nil, + carbRatios: nil, + bgTargets: BGTargets( + units: .mmolL, + userPreferredUnits: .mmolL, + targets: [BGTargetEntry(low: 100, high: 120, start: "00:00:00", offset: 0)] + ) + ) + + let normalized = try SettingsImportApplier.validateTherapy(therapy) + let targets = try #require(normalized.bgTargets) + + #expect(targets.units == .mgdL) + #expect(targets.userPreferredUnits == .mgdL) + #expect(targets.targets[0].high == targets.targets[0].low) // Trio convention: high == low + } + + @Test("Basal rates snap to pump-supported rates with a warning") func testBasalPumpSnap() throws { + let therapy = SettingsBackup.Therapy( + basalProfile: [BasalProfileEntry(start: "00:00:00", minutes: 0, rate: 0.62)], + insulinSensitivities: nil, + carbRatios: nil, + bgTargets: nil + ) + + let normalized = try SettingsImportApplier.validateTherapy(therapy, supportedBasalRates: [0.5, 0.6, 0.7]) + #expect(normalized.basalProfile?[0].rate == 0.6) + #expect(normalized.warnings.count == 1) + } + + @Test("Rates that snap to zero on the pump fail the total-basal check") func testZeroTotalBasalAfterSnap() { + let therapy = SettingsBackup.Therapy( + basalProfile: [BasalProfileEntry(start: "00:00:00", minutes: 0, rate: 0.01)], + insulinSensitivities: nil, + carbRatios: nil, + bgTargets: nil + ) + + #expect(throws: TherapyValidationError.zeroTotalBasal) { + _ = try SettingsImportApplier.validateTherapy(therapy, supportedBasalRates: [0, 0.5, 1.0]) + } + } + + // MARK: - Preset conflict resolution + + private let importedNames = ["Sport", "New"] + + private func resolve( + strategy: PresetConflictStrategy, + existing: [String] = ["Sport", "Night"], + active: Set = [] + ) -> SettingsImportApplier.PresetResolution { + SettingsImportApplier.resolvePresetConflicts( + imported: importedNames, + existingNames: existing, + activeNames: active, + strategy: strategy, + name: { $0 } + ) + } + + @Test("Replace same-named: replaces duplicates, adds new, keeps the rest") func testReplaceSameNamed() { + let resolution = resolve(strategy: .replaceSameNamed) + + #expect(resolution.namesToDelete == ["Sport"]) + #expect(resolution.toStore == ["Sport", "New"]) + #expect(resolution.changes == [ + PresetChange(name: "Sport", kind: .replaced), + PresetChange(name: "New", kind: .added) + ]) + } + + @Test("Keep existing: only adds unknown names") func testKeepExisting() { + let resolution = resolve(strategy: .keepExisting) + + #expect(resolution.namesToDelete.isEmpty) + #expect(resolution.toStore == ["New"]) + #expect(resolution.changes == [ + PresetChange(name: "Sport", kind: .keptExisting), + PresetChange(name: "New", kind: .added) + ]) + } + + @Test("Replace all: also removes presets missing from the file") func testReplaceAll() { + let resolution = resolve(strategy: .replaceAll) + + #expect(Set(resolution.namesToDelete) == ["Sport", "Night"]) + #expect(resolution.toStore == ["Sport", "New"]) + #expect(resolution.changes.contains(PresetChange(name: "Night", kind: .removed))) + } + + @Test("Running presets are never deleted or replaced") func testActiveSkipped() { + let resolution = resolve(strategy: .replaceAll, active: ["Sport"]) + + #expect(!resolution.namesToDelete.contains("Sport")) + #expect(!resolution.toStore.contains("Sport")) + #expect(resolution.changes.contains(PresetChange(name: "Sport", kind: .activeSkipped))) + } + + @Test("Duplicate names inside the file collapse to the last occurrence") func testDuplicateNamesInFile() { + let resolution = SettingsImportApplier.resolvePresetConflicts( + imported: [("Sport", 140), ("Sport", 150)], + existingNames: [], + activeNames: [], + strategy: .replaceSameNamed, + name: { $0.0 } + ) + + #expect(resolution.toStore.count == 1) + #expect(resolution.toStore[0].1 == 150) + #expect(resolution.warnings.count == 1) + } +} From 347157f71da618db64db7da519bdfd8128fa3aa8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 22 Aug 2026 20:56:55 +0000 Subject: [PATCH 2/2] Fix Equatable conformances: explicit == for cross-file extensions Swift cannot synthesize Equatable in an extension outside the type's declaring file, so the therapy container/entry types and PumpSettings get explicit == implementations. Surfaced by the first real (Xcode Cloud) build of this feature on Build-Experiment. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Y9XRFomNerbiR4p6D8T7hh --- Trio/Sources/Models/SettingsBackup.swift | 51 ++++++++++++++++++++---- 1 file changed, 44 insertions(+), 7 deletions(-) diff --git a/Trio/Sources/Models/SettingsBackup.swift b/Trio/Sources/Models/SettingsBackup.swift index 35e334f3568..a2bc3f80666 100644 --- a/Trio/Sources/Models/SettingsBackup.swift +++ b/Trio/Sources/Models/SettingsBackup.swift @@ -202,10 +202,47 @@ extension SettingsBackup { } } -extension InsulinSensitivities: Equatable {} -extension InsulinSensitivityEntry: Equatable {} -extension CarbRatios: Equatable {} -extension CarbRatioEntry: Equatable {} -extension BGTargets: Equatable {} -extension BGTargetEntry: Equatable {} -extension PumpSettings: Equatable {} +// Swift cannot synthesize Equatable in extensions outside the declaring file, so == is spelled +// out for the therapy types the backup embeds. +extension InsulinSensitivities: Equatable { + static func == (lhs: InsulinSensitivities, rhs: InsulinSensitivities) -> Bool { + lhs.units == rhs.units && lhs.userPreferredUnits == rhs.userPreferredUnits && + lhs.sensitivities == rhs.sensitivities + } +} + +extension InsulinSensitivityEntry: Equatable { + static func == (lhs: InsulinSensitivityEntry, rhs: InsulinSensitivityEntry) -> Bool { + lhs.sensitivity == rhs.sensitivity && lhs.offset == rhs.offset && lhs.start == rhs.start + } +} + +extension CarbRatios: Equatable { + static func == (lhs: CarbRatios, rhs: CarbRatios) -> Bool { + lhs.units == rhs.units && lhs.schedule == rhs.schedule + } +} + +extension CarbRatioEntry: Equatable { + static func == (lhs: CarbRatioEntry, rhs: CarbRatioEntry) -> Bool { + lhs.start == rhs.start && lhs.offset == rhs.offset && lhs.ratio == rhs.ratio + } +} + +extension BGTargets: Equatable { + static func == (lhs: BGTargets, rhs: BGTargets) -> Bool { + lhs.units == rhs.units && lhs.userPreferredUnits == rhs.userPreferredUnits && lhs.targets == rhs.targets + } +} + +extension BGTargetEntry: Equatable { + static func == (lhs: BGTargetEntry, rhs: BGTargetEntry) -> Bool { + lhs.low == rhs.low && lhs.high == rhs.high && lhs.start == rhs.start && lhs.offset == rhs.offset + } +} + +extension PumpSettings: Equatable { + static func == (lhs: PumpSettings, rhs: PumpSettings) -> Bool { + lhs.insulinActionCurve == rhs.insulinActionCurve && lhs.maxBolus == rhs.maxBolus && lhs.maxBasal == rhs.maxBasal + } +}