TipStoreTests.swift (4268B)
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 return (TipStore(defaults: defaults, catalog: catalog), defaults) 22 } 23 24 @Test("currentTip returns the first catalog tip by default") 25 func firstTipInitially() { 26 let (store, _) = makeStore() 27 #expect(store.currentTip()?.id == "one") 28 } 29 30 @Test("Dismissing a tip advances to the next undismissed one") 31 func dismissAdvances() { 32 let (store, _) = makeStore() 33 store.markDismissed("one") 34 #expect(store.currentTip()?.id == "two") 35 store.markDismissed("two") 36 #expect(store.currentTip()?.id == "three") 37 } 38 39 @Test("Once every tip is dismissed there is nothing to surface") 40 func allDismissed() { 41 let (store, _) = makeStore() 42 for tip in catalog { store.markDismissed(tip.id) } 43 #expect(store.currentTip() == nil) 44 } 45 46 @Test("Disabling tips suppresses the banner regardless of dismissals") 47 func disableSuppresses() { 48 let (store, _) = makeStore() 49 store.disable() 50 #expect(store.isDisabled) 51 #expect(store.currentTip() == nil) 52 // Re-enabling brings back the next undismissed tip. 53 store.enable() 54 #expect(!store.isDisabled) 55 #expect(store.currentTip()?.id == "one") 56 } 57 58 @Test("Dismissals and the opt-out flag persist across instances") 59 func persistsAcrossInstances() { 60 let (store, defaults) = makeStore() 61 store.markDismissed("one") 62 store.disable() 63 64 let (reloaded, _) = makeStore(defaults: defaults) 65 #expect(reloaded.isDisabled) 66 reloaded.enable() 67 #expect(reloaded.currentTip()?.id == "two") 68 } 69 70 @Test("Tips scoped to other platforms are filtered out") 71 func platformScoping() { 72 let catalog = [ 73 Tip(id: "both", title: "Both", body: "Everywhere"), 74 Tip(id: "ipad", title: "iPad", body: "iPad only", only: [.iPad]), 75 Tip(id: "iphone", title: "iPhone", body: "iPhone only", only: [.iPhone]), 76 ] 77 let defaults = UserDefaults(suiteName: "test-\(UUID().uuidString)")! 78 let onPhone = TipStore(defaults: defaults, catalog: catalog, platform: .iPhone) 79 #expect(onPhone.visibleTips.map(\.id) == ["both", "iphone"]) 80 // The first applicable tip skips the iPad-only one. 81 #expect(onPhone.currentTip()?.id == "both") 82 onPhone.markDismissed("both") 83 #expect(onPhone.currentTip()?.id == "iphone") 84 85 let onPad = TipStore(defaults: defaults, catalog: catalog, platform: .iPad) 86 #expect(onPad.visibleTips.map(\.id) == ["both", "ipad"]) 87 } 88 89 @Test("Tip announcement ids round-trip so the banner can recognise a tip") 90 func announcementIDRoundTrips() { 91 let id = Tip.announcementID(for: "welcome") 92 #expect(Tip.tipID(fromAnnouncementID: id) == "welcome") 93 // A non-tip announcement id is not mistaken for a tip. 94 #expect(Tip.tipID(fromAnnouncementID: "reset-database-error") == nil) 95 } 96 97 @Test("A tip's live banner is a lowest-severity, manually dismissable global") 98 func liveAnnouncementShape() { 99 let tip = catalog[0] 100 let announcement = tip.liveAnnouncement() 101 #expect(announcement.scope == .global) 102 #expect(announcement.severity == .tip) 103 #expect(announcement.dismissal == .manual) 104 #expect(announcement.title == "One") 105 #expect(announcement.body == "First") 106 } 107 108 @Test("A tip's archive banner has no close control") 109 func archiveAnnouncementHasNoCloseControl() { 110 // The banner shows a ✕ only for `.manual`; the archive uses `.sticky`. 111 #expect(catalog[0].archiveAnnouncement().dismissal == .sticky) 112 } 113 }