DeviceAlertsStoreTests.swift 6.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140
  1. import Foundation
  2. import Testing
  3. @testable import Trio
  4. @Suite("Trio Alerts: DeviceAlertsStore variant precedence", .serialized) struct DeviceAlertsStoreTests {
  5. /// Each test gets a unique suite name so UserDefaults state can't leak
  6. /// between tests in parallel runs.
  7. private static func makeStore(seed: [DeviceAlertSeverityConfig]? = nil) -> DeviceAlertsStore {
  8. let suiteName = "DeviceAlertsStoreTests.\(UUID().uuidString)"
  9. let defaults = UserDefaults(suiteName: suiteName)!
  10. defaults.removePersistentDomain(forName: suiteName)
  11. if let seed {
  12. let data = try? JSONEncoder().encode(seed)
  13. defaults.set(data, forKey: "trio.deviceAlertSeverityConfigs.v1")
  14. }
  15. return DeviceAlertsStore(defaults: defaults, configsKey: "trio.deviceAlertSeverityConfigs.v1")
  16. }
  17. @Test("Fresh store seeds one .always config per severity") func freshSeed() {
  18. let store = Self.makeStore()
  19. for severity in DeviceAlertSeverity.allCases {
  20. let always = store.configs.first { $0.severity == severity && $0.activeOption == .always }
  21. #expect(always != nil, "Missing .always seed for \(severity)")
  22. }
  23. #expect(store.configs.count == DeviceAlertSeverity.allCases.count)
  24. }
  25. @Test("config(for:isNight:) returns .day variant during daytime") func dayMatchPicksDayVariant() {
  26. var dayOnly = DeviceAlertSeverityConfig(severity: .critical, activeOption: .day)
  27. dayOnly.soundFilename = "day.caf"
  28. let store = Self.makeStore(seed: [
  29. DeviceAlertSeverityConfig(severity: .critical, activeOption: .always),
  30. dayOnly,
  31. DeviceAlertSeverityConfig(severity: .timeSensitive, activeOption: .always),
  32. DeviceAlertSeverityConfig(severity: .normal, activeOption: .always)
  33. ])
  34. let match = store.config(for: .critical, at: Date(), isNight: false)
  35. #expect(match?.soundFilename == "day.caf")
  36. }
  37. @Test("config(for:isNight:) returns .night variant overnight") func nightMatchPicksNightVariant() {
  38. var nightOnly = DeviceAlertSeverityConfig(severity: .critical, activeOption: .night)
  39. nightOnly.soundFilename = "night.caf"
  40. let store = Self.makeStore(seed: [
  41. DeviceAlertSeverityConfig(severity: .critical, activeOption: .always),
  42. nightOnly,
  43. DeviceAlertSeverityConfig(severity: .timeSensitive, activeOption: .always),
  44. DeviceAlertSeverityConfig(severity: .normal, activeOption: .always)
  45. ])
  46. let match = store.config(for: .critical, at: Date(), isNight: true)
  47. #expect(match?.soundFilename == "night.caf")
  48. }
  49. @Test("Day variant doesn't match at night — falls back to .always") func dayVariantSkippedAtNight() {
  50. var always = DeviceAlertSeverityConfig(severity: .critical, activeOption: .always)
  51. always.soundFilename = "fallback.caf"
  52. var dayOnly = DeviceAlertSeverityConfig(severity: .critical, activeOption: .day)
  53. dayOnly.soundFilename = "day.caf"
  54. let store = Self.makeStore(seed: [
  55. always,
  56. dayOnly,
  57. DeviceAlertSeverityConfig(severity: .timeSensitive, activeOption: .always),
  58. DeviceAlertSeverityConfig(severity: .normal, activeOption: .always)
  59. ])
  60. let match = store.config(for: .critical, at: Date(), isNight: true)
  61. #expect(match?.soundFilename == "fallback.caf")
  62. }
  63. @Test("Disabled variants are skipped; falls back to next enabled") func disabledVariantSkipped() {
  64. // Use timeSensitive — Critical configs are always considered enabled
  65. // regardless of the stored isEnabled flag.
  66. var dayDisabled = DeviceAlertSeverityConfig(severity: .timeSensitive, activeOption: .day)
  67. dayDisabled.isEnabled = false
  68. dayDisabled.soundFilename = "disabled-day.caf"
  69. var alwaysFallback = DeviceAlertSeverityConfig(severity: .timeSensitive, activeOption: .always)
  70. alwaysFallback.soundFilename = "always.caf"
  71. let store = Self.makeStore(seed: [
  72. DeviceAlertSeverityConfig(severity: .critical, activeOption: .always),
  73. alwaysFallback,
  74. dayDisabled,
  75. DeviceAlertSeverityConfig(severity: .normal, activeOption: .always)
  76. ])
  77. let match = store.config(for: .timeSensitive, at: Date(), isNight: false)
  78. #expect(match?.soundFilename == "always.caf")
  79. }
  80. @Test("Critical tier ignores stored isEnabled flag") func criticalAlwaysEnabled() {
  81. var disabledCritical = DeviceAlertSeverityConfig(severity: .critical, activeOption: .always)
  82. disabledCritical.isEnabled = false
  83. disabledCritical.soundFilename = "critical.caf"
  84. let store = Self.makeStore(seed: [
  85. disabledCritical,
  86. DeviceAlertSeverityConfig(severity: .timeSensitive, activeOption: .always),
  87. DeviceAlertSeverityConfig(severity: .normal, activeOption: .always)
  88. ])
  89. let match = store.config(for: .critical, at: Date(), isNight: false)
  90. #expect(match?.soundFilename == "critical.caf")
  91. }
  92. @Test("All variants disabled returns nil") func allDisabledReturnsNil() {
  93. var always = DeviceAlertSeverityConfig(severity: .normal, activeOption: .always)
  94. always.isEnabled = false
  95. let store = Self.makeStore(seed: [
  96. DeviceAlertSeverityConfig(severity: .critical, activeOption: .always),
  97. DeviceAlertSeverityConfig(severity: .timeSensitive, activeOption: .always),
  98. always
  99. ])
  100. #expect(store.config(for: .normal, at: Date(), isNight: false) == nil)
  101. }
  102. @Test("canDelete protects the last .always per severity") func canDeleteLastAlways() {
  103. let store = Self.makeStore()
  104. let onlyAlways = store.configs.first { $0.severity == .critical && $0.activeOption == .always }!
  105. #expect(!store.canDelete(onlyAlways))
  106. }
  107. @Test("canDelete allows removing one of two .always in the same tier") func canDeleteSecondAlways() {
  108. let extraAlways = DeviceAlertSeverityConfig(severity: .critical, activeOption: .always)
  109. let store = Self.makeStore(seed: [
  110. DeviceAlertSeverityConfig(severity: .critical, activeOption: .always),
  111. extraAlways,
  112. DeviceAlertSeverityConfig(severity: .timeSensitive, activeOption: .always),
  113. DeviceAlertSeverityConfig(severity: .normal, activeOption: .always)
  114. ])
  115. #expect(store.canDelete(extraAlways))
  116. }
  117. @Test("canDelete always allows non-always variants") func canDeleteDayOrNight() {
  118. let dayOnly = DeviceAlertSeverityConfig(severity: .critical, activeOption: .day)
  119. let store = Self.makeStore(seed: [
  120. DeviceAlertSeverityConfig(severity: .critical, activeOption: .always),
  121. dayOnly,
  122. DeviceAlertSeverityConfig(severity: .timeSensitive, activeOption: .always),
  123. DeviceAlertSeverityConfig(severity: .normal, activeOption: .always)
  124. ])
  125. #expect(store.canDelete(dayOnly))
  126. }
  127. }