diff --git a/CHANGELOG.md b/CHANGELOG.md index 71e4dd16c..c77f092c2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ The changelog for `SuperwallKit`. Also see the [releases](https://github.com/sup ## 3.3.3 +### Enhancements + +- When you experience `no_rule_match`, the `TriggerFire` event params will specify which part of the rules didn't match in the format `"unmatched_rule_": ""`. Where `outcome` will either be `OCCURRENCE`, referring to the limit applied to a rule, or `EXPRESSION`. The `id` is the experiment id. + ### Fixes - Fixes issue where verification was happening after the finishing of transactions when not using a `PurchaseController`. diff --git a/Sources/SuperwallKit/Analytics/Internal Tracking/Trackable Events/TrackableSuperwallEvent.swift b/Sources/SuperwallKit/Analytics/Internal Tracking/Trackable Events/TrackableSuperwallEvent.swift index f7926cd47..2d5961adb 100644 --- a/Sources/SuperwallKit/Analytics/Internal Tracking/Trackable Events/TrackableSuperwallEvent.swift +++ b/Sources/SuperwallKit/Analytics/Internal Tracking/Trackable Events/TrackableSuperwallEvent.swift @@ -233,11 +233,11 @@ enum InternalSuperwallEvent { } struct TriggerFire: TrackableSuperwallEvent { - let triggerResult: TriggerResult + let triggerResult: InternalTriggerResult var superwallEvent: SuperwallEvent { return .triggerFire( eventName: triggerName, - result: triggerResult + result: triggerResult.toPublicType() ) } let triggerName: String @@ -254,10 +254,14 @@ enum InternalSuperwallEvent { } switch triggerResult { - case .noRuleMatch: - return params + [ + case .noRuleMatch(let unmatchedRules): + params += [ "result": "no_rule_match" ] + for unmatchedRule in unmatchedRules { + params["unmatched_rule_\(unmatchedRule.experimentId)"] = unmatchedRule.source.rawValue + } + return params case .holdout(let experiment): return params + [ "variant_id": experiment.variant.id as Any, diff --git a/Sources/SuperwallKit/Analytics/Trigger Session Manager/TriggerSessionManager.swift b/Sources/SuperwallKit/Analytics/Trigger Session Manager/TriggerSessionManager.swift index 5fbab3936..08f7bd9c0 100644 --- a/Sources/SuperwallKit/Analytics/Trigger Session Manager/TriggerSessionManager.swift +++ b/Sources/SuperwallKit/Analytics/Trigger Session Manager/TriggerSessionManager.swift @@ -136,7 +136,7 @@ actor TriggerSessionManager { for presentationInfo: PresentationInfo, on presentingViewController: UIViewController? = nil, paywall: Paywall? = nil, - triggerResult: TriggerResult?, + triggerResult: InternalTriggerResult?, trackEvent: (Trackable) async -> TrackingResult = Superwall.shared.track ) async { guard let eventName = presentationInfo.eventName else { @@ -152,7 +152,7 @@ actor TriggerSessionManager { presentationInfo: presentationInfo, presentingViewController: presentingViewController, paywall: paywall, - triggerResult: triggerResult + triggerResult: triggerResult?.toPublicType() ) else { return } diff --git a/Sources/SuperwallKit/Models/Triggers/RawExperiment.swift b/Sources/SuperwallKit/Models/Triggers/RawExperiment.swift index a3f3204aa..41c692370 100644 --- a/Sources/SuperwallKit/Models/Triggers/RawExperiment.swift +++ b/Sources/SuperwallKit/Models/Triggers/RawExperiment.swift @@ -9,8 +9,13 @@ import Foundation /// An experiment without a confirmed variant assignment. struct RawExperiment: Decodable, Hashable { + /// The ID of the experiment var id: String + + /// The campaign ID. var groupId: String + + /// The variants associated with the experiment. var variants: [VariantOption] } diff --git a/Sources/SuperwallKit/Models/Triggers/TriggerResult.swift b/Sources/SuperwallKit/Models/Triggers/TriggerResult.swift index a7b4a1d70..5e80cb4fd 100644 --- a/Sources/SuperwallKit/Models/Triggers/TriggerResult.swift +++ b/Sources/SuperwallKit/Models/Triggers/TriggerResult.swift @@ -39,3 +39,51 @@ public enum TriggerResult: Sendable, Equatable { /// In these instances, consider falling back to a native paywall. case error(NSError) } + +/// The result of a paywall trigger. `noRuleMatch` is an associated enum. +/// +/// Triggers can conditionally show paywalls. Contains the possible cases resulting from the trigger. +enum InternalTriggerResult: Equatable { + /// This event was not found on the dashboard. + /// + /// Please make sure you have added the event to a campaign on the dashboard and + /// double check its spelling. + case eventNotFound + + /// No matching rule was found for this trigger so no paywall will be shown. + case noRuleMatch([UnmatchedRule]) + + /// A matching rule was found and this user will be shown a paywall. + /// + /// - Parameters: + /// - experiment: The experiment associated with the trigger. + case paywall(Experiment) + + /// A matching rule was found and this user was assigned to a holdout group so will not be shown a paywall. + /// + /// - Parameters: + /// - experiment: The experiment associated with the trigger. + case holdout(Experiment) + + /// An error occurred and the user will not be shown a paywall. + /// + /// If the error code is `101`, it means that no view controller could be found to present on. Otherwise a network failure may have occurred. + /// + /// In these instances, consider falling back to a native paywall. + case error(NSError) + + func toPublicType() -> TriggerResult { + switch self { + case .eventNotFound: + return .eventNotFound + case .noRuleMatch: + return .noRuleMatch + case .paywall(let experiment): + return .paywall(experiment) + case .holdout(let experiment): + return .holdout(experiment) + case .error(let nSError): + return .error(nSError) + } + } +} diff --git a/Sources/SuperwallKit/Models/Triggers/TriggerRule.swift b/Sources/SuperwallKit/Models/Triggers/TriggerRule.swift index 6d6ec543a..e6f2a8660 100644 --- a/Sources/SuperwallKit/Models/Triggers/TriggerRule.swift +++ b/Sources/SuperwallKit/Models/Triggers/TriggerRule.swift @@ -7,11 +7,52 @@ import Foundation -struct TriggerRuleOutcome { +struct UnmatchedRule: Equatable { + enum Source: String { + case expression = "EXPRESSION" + case occurrence = "OCCURRENCE" + } + let source: Source + let experimentId: String +} + +struct MatchedItem { let rule: TriggerRule let unsavedOccurrence: TriggerRuleOccurrence? } +enum TriggerRuleOutcome: Equatable { + static func == (lhs: TriggerRuleOutcome, rhs: TriggerRuleOutcome) -> Bool { + switch (lhs, rhs) { + case let (.match(item1), .match(item2)): + return item1.rule == item2.rule + && item1.unsavedOccurrence == item2.unsavedOccurrence + case let (.noMatch(unmatchedRule1), .noMatch(unmatchedRule2)): + return unmatchedRule1.source == unmatchedRule2.source + && unmatchedRule1.experimentId == unmatchedRule2.experimentId + default: + return false + } + } + + case noMatch(UnmatchedRule) + case match(MatchedItem) + + static func noMatch( + source: UnmatchedRule.Source, + experimentId: String + ) -> TriggerRuleOutcome { + return .noMatch(.init(source: source, experimentId: experimentId)) + } + + static func match( + rule: TriggerRule, + unsavedOccurrence: TriggerRuleOccurrence? = nil + ) -> TriggerRuleOutcome { + return .match(.init(rule: rule, unsavedOccurrence: unsavedOccurrence)) + } +} + struct TriggerRule: Decodable, Hashable { var experiment: RawExperiment var expression: String? diff --git a/Sources/SuperwallKit/Paywall/Presentation/Get Presentation Result/GetPresentationResultLogic.swift b/Sources/SuperwallKit/Paywall/Presentation/Get Presentation Result/GetPresentationResultLogic.swift index d6a9359de..9963f7556 100644 --- a/Sources/SuperwallKit/Paywall/Presentation/Get Presentation Result/GetPresentationResultLogic.swift +++ b/Sources/SuperwallKit/Paywall/Presentation/Get Presentation Result/GetPresentationResultLogic.swift @@ -9,7 +9,7 @@ import Foundation enum GetPresentationResultLogic { /// Converts a ``TriggerResult`` to a ``PresentationResult`` - static func convertTriggerResult(_ triggerResult: TriggerResult) -> PresentationResult { + static func convertTriggerResult(_ triggerResult: InternalTriggerResult) -> PresentationResult { switch triggerResult { case .eventNotFound: return .eventNotFound diff --git a/Sources/SuperwallKit/Paywall/Presentation/Internal Presentation/Operators/CheckUserSubscription.swift b/Sources/SuperwallKit/Paywall/Presentation/Internal Presentation/Operators/CheckUserSubscription.swift index 615ea15c4..b21b2b527 100644 --- a/Sources/SuperwallKit/Paywall/Presentation/Internal Presentation/Operators/CheckUserSubscription.swift +++ b/Sources/SuperwallKit/Paywall/Presentation/Internal Presentation/Operators/CheckUserSubscription.swift @@ -18,7 +18,7 @@ extension Superwall { /// - paywallStatePublisher: A `PassthroughSubject` that gets sent ``PaywallState`` objects. func checkUserSubscription( request: PresentationRequest, - triggerResult: TriggerResult, + triggerResult: InternalTriggerResult, paywallStatePublisher: PassthroughSubject? ) async throws { switch triggerResult { diff --git a/Sources/SuperwallKit/Paywall/Presentation/Internal Presentation/Operators/GetPresenter.swift b/Sources/SuperwallKit/Paywall/Presentation/Internal Presentation/Operators/GetPresenter.swift index 470d78f8a..784d2323e 100644 --- a/Sources/SuperwallKit/Paywall/Presentation/Internal Presentation/Operators/GetPresenter.swift +++ b/Sources/SuperwallKit/Paywall/Presentation/Internal Presentation/Operators/GetPresenter.swift @@ -107,7 +107,7 @@ extension Superwall { private func activateSession( for request: PresentationRequest, paywall: Paywall, - triggerResult: TriggerResult + triggerResult: InternalTriggerResult ) async { let sessionEventsManager = dependencyContainer.sessionEventsManager await sessionEventsManager?.triggerSession.activateSession( diff --git a/Sources/SuperwallKit/Paywall/Presentation/Rule Logic/Expression Evaluator/ExpressionEvaluator.swift b/Sources/SuperwallKit/Paywall/Presentation/Rule Logic/Expression Evaluator/ExpressionEvaluator.swift index 7c3ded0fc..2c3bec376 100644 --- a/Sources/SuperwallKit/Paywall/Presentation/Rule Logic/Expression Evaluator/ExpressionEvaluator.swift +++ b/Sources/SuperwallKit/Paywall/Presentation/Rule Logic/Expression Evaluator/ExpressionEvaluator.swift @@ -12,11 +12,6 @@ struct ExpressionEvaluator { private let storage: Storage private unowned let factory: RuleAttributesFactory - struct TriggerFireOutcome { - let shouldFire: Bool - var unsavedOccurrence: TriggerRuleOccurrence? - } - init( storage: Storage, factory: RuleAttributesFactory @@ -28,18 +23,18 @@ struct ExpressionEvaluator { func evaluateExpression( fromRule rule: TriggerRule, eventData: EventData - ) async -> TriggerFireOutcome { + ) async -> TriggerRuleOutcome { // Expression matches all if rule.expressionJs == nil && rule.expression == nil { - let shouldFire = await shouldFire( - forOccurrence: rule.occurrence, - ruleMatched: true + let ruleMatched = await tryToMatchOccurrence( + from: rule, + expressionMatched: true ) - return shouldFire + return ruleMatched } guard let jsCtx = JSContext() else { - return TriggerFireOutcome(shouldFire: false) + return .noMatch(source: .expression, experimentId: rule.experiment.id) } jsCtx.exceptionHandler = { (_, value: JSValue?) in guard let value = value else { @@ -62,30 +57,30 @@ struct ExpressionEvaluator { ) } - guard let postfix = await getPostfix( - forRule: rule, + guard let base64Params = await getBase64Params( + from: rule, withEventData: eventData ) else { - return TriggerFireOutcome(shouldFire: false) + return .noMatch(source: .expression, experimentId: rule.experiment.id) } - let result = jsCtx.evaluateScript(script + "\n " + postfix) + let result = jsCtx.evaluateScript(script + "\n " + base64Params) if result?.isString == nil { - return TriggerFireOutcome(shouldFire: false) + return .noMatch(source: .expression, experimentId: rule.experiment.id) } - let isMatched = result?.toString() == "true" + let expressionMatched = result?.toString() == "true" - let shouldFire = await shouldFire( - forOccurrence: rule.occurrence, - ruleMatched: isMatched + let ruleMatched = await tryToMatchOccurrence( + from: rule, + expressionMatched: expressionMatched ) - return shouldFire + return ruleMatched } - private func getPostfix( - forRule rule: TriggerRule, + private func getBase64Params( + from rule: TriggerRule, withEventData eventData: EventData ) async -> String? { let attributes = await factory.makeRuleAttributes( @@ -116,19 +111,19 @@ struct ExpressionEvaluator { return nil } - func shouldFire( - forOccurrence occurrence: TriggerRuleOccurrence?, - ruleMatched: Bool - ) async -> TriggerFireOutcome { - if ruleMatched { - guard let occurrence = occurrence else { + func tryToMatchOccurrence( + from rule: TriggerRule, + expressionMatched: Bool + ) async -> TriggerRuleOutcome { + if expressionMatched { + guard let occurrence = rule.occurrence else { Logger.debug( logLevel: .debug, scope: .paywallPresentation, message: "No occurrence parameter found for trigger rule." ) - return TriggerFireOutcome(shouldFire: true) + return .match(rule: rule) } let count = await storage @@ -142,14 +137,12 @@ struct ExpressionEvaluator { if shouldFire { unsavedOccurrence = occurrence + return .match(rule: rule, unsavedOccurrence: unsavedOccurrence) + } else { + return .noMatch(source: .occurrence, experimentId: rule.experiment.id) } - - return TriggerFireOutcome( - shouldFire: shouldFire, - unsavedOccurrence: unsavedOccurrence - ) } - return TriggerFireOutcome(shouldFire: false) + return .noMatch(source: .expression, experimentId: rule.experiment.id) } } diff --git a/Sources/SuperwallKit/Paywall/Presentation/Rule Logic/RuleLogic.swift b/Sources/SuperwallKit/Paywall/Presentation/Rule Logic/RuleLogic.swift index ca1343832..a607fd864 100644 --- a/Sources/SuperwallKit/Paywall/Presentation/Rule Logic/RuleLogic.swift +++ b/Sources/SuperwallKit/Paywall/Presentation/Rule Logic/RuleLogic.swift @@ -16,7 +16,12 @@ struct ConfirmableAssignment: Equatable { struct RuleEvaluationOutcome { var confirmableAssignment: ConfirmableAssignment? var unsavedOccurrence: TriggerRuleOccurrence? - var triggerResult: TriggerResult + var triggerResult: InternalTriggerResult +} + +enum RuleMatchOutcome { + case matched(MatchedItem) + case noMatchingRules([UnmatchedRule]) } struct RuleLogic { @@ -50,16 +55,23 @@ struct RuleLogic { return RuleEvaluationOutcome(triggerResult: .eventNotFound) } - guard let ruleOutcome = await findMatchingRule( + let ruleMatchOutcome = await findMatchingRule( for: event, withTrigger: trigger - ) else { - return RuleEvaluationOutcome(triggerResult: .noRuleMatch) + ) + + let matchedRuleItem: MatchedItem + + switch ruleMatchOutcome { + case .matched(let item): + matchedRuleItem = item + case .noMatchingRules(let unmatchedRules): + return.init(triggerResult: .noRuleMatch(unmatchedRules)) } let variant: Experiment.Variant var confirmableAssignment: ConfirmableAssignment? - let rule = ruleOutcome.rule + let rule = matchedRuleItem.rule // For a matching rule there will be an unconfirmed (in-memory) or confirmed (on disk) variant assignment. // First check the disk, otherwise check memory. let confirmedAssignments = storage.getConfirmedAssignments() @@ -92,7 +104,7 @@ struct RuleLogic { case .holdout: return RuleEvaluationOutcome( confirmableAssignment: confirmableAssignment, - unsavedOccurrence: ruleOutcome.unsavedOccurrence, + unsavedOccurrence: matchedRuleItem.unsavedOccurrence, triggerResult: .holdout( Experiment( id: rule.experiment.id, @@ -104,7 +116,7 @@ struct RuleLogic { case .treatment: return RuleEvaluationOutcome( confirmableAssignment: confirmableAssignment, - unsavedOccurrence: ruleOutcome.unsavedOccurrence, + unsavedOccurrence: matchedRuleItem.unsavedOccurrence, triggerResult: .paywall( Experiment( id: rule.experiment.id, @@ -119,24 +131,28 @@ struct RuleLogic { func findMatchingRule( for event: EventData, withTrigger trigger: Trigger - ) async -> TriggerRuleOutcome? { + ) async -> RuleMatchOutcome { let expressionEvaluator = ExpressionEvaluator( storage: storage, factory: factory ) + var unmatchedRules: [UnmatchedRule] = [] + for rule in trigger.rules { let outcome = await expressionEvaluator.evaluateExpression( fromRule: rule, eventData: event ) - if outcome.shouldFire { - return TriggerRuleOutcome( - rule: rule, - unsavedOccurrence: outcome.unsavedOccurrence - ) + + switch outcome { + case .match(let item): + return .matched(item) + case .noMatch(let noRuleMatch): + unmatchedRules.append(noRuleMatch) } } - return nil + + return .noMatchingRules(unmatchedRules) } } diff --git a/Tests/SuperwallKitTests/Analytics/Internal Tracking/TrackTests.swift b/Tests/SuperwallKitTests/Analytics/Internal Tracking/TrackTests.swift index 3a1d9de3e..30459af3e 100644 --- a/Tests/SuperwallKitTests/Analytics/Internal Tracking/TrackTests.swift +++ b/Tests/SuperwallKitTests/Analytics/Internal Tracking/TrackTests.swift @@ -288,12 +288,18 @@ final class TrackingTests: XCTestCase { func test_triggerFire_noRuleMatch() async { let triggerName = "My Trigger" let dependencyContainer = DependencyContainer() - let result = await Superwall.shared.track(InternalSuperwallEvent.TriggerFire(triggerResult: .noRuleMatch, triggerName: triggerName, sessionEventsManager: dependencyContainer.sessionEventsManager)) + let unmatchedRules: [UnmatchedRule] = [ + .init(source: .expression, experimentId: "1"), + .init(source: .occurrence, experimentId: "2") + ] + let result = await Superwall.shared.track(InternalSuperwallEvent.TriggerFire(triggerResult: .noRuleMatch(unmatchedRules), triggerName: triggerName, sessionEventsManager: dependencyContainer.sessionEventsManager)) XCTAssertNotNil(result.parameters.eventParams["$app_session_id"]) XCTAssertTrue(result.parameters.eventParams["$is_standard_event"] as! Bool) XCTAssertEqual(result.parameters.eventParams["$event_name"] as! String, "trigger_fire") XCTAssertEqual(result.parameters.eventParams["$result"] as! String, "no_rule_match") XCTAssertEqual(result.parameters.eventParams["$trigger_name"] as! String, triggerName) + XCTAssertEqual(result.parameters.eventParams["$unmatched_rule_1"] as! String, "EXPRESSION") + XCTAssertEqual(result.parameters.eventParams["$unmatched_rule_2"] as! String, "OCCURRENCE") // TODO: Missing test for trigger_session_id here. Need to figure out a way to activate it } diff --git a/Tests/SuperwallKitTests/Analytics/Trigger Session Manager/TriggerSessionManagerTests.swift b/Tests/SuperwallKitTests/Analytics/Trigger Session Manager/TriggerSessionManagerTests.swift index edc0f9b5a..d804a8178 100644 --- a/Tests/SuperwallKitTests/Analytics/Trigger Session Manager/TriggerSessionManagerTests.swift +++ b/Tests/SuperwallKitTests/Analytics/Trigger Session Manager/TriggerSessionManagerTests.swift @@ -187,7 +187,7 @@ final class TriggerSessionManagerTests: XCTestCase { // When await sessionManager.activateSession( for: .explicitTrigger(eventData), - triggerResult: .noRuleMatch + triggerResult: .noRuleMatch([]) ) let triggerSessions = await queue.triggerSessions diff --git a/Tests/SuperwallKitTests/Config/Assignments/Expression Evaluator/ExpressionEvaluatorLogicTests.swift b/Tests/SuperwallKitTests/Config/Assignments/Expression Evaluator/ExpressionEvaluatorLogicTests.swift deleted file mode 100644 index ea3ebc27e..000000000 --- a/Tests/SuperwallKitTests/Config/Assignments/Expression Evaluator/ExpressionEvaluatorLogicTests.swift +++ /dev/null @@ -1,89 +0,0 @@ -// -// File.swift -// -// -// Created by Yusuf Tör on 11/07/2022. -// - -import Foundation -import XCTest -@testable import SuperwallKit - -@available(iOS 14.0, *) -final class ExpressionEvaluatorLogicTests: XCTestCase { - func testShouldFire_noMatch() async { - let dependencyContainer = DependencyContainer() - let storage = StorageMock() - let evaluator = ExpressionEvaluator( - storage: storage, - factory: dependencyContainer - ) - let outcome = await evaluator.shouldFire( - forOccurrence: .stub(), - ruleMatched: false - ) - XCTAssertFalse(outcome.shouldFire) - } - - func testShouldFire_noOccurrenceRule() async { - let dependencyContainer = DependencyContainer() - let storage = StorageMock() - let evaluator = ExpressionEvaluator( - storage: storage, - factory: dependencyContainer - ) - let outcome = await evaluator.shouldFire( - forOccurrence: nil, - ruleMatched: true - ) - XCTAssertTrue(outcome.shouldFire) - } - - func testShouldFire_shouldntFire_maxCountGTCount() async { - let dependencyContainer = DependencyContainer() - let coreDataManagerMock = CoreDataManagerFakeDataMock(internalOccurrenceCount: 1) - let storage = StorageMock(coreDataManager: coreDataManagerMock) - let evaluator = ExpressionEvaluator( - storage: storage, - factory: dependencyContainer - ) - let outcome = await evaluator.shouldFire( - forOccurrence: .stub() - .setting(\.maxCount, to: 1), - ruleMatched: true - ) - XCTAssertFalse(outcome.shouldFire) - } - - func testShouldFire_shouldFire_maxCountEqualToCount() async { - let dependencyContainer = DependencyContainer() - let coreDataManagerMock = CoreDataManagerFakeDataMock(internalOccurrenceCount: 0) - let storage = StorageMock(coreDataManager: coreDataManagerMock) - let evaluator = ExpressionEvaluator( - storage: storage, - factory: dependencyContainer - ) - let outcome = await evaluator.shouldFire( - forOccurrence: .stub() - .setting(\.maxCount, to: 1), - ruleMatched: true - ) - XCTAssertTrue(outcome.shouldFire) - } - - func testShouldFire_shouldFire_maxCountLtCount() async { - let dependencyContainer = DependencyContainer() - let coreDataManagerMock = CoreDataManagerFakeDataMock(internalOccurrenceCount: 1) - let storage = StorageMock(coreDataManager: coreDataManagerMock) - let evaluator = ExpressionEvaluator( - storage: storage, - factory: dependencyContainer - ) - let outcome = await evaluator.shouldFire( - forOccurrence: .stub() - .setting(\.maxCount, to: 4), - ruleMatched: true - ) - XCTAssertTrue(outcome.shouldFire) - } -} diff --git a/Tests/SuperwallKitTests/Config/Assignments/Expression Evaluator/ExpressionEvaluatorTests.swift b/Tests/SuperwallKitTests/Config/Assignments/Expression Evaluator/ExpressionEvaluatorTests.swift index 9f653d453..4b1d8e720 100644 --- a/Tests/SuperwallKitTests/Config/Assignments/Expression Evaluator/ExpressionEvaluatorTests.swift +++ b/Tests/SuperwallKitTests/Config/Assignments/Expression Evaluator/ExpressionEvaluatorTests.swift @@ -10,7 +10,101 @@ import Foundation import XCTest @testable import SuperwallKit +@available(iOS 14.0, *) final class ExpressionEvaluatorTests: XCTestCase { + // MARK: - tryToMatchOccurrence + func test_tryToMatchOccurrence_noMatch() async { + let dependencyContainer = DependencyContainer() + let storage = StorageMock() + let evaluator = ExpressionEvaluator( + storage: storage, + factory: dependencyContainer + ) + let rule = TriggerRule.stub() + .setting(\.occurrence, to: .stub()) + let outcome = await evaluator.tryToMatchOccurrence( + from: rule, + expressionMatched: false + ) + XCTAssertEqual(outcome, .noMatch(source: .expression, experimentId: rule.experiment.id)) + } + + func test_tryToMatchOccurrence_noOccurrenceRule() async { + let dependencyContainer = DependencyContainer() + let storage = StorageMock() + let evaluator = ExpressionEvaluator( + storage: storage, + factory: dependencyContainer + ) + let rule = TriggerRule.stub() + .setting(\.occurrence, to: nil) + let outcome = await evaluator.tryToMatchOccurrence( + from: rule, + expressionMatched: true + ) + XCTAssertEqual(outcome, .match(rule: rule)) + } + + func test_tryToMatchOccurrence_shouldntFire_maxCountGTCount() async { + let dependencyContainer = DependencyContainer() + let coreDataManagerMock = CoreDataManagerFakeDataMock(internalOccurrenceCount: 1) + let storage = StorageMock(coreDataManager: coreDataManagerMock) + let evaluator = ExpressionEvaluator( + storage: storage, + factory: dependencyContainer + ) + + let rule = TriggerRule.stub() + .setting(\.occurrence, to: .stub().setting(\.maxCount, to: 1)) + let outcome = await evaluator.tryToMatchOccurrence( + from: rule, + expressionMatched: true + ) + + XCTAssertEqual(outcome, .noMatch(source: .occurrence, experimentId: rule.experiment.id)) + } + + func test_tryToMatchOccurrence_shouldFire_maxCountEqualToCount() async { + let dependencyContainer = DependencyContainer() + let coreDataManagerMock = CoreDataManagerFakeDataMock(internalOccurrenceCount: 0) + let storage = StorageMock(coreDataManager: coreDataManagerMock) + let evaluator = ExpressionEvaluator( + storage: storage, + factory: dependencyContainer + ) + + let occurrence: TriggerRuleOccurrence = .stub().setting(\.maxCount, to: 1) + let rule = TriggerRule.stub() + .setting(\.occurrence, to: occurrence) + let outcome = await evaluator.tryToMatchOccurrence( + from: rule, + expressionMatched: true + ) + + XCTAssertEqual(outcome, .match(rule: rule, unsavedOccurrence: occurrence)) + } + + func test_tryToMatchOccurrence_shouldFire_maxCountLtCount() async { + let dependencyContainer = DependencyContainer() + let coreDataManagerMock = CoreDataManagerFakeDataMock(internalOccurrenceCount: 1) + let storage = StorageMock(coreDataManager: coreDataManagerMock) + let evaluator = ExpressionEvaluator( + storage: storage, + factory: dependencyContainer + ) + + let occurrence: TriggerRuleOccurrence = .stub().setting(\.maxCount, to: 4) + let rule = TriggerRule.stub() + .setting(\.occurrence, to: occurrence) + let outcome = await evaluator.tryToMatchOccurrence( + from: rule, + expressionMatched: true + ) + + XCTAssertEqual(outcome, .match(rule: rule, unsavedOccurrence: occurrence)) + } + + // MARK: - evaluateExpression func testExpressionMatchesAll() async { let dependencyContainer = DependencyContainer() dependencyContainer.storage.reset() @@ -18,13 +112,16 @@ final class ExpressionEvaluatorTests: XCTestCase { storage: dependencyContainer.storage, factory: dependencyContainer ) + + let rule: TriggerRule = .stub() + .setting(\.expression, to: nil) + .setting(\.expressionJs, to: nil) let result = await evaluator.evaluateExpression( - fromRule: .stub() - .setting(\.expression, to: nil) - .setting(\.expressionJs, to: nil), + fromRule: rule, eventData: .stub() ) - XCTAssertTrue(result.shouldFire) + + XCTAssertEqual(result, .match(rule: rule)) } // MARK: - Expression @@ -37,13 +134,14 @@ final class ExpressionEvaluatorTests: XCTestCase { factory: dependencyContainer ) dependencyContainer.identityManager.mergeUserAttributes(["a": "b"]) + let rule: TriggerRule = .stub() + .setting(\.expression, to: "user.a == \"b\"") let result = await evaluator.evaluateExpression( - fromRule: .stub() - .setting(\.expression, to: "user.a == \"b\""), + fromRule: rule, eventData: EventData(name: "ss", parameters: [:], createdAt: Date()) ) - XCTAssertTrue(result.shouldFire) - XCTAssertNil(result.unsavedOccurrence) + + XCTAssertEqual(result, .match(rule: rule)) } func testExpressionEvaluator_expression_withOccurrence() async { @@ -55,14 +153,15 @@ final class ExpressionEvaluatorTests: XCTestCase { ) dependencyContainer.identityManager.mergeUserAttributes(["a": "b"]) let occurrence = TriggerRuleOccurrence(key: "a", maxCount: 1, interval: .infinity) + let rule: TriggerRule = .stub() + .setting(\.expression, to: "user.a == \"b\"") + .setting(\.occurrence, to: occurrence) let result = await evaluator.evaluateExpression( - fromRule: .stub() - .setting(\.expression, to: "user.a == \"b\"") - .setting(\.occurrence, to: occurrence), + fromRule: rule, eventData: EventData(name: "ss", parameters: [:], createdAt: Date()) ) - XCTAssertTrue(result.shouldFire) - XCTAssertEqual(result.unsavedOccurrence, occurrence) + + XCTAssertEqual(result, .match(rule: rule, unsavedOccurrence: occurrence)) } func testExpressionEvaluator_expressionParams() async { @@ -73,12 +172,13 @@ final class ExpressionEvaluatorTests: XCTestCase { factory: dependencyContainer ) dependencyContainer.identityManager.mergeUserAttributes([:]) + let rule: TriggerRule = .stub() + .setting(\.expression, to: "params.a == \"b\"") let result = await evaluator.evaluateExpression( - fromRule: .stub() - .setting(\.expression, to: "params.a == \"b\""), + fromRule: rule, eventData: EventData(name: "ss", parameters: ["a": "b"], createdAt: Date()) ) - XCTAssertTrue(result.shouldFire) + XCTAssertEqual(result, .match(rule: rule)) } func testExpressionEvaluator_expressionDeviceTrue() async { @@ -89,12 +189,13 @@ final class ExpressionEvaluatorTests: XCTestCase { factory: dependencyContainer ) dependencyContainer.identityManager.mergeUserAttributes([:]) + let rule: TriggerRule = .stub() + .setting(\.expression, to: "device.platform == \"iOS\"") let result = await evaluator.evaluateExpression( - fromRule: .stub() - .setting(\.expression, to: "device.platform == \"iOS\""), + fromRule: rule, eventData: EventData(name: "ss", parameters: ["a": "b"], createdAt: Date()) ) - XCTAssertTrue(result.shouldFire) + XCTAssertEqual(result, .match(rule: rule)) } func testExpressionEvaluator_expressionDeviceFalse() async { @@ -105,12 +206,13 @@ final class ExpressionEvaluatorTests: XCTestCase { factory: dependencyContainer ) dependencyContainer.identityManager.mergeUserAttributes([:]) + let rule: TriggerRule = .stub() + .setting(\.expression, to: "device.platform == \"Android\"") let result = await evaluator.evaluateExpression( - fromRule: .stub() - .setting(\.expression, to: "device.platform == \"Android\""), + fromRule: rule, eventData: EventData(name: "ss", parameters: ["a": "b"], createdAt: Date()) ) - XCTAssertFalse(result.shouldFire) + XCTAssertEqual(result, .noMatch(source: .expression, experimentId: rule.experiment.id)) } func testExpressionEvaluator_expressionFalse() async { @@ -121,12 +223,13 @@ final class ExpressionEvaluatorTests: XCTestCase { factory: dependencyContainer ) dependencyContainer.identityManager.mergeUserAttributes([:]) + let rule: TriggerRule = .stub() + .setting(\.expression, to: "a == \"b\"") let result = await evaluator.evaluateExpression( - fromRule: .stub() - .setting(\.expression, to: "a == \"b\""), + fromRule: rule, eventData: .stub() ) - XCTAssertFalse(result.shouldFire) + XCTAssertEqual(result, .noMatch(source: .expression, experimentId: rule.experiment.id)) } /* func testExpressionEvaluator_events() { @@ -151,12 +254,13 @@ final class ExpressionEvaluatorTests: XCTestCase { storage: dependencyContainer.storage, factory: dependencyContainer ) + let rule: TriggerRule = .stub() + .setting(\.expressionJs, to: "function superwallEvaluator(){ return true }; superwallEvaluator") let result = await evaluator.evaluateExpression( - fromRule: .stub() - .setting(\.expressionJs, to: "function superwallEvaluator(){ return true }; superwallEvaluator"), + fromRule: rule, eventData: .stub() ) - XCTAssertTrue(result.shouldFire) + XCTAssertEqual(result, .match(rule: rule)) } func testExpressionEvaluator_expressionJSValues_true() async { @@ -165,12 +269,13 @@ final class ExpressionEvaluatorTests: XCTestCase { storage: dependencyContainer.storage, factory: dependencyContainer ) + let rule: TriggerRule = .stub() + .setting(\.expressionJs, to: "function superwallEvaluator(values) { return values.params.a ==\"b\" }; superwallEvaluator") let result = await evaluator.evaluateExpression( - fromRule: .stub() - .setting(\.expressionJs, to: "function superwallEvaluator(values) { return values.params.a ==\"b\" }; superwallEvaluator"), + fromRule: rule, eventData: EventData(name: "ss", parameters: ["a": "b"], createdAt: Date()) ) - XCTAssertTrue(result.shouldFire) + XCTAssertEqual(result, .match(rule: rule)) } func testExpressionEvaluator_expressionJSValues_false() async { @@ -179,12 +284,13 @@ final class ExpressionEvaluatorTests: XCTestCase { storage: dependencyContainer.storage, factory: dependencyContainer ) + let rule: TriggerRule = .stub() + .setting(\.expressionJs, to: "function superwallEvaluator(values) { return values.params.a ==\"b\" }; superwallEvaluator") let result = await evaluator.evaluateExpression( - fromRule: .stub() - .setting(\.expressionJs, to: "function superwallEvaluator(values) { return values.params.a ==\"b\" }; superwallEvaluator"), + fromRule: rule, eventData: EventData(name: "ss", parameters: ["a": "b"], createdAt: Date()) ) - XCTAssertTrue(result.shouldFire) + XCTAssertEqual(result, .match(rule: rule)) } func testExpressionEvaluator_expressionJSNumbers() async { @@ -193,12 +299,13 @@ final class ExpressionEvaluatorTests: XCTestCase { storage: dependencyContainer.storage, factory: dependencyContainer ) + let rule: TriggerRule = .stub() + .setting(\.expressionJs, to: "function superwallEvaluator(values) { return 1 == 1 }; superwallEvaluator") let result = await evaluator.evaluateExpression( - fromRule: .stub() - .setting(\.expressionJs, to: "function superwallEvaluator(values) { return 1 == 1 }; superwallEvaluator"), + fromRule: rule, eventData: .stub() ) - XCTAssertTrue(result.shouldFire) + XCTAssertEqual(result, .match(rule: rule)) } /* func testExpressionEvaluator_expressionJSValues_events() { @@ -221,11 +328,12 @@ final class ExpressionEvaluatorTests: XCTestCase { storage: dependencyContainer.storage, factory: dependencyContainer ) + let rule: TriggerRule = .stub() + .setting(\.expressionJs, to: "") let result = await evaluator.evaluateExpression( - fromRule: .stub() - .setting(\.expressionJs, to: ""), + fromRule: rule, eventData: .stub() ) - XCTAssertFalse(result.shouldFire) + XCTAssertEqual(result, .noMatch(source: .expression, experimentId: rule.experiment.id)) } } diff --git a/Tests/SuperwallKitTests/Paywall/Presentation/Internal Presentation/Operators/HandleTriggerResultOperatorTests.swift b/Tests/SuperwallKitTests/Paywall/Presentation/Internal Presentation/Operators/HandleTriggerResultOperatorTests.swift index 08bf8b40f..f69831dc0 100644 --- a/Tests/SuperwallKitTests/Paywall/Presentation/Internal Presentation/Operators/HandleTriggerResultOperatorTests.swift +++ b/Tests/SuperwallKitTests/Paywall/Presentation/Internal Presentation/Operators/HandleTriggerResultOperatorTests.swift @@ -96,7 +96,7 @@ final class HandleTriggerResultOperatorTests: XCTestCase { func test_handleTriggerResult_noRuleMatch() async { let input = RuleEvaluationOutcome( - triggerResult: .noRuleMatch + triggerResult: .noRuleMatch([]) ) let statePublisher = PassthroughSubject()