crossmate

A collaborative crossword app for iOS
Log | Files | Refs | LICENSE

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 }