TipStoreTests.swift (6110B)
1 import Foundation 2 import Testing 3 4 @testable import Crossmate 5 6 @Suite("TipStore") 7 @MainActor 8 struct TipStoreTests { 9 10 private let catalog = [ 11 Tip(id: "one", title: "One", body: "First"), 12 Tip(id: "two", title: "Two", body: "Second"), 13 Tip(id: "three", title: "Three", body: "Third"), 14 ] 15 16 private func makeStore( 17 defaults: UserDefaults? = nil 18 ) -> (store: TipStore, defaults: UserDefaults) { 19 // Fresh UserDefaults suite per test to avoid cross-test pollution. 20 let defaults = defaults ?? UserDefaults(suiteName: "test-\(UUID().uuidString)")! 21 // Past the launch warm-up by default so tests can focus on dismissal, 22 // disable, and platform logic. The warm-up itself is covered separately. 23 return (warmedUp(TipStore(defaults: defaults, catalog: catalog)), defaults) 24 } 25 26 /// Notes enough cold launches to move `store` past the tip warm-up. 27 @discardableResult 28 private func warmedUp(_ store: TipStore) -> TipStore { 29 for _ in 0...TipStore.launchesBeforeTips { store.noteColdLaunch() } 30 return store 31 } 32 33 @Test("currentTip returns the first catalog tip by default") 34 func firstTipInitially() { 35 let (store, _) = makeStore() 36 #expect(store.currentTip()?.id == "one") 37 } 38 39 @Test("Dismissing a tip advances to the next undismissed one") 40 func dismissAdvances() { 41 let (store, _) = makeStore() 42 store.markDismissed("one") 43 #expect(store.currentTip()?.id == "two") 44 store.markDismissed("two") 45 #expect(store.currentTip()?.id == "three") 46 } 47 48 @Test("Once every tip is dismissed there is nothing to surface") 49 func allDismissed() { 50 let (store, _) = makeStore() 51 for tip in catalog { store.markDismissed(tip.id) } 52 #expect(store.currentTip() == nil) 53 } 54 55 @Test("Disabling tips suppresses the banner regardless of dismissals") 56 func disableSuppresses() { 57 let (store, _) = makeStore() 58 store.disable() 59 #expect(store.isDisabled) 60 #expect(store.currentTip() == nil) 61 // Re-enabling brings back the next undismissed tip. 62 store.enable() 63 #expect(!store.isDisabled) 64 #expect(store.currentTip()?.id == "one") 65 } 66 67 @Test("Dismissals and the opt-out flag persist across instances") 68 func persistsAcrossInstances() { 69 let (store, defaults) = makeStore() 70 store.markDismissed("one") 71 store.disable() 72 73 let (reloaded, _) = makeStore(defaults: defaults) 74 #expect(reloaded.isDisabled) 75 reloaded.enable() 76 #expect(reloaded.currentTip()?.id == "two") 77 } 78 79 @Test("No tip surfaces during the launch warm-up") 80 func warmUpSuppressesTips() { 81 let defaults = UserDefaults(suiteName: "test-\(UUID().uuidString)")! 82 let store = TipStore(defaults: defaults, catalog: catalog) 83 // A brand new store surfaces nothing, and each warm-up launch stays 84 // tip-free. 85 #expect(store.currentTip() == nil) 86 for _ in 0..<TipStore.launchesBeforeTips { 87 store.noteColdLaunch() 88 #expect(store.currentTip() == nil) 89 } 90 // The launch after the warm-up surfaces the first tip. 91 store.noteColdLaunch() 92 #expect(store.currentTip()?.id == "one") 93 } 94 95 @Test("The launch warm-up persists across instances") 96 func warmUpPersists() { 97 let defaults = UserDefaults(suiteName: "test-\(UUID().uuidString)")! 98 // Note every warm-up launch but one on a first instance… 99 let first = TipStore(defaults: defaults, catalog: catalog) 100 for _ in 0..<TipStore.launchesBeforeTips { first.noteColdLaunch() } 101 102 // …a reload picks up the persisted count: still within the warm-up, so 103 // the final launch is what tips over into surfacing a tip. 104 let reloaded = TipStore(defaults: defaults, catalog: catalog) 105 #expect(reloaded.currentTip() == nil) 106 reloaded.noteColdLaunch() 107 #expect(reloaded.currentTip()?.id == "one") 108 } 109 110 @Test("Tips scoped to other platforms are filtered out") 111 func platformScoping() { 112 let catalog = [ 113 Tip(id: "both", title: "Both", body: "Everywhere"), 114 Tip(id: "ipad", title: "iPad", body: "iPad only", only: [.iPad]), 115 Tip(id: "iphone", title: "iPhone", body: "iPhone only", only: [.iPhone]), 116 ] 117 let defaults = UserDefaults(suiteName: "test-\(UUID().uuidString)")! 118 let onPhone = warmedUp(TipStore(defaults: defaults, catalog: catalog, platform: .iPhone)) 119 #expect(onPhone.visibleTips.map(\.id) == ["both", "iphone"]) 120 // The first applicable tip skips the iPad-only one. 121 #expect(onPhone.currentTip()?.id == "both") 122 onPhone.markDismissed("both") 123 #expect(onPhone.currentTip()?.id == "iphone") 124 125 let onPad = TipStore(defaults: defaults, catalog: catalog, platform: .iPad) 126 #expect(onPad.visibleTips.map(\.id) == ["both", "ipad"]) 127 } 128 129 @Test("Tip announcement ids round-trip so the banner can recognise a tip") 130 func announcementIDRoundTrips() { 131 let id = Tip.announcementID(for: "welcome") 132 #expect(Tip.tipID(fromAnnouncementID: id) == "welcome") 133 // A non-tip announcement id is not mistaken for a tip. 134 #expect(Tip.tipID(fromAnnouncementID: "reset-database-error") == nil) 135 } 136 137 @Test("A tip's live banner is a lowest-severity, manually dismissable global") 138 func liveAnnouncementShape() { 139 let tip = catalog[0] 140 let announcement = tip.liveAnnouncement() 141 #expect(announcement.scope == .global) 142 #expect(announcement.severity == .tip) 143 #expect(announcement.dismissal == .manual) 144 #expect(announcement.title == "One") 145 #expect(announcement.body == "First") 146 } 147 148 @Test("A tip's archive banner has no close control") 149 func archiveAnnouncementHasNoCloseControl() { 150 // The banner shows a ✕ only for `.manual`; the archive uses `.sticky`. 151 #expect(catalog[0].archiveAnnouncement().dismissal == .sticky) 152 } 153 }