import Foundation import Testing @testable import Cable /// The rating gate is invisible in the app — the OS may swallow the dialog — so its rules are /// only observable here. Serialized because `ReviewPrompt.store` is process-wide state. @Suite(.serialized) struct ReviewPromptTests { private func withFreshStore(_ body: (UserDefaults) -> Void) { let name = "review.tests.\(UUID().uuidString)" guard let defaults = UserDefaults(suiteName: name) else { Issue.record("could not create a test defaults suite") return } let previous = ReviewPrompt.store ReviewPrompt.store = defaults defer { ReviewPrompt.store = previous defaults.removePersistentDomain(forName: name) } body(defaults) } private var appVersion: String { Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "unknown" } @Test func oneMilestoneIsNotEnough() { withFreshStore { _ in ReviewPrompt.record(.billOfMaterials) #expect(ReviewPrompt.isEligible == false) } } @Test func twoDistinctMilestonesOpenTheGate() { withFreshStore { _ in ReviewPrompt.record(.systemPlanned) ReviewPrompt.record(.billOfMaterials) #expect(ReviewPrompt.isEligible) } } @Test func repeatingOneMilestoneNeverOpensTheGate() { withFreshStore { store in for _ in 0..<5 { ReviewPrompt.record(.systemPlanned) } #expect(store.stringArray(forKey: "review.milestones") == ["systemPlanned"]) #expect(ReviewPrompt.isEligible == false) } } @Test func aRecentPromptBlocksTheGate() { withFreshStore { store in ReviewPrompt.record(.systemPlanned) ReviewPrompt.record(.exported) store.set(Date().timeIntervalSince1970 - 10 * 86_400, forKey: "review.lastPromptDate") #expect(ReviewPrompt.isEligible == false) store.set(Date().timeIntervalSince1970 - 91 * 86_400, forKey: "review.lastPromptDate") #expect(ReviewPrompt.isEligible) } } @Test func theCurrentVersionIsOnlyAskedOnce() { withFreshStore { store in ReviewPrompt.record(.systemPlanned) ReviewPrompt.record(.exported) store.set(appVersion, forKey: "review.lastPromptedVersion") #expect(ReviewPrompt.isEligible == false) store.set("0.0.0-old", forKey: "review.lastPromptedVersion") #expect(ReviewPrompt.isEligible) } } /// Installs that already exported under the previous gate keep that credit, but still need a /// second, genuine milestone before we ask. @Test func migrationCarriesLegacyExportCreditAndDropsOldKeys() { withFreshStore { store in store.set(1, forKey: "review.successfulExportCount") store.set(true, forKey: "review.migrationDone") store.set(Date().timeIntervalSince1970, forKey: "review.firstLaunchDate") ReviewPrompt.migrateIfNeeded(isFirstLaunch: false) #expect(store.stringArray(forKey: "review.milestones") == ["exported"]) #expect(store.string(forKey: "review.userType") == "existing") #expect(store.object(forKey: "review.successfulExportCount") == nil) #expect(store.object(forKey: "review.migrationDone") == nil) #expect(store.object(forKey: "review.firstLaunchDate") == nil) #expect(ReviewPrompt.isEligible == false) ReviewPrompt.record(.billOfMaterials) #expect(ReviewPrompt.isEligible) } } @Test func aFreshInstallStartsWithNoCredit() { withFreshStore { store in ReviewPrompt.migrateIfNeeded(isFirstLaunch: true) #expect(store.stringArray(forKey: "review.milestones") == nil) #expect(store.string(forKey: "review.userType") == "new") #expect(ReviewPrompt.isEligible == false) } } @Test func theManualReviewLinkTargetsTheStoreListing() { #expect( ReviewPrompt.writeReviewURL.absoluteString == "https://apps.apple.com/app/id6752443870?action=write-review" ) } }