GlucoseAlertsStoreTests.swift 6.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145
  1. import Foundation
  2. import Testing
  3. @testable import Trio
  4. @Suite("Trio Alerts: GlucoseAlertsStore seed + backfill", .serialized) struct GlucoseAlertsStoreTests {
  5. private static func makeStore(seed: [GlucoseAlert]? = nil) -> GlucoseAlertsStore {
  6. let suiteName = "GlucoseAlertsStoreTests.\(UUID().uuidString)"
  7. let defaults = UserDefaults(suiteName: suiteName)!
  8. defaults.removePersistentDomain(forName: suiteName)
  9. let alertsKey = "alerts.\(suiteName)"
  10. let configKey = "config.\(suiteName)"
  11. if let seed {
  12. let data = try? JSONEncoder().encode(seed)
  13. defaults.set(data, forKey: alertsKey)
  14. }
  15. return GlucoseAlertsStore(defaults: defaults, alertsKey: alertsKey, configKey: configKey)
  16. }
  17. @Test("Fresh store seeds one alert per type") func freshSeedAllTypes() {
  18. let store = Self.makeStore()
  19. let types = Set(store.alerts.map(\.type))
  20. for type in GlucoseAlertType.allCases {
  21. #expect(types.contains(type), "Missing seed for \(type)")
  22. }
  23. #expect(store.alerts.count == GlucoseAlertType.allCases.count)
  24. }
  25. @Test("Legacy load (no carbsRequired) backfills the new type") func legacyLoadBackfillsCarbsRequired() {
  26. let legacy: [GlucoseAlert] = [
  27. GlucoseAlert(type: .urgentLow),
  28. GlucoseAlert(type: .low),
  29. GlucoseAlert(type: .forecastedLow),
  30. GlucoseAlert(type: .high)
  31. ]
  32. let store = Self.makeStore(seed: legacy)
  33. let types = Set(store.alerts.map(\.type))
  34. #expect(types.contains(.carbsRequired))
  35. #expect(store.alerts.count == GlucoseAlertType.allCases.count)
  36. }
  37. @Test("Backfill preserves user customizations on existing entries") func backfillPreservesCustomizations() {
  38. var custom = GlucoseAlert(type: .low)
  39. custom.thresholdMgDL = 65
  40. custom.soundFilename = "custom_sound.caf"
  41. custom.isEnabled = false
  42. let store = Self.makeStore(seed: [custom])
  43. let restored = store.alerts.first { $0.type == .low }!
  44. #expect(restored.thresholdMgDL == 65)
  45. #expect(restored.soundFilename == "custom_sound.caf")
  46. #expect(restored.isEnabled == false)
  47. // And new types still got appended.
  48. #expect(store.alerts.contains { $0.type == .carbsRequired })
  49. }
  50. @Test("Full load doesn't double up on existing types") func fullLoadNoDuplicates() {
  51. let full = GlucoseAlertType.allCases.map { GlucoseAlert(type: $0) }
  52. let store = Self.makeStore(seed: full)
  53. let countsByType = Dictionary(grouping: store.alerts, by: \.type).mapValues(\.count)
  54. for type in GlucoseAlertType.allCases {
  55. #expect(countsByType[type] == 1, "Duplicated \(type) on load")
  56. }
  57. }
  58. // MARK: - availableActiveOptions for new alarms
  59. private static func storeWithOnly(_ alerts: [GlucoseAlert]) -> GlucoseAlertsStore {
  60. let store = Self.makeStore()
  61. store.alerts = alerts
  62. return store
  63. }
  64. @Test("Single .always covers everything: no options available") func alwaysCoversAll() {
  65. let existing = GlucoseAlert(type: .low) // default .always
  66. let store = Self.storeWithOnly([existing])
  67. #expect(store.availableActiveOptions(forNewAlarmOfType: .low).isEmpty)
  68. }
  69. @Test("Only .day taken → only .night available") func dayTakenOffersNight() {
  70. var existing = GlucoseAlert(type: .low)
  71. existing.activeOption = .day
  72. let store = Self.storeWithOnly([existing])
  73. #expect(store.availableActiveOptions(forNewAlarmOfType: .low) == [.night])
  74. }
  75. @Test("Only .night taken → only .day available") func nightTakenOffersDay() {
  76. var existing = GlucoseAlert(type: .high)
  77. existing.activeOption = .night
  78. let store = Self.storeWithOnly([existing])
  79. #expect(store.availableActiveOptions(forNewAlarmOfType: .high) == [.day])
  80. }
  81. @Test(".day + .night both taken → none available") func dayAndNightTakenLocked() {
  82. var day = GlucoseAlert(type: .forecastedLow)
  83. day.activeOption = .day
  84. var night = GlucoseAlert(type: .forecastedLow)
  85. night.activeOption = .night
  86. let store = Self.storeWithOnly([day, night])
  87. #expect(store.availableActiveOptions(forNewAlarmOfType: .forecastedLow).isEmpty)
  88. }
  89. @Test("No alarm of this type → all three options available") func emptyOffersAll() {
  90. let store = Self.storeWithOnly([])
  91. #expect(store.availableActiveOptions(forNewAlarmOfType: .carbsRequired) == [.always, .day, .night])
  92. }
  93. @Test("Gating is per-type — other types don't block") func gatingIsPerType() {
  94. let lowAlways = GlucoseAlert(type: .low) // .always blocks Low
  95. let store = Self.storeWithOnly([lowAlways])
  96. #expect(store.availableActiveOptions(forNewAlarmOfType: .low).isEmpty)
  97. #expect(store.availableActiveOptions(forNewAlarmOfType: .high) == [.always, .day, .night])
  98. }
  99. @Test("Editing an existing .day alarm: .always becomes pickable again") func editingExcludesSelf() {
  100. var existing = GlucoseAlert(type: .urgentLow)
  101. existing.activeOption = .day
  102. let store = Self.storeWithOnly([existing])
  103. let available = store.availableActiveOptions(forType: .urgentLow, excludingAlertID: existing.id)
  104. #expect(available == [.always, .day, .night])
  105. }
  106. @Test("Editing one of two split alarms: only own window available") func editingOneOfTwoSplit() {
  107. var day = GlucoseAlert(type: .low)
  108. day.activeOption = .day
  109. var night = GlucoseAlert(type: .low)
  110. night.activeOption = .night
  111. let store = Self.storeWithOnly([day, night])
  112. // Editing the .day alarm: .night is taken by the other, .always would conflict with .night.
  113. let editingDay = store.availableActiveOptions(forType: .low, excludingAlertID: day.id)
  114. #expect(editingDay == [.day])
  115. }
  116. /// Pins the user-reported regression: fresh install seeds every type with
  117. /// `.always`, so the Add picker must show every type as unavailable.
  118. @Test("Fresh-seeded store has no available windows for any type") func freshSeedHasNoneAvailable() {
  119. let store = Self.makeStore()
  120. for type in GlucoseAlertType.allCases {
  121. #expect(
  122. store.availableActiveOptions(forNewAlarmOfType: type).isEmpty,
  123. "Expected \(type) to be fully covered by the default .always seed"
  124. )
  125. }
  126. }
  127. }