diff --git a/Authorization/Authorization/Presentation/AuthorizationAnalytics.swift b/Authorization/Authorization/Presentation/AuthorizationAnalytics.swift index 060560ba6..b59ebd774 100644 --- a/Authorization/Authorization/Presentation/AuthorizationAnalytics.swift +++ b/Authorization/Authorization/Presentation/AuthorizationAnalytics.swift @@ -14,7 +14,7 @@ public enum AuthMethod: Equatable { public var analyticsValue: String { switch self { case .password: - "Password" + "password" case .socailAuth(let socialAuthMethod): socialAuthMethod.rawValue } @@ -22,31 +22,37 @@ public enum AuthMethod: Equatable { } public enum SocialAuthMethod: String { - case facebook = "Facebook" - case google = "Google" - case microsoft = "Microsoft" - case apple = "Apple" + case facebook = "facebook" + case google = "google" + case microsoft = "microsoft" + case apple = "apple" } //sourcery: AutoMockable public protocol AuthorizationAnalytics { func identify(id: String, username: String, email: String) func userLogin(method: AuthMethod) - func signUpClicked() + func registerClicked() + func signInClicked() + func userSignInClicked() func createAccountClicked() - func registrationSuccess() + func registrationSuccess(method: String) func forgotPasswordClicked() - func resetPasswordClicked(success: Bool) + func resetPasswordClicked() + func resetPassword(success: Bool) } #if DEBUG class AuthorizationAnalyticsMock: AuthorizationAnalytics { func identify(id: String, username: String, email: String) {} public func userLogin(method: AuthMethod) {} - public func signUpClicked() {} + public func registerClicked() {} + public func signInClicked() {} + public func userSignInClicked() {} public func createAccountClicked() {} - public func registrationSuccess() {} + public func registrationSuccess(method: String) {} public func forgotPasswordClicked() {} - public func resetPasswordClicked(success: Bool) {} + public func resetPasswordClicked() {} + public func resetPassword(success: Bool) {} } #endif diff --git a/Authorization/Authorization/Presentation/Login/SignInViewModel.swift b/Authorization/Authorization/Presentation/Login/SignInViewModel.swift index deebec363..041c98ca7 100644 --- a/Authorization/Authorization/Presentation/Login/SignInViewModel.swift +++ b/Authorization/Authorization/Presentation/Login/SignInViewModel.swift @@ -75,7 +75,7 @@ public class SignInViewModel: ObservableObject { errorMessage = AuthLocalization.Error.invalidPasswordLenght return } - + analytics.userSignInClicked() isShowProgress = true do { let user = try await interactor.login(username: username, password: password) diff --git a/Authorization/Authorization/Presentation/Registration/SignUpView.swift b/Authorization/Authorization/Presentation/Registration/SignUpView.swift index 999f5d2b0..aa2a089ea 100644 --- a/Authorization/Authorization/Presentation/Registration/SignUpView.swift +++ b/Authorization/Authorization/Presentation/Registration/SignUpView.swift @@ -139,7 +139,7 @@ public struct SignUpView: View { StyledButton(AuthLocalization.SignUp.createAccountBtn) { viewModel.thirdPartyAuthSuccess = false Task { - await viewModel.registerUser() + await viewModel.registerUser(authMetod: viewModel.authMethod) } viewModel.trackCreateAccountClicked() } diff --git a/Authorization/Authorization/Presentation/Registration/SignUpViewModel.swift b/Authorization/Authorization/Presentation/Registration/SignUpViewModel.swift index 62c29c6f0..1f57b8c02 100644 --- a/Authorization/Authorization/Presentation/Registration/SignUpViewModel.swift +++ b/Authorization/Authorization/Presentation/Registration/SignUpViewModel.swift @@ -54,6 +54,7 @@ public class SignUpViewModel: ObservableObject { private let interactor: AuthInteractorProtocol private let analytics: AuthorizationAnalytics private let validator: Validator + var authMethod: AuthMethod = .password public init( interactor: AuthInteractorProtocol, @@ -121,7 +122,7 @@ public class SignUpViewModel: ObservableObject { private var backend: String? @MainActor - func registerUser() async { + func registerUser(authMetod: AuthMethod = .password) async { do { let validateFields = configureFields() let errors = try await interactor.validateRegistrationFields(fields: validateFields) @@ -132,7 +133,7 @@ public class SignUpViewModel: ObservableObject { isSocial: externalToken != nil ) analytics.identify(id: "\(user.id)", username: user.username, email: user.email) - analytics.registrationSuccess() + analytics.registrationSuccess(method: authMetod.analyticsValue) isShowProgress = false router.showMainOrWhatsNewScreen(sourceScreen: sourceScreen) @@ -198,7 +199,8 @@ public class SignUpViewModel: ObservableObject { self.backend = backend thirdPartyAuthSuccess = true isShowProgress = false - await registerUser() + self.authMethod = authMethod + await registerUser(authMetod: authMethod) } } diff --git a/Authorization/Authorization/Presentation/Reset Password/ResetPasswordViewModel.swift b/Authorization/Authorization/Presentation/Reset Password/ResetPasswordViewModel.swift index cf2b1d71a..10b2edc00 100644 --- a/Authorization/Authorization/Presentation/Reset Password/ResetPasswordViewModel.swift +++ b/Authorization/Authorization/Presentation/Reset Password/ResetPasswordViewModel.swift @@ -50,14 +50,15 @@ public class ResetPasswordViewModel: ObservableObject { return } isShowProgress = true + analytics.resetPasswordClicked() do { _ = try await interactor.resetPassword(email: email).responseText.hideHtmlTagsAndUrls() isRecovered.wrappedValue.toggle() - analytics.resetPasswordClicked(success: true) + analytics.resetPassword(success: true) isShowProgress = false } catch { isShowProgress = false - analytics.resetPasswordClicked(success: false) + analytics.resetPassword(success: false) if let validationError = error.validationError, let value = validationError.data?["value"] as? String { errorMessage = value diff --git a/Authorization/Authorization/Presentation/Startup/StartupView.swift b/Authorization/Authorization/Presentation/Startup/StartupView.swift index bf5313947..517cb365e 100644 --- a/Authorization/Authorization/Presentation/Startup/StartupView.swift +++ b/Authorization/Authorization/Presentation/Startup/StartupView.swift @@ -128,7 +128,8 @@ public struct StartupView: View { struct StartupView_Previews: PreviewProvider { static var previews: some View { let vm = StartupViewModel( - router: AuthorizationRouterMock() + router: AuthorizationRouterMock(), + analytics: CoreAnalyticsMock() ) StartupView(viewModel: vm) diff --git a/Authorization/Authorization/Presentation/Startup/StartupViewModel.swift b/Authorization/Authorization/Presentation/Startup/StartupViewModel.swift index 1549940a1..650ae5f7f 100644 --- a/Authorization/Authorization/Presentation/Startup/StartupViewModel.swift +++ b/Authorization/Authorization/Presentation/Startup/StartupViewModel.swift @@ -10,11 +10,27 @@ import Core public class StartupViewModel: ObservableObject { let router: AuthorizationRouter + let analytics: CoreAnalytics + @Published var searchQuery: String? public init( - router: AuthorizationRouter + router: AuthorizationRouter, + analytics: CoreAnalytics ) { self.router = router + self.analytics = analytics + } + + func logAnalytics(searchQuery: String?) { + if let searchQuery { + analytics.trackEvent( + .logistrationCoursesSearch, + biValue: .logistrationCoursesSearch, + parameters: [EventParamKey.searchQuery: searchQuery] + ) + } else { + analytics.trackEvent(.logistrationExploreAllCourses, biValue: .logistrationExploreAllCourses) + } } } diff --git a/Authorization/AuthorizationTests/AuthorizationMock.generated.swift b/Authorization/AuthorizationTests/AuthorizationMock.generated.swift index e6c3b9580..b31d6c690 100644 --- a/Authorization/AuthorizationTests/AuthorizationMock.generated.swift +++ b/Authorization/AuthorizationTests/AuthorizationMock.generated.swift @@ -521,9 +521,21 @@ open class AuthorizationAnalyticsMock: AuthorizationAnalytics, Mock { perform?(`method`) } - open func signUpClicked() { - addInvocation(.m_signUpClicked) - let perform = methodPerformValue(.m_signUpClicked) as? () -> Void + open func registerClicked() { + addInvocation(.m_registerClicked) + let perform = methodPerformValue(.m_registerClicked) as? () -> Void + perform?() + } + + open func signInClicked() { + addInvocation(.m_signInClicked) + let perform = methodPerformValue(.m_signInClicked) as? () -> Void + perform?() + } + + open func userSignInClicked() { + addInvocation(.m_userSignInClicked) + let perform = methodPerformValue(.m_userSignInClicked) as? () -> Void perform?() } @@ -533,10 +545,10 @@ open class AuthorizationAnalyticsMock: AuthorizationAnalytics, Mock { perform?() } - open func registrationSuccess() { - addInvocation(.m_registrationSuccess) - let perform = methodPerformValue(.m_registrationSuccess) as? () -> Void - perform?() + open func registrationSuccess(method: String) { + addInvocation(.m_registrationSuccess__method_method(Parameter.value(`method`))) + let perform = methodPerformValue(.m_registrationSuccess__method_method(Parameter.value(`method`))) as? (String) -> Void + perform?(`method`) } open func forgotPasswordClicked() { @@ -545,9 +557,15 @@ open class AuthorizationAnalyticsMock: AuthorizationAnalytics, Mock { perform?() } - open func resetPasswordClicked(success: Bool) { - addInvocation(.m_resetPasswordClicked__success_success(Parameter.value(`success`))) - let perform = methodPerformValue(.m_resetPasswordClicked__success_success(Parameter.value(`success`))) as? (Bool) -> Void + open func resetPasswordClicked() { + addInvocation(.m_resetPasswordClicked) + let perform = methodPerformValue(.m_resetPasswordClicked) as? () -> Void + perform?() + } + + open func resetPassword(success: Bool) { + addInvocation(.m_resetPassword__success_success(Parameter.value(`success`))) + let perform = methodPerformValue(.m_resetPassword__success_success(Parameter.value(`success`))) as? (Bool) -> Void perform?(`success`) } @@ -555,11 +573,14 @@ open class AuthorizationAnalyticsMock: AuthorizationAnalytics, Mock { fileprivate enum MethodType { case m_identify__id_idusername_usernameemail_email(Parameter, Parameter, Parameter) case m_userLogin__method_method(Parameter) - case m_signUpClicked + case m_registerClicked + case m_signInClicked + case m_userSignInClicked case m_createAccountClicked - case m_registrationSuccess + case m_registrationSuccess__method_method(Parameter) case m_forgotPasswordClicked - case m_resetPasswordClicked__success_success(Parameter) + case m_resetPasswordClicked + case m_resetPassword__success_success(Parameter) static func compareParameters(lhs: MethodType, rhs: MethodType, matcher: Matcher) -> Matcher.ComparisonResult { switch (lhs, rhs) { @@ -575,15 +596,24 @@ open class AuthorizationAnalyticsMock: AuthorizationAnalytics, Mock { results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsMethod, rhs: rhsMethod, with: matcher), lhsMethod, rhsMethod, "method")) return Matcher.ComparisonResult(results) - case (.m_signUpClicked, .m_signUpClicked): return .match + case (.m_registerClicked, .m_registerClicked): return .match + + case (.m_signInClicked, .m_signInClicked): return .match + + case (.m_userSignInClicked, .m_userSignInClicked): return .match case (.m_createAccountClicked, .m_createAccountClicked): return .match - case (.m_registrationSuccess, .m_registrationSuccess): return .match + case (.m_registrationSuccess__method_method(let lhsMethod), .m_registrationSuccess__method_method(let rhsMethod)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsMethod, rhs: rhsMethod, with: matcher), lhsMethod, rhsMethod, "method")) + return Matcher.ComparisonResult(results) case (.m_forgotPasswordClicked, .m_forgotPasswordClicked): return .match - case (.m_resetPasswordClicked__success_success(let lhsSuccess), .m_resetPasswordClicked__success_success(let rhsSuccess)): + case (.m_resetPasswordClicked, .m_resetPasswordClicked): return .match + + case (.m_resetPassword__success_success(let lhsSuccess), .m_resetPassword__success_success(let rhsSuccess)): var results: [Matcher.ParameterComparisonResult] = [] results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsSuccess, rhs: rhsSuccess, with: matcher), lhsSuccess, rhsSuccess, "success")) return Matcher.ComparisonResult(results) @@ -595,22 +625,28 @@ open class AuthorizationAnalyticsMock: AuthorizationAnalytics, Mock { switch self { case let .m_identify__id_idusername_usernameemail_email(p0, p1, p2): return p0.intValue + p1.intValue + p2.intValue case let .m_userLogin__method_method(p0): return p0.intValue - case .m_signUpClicked: return 0 + case .m_registerClicked: return 0 + case .m_signInClicked: return 0 + case .m_userSignInClicked: return 0 case .m_createAccountClicked: return 0 - case .m_registrationSuccess: return 0 + case let .m_registrationSuccess__method_method(p0): return p0.intValue case .m_forgotPasswordClicked: return 0 - case let .m_resetPasswordClicked__success_success(p0): return p0.intValue + case .m_resetPasswordClicked: return 0 + case let .m_resetPassword__success_success(p0): return p0.intValue } } func assertionName() -> String { switch self { case .m_identify__id_idusername_usernameemail_email: return ".identify(id:username:email:)" case .m_userLogin__method_method: return ".userLogin(method:)" - case .m_signUpClicked: return ".signUpClicked()" + case .m_registerClicked: return ".registerClicked()" + case .m_signInClicked: return ".signInClicked()" + case .m_userSignInClicked: return ".userSignInClicked()" case .m_createAccountClicked: return ".createAccountClicked()" - case .m_registrationSuccess: return ".registrationSuccess()" + case .m_registrationSuccess__method_method: return ".registrationSuccess(method:)" case .m_forgotPasswordClicked: return ".forgotPasswordClicked()" - case .m_resetPasswordClicked__success_success: return ".resetPasswordClicked(success:)" + case .m_resetPasswordClicked: return ".resetPasswordClicked()" + case .m_resetPassword__success_success: return ".resetPassword(success:)" } } } @@ -631,11 +667,14 @@ open class AuthorizationAnalyticsMock: AuthorizationAnalytics, Mock { public static func identify(id: Parameter, username: Parameter, email: Parameter) -> Verify { return Verify(method: .m_identify__id_idusername_usernameemail_email(`id`, `username`, `email`))} public static func userLogin(method: Parameter) -> Verify { return Verify(method: .m_userLogin__method_method(`method`))} - public static func signUpClicked() -> Verify { return Verify(method: .m_signUpClicked)} + public static func registerClicked() -> Verify { return Verify(method: .m_registerClicked)} + public static func signInClicked() -> Verify { return Verify(method: .m_signInClicked)} + public static func userSignInClicked() -> Verify { return Verify(method: .m_userSignInClicked)} public static func createAccountClicked() -> Verify { return Verify(method: .m_createAccountClicked)} - public static func registrationSuccess() -> Verify { return Verify(method: .m_registrationSuccess)} + public static func registrationSuccess(method: Parameter) -> Verify { return Verify(method: .m_registrationSuccess__method_method(`method`))} public static func forgotPasswordClicked() -> Verify { return Verify(method: .m_forgotPasswordClicked)} - public static func resetPasswordClicked(success: Parameter) -> Verify { return Verify(method: .m_resetPasswordClicked__success_success(`success`))} + public static func resetPasswordClicked() -> Verify { return Verify(method: .m_resetPasswordClicked)} + public static func resetPassword(success: Parameter) -> Verify { return Verify(method: .m_resetPassword__success_success(`success`))} } public struct Perform { @@ -648,20 +687,29 @@ open class AuthorizationAnalyticsMock: AuthorizationAnalytics, Mock { public static func userLogin(method: Parameter, perform: @escaping (AuthMethod) -> Void) -> Perform { return Perform(method: .m_userLogin__method_method(`method`), performs: perform) } - public static func signUpClicked(perform: @escaping () -> Void) -> Perform { - return Perform(method: .m_signUpClicked, performs: perform) + public static func registerClicked(perform: @escaping () -> Void) -> Perform { + return Perform(method: .m_registerClicked, performs: perform) + } + public static func signInClicked(perform: @escaping () -> Void) -> Perform { + return Perform(method: .m_signInClicked, performs: perform) + } + public static func userSignInClicked(perform: @escaping () -> Void) -> Perform { + return Perform(method: .m_userSignInClicked, performs: perform) } public static func createAccountClicked(perform: @escaping () -> Void) -> Perform { return Perform(method: .m_createAccountClicked, performs: perform) } - public static func registrationSuccess(perform: @escaping () -> Void) -> Perform { - return Perform(method: .m_registrationSuccess, performs: perform) + public static func registrationSuccess(method: Parameter, perform: @escaping (String) -> Void) -> Perform { + return Perform(method: .m_registrationSuccess__method_method(`method`), performs: perform) } public static func forgotPasswordClicked(perform: @escaping () -> Void) -> Perform { return Perform(method: .m_forgotPasswordClicked, performs: perform) } - public static func resetPasswordClicked(success: Parameter, perform: @escaping (Bool) -> Void) -> Perform { - return Perform(method: .m_resetPasswordClicked__success_success(`success`), performs: perform) + public static func resetPasswordClicked(perform: @escaping () -> Void) -> Perform { + return Perform(method: .m_resetPasswordClicked, performs: perform) + } + public static func resetPassword(success: Parameter, perform: @escaping (Bool) -> Void) -> Perform { + return Perform(method: .m_resetPassword__success_success(`success`), performs: perform) } } @@ -1858,6 +1906,281 @@ open class ConnectivityProtocolMock: ConnectivityProtocol, Mock { } } +// MARK: - CoreAnalytics + +open class CoreAnalyticsMock: CoreAnalytics, Mock { + public init(sequencing sequencingPolicy: SequencingPolicy = .lastWrittenResolvedFirst, stubbing stubbingPolicy: StubbingPolicy = .wrap, file: StaticString = #file, line: UInt = #line) { + SwiftyMockyTestObserver.setup() + self.sequencingPolicy = sequencingPolicy + self.stubbingPolicy = stubbingPolicy + self.file = file + self.line = line + } + + var matcher: Matcher = Matcher.default + var stubbingPolicy: StubbingPolicy = .wrap + var sequencingPolicy: SequencingPolicy = .lastWrittenResolvedFirst + + private var queue = DispatchQueue(label: "com.swiftymocky.invocations", qos: .userInteractive) + private var invocations: [MethodType] = [] + private var methodReturnValues: [Given] = [] + private var methodPerformValues: [Perform] = [] + private var file: StaticString? + private var line: UInt? + + public typealias PropertyStub = Given + public typealias MethodStub = Given + public typealias SubscriptStub = Given + + /// Convenience method - call setupMock() to extend debug information when failure occurs + public func setupMock(file: StaticString = #file, line: UInt = #line) { + self.file = file + self.line = line + } + + /// Clear mock internals. You can specify what to reset (invocations aka verify, givens or performs) or leave it empty to clear all mock internals + public func resetMock(_ scopes: MockScope...) { + let scopes: [MockScope] = scopes.isEmpty ? [.invocation, .given, .perform] : scopes + if scopes.contains(.invocation) { invocations = [] } + if scopes.contains(.given) { methodReturnValues = [] } + if scopes.contains(.perform) { methodPerformValues = [] } + } + + + + + + open func trackEvent(_ event: AnalyticsEvent, parameters: [String: Any]?) { + addInvocation(.m_trackEvent__eventparameters_parameters(Parameter.value(`event`), Parameter<[String: Any]?>.value(`parameters`))) + let perform = methodPerformValue(.m_trackEvent__eventparameters_parameters(Parameter.value(`event`), Parameter<[String: Any]?>.value(`parameters`))) as? (AnalyticsEvent, [String: Any]?) -> Void + perform?(`event`, `parameters`) + } + + open func trackEvent(_ event: AnalyticsEvent, biValue: EventBIValue, parameters: [String: Any]?) { + addInvocation(.m_trackEvent__eventbiValue_biValueparameters_parameters(Parameter.value(`event`), Parameter.value(`biValue`), Parameter<[String: Any]?>.value(`parameters`))) + let perform = methodPerformValue(.m_trackEvent__eventbiValue_biValueparameters_parameters(Parameter.value(`event`), Parameter.value(`biValue`), Parameter<[String: Any]?>.value(`parameters`))) as? (AnalyticsEvent, EventBIValue, [String: Any]?) -> Void + perform?(`event`, `biValue`, `parameters`) + } + + open func appreview(_ event: AnalyticsEvent, biValue: EventBIValue, action: String?, rating: Int?) { + addInvocation(.m_appreview__eventbiValue_biValueaction_actionrating_rating(Parameter.value(`event`), Parameter.value(`biValue`), Parameter.value(`action`), Parameter.value(`rating`))) + let perform = methodPerformValue(.m_appreview__eventbiValue_biValueaction_actionrating_rating(Parameter.value(`event`), Parameter.value(`biValue`), Parameter.value(`action`), Parameter.value(`rating`))) as? (AnalyticsEvent, EventBIValue, String?, Int?) -> Void + perform?(`event`, `biValue`, `action`, `rating`) + } + + open func videoQualityChanged(_ event: AnalyticsEvent, bivalue: EventBIValue, value: String, oldValue: String) { + addInvocation(.m_videoQualityChanged__eventbivalue_bivaluevalue_valueoldValue_oldValue(Parameter.value(`event`), Parameter.value(`bivalue`), Parameter.value(`value`), Parameter.value(`oldValue`))) + let perform = methodPerformValue(.m_videoQualityChanged__eventbivalue_bivaluevalue_valueoldValue_oldValue(Parameter.value(`event`), Parameter.value(`bivalue`), Parameter.value(`value`), Parameter.value(`oldValue`))) as? (AnalyticsEvent, EventBIValue, String, String) -> Void + perform?(`event`, `bivalue`, `value`, `oldValue`) + } + + open func trackEvent(_ event: AnalyticsEvent) { + addInvocation(.m_trackEvent__event(Parameter.value(`event`))) + let perform = methodPerformValue(.m_trackEvent__event(Parameter.value(`event`))) as? (AnalyticsEvent) -> Void + perform?(`event`) + } + + open func trackEvent(_ event: AnalyticsEvent, biValue: EventBIValue) { + addInvocation(.m_trackEvent__eventbiValue_biValue(Parameter.value(`event`), Parameter.value(`biValue`))) + let perform = methodPerformValue(.m_trackEvent__eventbiValue_biValue(Parameter.value(`event`), Parameter.value(`biValue`))) as? (AnalyticsEvent, EventBIValue) -> Void + perform?(`event`, `biValue`) + } + + + fileprivate enum MethodType { + case m_trackEvent__eventparameters_parameters(Parameter, Parameter<[String: Any]?>) + case m_trackEvent__eventbiValue_biValueparameters_parameters(Parameter, Parameter, Parameter<[String: Any]?>) + case m_appreview__eventbiValue_biValueaction_actionrating_rating(Parameter, Parameter, Parameter, Parameter) + case m_videoQualityChanged__eventbivalue_bivaluevalue_valueoldValue_oldValue(Parameter, Parameter, Parameter, Parameter) + case m_trackEvent__event(Parameter) + case m_trackEvent__eventbiValue_biValue(Parameter, Parameter) + + static func compareParameters(lhs: MethodType, rhs: MethodType, matcher: Matcher) -> Matcher.ComparisonResult { + switch (lhs, rhs) { + case (.m_trackEvent__eventparameters_parameters(let lhsEvent, let lhsParameters), .m_trackEvent__eventparameters_parameters(let rhsEvent, let rhsParameters)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsEvent, rhs: rhsEvent, with: matcher), lhsEvent, rhsEvent, "_ event")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsParameters, rhs: rhsParameters, with: matcher), lhsParameters, rhsParameters, "parameters")) + return Matcher.ComparisonResult(results) + + case (.m_trackEvent__eventbiValue_biValueparameters_parameters(let lhsEvent, let lhsBivalue, let lhsParameters), .m_trackEvent__eventbiValue_biValueparameters_parameters(let rhsEvent, let rhsBivalue, let rhsParameters)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsEvent, rhs: rhsEvent, with: matcher), lhsEvent, rhsEvent, "_ event")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsBivalue, rhs: rhsBivalue, with: matcher), lhsBivalue, rhsBivalue, "biValue")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsParameters, rhs: rhsParameters, with: matcher), lhsParameters, rhsParameters, "parameters")) + return Matcher.ComparisonResult(results) + + case (.m_appreview__eventbiValue_biValueaction_actionrating_rating(let lhsEvent, let lhsBivalue, let lhsAction, let lhsRating), .m_appreview__eventbiValue_biValueaction_actionrating_rating(let rhsEvent, let rhsBivalue, let rhsAction, let rhsRating)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsEvent, rhs: rhsEvent, with: matcher), lhsEvent, rhsEvent, "_ event")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsBivalue, rhs: rhsBivalue, with: matcher), lhsBivalue, rhsBivalue, "biValue")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsAction, rhs: rhsAction, with: matcher), lhsAction, rhsAction, "action")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsRating, rhs: rhsRating, with: matcher), lhsRating, rhsRating, "rating")) + return Matcher.ComparisonResult(results) + + case (.m_videoQualityChanged__eventbivalue_bivaluevalue_valueoldValue_oldValue(let lhsEvent, let lhsBivalue, let lhsValue, let lhsOldvalue), .m_videoQualityChanged__eventbivalue_bivaluevalue_valueoldValue_oldValue(let rhsEvent, let rhsBivalue, let rhsValue, let rhsOldvalue)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsEvent, rhs: rhsEvent, with: matcher), lhsEvent, rhsEvent, "_ event")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsBivalue, rhs: rhsBivalue, with: matcher), lhsBivalue, rhsBivalue, "bivalue")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsValue, rhs: rhsValue, with: matcher), lhsValue, rhsValue, "value")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsOldvalue, rhs: rhsOldvalue, with: matcher), lhsOldvalue, rhsOldvalue, "oldValue")) + return Matcher.ComparisonResult(results) + + case (.m_trackEvent__event(let lhsEvent), .m_trackEvent__event(let rhsEvent)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsEvent, rhs: rhsEvent, with: matcher), lhsEvent, rhsEvent, "_ event")) + return Matcher.ComparisonResult(results) + + case (.m_trackEvent__eventbiValue_biValue(let lhsEvent, let lhsBivalue), .m_trackEvent__eventbiValue_biValue(let rhsEvent, let rhsBivalue)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsEvent, rhs: rhsEvent, with: matcher), lhsEvent, rhsEvent, "_ event")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsBivalue, rhs: rhsBivalue, with: matcher), lhsBivalue, rhsBivalue, "biValue")) + return Matcher.ComparisonResult(results) + default: return .none + } + } + + func intValue() -> Int { + switch self { + case let .m_trackEvent__eventparameters_parameters(p0, p1): return p0.intValue + p1.intValue + case let .m_trackEvent__eventbiValue_biValueparameters_parameters(p0, p1, p2): return p0.intValue + p1.intValue + p2.intValue + case let .m_appreview__eventbiValue_biValueaction_actionrating_rating(p0, p1, p2, p3): return p0.intValue + p1.intValue + p2.intValue + p3.intValue + case let .m_videoQualityChanged__eventbivalue_bivaluevalue_valueoldValue_oldValue(p0, p1, p2, p3): return p0.intValue + p1.intValue + p2.intValue + p3.intValue + case let .m_trackEvent__event(p0): return p0.intValue + case let .m_trackEvent__eventbiValue_biValue(p0, p1): return p0.intValue + p1.intValue + } + } + func assertionName() -> String { + switch self { + case .m_trackEvent__eventparameters_parameters: return ".trackEvent(_:parameters:)" + case .m_trackEvent__eventbiValue_biValueparameters_parameters: return ".trackEvent(_:biValue:parameters:)" + case .m_appreview__eventbiValue_biValueaction_actionrating_rating: return ".appreview(_:biValue:action:rating:)" + case .m_videoQualityChanged__eventbivalue_bivaluevalue_valueoldValue_oldValue: return ".videoQualityChanged(_:bivalue:value:oldValue:)" + case .m_trackEvent__event: return ".trackEvent(_:)" + case .m_trackEvent__eventbiValue_biValue: return ".trackEvent(_:biValue:)" + } + } + } + + open class Given: StubbedMethod { + fileprivate var method: MethodType + + private init(method: MethodType, products: [StubProduct]) { + self.method = method + super.init(products) + } + + + } + + public struct Verify { + fileprivate var method: MethodType + + public static func trackEvent(_ event: Parameter, parameters: Parameter<[String: Any]?>) -> Verify { return Verify(method: .m_trackEvent__eventparameters_parameters(`event`, `parameters`))} + public static func trackEvent(_ event: Parameter, biValue: Parameter, parameters: Parameter<[String: Any]?>) -> Verify { return Verify(method: .m_trackEvent__eventbiValue_biValueparameters_parameters(`event`, `biValue`, `parameters`))} + public static func appreview(_ event: Parameter, biValue: Parameter, action: Parameter, rating: Parameter) -> Verify { return Verify(method: .m_appreview__eventbiValue_biValueaction_actionrating_rating(`event`, `biValue`, `action`, `rating`))} + public static func videoQualityChanged(_ event: Parameter, bivalue: Parameter, value: Parameter, oldValue: Parameter) -> Verify { return Verify(method: .m_videoQualityChanged__eventbivalue_bivaluevalue_valueoldValue_oldValue(`event`, `bivalue`, `value`, `oldValue`))} + public static func trackEvent(_ event: Parameter) -> Verify { return Verify(method: .m_trackEvent__event(`event`))} + public static func trackEvent(_ event: Parameter, biValue: Parameter) -> Verify { return Verify(method: .m_trackEvent__eventbiValue_biValue(`event`, `biValue`))} + } + + public struct Perform { + fileprivate var method: MethodType + var performs: Any + + public static func trackEvent(_ event: Parameter, parameters: Parameter<[String: Any]?>, perform: @escaping (AnalyticsEvent, [String: Any]?) -> Void) -> Perform { + return Perform(method: .m_trackEvent__eventparameters_parameters(`event`, `parameters`), performs: perform) + } + public static func trackEvent(_ event: Parameter, biValue: Parameter, parameters: Parameter<[String: Any]?>, perform: @escaping (AnalyticsEvent, EventBIValue, [String: Any]?) -> Void) -> Perform { + return Perform(method: .m_trackEvent__eventbiValue_biValueparameters_parameters(`event`, `biValue`, `parameters`), performs: perform) + } + public static func appreview(_ event: Parameter, biValue: Parameter, action: Parameter, rating: Parameter, perform: @escaping (AnalyticsEvent, EventBIValue, String?, Int?) -> Void) -> Perform { + return Perform(method: .m_appreview__eventbiValue_biValueaction_actionrating_rating(`event`, `biValue`, `action`, `rating`), performs: perform) + } + public static func videoQualityChanged(_ event: Parameter, bivalue: Parameter, value: Parameter, oldValue: Parameter, perform: @escaping (AnalyticsEvent, EventBIValue, String, String) -> Void) -> Perform { + return Perform(method: .m_videoQualityChanged__eventbivalue_bivaluevalue_valueoldValue_oldValue(`event`, `bivalue`, `value`, `oldValue`), performs: perform) + } + public static func trackEvent(_ event: Parameter, perform: @escaping (AnalyticsEvent) -> Void) -> Perform { + return Perform(method: .m_trackEvent__event(`event`), performs: perform) + } + public static func trackEvent(_ event: Parameter, biValue: Parameter, perform: @escaping (AnalyticsEvent, EventBIValue) -> Void) -> Perform { + return Perform(method: .m_trackEvent__eventbiValue_biValue(`event`, `biValue`), performs: perform) + } + } + + public func given(_ method: Given) { + methodReturnValues.append(method) + } + + public func perform(_ method: Perform) { + methodPerformValues.append(method) + methodPerformValues.sort { $0.method.intValue() < $1.method.intValue() } + } + + public func verify(_ method: Verify, count: Count = Count.moreOrEqual(to: 1), file: StaticString = #file, line: UInt = #line) { + let fullMatches = matchingCalls(method, file: file, line: line) + let success = count.matches(fullMatches) + let assertionName = method.method.assertionName() + let feedback: String = { + guard !success else { return "" } + return Utils.closestCallsMessage( + for: self.invocations.map { invocation in + matcher.set(file: file, line: line) + defer { matcher.clearFileAndLine() } + return MethodType.compareParameters(lhs: invocation, rhs: method.method, matcher: matcher) + }, + name: assertionName + ) + }() + MockyAssert(success, "Expected: \(count) invocations of `\(assertionName)`, but was: \(fullMatches).\(feedback)", file: file, line: line) + } + + private func addInvocation(_ call: MethodType) { + self.queue.sync { invocations.append(call) } + } + private func methodReturnValue(_ method: MethodType) throws -> StubProduct { + matcher.set(file: self.file, line: self.line) + defer { matcher.clearFileAndLine() } + let candidates = sequencingPolicy.sorted(methodReturnValues, by: { $0.method.intValue() > $1.method.intValue() }) + let matched = candidates.first(where: { $0.isValid && MethodType.compareParameters(lhs: $0.method, rhs: method, matcher: matcher).isFullMatch }) + guard let product = matched?.getProduct(policy: self.stubbingPolicy) else { throw MockError.notStubed } + return product + } + private func methodPerformValue(_ method: MethodType) -> Any? { + matcher.set(file: self.file, line: self.line) + defer { matcher.clearFileAndLine() } + let matched = methodPerformValues.reversed().first { MethodType.compareParameters(lhs: $0.method, rhs: method, matcher: matcher).isFullMatch } + return matched?.performs + } + private func matchingCalls(_ method: MethodType, file: StaticString?, line: UInt?) -> [MethodType] { + matcher.set(file: file ?? self.file, line: line ?? self.line) + defer { matcher.clearFileAndLine() } + return invocations.filter { MethodType.compareParameters(lhs: $0, rhs: method, matcher: matcher).isFullMatch } + } + private func matchingCalls(_ method: Verify, file: StaticString?, line: UInt?) -> Int { + return matchingCalls(method.method, file: file, line: line).count + } + private func givenGetterValue(_ method: MethodType, _ message: String) -> T { + do { + return try methodReturnValue(method).casted() + } catch { + onFatalFailure(message) + Failure(message) + } + } + private func optionalGivenGetterValue(_ method: MethodType, _ message: String) -> T? { + do { + return try methodReturnValue(method).casted() + } catch { + return nil + } + } + private func onFatalFailure(_ message: String) { + guard let file = self.file, let line = self.line else { return } // Let if fail if cannot handle gratefully + SwiftyMockyTestObserver.handleFatalError(message: message, file: file, line: line) + } +} + // MARK: - DownloadManagerProtocol open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { diff --git a/Core/Core.xcodeproj/project.pbxproj b/Core/Core.xcodeproj/project.pbxproj index 0f3115b5f..3da8c0ff2 100644 --- a/Core/Core.xcodeproj/project.pbxproj +++ b/Core/Core.xcodeproj/project.pbxproj @@ -127,6 +127,7 @@ 07E0939F2B308D2800F1E4B2 /* Data_Certificate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 07E0939E2B308D2800F1E4B2 /* Data_Certificate.swift */; }; 141F1D302B7328D4009E81EB /* WebviewCookiesUpdateProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 141F1D2F2B7328D4009E81EB /* WebviewCookiesUpdateProtocol.swift */; }; 142EDD6C2B831D1400F9F320 /* BranchSDK in Frameworks */ = {isa = PBXBuildFile; productRef = 142EDD6B2B831D1400F9F320 /* BranchSDK */; }; + 14769D3C2B9822EE00AB36D4 /* CoreAnalytics.swift in Sources */ = {isa = PBXBuildFile; fileRef = 14769D3B2B9822EE00AB36D4 /* CoreAnalytics.swift */; }; A51CDBE72B6D21F2009B6D4E /* SegmentConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = A51CDBE62B6D21F2009B6D4E /* SegmentConfig.swift */; }; A53A32352B233DEC005FE38A /* ThemeConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = A53A32342B233DEC005FE38A /* ThemeConfig.swift */; }; A595689B2B6173DF00ED4F90 /* BranchConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = A595689A2B6173DF00ED4F90 /* BranchConfig.swift */; }; @@ -301,6 +302,7 @@ 07E0939E2B308D2800F1E4B2 /* Data_Certificate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Data_Certificate.swift; sourceTree = ""; }; 0E13E9173C9C4CFC19F8B6F2 /* Pods-App-Core.debugstage.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-Core.debugstage.xcconfig"; path = "Target Support Files/Pods-App-Core/Pods-App-Core.debugstage.xcconfig"; sourceTree = ""; }; 141F1D2F2B7328D4009E81EB /* WebviewCookiesUpdateProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebviewCookiesUpdateProtocol.swift; sourceTree = ""; }; + 14769D3B2B9822EE00AB36D4 /* CoreAnalytics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoreAnalytics.swift; sourceTree = ""; }; 1A154A95AF4EE85A4A1C083B /* Pods-App-Core.releasedev.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-Core.releasedev.xcconfig"; path = "Target Support Files/Pods-App-Core/Pods-App-Core.releasedev.xcconfig"; sourceTree = ""; }; 2B7E6FE7843FC4CF2BFA712D /* Pods-App-Core.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-Core.debug.xcconfig"; path = "Target Support Files/Pods-App-Core/Pods-App-Core.debug.xcconfig"; sourceTree = ""; }; 349B90CD6579F7B8D257E515 /* Pods_App_Core.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_App_Core.framework; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -620,6 +622,7 @@ 0770DE0A28D07831006D8A5D /* Core */ = { isa = PBXGroup; children = ( + 14769D3A2B9822D900AB36D4 /* Analytics */, BA8FA65F2AD5973500EA029A /* Providers */, 027BD3A12909470F00392132 /* AvoidingHelpers */, 0770DE5528D0B142006D8A5D /* SwiftGen */, @@ -711,6 +714,14 @@ path = Base; sourceTree = ""; }; + 14769D3A2B9822D900AB36D4 /* Analytics */ = { + isa = PBXGroup; + children = ( + 14769D3B2B9822EE00AB36D4 /* CoreAnalytics.swift */, + ); + path = Analytics; + sourceTree = ""; + }; BA30427C2B20B235009B64B7 /* SocialAuth */ = { isa = PBXGroup; children = ( @@ -1068,6 +1079,7 @@ 070019AE28F701B200D5FC78 /* Certificate.swift in Sources */, BA4AFB442B6A5AF100A21367 /* CheckBoxView.swift in Sources */, 076F297F2A1F80C800967E7D /* Pagination.swift in Sources */, + 14769D3C2B9822EE00AB36D4 /* CoreAnalytics.swift in Sources */, 02AFCC1A2AEFDC18000360F0 /* ThirdPartyMailer.swift in Sources */, 0770DE5F28D0B22C006D8A5D /* Strings.swift in Sources */, BA981BCE2B8F5C49005707C2 /* Sequence+Extensions.swift in Sources */, diff --git a/Core/Core/Analytics/CoreAnalytics.swift b/Core/Core/Analytics/CoreAnalytics.swift new file mode 100644 index 000000000..57f7e49d4 --- /dev/null +++ b/Core/Core/Analytics/CoreAnalytics.swift @@ -0,0 +1,246 @@ +// +// CoreAnalytics.swift +// Core +// +// Created by Saeed Bashir on 3/6/24. +// + +import Foundation + +//sourcery: AutoMockable +public protocol CoreAnalytics { + func trackEvent(_ event: AnalyticsEvent, parameters: [String: Any]?) + func trackEvent(_ event: AnalyticsEvent, biValue: EventBIValue, parameters: [String: Any]?) + func appreview(_ event: AnalyticsEvent, biValue: EventBIValue, action: String?, rating: Int?) + func videoQualityChanged( + _ event: AnalyticsEvent, + bivalue: EventBIValue, + value: String, + oldValue: String + ) +} + +public extension CoreAnalytics { + func trackEvent(_ event: AnalyticsEvent) { + trackEvent(event, parameters: nil) + } + + func trackEvent(_ event: AnalyticsEvent, biValue: EventBIValue) { + trackEvent(event, biValue: biValue, parameters: nil) + } +} + +#if DEBUG +public class CoreAnalyticsMock: CoreAnalytics { + public init() {} + public func trackEvent(_ event: AnalyticsEvent, parameters: [String: Any]? = nil) {} + public func trackEvent(_ event: AnalyticsEvent, biValue: EventBIValue, parameters: [String: Any]?) {} + public func appreview(_ event: AnalyticsEvent, biValue: EventBIValue, action: String? = nil, rating: Int? = 0) {} + public func videoQualityChanged( + _ event: AnalyticsEvent, + bivalue: EventBIValue, + value: String, + oldValue: String + ) {} +} +#endif + +public enum AnalyticsEvent: String { + case launch = "Launch" + case logistrationCoursesSearch = "Logistration:Courses Search" + case logistrationExploreAllCourses = "Logistration:Explore All Courses" + case userLogin = "Logistration:Sign In Success" + case registerClicked = "Logistration:Register Clicked" + case signInClicked = "Logistration:Sign In Clicked" + case userSignInClicked = "Logistration:User Sign In Clicked" + case createAccountClicked = "Logistration:Create Account Clicked" + case registrationSuccess = "Logistration:Register Success" + case userLogout = "Profile:Logged Out" + case userLogoutClicked = "Profile:Logout Clicked" + case forgotPasswordClicked = "Logistration:Forgot Password Clicked" + case resetPasswordClicked = "Logistration:Reset Password Clicked" + case resetPasswordSuccess = "Logistration:Reset Password Success" + case mainDiscoveryTabClicked = "MainDashboard:Discover" + case mainDashboardTabClicked = "MainDashboard:My Courses" + case mainProgramsTabClicked = "MainDashboard:My Programs" + case mainProfileTabClicked = "MainDashboard:Profile" + case discoverySearchBarClicked = "Discovery:Search Bar Clicked" + case discoveryCoursesSearch = "Discovery:Courses Search" + case discoveryCourseClicked = "Discovery:Course Clicked" + case discoveryProgramInfo = "Discovery:Program Info" + case dashboardCourseClicked = "Course:Dashboard" + case profileEditClicked = "Profile:Edit Clicked" + case profileSwitch = "Profile:Switch Profile" + case profileWifiToggle = "Profile:Wifi Toggle" + case profileEditDoneClicked = "Profile:Edit Done Clicked" + case profileDeleteAccountClicked = "Profile:Delete Account Clicked" + case profileUserDeleteAccountClicked = "Profile:User Delete Account Clicked" + case profileDeleteAccountSuccess = "Profile:Delete Account Success" + case videoStreamQualityChanged = "Video:Streaming Quality Changed" + case videoDownloadQualityChanged = "Video:Download Quality Changed" + case profileVideoSettingsClicked = "Profile:Video Setting Clicked" + case privacyPolicyClicked = "Profile:Privacy Policy Clicked" + case cookiePolicyClicked = "Profile:Cookie Policy Clicked" + case emailSupportClicked = "Profile:Contact Support Clicked" + case faqClicked = "Profile:FAQ Clicked" + case tosClicked = "Profile:Terms of Use Clicked" + case dataSellClicked = "Profile:Data Sell Clicked" + case courseEnrollClicked = "Discovery:Course Enroll Clicked" + case courseEnrollSuccess = "Discovery:Course Enroll Success" + case externalLinkOpenAlert = "External:Link Opening Alert" + case externalLinkOpenAlertAction = "External:Link Opening Alert Action" + case viewCourseClicked = "Discovery:Course Info" + case resumeCourseClicked = "Course:Resume Course Clicked" + case sequentialClicked = "Course:Sequential Clicked" + case verticalClicked = "Course:Unit Detail" + case nextBlockClicked = "Course:Next Block Clicked" + case prevBlockClicked = "Course:Prev Block Clicked" + case finishVerticalClicked = "Course:Unit Finished Clicked" + case finishVerticalNextSectionClicked = "Course:Finish Unit Next Unit Clicked" + case finishVerticalBackToOutlineClicked = "Course:Unit Finish Back To Outline Clicked" + case courseOutlineCourseTabClicked = "Course:Home Tab" + case courseOutlineVideosTabClicked = "Course:Videos Tab" + case courseOutlineDatesTabClicked = "Course:Dates Tab" + case courseOutlineDiscussionTabClicked = "Course:Discussion Tab" + case courseOutlineHandoutsTabClicked = "Course:Handouts Tab" + case datesComponentClicked = "Dates:Course Component Clicked" + case plsBannerViewed = "PLS:Banner Viewed" + case plsShiftDatesClicked = "PLS:Shift Button Clicked" + case plsShiftDatesSuccess = "PLS:Shift Dates Success" + case courseViewCertificateClicked = "Course:View Certificate Clicked" + case bulkDownloadVideosToggle = "Video:Bulk Download Toggle" + case bulkDownloadVideosSubsection = "Video:Bulk Download Subsection" + case bulkDeleteVideosSubsection = "Videos:Delete Subsection Videos" + case discussionAllPostsClicked = "Discussion:All Posts Clicked" + case discussionFollowingClicked = "Discussion:Following Posts Clicked" + case discussionTopicClicked = "Discussion:Topic Clicked" + case appreviewPopupViewed = "AppReviews:Rating Dialog Viewed" + case appreviewPopupAction = "AppReviews:Rating Dialog Action" + case courseAnnouncement = "Course:Announcements" + case courseHandouts = "Course:Handouts" + case whatnewPopup = "WhatsNew:Pop up Viewed" + case whatnewDone = "WhatsNew:Done" + case whatnewClose = "WhatsNew:Close" +} + +public enum EventBIValue: String { + case launch = "edx.bi.app.launch" + case logistrationCoursesSearch = "edx.bi.app.logistration.courses_search" + case logistrationExploreAllCourses = "edx.bi.app.logistration.explore.all.courses" + case userLogin = "edx.bi.app.user.signin.success" + case signInClicked = "edx.bi.app.logistration.signin.clicked" + case registerClicked = "edx.bi.app.logistration.register.clicked" + case registrationSuccess = "edx.bi.app.user.register.success" + case userSignInClicked = "edx.bi.app.logistration.user.signin.clicked" + case createAccountClicked = "edx.bi.app.logistration.user.create_account.clicked" + case forgotPasswordClicked = "edx.bi.app.logistration.forgot_password.clicked" + case resetPasswordClicked = "edx.bi.app.user.reset_password.clicked" + case resetPasswordSuccess = "edx.bi.app.user.reset_password.success" + case courseEnrollClicked = "edx.bi.app.course.enroll.clicked" + case courseEnrollSuccess = "edx.bi.app.course.enroll.success" + case externalLinkOpenAlert = "edx.bi.app.discovery.external_link.opening.alert" + case externalLinkOpenAlertAction = "edx.bi.app.discovery.external_link.opening.alert_action" + case viewCourseClicked = "edx.bi.app.course.info" + case resumeCourseClicked = "edx.bi.app.course.resume_course.clicked" + case mainDiscoveryTabClicked = "edx.bi.app.main_dashboard.discover" + case mainDashboardTabClicked = "edx.bi.app.main_dashboard.my_course" + case mainProgramsTabClicked = "edx.bi.app.main_dashboard.my_program" + case mainProfileTabClicked = "edx.bi.app.main_dashboard.profile" + case profileEditClicked = "edx.bi.app.profile.edit.clicked" + case profileEditDoneClicked = "edx.bi.app.profile.edit_done.clicked" + case profileVideoSettingsClicked = "edx.bi.app.profile.video_setting.clicked" + case emailSupportClicked = "edx.bi.app.profile.email_support.clicked" + case faqClicked = "edx.bi.app.profile.faq.clicked" + case tosClicked = "edx.bi.app.profile.terms_of_use.clicked" + case dataSellClicked = "edx.bi.app.profile.do_not_sell_data.clicked" + case privacyPolicyClicked = "edx.bi.app.profile.privacy_policy.clicked" + case cookiePolicyClicked = "edx.bi.app.profile.cookie_policy.clicked" + case profileDeleteAccountClicked = "edx.bi.app.profile.delete_account.clicked" + case userLogout = "edx.bi.app.user.logout" + case datesComponentClicked = "edx.bi.app.coursedates.component.clicked" + case plsBannerViewed = "edx.bi.app.dates.pls_banner.viewed" + case plsShiftDatesClicked = "edx.bi.app.dates.pls_banner.shift_dates.clicked" + case plsShiftDatesSuccess = "edx.bi.app.dates.pls_banner.shift_dates.success" + case courseViewCertificateClicked = "edx.bi.app.course.view_certificate.clicked" + case bulkDownloadVideosToggle = "edx.bi.app.videos.download.toggle" + case bulkDownloadVideosSubsection = "edx.bi.video.subsection.bulkdownload" + case bulkDeleteVideosSubsection = "edx.bi.app.video.delete.subsection" + case dashboardCourseClicked = "edx.bi.app.course.dashboard" + case courseOutlineVideosTabClicked = "edx.bi.app.course.video_tab" + case courseOutlineDatesTabClicked = "edx.bi.app.course.dates_tab" + case courseOutlineDiscussionTabClicked = "edx.bi.app.course.discussion_tab" + case courseOutlineHandoutsTabClicked = "edx.bi.app.course.handouts_tab" + case verticalClicked = "edx.bi.app.course.unit_detail" + case nextBlockClicked = "edx.bi.app.course.next_block.clicked" + case prevBlockClicked = "bi.app.course.prev_block.clicked" + case sequentialClicked = "edx.bi.app.course.sequential.clicked" + case finishVerticalClicked = "edx.bi.app.course.unit_finished.clicked" + case finishVerticalNextSectionClicked = "edx.bi.app.course.finish_unit.next_unit.clicked" + case finishVerticalBackToOutlineClicked = "edx.bi.app.course.finish_unit.back_to_outline.clicked" + case discoverySearchBarClicked = "edx.bi.app.discovery.search_bar.clicked" + case discoveryCoursesSearch = "edx.bi.app.discovery.courses_search" + case discoveryCourseClicked = "edx.bi.app.discovery.course.clicked" + case discussionAllPostsClicked = "edx.bi.app.discussion.all_posts.clicked" + case discussionFollowingClicked = "edx.bi.app.discussion.following_posts.clicked" + case discussionTopicClicked = "edx.bi.app.discussion.topic.clicked" + case discoveryProgramInfo = "edx.bi.app.discovery.program_info" + case userLogoutClicked = "edx.bi.app.profile.logout.clicked" + case courseOutlineCourseTabClicked = "edx.bi.app.course.home_tab" + case appreviewPopupViewed = "edx.bi.app.app_reviews.rating_dialog.viewed" + case appreviewPopupAction = "edx.bi.app.app_reviews.rating_dialog.action" + case profileSwitch = "edx.bi.app.profile.switch_profile.clicked" + case profileWifiToggle = "edx.bi.app.profile.wifi_toggle" + case profileUserDeleteAccountClicked = "edx.bi.app.profile.user.delete_account.clicked" + case profileDeleteAccountSuccess = "edx.bi.app.profile.delete_account.success" + case videoStreamQualityChanged = "edx.bi.app.video.streaming_quality.changed" + case videoDownloadQualityChanged = "edx.bi.app.video.download_quality.changed" + case courseAnnouncement = "edx.bi.app.course.announcements" + case courseHandouts = "edx.bi.app.course.handouts" + case whatnewPopup = "edx.bi.app.whats_new.popup.viewed" + case whatnewDone = "edx.bi.app.whats_new.done" + case whatnewClose = "edx.bi.app.whats_new.close" +} + +public struct EventParamKey { + public static let courseID = "course_id" + public static let courseName = "course_name" + public static let topicID = "topic_id" + public static let topicName = "topic_name" + public static let blockID = "block_id" + public static let blockName = "block_name" + public static let unitID = "unit_id" + public static let unitName = "unit_name" + public static let method = "method" + public static let label = "label" + public static let coursesCount = "courses_count" + public static let force = "force" + public static let success = "success" + public static let category = "category" + public static let appVersion = "app_version" + public static let name = "name" + public static let link = "link" + public static let url = "url" + public static let screenName = "screen_name" + public static let alertAction = "alert_action" + public static let action = "action" + public static let searchQuery = "search_query" + public static let value = "value" + public static let oldValue = "old_value" + public static let rating = "rating" + public static let bannerType = "banner_type" + public static let courseSection = "course_section" + public static let courseSubsection = "course_subsection" + public static let noOfVideos = "number_of_videos" + public static let supported = "supported" + public static let conversion = "conversion" +} + +public struct EventCategory { + public static let appreviews = "app-reviews" + public static let whatsNew = "whats_new" + public static let courseDates = "course_dates" + public static let discovery = "discovery" + public static let profile = "profile" + public static let video = "video" + public static let course = "course" +} diff --git a/Core/Core/Data/Model/UserSettings.swift b/Core/Core/Data/Model/UserSettings.swift index ab113a74e..1b25e9d6c 100644 --- a/Core/Core/Data/Model/UserSettings.swift +++ b/Core/Core/Data/Model/UserSettings.swift @@ -28,11 +28,20 @@ public enum StreamingQuality: Codable { case low case medium case high + + public var value: String? { + return String(describing: self).components(separatedBy: "(").first + } } public enum DownloadQuality: Codable, CaseIterable { case auto - case low_360 - case medium_540 - case high_720 + case low + case medium + case high + + public var value: String? { + return String(describing: self).components(separatedBy: "(").first + } + } diff --git a/Core/Core/Domain/Model/CourseBlockModel.swift b/Core/Core/Domain/Model/CourseBlockModel.swift index 9e3eb0b11..3d8ae7a4f 100644 --- a/Core/Core/Domain/Model/CourseBlockModel.swift +++ b/Core/Core/Domain/Model/CourseBlockModel.swift @@ -258,15 +258,15 @@ public struct CourseBlockEncodedVideo { [mobileLow, mobileHigh, desktopMP4, fallback, hls] .first(where: { $0?.isDownloadable == true })? .flatMap { $0 } - case .high_720: + case .high: [desktopMP4, mobileHigh, mobileLow, fallback, hls] .first(where: { $0?.isDownloadable == true })? .flatMap { $0 } - case .medium_540: + case .medium: [mobileHigh, mobileLow, desktopMP4, fallback, hls] .first(where: { $0?.isDownloadable == true })? .flatMap { $0 } - case .low_360: + case .low: [mobileLow, mobileHigh, desktopMP4, fallback, hls] .first(where: { $0?.isDownloadable == true })? .flatMap { $0 } diff --git a/Core/Core/View/Base/AppReview/AppReviewView.swift b/Core/Core/View/Base/AppReview/AppReviewView.swift index c22765245..176c9707a 100644 --- a/Core/Core/View/Base/AppReview/AppReviewView.swift +++ b/Core/Core/View/Base/AppReview/AppReviewView.swift @@ -26,6 +26,7 @@ public struct AppReviewView: View { .ignoresSafeArea() .onTapGesture { presentationMode.wrappedValue.dismiss() + viewModel.trackAppReviewAction("dismissed") } if viewModel.showSelectMailClientView { SelectMailClientView(clients: viewModel.clients, onMailTapped: { client in @@ -59,10 +60,14 @@ public struct AppReviewView: View { Text(CoreLocalization.Review.notNow) .font(Theme.Fonts.labelLarge) .foregroundColor(Theme.Colors.accentColor) - .onTapGesture { presentationMode.wrappedValue.dismiss() } + .onTapGesture { + viewModel.trackAppReviewAction("dismissed") + presentationMode.wrappedValue.dismiss() + } AppReviewButton(type: .submit, action: { viewModel.reviewAction() + viewModel.trackAppReviewAction("submit") }, isActive: .constant(viewModel.rating != 0)) } @@ -99,10 +104,14 @@ public struct AppReviewView: View { Text(CoreLocalization.Review.notNow) .font(Theme.Fonts.labelLarge) .foregroundColor(Theme.Colors.accentColor) - .onTapGesture { presentationMode.wrappedValue.dismiss() } + .onTapGesture { + viewModel.trackAppReviewAction("dismissed") + presentationMode.wrappedValue.dismiss() + } AppReviewButton(type: .shareFeedback, action: { viewModel.writeFeedbackToMail() + viewModel.trackAppReviewAction("share_feedback") }, isActive: .constant(viewModel.feedback.count >= 3)) } @@ -111,12 +120,16 @@ public struct AppReviewView: View { Text(CoreLocalization.Review.notNow) .font(Theme.Fonts.labelLarge) .foregroundColor(Theme.Colors.accentColor) - .onTapGesture { presentationMode.wrappedValue.dismiss() } + .onTapGesture { + viewModel.trackAppReviewAction("dismissed") + presentationMode.wrappedValue.dismiss() + } AppReviewButton(type: .rateUs, action: { presentationMode.wrappedValue.dismiss() SKStoreReviewController.requestReviewInCurrentScene() viewModel.storage.lastReviewDate = Date() + viewModel.trackAppReviewAction("rate_app") }, isActive: .constant(true)) } } @@ -135,13 +148,22 @@ public struct AppReviewView: View { .shadow(color: Color.black.opacity(0.4), radius: 12, x: 0, y: 0) } } + .onFirstAppear { + viewModel.trackAppReviewViewed() + } } } #if DEBUG struct AppReviewView_Previews: PreviewProvider { static var previews: some View { - AppReviewView(viewModel: AppReviewViewModel(config: ConfigMock(), storage: CoreStorageMock())) + AppReviewView( + viewModel: AppReviewViewModel( + config: ConfigMock(), + storage: CoreStorageMock(), + analytics: CoreAnalyticsMock() + ) + ) } } #endif diff --git a/Core/Core/View/Base/AppReview/AppReviewViewModel.swift b/Core/Core/View/Base/AppReview/AppReviewViewModel.swift index a14f78345..69026248c 100644 --- a/Core/Core/View/Base/AppReview/AppReviewViewModel.swift +++ b/Core/Core/View/Base/AppReview/AppReviewViewModel.swift @@ -51,10 +51,12 @@ public class AppReviewViewModel: ObservableObject { private let config: ConfigProtocol var storage: CoreStorage + private let analytics: CoreAnalytics - public init(config: ConfigProtocol, storage: CoreStorage) { + public init(config: ConfigProtocol, storage: CoreStorage, analytics: CoreAnalytics) { self.config = config self.storage = storage + self.analytics = analytics } public func shouldShowRatingView() -> Bool { @@ -152,4 +154,12 @@ public class AppReviewViewModel: ObservableObject { return false } + + func trackAppReviewAction(_ action: String? = nil) { + analytics.appreview(.appreviewPopupAction, biValue: .appreviewPopupAction, action: action, rating: rating) + } + + func trackAppReviewViewed() { + analytics.appreview(.appreviewPopupViewed, biValue: .appreviewPopupViewed, action: nil, rating: rating) + } } diff --git a/Core/Core/View/Base/LogistrationBottomView.swift b/Core/Core/View/Base/LogistrationBottomView.swift index 6795c62ea..5f01cc98a 100644 --- a/Core/Core/View/Base/LogistrationBottomView.swift +++ b/Core/Core/View/Base/LogistrationBottomView.swift @@ -15,6 +15,10 @@ public enum LogistrationSourceScreen: Equatable { case discovery case courseDetail(String, String) case programDetails(String) + + public var value: String? { + return String(describing: self).components(separatedBy: "(").first + } } public enum LogistrationAction { diff --git a/Core/Core/View/Base/VideoDownloadQualityView.swift b/Core/Core/View/Base/VideoDownloadQualityView.swift index 7585d4c57..7482a8d0d 100644 --- a/Core/Core/View/Base/VideoDownloadQualityView.swift +++ b/Core/Core/View/Base/VideoDownloadQualityView.swift @@ -13,7 +13,7 @@ public final class VideoDownloadQualityViewModel: ObservableObject { var didSelect: ((DownloadQuality) -> Void)? let downloadQuality = DownloadQuality.allCases - + @Published var selectedDownloadQuality: DownloadQuality { willSet { if newValue != selectedDownloadQuality { @@ -32,10 +32,12 @@ public struct VideoDownloadQualityView: View { @StateObject private var viewModel: VideoDownloadQualityViewModel + private var analytics: CoreAnalytics public init( downloadQuality: DownloadQuality, - didSelect: ((DownloadQuality) -> Void)? + didSelect: ((DownloadQuality) -> Void)?, + analytics: CoreAnalytics ) { self._viewModel = StateObject( wrappedValue: .init( @@ -43,6 +45,7 @@ public struct VideoDownloadQualityView: View { didSelect: didSelect ) ) + self.analytics = analytics } public var body: some View { @@ -52,6 +55,13 @@ public struct VideoDownloadQualityView: View { VStack(alignment: .leading, spacing: 24) { ForEach(viewModel.downloadQuality, id: \.self) { quality in Button(action: { + analytics.videoQualityChanged( + .videoDownloadQualityChanged, + bivalue: .videoDownloadQualityChanged, + value: quality.value ?? "", + oldValue: viewModel.selectedDownloadQuality.value ?? "" + ) + viewModel.selectedDownloadQuality = quality }, label: { HStack { @@ -127,11 +137,11 @@ public extension DownloadQuality { switch self { case .auto: return CoreLocalization.Settings.downloadQualityAutoTitle - case .low_360: + case .low: return CoreLocalization.Settings.downloadQuality360Title - case .medium_540: + case .medium: return CoreLocalization.Settings.downloadQuality540Title - case .high_720: + case .high: return CoreLocalization.Settings.downloadQuality720Title } } @@ -140,11 +150,11 @@ public extension DownloadQuality { switch self { case .auto: return CoreLocalization.Settings.downloadQualityAutoDescription - case .low_360: + case .low: return CoreLocalization.Settings.downloadQuality360Description - case .medium_540: + case .medium: return nil - case .high_720: + case .high: return CoreLocalization.Settings.downloadQuality720Description } } @@ -154,12 +164,12 @@ public extension DownloadQuality { case .auto: return CoreLocalization.Settings.downloadQualityAutoTitle + " (" + CoreLocalization.Settings.downloadQualityAutoDescription + ")" - case .low_360: + case .low: return CoreLocalization.Settings.downloadQuality360Title + " (" + CoreLocalization.Settings.downloadQuality360Description + ")" - case .medium_540: + case .medium: return CoreLocalization.Settings.downloadQuality540Title - case .high_720: + case .high: return CoreLocalization.Settings.downloadQuality720Title + " (" + CoreLocalization.Settings.downloadQuality720Description + ")" } diff --git a/Course/Course/Data/Model/Data_CourseDates.swift b/Course/Course/Data/Model/Data_CourseDates.swift index 62d493dc4..2ef5d339b 100644 --- a/Course/Course/Data/Model/Data_CourseDates.swift +++ b/Course/Course/Data/Model/Data_CourseDates.swift @@ -138,6 +138,19 @@ public extension DataLayer { "" } } + + var analyticsBannerType: String { + switch self { + case .datesTabInfoBanner: + "info" + case .upgradeToCompleteGradedBanner: + "upgrade_to_participate" + case .upgradeToResetBanner: + "upgrade_to_shift" + case .resetDatesBanner: + "shift_dates" + } + } } } diff --git a/Course/Course/Presentation/Container/CourseContainerView.swift b/Course/Course/Presentation/Container/CourseContainerView.swift index 54c0cca35..dd881e0d2 100644 --- a/Course/Course/Presentation/Container/CourseContainerView.swift +++ b/Course/Course/Presentation/Container/CourseContainerView.swift @@ -199,7 +199,8 @@ struct CourseScreensView_Previews: PreviewProvider { courseStart: nil, courseEnd: nil, enrollmentStart: nil, - enrollmentEnd: nil + enrollmentEnd: nil, + coreAnalytics: CoreAnalyticsMock() ), courseID: "", title: "Title of Course") } diff --git a/Course/Course/Presentation/Container/CourseContainerViewModel.swift b/Course/Course/Presentation/Container/CourseContainerViewModel.swift index 85418cb27..c3259ed8c 100644 --- a/Course/Course/Presentation/Container/CourseContainerViewModel.swift +++ b/Course/Course/Presentation/Container/CourseContainerViewModel.swift @@ -90,7 +90,8 @@ public class CourseContainerViewModel: BaseCourseViewModel { private let interactor: CourseInteractorProtocol private let authInteractor: AuthInteractorProtocol - private let analytics: CourseAnalytics + let analytics: CourseAnalytics + let coreAnalytics: CoreAnalytics private(set) var storage: CourseStorage public init( @@ -106,7 +107,8 @@ public class CourseContainerViewModel: BaseCourseViewModel { courseStart: Date?, courseEnd: Date?, enrollmentStart: Date?, - enrollmentEnd: Date? + enrollmentEnd: Date?, + coreAnalytics: CoreAnalytics ) { self.interactor = interactor self.authInteractor = authInteractor @@ -122,6 +124,7 @@ public class CourseContainerViewModel: BaseCourseViewModel { self.storage = storage self.userSettings = storage.userSettings self.isInternetAvaliable = connectivity.isInternetAvaliable + self.coreAnalytics = coreAnalytics super.init(manager: manager) addObservers() @@ -179,14 +182,32 @@ public class CourseContainerViewModel: BaseCourseViewModel { } @MainActor - func shiftDueDates(courseID: String, withProgress: Bool = true) async { + func shiftDueDates(courseID: String, withProgress: Bool = true, screen: DatesStatusInfoScreen, type: String) async { isShowProgress = withProgress do { try await interactor.shiftDueDates(courseID: courseID) NotificationCenter.default.post(name: .shiftCourseDates, object: courseID) isShowProgress = false + + analytics.plsSuccessEvent( + .plsShiftDatesSuccess, + bivalue: .plsShiftDatesSuccess, + courseID: courseID, + screenName: screen.rawValue, + type: type, + success: true + ) + } catch let error { isShowProgress = false + analytics.plsSuccessEvent( + .plsShiftDatesSuccess, + bivalue: .plsShiftDatesSuccess, + courseID: courseID, + screenName: screen.rawValue, + type: type, + success: false + ) if error.isInternetError || error is NoCachedDataError { errorMessage = CoreLocalization.Error.slowOrNoInternetConnection } else { @@ -227,6 +248,22 @@ public class CourseContainerViewModel: BaseCourseViewModel { if state == .available, isShowedAllowLargeDownloadAlert(blocks: blocks) { return } + + if state == .available { + analytics.bulkDownloadVideosSubsection( + courseID: courseStructure?.id ?? "", + sectionID: chapter.id, + subSectionID: sequential.id, + videos: blocks.count + ) + } else if state == .finished { + analytics.bulkDeleteVideosSubsection( + courseID: courseStructure?.id ?? "", + subSectionID: sequential.id, + videos: blocks.count + ) + } + await download(state: state, blocks: blocks) } @@ -292,6 +329,14 @@ public class CourseContainerViewModel: BaseCourseViewModel { blockName: vertical.displayName ) } + + func trackViewCertificateClicked(courseID: String) { + analytics.trackCourseEvent( + .courseViewCertificateClicked, + biValue: .courseViewCertificateClicked, + courseID: courseID + ) + } func trackSequentialClicked(_ sequential: CourseSequential) { guard let course = courseStructure else { return } @@ -303,9 +348,9 @@ public class CourseContainerViewModel: BaseCourseViewModel { ) } - func trackResumeCourseTapped(blockId: String) { + func trackResumeCourseClicked(blockId: String) { guard let course = courseStructure else { return } - analytics.resumeCourseTapped( + analytics.resumeCourseClicked( courseId: course.id, courseName: course.displayName, blockId: blockId diff --git a/Course/Course/Presentation/CourseAnalytics.swift b/Course/Course/Presentation/CourseAnalytics.swift index ca7e66db7..b17cda57c 100644 --- a/Course/Course/Presentation/CourseAnalytics.swift +++ b/Course/Course/Presentation/CourseAnalytics.swift @@ -6,10 +6,11 @@ // import Foundation +import Core //sourcery: AutoMockable public protocol CourseAnalytics { - func resumeCourseTapped(courseId: String, courseName: String, blockId: String) + func resumeCourseClicked(courseId: String, courseName: String, blockId: String) func sequentialClicked(courseId: String, courseName: String, blockId: String, blockName: String) func verticalClicked(courseId: String, courseName: String, blockId: String, blockName: String) func nextBlockClicked(courseId: String, courseName: String, blockId: String, blockName: String) @@ -22,11 +23,47 @@ public protocol CourseAnalytics { func courseOutlineDatesTabClicked(courseId: String, courseName: String) func courseOutlineDiscussionTabClicked(courseId: String, courseName: String) func courseOutlineHandoutsTabClicked(courseId: String, courseName: String) + func datesComponentTapped( + courseId: String, + blockId: String, + link: String, + supported: Bool + ) + func trackCourseEvent(_ event: AnalyticsEvent, biValue: EventBIValue, courseID: String) + func plsEvent( + _ event: AnalyticsEvent, + bivalue: EventBIValue, + courseID: String, + screenName: String, + type: String + ) + + func plsSuccessEvent( + _ event: AnalyticsEvent, + bivalue: EventBIValue, + courseID: String, + screenName: String, + type: String, + success: Bool + ) + + func bulkDownloadVideosToggle(courseID: String, action: Bool) + func bulkDownloadVideosSubsection( + courseID: String, + sectionID: String, + subSectionID: String, + videos: Int + ) + func bulkDeleteVideosSubsection( + courseID: String, + subSectionID: String, + videos: Int + ) } #if DEBUG class CourseAnalyticsMock: CourseAnalytics { - public func resumeCourseTapped(courseId: String, courseName: String, blockId: String) {} + public func resumeCourseClicked(courseId: String, courseName: String, blockId: String) {} public func sequentialClicked(courseId: String, courseName: String, blockId: String, blockName: String) {} public func verticalClicked(courseId: String, courseName: String, blockId: String, blockName: String) {} public func nextBlockClicked(courseId: String, courseName: String, blockId: String, blockName: String) {} @@ -44,5 +81,41 @@ class CourseAnalyticsMock: CourseAnalytics { public func courseOutlineDatesTabClicked(courseId: String, courseName: String) {} public func courseOutlineDiscussionTabClicked(courseId: String, courseName: String) {} public func courseOutlineHandoutsTabClicked(courseId: String, courseName: String) {} + public func datesComponentTapped( + courseId: String, + blockId: String, + link: String, + supported: Bool + ) {} + public func trackCourseEvent(_ event: AnalyticsEvent, biValue: EventBIValue, courseID: String) {} + public func plsEvent( + _ event: AnalyticsEvent, + bivalue: EventBIValue, + courseID: String, + screenName: String, + type: String + ) {} + + public func plsSuccessEvent( + _ event: AnalyticsEvent, + bivalue: EventBIValue, + courseID: String, + screenName: String, + type: String, + success: Bool + ) {} + public func bulkDownloadVideosToggle(courseID: String, action: Bool) {} + public func bulkDownloadVideosSubsection( + courseID: String, + sectionID: String, + subSectionID: String, + videos: Int + ) {} + + public func bulkDeleteVideosSubsection( + courseID: String, + subSectionID: String, + videos: Int + ) {} } #endif diff --git a/Course/Course/Presentation/Dates/CourseDatesView.swift b/Course/Course/Presentation/Dates/CourseDatesView.swift index b4589c92f..482cdacd4 100644 --- a/Course/Course/Presentation/Dates/CourseDatesView.swift +++ b/Course/Course/Presentation/Dates/CourseDatesView.swift @@ -110,7 +110,8 @@ struct CourseDateListView: View { DatesStatusInfoView( datesBannerInfo: courseDates.datesBannerInfo, courseID: courseID, - courseDatesViewModel: viewModel + courseDatesViewModel: viewModel, + screen: .courseDates ) .padding(.bottom, 16) } @@ -322,6 +323,9 @@ struct StyleBlock: View { Task { await viewModel.showCourseDetails(componentID: block.firstComponentBlockID) } + viewModel.logdateComponentTapped(block: block, supported: true) + } else { + viewModel.logdateComponentTapped(block: block, supported: false) } } } @@ -393,7 +397,8 @@ struct CourseDatesView_Previews: PreviewProvider { router: CourseRouterMock(), cssInjector: CSSInjectorMock(), connectivity: Connectivity(), - courseID: "") + courseID: "", + analytics: CourseAnalyticsMock()) CourseDatesView( courseID: "", diff --git a/Course/Course/Presentation/Dates/CourseDatesViewModel.swift b/Course/Course/Presentation/Dates/CourseDatesViewModel.swift index a8d2f0f72..5aaee1da4 100644 --- a/Course/Course/Presentation/Dates/CourseDatesViewModel.swift +++ b/Course/Course/Presentation/Dates/CourseDatesViewModel.swift @@ -29,19 +29,22 @@ public class CourseDatesViewModel: ObservableObject { let router: CourseRouter let connectivity: ConnectivityProtocol let courseID: String + let analytics: CourseAnalytics public init( interactor: CourseInteractorProtocol, router: CourseRouter, cssInjector: CSSInjector, connectivity: ConnectivityProtocol, - courseID: String + courseID: String, + analytics: CourseAnalytics ) { self.interactor = interactor self.router = router self.cssInjector = cssInjector self.connectivity = connectivity self.courseID = courseID + self.analytics = analytics addObservers() } @@ -95,13 +98,29 @@ public class CourseDatesViewModel: ObservableObject { } @MainActor - func shiftDueDates(courseID: String, withProgress: Bool = true) async { + func shiftDueDates(courseID: String, withProgress: Bool = true, screen: DatesStatusInfoScreen, type: String) async { isShowProgress = withProgress do { try await interactor.shiftDueDates(courseID: courseID) NotificationCenter.default.post(name: .shiftCourseDates, object: courseID) isShowProgress = false + trackPLSuccessEvent( + .plsShiftDatesSuccess, + bivalue: .plsShiftDatesSuccess, + courseID: courseID, + screenName: screen.rawValue, + type: type, + success: true + ) } catch let error { + trackPLSuccessEvent( + .plsShiftDatesSuccess, + bivalue: .plsShiftDatesSuccess, + courseID: courseID, + screenName: screen.rawValue, + type: type, + success: false + ) isShowProgress = false if error.isInternetError || error is NoCachedDataError { errorMessage = CoreLocalization.Error.slowOrNoInternetConnection @@ -139,4 +158,47 @@ extension CourseDatesViewModel { func resetDueDatesShiftedFlag() { dueDatesShifted = false } + + func logdateComponentTapped(block: CourseDateBlock, supported: Bool) { + analytics.datesComponentTapped( + courseId: courseID, + blockId: block.firstComponentBlockID, + link: block.link, + supported: supported + ) + } + + func trackPLSEvent( + _ event: AnalyticsEvent, + bivalue: EventBIValue, + courseID: String, + screenName: String, + type: String + ) { + analytics.plsEvent( + event, + bivalue: bivalue, + courseID: courseID, + screenName: screenName, + type: type + ) + } + + private func trackPLSuccessEvent( + _ event: AnalyticsEvent, + bivalue: EventBIValue, + courseID: String, + screenName: String, + type: String, + success: Bool + ) { + analytics.plsSuccessEvent( + event, + bivalue: bivalue, + courseID: courseID, + screenName: screenName, + type: type, + success: success + ) + } } diff --git a/Course/Course/Presentation/Dates/DatesStatusInfoView.swift b/Course/Course/Presentation/Dates/DatesStatusInfoView.swift index 38a878dfe..b193dae22 100644 --- a/Course/Course/Presentation/Dates/DatesStatusInfoView.swift +++ b/Course/Course/Presentation/Dates/DatesStatusInfoView.swift @@ -10,11 +10,18 @@ import SwiftUI import Core import Theme +public enum DatesStatusInfoScreen: String { + case courseDashbaord = "course_dashbaord" + case courseDates = "course_dates" +} + struct DatesStatusInfoView: View { let datesBannerInfo: DatesBannerInfo let courseID: String var courseDatesViewModel: CourseDatesViewModel? var courseContainerViewModel: CourseContainerViewModel? + var screen: DatesStatusInfoScreen + @State private var isLoading = false var body: some View { @@ -40,11 +47,26 @@ struct DatesStatusInfoView: View { UnitButtonView(type: .custom(button)) { guard !isLoading else { return } isLoading = true + courseDatesViewModel?.trackPLSEvent( + .plsShiftDatesClicked, + bivalue: .plsShiftDatesClicked, + courseID: courseID, + screenName: screen.rawValue, + type: datesBannerInfo.status?.analyticsBannerType ?? "" + ) Task { if courseDatesViewModel != nil { - await courseDatesViewModel?.shiftDueDates(courseID: courseID) + await courseDatesViewModel?.shiftDueDates( + courseID: courseID, + screen: screen, + type: datesBannerInfo.status?.analyticsBannerType ?? "" + ) } else if courseContainerViewModel != nil { - await courseContainerViewModel?.shiftDueDates(courseID: courseID) + await courseContainerViewModel?.shiftDueDates( + courseID: courseID, + screen: screen, + type: datesBannerInfo.status?.analyticsBannerType ?? "" + ) } isLoading = false } @@ -59,6 +81,15 @@ struct DatesStatusInfoView: View { .stroke(Theme.Colors.datesSectionStroke, lineWidth: 2) ) .background(Theme.Colors.datesSectionBackground) + .onFirstAppear { + courseDatesViewModel?.trackPLSEvent( + .plsBannerViewed, + bivalue: .plsBannerViewed, + courseID: courseID, + screenName: screen.rawValue, + type: datesBannerInfo.status?.analyticsBannerType ?? "" + ) + } } } @@ -75,7 +106,8 @@ struct DatesStatusInfoView_Previews: PreviewProvider { DatesStatusInfoView( datesBannerInfo: datesBannerInfo, - courseID: "courseID" + courseID: "courseID", + screen: .courseDashbaord ) } } diff --git a/Course/Course/Presentation/Handouts/HandoutsView.swift b/Course/Course/Presentation/Handouts/HandoutsView.swift index 73a9af0a8..404b6d29a 100644 --- a/Course/Course/Presentation/Handouts/HandoutsView.swift +++ b/Course/Course/Presentation/Handouts/HandoutsView.swift @@ -44,6 +44,11 @@ struct HandoutsView: View { announcements: nil, router: viewModel.router, cssInjector: viewModel.cssInjector) + viewModel.analytics.trackCourseEvent( + .courseHandouts, + biValue: .courseHandouts, + courseID: courseID + ) }) Divider() HandoutsItemCell(type: .announcements, onTapAction: { @@ -53,6 +58,11 @@ struct HandoutsView: View { announcements: viewModel.updates, router: viewModel.router, cssInjector: viewModel.cssInjector) + viewModel.analytics.trackCourseEvent( + .courseAnnouncement, + biValue: .courseAnnouncement, + courseID: courseID + ) } }) }.padding(.horizontal, 32) @@ -108,7 +118,8 @@ struct HandoutsView_Previews: PreviewProvider { router: CourseRouterMock(), cssInjector: CSSInjectorMock(), connectivity: Connectivity(), - courseID: "") + courseID: "", + analytics: CourseAnalyticsMock()) HandoutsView(courseID: "", viewModel: viewModel) } diff --git a/Course/Course/Presentation/Handouts/HandoutsViewModel.swift b/Course/Course/Presentation/Handouts/HandoutsViewModel.swift index 2055e4adb..c5fc64a64 100644 --- a/Course/Course/Presentation/Handouts/HandoutsViewModel.swift +++ b/Course/Course/Presentation/Handouts/HandoutsViewModel.swift @@ -28,18 +28,21 @@ public class HandoutsViewModel: ObservableObject { let cssInjector: CSSInjector let router: CourseRouter let connectivity: ConnectivityProtocol + let analytics: CourseAnalytics public init( interactor: CourseInteractorProtocol, router: CourseRouter, cssInjector: CSSInjector, connectivity: ConnectivityProtocol, - courseID: String + courseID: String, + analytics: CourseAnalytics ) { self.interactor = interactor self.router = router self.cssInjector = cssInjector self.connectivity = connectivity + self.analytics = analytics } @MainActor diff --git a/Course/Course/Presentation/Outline/CourseOutlineView.swift b/Course/Course/Presentation/Outline/CourseOutlineView.swift index b4ebb132e..b67b902eb 100644 --- a/Course/Course/Presentation/Outline/CourseOutlineView.swift +++ b/Course/Course/Presentation/Outline/CourseOutlineView.swift @@ -66,7 +66,8 @@ public struct CourseOutlineView: View { DatesStatusInfoView( datesBannerInfo: courseDeadlineInfo.datesBannerInfo, courseID: courseID, - courseContainerViewModel: viewModel + courseContainerViewModel: viewModel, + screen: .courseDashbaord ) .padding(.horizontal, 16) .padding(.top, 16) @@ -96,7 +97,7 @@ public struct CourseOutlineView: View { } } - viewModel.trackResumeCourseTapped( + viewModel.trackResumeCourseClicked( blockId: continueBlock?.id ?? "" ) @@ -208,7 +209,8 @@ public struct CourseOutlineView: View { viewModel.storage.userSettings.map { VideoDownloadQualityContainerView( downloadQuality: $0.downloadQuality, - didSelect: viewModel.update(downloadQuality:) + didSelect: viewModel.update(downloadQuality:), + analytics: viewModel.coreAnalytics ) } } @@ -247,7 +249,8 @@ public struct CourseOutlineView: View { }, onTap: { showingDownloads = true - } + }, + analytics: viewModel.analytics ) viewModel.userSettings.map { VideoDownloadQualityBarView( @@ -290,7 +293,10 @@ public struct CourseOutlineView: View { .multilineTextAlignment(.center) StyledButton( CourseLocalization.Outline.viewCertificate, - action: { openCertificateView = true }, + action: { + openCertificateView = true + viewModel.trackViewCertificateClicked(courseID: courseID) + }, isTransparent: true ) .frame(width: 141) @@ -335,7 +341,8 @@ struct CourseOutlineView_Previews: PreviewProvider { courseStart: Date(), courseEnd: nil, enrollmentStart: Date(), - enrollmentEnd: nil + enrollmentEnd: nil, + coreAnalytics: CoreAnalyticsMock() ) Task { await withTaskGroup(of: Void.self) { group in diff --git a/Course/Course/Presentation/Subviews/CourseVideoDownloadBarView/CourseVideoDownloadBarView.swift b/Course/Course/Presentation/Subviews/CourseVideoDownloadBarView/CourseVideoDownloadBarView.swift index f99f17ca6..50ca98bbb 100644 --- a/Course/Course/Presentation/Subviews/CourseVideoDownloadBarView/CourseVideoDownloadBarView.swift +++ b/Course/Course/Presentation/Subviews/CourseVideoDownloadBarView/CourseVideoDownloadBarView.swift @@ -22,12 +22,14 @@ struct CourseVideoDownloadBarView: View { courseStructure: CourseStructure, courseViewModel: CourseContainerViewModel, onNotInternetAvaliable: (() -> Void)?, - onTap: (() -> Void)? = nil + onTap: (() -> Void)? = nil, + analytics: CourseAnalytics ) { self._viewModel = .init( wrappedValue: .init( courseStructure: courseStructure, - courseViewModel: courseViewModel + courseViewModel: courseViewModel, + analytics: analytics ) ) self.onNotInternetAvaliable = onNotInternetAvaliable diff --git a/Course/Course/Presentation/Subviews/CourseVideoDownloadBarView/CourseVideoDownloadBarViewModel.swift b/Course/Course/Presentation/Subviews/CourseVideoDownloadBarView/CourseVideoDownloadBarViewModel.swift index 91b0cd281..463d8596e 100644 --- a/Course/Course/Presentation/Subviews/CourseVideoDownloadBarView/CourseVideoDownloadBarViewModel.swift +++ b/Course/Course/Presentation/Subviews/CourseVideoDownloadBarView/CourseVideoDownloadBarViewModel.swift @@ -15,6 +15,7 @@ final class CourseVideoDownloadBarViewModel: ObservableObject { private let courseStructure: CourseStructure private let courseViewModel: CourseContainerViewModel + private let analytics: CourseAnalytics @Published private(set) var currentDownloadTask: DownloadDataTask? @Published private(set) var isOn: Bool = false @@ -105,10 +106,12 @@ final class CourseVideoDownloadBarViewModel: ObservableObject { init( courseStructure: CourseStructure, - courseViewModel: CourseContainerViewModel + courseViewModel: CourseContainerViewModel, + analytics: CourseAnalytics ) { self.courseStructure = courseStructure self.courseViewModel = courseViewModel + self.analytics = analytics observers() } @@ -132,6 +135,7 @@ final class CourseVideoDownloadBarViewModel: ObservableObject { Task { await self.downloadAll(isOn: false) } + analytics.bulkDownloadVideosToggle(courseID: courseStructure.id, action: false) self.courseViewModel.router.dismiss(animated: true) }, type: .deleteVideo @@ -152,13 +156,15 @@ final class CourseVideoDownloadBarViewModel: ObservableObject { Task { await self.downloadAll(isOn: false) } + analytics.bulkDownloadVideosToggle(courseID: courseStructure.id, action: false) self.courseViewModel.router.dismiss(animated: true) }, type: .deleteVideo ) return } - + + analytics.bulkDownloadVideosToggle(courseID: courseStructure.id, action: true) await downloadAll(isOn: true) } diff --git a/Course/Course/Presentation/Subviews/VideoDownloadQualityBarView/VideoDownloadQualityContainerView.swift b/Course/Course/Presentation/Subviews/VideoDownloadQualityBarView/VideoDownloadQualityContainerView.swift index f81c4b91d..4418d3513 100644 --- a/Course/Course/Presentation/Subviews/VideoDownloadQualityBarView/VideoDownloadQualityContainerView.swift +++ b/Course/Course/Presentation/Subviews/VideoDownloadQualityBarView/VideoDownloadQualityContainerView.swift @@ -15,17 +15,20 @@ struct VideoDownloadQualityContainerView: View { private var downloadQuality: DownloadQuality private var didSelect: ((DownloadQuality) -> Void)? + private let analytics: CoreAnalytics - init(downloadQuality: DownloadQuality, didSelect: ((DownloadQuality) -> Void)?) { + init(downloadQuality: DownloadQuality, didSelect: ((DownloadQuality) -> Void)?, analytics: CoreAnalytics) { self.downloadQuality = downloadQuality self.didSelect = didSelect + self.analytics = analytics } var body: some View { NavigationView { VideoDownloadQualityView( downloadQuality: downloadQuality, - didSelect: didSelect + didSelect: didSelect, + analytics: analytics ) .navigationBarTitleDisplayMode(.inline) .toolbar { diff --git a/Course/Course/Presentation/Video/EncodedVideoPlayer.swift b/Course/Course/Presentation/Video/EncodedVideoPlayer.swift index 6249ccb7b..485eb5c17 100644 --- a/Course/Course/Presentation/Video/EncodedVideoPlayer.swift +++ b/Course/Course/Presentation/Video/EncodedVideoPlayer.swift @@ -73,6 +73,7 @@ public struct EncodedVideoPlayer: View { if progress == 1 { viewModel.router.presentAppReview() } + }, seconds: { seconds in currentTime = seconds }) diff --git a/Course/Course/Presentation/Video/EncodedVideoPlayerViewModel.swift b/Course/Course/Presentation/Video/EncodedVideoPlayerViewModel.swift index 6163c8f93..8d2defbcd 100644 --- a/Course/Course/Presentation/Video/EncodedVideoPlayerViewModel.swift +++ b/Course/Course/Presentation/Video/EncodedVideoPlayerViewModel.swift @@ -33,7 +33,7 @@ public class EncodedVideoPlayerViewModel: VideoPlayerViewModel { courseID: courseID, languages: languages, interactor: interactor, - router: router, + router: router, appStorage: appStorage, connectivity: connectivity) diff --git a/Course/CourseTests/CourseMock.generated.swift b/Course/CourseTests/CourseMock.generated.swift index 70218103b..bd3e8f4a8 100644 --- a/Course/CourseTests/CourseMock.generated.swift +++ b/Course/CourseTests/CourseMock.generated.swift @@ -1114,6 +1114,281 @@ open class ConnectivityProtocolMock: ConnectivityProtocol, Mock { } } +// MARK: - CoreAnalytics + +open class CoreAnalyticsMock: CoreAnalytics, Mock { + public init(sequencing sequencingPolicy: SequencingPolicy = .lastWrittenResolvedFirst, stubbing stubbingPolicy: StubbingPolicy = .wrap, file: StaticString = #file, line: UInt = #line) { + SwiftyMockyTestObserver.setup() + self.sequencingPolicy = sequencingPolicy + self.stubbingPolicy = stubbingPolicy + self.file = file + self.line = line + } + + var matcher: Matcher = Matcher.default + var stubbingPolicy: StubbingPolicy = .wrap + var sequencingPolicy: SequencingPolicy = .lastWrittenResolvedFirst + + private var queue = DispatchQueue(label: "com.swiftymocky.invocations", qos: .userInteractive) + private var invocations: [MethodType] = [] + private var methodReturnValues: [Given] = [] + private var methodPerformValues: [Perform] = [] + private var file: StaticString? + private var line: UInt? + + public typealias PropertyStub = Given + public typealias MethodStub = Given + public typealias SubscriptStub = Given + + /// Convenience method - call setupMock() to extend debug information when failure occurs + public func setupMock(file: StaticString = #file, line: UInt = #line) { + self.file = file + self.line = line + } + + /// Clear mock internals. You can specify what to reset (invocations aka verify, givens or performs) or leave it empty to clear all mock internals + public func resetMock(_ scopes: MockScope...) { + let scopes: [MockScope] = scopes.isEmpty ? [.invocation, .given, .perform] : scopes + if scopes.contains(.invocation) { invocations = [] } + if scopes.contains(.given) { methodReturnValues = [] } + if scopes.contains(.perform) { methodPerformValues = [] } + } + + + + + + open func trackEvent(_ event: AnalyticsEvent, parameters: [String: Any]?) { + addInvocation(.m_trackEvent__eventparameters_parameters(Parameter.value(`event`), Parameter<[String: Any]?>.value(`parameters`))) + let perform = methodPerformValue(.m_trackEvent__eventparameters_parameters(Parameter.value(`event`), Parameter<[String: Any]?>.value(`parameters`))) as? (AnalyticsEvent, [String: Any]?) -> Void + perform?(`event`, `parameters`) + } + + open func trackEvent(_ event: AnalyticsEvent, biValue: EventBIValue, parameters: [String: Any]?) { + addInvocation(.m_trackEvent__eventbiValue_biValueparameters_parameters(Parameter.value(`event`), Parameter.value(`biValue`), Parameter<[String: Any]?>.value(`parameters`))) + let perform = methodPerformValue(.m_trackEvent__eventbiValue_biValueparameters_parameters(Parameter.value(`event`), Parameter.value(`biValue`), Parameter<[String: Any]?>.value(`parameters`))) as? (AnalyticsEvent, EventBIValue, [String: Any]?) -> Void + perform?(`event`, `biValue`, `parameters`) + } + + open func appreview(_ event: AnalyticsEvent, biValue: EventBIValue, action: String?, rating: Int?) { + addInvocation(.m_appreview__eventbiValue_biValueaction_actionrating_rating(Parameter.value(`event`), Parameter.value(`biValue`), Parameter.value(`action`), Parameter.value(`rating`))) + let perform = methodPerformValue(.m_appreview__eventbiValue_biValueaction_actionrating_rating(Parameter.value(`event`), Parameter.value(`biValue`), Parameter.value(`action`), Parameter.value(`rating`))) as? (AnalyticsEvent, EventBIValue, String?, Int?) -> Void + perform?(`event`, `biValue`, `action`, `rating`) + } + + open func videoQualityChanged(_ event: AnalyticsEvent, bivalue: EventBIValue, value: String, oldValue: String) { + addInvocation(.m_videoQualityChanged__eventbivalue_bivaluevalue_valueoldValue_oldValue(Parameter.value(`event`), Parameter.value(`bivalue`), Parameter.value(`value`), Parameter.value(`oldValue`))) + let perform = methodPerformValue(.m_videoQualityChanged__eventbivalue_bivaluevalue_valueoldValue_oldValue(Parameter.value(`event`), Parameter.value(`bivalue`), Parameter.value(`value`), Parameter.value(`oldValue`))) as? (AnalyticsEvent, EventBIValue, String, String) -> Void + perform?(`event`, `bivalue`, `value`, `oldValue`) + } + + open func trackEvent(_ event: AnalyticsEvent) { + addInvocation(.m_trackEvent__event(Parameter.value(`event`))) + let perform = methodPerformValue(.m_trackEvent__event(Parameter.value(`event`))) as? (AnalyticsEvent) -> Void + perform?(`event`) + } + + open func trackEvent(_ event: AnalyticsEvent, biValue: EventBIValue) { + addInvocation(.m_trackEvent__eventbiValue_biValue(Parameter.value(`event`), Parameter.value(`biValue`))) + let perform = methodPerformValue(.m_trackEvent__eventbiValue_biValue(Parameter.value(`event`), Parameter.value(`biValue`))) as? (AnalyticsEvent, EventBIValue) -> Void + perform?(`event`, `biValue`) + } + + + fileprivate enum MethodType { + case m_trackEvent__eventparameters_parameters(Parameter, Parameter<[String: Any]?>) + case m_trackEvent__eventbiValue_biValueparameters_parameters(Parameter, Parameter, Parameter<[String: Any]?>) + case m_appreview__eventbiValue_biValueaction_actionrating_rating(Parameter, Parameter, Parameter, Parameter) + case m_videoQualityChanged__eventbivalue_bivaluevalue_valueoldValue_oldValue(Parameter, Parameter, Parameter, Parameter) + case m_trackEvent__event(Parameter) + case m_trackEvent__eventbiValue_biValue(Parameter, Parameter) + + static func compareParameters(lhs: MethodType, rhs: MethodType, matcher: Matcher) -> Matcher.ComparisonResult { + switch (lhs, rhs) { + case (.m_trackEvent__eventparameters_parameters(let lhsEvent, let lhsParameters), .m_trackEvent__eventparameters_parameters(let rhsEvent, let rhsParameters)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsEvent, rhs: rhsEvent, with: matcher), lhsEvent, rhsEvent, "_ event")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsParameters, rhs: rhsParameters, with: matcher), lhsParameters, rhsParameters, "parameters")) + return Matcher.ComparisonResult(results) + + case (.m_trackEvent__eventbiValue_biValueparameters_parameters(let lhsEvent, let lhsBivalue, let lhsParameters), .m_trackEvent__eventbiValue_biValueparameters_parameters(let rhsEvent, let rhsBivalue, let rhsParameters)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsEvent, rhs: rhsEvent, with: matcher), lhsEvent, rhsEvent, "_ event")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsBivalue, rhs: rhsBivalue, with: matcher), lhsBivalue, rhsBivalue, "biValue")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsParameters, rhs: rhsParameters, with: matcher), lhsParameters, rhsParameters, "parameters")) + return Matcher.ComparisonResult(results) + + case (.m_appreview__eventbiValue_biValueaction_actionrating_rating(let lhsEvent, let lhsBivalue, let lhsAction, let lhsRating), .m_appreview__eventbiValue_biValueaction_actionrating_rating(let rhsEvent, let rhsBivalue, let rhsAction, let rhsRating)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsEvent, rhs: rhsEvent, with: matcher), lhsEvent, rhsEvent, "_ event")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsBivalue, rhs: rhsBivalue, with: matcher), lhsBivalue, rhsBivalue, "biValue")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsAction, rhs: rhsAction, with: matcher), lhsAction, rhsAction, "action")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsRating, rhs: rhsRating, with: matcher), lhsRating, rhsRating, "rating")) + return Matcher.ComparisonResult(results) + + case (.m_videoQualityChanged__eventbivalue_bivaluevalue_valueoldValue_oldValue(let lhsEvent, let lhsBivalue, let lhsValue, let lhsOldvalue), .m_videoQualityChanged__eventbivalue_bivaluevalue_valueoldValue_oldValue(let rhsEvent, let rhsBivalue, let rhsValue, let rhsOldvalue)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsEvent, rhs: rhsEvent, with: matcher), lhsEvent, rhsEvent, "_ event")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsBivalue, rhs: rhsBivalue, with: matcher), lhsBivalue, rhsBivalue, "bivalue")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsValue, rhs: rhsValue, with: matcher), lhsValue, rhsValue, "value")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsOldvalue, rhs: rhsOldvalue, with: matcher), lhsOldvalue, rhsOldvalue, "oldValue")) + return Matcher.ComparisonResult(results) + + case (.m_trackEvent__event(let lhsEvent), .m_trackEvent__event(let rhsEvent)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsEvent, rhs: rhsEvent, with: matcher), lhsEvent, rhsEvent, "_ event")) + return Matcher.ComparisonResult(results) + + case (.m_trackEvent__eventbiValue_biValue(let lhsEvent, let lhsBivalue), .m_trackEvent__eventbiValue_biValue(let rhsEvent, let rhsBivalue)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsEvent, rhs: rhsEvent, with: matcher), lhsEvent, rhsEvent, "_ event")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsBivalue, rhs: rhsBivalue, with: matcher), lhsBivalue, rhsBivalue, "biValue")) + return Matcher.ComparisonResult(results) + default: return .none + } + } + + func intValue() -> Int { + switch self { + case let .m_trackEvent__eventparameters_parameters(p0, p1): return p0.intValue + p1.intValue + case let .m_trackEvent__eventbiValue_biValueparameters_parameters(p0, p1, p2): return p0.intValue + p1.intValue + p2.intValue + case let .m_appreview__eventbiValue_biValueaction_actionrating_rating(p0, p1, p2, p3): return p0.intValue + p1.intValue + p2.intValue + p3.intValue + case let .m_videoQualityChanged__eventbivalue_bivaluevalue_valueoldValue_oldValue(p0, p1, p2, p3): return p0.intValue + p1.intValue + p2.intValue + p3.intValue + case let .m_trackEvent__event(p0): return p0.intValue + case let .m_trackEvent__eventbiValue_biValue(p0, p1): return p0.intValue + p1.intValue + } + } + func assertionName() -> String { + switch self { + case .m_trackEvent__eventparameters_parameters: return ".trackEvent(_:parameters:)" + case .m_trackEvent__eventbiValue_biValueparameters_parameters: return ".trackEvent(_:biValue:parameters:)" + case .m_appreview__eventbiValue_biValueaction_actionrating_rating: return ".appreview(_:biValue:action:rating:)" + case .m_videoQualityChanged__eventbivalue_bivaluevalue_valueoldValue_oldValue: return ".videoQualityChanged(_:bivalue:value:oldValue:)" + case .m_trackEvent__event: return ".trackEvent(_:)" + case .m_trackEvent__eventbiValue_biValue: return ".trackEvent(_:biValue:)" + } + } + } + + open class Given: StubbedMethod { + fileprivate var method: MethodType + + private init(method: MethodType, products: [StubProduct]) { + self.method = method + super.init(products) + } + + + } + + public struct Verify { + fileprivate var method: MethodType + + public static func trackEvent(_ event: Parameter, parameters: Parameter<[String: Any]?>) -> Verify { return Verify(method: .m_trackEvent__eventparameters_parameters(`event`, `parameters`))} + public static func trackEvent(_ event: Parameter, biValue: Parameter, parameters: Parameter<[String: Any]?>) -> Verify { return Verify(method: .m_trackEvent__eventbiValue_biValueparameters_parameters(`event`, `biValue`, `parameters`))} + public static func appreview(_ event: Parameter, biValue: Parameter, action: Parameter, rating: Parameter) -> Verify { return Verify(method: .m_appreview__eventbiValue_biValueaction_actionrating_rating(`event`, `biValue`, `action`, `rating`))} + public static func videoQualityChanged(_ event: Parameter, bivalue: Parameter, value: Parameter, oldValue: Parameter) -> Verify { return Verify(method: .m_videoQualityChanged__eventbivalue_bivaluevalue_valueoldValue_oldValue(`event`, `bivalue`, `value`, `oldValue`))} + public static func trackEvent(_ event: Parameter) -> Verify { return Verify(method: .m_trackEvent__event(`event`))} + public static func trackEvent(_ event: Parameter, biValue: Parameter) -> Verify { return Verify(method: .m_trackEvent__eventbiValue_biValue(`event`, `biValue`))} + } + + public struct Perform { + fileprivate var method: MethodType + var performs: Any + + public static func trackEvent(_ event: Parameter, parameters: Parameter<[String: Any]?>, perform: @escaping (AnalyticsEvent, [String: Any]?) -> Void) -> Perform { + return Perform(method: .m_trackEvent__eventparameters_parameters(`event`, `parameters`), performs: perform) + } + public static func trackEvent(_ event: Parameter, biValue: Parameter, parameters: Parameter<[String: Any]?>, perform: @escaping (AnalyticsEvent, EventBIValue, [String: Any]?) -> Void) -> Perform { + return Perform(method: .m_trackEvent__eventbiValue_biValueparameters_parameters(`event`, `biValue`, `parameters`), performs: perform) + } + public static func appreview(_ event: Parameter, biValue: Parameter, action: Parameter, rating: Parameter, perform: @escaping (AnalyticsEvent, EventBIValue, String?, Int?) -> Void) -> Perform { + return Perform(method: .m_appreview__eventbiValue_biValueaction_actionrating_rating(`event`, `biValue`, `action`, `rating`), performs: perform) + } + public static func videoQualityChanged(_ event: Parameter, bivalue: Parameter, value: Parameter, oldValue: Parameter, perform: @escaping (AnalyticsEvent, EventBIValue, String, String) -> Void) -> Perform { + return Perform(method: .m_videoQualityChanged__eventbivalue_bivaluevalue_valueoldValue_oldValue(`event`, `bivalue`, `value`, `oldValue`), performs: perform) + } + public static func trackEvent(_ event: Parameter, perform: @escaping (AnalyticsEvent) -> Void) -> Perform { + return Perform(method: .m_trackEvent__event(`event`), performs: perform) + } + public static func trackEvent(_ event: Parameter, biValue: Parameter, perform: @escaping (AnalyticsEvent, EventBIValue) -> Void) -> Perform { + return Perform(method: .m_trackEvent__eventbiValue_biValue(`event`, `biValue`), performs: perform) + } + } + + public func given(_ method: Given) { + methodReturnValues.append(method) + } + + public func perform(_ method: Perform) { + methodPerformValues.append(method) + methodPerformValues.sort { $0.method.intValue() < $1.method.intValue() } + } + + public func verify(_ method: Verify, count: Count = Count.moreOrEqual(to: 1), file: StaticString = #file, line: UInt = #line) { + let fullMatches = matchingCalls(method, file: file, line: line) + let success = count.matches(fullMatches) + let assertionName = method.method.assertionName() + let feedback: String = { + guard !success else { return "" } + return Utils.closestCallsMessage( + for: self.invocations.map { invocation in + matcher.set(file: file, line: line) + defer { matcher.clearFileAndLine() } + return MethodType.compareParameters(lhs: invocation, rhs: method.method, matcher: matcher) + }, + name: assertionName + ) + }() + MockyAssert(success, "Expected: \(count) invocations of `\(assertionName)`, but was: \(fullMatches).\(feedback)", file: file, line: line) + } + + private func addInvocation(_ call: MethodType) { + self.queue.sync { invocations.append(call) } + } + private func methodReturnValue(_ method: MethodType) throws -> StubProduct { + matcher.set(file: self.file, line: self.line) + defer { matcher.clearFileAndLine() } + let candidates = sequencingPolicy.sorted(methodReturnValues, by: { $0.method.intValue() > $1.method.intValue() }) + let matched = candidates.first(where: { $0.isValid && MethodType.compareParameters(lhs: $0.method, rhs: method, matcher: matcher).isFullMatch }) + guard let product = matched?.getProduct(policy: self.stubbingPolicy) else { throw MockError.notStubed } + return product + } + private func methodPerformValue(_ method: MethodType) -> Any? { + matcher.set(file: self.file, line: self.line) + defer { matcher.clearFileAndLine() } + let matched = methodPerformValues.reversed().first { MethodType.compareParameters(lhs: $0.method, rhs: method, matcher: matcher).isFullMatch } + return matched?.performs + } + private func matchingCalls(_ method: MethodType, file: StaticString?, line: UInt?) -> [MethodType] { + matcher.set(file: file ?? self.file, line: line ?? self.line) + defer { matcher.clearFileAndLine() } + return invocations.filter { MethodType.compareParameters(lhs: $0, rhs: method, matcher: matcher).isFullMatch } + } + private func matchingCalls(_ method: Verify, file: StaticString?, line: UInt?) -> Int { + return matchingCalls(method.method, file: file, line: line).count + } + private func givenGetterValue(_ method: MethodType, _ message: String) -> T { + do { + return try methodReturnValue(method).casted() + } catch { + onFatalFailure(message) + Failure(message) + } + } + private func optionalGivenGetterValue(_ method: MethodType, _ message: String) -> T? { + do { + return try methodReturnValue(method).casted() + } catch { + return nil + } + } + private func onFatalFailure(_ message: String) { + guard let file = self.file, let line = self.line else { return } // Let if fail if cannot handle gratefully + SwiftyMockyTestObserver.handleFatalError(message: message, file: file, line: line) + } +} + // MARK: - CourseAnalytics open class CourseAnalyticsMock: CourseAnalytics, Mock { @@ -1158,9 +1433,9 @@ open class CourseAnalyticsMock: CourseAnalytics, Mock { - open func resumeCourseTapped(courseId: String, courseName: String, blockId: String) { - addInvocation(.m_resumeCourseTapped__courseId_courseIdcourseName_courseNameblockId_blockId(Parameter.value(`courseId`), Parameter.value(`courseName`), Parameter.value(`blockId`))) - let perform = methodPerformValue(.m_resumeCourseTapped__courseId_courseIdcourseName_courseNameblockId_blockId(Parameter.value(`courseId`), Parameter.value(`courseName`), Parameter.value(`blockId`))) as? (String, String, String) -> Void + open func resumeCourseClicked(courseId: String, courseName: String, blockId: String) { + addInvocation(.m_resumeCourseClicked__courseId_courseIdcourseName_courseNameblockId_blockId(Parameter.value(`courseId`), Parameter.value(`courseName`), Parameter.value(`blockId`))) + let perform = methodPerformValue(.m_resumeCourseClicked__courseId_courseIdcourseName_courseNameblockId_blockId(Parameter.value(`courseId`), Parameter.value(`courseName`), Parameter.value(`blockId`))) as? (String, String, String) -> Void perform?(`courseId`, `courseName`, `blockId`) } @@ -1236,9 +1511,51 @@ open class CourseAnalyticsMock: CourseAnalytics, Mock { perform?(`courseId`, `courseName`) } + open func datesComponentTapped(courseId: String, blockId: String, link: String, supported: Bool) { + addInvocation(.m_datesComponentTapped__courseId_courseIdblockId_blockIdlink_linksupported_supported(Parameter.value(`courseId`), Parameter.value(`blockId`), Parameter.value(`link`), Parameter.value(`supported`))) + let perform = methodPerformValue(.m_datesComponentTapped__courseId_courseIdblockId_blockIdlink_linksupported_supported(Parameter.value(`courseId`), Parameter.value(`blockId`), Parameter.value(`link`), Parameter.value(`supported`))) as? (String, String, String, Bool) -> Void + perform?(`courseId`, `blockId`, `link`, `supported`) + } + + open func trackCourseEvent(_ event: AnalyticsEvent, biValue: EventBIValue, courseID: String) { + addInvocation(.m_trackCourseEvent__eventbiValue_biValuecourseID_courseID(Parameter.value(`event`), Parameter.value(`biValue`), Parameter.value(`courseID`))) + let perform = methodPerformValue(.m_trackCourseEvent__eventbiValue_biValuecourseID_courseID(Parameter.value(`event`), Parameter.value(`biValue`), Parameter.value(`courseID`))) as? (AnalyticsEvent, EventBIValue, String) -> Void + perform?(`event`, `biValue`, `courseID`) + } + + open func plsEvent(_ event: AnalyticsEvent, bivalue: EventBIValue, courseID: String, screenName: String, type: String) { + addInvocation(.m_plsEvent__eventbivalue_bivaluecourseID_courseIDscreenName_screenNametype_type(Parameter.value(`event`), Parameter.value(`bivalue`), Parameter.value(`courseID`), Parameter.value(`screenName`), Parameter.value(`type`))) + let perform = methodPerformValue(.m_plsEvent__eventbivalue_bivaluecourseID_courseIDscreenName_screenNametype_type(Parameter.value(`event`), Parameter.value(`bivalue`), Parameter.value(`courseID`), Parameter.value(`screenName`), Parameter.value(`type`))) as? (AnalyticsEvent, EventBIValue, String, String, String) -> Void + perform?(`event`, `bivalue`, `courseID`, `screenName`, `type`) + } + + open func plsSuccessEvent(_ event: AnalyticsEvent, bivalue: EventBIValue, courseID: String, screenName: String, type: String, success: Bool) { + addInvocation(.m_plsSuccessEvent__eventbivalue_bivaluecourseID_courseIDscreenName_screenNametype_typesuccess_success(Parameter.value(`event`), Parameter.value(`bivalue`), Parameter.value(`courseID`), Parameter.value(`screenName`), Parameter.value(`type`), Parameter.value(`success`))) + let perform = methodPerformValue(.m_plsSuccessEvent__eventbivalue_bivaluecourseID_courseIDscreenName_screenNametype_typesuccess_success(Parameter.value(`event`), Parameter.value(`bivalue`), Parameter.value(`courseID`), Parameter.value(`screenName`), Parameter.value(`type`), Parameter.value(`success`))) as? (AnalyticsEvent, EventBIValue, String, String, String, Bool) -> Void + perform?(`event`, `bivalue`, `courseID`, `screenName`, `type`, `success`) + } + + open func bulkDownloadVideosToggle(courseID: String, action: Bool) { + addInvocation(.m_bulkDownloadVideosToggle__courseID_courseIDaction_action(Parameter.value(`courseID`), Parameter.value(`action`))) + let perform = methodPerformValue(.m_bulkDownloadVideosToggle__courseID_courseIDaction_action(Parameter.value(`courseID`), Parameter.value(`action`))) as? (String, Bool) -> Void + perform?(`courseID`, `action`) + } + + open func bulkDownloadVideosSubsection(courseID: String, sectionID: String, subSectionID: String, videos: Int) { + addInvocation(.m_bulkDownloadVideosSubsection__courseID_courseIDsectionID_sectionIDsubSectionID_subSectionIDvideos_videos(Parameter.value(`courseID`), Parameter.value(`sectionID`), Parameter.value(`subSectionID`), Parameter.value(`videos`))) + let perform = methodPerformValue(.m_bulkDownloadVideosSubsection__courseID_courseIDsectionID_sectionIDsubSectionID_subSectionIDvideos_videos(Parameter.value(`courseID`), Parameter.value(`sectionID`), Parameter.value(`subSectionID`), Parameter.value(`videos`))) as? (String, String, String, Int) -> Void + perform?(`courseID`, `sectionID`, `subSectionID`, `videos`) + } + + open func bulkDeleteVideosSubsection(courseID: String, subSectionID: String, videos: Int) { + addInvocation(.m_bulkDeleteVideosSubsection__courseID_courseIDsubSectionID_subSectionIDvideos_videos(Parameter.value(`courseID`), Parameter.value(`subSectionID`), Parameter.value(`videos`))) + let perform = methodPerformValue(.m_bulkDeleteVideosSubsection__courseID_courseIDsubSectionID_subSectionIDvideos_videos(Parameter.value(`courseID`), Parameter.value(`subSectionID`), Parameter.value(`videos`))) as? (String, String, Int) -> Void + perform?(`courseID`, `subSectionID`, `videos`) + } + fileprivate enum MethodType { - case m_resumeCourseTapped__courseId_courseIdcourseName_courseNameblockId_blockId(Parameter, Parameter, Parameter) + case m_resumeCourseClicked__courseId_courseIdcourseName_courseNameblockId_blockId(Parameter, Parameter, Parameter) case m_sequentialClicked__courseId_courseIdcourseName_courseNameblockId_blockIdblockName_blockName(Parameter, Parameter, Parameter, Parameter) case m_verticalClicked__courseId_courseIdcourseName_courseNameblockId_blockIdblockName_blockName(Parameter, Parameter, Parameter, Parameter) case m_nextBlockClicked__courseId_courseIdcourseName_courseNameblockId_blockIdblockName_blockName(Parameter, Parameter, Parameter, Parameter) @@ -1251,10 +1568,17 @@ open class CourseAnalyticsMock: CourseAnalytics, Mock { case m_courseOutlineDatesTabClicked__courseId_courseIdcourseName_courseName(Parameter, Parameter) case m_courseOutlineDiscussionTabClicked__courseId_courseIdcourseName_courseName(Parameter, Parameter) case m_courseOutlineHandoutsTabClicked__courseId_courseIdcourseName_courseName(Parameter, Parameter) + case m_datesComponentTapped__courseId_courseIdblockId_blockIdlink_linksupported_supported(Parameter, Parameter, Parameter, Parameter) + case m_trackCourseEvent__eventbiValue_biValuecourseID_courseID(Parameter, Parameter, Parameter) + case m_plsEvent__eventbivalue_bivaluecourseID_courseIDscreenName_screenNametype_type(Parameter, Parameter, Parameter, Parameter, Parameter) + case m_plsSuccessEvent__eventbivalue_bivaluecourseID_courseIDscreenName_screenNametype_typesuccess_success(Parameter, Parameter, Parameter, Parameter, Parameter, Parameter) + case m_bulkDownloadVideosToggle__courseID_courseIDaction_action(Parameter, Parameter) + case m_bulkDownloadVideosSubsection__courseID_courseIDsectionID_sectionIDsubSectionID_subSectionIDvideos_videos(Parameter, Parameter, Parameter, Parameter) + case m_bulkDeleteVideosSubsection__courseID_courseIDsubSectionID_subSectionIDvideos_videos(Parameter, Parameter, Parameter) static func compareParameters(lhs: MethodType, rhs: MethodType, matcher: Matcher) -> Matcher.ComparisonResult { switch (lhs, rhs) { - case (.m_resumeCourseTapped__courseId_courseIdcourseName_courseNameblockId_blockId(let lhsCourseid, let lhsCoursename, let lhsBlockid), .m_resumeCourseTapped__courseId_courseIdcourseName_courseNameblockId_blockId(let rhsCourseid, let rhsCoursename, let rhsBlockid)): + case (.m_resumeCourseClicked__courseId_courseIdcourseName_courseNameblockId_blockId(let lhsCourseid, let lhsCoursename, let lhsBlockid), .m_resumeCourseClicked__courseId_courseIdcourseName_courseNameblockId_blockId(let rhsCourseid, let rhsCoursename, let rhsBlockid)): var results: [Matcher.ParameterComparisonResult] = [] results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsCourseid, rhs: rhsCourseid, with: matcher), lhsCourseid, rhsCourseid, "courseId")) results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsCoursename, rhs: rhsCoursename, with: matcher), lhsCoursename, rhsCoursename, "courseName")) @@ -1344,13 +1668,68 @@ open class CourseAnalyticsMock: CourseAnalytics, Mock { results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsCourseid, rhs: rhsCourseid, with: matcher), lhsCourseid, rhsCourseid, "courseId")) results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsCoursename, rhs: rhsCoursename, with: matcher), lhsCoursename, rhsCoursename, "courseName")) return Matcher.ComparisonResult(results) + + case (.m_datesComponentTapped__courseId_courseIdblockId_blockIdlink_linksupported_supported(let lhsCourseid, let lhsBlockid, let lhsLink, let lhsSupported), .m_datesComponentTapped__courseId_courseIdblockId_blockIdlink_linksupported_supported(let rhsCourseid, let rhsBlockid, let rhsLink, let rhsSupported)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsCourseid, rhs: rhsCourseid, with: matcher), lhsCourseid, rhsCourseid, "courseId")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsBlockid, rhs: rhsBlockid, with: matcher), lhsBlockid, rhsBlockid, "blockId")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsLink, rhs: rhsLink, with: matcher), lhsLink, rhsLink, "link")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsSupported, rhs: rhsSupported, with: matcher), lhsSupported, rhsSupported, "supported")) + return Matcher.ComparisonResult(results) + + case (.m_trackCourseEvent__eventbiValue_biValuecourseID_courseID(let lhsEvent, let lhsBivalue, let lhsCourseid), .m_trackCourseEvent__eventbiValue_biValuecourseID_courseID(let rhsEvent, let rhsBivalue, let rhsCourseid)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsEvent, rhs: rhsEvent, with: matcher), lhsEvent, rhsEvent, "_ event")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsBivalue, rhs: rhsBivalue, with: matcher), lhsBivalue, rhsBivalue, "biValue")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsCourseid, rhs: rhsCourseid, with: matcher), lhsCourseid, rhsCourseid, "courseID")) + return Matcher.ComparisonResult(results) + + case (.m_plsEvent__eventbivalue_bivaluecourseID_courseIDscreenName_screenNametype_type(let lhsEvent, let lhsBivalue, let lhsCourseid, let lhsScreenname, let lhsType), .m_plsEvent__eventbivalue_bivaluecourseID_courseIDscreenName_screenNametype_type(let rhsEvent, let rhsBivalue, let rhsCourseid, let rhsScreenname, let rhsType)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsEvent, rhs: rhsEvent, with: matcher), lhsEvent, rhsEvent, "_ event")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsBivalue, rhs: rhsBivalue, with: matcher), lhsBivalue, rhsBivalue, "bivalue")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsCourseid, rhs: rhsCourseid, with: matcher), lhsCourseid, rhsCourseid, "courseID")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsScreenname, rhs: rhsScreenname, with: matcher), lhsScreenname, rhsScreenname, "screenName")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsType, rhs: rhsType, with: matcher), lhsType, rhsType, "type")) + return Matcher.ComparisonResult(results) + + case (.m_plsSuccessEvent__eventbivalue_bivaluecourseID_courseIDscreenName_screenNametype_typesuccess_success(let lhsEvent, let lhsBivalue, let lhsCourseid, let lhsScreenname, let lhsType, let lhsSuccess), .m_plsSuccessEvent__eventbivalue_bivaluecourseID_courseIDscreenName_screenNametype_typesuccess_success(let rhsEvent, let rhsBivalue, let rhsCourseid, let rhsScreenname, let rhsType, let rhsSuccess)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsEvent, rhs: rhsEvent, with: matcher), lhsEvent, rhsEvent, "_ event")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsBivalue, rhs: rhsBivalue, with: matcher), lhsBivalue, rhsBivalue, "bivalue")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsCourseid, rhs: rhsCourseid, with: matcher), lhsCourseid, rhsCourseid, "courseID")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsScreenname, rhs: rhsScreenname, with: matcher), lhsScreenname, rhsScreenname, "screenName")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsType, rhs: rhsType, with: matcher), lhsType, rhsType, "type")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsSuccess, rhs: rhsSuccess, with: matcher), lhsSuccess, rhsSuccess, "success")) + return Matcher.ComparisonResult(results) + + case (.m_bulkDownloadVideosToggle__courseID_courseIDaction_action(let lhsCourseid, let lhsAction), .m_bulkDownloadVideosToggle__courseID_courseIDaction_action(let rhsCourseid, let rhsAction)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsCourseid, rhs: rhsCourseid, with: matcher), lhsCourseid, rhsCourseid, "courseID")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsAction, rhs: rhsAction, with: matcher), lhsAction, rhsAction, "action")) + return Matcher.ComparisonResult(results) + + case (.m_bulkDownloadVideosSubsection__courseID_courseIDsectionID_sectionIDsubSectionID_subSectionIDvideos_videos(let lhsCourseid, let lhsSectionid, let lhsSubsectionid, let lhsVideos), .m_bulkDownloadVideosSubsection__courseID_courseIDsectionID_sectionIDsubSectionID_subSectionIDvideos_videos(let rhsCourseid, let rhsSectionid, let rhsSubsectionid, let rhsVideos)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsCourseid, rhs: rhsCourseid, with: matcher), lhsCourseid, rhsCourseid, "courseID")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsSectionid, rhs: rhsSectionid, with: matcher), lhsSectionid, rhsSectionid, "sectionID")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsSubsectionid, rhs: rhsSubsectionid, with: matcher), lhsSubsectionid, rhsSubsectionid, "subSectionID")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsVideos, rhs: rhsVideos, with: matcher), lhsVideos, rhsVideos, "videos")) + return Matcher.ComparisonResult(results) + + case (.m_bulkDeleteVideosSubsection__courseID_courseIDsubSectionID_subSectionIDvideos_videos(let lhsCourseid, let lhsSubsectionid, let lhsVideos), .m_bulkDeleteVideosSubsection__courseID_courseIDsubSectionID_subSectionIDvideos_videos(let rhsCourseid, let rhsSubsectionid, let rhsVideos)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsCourseid, rhs: rhsCourseid, with: matcher), lhsCourseid, rhsCourseid, "courseID")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsSubsectionid, rhs: rhsSubsectionid, with: matcher), lhsSubsectionid, rhsSubsectionid, "subSectionID")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsVideos, rhs: rhsVideos, with: matcher), lhsVideos, rhsVideos, "videos")) + return Matcher.ComparisonResult(results) default: return .none } } func intValue() -> Int { switch self { - case let .m_resumeCourseTapped__courseId_courseIdcourseName_courseNameblockId_blockId(p0, p1, p2): return p0.intValue + p1.intValue + p2.intValue + case let .m_resumeCourseClicked__courseId_courseIdcourseName_courseNameblockId_blockId(p0, p1, p2): return p0.intValue + p1.intValue + p2.intValue case let .m_sequentialClicked__courseId_courseIdcourseName_courseNameblockId_blockIdblockName_blockName(p0, p1, p2, p3): return p0.intValue + p1.intValue + p2.intValue + p3.intValue case let .m_verticalClicked__courseId_courseIdcourseName_courseNameblockId_blockIdblockName_blockName(p0, p1, p2, p3): return p0.intValue + p1.intValue + p2.intValue + p3.intValue case let .m_nextBlockClicked__courseId_courseIdcourseName_courseNameblockId_blockIdblockName_blockName(p0, p1, p2, p3): return p0.intValue + p1.intValue + p2.intValue + p3.intValue @@ -1363,11 +1742,18 @@ open class CourseAnalyticsMock: CourseAnalytics, Mock { case let .m_courseOutlineDatesTabClicked__courseId_courseIdcourseName_courseName(p0, p1): return p0.intValue + p1.intValue case let .m_courseOutlineDiscussionTabClicked__courseId_courseIdcourseName_courseName(p0, p1): return p0.intValue + p1.intValue case let .m_courseOutlineHandoutsTabClicked__courseId_courseIdcourseName_courseName(p0, p1): return p0.intValue + p1.intValue + case let .m_datesComponentTapped__courseId_courseIdblockId_blockIdlink_linksupported_supported(p0, p1, p2, p3): return p0.intValue + p1.intValue + p2.intValue + p3.intValue + case let .m_trackCourseEvent__eventbiValue_biValuecourseID_courseID(p0, p1, p2): return p0.intValue + p1.intValue + p2.intValue + case let .m_plsEvent__eventbivalue_bivaluecourseID_courseIDscreenName_screenNametype_type(p0, p1, p2, p3, p4): return p0.intValue + p1.intValue + p2.intValue + p3.intValue + p4.intValue + case let .m_plsSuccessEvent__eventbivalue_bivaluecourseID_courseIDscreenName_screenNametype_typesuccess_success(p0, p1, p2, p3, p4, p5): return p0.intValue + p1.intValue + p2.intValue + p3.intValue + p4.intValue + p5.intValue + case let .m_bulkDownloadVideosToggle__courseID_courseIDaction_action(p0, p1): return p0.intValue + p1.intValue + case let .m_bulkDownloadVideosSubsection__courseID_courseIDsectionID_sectionIDsubSectionID_subSectionIDvideos_videos(p0, p1, p2, p3): return p0.intValue + p1.intValue + p2.intValue + p3.intValue + case let .m_bulkDeleteVideosSubsection__courseID_courseIDsubSectionID_subSectionIDvideos_videos(p0, p1, p2): return p0.intValue + p1.intValue + p2.intValue } } func assertionName() -> String { switch self { - case .m_resumeCourseTapped__courseId_courseIdcourseName_courseNameblockId_blockId: return ".resumeCourseTapped(courseId:courseName:blockId:)" + case .m_resumeCourseClicked__courseId_courseIdcourseName_courseNameblockId_blockId: return ".resumeCourseClicked(courseId:courseName:blockId:)" case .m_sequentialClicked__courseId_courseIdcourseName_courseNameblockId_blockIdblockName_blockName: return ".sequentialClicked(courseId:courseName:blockId:blockName:)" case .m_verticalClicked__courseId_courseIdcourseName_courseNameblockId_blockIdblockName_blockName: return ".verticalClicked(courseId:courseName:blockId:blockName:)" case .m_nextBlockClicked__courseId_courseIdcourseName_courseNameblockId_blockIdblockName_blockName: return ".nextBlockClicked(courseId:courseName:blockId:blockName:)" @@ -1380,6 +1766,13 @@ open class CourseAnalyticsMock: CourseAnalytics, Mock { case .m_courseOutlineDatesTabClicked__courseId_courseIdcourseName_courseName: return ".courseOutlineDatesTabClicked(courseId:courseName:)" case .m_courseOutlineDiscussionTabClicked__courseId_courseIdcourseName_courseName: return ".courseOutlineDiscussionTabClicked(courseId:courseName:)" case .m_courseOutlineHandoutsTabClicked__courseId_courseIdcourseName_courseName: return ".courseOutlineHandoutsTabClicked(courseId:courseName:)" + case .m_datesComponentTapped__courseId_courseIdblockId_blockIdlink_linksupported_supported: return ".datesComponentTapped(courseId:blockId:link:supported:)" + case .m_trackCourseEvent__eventbiValue_biValuecourseID_courseID: return ".trackCourseEvent(_:biValue:courseID:)" + case .m_plsEvent__eventbivalue_bivaluecourseID_courseIDscreenName_screenNametype_type: return ".plsEvent(_:bivalue:courseID:screenName:type:)" + case .m_plsSuccessEvent__eventbivalue_bivaluecourseID_courseIDscreenName_screenNametype_typesuccess_success: return ".plsSuccessEvent(_:bivalue:courseID:screenName:type:success:)" + case .m_bulkDownloadVideosToggle__courseID_courseIDaction_action: return ".bulkDownloadVideosToggle(courseID:action:)" + case .m_bulkDownloadVideosSubsection__courseID_courseIDsectionID_sectionIDsubSectionID_subSectionIDvideos_videos: return ".bulkDownloadVideosSubsection(courseID:sectionID:subSectionID:videos:)" + case .m_bulkDeleteVideosSubsection__courseID_courseIDsubSectionID_subSectionIDvideos_videos: return ".bulkDeleteVideosSubsection(courseID:subSectionID:videos:)" } } } @@ -1398,7 +1791,7 @@ open class CourseAnalyticsMock: CourseAnalytics, Mock { public struct Verify { fileprivate var method: MethodType - public static func resumeCourseTapped(courseId: Parameter, courseName: Parameter, blockId: Parameter) -> Verify { return Verify(method: .m_resumeCourseTapped__courseId_courseIdcourseName_courseNameblockId_blockId(`courseId`, `courseName`, `blockId`))} + public static func resumeCourseClicked(courseId: Parameter, courseName: Parameter, blockId: Parameter) -> Verify { return Verify(method: .m_resumeCourseClicked__courseId_courseIdcourseName_courseNameblockId_blockId(`courseId`, `courseName`, `blockId`))} public static func sequentialClicked(courseId: Parameter, courseName: Parameter, blockId: Parameter, blockName: Parameter) -> Verify { return Verify(method: .m_sequentialClicked__courseId_courseIdcourseName_courseNameblockId_blockIdblockName_blockName(`courseId`, `courseName`, `blockId`, `blockName`))} public static func verticalClicked(courseId: Parameter, courseName: Parameter, blockId: Parameter, blockName: Parameter) -> Verify { return Verify(method: .m_verticalClicked__courseId_courseIdcourseName_courseNameblockId_blockIdblockName_blockName(`courseId`, `courseName`, `blockId`, `blockName`))} public static func nextBlockClicked(courseId: Parameter, courseName: Parameter, blockId: Parameter, blockName: Parameter) -> Verify { return Verify(method: .m_nextBlockClicked__courseId_courseIdcourseName_courseNameblockId_blockIdblockName_blockName(`courseId`, `courseName`, `blockId`, `blockName`))} @@ -1411,14 +1804,21 @@ open class CourseAnalyticsMock: CourseAnalytics, Mock { public static func courseOutlineDatesTabClicked(courseId: Parameter, courseName: Parameter) -> Verify { return Verify(method: .m_courseOutlineDatesTabClicked__courseId_courseIdcourseName_courseName(`courseId`, `courseName`))} public static func courseOutlineDiscussionTabClicked(courseId: Parameter, courseName: Parameter) -> Verify { return Verify(method: .m_courseOutlineDiscussionTabClicked__courseId_courseIdcourseName_courseName(`courseId`, `courseName`))} public static func courseOutlineHandoutsTabClicked(courseId: Parameter, courseName: Parameter) -> Verify { return Verify(method: .m_courseOutlineHandoutsTabClicked__courseId_courseIdcourseName_courseName(`courseId`, `courseName`))} + public static func datesComponentTapped(courseId: Parameter, blockId: Parameter, link: Parameter, supported: Parameter) -> Verify { return Verify(method: .m_datesComponentTapped__courseId_courseIdblockId_blockIdlink_linksupported_supported(`courseId`, `blockId`, `link`, `supported`))} + public static func trackCourseEvent(_ event: Parameter, biValue: Parameter, courseID: Parameter) -> Verify { return Verify(method: .m_trackCourseEvent__eventbiValue_biValuecourseID_courseID(`event`, `biValue`, `courseID`))} + public static func plsEvent(_ event: Parameter, bivalue: Parameter, courseID: Parameter, screenName: Parameter, type: Parameter) -> Verify { return Verify(method: .m_plsEvent__eventbivalue_bivaluecourseID_courseIDscreenName_screenNametype_type(`event`, `bivalue`, `courseID`, `screenName`, `type`))} + public static func plsSuccessEvent(_ event: Parameter, bivalue: Parameter, courseID: Parameter, screenName: Parameter, type: Parameter, success: Parameter) -> Verify { return Verify(method: .m_plsSuccessEvent__eventbivalue_bivaluecourseID_courseIDscreenName_screenNametype_typesuccess_success(`event`, `bivalue`, `courseID`, `screenName`, `type`, `success`))} + public static func bulkDownloadVideosToggle(courseID: Parameter, action: Parameter) -> Verify { return Verify(method: .m_bulkDownloadVideosToggle__courseID_courseIDaction_action(`courseID`, `action`))} + public static func bulkDownloadVideosSubsection(courseID: Parameter, sectionID: Parameter, subSectionID: Parameter, videos: Parameter) -> Verify { return Verify(method: .m_bulkDownloadVideosSubsection__courseID_courseIDsectionID_sectionIDsubSectionID_subSectionIDvideos_videos(`courseID`, `sectionID`, `subSectionID`, `videos`))} + public static func bulkDeleteVideosSubsection(courseID: Parameter, subSectionID: Parameter, videos: Parameter) -> Verify { return Verify(method: .m_bulkDeleteVideosSubsection__courseID_courseIDsubSectionID_subSectionIDvideos_videos(`courseID`, `subSectionID`, `videos`))} } public struct Perform { fileprivate var method: MethodType var performs: Any - public static func resumeCourseTapped(courseId: Parameter, courseName: Parameter, blockId: Parameter, perform: @escaping (String, String, String) -> Void) -> Perform { - return Perform(method: .m_resumeCourseTapped__courseId_courseIdcourseName_courseNameblockId_blockId(`courseId`, `courseName`, `blockId`), performs: perform) + public static func resumeCourseClicked(courseId: Parameter, courseName: Parameter, blockId: Parameter, perform: @escaping (String, String, String) -> Void) -> Perform { + return Perform(method: .m_resumeCourseClicked__courseId_courseIdcourseName_courseNameblockId_blockId(`courseId`, `courseName`, `blockId`), performs: perform) } public static func sequentialClicked(courseId: Parameter, courseName: Parameter, blockId: Parameter, blockName: Parameter, perform: @escaping (String, String, String, String) -> Void) -> Perform { return Perform(method: .m_sequentialClicked__courseId_courseIdcourseName_courseNameblockId_blockIdblockName_blockName(`courseId`, `courseName`, `blockId`, `blockName`), performs: perform) @@ -1456,6 +1856,27 @@ open class CourseAnalyticsMock: CourseAnalytics, Mock { public static func courseOutlineHandoutsTabClicked(courseId: Parameter, courseName: Parameter, perform: @escaping (String, String) -> Void) -> Perform { return Perform(method: .m_courseOutlineHandoutsTabClicked__courseId_courseIdcourseName_courseName(`courseId`, `courseName`), performs: perform) } + public static func datesComponentTapped(courseId: Parameter, blockId: Parameter, link: Parameter, supported: Parameter, perform: @escaping (String, String, String, Bool) -> Void) -> Perform { + return Perform(method: .m_datesComponentTapped__courseId_courseIdblockId_blockIdlink_linksupported_supported(`courseId`, `blockId`, `link`, `supported`), performs: perform) + } + public static func trackCourseEvent(_ event: Parameter, biValue: Parameter, courseID: Parameter, perform: @escaping (AnalyticsEvent, EventBIValue, String) -> Void) -> Perform { + return Perform(method: .m_trackCourseEvent__eventbiValue_biValuecourseID_courseID(`event`, `biValue`, `courseID`), performs: perform) + } + public static func plsEvent(_ event: Parameter, bivalue: Parameter, courseID: Parameter, screenName: Parameter, type: Parameter, perform: @escaping (AnalyticsEvent, EventBIValue, String, String, String) -> Void) -> Perform { + return Perform(method: .m_plsEvent__eventbivalue_bivaluecourseID_courseIDscreenName_screenNametype_type(`event`, `bivalue`, `courseID`, `screenName`, `type`), performs: perform) + } + public static func plsSuccessEvent(_ event: Parameter, bivalue: Parameter, courseID: Parameter, screenName: Parameter, type: Parameter, success: Parameter, perform: @escaping (AnalyticsEvent, EventBIValue, String, String, String, Bool) -> Void) -> Perform { + return Perform(method: .m_plsSuccessEvent__eventbivalue_bivaluecourseID_courseIDscreenName_screenNametype_typesuccess_success(`event`, `bivalue`, `courseID`, `screenName`, `type`, `success`), performs: perform) + } + public static func bulkDownloadVideosToggle(courseID: Parameter, action: Parameter, perform: @escaping (String, Bool) -> Void) -> Perform { + return Perform(method: .m_bulkDownloadVideosToggle__courseID_courseIDaction_action(`courseID`, `action`), performs: perform) + } + public static func bulkDownloadVideosSubsection(courseID: Parameter, sectionID: Parameter, subSectionID: Parameter, videos: Parameter, perform: @escaping (String, String, String, Int) -> Void) -> Perform { + return Perform(method: .m_bulkDownloadVideosSubsection__courseID_courseIDsectionID_sectionIDsubSectionID_subSectionIDvideos_videos(`courseID`, `sectionID`, `subSectionID`, `videos`), performs: perform) + } + public static func bulkDeleteVideosSubsection(courseID: Parameter, subSectionID: Parameter, videos: Parameter, perform: @escaping (String, String, Int) -> Void) -> Perform { + return Perform(method: .m_bulkDeleteVideosSubsection__courseID_courseIDsubSectionID_subSectionIDvideos_videos(`courseID`, `subSectionID`, `videos`), performs: perform) + } } public func given(_ method: Given) { diff --git a/Course/CourseTests/Presentation/Container/CourseContainerViewModelTests.swift b/Course/CourseTests/Presentation/Container/CourseContainerViewModelTests.swift index 57948a995..d8160aff6 100644 --- a/Course/CourseTests/Presentation/Container/CourseContainerViewModelTests.swift +++ b/Course/CourseTests/Presentation/Container/CourseContainerViewModelTests.swift @@ -39,7 +39,8 @@ final class CourseContainerViewModelTests: XCTestCase { courseStart: Date(), courseEnd: nil, enrollmentStart: nil, - enrollmentEnd: nil + enrollmentEnd: nil, + coreAnalytics: CoreAnalyticsMock() ) let block = CourseBlock( @@ -143,7 +144,8 @@ final class CourseContainerViewModelTests: XCTestCase { courseStart: Date(), courseEnd: nil, enrollmentStart: nil, - enrollmentEnd: nil + enrollmentEnd: nil, + coreAnalytics: CoreAnalyticsMock() ) let courseStructure = CourseStructure( @@ -199,7 +201,8 @@ final class CourseContainerViewModelTests: XCTestCase { courseStart: Date(), courseEnd: nil, enrollmentStart: nil, - enrollmentEnd: nil + enrollmentEnd: nil, + coreAnalytics: CoreAnalyticsMock() ) let noInternetError = AFError.sessionInvalidated(error: URLError(.notConnectedToInternet)) @@ -241,7 +244,8 @@ final class CourseContainerViewModelTests: XCTestCase { courseStart: Date(), courseEnd: nil, enrollmentStart: nil, - enrollmentEnd: nil + enrollmentEnd: nil, + coreAnalytics: CoreAnalyticsMock() ) Given(interactor, .getCourseBlocks(courseID: "123", @@ -280,7 +284,8 @@ final class CourseContainerViewModelTests: XCTestCase { courseStart: Date(), courseEnd: nil, enrollmentStart: nil, - enrollmentEnd: nil + enrollmentEnd: nil, + coreAnalytics: CoreAnalyticsMock() ) Given(interactor, .getCourseBlocks(courseID: "123", @@ -319,7 +324,8 @@ final class CourseContainerViewModelTests: XCTestCase { courseStart: Date(), courseEnd: nil, enrollmentStart: nil, - enrollmentEnd: nil + enrollmentEnd: nil, + coreAnalytics: CoreAnalyticsMock() ) viewModel.trackSelectedTab(selection: .course, courseId: "1", courseName: "name") @@ -447,7 +453,8 @@ final class CourseContainerViewModelTests: XCTestCase { courseStart: Date(), courseEnd: nil, enrollmentStart: nil, - enrollmentEnd: nil + enrollmentEnd: nil, + coreAnalytics: CoreAnalyticsMock() ) viewModel.courseStructure = courseStructure await viewModel.setDownloadsStates() @@ -564,7 +571,8 @@ final class CourseContainerViewModelTests: XCTestCase { courseStart: Date(), courseEnd: nil, enrollmentStart: nil, - enrollmentEnd: nil + enrollmentEnd: nil, + coreAnalytics: CoreAnalyticsMock() ) viewModel.courseStructure = courseStructure await viewModel.setDownloadsStates() @@ -681,7 +689,8 @@ final class CourseContainerViewModelTests: XCTestCase { courseStart: Date(), courseEnd: nil, enrollmentStart: nil, - enrollmentEnd: nil + enrollmentEnd: nil, + coreAnalytics: CoreAnalyticsMock() ) viewModel.courseStructure = courseStructure await viewModel.setDownloadsStates() @@ -799,7 +808,8 @@ final class CourseContainerViewModelTests: XCTestCase { courseStart: Date(), courseEnd: nil, enrollmentStart: nil, - enrollmentEnd: nil + enrollmentEnd: nil, + coreAnalytics: CoreAnalyticsMock() ) viewModel.courseStructure = courseStructure await viewModel.setDownloadsStates() @@ -925,7 +935,8 @@ final class CourseContainerViewModelTests: XCTestCase { courseStart: Date(), courseEnd: nil, enrollmentStart: nil, - enrollmentEnd: nil + enrollmentEnd: nil, + coreAnalytics: CoreAnalyticsMock() ) viewModel.courseStructure = courseStructure await viewModel.setDownloadsStates() @@ -1051,7 +1062,8 @@ final class CourseContainerViewModelTests: XCTestCase { courseStart: Date(), courseEnd: nil, enrollmentStart: nil, - enrollmentEnd: nil + enrollmentEnd: nil, + coreAnalytics: CoreAnalyticsMock() ) viewModel.courseStructure = courseStructure await viewModel.setDownloadsStates() @@ -1196,7 +1208,8 @@ final class CourseContainerViewModelTests: XCTestCase { courseStart: Date(), courseEnd: nil, enrollmentStart: nil, - enrollmentEnd: nil + enrollmentEnd: nil, + coreAnalytics: CoreAnalyticsMock() ) viewModel.courseStructure = courseStructure await viewModel.setDownloadsStates() diff --git a/Course/CourseTests/Presentation/Unit/CourseDateViewModelTests.swift b/Course/CourseTests/Presentation/Unit/CourseDateViewModelTests.swift index 765b8a763..68f23f71b 100644 --- a/Course/CourseTests/Presentation/Unit/CourseDateViewModelTests.swift +++ b/Course/CourseTests/Presentation/Unit/CourseDateViewModelTests.swift @@ -38,7 +38,8 @@ final class CourseDateViewModelTests: XCTestCase { router: router, cssInjector: cssInjector, connectivity: connectivity, - courseID: "1") + courseID: "1", + analytics: CourseAnalyticsMock()) await viewModel.getCourseDates(courseID: "1") @@ -63,7 +64,8 @@ final class CourseDateViewModelTests: XCTestCase { router: router, cssInjector: cssInjector, connectivity: connectivity, - courseID: "1") + courseID: "1", + analytics: CourseAnalyticsMock()) await viewModel.getCourseDates(courseID: "1") @@ -88,7 +90,8 @@ final class CourseDateViewModelTests: XCTestCase { router: router, cssInjector: cssInjector, connectivity: connectivity, - courseID: "1") + courseID: "1", + analytics: CourseAnalyticsMock()) await viewModel.getCourseDates(courseID: "1") diff --git a/Course/CourseTests/Presentation/Unit/HandoutsViewModelTests.swift b/Course/CourseTests/Presentation/Unit/HandoutsViewModelTests.swift index 4e9500a7f..c5874f90c 100644 --- a/Course/CourseTests/Presentation/Unit/HandoutsViewModelTests.swift +++ b/Course/CourseTests/Presentation/Unit/HandoutsViewModelTests.swift @@ -24,11 +24,13 @@ final class HandoutsViewModelTests: XCTestCase { Given(interactor, .getHandouts(courseID: .any, willReturn: "Result")) Given(interactor, .getUpdates(courseID: .any, willReturn: courseUpdate)) - let viewModel = HandoutsViewModel(interactor: interactor, - router: router, - cssInjector: CSSInjectorMock(), - connectivity: connectivity, - courseID: "123") + let viewModel = HandoutsViewModel( + interactor: interactor, + router: router, + cssInjector: CSSInjectorMock(), + connectivity: connectivity, + courseID: "123", + analytics: CourseAnalyticsMock()) await viewModel.getHandouts(courseID: "") @@ -50,11 +52,13 @@ final class HandoutsViewModelTests: XCTestCase { Given(interactor, .getHandouts(courseID: .any, willThrow: noInternetError)) Given(interactor, .getUpdates(courseID: .any, willThrow: noInternetError)) - let viewModel = HandoutsViewModel(interactor: interactor, - router: router, - cssInjector: CSSInjectorMock(), - connectivity: connectivity, - courseID: "123") + let viewModel = HandoutsViewModel( + interactor: interactor, + router: router, + cssInjector: CSSInjectorMock(), + connectivity: connectivity, + courseID: "123", + analytics: CourseAnalyticsMock()) await viewModel.getHandouts(courseID: "") @@ -74,11 +78,13 @@ final class HandoutsViewModelTests: XCTestCase { Given(interactor, .getHandouts(courseID: .any, willThrow: NSError())) Given(interactor, .getUpdates(courseID: .any, willThrow: NSError())) - let viewModel = HandoutsViewModel(interactor: interactor, - router: router, - cssInjector: CSSInjectorMock(), - connectivity: connectivity, - courseID: "123") + let viewModel = HandoutsViewModel( + interactor: interactor, + router: router, + cssInjector: CSSInjectorMock(), + connectivity: connectivity, + courseID: "123", + analytics: CourseAnalyticsMock()) await viewModel.getHandouts(courseID: "") @@ -99,11 +105,13 @@ final class HandoutsViewModelTests: XCTestCase { Given(interactor, .getUpdates(courseID: .any, willReturn: courseUpdate)) - let viewModel = HandoutsViewModel(interactor: interactor, - router: router, - cssInjector: CSSInjectorMock(), - connectivity: connectivity, - courseID: "123") + let viewModel = HandoutsViewModel( + interactor: interactor, + router: router, + cssInjector: CSSInjectorMock(), + connectivity: connectivity, + courseID: "123", + analytics: CourseAnalyticsMock()) await viewModel.getUpdates(courseID: "") @@ -120,11 +128,13 @@ final class HandoutsViewModelTests: XCTestCase { let router = CourseRouterMock() let connectivity = ConnectivityProtocolMock() - let viewModel = HandoutsViewModel(interactor: interactor, - router: router, - cssInjector: CSSInjectorMock(), - connectivity: connectivity, - courseID: "123") + let viewModel = HandoutsViewModel( + interactor: interactor, + router: router, + cssInjector: CSSInjectorMock(), + connectivity: connectivity, + courseID: "123", + analytics: CourseAnalyticsMock()) let noInternetError = AFError.sessionInvalidated(error: URLError(.notConnectedToInternet)) @@ -145,11 +155,13 @@ final class HandoutsViewModelTests: XCTestCase { let router = CourseRouterMock() let connectivity = ConnectivityProtocolMock() - let viewModel = HandoutsViewModel(interactor: interactor, - router: router, - cssInjector: CSSInjectorMock(), - connectivity: connectivity, - courseID: "123") + let viewModel = HandoutsViewModel( + interactor: interactor, + router: router, + cssInjector: CSSInjectorMock(), + connectivity: connectivity, + courseID: "123", + analytics: CourseAnalyticsMock()) Given(interactor, .getUpdates(courseID: .any, willThrow: NSError())) diff --git a/Dashboard/DashboardTests/DashboardMock.generated.swift b/Dashboard/DashboardTests/DashboardMock.generated.swift index 9e3f24919..8f1b9f79e 100644 --- a/Dashboard/DashboardTests/DashboardMock.generated.swift +++ b/Dashboard/DashboardTests/DashboardMock.generated.swift @@ -1114,6 +1114,281 @@ open class ConnectivityProtocolMock: ConnectivityProtocol, Mock { } } +// MARK: - CoreAnalytics + +open class CoreAnalyticsMock: CoreAnalytics, Mock { + public init(sequencing sequencingPolicy: SequencingPolicy = .lastWrittenResolvedFirst, stubbing stubbingPolicy: StubbingPolicy = .wrap, file: StaticString = #file, line: UInt = #line) { + SwiftyMockyTestObserver.setup() + self.sequencingPolicy = sequencingPolicy + self.stubbingPolicy = stubbingPolicy + self.file = file + self.line = line + } + + var matcher: Matcher = Matcher.default + var stubbingPolicy: StubbingPolicy = .wrap + var sequencingPolicy: SequencingPolicy = .lastWrittenResolvedFirst + + private var queue = DispatchQueue(label: "com.swiftymocky.invocations", qos: .userInteractive) + private var invocations: [MethodType] = [] + private var methodReturnValues: [Given] = [] + private var methodPerformValues: [Perform] = [] + private var file: StaticString? + private var line: UInt? + + public typealias PropertyStub = Given + public typealias MethodStub = Given + public typealias SubscriptStub = Given + + /// Convenience method - call setupMock() to extend debug information when failure occurs + public func setupMock(file: StaticString = #file, line: UInt = #line) { + self.file = file + self.line = line + } + + /// Clear mock internals. You can specify what to reset (invocations aka verify, givens or performs) or leave it empty to clear all mock internals + public func resetMock(_ scopes: MockScope...) { + let scopes: [MockScope] = scopes.isEmpty ? [.invocation, .given, .perform] : scopes + if scopes.contains(.invocation) { invocations = [] } + if scopes.contains(.given) { methodReturnValues = [] } + if scopes.contains(.perform) { methodPerformValues = [] } + } + + + + + + open func trackEvent(_ event: AnalyticsEvent, parameters: [String: Any]?) { + addInvocation(.m_trackEvent__eventparameters_parameters(Parameter.value(`event`), Parameter<[String: Any]?>.value(`parameters`))) + let perform = methodPerformValue(.m_trackEvent__eventparameters_parameters(Parameter.value(`event`), Parameter<[String: Any]?>.value(`parameters`))) as? (AnalyticsEvent, [String: Any]?) -> Void + perform?(`event`, `parameters`) + } + + open func trackEvent(_ event: AnalyticsEvent, biValue: EventBIValue, parameters: [String: Any]?) { + addInvocation(.m_trackEvent__eventbiValue_biValueparameters_parameters(Parameter.value(`event`), Parameter.value(`biValue`), Parameter<[String: Any]?>.value(`parameters`))) + let perform = methodPerformValue(.m_trackEvent__eventbiValue_biValueparameters_parameters(Parameter.value(`event`), Parameter.value(`biValue`), Parameter<[String: Any]?>.value(`parameters`))) as? (AnalyticsEvent, EventBIValue, [String: Any]?) -> Void + perform?(`event`, `biValue`, `parameters`) + } + + open func appreview(_ event: AnalyticsEvent, biValue: EventBIValue, action: String?, rating: Int?) { + addInvocation(.m_appreview__eventbiValue_biValueaction_actionrating_rating(Parameter.value(`event`), Parameter.value(`biValue`), Parameter.value(`action`), Parameter.value(`rating`))) + let perform = methodPerformValue(.m_appreview__eventbiValue_biValueaction_actionrating_rating(Parameter.value(`event`), Parameter.value(`biValue`), Parameter.value(`action`), Parameter.value(`rating`))) as? (AnalyticsEvent, EventBIValue, String?, Int?) -> Void + perform?(`event`, `biValue`, `action`, `rating`) + } + + open func videoQualityChanged(_ event: AnalyticsEvent, bivalue: EventBIValue, value: String, oldValue: String) { + addInvocation(.m_videoQualityChanged__eventbivalue_bivaluevalue_valueoldValue_oldValue(Parameter.value(`event`), Parameter.value(`bivalue`), Parameter.value(`value`), Parameter.value(`oldValue`))) + let perform = methodPerformValue(.m_videoQualityChanged__eventbivalue_bivaluevalue_valueoldValue_oldValue(Parameter.value(`event`), Parameter.value(`bivalue`), Parameter.value(`value`), Parameter.value(`oldValue`))) as? (AnalyticsEvent, EventBIValue, String, String) -> Void + perform?(`event`, `bivalue`, `value`, `oldValue`) + } + + open func trackEvent(_ event: AnalyticsEvent) { + addInvocation(.m_trackEvent__event(Parameter.value(`event`))) + let perform = methodPerformValue(.m_trackEvent__event(Parameter.value(`event`))) as? (AnalyticsEvent) -> Void + perform?(`event`) + } + + open func trackEvent(_ event: AnalyticsEvent, biValue: EventBIValue) { + addInvocation(.m_trackEvent__eventbiValue_biValue(Parameter.value(`event`), Parameter.value(`biValue`))) + let perform = methodPerformValue(.m_trackEvent__eventbiValue_biValue(Parameter.value(`event`), Parameter.value(`biValue`))) as? (AnalyticsEvent, EventBIValue) -> Void + perform?(`event`, `biValue`) + } + + + fileprivate enum MethodType { + case m_trackEvent__eventparameters_parameters(Parameter, Parameter<[String: Any]?>) + case m_trackEvent__eventbiValue_biValueparameters_parameters(Parameter, Parameter, Parameter<[String: Any]?>) + case m_appreview__eventbiValue_biValueaction_actionrating_rating(Parameter, Parameter, Parameter, Parameter) + case m_videoQualityChanged__eventbivalue_bivaluevalue_valueoldValue_oldValue(Parameter, Parameter, Parameter, Parameter) + case m_trackEvent__event(Parameter) + case m_trackEvent__eventbiValue_biValue(Parameter, Parameter) + + static func compareParameters(lhs: MethodType, rhs: MethodType, matcher: Matcher) -> Matcher.ComparisonResult { + switch (lhs, rhs) { + case (.m_trackEvent__eventparameters_parameters(let lhsEvent, let lhsParameters), .m_trackEvent__eventparameters_parameters(let rhsEvent, let rhsParameters)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsEvent, rhs: rhsEvent, with: matcher), lhsEvent, rhsEvent, "_ event")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsParameters, rhs: rhsParameters, with: matcher), lhsParameters, rhsParameters, "parameters")) + return Matcher.ComparisonResult(results) + + case (.m_trackEvent__eventbiValue_biValueparameters_parameters(let lhsEvent, let lhsBivalue, let lhsParameters), .m_trackEvent__eventbiValue_biValueparameters_parameters(let rhsEvent, let rhsBivalue, let rhsParameters)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsEvent, rhs: rhsEvent, with: matcher), lhsEvent, rhsEvent, "_ event")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsBivalue, rhs: rhsBivalue, with: matcher), lhsBivalue, rhsBivalue, "biValue")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsParameters, rhs: rhsParameters, with: matcher), lhsParameters, rhsParameters, "parameters")) + return Matcher.ComparisonResult(results) + + case (.m_appreview__eventbiValue_biValueaction_actionrating_rating(let lhsEvent, let lhsBivalue, let lhsAction, let lhsRating), .m_appreview__eventbiValue_biValueaction_actionrating_rating(let rhsEvent, let rhsBivalue, let rhsAction, let rhsRating)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsEvent, rhs: rhsEvent, with: matcher), lhsEvent, rhsEvent, "_ event")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsBivalue, rhs: rhsBivalue, with: matcher), lhsBivalue, rhsBivalue, "biValue")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsAction, rhs: rhsAction, with: matcher), lhsAction, rhsAction, "action")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsRating, rhs: rhsRating, with: matcher), lhsRating, rhsRating, "rating")) + return Matcher.ComparisonResult(results) + + case (.m_videoQualityChanged__eventbivalue_bivaluevalue_valueoldValue_oldValue(let lhsEvent, let lhsBivalue, let lhsValue, let lhsOldvalue), .m_videoQualityChanged__eventbivalue_bivaluevalue_valueoldValue_oldValue(let rhsEvent, let rhsBivalue, let rhsValue, let rhsOldvalue)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsEvent, rhs: rhsEvent, with: matcher), lhsEvent, rhsEvent, "_ event")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsBivalue, rhs: rhsBivalue, with: matcher), lhsBivalue, rhsBivalue, "bivalue")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsValue, rhs: rhsValue, with: matcher), lhsValue, rhsValue, "value")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsOldvalue, rhs: rhsOldvalue, with: matcher), lhsOldvalue, rhsOldvalue, "oldValue")) + return Matcher.ComparisonResult(results) + + case (.m_trackEvent__event(let lhsEvent), .m_trackEvent__event(let rhsEvent)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsEvent, rhs: rhsEvent, with: matcher), lhsEvent, rhsEvent, "_ event")) + return Matcher.ComparisonResult(results) + + case (.m_trackEvent__eventbiValue_biValue(let lhsEvent, let lhsBivalue), .m_trackEvent__eventbiValue_biValue(let rhsEvent, let rhsBivalue)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsEvent, rhs: rhsEvent, with: matcher), lhsEvent, rhsEvent, "_ event")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsBivalue, rhs: rhsBivalue, with: matcher), lhsBivalue, rhsBivalue, "biValue")) + return Matcher.ComparisonResult(results) + default: return .none + } + } + + func intValue() -> Int { + switch self { + case let .m_trackEvent__eventparameters_parameters(p0, p1): return p0.intValue + p1.intValue + case let .m_trackEvent__eventbiValue_biValueparameters_parameters(p0, p1, p2): return p0.intValue + p1.intValue + p2.intValue + case let .m_appreview__eventbiValue_biValueaction_actionrating_rating(p0, p1, p2, p3): return p0.intValue + p1.intValue + p2.intValue + p3.intValue + case let .m_videoQualityChanged__eventbivalue_bivaluevalue_valueoldValue_oldValue(p0, p1, p2, p3): return p0.intValue + p1.intValue + p2.intValue + p3.intValue + case let .m_trackEvent__event(p0): return p0.intValue + case let .m_trackEvent__eventbiValue_biValue(p0, p1): return p0.intValue + p1.intValue + } + } + func assertionName() -> String { + switch self { + case .m_trackEvent__eventparameters_parameters: return ".trackEvent(_:parameters:)" + case .m_trackEvent__eventbiValue_biValueparameters_parameters: return ".trackEvent(_:biValue:parameters:)" + case .m_appreview__eventbiValue_biValueaction_actionrating_rating: return ".appreview(_:biValue:action:rating:)" + case .m_videoQualityChanged__eventbivalue_bivaluevalue_valueoldValue_oldValue: return ".videoQualityChanged(_:bivalue:value:oldValue:)" + case .m_trackEvent__event: return ".trackEvent(_:)" + case .m_trackEvent__eventbiValue_biValue: return ".trackEvent(_:biValue:)" + } + } + } + + open class Given: StubbedMethod { + fileprivate var method: MethodType + + private init(method: MethodType, products: [StubProduct]) { + self.method = method + super.init(products) + } + + + } + + public struct Verify { + fileprivate var method: MethodType + + public static func trackEvent(_ event: Parameter, parameters: Parameter<[String: Any]?>) -> Verify { return Verify(method: .m_trackEvent__eventparameters_parameters(`event`, `parameters`))} + public static func trackEvent(_ event: Parameter, biValue: Parameter, parameters: Parameter<[String: Any]?>) -> Verify { return Verify(method: .m_trackEvent__eventbiValue_biValueparameters_parameters(`event`, `biValue`, `parameters`))} + public static func appreview(_ event: Parameter, biValue: Parameter, action: Parameter, rating: Parameter) -> Verify { return Verify(method: .m_appreview__eventbiValue_biValueaction_actionrating_rating(`event`, `biValue`, `action`, `rating`))} + public static func videoQualityChanged(_ event: Parameter, bivalue: Parameter, value: Parameter, oldValue: Parameter) -> Verify { return Verify(method: .m_videoQualityChanged__eventbivalue_bivaluevalue_valueoldValue_oldValue(`event`, `bivalue`, `value`, `oldValue`))} + public static func trackEvent(_ event: Parameter) -> Verify { return Verify(method: .m_trackEvent__event(`event`))} + public static func trackEvent(_ event: Parameter, biValue: Parameter) -> Verify { return Verify(method: .m_trackEvent__eventbiValue_biValue(`event`, `biValue`))} + } + + public struct Perform { + fileprivate var method: MethodType + var performs: Any + + public static func trackEvent(_ event: Parameter, parameters: Parameter<[String: Any]?>, perform: @escaping (AnalyticsEvent, [String: Any]?) -> Void) -> Perform { + return Perform(method: .m_trackEvent__eventparameters_parameters(`event`, `parameters`), performs: perform) + } + public static func trackEvent(_ event: Parameter, biValue: Parameter, parameters: Parameter<[String: Any]?>, perform: @escaping (AnalyticsEvent, EventBIValue, [String: Any]?) -> Void) -> Perform { + return Perform(method: .m_trackEvent__eventbiValue_biValueparameters_parameters(`event`, `biValue`, `parameters`), performs: perform) + } + public static func appreview(_ event: Parameter, biValue: Parameter, action: Parameter, rating: Parameter, perform: @escaping (AnalyticsEvent, EventBIValue, String?, Int?) -> Void) -> Perform { + return Perform(method: .m_appreview__eventbiValue_biValueaction_actionrating_rating(`event`, `biValue`, `action`, `rating`), performs: perform) + } + public static func videoQualityChanged(_ event: Parameter, bivalue: Parameter, value: Parameter, oldValue: Parameter, perform: @escaping (AnalyticsEvent, EventBIValue, String, String) -> Void) -> Perform { + return Perform(method: .m_videoQualityChanged__eventbivalue_bivaluevalue_valueoldValue_oldValue(`event`, `bivalue`, `value`, `oldValue`), performs: perform) + } + public static func trackEvent(_ event: Parameter, perform: @escaping (AnalyticsEvent) -> Void) -> Perform { + return Perform(method: .m_trackEvent__event(`event`), performs: perform) + } + public static func trackEvent(_ event: Parameter, biValue: Parameter, perform: @escaping (AnalyticsEvent, EventBIValue) -> Void) -> Perform { + return Perform(method: .m_trackEvent__eventbiValue_biValue(`event`, `biValue`), performs: perform) + } + } + + public func given(_ method: Given) { + methodReturnValues.append(method) + } + + public func perform(_ method: Perform) { + methodPerformValues.append(method) + methodPerformValues.sort { $0.method.intValue() < $1.method.intValue() } + } + + public func verify(_ method: Verify, count: Count = Count.moreOrEqual(to: 1), file: StaticString = #file, line: UInt = #line) { + let fullMatches = matchingCalls(method, file: file, line: line) + let success = count.matches(fullMatches) + let assertionName = method.method.assertionName() + let feedback: String = { + guard !success else { return "" } + return Utils.closestCallsMessage( + for: self.invocations.map { invocation in + matcher.set(file: file, line: line) + defer { matcher.clearFileAndLine() } + return MethodType.compareParameters(lhs: invocation, rhs: method.method, matcher: matcher) + }, + name: assertionName + ) + }() + MockyAssert(success, "Expected: \(count) invocations of `\(assertionName)`, but was: \(fullMatches).\(feedback)", file: file, line: line) + } + + private func addInvocation(_ call: MethodType) { + self.queue.sync { invocations.append(call) } + } + private func methodReturnValue(_ method: MethodType) throws -> StubProduct { + matcher.set(file: self.file, line: self.line) + defer { matcher.clearFileAndLine() } + let candidates = sequencingPolicy.sorted(methodReturnValues, by: { $0.method.intValue() > $1.method.intValue() }) + let matched = candidates.first(where: { $0.isValid && MethodType.compareParameters(lhs: $0.method, rhs: method, matcher: matcher).isFullMatch }) + guard let product = matched?.getProduct(policy: self.stubbingPolicy) else { throw MockError.notStubed } + return product + } + private func methodPerformValue(_ method: MethodType) -> Any? { + matcher.set(file: self.file, line: self.line) + defer { matcher.clearFileAndLine() } + let matched = methodPerformValues.reversed().first { MethodType.compareParameters(lhs: $0.method, rhs: method, matcher: matcher).isFullMatch } + return matched?.performs + } + private func matchingCalls(_ method: MethodType, file: StaticString?, line: UInt?) -> [MethodType] { + matcher.set(file: file ?? self.file, line: line ?? self.line) + defer { matcher.clearFileAndLine() } + return invocations.filter { MethodType.compareParameters(lhs: $0, rhs: method, matcher: matcher).isFullMatch } + } + private func matchingCalls(_ method: Verify, file: StaticString?, line: UInt?) -> Int { + return matchingCalls(method.method, file: file, line: line).count + } + private func givenGetterValue(_ method: MethodType, _ message: String) -> T { + do { + return try methodReturnValue(method).casted() + } catch { + onFatalFailure(message) + Failure(message) + } + } + private func optionalGivenGetterValue(_ method: MethodType, _ message: String) -> T? { + do { + return try methodReturnValue(method).casted() + } catch { + return nil + } + } + private func onFatalFailure(_ message: String) { + guard let file = self.file, let line = self.line else { return } // Let if fail if cannot handle gratefully + SwiftyMockyTestObserver.handleFatalError(message: message, file: file, line: line) + } +} + // MARK: - DashboardAnalytics open class DashboardAnalyticsMock: DashboardAnalytics, Mock { diff --git a/Discovery/Discovery/Presentation/DiscoveryAnalytics.swift b/Discovery/Discovery/Presentation/DiscoveryAnalytics.swift index bcb8190fc..bfc9e7075 100644 --- a/Discovery/Discovery/Presentation/DiscoveryAnalytics.swift +++ b/Discovery/Discovery/Presentation/DiscoveryAnalytics.swift @@ -6,6 +6,7 @@ // import Foundation +import Core //sourcery: AutoMockable public protocol DiscoveryAnalytics { @@ -15,6 +16,9 @@ public protocol DiscoveryAnalytics { func viewCourseClicked(courseId: String, courseName: String) func courseEnrollClicked(courseId: String, courseName: String) func courseEnrollSuccess(courseId: String, courseName: String) + func externalLinkOpen(url: String, screen: String) + func externalLinkOpenAction(url: String, screen: String, action: String) + func discoveryEvent(event: AnalyticsEvent, biValue: EventBIValue) } #if DEBUG @@ -25,5 +29,8 @@ class DiscoveryAnalyticsMock: DiscoveryAnalytics { public func viewCourseClicked(courseId: String, courseName: String) {} public func courseEnrollClicked(courseId: String, courseName: String) {} public func courseEnrollSuccess(courseId: String, courseName: String) {} + public func externalLinkOpen(url: String, screen: String) {} + public func externalLinkOpenAction(url: String, screen: String, action: String) {} + public func discoveryEvent(event: AnalyticsEvent, biValue: EventBIValue) {} } #endif diff --git a/Discovery/Discovery/Presentation/WebDiscovery/DiscoveryWebviewViewModel.swift b/Discovery/Discovery/Presentation/WebDiscovery/DiscoveryWebviewViewModel.swift index 99cd937b0..18e91733b 100644 --- a/Discovery/Discovery/Presentation/WebDiscovery/DiscoveryWebviewViewModel.swift +++ b/Discovery/Discovery/Presentation/WebDiscovery/DiscoveryWebviewViewModel.swift @@ -135,14 +135,25 @@ extension DiscoveryWebviewViewModel: WebViewNavigationDelegate { } if let url = request.url, outsideLink || capturedLink || externalLink, UIApplication.shared.canOpenURL(url) { + analytics.externalLinkOpen(url: url.absoluteString, screen: sourceScreen.value ?? "") router.presentAlert( alertTitle: DiscoveryLocalization.Alert.leavingAppTitle, alertMessage: DiscoveryLocalization.Alert.leavingAppMessage, positiveAction: CoreLocalization.Webview.Alert.continue, onCloseTapped: { [weak self] in self?.router.dismiss(animated: true) - }, okTapped: { + self?.analytics.externalLinkOpenAction( + url: url.absoluteString, + screen: self?.sourceScreen.value ?? "", + action: "cancel" + ) + }, okTapped: { [weak self] in UIApplication.shared.open(url, options: [:]) + self?.analytics.externalLinkOpenAction( + url: url.absoluteString, + screen: self?.sourceScreen.value ?? "", + action: "continue" + ) }, type: .default(positiveAction: CoreLocalization.Webview.Alert.continue, image: nil) ) return true @@ -176,6 +187,7 @@ extension DiscoveryWebviewViewModel: WebViewNavigationDelegate { case .programDetail: guard let pathID = programDetailPathId(from: url) else { return false } + analytics.discoveryEvent(event: .discoveryProgramInfo, biValue: .discoveryProgramInfo) router.showWebDiscoveryDetails( pathID: pathID, discoveryType: .programDetail(pathID), diff --git a/Discovery/DiscoveryTests/DiscoveryMock.generated.swift b/Discovery/DiscoveryTests/DiscoveryMock.generated.swift index 7c7b67d29..2721eded5 100644 --- a/Discovery/DiscoveryTests/DiscoveryMock.generated.swift +++ b/Discovery/DiscoveryTests/DiscoveryMock.generated.swift @@ -1114,6 +1114,281 @@ open class ConnectivityProtocolMock: ConnectivityProtocol, Mock { } } +// MARK: - CoreAnalytics + +open class CoreAnalyticsMock: CoreAnalytics, Mock { + public init(sequencing sequencingPolicy: SequencingPolicy = .lastWrittenResolvedFirst, stubbing stubbingPolicy: StubbingPolicy = .wrap, file: StaticString = #file, line: UInt = #line) { + SwiftyMockyTestObserver.setup() + self.sequencingPolicy = sequencingPolicy + self.stubbingPolicy = stubbingPolicy + self.file = file + self.line = line + } + + var matcher: Matcher = Matcher.default + var stubbingPolicy: StubbingPolicy = .wrap + var sequencingPolicy: SequencingPolicy = .lastWrittenResolvedFirst + + private var queue = DispatchQueue(label: "com.swiftymocky.invocations", qos: .userInteractive) + private var invocations: [MethodType] = [] + private var methodReturnValues: [Given] = [] + private var methodPerformValues: [Perform] = [] + private var file: StaticString? + private var line: UInt? + + public typealias PropertyStub = Given + public typealias MethodStub = Given + public typealias SubscriptStub = Given + + /// Convenience method - call setupMock() to extend debug information when failure occurs + public func setupMock(file: StaticString = #file, line: UInt = #line) { + self.file = file + self.line = line + } + + /// Clear mock internals. You can specify what to reset (invocations aka verify, givens or performs) or leave it empty to clear all mock internals + public func resetMock(_ scopes: MockScope...) { + let scopes: [MockScope] = scopes.isEmpty ? [.invocation, .given, .perform] : scopes + if scopes.contains(.invocation) { invocations = [] } + if scopes.contains(.given) { methodReturnValues = [] } + if scopes.contains(.perform) { methodPerformValues = [] } + } + + + + + + open func trackEvent(_ event: AnalyticsEvent, parameters: [String: Any]?) { + addInvocation(.m_trackEvent__eventparameters_parameters(Parameter.value(`event`), Parameter<[String: Any]?>.value(`parameters`))) + let perform = methodPerformValue(.m_trackEvent__eventparameters_parameters(Parameter.value(`event`), Parameter<[String: Any]?>.value(`parameters`))) as? (AnalyticsEvent, [String: Any]?) -> Void + perform?(`event`, `parameters`) + } + + open func trackEvent(_ event: AnalyticsEvent, biValue: EventBIValue, parameters: [String: Any]?) { + addInvocation(.m_trackEvent__eventbiValue_biValueparameters_parameters(Parameter.value(`event`), Parameter.value(`biValue`), Parameter<[String: Any]?>.value(`parameters`))) + let perform = methodPerformValue(.m_trackEvent__eventbiValue_biValueparameters_parameters(Parameter.value(`event`), Parameter.value(`biValue`), Parameter<[String: Any]?>.value(`parameters`))) as? (AnalyticsEvent, EventBIValue, [String: Any]?) -> Void + perform?(`event`, `biValue`, `parameters`) + } + + open func appreview(_ event: AnalyticsEvent, biValue: EventBIValue, action: String?, rating: Int?) { + addInvocation(.m_appreview__eventbiValue_biValueaction_actionrating_rating(Parameter.value(`event`), Parameter.value(`biValue`), Parameter.value(`action`), Parameter.value(`rating`))) + let perform = methodPerformValue(.m_appreview__eventbiValue_biValueaction_actionrating_rating(Parameter.value(`event`), Parameter.value(`biValue`), Parameter.value(`action`), Parameter.value(`rating`))) as? (AnalyticsEvent, EventBIValue, String?, Int?) -> Void + perform?(`event`, `biValue`, `action`, `rating`) + } + + open func videoQualityChanged(_ event: AnalyticsEvent, bivalue: EventBIValue, value: String, oldValue: String) { + addInvocation(.m_videoQualityChanged__eventbivalue_bivaluevalue_valueoldValue_oldValue(Parameter.value(`event`), Parameter.value(`bivalue`), Parameter.value(`value`), Parameter.value(`oldValue`))) + let perform = methodPerformValue(.m_videoQualityChanged__eventbivalue_bivaluevalue_valueoldValue_oldValue(Parameter.value(`event`), Parameter.value(`bivalue`), Parameter.value(`value`), Parameter.value(`oldValue`))) as? (AnalyticsEvent, EventBIValue, String, String) -> Void + perform?(`event`, `bivalue`, `value`, `oldValue`) + } + + open func trackEvent(_ event: AnalyticsEvent) { + addInvocation(.m_trackEvent__event(Parameter.value(`event`))) + let perform = methodPerformValue(.m_trackEvent__event(Parameter.value(`event`))) as? (AnalyticsEvent) -> Void + perform?(`event`) + } + + open func trackEvent(_ event: AnalyticsEvent, biValue: EventBIValue) { + addInvocation(.m_trackEvent__eventbiValue_biValue(Parameter.value(`event`), Parameter.value(`biValue`))) + let perform = methodPerformValue(.m_trackEvent__eventbiValue_biValue(Parameter.value(`event`), Parameter.value(`biValue`))) as? (AnalyticsEvent, EventBIValue) -> Void + perform?(`event`, `biValue`) + } + + + fileprivate enum MethodType { + case m_trackEvent__eventparameters_parameters(Parameter, Parameter<[String: Any]?>) + case m_trackEvent__eventbiValue_biValueparameters_parameters(Parameter, Parameter, Parameter<[String: Any]?>) + case m_appreview__eventbiValue_biValueaction_actionrating_rating(Parameter, Parameter, Parameter, Parameter) + case m_videoQualityChanged__eventbivalue_bivaluevalue_valueoldValue_oldValue(Parameter, Parameter, Parameter, Parameter) + case m_trackEvent__event(Parameter) + case m_trackEvent__eventbiValue_biValue(Parameter, Parameter) + + static func compareParameters(lhs: MethodType, rhs: MethodType, matcher: Matcher) -> Matcher.ComparisonResult { + switch (lhs, rhs) { + case (.m_trackEvent__eventparameters_parameters(let lhsEvent, let lhsParameters), .m_trackEvent__eventparameters_parameters(let rhsEvent, let rhsParameters)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsEvent, rhs: rhsEvent, with: matcher), lhsEvent, rhsEvent, "_ event")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsParameters, rhs: rhsParameters, with: matcher), lhsParameters, rhsParameters, "parameters")) + return Matcher.ComparisonResult(results) + + case (.m_trackEvent__eventbiValue_biValueparameters_parameters(let lhsEvent, let lhsBivalue, let lhsParameters), .m_trackEvent__eventbiValue_biValueparameters_parameters(let rhsEvent, let rhsBivalue, let rhsParameters)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsEvent, rhs: rhsEvent, with: matcher), lhsEvent, rhsEvent, "_ event")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsBivalue, rhs: rhsBivalue, with: matcher), lhsBivalue, rhsBivalue, "biValue")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsParameters, rhs: rhsParameters, with: matcher), lhsParameters, rhsParameters, "parameters")) + return Matcher.ComparisonResult(results) + + case (.m_appreview__eventbiValue_biValueaction_actionrating_rating(let lhsEvent, let lhsBivalue, let lhsAction, let lhsRating), .m_appreview__eventbiValue_biValueaction_actionrating_rating(let rhsEvent, let rhsBivalue, let rhsAction, let rhsRating)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsEvent, rhs: rhsEvent, with: matcher), lhsEvent, rhsEvent, "_ event")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsBivalue, rhs: rhsBivalue, with: matcher), lhsBivalue, rhsBivalue, "biValue")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsAction, rhs: rhsAction, with: matcher), lhsAction, rhsAction, "action")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsRating, rhs: rhsRating, with: matcher), lhsRating, rhsRating, "rating")) + return Matcher.ComparisonResult(results) + + case (.m_videoQualityChanged__eventbivalue_bivaluevalue_valueoldValue_oldValue(let lhsEvent, let lhsBivalue, let lhsValue, let lhsOldvalue), .m_videoQualityChanged__eventbivalue_bivaluevalue_valueoldValue_oldValue(let rhsEvent, let rhsBivalue, let rhsValue, let rhsOldvalue)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsEvent, rhs: rhsEvent, with: matcher), lhsEvent, rhsEvent, "_ event")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsBivalue, rhs: rhsBivalue, with: matcher), lhsBivalue, rhsBivalue, "bivalue")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsValue, rhs: rhsValue, with: matcher), lhsValue, rhsValue, "value")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsOldvalue, rhs: rhsOldvalue, with: matcher), lhsOldvalue, rhsOldvalue, "oldValue")) + return Matcher.ComparisonResult(results) + + case (.m_trackEvent__event(let lhsEvent), .m_trackEvent__event(let rhsEvent)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsEvent, rhs: rhsEvent, with: matcher), lhsEvent, rhsEvent, "_ event")) + return Matcher.ComparisonResult(results) + + case (.m_trackEvent__eventbiValue_biValue(let lhsEvent, let lhsBivalue), .m_trackEvent__eventbiValue_biValue(let rhsEvent, let rhsBivalue)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsEvent, rhs: rhsEvent, with: matcher), lhsEvent, rhsEvent, "_ event")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsBivalue, rhs: rhsBivalue, with: matcher), lhsBivalue, rhsBivalue, "biValue")) + return Matcher.ComparisonResult(results) + default: return .none + } + } + + func intValue() -> Int { + switch self { + case let .m_trackEvent__eventparameters_parameters(p0, p1): return p0.intValue + p1.intValue + case let .m_trackEvent__eventbiValue_biValueparameters_parameters(p0, p1, p2): return p0.intValue + p1.intValue + p2.intValue + case let .m_appreview__eventbiValue_biValueaction_actionrating_rating(p0, p1, p2, p3): return p0.intValue + p1.intValue + p2.intValue + p3.intValue + case let .m_videoQualityChanged__eventbivalue_bivaluevalue_valueoldValue_oldValue(p0, p1, p2, p3): return p0.intValue + p1.intValue + p2.intValue + p3.intValue + case let .m_trackEvent__event(p0): return p0.intValue + case let .m_trackEvent__eventbiValue_biValue(p0, p1): return p0.intValue + p1.intValue + } + } + func assertionName() -> String { + switch self { + case .m_trackEvent__eventparameters_parameters: return ".trackEvent(_:parameters:)" + case .m_trackEvent__eventbiValue_biValueparameters_parameters: return ".trackEvent(_:biValue:parameters:)" + case .m_appreview__eventbiValue_biValueaction_actionrating_rating: return ".appreview(_:biValue:action:rating:)" + case .m_videoQualityChanged__eventbivalue_bivaluevalue_valueoldValue_oldValue: return ".videoQualityChanged(_:bivalue:value:oldValue:)" + case .m_trackEvent__event: return ".trackEvent(_:)" + case .m_trackEvent__eventbiValue_biValue: return ".trackEvent(_:biValue:)" + } + } + } + + open class Given: StubbedMethod { + fileprivate var method: MethodType + + private init(method: MethodType, products: [StubProduct]) { + self.method = method + super.init(products) + } + + + } + + public struct Verify { + fileprivate var method: MethodType + + public static func trackEvent(_ event: Parameter, parameters: Parameter<[String: Any]?>) -> Verify { return Verify(method: .m_trackEvent__eventparameters_parameters(`event`, `parameters`))} + public static func trackEvent(_ event: Parameter, biValue: Parameter, parameters: Parameter<[String: Any]?>) -> Verify { return Verify(method: .m_trackEvent__eventbiValue_biValueparameters_parameters(`event`, `biValue`, `parameters`))} + public static func appreview(_ event: Parameter, biValue: Parameter, action: Parameter, rating: Parameter) -> Verify { return Verify(method: .m_appreview__eventbiValue_biValueaction_actionrating_rating(`event`, `biValue`, `action`, `rating`))} + public static func videoQualityChanged(_ event: Parameter, bivalue: Parameter, value: Parameter, oldValue: Parameter) -> Verify { return Verify(method: .m_videoQualityChanged__eventbivalue_bivaluevalue_valueoldValue_oldValue(`event`, `bivalue`, `value`, `oldValue`))} + public static func trackEvent(_ event: Parameter) -> Verify { return Verify(method: .m_trackEvent__event(`event`))} + public static func trackEvent(_ event: Parameter, biValue: Parameter) -> Verify { return Verify(method: .m_trackEvent__eventbiValue_biValue(`event`, `biValue`))} + } + + public struct Perform { + fileprivate var method: MethodType + var performs: Any + + public static func trackEvent(_ event: Parameter, parameters: Parameter<[String: Any]?>, perform: @escaping (AnalyticsEvent, [String: Any]?) -> Void) -> Perform { + return Perform(method: .m_trackEvent__eventparameters_parameters(`event`, `parameters`), performs: perform) + } + public static func trackEvent(_ event: Parameter, biValue: Parameter, parameters: Parameter<[String: Any]?>, perform: @escaping (AnalyticsEvent, EventBIValue, [String: Any]?) -> Void) -> Perform { + return Perform(method: .m_trackEvent__eventbiValue_biValueparameters_parameters(`event`, `biValue`, `parameters`), performs: perform) + } + public static func appreview(_ event: Parameter, biValue: Parameter, action: Parameter, rating: Parameter, perform: @escaping (AnalyticsEvent, EventBIValue, String?, Int?) -> Void) -> Perform { + return Perform(method: .m_appreview__eventbiValue_biValueaction_actionrating_rating(`event`, `biValue`, `action`, `rating`), performs: perform) + } + public static func videoQualityChanged(_ event: Parameter, bivalue: Parameter, value: Parameter, oldValue: Parameter, perform: @escaping (AnalyticsEvent, EventBIValue, String, String) -> Void) -> Perform { + return Perform(method: .m_videoQualityChanged__eventbivalue_bivaluevalue_valueoldValue_oldValue(`event`, `bivalue`, `value`, `oldValue`), performs: perform) + } + public static func trackEvent(_ event: Parameter, perform: @escaping (AnalyticsEvent) -> Void) -> Perform { + return Perform(method: .m_trackEvent__event(`event`), performs: perform) + } + public static func trackEvent(_ event: Parameter, biValue: Parameter, perform: @escaping (AnalyticsEvent, EventBIValue) -> Void) -> Perform { + return Perform(method: .m_trackEvent__eventbiValue_biValue(`event`, `biValue`), performs: perform) + } + } + + public func given(_ method: Given) { + methodReturnValues.append(method) + } + + public func perform(_ method: Perform) { + methodPerformValues.append(method) + methodPerformValues.sort { $0.method.intValue() < $1.method.intValue() } + } + + public func verify(_ method: Verify, count: Count = Count.moreOrEqual(to: 1), file: StaticString = #file, line: UInt = #line) { + let fullMatches = matchingCalls(method, file: file, line: line) + let success = count.matches(fullMatches) + let assertionName = method.method.assertionName() + let feedback: String = { + guard !success else { return "" } + return Utils.closestCallsMessage( + for: self.invocations.map { invocation in + matcher.set(file: file, line: line) + defer { matcher.clearFileAndLine() } + return MethodType.compareParameters(lhs: invocation, rhs: method.method, matcher: matcher) + }, + name: assertionName + ) + }() + MockyAssert(success, "Expected: \(count) invocations of `\(assertionName)`, but was: \(fullMatches).\(feedback)", file: file, line: line) + } + + private func addInvocation(_ call: MethodType) { + self.queue.sync { invocations.append(call) } + } + private func methodReturnValue(_ method: MethodType) throws -> StubProduct { + matcher.set(file: self.file, line: self.line) + defer { matcher.clearFileAndLine() } + let candidates = sequencingPolicy.sorted(methodReturnValues, by: { $0.method.intValue() > $1.method.intValue() }) + let matched = candidates.first(where: { $0.isValid && MethodType.compareParameters(lhs: $0.method, rhs: method, matcher: matcher).isFullMatch }) + guard let product = matched?.getProduct(policy: self.stubbingPolicy) else { throw MockError.notStubed } + return product + } + private func methodPerformValue(_ method: MethodType) -> Any? { + matcher.set(file: self.file, line: self.line) + defer { matcher.clearFileAndLine() } + let matched = methodPerformValues.reversed().first { MethodType.compareParameters(lhs: $0.method, rhs: method, matcher: matcher).isFullMatch } + return matched?.performs + } + private func matchingCalls(_ method: MethodType, file: StaticString?, line: UInt?) -> [MethodType] { + matcher.set(file: file ?? self.file, line: line ?? self.line) + defer { matcher.clearFileAndLine() } + return invocations.filter { MethodType.compareParameters(lhs: $0, rhs: method, matcher: matcher).isFullMatch } + } + private func matchingCalls(_ method: Verify, file: StaticString?, line: UInt?) -> Int { + return matchingCalls(method.method, file: file, line: line).count + } + private func givenGetterValue(_ method: MethodType, _ message: String) -> T { + do { + return try methodReturnValue(method).casted() + } catch { + onFatalFailure(message) + Failure(message) + } + } + private func optionalGivenGetterValue(_ method: MethodType, _ message: String) -> T? { + do { + return try methodReturnValue(method).casted() + } catch { + return nil + } + } + private func onFatalFailure(_ message: String) { + guard let file = self.file, let line = self.line else { return } // Let if fail if cannot handle gratefully + SwiftyMockyTestObserver.handleFatalError(message: message, file: file, line: line) + } +} + // MARK: - DiscoveryAnalytics open class DiscoveryAnalyticsMock: DiscoveryAnalytics, Mock { @@ -1194,6 +1469,24 @@ open class DiscoveryAnalyticsMock: DiscoveryAnalytics, Mock { perform?(`courseId`, `courseName`) } + open func externalLinkOpen(url: String, screen: String) { + addInvocation(.m_externalLinkOpen__url_urlscreen_screen(Parameter.value(`url`), Parameter.value(`screen`))) + let perform = methodPerformValue(.m_externalLinkOpen__url_urlscreen_screen(Parameter.value(`url`), Parameter.value(`screen`))) as? (String, String) -> Void + perform?(`url`, `screen`) + } + + open func externalLinkOpenAction(url: String, screen: String, action: String) { + addInvocation(.m_externalLinkOpenAction__url_urlscreen_screenaction_action(Parameter.value(`url`), Parameter.value(`screen`), Parameter.value(`action`))) + let perform = methodPerformValue(.m_externalLinkOpenAction__url_urlscreen_screenaction_action(Parameter.value(`url`), Parameter.value(`screen`), Parameter.value(`action`))) as? (String, String, String) -> Void + perform?(`url`, `screen`, `action`) + } + + open func discoveryEvent(event: AnalyticsEvent, biValue: EventBIValue) { + addInvocation(.m_discoveryEvent__event_eventbiValue_biValue(Parameter.value(`event`), Parameter.value(`biValue`))) + let perform = methodPerformValue(.m_discoveryEvent__event_eventbiValue_biValue(Parameter.value(`event`), Parameter.value(`biValue`))) as? (AnalyticsEvent, EventBIValue) -> Void + perform?(`event`, `biValue`) + } + fileprivate enum MethodType { case m_discoverySearchBarClicked @@ -1202,6 +1495,9 @@ open class DiscoveryAnalyticsMock: DiscoveryAnalytics, Mock { case m_viewCourseClicked__courseId_courseIdcourseName_courseName(Parameter, Parameter) case m_courseEnrollClicked__courseId_courseIdcourseName_courseName(Parameter, Parameter) case m_courseEnrollSuccess__courseId_courseIdcourseName_courseName(Parameter, Parameter) + case m_externalLinkOpen__url_urlscreen_screen(Parameter, Parameter) + case m_externalLinkOpenAction__url_urlscreen_screenaction_action(Parameter, Parameter, Parameter) + case m_discoveryEvent__event_eventbiValue_biValue(Parameter, Parameter) static func compareParameters(lhs: MethodType, rhs: MethodType, matcher: Matcher) -> Matcher.ComparisonResult { switch (lhs, rhs) { @@ -1236,6 +1532,25 @@ open class DiscoveryAnalyticsMock: DiscoveryAnalytics, Mock { results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsCourseid, rhs: rhsCourseid, with: matcher), lhsCourseid, rhsCourseid, "courseId")) results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsCoursename, rhs: rhsCoursename, with: matcher), lhsCoursename, rhsCoursename, "courseName")) return Matcher.ComparisonResult(results) + + case (.m_externalLinkOpen__url_urlscreen_screen(let lhsUrl, let lhsScreen), .m_externalLinkOpen__url_urlscreen_screen(let rhsUrl, let rhsScreen)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsUrl, rhs: rhsUrl, with: matcher), lhsUrl, rhsUrl, "url")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsScreen, rhs: rhsScreen, with: matcher), lhsScreen, rhsScreen, "screen")) + return Matcher.ComparisonResult(results) + + case (.m_externalLinkOpenAction__url_urlscreen_screenaction_action(let lhsUrl, let lhsScreen, let lhsAction), .m_externalLinkOpenAction__url_urlscreen_screenaction_action(let rhsUrl, let rhsScreen, let rhsAction)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsUrl, rhs: rhsUrl, with: matcher), lhsUrl, rhsUrl, "url")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsScreen, rhs: rhsScreen, with: matcher), lhsScreen, rhsScreen, "screen")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsAction, rhs: rhsAction, with: matcher), lhsAction, rhsAction, "action")) + return Matcher.ComparisonResult(results) + + case (.m_discoveryEvent__event_eventbiValue_biValue(let lhsEvent, let lhsBivalue), .m_discoveryEvent__event_eventbiValue_biValue(let rhsEvent, let rhsBivalue)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsEvent, rhs: rhsEvent, with: matcher), lhsEvent, rhsEvent, "event")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsBivalue, rhs: rhsBivalue, with: matcher), lhsBivalue, rhsBivalue, "biValue")) + return Matcher.ComparisonResult(results) default: return .none } } @@ -1248,6 +1563,9 @@ open class DiscoveryAnalyticsMock: DiscoveryAnalytics, Mock { case let .m_viewCourseClicked__courseId_courseIdcourseName_courseName(p0, p1): return p0.intValue + p1.intValue case let .m_courseEnrollClicked__courseId_courseIdcourseName_courseName(p0, p1): return p0.intValue + p1.intValue case let .m_courseEnrollSuccess__courseId_courseIdcourseName_courseName(p0, p1): return p0.intValue + p1.intValue + case let .m_externalLinkOpen__url_urlscreen_screen(p0, p1): return p0.intValue + p1.intValue + case let .m_externalLinkOpenAction__url_urlscreen_screenaction_action(p0, p1, p2): return p0.intValue + p1.intValue + p2.intValue + case let .m_discoveryEvent__event_eventbiValue_biValue(p0, p1): return p0.intValue + p1.intValue } } func assertionName() -> String { @@ -1258,6 +1576,9 @@ open class DiscoveryAnalyticsMock: DiscoveryAnalytics, Mock { case .m_viewCourseClicked__courseId_courseIdcourseName_courseName: return ".viewCourseClicked(courseId:courseName:)" case .m_courseEnrollClicked__courseId_courseIdcourseName_courseName: return ".courseEnrollClicked(courseId:courseName:)" case .m_courseEnrollSuccess__courseId_courseIdcourseName_courseName: return ".courseEnrollSuccess(courseId:courseName:)" + case .m_externalLinkOpen__url_urlscreen_screen: return ".externalLinkOpen(url:screen:)" + case .m_externalLinkOpenAction__url_urlscreen_screenaction_action: return ".externalLinkOpenAction(url:screen:action:)" + case .m_discoveryEvent__event_eventbiValue_biValue: return ".discoveryEvent(event:biValue:)" } } } @@ -1282,6 +1603,9 @@ open class DiscoveryAnalyticsMock: DiscoveryAnalytics, Mock { public static func viewCourseClicked(courseId: Parameter, courseName: Parameter) -> Verify { return Verify(method: .m_viewCourseClicked__courseId_courseIdcourseName_courseName(`courseId`, `courseName`))} public static func courseEnrollClicked(courseId: Parameter, courseName: Parameter) -> Verify { return Verify(method: .m_courseEnrollClicked__courseId_courseIdcourseName_courseName(`courseId`, `courseName`))} public static func courseEnrollSuccess(courseId: Parameter, courseName: Parameter) -> Verify { return Verify(method: .m_courseEnrollSuccess__courseId_courseIdcourseName_courseName(`courseId`, `courseName`))} + public static func externalLinkOpen(url: Parameter, screen: Parameter) -> Verify { return Verify(method: .m_externalLinkOpen__url_urlscreen_screen(`url`, `screen`))} + public static func externalLinkOpenAction(url: Parameter, screen: Parameter, action: Parameter) -> Verify { return Verify(method: .m_externalLinkOpenAction__url_urlscreen_screenaction_action(`url`, `screen`, `action`))} + public static func discoveryEvent(event: Parameter, biValue: Parameter) -> Verify { return Verify(method: .m_discoveryEvent__event_eventbiValue_biValue(`event`, `biValue`))} } public struct Perform { @@ -1306,6 +1630,15 @@ open class DiscoveryAnalyticsMock: DiscoveryAnalytics, Mock { public static func courseEnrollSuccess(courseId: Parameter, courseName: Parameter, perform: @escaping (String, String) -> Void) -> Perform { return Perform(method: .m_courseEnrollSuccess__courseId_courseIdcourseName_courseName(`courseId`, `courseName`), performs: perform) } + public static func externalLinkOpen(url: Parameter, screen: Parameter, perform: @escaping (String, String) -> Void) -> Perform { + return Perform(method: .m_externalLinkOpen__url_urlscreen_screen(`url`, `screen`), performs: perform) + } + public static func externalLinkOpenAction(url: Parameter, screen: Parameter, action: Parameter, perform: @escaping (String, String, String) -> Void) -> Perform { + return Perform(method: .m_externalLinkOpenAction__url_urlscreen_screenaction_action(`url`, `screen`, `action`), performs: perform) + } + public static func discoveryEvent(event: Parameter, biValue: Parameter, perform: @escaping (AnalyticsEvent, EventBIValue) -> Void) -> Perform { + return Perform(method: .m_discoveryEvent__event_eventbiValue_biValue(`event`, `biValue`), performs: perform) + } } public func given(_ method: Given) { diff --git a/Discussion/DiscussionTests/DiscussionMock.generated.swift b/Discussion/DiscussionTests/DiscussionMock.generated.swift index 2202fd248..4da34aadc 100644 --- a/Discussion/DiscussionTests/DiscussionMock.generated.swift +++ b/Discussion/DiscussionTests/DiscussionMock.generated.swift @@ -1114,6 +1114,281 @@ open class ConnectivityProtocolMock: ConnectivityProtocol, Mock { } } +// MARK: - CoreAnalytics + +open class CoreAnalyticsMock: CoreAnalytics, Mock { + public init(sequencing sequencingPolicy: SequencingPolicy = .lastWrittenResolvedFirst, stubbing stubbingPolicy: StubbingPolicy = .wrap, file: StaticString = #file, line: UInt = #line) { + SwiftyMockyTestObserver.setup() + self.sequencingPolicy = sequencingPolicy + self.stubbingPolicy = stubbingPolicy + self.file = file + self.line = line + } + + var matcher: Matcher = Matcher.default + var stubbingPolicy: StubbingPolicy = .wrap + var sequencingPolicy: SequencingPolicy = .lastWrittenResolvedFirst + + private var queue = DispatchQueue(label: "com.swiftymocky.invocations", qos: .userInteractive) + private var invocations: [MethodType] = [] + private var methodReturnValues: [Given] = [] + private var methodPerformValues: [Perform] = [] + private var file: StaticString? + private var line: UInt? + + public typealias PropertyStub = Given + public typealias MethodStub = Given + public typealias SubscriptStub = Given + + /// Convenience method - call setupMock() to extend debug information when failure occurs + public func setupMock(file: StaticString = #file, line: UInt = #line) { + self.file = file + self.line = line + } + + /// Clear mock internals. You can specify what to reset (invocations aka verify, givens or performs) or leave it empty to clear all mock internals + public func resetMock(_ scopes: MockScope...) { + let scopes: [MockScope] = scopes.isEmpty ? [.invocation, .given, .perform] : scopes + if scopes.contains(.invocation) { invocations = [] } + if scopes.contains(.given) { methodReturnValues = [] } + if scopes.contains(.perform) { methodPerformValues = [] } + } + + + + + + open func trackEvent(_ event: AnalyticsEvent, parameters: [String: Any]?) { + addInvocation(.m_trackEvent__eventparameters_parameters(Parameter.value(`event`), Parameter<[String: Any]?>.value(`parameters`))) + let perform = methodPerformValue(.m_trackEvent__eventparameters_parameters(Parameter.value(`event`), Parameter<[String: Any]?>.value(`parameters`))) as? (AnalyticsEvent, [String: Any]?) -> Void + perform?(`event`, `parameters`) + } + + open func trackEvent(_ event: AnalyticsEvent, biValue: EventBIValue, parameters: [String: Any]?) { + addInvocation(.m_trackEvent__eventbiValue_biValueparameters_parameters(Parameter.value(`event`), Parameter.value(`biValue`), Parameter<[String: Any]?>.value(`parameters`))) + let perform = methodPerformValue(.m_trackEvent__eventbiValue_biValueparameters_parameters(Parameter.value(`event`), Parameter.value(`biValue`), Parameter<[String: Any]?>.value(`parameters`))) as? (AnalyticsEvent, EventBIValue, [String: Any]?) -> Void + perform?(`event`, `biValue`, `parameters`) + } + + open func appreview(_ event: AnalyticsEvent, biValue: EventBIValue, action: String?, rating: Int?) { + addInvocation(.m_appreview__eventbiValue_biValueaction_actionrating_rating(Parameter.value(`event`), Parameter.value(`biValue`), Parameter.value(`action`), Parameter.value(`rating`))) + let perform = methodPerformValue(.m_appreview__eventbiValue_biValueaction_actionrating_rating(Parameter.value(`event`), Parameter.value(`biValue`), Parameter.value(`action`), Parameter.value(`rating`))) as? (AnalyticsEvent, EventBIValue, String?, Int?) -> Void + perform?(`event`, `biValue`, `action`, `rating`) + } + + open func videoQualityChanged(_ event: AnalyticsEvent, bivalue: EventBIValue, value: String, oldValue: String) { + addInvocation(.m_videoQualityChanged__eventbivalue_bivaluevalue_valueoldValue_oldValue(Parameter.value(`event`), Parameter.value(`bivalue`), Parameter.value(`value`), Parameter.value(`oldValue`))) + let perform = methodPerformValue(.m_videoQualityChanged__eventbivalue_bivaluevalue_valueoldValue_oldValue(Parameter.value(`event`), Parameter.value(`bivalue`), Parameter.value(`value`), Parameter.value(`oldValue`))) as? (AnalyticsEvent, EventBIValue, String, String) -> Void + perform?(`event`, `bivalue`, `value`, `oldValue`) + } + + open func trackEvent(_ event: AnalyticsEvent) { + addInvocation(.m_trackEvent__event(Parameter.value(`event`))) + let perform = methodPerformValue(.m_trackEvent__event(Parameter.value(`event`))) as? (AnalyticsEvent) -> Void + perform?(`event`) + } + + open func trackEvent(_ event: AnalyticsEvent, biValue: EventBIValue) { + addInvocation(.m_trackEvent__eventbiValue_biValue(Parameter.value(`event`), Parameter.value(`biValue`))) + let perform = methodPerformValue(.m_trackEvent__eventbiValue_biValue(Parameter.value(`event`), Parameter.value(`biValue`))) as? (AnalyticsEvent, EventBIValue) -> Void + perform?(`event`, `biValue`) + } + + + fileprivate enum MethodType { + case m_trackEvent__eventparameters_parameters(Parameter, Parameter<[String: Any]?>) + case m_trackEvent__eventbiValue_biValueparameters_parameters(Parameter, Parameter, Parameter<[String: Any]?>) + case m_appreview__eventbiValue_biValueaction_actionrating_rating(Parameter, Parameter, Parameter, Parameter) + case m_videoQualityChanged__eventbivalue_bivaluevalue_valueoldValue_oldValue(Parameter, Parameter, Parameter, Parameter) + case m_trackEvent__event(Parameter) + case m_trackEvent__eventbiValue_biValue(Parameter, Parameter) + + static func compareParameters(lhs: MethodType, rhs: MethodType, matcher: Matcher) -> Matcher.ComparisonResult { + switch (lhs, rhs) { + case (.m_trackEvent__eventparameters_parameters(let lhsEvent, let lhsParameters), .m_trackEvent__eventparameters_parameters(let rhsEvent, let rhsParameters)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsEvent, rhs: rhsEvent, with: matcher), lhsEvent, rhsEvent, "_ event")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsParameters, rhs: rhsParameters, with: matcher), lhsParameters, rhsParameters, "parameters")) + return Matcher.ComparisonResult(results) + + case (.m_trackEvent__eventbiValue_biValueparameters_parameters(let lhsEvent, let lhsBivalue, let lhsParameters), .m_trackEvent__eventbiValue_biValueparameters_parameters(let rhsEvent, let rhsBivalue, let rhsParameters)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsEvent, rhs: rhsEvent, with: matcher), lhsEvent, rhsEvent, "_ event")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsBivalue, rhs: rhsBivalue, with: matcher), lhsBivalue, rhsBivalue, "biValue")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsParameters, rhs: rhsParameters, with: matcher), lhsParameters, rhsParameters, "parameters")) + return Matcher.ComparisonResult(results) + + case (.m_appreview__eventbiValue_biValueaction_actionrating_rating(let lhsEvent, let lhsBivalue, let lhsAction, let lhsRating), .m_appreview__eventbiValue_biValueaction_actionrating_rating(let rhsEvent, let rhsBivalue, let rhsAction, let rhsRating)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsEvent, rhs: rhsEvent, with: matcher), lhsEvent, rhsEvent, "_ event")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsBivalue, rhs: rhsBivalue, with: matcher), lhsBivalue, rhsBivalue, "biValue")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsAction, rhs: rhsAction, with: matcher), lhsAction, rhsAction, "action")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsRating, rhs: rhsRating, with: matcher), lhsRating, rhsRating, "rating")) + return Matcher.ComparisonResult(results) + + case (.m_videoQualityChanged__eventbivalue_bivaluevalue_valueoldValue_oldValue(let lhsEvent, let lhsBivalue, let lhsValue, let lhsOldvalue), .m_videoQualityChanged__eventbivalue_bivaluevalue_valueoldValue_oldValue(let rhsEvent, let rhsBivalue, let rhsValue, let rhsOldvalue)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsEvent, rhs: rhsEvent, with: matcher), lhsEvent, rhsEvent, "_ event")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsBivalue, rhs: rhsBivalue, with: matcher), lhsBivalue, rhsBivalue, "bivalue")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsValue, rhs: rhsValue, with: matcher), lhsValue, rhsValue, "value")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsOldvalue, rhs: rhsOldvalue, with: matcher), lhsOldvalue, rhsOldvalue, "oldValue")) + return Matcher.ComparisonResult(results) + + case (.m_trackEvent__event(let lhsEvent), .m_trackEvent__event(let rhsEvent)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsEvent, rhs: rhsEvent, with: matcher), lhsEvent, rhsEvent, "_ event")) + return Matcher.ComparisonResult(results) + + case (.m_trackEvent__eventbiValue_biValue(let lhsEvent, let lhsBivalue), .m_trackEvent__eventbiValue_biValue(let rhsEvent, let rhsBivalue)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsEvent, rhs: rhsEvent, with: matcher), lhsEvent, rhsEvent, "_ event")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsBivalue, rhs: rhsBivalue, with: matcher), lhsBivalue, rhsBivalue, "biValue")) + return Matcher.ComparisonResult(results) + default: return .none + } + } + + func intValue() -> Int { + switch self { + case let .m_trackEvent__eventparameters_parameters(p0, p1): return p0.intValue + p1.intValue + case let .m_trackEvent__eventbiValue_biValueparameters_parameters(p0, p1, p2): return p0.intValue + p1.intValue + p2.intValue + case let .m_appreview__eventbiValue_biValueaction_actionrating_rating(p0, p1, p2, p3): return p0.intValue + p1.intValue + p2.intValue + p3.intValue + case let .m_videoQualityChanged__eventbivalue_bivaluevalue_valueoldValue_oldValue(p0, p1, p2, p3): return p0.intValue + p1.intValue + p2.intValue + p3.intValue + case let .m_trackEvent__event(p0): return p0.intValue + case let .m_trackEvent__eventbiValue_biValue(p0, p1): return p0.intValue + p1.intValue + } + } + func assertionName() -> String { + switch self { + case .m_trackEvent__eventparameters_parameters: return ".trackEvent(_:parameters:)" + case .m_trackEvent__eventbiValue_biValueparameters_parameters: return ".trackEvent(_:biValue:parameters:)" + case .m_appreview__eventbiValue_biValueaction_actionrating_rating: return ".appreview(_:biValue:action:rating:)" + case .m_videoQualityChanged__eventbivalue_bivaluevalue_valueoldValue_oldValue: return ".videoQualityChanged(_:bivalue:value:oldValue:)" + case .m_trackEvent__event: return ".trackEvent(_:)" + case .m_trackEvent__eventbiValue_biValue: return ".trackEvent(_:biValue:)" + } + } + } + + open class Given: StubbedMethod { + fileprivate var method: MethodType + + private init(method: MethodType, products: [StubProduct]) { + self.method = method + super.init(products) + } + + + } + + public struct Verify { + fileprivate var method: MethodType + + public static func trackEvent(_ event: Parameter, parameters: Parameter<[String: Any]?>) -> Verify { return Verify(method: .m_trackEvent__eventparameters_parameters(`event`, `parameters`))} + public static func trackEvent(_ event: Parameter, biValue: Parameter, parameters: Parameter<[String: Any]?>) -> Verify { return Verify(method: .m_trackEvent__eventbiValue_biValueparameters_parameters(`event`, `biValue`, `parameters`))} + public static func appreview(_ event: Parameter, biValue: Parameter, action: Parameter, rating: Parameter) -> Verify { return Verify(method: .m_appreview__eventbiValue_biValueaction_actionrating_rating(`event`, `biValue`, `action`, `rating`))} + public static func videoQualityChanged(_ event: Parameter, bivalue: Parameter, value: Parameter, oldValue: Parameter) -> Verify { return Verify(method: .m_videoQualityChanged__eventbivalue_bivaluevalue_valueoldValue_oldValue(`event`, `bivalue`, `value`, `oldValue`))} + public static func trackEvent(_ event: Parameter) -> Verify { return Verify(method: .m_trackEvent__event(`event`))} + public static func trackEvent(_ event: Parameter, biValue: Parameter) -> Verify { return Verify(method: .m_trackEvent__eventbiValue_biValue(`event`, `biValue`))} + } + + public struct Perform { + fileprivate var method: MethodType + var performs: Any + + public static func trackEvent(_ event: Parameter, parameters: Parameter<[String: Any]?>, perform: @escaping (AnalyticsEvent, [String: Any]?) -> Void) -> Perform { + return Perform(method: .m_trackEvent__eventparameters_parameters(`event`, `parameters`), performs: perform) + } + public static func trackEvent(_ event: Parameter, biValue: Parameter, parameters: Parameter<[String: Any]?>, perform: @escaping (AnalyticsEvent, EventBIValue, [String: Any]?) -> Void) -> Perform { + return Perform(method: .m_trackEvent__eventbiValue_biValueparameters_parameters(`event`, `biValue`, `parameters`), performs: perform) + } + public static func appreview(_ event: Parameter, biValue: Parameter, action: Parameter, rating: Parameter, perform: @escaping (AnalyticsEvent, EventBIValue, String?, Int?) -> Void) -> Perform { + return Perform(method: .m_appreview__eventbiValue_biValueaction_actionrating_rating(`event`, `biValue`, `action`, `rating`), performs: perform) + } + public static func videoQualityChanged(_ event: Parameter, bivalue: Parameter, value: Parameter, oldValue: Parameter, perform: @escaping (AnalyticsEvent, EventBIValue, String, String) -> Void) -> Perform { + return Perform(method: .m_videoQualityChanged__eventbivalue_bivaluevalue_valueoldValue_oldValue(`event`, `bivalue`, `value`, `oldValue`), performs: perform) + } + public static func trackEvent(_ event: Parameter, perform: @escaping (AnalyticsEvent) -> Void) -> Perform { + return Perform(method: .m_trackEvent__event(`event`), performs: perform) + } + public static func trackEvent(_ event: Parameter, biValue: Parameter, perform: @escaping (AnalyticsEvent, EventBIValue) -> Void) -> Perform { + return Perform(method: .m_trackEvent__eventbiValue_biValue(`event`, `biValue`), performs: perform) + } + } + + public func given(_ method: Given) { + methodReturnValues.append(method) + } + + public func perform(_ method: Perform) { + methodPerformValues.append(method) + methodPerformValues.sort { $0.method.intValue() < $1.method.intValue() } + } + + public func verify(_ method: Verify, count: Count = Count.moreOrEqual(to: 1), file: StaticString = #file, line: UInt = #line) { + let fullMatches = matchingCalls(method, file: file, line: line) + let success = count.matches(fullMatches) + let assertionName = method.method.assertionName() + let feedback: String = { + guard !success else { return "" } + return Utils.closestCallsMessage( + for: self.invocations.map { invocation in + matcher.set(file: file, line: line) + defer { matcher.clearFileAndLine() } + return MethodType.compareParameters(lhs: invocation, rhs: method.method, matcher: matcher) + }, + name: assertionName + ) + }() + MockyAssert(success, "Expected: \(count) invocations of `\(assertionName)`, but was: \(fullMatches).\(feedback)", file: file, line: line) + } + + private func addInvocation(_ call: MethodType) { + self.queue.sync { invocations.append(call) } + } + private func methodReturnValue(_ method: MethodType) throws -> StubProduct { + matcher.set(file: self.file, line: self.line) + defer { matcher.clearFileAndLine() } + let candidates = sequencingPolicy.sorted(methodReturnValues, by: { $0.method.intValue() > $1.method.intValue() }) + let matched = candidates.first(where: { $0.isValid && MethodType.compareParameters(lhs: $0.method, rhs: method, matcher: matcher).isFullMatch }) + guard let product = matched?.getProduct(policy: self.stubbingPolicy) else { throw MockError.notStubed } + return product + } + private func methodPerformValue(_ method: MethodType) -> Any? { + matcher.set(file: self.file, line: self.line) + defer { matcher.clearFileAndLine() } + let matched = methodPerformValues.reversed().first { MethodType.compareParameters(lhs: $0.method, rhs: method, matcher: matcher).isFullMatch } + return matched?.performs + } + private func matchingCalls(_ method: MethodType, file: StaticString?, line: UInt?) -> [MethodType] { + matcher.set(file: file ?? self.file, line: line ?? self.line) + defer { matcher.clearFileAndLine() } + return invocations.filter { MethodType.compareParameters(lhs: $0, rhs: method, matcher: matcher).isFullMatch } + } + private func matchingCalls(_ method: Verify, file: StaticString?, line: UInt?) -> Int { + return matchingCalls(method.method, file: file, line: line).count + } + private func givenGetterValue(_ method: MethodType, _ message: String) -> T { + do { + return try methodReturnValue(method).casted() + } catch { + onFatalFailure(message) + Failure(message) + } + } + private func optionalGivenGetterValue(_ method: MethodType, _ message: String) -> T? { + do { + return try methodReturnValue(method).casted() + } catch { + return nil + } + } + private func onFatalFailure(_ message: String) { + guard let file = self.file, let line = self.line else { return } // Let if fail if cannot handle gratefully + SwiftyMockyTestObserver.handleFatalError(message: message, file: file, line: line) + } +} + // MARK: - DiscussionAnalytics open class DiscussionAnalyticsMock: DiscussionAnalytics, Mock { diff --git a/OpenEdX/DI/AppAssembly.swift b/OpenEdX/DI/AppAssembly.swift index 8077b4d8d..84bb16054 100644 --- a/OpenEdX/DI/AppAssembly.swift +++ b/OpenEdX/DI/AppAssembly.swift @@ -69,6 +69,14 @@ class AppAssembly: Assembly { r.resolve(AnalyticsManager.self)! }.inObjectScope(.container) + container.register(CoreAnalytics.self) { r in + r.resolve(AnalyticsManager.self)! + }.inObjectScope(.container) + + container.register(WhatsNewAnalytics.self) { r in + r.resolve(AnalyticsManager.self)! + }.inObjectScope(.container) + container.register(ConnectivityProtocol.self) { _ in Connectivity() } diff --git a/OpenEdX/DI/ScreenAssembly.swift b/OpenEdX/DI/ScreenAssembly.swift index 74074812d..09d7b9c32 100644 --- a/OpenEdX/DI/ScreenAssembly.swift +++ b/OpenEdX/DI/ScreenAssembly.swift @@ -45,7 +45,8 @@ class ScreenAssembly: Assembly { // MARK: Startup screen container.register(StartupViewModel.self) { r in StartupViewModel( - router: r.resolve(AuthorizationRouter.self)! + router: r.resolve(AuthorizationRouter.self)!, + analytics: r.resolve(CoreAnalytics.self)! ) } @@ -207,7 +208,8 @@ class ScreenAssembly: Assembly { container.register(SettingsViewModel.self) { r in SettingsViewModel( interactor: r.resolve(ProfileInteractorProtocol.self)!, - router: r.resolve(ProfileRouter.self)! + router: r.resolve(ProfileRouter.self)!, + analytics: r.resolve(CoreAnalytics.self)! ) } @@ -215,7 +217,8 @@ class ScreenAssembly: Assembly { DeleteAccountViewModel( interactor: r.resolve(ProfileInteractorProtocol.self)!, router: r.resolve(ProfileRouter.self)!, - connectivity: r.resolve(ConnectivityProtocol.self)! + connectivity: r.resolve(ConnectivityProtocol.self)!, + analytics: r.resolve(ProfileAnalytics.self)! ) } @@ -266,7 +269,8 @@ class ScreenAssembly: Assembly { courseStart: courseStart, courseEnd: courseEnd, enrollmentStart: enrollmentStart, - enrollmentEnd: enrollmentEnd + enrollmentEnd: enrollmentEnd, + coreAnalytics: r.resolve(CoreAnalytics.self)! ) } @@ -346,7 +350,8 @@ class ScreenAssembly: Assembly { router: r.resolve(CourseRouter.self)!, cssInjector: r.resolve(CSSInjector.self)!, connectivity: r.resolve(ConnectivityProtocol.self)!, - courseID: courseID + courseID: courseID, + analytics: r.resolve(CourseAnalytics.self)! ) } @@ -356,7 +361,9 @@ class ScreenAssembly: Assembly { router: r.resolve(CourseRouter.self)!, cssInjector: r.resolve(CSSInjector.self)!, connectivity: r.resolve(ConnectivityProtocol.self)!, - courseID: courseID) + courseID: courseID, + analytics: r.resolve(CourseAnalytics.self)! + ) } // MARK: Discussion diff --git a/OpenEdX/Managers/AnalyticsManager/AnalyticsManager.swift b/OpenEdX/Managers/AnalyticsManager/AnalyticsManager.swift index 4cb616fc8..116acfef8 100644 --- a/OpenEdX/Managers/AnalyticsManager/AnalyticsManager.swift +++ b/OpenEdX/Managers/AnalyticsManager/AnalyticsManager.swift @@ -13,11 +13,12 @@ import Dashboard import Profile import Course import Discussion +import WhatsNew import Swinject protocol AnalyticsService { func identify(id: String, username: String?, email: String?) - func logEvent(_ event: Event, parameters: [String: Any]?) + func logEvent(_ event: AnalyticsEvent, parameters: [String: Any]?) } class AnalyticsManager: AuthorizationAnalytics, @@ -26,8 +27,9 @@ class AnalyticsManager: AuthorizationAnalytics, DashboardAnalytics, ProfileAnalytics, CourseAnalytics, - DiscussionAnalytics { - + DiscussionAnalytics, + CoreAnalytics, + WhatsNewAnalytics { private var services: [AnalyticsService] = [] // Init Analytics Manager @@ -57,64 +59,113 @@ class AnalyticsManager: AuthorizationAnalytics, } } + private func logEvent(_ event: AnalyticsEvent, parameters: [String: Any]? = nil) { + for service in services { + service.logEvent(event, parameters: parameters) + } + } + + // MARK: Generic event tracker functions + public func trackEvent(_ event: AnalyticsEvent, parameters: [String: Any]? = nil) { + logEvent(event, parameters: parameters) + } + + public func trackEvent(_ event: AnalyticsEvent, biValue: EventBIValue, parameters: [String: Any]?) { + var eventParams: [String: Any] = [EventParamKey.name: biValue.rawValue] + + if let parameters { + eventParams.merge(parameters, uniquingKeysWith: { (first, _) in first }) + } + + logEvent(event, parameters: eventParams) + } + + private func trackEvent(_ event: AnalyticsEvent, biValue: EventBIValue) { + logEvent(event, parameters: [EventParamKey.name: biValue.rawValue]) + } + + // MARK: Pre Login + public func userLogin(method: AuthMethod) { - logEvent(.userLogin, parameters: [Key.method: method.analyticsValue]) + logEvent(.userLogin, parameters: [EventParamKey.method: method.analyticsValue]) + } + + public func registerClicked() { + trackEvent(.registerClicked, biValue: .registerClicked) + } + + public func signInClicked() { + trackEvent(.signInClicked, biValue: .signInClicked) } - public func signUpClicked() { - logEvent(.signUpClicked) + public func userSignInClicked() { + trackEvent(.userSignInClicked, biValue: .userSignInClicked) } public func createAccountClicked() { - logEvent(.createAccountClicked) + trackEvent(.createAccountClicked, biValue: .createAccountClicked) } - public func registrationSuccess() { - logEvent(.registrationSuccess) + public func registrationSuccess(method: String) { + let parameters = [ + EventParamKey.method: method, + EventParamKey.name: EventBIValue.registrationSuccess.rawValue + ] + logEvent(.registrationSuccess, parameters: parameters) } public func forgotPasswordClicked() { - logEvent(.forgotPasswordClicked) + trackEvent(.forgotPasswordClicked, biValue: .forgotPasswordClicked) } - public func resetPasswordClicked(success: Bool) { - logEvent(.resetPasswordClicked, parameters: [Key.success: success]) + public func resetPasswordClicked() { + trackEvent(.resetPasswordClicked, biValue: .resetPasswordClicked) + } + + public func resetPassword(success: Bool) { + trackEvent( + .resetPasswordSuccess, + biValue: .resetPasswordSuccess, + parameters: [EventParamKey.success: success] + ) } // MARK: MainScreenAnalytics public func mainDiscoveryTabClicked() { - logEvent(.mainDiscoveryTabClicked) + trackEvent(.mainDiscoveryTabClicked, biValue: .mainDiscoveryTabClicked) } public func mainDashboardTabClicked() { - logEvent(.mainDashboardTabClicked) + trackEvent(.mainDashboardTabClicked, biValue: .mainDashboardTabClicked) } public func mainProgramsTabClicked() { - logEvent(.mainProgramsTabClicked) + trackEvent(.mainProgramsTabClicked, biValue: .mainProgramsTabClicked) } public func mainProfileTabClicked() { - logEvent(.mainProfileTabClicked) + trackEvent(.mainProfileTabClicked, biValue: .mainProfileTabClicked) } // MARK: Discovery public func discoverySearchBarClicked() { - logEvent(.discoverySearchBarClicked) + trackEvent(.discoverySearchBarClicked, biValue: .discoverySearchBarClicked) } public func discoveryCoursesSearch(label: String, coursesCount: Int) { - logEvent(.discoveryCoursesSearch, - parameters: [Key.label: label, - Key.coursesCount: coursesCount]) + let parameters: [String: Any] = [EventParamKey.label: label, + EventParamKey.coursesCount: coursesCount, + EventParamKey.name: EventBIValue.discoveryCoursesSearch.rawValue] + logEvent(.discoveryCoursesSearch, parameters: parameters) } public func discoveryCourseClicked(courseID: String, courseName: String) { let parameters = [ - Key.courseID: courseID, - Key.courseName: courseName + EventParamKey.courseID: courseID, + EventParamKey.courseName: courseName, + EventParamKey.name: EventBIValue.discoveryCourseClicked.rawValue ] logEvent(.discoveryCourseClicked, parameters: parameters) } @@ -123,8 +174,9 @@ class AnalyticsManager: AuthorizationAnalytics, public func dashboardCourseClicked(courseID: String, courseName: String) { let parameters = [ - Key.courseID: courseID, - Key.courseName: courseName + EventParamKey.courseID: courseID, + EventParamKey.courseName: courseName, + EventParamKey.name: EventBIValue.dashboardCourseClicked.rawValue ] logEvent(.dashboardCourseClicked, parameters: parameters) } @@ -132,118 +184,277 @@ class AnalyticsManager: AuthorizationAnalytics, // MARK: Profile public func profileEditClicked() { - logEvent(.profileEditClicked) + let parameters = [ + EventParamKey.name: EventBIValue.profileEditClicked.rawValue, + EventParamKey.category: EventCategory.profile + ] + + logEvent(.profileEditClicked, parameters: parameters) + } + + public func profileSwitch(action: String) { + let parameters = [ + EventParamKey.action: action, + EventParamKey.category: EventCategory.profile + ] + + trackEvent(.profileWifiToggle, biValue: .profileWifiToggle, parameters: parameters) + } + + public func profileWifiToggle(action: String) { + let parameters = [ + EventParamKey.action: action, + EventParamKey.category: EventCategory.profile + ] + + trackEvent(.profileSwitch, biValue: .profileSwitch, parameters: parameters) } public func profileEditDoneClicked() { - logEvent(.profileEditDoneClicked) + let parameters = [ + EventParamKey.name: EventBIValue.profileEditDoneClicked.rawValue, + EventParamKey.category: EventCategory.profile + ] + logEvent(.profileEditDoneClicked, parameters: parameters) } public func profileDeleteAccountClicked() { + let parameters = [ + EventParamKey.name: EventBIValue.profileDeleteAccountClicked.rawValue, + EventParamKey.category: EventCategory.profile + ] logEvent(.profileDeleteAccountClicked) } public func profileVideoSettingsClicked() { - logEvent(.profileVideoSettingsClicked) + let parameters = [ + EventParamKey.name: EventBIValue.profileVideoSettingsClicked.rawValue, + EventParamKey.category: EventCategory.profile + ] + logEvent(.profileVideoSettingsClicked, parameters: parameters) + } + + public func profileUserDeleteAccountClicked() { + trackEvent( + .profileUserDeleteAccountClicked, + biValue: .profileUserDeleteAccountClicked, + parameters: [EventParamKey.category: EventCategory.profile] + ) + } + + public func profileDeleteAccountSuccess(success: Bool) { + trackEvent( + .profileUserDeleteAccountClicked, + biValue: .profileUserDeleteAccountClicked, + parameters: [ + EventParamKey.category: EventCategory.profile, + EventParamKey.success: success + ] + ) + } + + public func videoQualityChanged( + _ event: AnalyticsEvent, + bivalue: EventBIValue, + value: String, + oldValue: String + ) { + let parameters = [ + EventParamKey.name: bivalue.rawValue, + EventParamKey.category: EventCategory.video, + EventParamKey.value: value, + EventParamKey.oldValue: oldValue + ] + + logEvent(event, parameters: parameters) + } + + public func profileEvent(_ event: AnalyticsEvent, biValue: EventBIValue) { + let parameters = [ + EventParamKey.category: EventCategory.profile, + EventParamKey.name: biValue.rawValue + ] + + logEvent(event, parameters: parameters) } public func privacyPolicyClicked() { - logEvent(.privacyPolicyClicked) + let parameters = [ + EventParamKey.name: EventBIValue.privacyPolicyClicked.rawValue, + EventParamKey.category: EventCategory.profile + ] + logEvent(.privacyPolicyClicked, parameters: parameters) } public func cookiePolicyClicked() { + let parameters = [ + EventParamKey.name: EventBIValue.cookiePolicyClicked.rawValue, + EventParamKey.category: EventCategory.profile + ] logEvent(.cookiePolicyClicked) } public func emailSupportClicked() { - logEvent(.emailSupportClicked) + let parameters = [ + EventParamKey.name: EventBIValue.emailSupportClicked.rawValue, + EventParamKey.category: EventCategory.profile + ] + logEvent(.emailSupportClicked, parameters: parameters) + } + + public func faqClicked() { + let parameters = [ + EventParamKey.name: EventBIValue.faqClicked.rawValue, + EventParamKey.category: EventCategory.profile + ] + logEvent(.faqClicked, parameters: parameters) + } + + public func tosClicked() { + let parameters = [ + EventParamKey.name: EventBIValue.tosClicked.rawValue, + EventParamKey.category: EventCategory.profile + ] + logEvent(.tosClicked, parameters: parameters) + } + + public func dataSellClicked() { + let parameters = [ + EventParamKey.name: EventBIValue.dataSellClicked.rawValue, + EventParamKey.category: EventCategory.profile + ] + logEvent(.dataSellClicked, parameters: parameters) } public func userLogout(force: Bool) { - logEvent(.userLogout, parameters: [Key.force: force]) + let parameters = [ + EventParamKey.name: EventBIValue.userLogout.rawValue, + EventParamKey.category: EventCategory.profile + ] + logEvent(.userLogout, parameters: [EventParamKey.force: force]) } // MARK: Course public func courseEnrollClicked(courseId: String, courseName: String) { let parameters = [ - Key.courseID: courseId, - Key.courseName: courseName + EventParamKey.courseID: courseId, + EventParamKey.courseName: courseName, + EventParamKey.conversion: courseId, + EventParamKey.category: EventCategory.discovery ] logEvent(.courseEnrollClicked, parameters: parameters) } public func courseEnrollSuccess(courseId: String, courseName: String) { let parameters = [ - Key.courseID: courseId, - Key.courseName: courseName + EventParamKey.courseID: courseId, + EventParamKey.courseName: courseName, + EventParamKey.conversion: courseId, + EventParamKey.category: EventCategory.discovery ] logEvent(.courseEnrollSuccess, parameters: parameters) } + func externalLinkOpen(url: String, screen: String) { + let parameters = [ + EventParamKey.url: url, + EventParamKey.screenName: screen, + EventParamKey.category: EventCategory.discovery, + EventParamKey.name: EventBIValue.externalLinkOpenAlert.rawValue + ] + logEvent(.externalLinkOpenAlert, parameters: parameters) + } + + func externalLinkOpenAction(url: String, screen: String, action: String) { + let parameters = [ + EventParamKey.url: url, + EventParamKey.screenName: screen, + EventParamKey.alertAction: action, + EventParamKey.category: EventCategory.discovery, + EventParamKey.name: EventBIValue.externalLinkOpenAlertAction.rawValue + ] + logEvent(.externalLinkOpenAlertAction, parameters: parameters) + } + + public func discoveryEvent(event: AnalyticsEvent, biValue: EventBIValue) { + let parameters = [ + EventParamKey.category: EventCategory.discovery, + EventParamKey.name: biValue.rawValue + ] + + logEvent(event, parameters: parameters) + } + public func viewCourseClicked(courseId: String, courseName: String) { let parameters = [ - Key.courseID: courseId, - Key.courseName: courseName + EventParamKey.courseID: courseId, + EventParamKey.courseName: courseName, + EventParamKey.category: EventCategory.discovery ] logEvent(.viewCourseClicked, parameters: parameters) } - public func resumeCourseTapped(courseId: String, courseName: String, blockId: String) { + public func resumeCourseClicked(courseId: String, courseName: String, blockId: String) { let parameters = [ - Key.courseID: courseId, - Key.courseName: courseName, - Key.blockID: blockId + EventParamKey.courseID: courseId, + EventParamKey.courseName: courseName, + EventParamKey.blockID: blockId, + EventParamKey.name: EventBIValue.resumeCourseClicked.rawValue ] - logEvent(.resumeCourseTapped, parameters: parameters) + logEvent(.resumeCourseClicked, parameters: parameters) } public func sequentialClicked(courseId: String, courseName: String, blockId: String, blockName: String) { let parameters = [ - Key.courseID: courseId, - Key.courseName: courseName, - Key.blockID: blockId, - Key.blockName: blockName + EventParamKey.courseID: courseId, + EventParamKey.courseName: courseName, + EventParamKey.blockID: blockId, + EventParamKey.blockName: blockName, + EventParamKey.name: EventBIValue.sequentialClicked.rawValue ] logEvent(.sequentialClicked, parameters: parameters) } public func verticalClicked(courseId: String, courseName: String, blockId: String, blockName: String) { let parameters = [ - Key.courseID: courseId, - Key.courseName: courseName, - Key.blockID: blockId, - Key.blockName: blockName + EventParamKey.courseID: courseId, + EventParamKey.courseName: courseName, + EventParamKey.unitID: blockId, + EventParamKey.unitName: blockName ] logEvent(.verticalClicked, parameters: parameters) } public func nextBlockClicked(courseId: String, courseName: String, blockId: String, blockName: String) { let parameters = [ - Key.courseID: courseId, - Key.courseName: courseName, - Key.blockID: blockId, - Key.blockName: blockName + EventParamKey.courseID: courseId, + EventParamKey.courseName: courseName, + EventParamKey.blockID: blockId, + EventParamKey.blockName: blockName, + EventParamKey.name: EventBIValue.nextBlockClicked.rawValue ] logEvent(.nextBlockClicked, parameters: parameters) } public func prevBlockClicked(courseId: String, courseName: String, blockId: String, blockName: String) { let parameters = [ - Key.courseID: courseId, - Key.courseName: courseName, - Key.blockID: blockId, - Key.blockName: blockName + EventParamKey.courseID: courseId, + EventParamKey.courseName: courseName, + EventParamKey.blockID: blockId, + EventParamKey.blockName: blockName, + EventParamKey.name: EventBIValue.prevBlockClicked.rawValue ] logEvent(.prevBlockClicked, parameters: parameters) } public func finishVerticalClicked(courseId: String, courseName: String, blockId: String, blockName: String) { let parameters = [ - Key.courseID: courseId, - Key.courseName: courseName, - Key.blockID: blockId, - Key.blockName: blockName + EventParamKey.courseID: courseId, + EventParamKey.courseName: courseName, + EventParamKey.blockID: blockId, + EventParamKey.blockName: blockName, + EventParamKey.name: EventBIValue.finishVerticalClicked.rawValue ] logEvent(.finishVerticalClicked, parameters: parameters) } @@ -255,156 +466,260 @@ class AnalyticsManager: AuthorizationAnalytics, blockName: String ) { let parameters = [ - Key.courseID: courseId, - Key.courseName: courseName, - Key.blockID: blockId, - Key.blockName: blockName + EventParamKey.courseID: courseId, + EventParamKey.courseName: courseName, + EventParamKey.blockID: blockId, + EventParamKey.blockName: blockName, + EventParamKey.name: EventBIValue.finishVerticalNextSectionClicked.rawValue ] logEvent(.finishVerticalNextSectionClicked, parameters: parameters) } public func finishVerticalBackToOutlineClicked(courseId: String, courseName: String) { let parameters = [ - Key.courseID: courseId, - Key.courseName: courseName + EventParamKey.courseID: courseId, + EventParamKey.courseName: courseName, + EventParamKey.name: EventBIValue.finishVerticalBackToOutlineClicked.rawValue ] logEvent(.finishVerticalBackToOutlineClicked, parameters: parameters) } public func courseOutlineCourseTabClicked(courseId: String, courseName: String) { let parameters = [ - Key.courseID: courseId, - Key.courseName: courseName + EventParamKey.courseID: courseId, + EventParamKey.courseName: courseName, + EventParamKey.name: EventBIValue.courseOutlineCourseTabClicked.rawValue ] logEvent(.courseOutlineCourseTabClicked, parameters: parameters) } public func courseOutlineVideosTabClicked(courseId: String, courseName: String) { let parameters = [ - Key.courseID: courseId, - Key.courseName: courseName + EventParamKey.courseID: courseId, + EventParamKey.courseName: courseName, + EventParamKey.name: EventBIValue.courseOutlineVideosTabClicked.rawValue ] logEvent(.courseOutlineVideosTabClicked, parameters: parameters) } public func courseOutlineDatesTabClicked(courseId: String, courseName: String) { let parameters = [ - Key.courseID: courseId, - Key.courseName: courseName + EventParamKey.courseID: courseId, + EventParamKey.courseName: courseName, + EventParamKey.name: EventBIValue.courseOutlineDatesTabClicked.rawValue ] logEvent(.courseOutlineDatesTabClicked, parameters: parameters) } public func courseOutlineDiscussionTabClicked(courseId: String, courseName: String) { let parameters = [ - Key.courseID: courseId, - Key.courseName: courseName + EventParamKey.courseID: courseId, + EventParamKey.courseName: courseName, + EventParamKey.name: EventBIValue.courseOutlineDiscussionTabClicked.rawValue ] logEvent(.courseOutlineDiscussionTabClicked, parameters: parameters) } public func courseOutlineHandoutsTabClicked(courseId: String, courseName: String) { let parameters = [ - Key.courseID: courseId, - Key.courseName: courseName + EventParamKey.courseID: courseId, + EventParamKey.courseName: courseName, + EventParamKey.name: EventBIValue.courseOutlineHandoutsTabClicked.rawValue ] logEvent(.courseOutlineHandoutsTabClicked, parameters: parameters) } + func datesComponentTapped( + courseId: String, + blockId: String, + link: String, + supported: Bool + ) { + let parameters: [String: Any] = [ + EventParamKey.courseID: courseId, + EventParamKey.blockID: blockId, + EventParamKey.link: link, + EventParamKey.supported: supported, + EventParamKey.category: EventCategory.courseDates, + EventParamKey.name: EventBIValue.datesComponentClicked.rawValue + ] + + logEvent(.datesComponentClicked, parameters: parameters) + } + + public func trackCourseEvent(_ event: AnalyticsEvent, biValue: EventBIValue, courseID: String) { + let parameters = [ + EventParamKey.courseID: courseID, + EventParamKey.category: EventCategory.course, + EventParamKey.name: biValue.rawValue + ] + + logEvent(event, parameters: parameters) + } + + public func plsEvent( + _ event: AnalyticsEvent, + bivalue: EventBIValue, + courseID: String, + screenName: String, + type: String + ) { + let parameters = [ + EventParamKey.courseID: courseID, + EventParamKey.name: bivalue.rawValue, + EventParamKey.screenName: screenName, + EventParamKey.bannerType: type + ] + + logEvent(event, parameters: parameters) + } + + public func plsSuccessEvent( + _ event: AnalyticsEvent, + bivalue: EventBIValue, + courseID: String, + screenName: String, + type: String, + success: Bool + ) { + let parameters: [String: Any] = [ + EventParamKey.courseID: courseID, + EventParamKey.name: bivalue.rawValue, + EventParamKey.screenName: screenName, + EventParamKey.bannerType: type, + EventParamKey.success: success + ] + + logEvent(event, parameters: parameters) + } + + public func bulkDownloadVideosToggle(courseID: String, action: Bool) { + let parameters: [String: Any] = [ + EventParamKey.courseID: courseID, + EventParamKey.action: action, + EventParamKey.category: EventCategory.video, + EventParamKey.name: EventBIValue.bulkDownloadVideosToggle.rawValue + ] + + logEvent(.bulkDownloadVideosToggle, parameters: parameters) + } + + public func bulkDownloadVideosSubsection( + courseID: String, + sectionID: String, + subSectionID: String, + videos: Int + ) { + let parameters: [String: Any] = [ + EventParamKey.courseID: courseID, + EventParamKey.courseSection: sectionID, + EventParamKey.courseSubsection: subSectionID, + EventParamKey.noOfVideos: videos, + EventParamKey.category: EventCategory.video, + EventParamKey.name: EventBIValue.bulkDownloadVideosSubsection.rawValue + ] + + logEvent(.bulkDownloadVideosSubsection, parameters: parameters) + } + + public func bulkDeleteVideosSubsection( + courseID: String, + subSectionID: String, + videos: Int + ) { + let parameters: [String: Any] = [ + EventParamKey.courseID: courseID, + EventParamKey.courseSubsection: subSectionID, + EventParamKey.noOfVideos: videos, + EventParamKey.category: EventCategory.video, + EventParamKey.name: EventBIValue.bulkDeleteVideosSubsection.rawValue + ] + + logEvent(.bulkDeleteVideosSubsection, parameters: parameters) + } + // MARK: Discussion public func discussionAllPostsClicked(courseId: String, courseName: String) { let parameters = [ - Key.courseID: courseId, - Key.courseName: courseName + EventParamKey.courseID: courseId, + EventParamKey.courseName: courseName, + EventParamKey.name: EventBIValue.discussionAllPostsClicked.rawValue ] logEvent(.discussionAllPostsClicked, parameters: parameters) } public func discussionFollowingClicked(courseId: String, courseName: String) { let parameters = [ - Key.courseID: courseId, - Key.courseName: courseName + EventParamKey.courseID: courseId, + EventParamKey.courseName: courseName, + EventParamKey.name: EventBIValue.discussionFollowingClicked.rawValue ] logEvent(.discussionFollowingClicked, parameters: parameters) } public func discussionTopicClicked(courseId: String, courseName: String, topicId: String, topicName: String) { let parameters = [ - Key.courseID: courseId, - Key.courseName: courseName, - Key.topicID: topicId, - Key.topicName: topicName + EventParamKey.courseID: courseId, + EventParamKey.courseName: courseName, + EventParamKey.topicID: topicId, + EventParamKey.topicName: topicName, + EventParamKey.name: EventBIValue.discussionTopicClicked.rawValue ] logEvent(.discussionTopicClicked, parameters: parameters) } - private func logEvent(_ event: Event, parameters: [String: Any]? = nil) { - for service in services { - service.logEvent(event, parameters: parameters) + // MARK: app review + + public func appreview( + _ event: AnalyticsEvent, + biValue: EventBIValue, + action: String? = nil, + rating: Int? = 0 + ) { + var parameters: [String: Any] = [ + EventParamKey.category: EventCategory.appreviews, + EventParamKey.name: biValue.rawValue, + ] + + if rating != 0 { + parameters[EventParamKey.rating] = rating ?? 0 + } + + if let action { + parameters[EventParamKey.action] = action } + + logEvent(event, parameters: parameters) + } + + // MARK: whats new + + func whatsnewPopup() { + let parameters = [ + EventParamKey.name: EventBIValue.whatnewPopup.rawValue, + EventParamKey.category: EventCategory.whatsNew + ] + logEvent(.whatnewPopup, parameters: parameters) + } + + func whatsnewDone(totalScreens: Int) { + let parameters: [String: Any] = [ + EventParamKey.category: EventCategory.whatsNew, + EventParamKey.name: EventBIValue.whatnewDone.rawValue, + "total_screens": totalScreens + ] + + logEvent(.whatnewDone, parameters: parameters) + } + + func whatsnewClose(totalScreens: Int, currentScreen: Int) { + let parameters: [String: Any] = [ + EventParamKey.category: EventCategory.whatsNew, + EventParamKey.name: EventBIValue.whatnewClose.rawValue, + "total_screens": totalScreens, + "currently_viewed": currentScreen + ] + + logEvent(.whatnewClose, parameters: parameters) } -} - -struct Key { - static let courseID = "course_id" - static let courseName = "course_name" - static let topicID = "topic_id" - static let topicName = "topic_name" - static let blockID = "block_id" - static let blockName = "block_name" - static let method = "method" - static let label = "label" - static let coursesCount = "courses_count" - static let force = "force" - static let success = "success" -} - -enum Event: String { - case userLogin = "User_Login" - case signUpClicked = "Sign_up_Clicked" - case createAccountClicked = "Create_Account_Clicked" - case registrationSuccess = "Registration_Success" - case userLogout = "User_Logout" - case forgotPasswordClicked = "Forgot_password_Clicked" - case resetPasswordClicked = "Reset_password_Clicked" - - case mainDiscoveryTabClicked = "Main_Discovery_tab_Clicked" - case mainDashboardTabClicked = "Main_Dashboard_tab_Clicked" - case mainProgramsTabClicked = "Main_Programs_tab_Clicked" - case mainProfileTabClicked = "Main_Profile_tab_Clicked" - - case discoverySearchBarClicked = "Discovery_Search_Bar_Clicked" - case discoveryCoursesSearch = "Discovery_Courses_Search" - case discoveryCourseClicked = "Discovery_Course_Clicked" - - case dashboardCourseClicked = "Dashboard_Course_Clicked" - - case profileEditClicked = "Profile_Edit_Clicked" - case profileEditDoneClicked = "Profile_Edit_Done_Clicked" - case profileDeleteAccountClicked = "Profile_Delete_Account_Clicked" - case profileVideoSettingsClicked = "Profile_Video_settings_Clicked" - case privacyPolicyClicked = "Privacy_Policy_Clicked" - case cookiePolicyClicked = "Cookie_Policy_Clicked" - case emailSupportClicked = "Email_Support_Clicked" - - case courseEnrollClicked = "Course_Enroll_Clicked" - case courseEnrollSuccess = "Course_Enroll_Success" - case viewCourseClicked = "View_Course_Clicked" - case resumeCourseTapped = "Resume_Course_Tapped" - case sequentialClicked = "Sequential_Clicked" - case verticalClicked = "Vertical_Clicked" - case nextBlockClicked = "Next_Block_Clicked" - case prevBlockClicked = "Prev_Block_Clicked" - case finishVerticalClicked = "Finish_Vertical_Clicked" - case finishVerticalNextSectionClicked = "Finish_Vertical_Next_section_Clicked" - case finishVerticalBackToOutlineClicked = "Finish_Vertical_Back_to_outline_Clicked" - case courseOutlineCourseTabClicked = "Course_Outline_Course_tab_Clicked" - case courseOutlineVideosTabClicked = "Course_Outline_Videos_tab_Clicked" - case courseOutlineDatesTabClicked = "Course_Outline_Dates_tab_Clicked" - case courseOutlineDiscussionTabClicked = "Course_Outline_Discussion_tab_Clicked" - case courseOutlineHandoutsTabClicked = "Course_Outline_Handouts_tab_Clicked" - - case discussionAllPostsClicked = "Discussion_All_Posts_Clicked" - case discussionFollowingClicked = "Discussion_Following_Clicked" - case discussionTopicClicked = "Discussion_Topic_Clicked" } diff --git a/OpenEdX/Managers/FirebaseAnalyticsService/FirebaseAnalyticsService.swift b/OpenEdX/Managers/FirebaseAnalyticsService/FirebaseAnalyticsService.swift index f41df2555..12bd225e8 100644 --- a/OpenEdX/Managers/FirebaseAnalyticsService/FirebaseAnalyticsService.swift +++ b/OpenEdX/Managers/FirebaseAnalyticsService/FirebaseAnalyticsService.swift @@ -9,6 +9,9 @@ import Foundation import Firebase import Core +private let MaxParameterValueCharacters = 100 +private let MaxNameValueCharacters = 40 + class FirebaseAnalyticsService: AnalyticsService { // Init manager public init(config: ConfigProtocol) { @@ -21,8 +24,73 @@ class FirebaseAnalyticsService: AnalyticsService { Analytics.setUserID(id) } - func logEvent(_ event: Event, parameters: [String: Any]?) { - Analytics.logEvent(event.rawValue, parameters: parameters) + func logEvent(_ event: AnalyticsEvent, parameters: [String: Any]?) { + guard let name = try? formatFirebaseName(event.rawValue) else { + debugLog("Firebase: event name is not supported: \(event.rawValue)") + return + } + + Analytics.logEvent(name, parameters: formatParamaters(params: parameters)) + } +} + +extension FirebaseAnalyticsService { + private func formatParamaters(params: [String: Any]?) -> [String: Any] { + // Firebase only supports String or Number as value for event parameters + var formattedParams: [String: Any] = [:] + + for (key, value) in params ?? [:] { + if let key = try? formatFirebaseName(key) { + formattedParams[key] = formatParamValue(value: value) + } + } + + return formattedParams + } + + private func formatFirebaseName(_ eventName: String) throws -> String { + let trimmed = eventName.trimmingCharacters(in: .whitespaces) + do { + let regex = try NSRegularExpression(pattern: "([^a-zA-Z0-9_])", options: .caseInsensitive) + let formattedString = regex.stringByReplacingMatches( + in: trimmed, + options: .reportProgress, + range: NSRange(location: 0, length: trimmed.count), + withTemplate: "_" + ) + + // Resize the string to maximum 40 characters if needed + let range = NSRange(location: 0, length: min(formattedString.count, MaxNameValueCharacters)) + var formattedName = NSString(string: formattedString).substring(with: range) + + while formattedName.contains("__") { + formattedName = formattedName.replace(string: "__", replacement: "_") + } + + return formattedName + + } catch { + debugLog("Could not parse event name for Firebase.") + throw(error) + } + } + + private func formatParamValue(value: Any?) -> Any? { + + guard var formattedValue = value as? String else { return value} + + // Firebase only supports 100 characters for parameter value + if formattedValue.count > MaxParameterValueCharacters { + let index = formattedValue.index(formattedValue.startIndex, offsetBy: MaxParameterValueCharacters) + formattedValue = String(formattedValue[.. String { + return replacingOccurrences(of: string, with: replacement, options: NSString.CompareOptions.literal, range: nil) + } } diff --git a/OpenEdX/Managers/SegmentAnalyticsService/SegmentAnalyticsService.swift b/OpenEdX/Managers/SegmentAnalyticsService/SegmentAnalyticsService.swift index 0d2315ec5..b8c2cd422 100644 --- a/OpenEdX/Managers/SegmentAnalyticsService/SegmentAnalyticsService.swift +++ b/OpenEdX/Managers/SegmentAnalyticsService/SegmentAnalyticsService.swift @@ -35,7 +35,7 @@ class SegmentAnalyticsService: AnalyticsService { analytics?.identify(userId: id, traits: traits) } - func logEvent(_ event: Event, parameters: [String: Any]?) { + func logEvent(_ event: AnalyticsEvent, parameters: [String: Any]?) { analytics?.track( name: event.rawValue, properties: parameters diff --git a/OpenEdX/RouteController.swift b/OpenEdX/RouteController.swift index 499992615..ac53c3d36 100644 --- a/OpenEdX/RouteController.swift +++ b/OpenEdX/RouteController.swift @@ -26,6 +26,10 @@ class RouteController: UIViewController { diContainer.resolve(AuthorizationAnalytics.self)! }() + private lazy var coreAnalytics: CoreAnalytics = { + diContainer.resolve(CoreAnalytics.self)! + }() + override func viewDidLoad() { super.viewDidLoad() @@ -39,6 +43,8 @@ class RouteController: UIViewController { self.showStartupScreen() } } + + coreAnalytics.trackEvent(.launch, biValue: .launch) } private func showStartupScreen() { @@ -62,10 +68,15 @@ class RouteController: UIViewController { } private func showMainOrWhatsNewScreen() { - var storage = Container.shared.resolve(WhatsNewStorage.self)! - let config = Container.shared.resolve(ConfigProtocol.self)! + guard var storage = Container.shared.resolve(WhatsNewStorage.self), + let config = Container.shared.resolve(ConfigProtocol.self), + let analytics = Container.shared.resolve(WhatsNewAnalytics.self) + else { + assert(false, "unable to resolve basic dependencies to start app") + return + } - let viewModel = WhatsNewViewModel(storage: storage) + let viewModel = WhatsNewViewModel(storage: storage, analytics: analytics) let shouldShowWhatsNew = viewModel.shouldShowWhatsNew() if shouldShowWhatsNew && config.features.whatNewEnabled { diff --git a/OpenEdX/Router.swift b/OpenEdX/Router.swift index 8e9667458..b15cccd80 100644 --- a/OpenEdX/Router.swift +++ b/OpenEdX/Router.swift @@ -69,12 +69,13 @@ public class Router: AuthorizationRouter, let config = Container.shared.resolve(ConfigProtocol.self)! let persistence = Container.shared.resolve(CorePersistenceProtocol.self)! let coreStorage = Container.shared.resolve(CoreStorage.self)! + let analytics = Container.shared.resolve(WhatsNewAnalytics.self)! if let userId = coreStorage.user?.id { persistence.set(userId: userId) } - let viewModel = WhatsNewViewModel(storage: whatsNewStorage, sourceScreen: sourceScreen) + let viewModel = WhatsNewViewModel(storage: whatsNewStorage, sourceScreen: sourceScreen, analytics: analytics) let whatsNew = WhatsNewView(router: Container.shared.resolve(WhatsNewRouter.self)!, viewModel: viewModel) let shouldShowWhatsNew = viewModel.shouldShowWhatsNew() @@ -101,11 +102,15 @@ public class Router: AuthorizationRouter, guard let viewModel = Container.shared.resolve( SignInViewModel.self, argument: sourceScreen + ), let authAnalytics = Container.shared.resolve( + AuthorizationAnalytics.self ) else { return } let view = SignInView(viewModel: viewModel) let controller = UIHostingController(rootView: view) navigationController.pushViewController(controller, animated: true) + + authAnalytics.signInClicked() } public func showStartupScreen() { @@ -129,10 +134,11 @@ public class Router: AuthorizationRouter, guard let config = Container.shared.resolve(ConfigProtocol.self), let storage = Container.shared.resolve(CoreStorage.self), let connectivity = Container.shared.resolve(ConnectivityProtocol.self), + let analytics = Container.shared.resolve(CoreAnalytics.self), connectivity.isInternetAvaliable else { return } - let vm = AppReviewViewModel(config: config, storage: storage) - if vm.shouldShowRatingView() { + let vm = AppReviewViewModel(config: config, storage: storage, analytics: analytics) + if true { presentView( transitionStyle: .crossDissolve, view: AppReviewView(viewModel: vm) @@ -211,7 +217,7 @@ public class Router: AuthorizationRouter, let controller = UIHostingController(rootView: view) navigationController.pushViewController(controller, animated: true) - authAnalytics.signUpClicked() + authAnalytics.registerClicked() } public func showForgotPasswordScreen() { @@ -604,11 +610,13 @@ public class Router: AuthorizationRouter, public func showVideoDownloadQualityView( downloadQuality: DownloadQuality, - didSelect: ((DownloadQuality) -> Void)? + didSelect: ((DownloadQuality) -> Void)?, + analytics: CoreAnalytics ) { let view = VideoDownloadQualityView( downloadQuality: downloadQuality, - didSelect: didSelect + didSelect: didSelect, + analytics: analytics ) let controller = UIHostingController(rootView: view) navigationController.pushViewController(controller, animated: true) diff --git a/Profile/Profile/Domain/Model/ProfileType.swift b/Profile/Profile/Domain/Model/ProfileType.swift index 3542c0b01..261c5c735 100644 --- a/Profile/Profile/Domain/Model/ProfileType.swift +++ b/Profile/Profile/Domain/Model/ProfileType.swift @@ -54,4 +54,8 @@ public enum ProfileType { return ProfileLocalization.fullProfile } } + + public var value: String? { + return String(describing: self).components(separatedBy: "(").first + } } diff --git a/Profile/Profile/Presentation/DeleteAccount/DeleteAccountView.swift b/Profile/Profile/Presentation/DeleteAccount/DeleteAccountView.swift index df3b47d0d..ab1d61148 100644 --- a/Profile/Profile/Presentation/DeleteAccount/DeleteAccountView.swift +++ b/Profile/Profile/Presentation/DeleteAccount/DeleteAccountView.swift @@ -173,7 +173,8 @@ struct DeleteAccountView_Previews: PreviewProvider { let vm = DeleteAccountViewModel( interactor: ProfileInteractor.mock, router: router, - connectivity: Connectivity() + connectivity: Connectivity(), + analytics: ProfileAnalyticsMock() ) DeleteAccountView(viewModel: vm) diff --git a/Profile/Profile/Presentation/DeleteAccount/DeleteAccountViewModel.swift b/Profile/Profile/Presentation/DeleteAccount/DeleteAccountViewModel.swift index 2cece2de0..298f30ca7 100644 --- a/Profile/Profile/Presentation/DeleteAccount/DeleteAccountViewModel.swift +++ b/Profile/Profile/Presentation/DeleteAccount/DeleteAccountViewModel.swift @@ -27,26 +27,37 @@ public class DeleteAccountViewModel: ObservableObject { private let interactor: ProfileInteractorProtocol public let router: ProfileRouter public let connectivity: ConnectivityProtocol + let analytics: ProfileAnalytics - public init(interactor: ProfileInteractorProtocol, router: ProfileRouter, connectivity: ConnectivityProtocol) { + public init( + interactor: ProfileInteractorProtocol, + router: ProfileRouter, + connectivity: ConnectivityProtocol, + analytics: ProfileAnalytics + ) { self.interactor = interactor self.router = router self.connectivity = connectivity + self.analytics = analytics } @MainActor func deleteAccount(password: String) async throws { isShowProgress = true + analytics.profileUserDeleteAccountClicked() do { if try await interactor.deleteAccount(password: password) { isShowProgress = false router.showLoginScreen(sourceScreen: .default) + analytics.profileDeleteAccountSuccess(success: true) } else { isShowProgress = false incorrectPassword = true + analytics.profileDeleteAccountSuccess(success: false) } } catch { isShowProgress = false + analytics.profileDeleteAccountSuccess(success: false) if error.validationError?.statusCode == 403 { incorrectPassword = true } else if let validationError = error.validationError, diff --git a/Profile/Profile/Presentation/EditProfile/EditProfileViewModel.swift b/Profile/Profile/Presentation/EditProfile/EditProfileViewModel.swift index b134a7cd4..aee56c70c 100644 --- a/Profile/Profile/Presentation/EditProfile/EditProfileViewModel.swift +++ b/Profile/Profile/Presentation/EditProfile/EditProfileViewModel.swift @@ -164,6 +164,8 @@ public class EditProfileViewModel: ObservableObject { } else { profileChanges.profileType.toggle() } + + analytics.profileSwitch(action: profileChanges.profileType.value ?? "") } func checkProfileType() { diff --git a/Profile/Profile/Presentation/Profile/ProfileView.swift b/Profile/Profile/Presentation/Profile/ProfileView.swift index 2a158b211..f803e6a94 100644 --- a/Profile/Profile/Presentation/Profile/ProfileView.swift +++ b/Profile/Profile/Presentation/Profile/ProfileView.swift @@ -221,6 +221,7 @@ public struct ProfileView: View { private var logOutButton: some View { VStack { Button(action: { + viewModel.trackLogoutClickedClicked() viewModel.router.presentView( transitionStyle: .crossDissolve, animated: true diff --git a/Profile/Profile/Presentation/Profile/ProfileViewModel.swift b/Profile/Profile/Presentation/Profile/ProfileViewModel.swift index 6fba1b825..d509224a6 100644 --- a/Profile/Profile/Presentation/Profile/ProfileViewModel.swift +++ b/Profile/Profile/Presentation/Profile/ProfileViewModel.swift @@ -140,6 +140,18 @@ public class ProfileViewModel: ObservableObject { analytics.cookiePolicyClicked() } + func trackTOSClicked() { + analytics.tosClicked() + } + + func trackFAQClicked() { + analytics.faqClicked() + } + + func trackDataSellClicked() { + analytics.dataSellClicked() + } + func trackPrivacyPolicyClicked() { analytics.privacyPolicyClicked() } @@ -147,4 +159,8 @@ public class ProfileViewModel: ObservableObject { func trackProfileEditClicked() { analytics.profileEditClicked() } + + func trackLogoutClickedClicked() { + analytics.profileEvent(.userLogoutClicked, biValue: .userLogoutClicked) + } } diff --git a/Profile/Profile/Presentation/Profile/Subviews/ProfileSupportInfoView.swift b/Profile/Profile/Presentation/Profile/Subviews/ProfileSupportInfoView.swift index 134b38536..1b8c2ae63 100644 --- a/Profile/Profile/Presentation/Profile/Subviews/ProfileSupportInfoView.swift +++ b/Profile/Profile/Presentation/Profile/Subviews/ProfileSupportInfoView.swift @@ -9,6 +9,10 @@ import SwiftUI import Theme import Core +private enum SupportType { + case contactSupport, tos, privacyPolicy, cookiesPolicy, sellData, faq +} + struct ProfileSupportInfoView: View { struct LinkViewModel { @@ -48,6 +52,7 @@ struct ProfileSupportInfoView: View { title: ProfileLocalization.contact ), isEmailSupport: true, + supportType: .contactSupport, identifier: "contact_support" ) } @@ -57,7 +62,8 @@ struct ProfileSupportInfoView: View { viewModel: .init( url: url, title: ProfileLocalization.terms - ) + ), + type: .tos ) .accessibilityIdentifier("tos") } @@ -67,7 +73,8 @@ struct ProfileSupportInfoView: View { viewModel: .init( url: url, title: ProfileLocalization.privacy - ) + ), + type: .privacyPolicy ) .accessibilityIdentifier("privacy_policy") } @@ -77,7 +84,8 @@ struct ProfileSupportInfoView: View { viewModel: .init( url: url, title: ProfileLocalization.cookiePolicy - ) + ), + type: .cookiesPolicy ) .accessibilityIdentifier("cookies_policy") } @@ -87,7 +95,8 @@ struct ProfileSupportInfoView: View { viewModel: .init( url: url, title: ProfileLocalization.doNotSellInformation - ) + ), + type: .sellData ) .accessibilityIdentifier("dont_sell_data") } @@ -98,18 +107,20 @@ struct ProfileSupportInfoView: View { url: url, title: ProfileLocalization.faqTitle ), + supportType: .faq, identifier: "view_faq" ) } @ViewBuilder - private func navigationLink(viewModel: LinkViewModel) -> some View { + private func navigationLink(viewModel: LinkViewModel, type: SupportType) -> some View { NavigationLink { WebBrowser( url: viewModel.url.absoluteString, pageTitle: viewModel.title, showProgress: true ) + } label: { HStack { Text(viewModel.title) @@ -120,6 +131,21 @@ struct ProfileSupportInfoView: View { Image(systemName: "chevron.right") } } + .simultaneousGesture(TapGesture().onEnded { + switch type { + case .cookiesPolicy: + self.viewModel.trackCookiePolicyClicked() + case .tos: + self.viewModel.trackTOSClicked() + case .privacyPolicy: + self.viewModel.trackPrivacyPolicyClicked() + case .sellData: + self.viewModel.trackDataSellClicked() + + default: + break + } + }) .foregroundColor(.primary) .accessibilityElement(children: .ignore) .accessibilityLabel(viewModel.title) @@ -129,7 +155,12 @@ struct ProfileSupportInfoView: View { } @ViewBuilder - private func button(linkViewModel: LinkViewModel, isEmailSupport: Bool = false, identifier: String) -> some View { + private func button( + linkViewModel: LinkViewModel, + isEmailSupport: Bool = false, + supportType: SupportType, + identifier: String + ) -> some View { Button { guard UIApplication.shared.canOpenURL(linkViewModel.url) else { viewModel.errorMessage = isEmailSupport ? @@ -137,9 +168,16 @@ struct ProfileSupportInfoView: View { CoreLocalization.Error.unknownError return } - if isEmailSupport { + + switch supportType { + case .contactSupport: viewModel.trackEmailSupportClicked() + case .faq: + viewModel.trackFAQClicked() + default: + break } + UIApplication.shared.open(linkViewModel.url) } label: { HStack { diff --git a/Profile/Profile/Presentation/ProfileAnalytics.swift b/Profile/Profile/Presentation/ProfileAnalytics.swift index 58cc4b9d9..3713c15ba 100644 --- a/Profile/Profile/Presentation/ProfileAnalytics.swift +++ b/Profile/Profile/Presentation/ProfileAnalytics.swift @@ -6,28 +6,45 @@ // import Foundation +import Core //sourcery: AutoMockable public protocol ProfileAnalytics { func profileEditClicked() + func profileSwitch(action: String) func profileEditDoneClicked() func profileDeleteAccountClicked() func profileVideoSettingsClicked() func privacyPolicyClicked() func cookiePolicyClicked() func emailSupportClicked() + func faqClicked() + func tosClicked() + func dataSellClicked() func userLogout(force: Bool) + func profileWifiToggle(action: String) + func profileUserDeleteAccountClicked() + func profileDeleteAccountSuccess(success: Bool) + func profileEvent(_ event: AnalyticsEvent, biValue: EventBIValue) } #if DEBUG class ProfileAnalyticsMock: ProfileAnalytics { public func profileEditClicked() {} + public func profileSwitch(action: String) {} public func profileEditDoneClicked() {} public func profileDeleteAccountClicked() {} public func profileVideoSettingsClicked() {} public func privacyPolicyClicked() {} public func cookiePolicyClicked() {} public func emailSupportClicked() {} + public func faqClicked() {} + public func tosClicked() {} + public func dataSellClicked() {} public func userLogout(force: Bool) {} + public func profileWifiToggle(action: String) {} + public func profileUserDeleteAccountClicked() {} + public func profileDeleteAccountSuccess(success: Bool) {} + public func profileEvent(_ event: AnalyticsEvent, biValue: EventBIValue) {} } #endif diff --git a/Profile/Profile/Presentation/ProfileRouter.swift b/Profile/Profile/Presentation/ProfileRouter.swift index d449e2a3b..8d9539e92 100644 --- a/Profile/Profile/Presentation/ProfileRouter.swift +++ b/Profile/Profile/Presentation/ProfileRouter.swift @@ -24,7 +24,8 @@ public protocol ProfileRouter: BaseRouter { func showVideoDownloadQualityView( downloadQuality: DownloadQuality, - didSelect: ((DownloadQuality) -> Void)? + didSelect: ((DownloadQuality) -> Void)?, + analytics: CoreAnalytics ) func showDeleteProfileView() @@ -49,7 +50,8 @@ public class ProfileRouterMock: BaseRouterMock, ProfileRouter { public func showVideoDownloadQualityView( downloadQuality: DownloadQuality, - didSelect: ((DownloadQuality) -> Void)? + didSelect: ((DownloadQuality) -> Void)?, + analytics: CoreAnalytics ) {} public func showDeleteProfileView() {} diff --git a/Profile/Profile/Presentation/Settings/SettingsView.swift b/Profile/Profile/Presentation/Settings/SettingsView.swift index e695b0c88..d3ad59f26 100644 --- a/Profile/Profile/Presentation/Settings/SettingsView.swift +++ b/Profile/Profile/Presentation/Settings/SettingsView.swift @@ -66,7 +66,8 @@ public struct SettingsView: View { Button { viewModel.router.showVideoDownloadQualityView( downloadQuality: viewModel.userSettings.downloadQuality, - didSelect: viewModel.update(downloadQuality:) + didSelect: viewModel.update(downloadQuality:), + analytics: viewModel.analytics ) } label: { SettingsCell( @@ -123,7 +124,8 @@ struct SettingsView_Previews: PreviewProvider { let router = ProfileRouterMock() let vm = SettingsViewModel( interactor: ProfileInteractor.mock, - router: router + router: router, + analytics: CoreAnalyticsMock() ) SettingsView(viewModel: vm) diff --git a/Profile/Profile/Presentation/Settings/SettingsViewModel.swift b/Profile/Profile/Presentation/Settings/SettingsViewModel.swift index b24eee494..499623a89 100644 --- a/Profile/Profile/Presentation/Settings/SettingsViewModel.swift +++ b/Profile/Profile/Presentation/Settings/SettingsViewModel.swift @@ -53,10 +53,12 @@ public class SettingsViewModel: ObservableObject { private let interactor: ProfileInteractorProtocol let router: ProfileRouter + let analytics: CoreAnalytics - public init(interactor: ProfileInteractorProtocol, router: ProfileRouter) { + public init(interactor: ProfileInteractorProtocol, router: ProfileRouter, analytics: CoreAnalytics) { self.interactor = interactor self.router = router + self.analytics = analytics let userSettings = interactor.getSettings() self.userSettings = userSettings diff --git a/Profile/Profile/Presentation/Settings/VideoQualityView.swift b/Profile/Profile/Presentation/Settings/VideoQualityView.swift index dcb9b66eb..8d1b03fda 100644 --- a/Profile/Profile/Presentation/Settings/VideoQualityView.swift +++ b/Profile/Profile/Presentation/Settings/VideoQualityView.swift @@ -33,6 +33,12 @@ public struct VideoQualityView: View { ForEach(viewModel.quality, id: \.offset) { _, quality in Button(action: { + viewModel.analytics.videoQualityChanged( + .videoStreamQualityChanged, + bivalue: .videoStreamQualityChanged, + value: quality.value ?? "", + oldValue: viewModel.selectedQuality.value ?? "" + ) viewModel.selectedQuality = quality }, label: { HStack { @@ -86,8 +92,11 @@ public struct VideoQualityView: View { struct VideoQualityView_Previews: PreviewProvider { static var previews: some View { let router = ProfileRouterMock() - let vm = SettingsViewModel(interactor: ProfileInteractor.mock, - router: router) + let vm = SettingsViewModel( + interactor: ProfileInteractor.mock, + router: router, + analytics: CoreAnalyticsMock() + ) VideoQualityView(viewModel: vm) .preferredColorScheme(.light) diff --git a/Profile/ProfileTests/Presentation/DeleteAccount/DeleteAccountViewModelTests.swift b/Profile/ProfileTests/Presentation/DeleteAccount/DeleteAccountViewModelTests.swift index ca284bdf8..c6ba81755 100644 --- a/Profile/ProfileTests/Presentation/DeleteAccount/DeleteAccountViewModelTests.swift +++ b/Profile/ProfileTests/Presentation/DeleteAccount/DeleteAccountViewModelTests.swift @@ -18,7 +18,12 @@ final class DeleteAccountViewModelTests: XCTestCase { let interactor = ProfileInteractorProtocolMock() let router = ProfileRouterMock() let connectivity = ConnectivityProtocolMock() - let viewModel = DeleteAccountViewModel(interactor: interactor, router: router, connectivity: connectivity) + let viewModel = DeleteAccountViewModel( + interactor: interactor, + router: router, + connectivity: connectivity, + analytics: ProfileAnalyticsMock() + ) Given(interactor, .deleteAccount(password: .any, willReturn: true)) @@ -32,7 +37,12 @@ final class DeleteAccountViewModelTests: XCTestCase { let interactor = ProfileInteractorProtocolMock() let router = ProfileRouterMock() let connectivity = ConnectivityProtocolMock() - let viewModel = DeleteAccountViewModel(interactor: interactor, router: router, connectivity: connectivity) + let viewModel = DeleteAccountViewModel( + interactor: interactor, + router: router, + connectivity: connectivity, + analytics: ProfileAnalyticsMock() + ) Given(interactor, .deleteAccount(password: .any, willReturn: false)) @@ -48,7 +58,12 @@ final class DeleteAccountViewModelTests: XCTestCase { let interactor = ProfileInteractorProtocolMock() let router = ProfileRouterMock() let connectivity = ConnectivityProtocolMock() - let viewModel = DeleteAccountViewModel(interactor: interactor, router: router, connectivity: connectivity) + let viewModel = DeleteAccountViewModel( + interactor: interactor, + router: router, + connectivity: connectivity, + analytics: ProfileAnalyticsMock() + ) let validationError = CustomValidationError(statusCode: 401, data: ["error_code": "user_not_active"]) let error = AFError.responseValidationFailed( @@ -71,7 +86,12 @@ final class DeleteAccountViewModelTests: XCTestCase { let interactor = ProfileInteractorProtocolMock() let router = ProfileRouterMock() let connectivity = ConnectivityProtocolMock() - let viewModel = DeleteAccountViewModel(interactor: interactor, router: router, connectivity: connectivity) + let viewModel = DeleteAccountViewModel( + interactor: interactor, + router: router, + connectivity: connectivity, + analytics: ProfileAnalyticsMock() + ) Given(interactor, .deleteAccount(password: .any, willThrow: NSError())) @@ -89,7 +109,12 @@ final class DeleteAccountViewModelTests: XCTestCase { let interactor = ProfileInteractorProtocolMock() let router = ProfileRouterMock() let connectivity = ConnectivityProtocolMock() - let viewModel = DeleteAccountViewModel(interactor: interactor, router: router, connectivity: connectivity) + let viewModel = DeleteAccountViewModel( + interactor: interactor, + router: router, + connectivity: connectivity, + analytics: ProfileAnalyticsMock() + ) let noInternetError = AFError.sessionInvalidated(error: URLError(.notConnectedToInternet)) diff --git a/Profile/ProfileTests/ProfileMock.generated.swift b/Profile/ProfileTests/ProfileMock.generated.swift index 614d436b5..b0f2d231b 100644 --- a/Profile/ProfileTests/ProfileMock.generated.swift +++ b/Profile/ProfileTests/ProfileMock.generated.swift @@ -1114,6 +1114,281 @@ open class ConnectivityProtocolMock: ConnectivityProtocol, Mock { } } +// MARK: - CoreAnalytics + +open class CoreAnalyticsMock: CoreAnalytics, Mock { + public init(sequencing sequencingPolicy: SequencingPolicy = .lastWrittenResolvedFirst, stubbing stubbingPolicy: StubbingPolicy = .wrap, file: StaticString = #file, line: UInt = #line) { + SwiftyMockyTestObserver.setup() + self.sequencingPolicy = sequencingPolicy + self.stubbingPolicy = stubbingPolicy + self.file = file + self.line = line + } + + var matcher: Matcher = Matcher.default + var stubbingPolicy: StubbingPolicy = .wrap + var sequencingPolicy: SequencingPolicy = .lastWrittenResolvedFirst + + private var queue = DispatchQueue(label: "com.swiftymocky.invocations", qos: .userInteractive) + private var invocations: [MethodType] = [] + private var methodReturnValues: [Given] = [] + private var methodPerformValues: [Perform] = [] + private var file: StaticString? + private var line: UInt? + + public typealias PropertyStub = Given + public typealias MethodStub = Given + public typealias SubscriptStub = Given + + /// Convenience method - call setupMock() to extend debug information when failure occurs + public func setupMock(file: StaticString = #file, line: UInt = #line) { + self.file = file + self.line = line + } + + /// Clear mock internals. You can specify what to reset (invocations aka verify, givens or performs) or leave it empty to clear all mock internals + public func resetMock(_ scopes: MockScope...) { + let scopes: [MockScope] = scopes.isEmpty ? [.invocation, .given, .perform] : scopes + if scopes.contains(.invocation) { invocations = [] } + if scopes.contains(.given) { methodReturnValues = [] } + if scopes.contains(.perform) { methodPerformValues = [] } + } + + + + + + open func trackEvent(_ event: AnalyticsEvent, parameters: [String: Any]?) { + addInvocation(.m_trackEvent__eventparameters_parameters(Parameter.value(`event`), Parameter<[String: Any]?>.value(`parameters`))) + let perform = methodPerformValue(.m_trackEvent__eventparameters_parameters(Parameter.value(`event`), Parameter<[String: Any]?>.value(`parameters`))) as? (AnalyticsEvent, [String: Any]?) -> Void + perform?(`event`, `parameters`) + } + + open func trackEvent(_ event: AnalyticsEvent, biValue: EventBIValue, parameters: [String: Any]?) { + addInvocation(.m_trackEvent__eventbiValue_biValueparameters_parameters(Parameter.value(`event`), Parameter.value(`biValue`), Parameter<[String: Any]?>.value(`parameters`))) + let perform = methodPerformValue(.m_trackEvent__eventbiValue_biValueparameters_parameters(Parameter.value(`event`), Parameter.value(`biValue`), Parameter<[String: Any]?>.value(`parameters`))) as? (AnalyticsEvent, EventBIValue, [String: Any]?) -> Void + perform?(`event`, `biValue`, `parameters`) + } + + open func appreview(_ event: AnalyticsEvent, biValue: EventBIValue, action: String?, rating: Int?) { + addInvocation(.m_appreview__eventbiValue_biValueaction_actionrating_rating(Parameter.value(`event`), Parameter.value(`biValue`), Parameter.value(`action`), Parameter.value(`rating`))) + let perform = methodPerformValue(.m_appreview__eventbiValue_biValueaction_actionrating_rating(Parameter.value(`event`), Parameter.value(`biValue`), Parameter.value(`action`), Parameter.value(`rating`))) as? (AnalyticsEvent, EventBIValue, String?, Int?) -> Void + perform?(`event`, `biValue`, `action`, `rating`) + } + + open func videoQualityChanged(_ event: AnalyticsEvent, bivalue: EventBIValue, value: String, oldValue: String) { + addInvocation(.m_videoQualityChanged__eventbivalue_bivaluevalue_valueoldValue_oldValue(Parameter.value(`event`), Parameter.value(`bivalue`), Parameter.value(`value`), Parameter.value(`oldValue`))) + let perform = methodPerformValue(.m_videoQualityChanged__eventbivalue_bivaluevalue_valueoldValue_oldValue(Parameter.value(`event`), Parameter.value(`bivalue`), Parameter.value(`value`), Parameter.value(`oldValue`))) as? (AnalyticsEvent, EventBIValue, String, String) -> Void + perform?(`event`, `bivalue`, `value`, `oldValue`) + } + + open func trackEvent(_ event: AnalyticsEvent) { + addInvocation(.m_trackEvent__event(Parameter.value(`event`))) + let perform = methodPerformValue(.m_trackEvent__event(Parameter.value(`event`))) as? (AnalyticsEvent) -> Void + perform?(`event`) + } + + open func trackEvent(_ event: AnalyticsEvent, biValue: EventBIValue) { + addInvocation(.m_trackEvent__eventbiValue_biValue(Parameter.value(`event`), Parameter.value(`biValue`))) + let perform = methodPerformValue(.m_trackEvent__eventbiValue_biValue(Parameter.value(`event`), Parameter.value(`biValue`))) as? (AnalyticsEvent, EventBIValue) -> Void + perform?(`event`, `biValue`) + } + + + fileprivate enum MethodType { + case m_trackEvent__eventparameters_parameters(Parameter, Parameter<[String: Any]?>) + case m_trackEvent__eventbiValue_biValueparameters_parameters(Parameter, Parameter, Parameter<[String: Any]?>) + case m_appreview__eventbiValue_biValueaction_actionrating_rating(Parameter, Parameter, Parameter, Parameter) + case m_videoQualityChanged__eventbivalue_bivaluevalue_valueoldValue_oldValue(Parameter, Parameter, Parameter, Parameter) + case m_trackEvent__event(Parameter) + case m_trackEvent__eventbiValue_biValue(Parameter, Parameter) + + static func compareParameters(lhs: MethodType, rhs: MethodType, matcher: Matcher) -> Matcher.ComparisonResult { + switch (lhs, rhs) { + case (.m_trackEvent__eventparameters_parameters(let lhsEvent, let lhsParameters), .m_trackEvent__eventparameters_parameters(let rhsEvent, let rhsParameters)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsEvent, rhs: rhsEvent, with: matcher), lhsEvent, rhsEvent, "_ event")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsParameters, rhs: rhsParameters, with: matcher), lhsParameters, rhsParameters, "parameters")) + return Matcher.ComparisonResult(results) + + case (.m_trackEvent__eventbiValue_biValueparameters_parameters(let lhsEvent, let lhsBivalue, let lhsParameters), .m_trackEvent__eventbiValue_biValueparameters_parameters(let rhsEvent, let rhsBivalue, let rhsParameters)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsEvent, rhs: rhsEvent, with: matcher), lhsEvent, rhsEvent, "_ event")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsBivalue, rhs: rhsBivalue, with: matcher), lhsBivalue, rhsBivalue, "biValue")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsParameters, rhs: rhsParameters, with: matcher), lhsParameters, rhsParameters, "parameters")) + return Matcher.ComparisonResult(results) + + case (.m_appreview__eventbiValue_biValueaction_actionrating_rating(let lhsEvent, let lhsBivalue, let lhsAction, let lhsRating), .m_appreview__eventbiValue_biValueaction_actionrating_rating(let rhsEvent, let rhsBivalue, let rhsAction, let rhsRating)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsEvent, rhs: rhsEvent, with: matcher), lhsEvent, rhsEvent, "_ event")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsBivalue, rhs: rhsBivalue, with: matcher), lhsBivalue, rhsBivalue, "biValue")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsAction, rhs: rhsAction, with: matcher), lhsAction, rhsAction, "action")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsRating, rhs: rhsRating, with: matcher), lhsRating, rhsRating, "rating")) + return Matcher.ComparisonResult(results) + + case (.m_videoQualityChanged__eventbivalue_bivaluevalue_valueoldValue_oldValue(let lhsEvent, let lhsBivalue, let lhsValue, let lhsOldvalue), .m_videoQualityChanged__eventbivalue_bivaluevalue_valueoldValue_oldValue(let rhsEvent, let rhsBivalue, let rhsValue, let rhsOldvalue)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsEvent, rhs: rhsEvent, with: matcher), lhsEvent, rhsEvent, "_ event")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsBivalue, rhs: rhsBivalue, with: matcher), lhsBivalue, rhsBivalue, "bivalue")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsValue, rhs: rhsValue, with: matcher), lhsValue, rhsValue, "value")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsOldvalue, rhs: rhsOldvalue, with: matcher), lhsOldvalue, rhsOldvalue, "oldValue")) + return Matcher.ComparisonResult(results) + + case (.m_trackEvent__event(let lhsEvent), .m_trackEvent__event(let rhsEvent)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsEvent, rhs: rhsEvent, with: matcher), lhsEvent, rhsEvent, "_ event")) + return Matcher.ComparisonResult(results) + + case (.m_trackEvent__eventbiValue_biValue(let lhsEvent, let lhsBivalue), .m_trackEvent__eventbiValue_biValue(let rhsEvent, let rhsBivalue)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsEvent, rhs: rhsEvent, with: matcher), lhsEvent, rhsEvent, "_ event")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsBivalue, rhs: rhsBivalue, with: matcher), lhsBivalue, rhsBivalue, "biValue")) + return Matcher.ComparisonResult(results) + default: return .none + } + } + + func intValue() -> Int { + switch self { + case let .m_trackEvent__eventparameters_parameters(p0, p1): return p0.intValue + p1.intValue + case let .m_trackEvent__eventbiValue_biValueparameters_parameters(p0, p1, p2): return p0.intValue + p1.intValue + p2.intValue + case let .m_appreview__eventbiValue_biValueaction_actionrating_rating(p0, p1, p2, p3): return p0.intValue + p1.intValue + p2.intValue + p3.intValue + case let .m_videoQualityChanged__eventbivalue_bivaluevalue_valueoldValue_oldValue(p0, p1, p2, p3): return p0.intValue + p1.intValue + p2.intValue + p3.intValue + case let .m_trackEvent__event(p0): return p0.intValue + case let .m_trackEvent__eventbiValue_biValue(p0, p1): return p0.intValue + p1.intValue + } + } + func assertionName() -> String { + switch self { + case .m_trackEvent__eventparameters_parameters: return ".trackEvent(_:parameters:)" + case .m_trackEvent__eventbiValue_biValueparameters_parameters: return ".trackEvent(_:biValue:parameters:)" + case .m_appreview__eventbiValue_biValueaction_actionrating_rating: return ".appreview(_:biValue:action:rating:)" + case .m_videoQualityChanged__eventbivalue_bivaluevalue_valueoldValue_oldValue: return ".videoQualityChanged(_:bivalue:value:oldValue:)" + case .m_trackEvent__event: return ".trackEvent(_:)" + case .m_trackEvent__eventbiValue_biValue: return ".trackEvent(_:biValue:)" + } + } + } + + open class Given: StubbedMethod { + fileprivate var method: MethodType + + private init(method: MethodType, products: [StubProduct]) { + self.method = method + super.init(products) + } + + + } + + public struct Verify { + fileprivate var method: MethodType + + public static func trackEvent(_ event: Parameter, parameters: Parameter<[String: Any]?>) -> Verify { return Verify(method: .m_trackEvent__eventparameters_parameters(`event`, `parameters`))} + public static func trackEvent(_ event: Parameter, biValue: Parameter, parameters: Parameter<[String: Any]?>) -> Verify { return Verify(method: .m_trackEvent__eventbiValue_biValueparameters_parameters(`event`, `biValue`, `parameters`))} + public static func appreview(_ event: Parameter, biValue: Parameter, action: Parameter, rating: Parameter) -> Verify { return Verify(method: .m_appreview__eventbiValue_biValueaction_actionrating_rating(`event`, `biValue`, `action`, `rating`))} + public static func videoQualityChanged(_ event: Parameter, bivalue: Parameter, value: Parameter, oldValue: Parameter) -> Verify { return Verify(method: .m_videoQualityChanged__eventbivalue_bivaluevalue_valueoldValue_oldValue(`event`, `bivalue`, `value`, `oldValue`))} + public static func trackEvent(_ event: Parameter) -> Verify { return Verify(method: .m_trackEvent__event(`event`))} + public static func trackEvent(_ event: Parameter, biValue: Parameter) -> Verify { return Verify(method: .m_trackEvent__eventbiValue_biValue(`event`, `biValue`))} + } + + public struct Perform { + fileprivate var method: MethodType + var performs: Any + + public static func trackEvent(_ event: Parameter, parameters: Parameter<[String: Any]?>, perform: @escaping (AnalyticsEvent, [String: Any]?) -> Void) -> Perform { + return Perform(method: .m_trackEvent__eventparameters_parameters(`event`, `parameters`), performs: perform) + } + public static func trackEvent(_ event: Parameter, biValue: Parameter, parameters: Parameter<[String: Any]?>, perform: @escaping (AnalyticsEvent, EventBIValue, [String: Any]?) -> Void) -> Perform { + return Perform(method: .m_trackEvent__eventbiValue_biValueparameters_parameters(`event`, `biValue`, `parameters`), performs: perform) + } + public static func appreview(_ event: Parameter, biValue: Parameter, action: Parameter, rating: Parameter, perform: @escaping (AnalyticsEvent, EventBIValue, String?, Int?) -> Void) -> Perform { + return Perform(method: .m_appreview__eventbiValue_biValueaction_actionrating_rating(`event`, `biValue`, `action`, `rating`), performs: perform) + } + public static func videoQualityChanged(_ event: Parameter, bivalue: Parameter, value: Parameter, oldValue: Parameter, perform: @escaping (AnalyticsEvent, EventBIValue, String, String) -> Void) -> Perform { + return Perform(method: .m_videoQualityChanged__eventbivalue_bivaluevalue_valueoldValue_oldValue(`event`, `bivalue`, `value`, `oldValue`), performs: perform) + } + public static func trackEvent(_ event: Parameter, perform: @escaping (AnalyticsEvent) -> Void) -> Perform { + return Perform(method: .m_trackEvent__event(`event`), performs: perform) + } + public static func trackEvent(_ event: Parameter, biValue: Parameter, perform: @escaping (AnalyticsEvent, EventBIValue) -> Void) -> Perform { + return Perform(method: .m_trackEvent__eventbiValue_biValue(`event`, `biValue`), performs: perform) + } + } + + public func given(_ method: Given) { + methodReturnValues.append(method) + } + + public func perform(_ method: Perform) { + methodPerformValues.append(method) + methodPerformValues.sort { $0.method.intValue() < $1.method.intValue() } + } + + public func verify(_ method: Verify, count: Count = Count.moreOrEqual(to: 1), file: StaticString = #file, line: UInt = #line) { + let fullMatches = matchingCalls(method, file: file, line: line) + let success = count.matches(fullMatches) + let assertionName = method.method.assertionName() + let feedback: String = { + guard !success else { return "" } + return Utils.closestCallsMessage( + for: self.invocations.map { invocation in + matcher.set(file: file, line: line) + defer { matcher.clearFileAndLine() } + return MethodType.compareParameters(lhs: invocation, rhs: method.method, matcher: matcher) + }, + name: assertionName + ) + }() + MockyAssert(success, "Expected: \(count) invocations of `\(assertionName)`, but was: \(fullMatches).\(feedback)", file: file, line: line) + } + + private func addInvocation(_ call: MethodType) { + self.queue.sync { invocations.append(call) } + } + private func methodReturnValue(_ method: MethodType) throws -> StubProduct { + matcher.set(file: self.file, line: self.line) + defer { matcher.clearFileAndLine() } + let candidates = sequencingPolicy.sorted(methodReturnValues, by: { $0.method.intValue() > $1.method.intValue() }) + let matched = candidates.first(where: { $0.isValid && MethodType.compareParameters(lhs: $0.method, rhs: method, matcher: matcher).isFullMatch }) + guard let product = matched?.getProduct(policy: self.stubbingPolicy) else { throw MockError.notStubed } + return product + } + private func methodPerformValue(_ method: MethodType) -> Any? { + matcher.set(file: self.file, line: self.line) + defer { matcher.clearFileAndLine() } + let matched = methodPerformValues.reversed().first { MethodType.compareParameters(lhs: $0.method, rhs: method, matcher: matcher).isFullMatch } + return matched?.performs + } + private func matchingCalls(_ method: MethodType, file: StaticString?, line: UInt?) -> [MethodType] { + matcher.set(file: file ?? self.file, line: line ?? self.line) + defer { matcher.clearFileAndLine() } + return invocations.filter { MethodType.compareParameters(lhs: $0, rhs: method, matcher: matcher).isFullMatch } + } + private func matchingCalls(_ method: Verify, file: StaticString?, line: UInt?) -> Int { + return matchingCalls(method.method, file: file, line: line).count + } + private func givenGetterValue(_ method: MethodType, _ message: String) -> T { + do { + return try methodReturnValue(method).casted() + } catch { + onFatalFailure(message) + Failure(message) + } + } + private func optionalGivenGetterValue(_ method: MethodType, _ message: String) -> T? { + do { + return try methodReturnValue(method).casted() + } catch { + return nil + } + } + private func onFatalFailure(_ message: String) { + guard let file = self.file, let line = self.line else { return } // Let if fail if cannot handle gratefully + SwiftyMockyTestObserver.handleFatalError(message: message, file: file, line: line) + } +} + // MARK: - DownloadManagerProtocol open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { @@ -1778,6 +2053,12 @@ open class ProfileAnalyticsMock: ProfileAnalytics, Mock { perform?() } + open func profileSwitch(action: String) { + addInvocation(.m_profileSwitch__action_action(Parameter.value(`action`))) + let perform = methodPerformValue(.m_profileSwitch__action_action(Parameter.value(`action`))) as? (String) -> Void + perform?(`action`) + } + open func profileEditDoneClicked() { addInvocation(.m_profileEditDoneClicked) let perform = methodPerformValue(.m_profileEditDoneClicked) as? () -> Void @@ -1814,27 +2095,82 @@ open class ProfileAnalyticsMock: ProfileAnalytics, Mock { perform?() } + open func faqClicked() { + addInvocation(.m_faqClicked) + let perform = methodPerformValue(.m_faqClicked) as? () -> Void + perform?() + } + + open func tosClicked() { + addInvocation(.m_tosClicked) + let perform = methodPerformValue(.m_tosClicked) as? () -> Void + perform?() + } + + open func dataSellClicked() { + addInvocation(.m_dataSellClicked) + let perform = methodPerformValue(.m_dataSellClicked) as? () -> Void + perform?() + } + open func userLogout(force: Bool) { addInvocation(.m_userLogout__force_force(Parameter.value(`force`))) let perform = methodPerformValue(.m_userLogout__force_force(Parameter.value(`force`))) as? (Bool) -> Void perform?(`force`) } + open func profileWifiToggle(action: String) { + addInvocation(.m_profileWifiToggle__action_action(Parameter.value(`action`))) + let perform = methodPerformValue(.m_profileWifiToggle__action_action(Parameter.value(`action`))) as? (String) -> Void + perform?(`action`) + } + + open func profileUserDeleteAccountClicked() { + addInvocation(.m_profileUserDeleteAccountClicked) + let perform = methodPerformValue(.m_profileUserDeleteAccountClicked) as? () -> Void + perform?() + } + + open func profileDeleteAccountSuccess(success: Bool) { + addInvocation(.m_profileDeleteAccountSuccess__success_success(Parameter.value(`success`))) + let perform = methodPerformValue(.m_profileDeleteAccountSuccess__success_success(Parameter.value(`success`))) as? (Bool) -> Void + perform?(`success`) + } + + open func profileEvent(_ event: AnalyticsEvent, biValue: EventBIValue) { + addInvocation(.m_profileEvent__eventbiValue_biValue(Parameter.value(`event`), Parameter.value(`biValue`))) + let perform = methodPerformValue(.m_profileEvent__eventbiValue_biValue(Parameter.value(`event`), Parameter.value(`biValue`))) as? (AnalyticsEvent, EventBIValue) -> Void + perform?(`event`, `biValue`) + } + fileprivate enum MethodType { case m_profileEditClicked + case m_profileSwitch__action_action(Parameter) case m_profileEditDoneClicked case m_profileDeleteAccountClicked case m_profileVideoSettingsClicked case m_privacyPolicyClicked case m_cookiePolicyClicked case m_emailSupportClicked + case m_faqClicked + case m_tosClicked + case m_dataSellClicked case m_userLogout__force_force(Parameter) + case m_profileWifiToggle__action_action(Parameter) + case m_profileUserDeleteAccountClicked + case m_profileDeleteAccountSuccess__success_success(Parameter) + case m_profileEvent__eventbiValue_biValue(Parameter, Parameter) static func compareParameters(lhs: MethodType, rhs: MethodType, matcher: Matcher) -> Matcher.ComparisonResult { switch (lhs, rhs) { case (.m_profileEditClicked, .m_profileEditClicked): return .match + case (.m_profileSwitch__action_action(let lhsAction), .m_profileSwitch__action_action(let rhsAction)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsAction, rhs: rhsAction, with: matcher), lhsAction, rhsAction, "action")) + return Matcher.ComparisonResult(results) + case (.m_profileEditDoneClicked, .m_profileEditDoneClicked): return .match case (.m_profileDeleteAccountClicked, .m_profileDeleteAccountClicked): return .match @@ -1847,10 +2183,34 @@ open class ProfileAnalyticsMock: ProfileAnalytics, Mock { case (.m_emailSupportClicked, .m_emailSupportClicked): return .match + case (.m_faqClicked, .m_faqClicked): return .match + + case (.m_tosClicked, .m_tosClicked): return .match + + case (.m_dataSellClicked, .m_dataSellClicked): return .match + case (.m_userLogout__force_force(let lhsForce), .m_userLogout__force_force(let rhsForce)): var results: [Matcher.ParameterComparisonResult] = [] results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsForce, rhs: rhsForce, with: matcher), lhsForce, rhsForce, "force")) return Matcher.ComparisonResult(results) + + case (.m_profileWifiToggle__action_action(let lhsAction), .m_profileWifiToggle__action_action(let rhsAction)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsAction, rhs: rhsAction, with: matcher), lhsAction, rhsAction, "action")) + return Matcher.ComparisonResult(results) + + case (.m_profileUserDeleteAccountClicked, .m_profileUserDeleteAccountClicked): return .match + + case (.m_profileDeleteAccountSuccess__success_success(let lhsSuccess), .m_profileDeleteAccountSuccess__success_success(let rhsSuccess)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsSuccess, rhs: rhsSuccess, with: matcher), lhsSuccess, rhsSuccess, "success")) + return Matcher.ComparisonResult(results) + + case (.m_profileEvent__eventbiValue_biValue(let lhsEvent, let lhsBivalue), .m_profileEvent__eventbiValue_biValue(let rhsEvent, let rhsBivalue)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsEvent, rhs: rhsEvent, with: matcher), lhsEvent, rhsEvent, "_ event")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsBivalue, rhs: rhsBivalue, with: matcher), lhsBivalue, rhsBivalue, "biValue")) + return Matcher.ComparisonResult(results) default: return .none } } @@ -1858,25 +2218,41 @@ open class ProfileAnalyticsMock: ProfileAnalytics, Mock { func intValue() -> Int { switch self { case .m_profileEditClicked: return 0 + case let .m_profileSwitch__action_action(p0): return p0.intValue case .m_profileEditDoneClicked: return 0 case .m_profileDeleteAccountClicked: return 0 case .m_profileVideoSettingsClicked: return 0 case .m_privacyPolicyClicked: return 0 case .m_cookiePolicyClicked: return 0 case .m_emailSupportClicked: return 0 + case .m_faqClicked: return 0 + case .m_tosClicked: return 0 + case .m_dataSellClicked: return 0 case let .m_userLogout__force_force(p0): return p0.intValue + case let .m_profileWifiToggle__action_action(p0): return p0.intValue + case .m_profileUserDeleteAccountClicked: return 0 + case let .m_profileDeleteAccountSuccess__success_success(p0): return p0.intValue + case let .m_profileEvent__eventbiValue_biValue(p0, p1): return p0.intValue + p1.intValue } } func assertionName() -> String { switch self { case .m_profileEditClicked: return ".profileEditClicked()" + case .m_profileSwitch__action_action: return ".profileSwitch(action:)" case .m_profileEditDoneClicked: return ".profileEditDoneClicked()" case .m_profileDeleteAccountClicked: return ".profileDeleteAccountClicked()" case .m_profileVideoSettingsClicked: return ".profileVideoSettingsClicked()" case .m_privacyPolicyClicked: return ".privacyPolicyClicked()" case .m_cookiePolicyClicked: return ".cookiePolicyClicked()" case .m_emailSupportClicked: return ".emailSupportClicked()" + case .m_faqClicked: return ".faqClicked()" + case .m_tosClicked: return ".tosClicked()" + case .m_dataSellClicked: return ".dataSellClicked()" case .m_userLogout__force_force: return ".userLogout(force:)" + case .m_profileWifiToggle__action_action: return ".profileWifiToggle(action:)" + case .m_profileUserDeleteAccountClicked: return ".profileUserDeleteAccountClicked()" + case .m_profileDeleteAccountSuccess__success_success: return ".profileDeleteAccountSuccess(success:)" + case .m_profileEvent__eventbiValue_biValue: return ".profileEvent(_:biValue:)" } } } @@ -1896,13 +2272,21 @@ open class ProfileAnalyticsMock: ProfileAnalytics, Mock { fileprivate var method: MethodType public static func profileEditClicked() -> Verify { return Verify(method: .m_profileEditClicked)} + public static func profileSwitch(action: Parameter) -> Verify { return Verify(method: .m_profileSwitch__action_action(`action`))} public static func profileEditDoneClicked() -> Verify { return Verify(method: .m_profileEditDoneClicked)} public static func profileDeleteAccountClicked() -> Verify { return Verify(method: .m_profileDeleteAccountClicked)} public static func profileVideoSettingsClicked() -> Verify { return Verify(method: .m_profileVideoSettingsClicked)} public static func privacyPolicyClicked() -> Verify { return Verify(method: .m_privacyPolicyClicked)} public static func cookiePolicyClicked() -> Verify { return Verify(method: .m_cookiePolicyClicked)} public static func emailSupportClicked() -> Verify { return Verify(method: .m_emailSupportClicked)} + public static func faqClicked() -> Verify { return Verify(method: .m_faqClicked)} + public static func tosClicked() -> Verify { return Verify(method: .m_tosClicked)} + public static func dataSellClicked() -> Verify { return Verify(method: .m_dataSellClicked)} public static func userLogout(force: Parameter) -> Verify { return Verify(method: .m_userLogout__force_force(`force`))} + public static func profileWifiToggle(action: Parameter) -> Verify { return Verify(method: .m_profileWifiToggle__action_action(`action`))} + public static func profileUserDeleteAccountClicked() -> Verify { return Verify(method: .m_profileUserDeleteAccountClicked)} + public static func profileDeleteAccountSuccess(success: Parameter) -> Verify { return Verify(method: .m_profileDeleteAccountSuccess__success_success(`success`))} + public static func profileEvent(_ event: Parameter, biValue: Parameter) -> Verify { return Verify(method: .m_profileEvent__eventbiValue_biValue(`event`, `biValue`))} } public struct Perform { @@ -1912,6 +2296,9 @@ open class ProfileAnalyticsMock: ProfileAnalytics, Mock { public static func profileEditClicked(perform: @escaping () -> Void) -> Perform { return Perform(method: .m_profileEditClicked, performs: perform) } + public static func profileSwitch(action: Parameter, perform: @escaping (String) -> Void) -> Perform { + return Perform(method: .m_profileSwitch__action_action(`action`), performs: perform) + } public static func profileEditDoneClicked(perform: @escaping () -> Void) -> Perform { return Perform(method: .m_profileEditDoneClicked, performs: perform) } @@ -1930,9 +2317,30 @@ open class ProfileAnalyticsMock: ProfileAnalytics, Mock { public static func emailSupportClicked(perform: @escaping () -> Void) -> Perform { return Perform(method: .m_emailSupportClicked, performs: perform) } + public static func faqClicked(perform: @escaping () -> Void) -> Perform { + return Perform(method: .m_faqClicked, performs: perform) + } + public static func tosClicked(perform: @escaping () -> Void) -> Perform { + return Perform(method: .m_tosClicked, performs: perform) + } + public static func dataSellClicked(perform: @escaping () -> Void) -> Perform { + return Perform(method: .m_dataSellClicked, performs: perform) + } public static func userLogout(force: Parameter, perform: @escaping (Bool) -> Void) -> Perform { return Perform(method: .m_userLogout__force_force(`force`), performs: perform) } + public static func profileWifiToggle(action: Parameter, perform: @escaping (String) -> Void) -> Perform { + return Perform(method: .m_profileWifiToggle__action_action(`action`), performs: perform) + } + public static func profileUserDeleteAccountClicked(perform: @escaping () -> Void) -> Perform { + return Perform(method: .m_profileUserDeleteAccountClicked, performs: perform) + } + public static func profileDeleteAccountSuccess(success: Parameter, perform: @escaping (Bool) -> Void) -> Perform { + return Perform(method: .m_profileDeleteAccountSuccess__success_success(`success`), performs: perform) + } + public static func profileEvent(_ event: Parameter, biValue: Parameter, perform: @escaping (AnalyticsEvent, EventBIValue) -> Void) -> Perform { + return Perform(method: .m_profileEvent__eventbiValue_biValue(`event`, `biValue`), performs: perform) + } } public func given(_ method: Given) { @@ -2642,10 +3050,10 @@ open class ProfileRouterMock: ProfileRouter, Mock { perform?(`viewModel`) } - open func showVideoDownloadQualityView(downloadQuality: DownloadQuality, didSelect: ((DownloadQuality) -> Void)?) { - addInvocation(.m_showVideoDownloadQualityView__downloadQuality_downloadQualitydidSelect_didSelect(Parameter.value(`downloadQuality`), Parameter<((DownloadQuality) -> Void)?>.value(`didSelect`))) - let perform = methodPerformValue(.m_showVideoDownloadQualityView__downloadQuality_downloadQualitydidSelect_didSelect(Parameter.value(`downloadQuality`), Parameter<((DownloadQuality) -> Void)?>.value(`didSelect`))) as? (DownloadQuality, ((DownloadQuality) -> Void)?) -> Void - perform?(`downloadQuality`, `didSelect`) + open func showVideoDownloadQualityView(downloadQuality: DownloadQuality, didSelect: ((DownloadQuality) -> Void)?, analytics: CoreAnalytics) { + addInvocation(.m_showVideoDownloadQualityView__downloadQuality_downloadQualitydidSelect_didSelectanalytics_analytics(Parameter.value(`downloadQuality`), Parameter<((DownloadQuality) -> Void)?>.value(`didSelect`), Parameter.value(`analytics`))) + let perform = methodPerformValue(.m_showVideoDownloadQualityView__downloadQuality_downloadQualitydidSelect_didSelectanalytics_analytics(Parameter.value(`downloadQuality`), Parameter<((DownloadQuality) -> Void)?>.value(`didSelect`), Parameter.value(`analytics`))) as? (DownloadQuality, ((DownloadQuality) -> Void)?, CoreAnalytics) -> Void + perform?(`downloadQuality`, `didSelect`, `analytics`) } open func showDeleteProfileView() { @@ -2755,7 +3163,7 @@ open class ProfileRouterMock: ProfileRouter, Mock { case m_showEditProfile__userModel_userModelavatar_avatarprofileDidEdit_profileDidEdit(Parameter, Parameter, Parameter<((UserProfile?, UIImage?)) -> Void>) case m_showSettings case m_showVideoQualityView__viewModel_viewModel(Parameter) - case m_showVideoDownloadQualityView__downloadQuality_downloadQualitydidSelect_didSelect(Parameter, Parameter<((DownloadQuality) -> Void)?>) + case m_showVideoDownloadQualityView__downloadQuality_downloadQualitydidSelect_didSelectanalytics_analytics(Parameter, Parameter<((DownloadQuality) -> Void)?>, Parameter) case m_showDeleteProfileView case m_backToRoot__animated_animated(Parameter) case m_back__animated_animated(Parameter) @@ -2790,10 +3198,11 @@ open class ProfileRouterMock: ProfileRouter, Mock { results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsViewmodel, rhs: rhsViewmodel, with: matcher), lhsViewmodel, rhsViewmodel, "viewModel")) return Matcher.ComparisonResult(results) - case (.m_showVideoDownloadQualityView__downloadQuality_downloadQualitydidSelect_didSelect(let lhsDownloadquality, let lhsDidselect), .m_showVideoDownloadQualityView__downloadQuality_downloadQualitydidSelect_didSelect(let rhsDownloadquality, let rhsDidselect)): + case (.m_showVideoDownloadQualityView__downloadQuality_downloadQualitydidSelect_didSelectanalytics_analytics(let lhsDownloadquality, let lhsDidselect, let lhsAnalytics), .m_showVideoDownloadQualityView__downloadQuality_downloadQualitydidSelect_didSelectanalytics_analytics(let rhsDownloadquality, let rhsDidselect, let rhsAnalytics)): var results: [Matcher.ParameterComparisonResult] = [] results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsDownloadquality, rhs: rhsDownloadquality, with: matcher), lhsDownloadquality, rhsDownloadquality, "downloadQuality")) results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsDidselect, rhs: rhsDidselect, with: matcher), lhsDidselect, rhsDidselect, "didSelect")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsAnalytics, rhs: rhsAnalytics, with: matcher), lhsAnalytics, rhsAnalytics, "analytics")) return Matcher.ComparisonResult(results) case (.m_showDeleteProfileView, .m_showDeleteProfileView): return .match @@ -2894,7 +3303,7 @@ open class ProfileRouterMock: ProfileRouter, Mock { case let .m_showEditProfile__userModel_userModelavatar_avatarprofileDidEdit_profileDidEdit(p0, p1, p2): return p0.intValue + p1.intValue + p2.intValue case .m_showSettings: return 0 case let .m_showVideoQualityView__viewModel_viewModel(p0): return p0.intValue - case let .m_showVideoDownloadQualityView__downloadQuality_downloadQualitydidSelect_didSelect(p0, p1): return p0.intValue + p1.intValue + case let .m_showVideoDownloadQualityView__downloadQuality_downloadQualitydidSelect_didSelectanalytics_analytics(p0, p1, p2): return p0.intValue + p1.intValue + p2.intValue case .m_showDeleteProfileView: return 0 case let .m_backToRoot__animated_animated(p0): return p0.intValue case let .m_back__animated_animated(p0): return p0.intValue @@ -2919,7 +3328,7 @@ open class ProfileRouterMock: ProfileRouter, Mock { case .m_showEditProfile__userModel_userModelavatar_avatarprofileDidEdit_profileDidEdit: return ".showEditProfile(userModel:avatar:profileDidEdit:)" case .m_showSettings: return ".showSettings()" case .m_showVideoQualityView__viewModel_viewModel: return ".showVideoQualityView(viewModel:)" - case .m_showVideoDownloadQualityView__downloadQuality_downloadQualitydidSelect_didSelect: return ".showVideoDownloadQualityView(downloadQuality:didSelect:)" + case .m_showVideoDownloadQualityView__downloadQuality_downloadQualitydidSelect_didSelectanalytics_analytics: return ".showVideoDownloadQualityView(downloadQuality:didSelect:analytics:)" case .m_showDeleteProfileView: return ".showDeleteProfileView()" case .m_backToRoot__animated_animated: return ".backToRoot(animated:)" case .m_back__animated_animated: return ".back(animated:)" @@ -2958,7 +3367,7 @@ open class ProfileRouterMock: ProfileRouter, Mock { public static func showEditProfile(userModel: Parameter, avatar: Parameter, profileDidEdit: Parameter<((UserProfile?, UIImage?)) -> Void>) -> Verify { return Verify(method: .m_showEditProfile__userModel_userModelavatar_avatarprofileDidEdit_profileDidEdit(`userModel`, `avatar`, `profileDidEdit`))} public static func showSettings() -> Verify { return Verify(method: .m_showSettings)} public static func showVideoQualityView(viewModel: Parameter) -> Verify { return Verify(method: .m_showVideoQualityView__viewModel_viewModel(`viewModel`))} - public static func showVideoDownloadQualityView(downloadQuality: Parameter, didSelect: Parameter<((DownloadQuality) -> Void)?>) -> Verify { return Verify(method: .m_showVideoDownloadQualityView__downloadQuality_downloadQualitydidSelect_didSelect(`downloadQuality`, `didSelect`))} + public static func showVideoDownloadQualityView(downloadQuality: Parameter, didSelect: Parameter<((DownloadQuality) -> Void)?>, analytics: Parameter) -> Verify { return Verify(method: .m_showVideoDownloadQualityView__downloadQuality_downloadQualitydidSelect_didSelectanalytics_analytics(`downloadQuality`, `didSelect`, `analytics`))} public static func showDeleteProfileView() -> Verify { return Verify(method: .m_showDeleteProfileView)} public static func backToRoot(animated: Parameter) -> Verify { return Verify(method: .m_backToRoot__animated_animated(`animated`))} public static func back(animated: Parameter) -> Verify { return Verify(method: .m_back__animated_animated(`animated`))} @@ -2991,8 +3400,8 @@ open class ProfileRouterMock: ProfileRouter, Mock { public static func showVideoQualityView(viewModel: Parameter, perform: @escaping (SettingsViewModel) -> Void) -> Perform { return Perform(method: .m_showVideoQualityView__viewModel_viewModel(`viewModel`), performs: perform) } - public static func showVideoDownloadQualityView(downloadQuality: Parameter, didSelect: Parameter<((DownloadQuality) -> Void)?>, perform: @escaping (DownloadQuality, ((DownloadQuality) -> Void)?) -> Void) -> Perform { - return Perform(method: .m_showVideoDownloadQualityView__downloadQuality_downloadQualitydidSelect_didSelect(`downloadQuality`, `didSelect`), performs: perform) + public static func showVideoDownloadQualityView(downloadQuality: Parameter, didSelect: Parameter<((DownloadQuality) -> Void)?>, analytics: Parameter, perform: @escaping (DownloadQuality, ((DownloadQuality) -> Void)?, CoreAnalytics) -> Void) -> Perform { + return Perform(method: .m_showVideoDownloadQualityView__downloadQuality_downloadQualitydidSelect_didSelectanalytics_analytics(`downloadQuality`, `didSelect`, `analytics`), performs: perform) } public static func showDeleteProfileView(perform: @escaping () -> Void) -> Perform { return Perform(method: .m_showDeleteProfileView, performs: perform) diff --git a/WhatsNew/WhatsNew.xcodeproj/project.pbxproj b/WhatsNew/WhatsNew.xcodeproj/project.pbxproj index dad830243..b78d64a7d 100644 --- a/WhatsNew/WhatsNew.xcodeproj/project.pbxproj +++ b/WhatsNew/WhatsNew.xcodeproj/project.pbxproj @@ -24,6 +24,7 @@ 02E6408C2AE006680079AEDA /* swiftgen.yml in Resources */ = {isa = PBXBuildFile; fileRef = 02E6408B2AE006680079AEDA /* swiftgen.yml */; }; 02EC90AA2AE904E1007DE1E0 /* WhatsNewStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02EC90A92AE904E1007DE1E0 /* WhatsNewStorage.swift */; }; 02EC90AC2AE90C64007DE1E0 /* WhatsNewPage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02EC90AB2AE90C64007DE1E0 /* WhatsNewPage.swift */; }; + 14769D3E2B99713800AB36D4 /* WhatsNewAnalytics.swift in Sources */ = {isa = PBXBuildFile; fileRef = 14769D3D2B99713800AB36D4 /* WhatsNewAnalytics.swift */; }; B3BB9B06B226989A619C6440 /* Pods_App_WhatsNew.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 05AC45C7050E30F8394E0C76 /* Pods_App_WhatsNew.framework */; }; EF5CA11A55CB49F2DA030D25 /* Pods_App_WhatsNew_WhatsNewTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = F8D1A5DF016EC4630637336C /* Pods_App_WhatsNew_WhatsNewTests.framework */; }; /* End PBXBuildFile section */ @@ -61,6 +62,7 @@ 02EC90B12AE91BF1007DE1E0 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 05AC45C7050E30F8394E0C76 /* Pods_App_WhatsNew.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_App_WhatsNew.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 0C01007F0E8CEDCD293E0A68 /* Pods-App-WhatsNew.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-WhatsNew.debug.xcconfig"; path = "Target Support Files/Pods-App-WhatsNew/Pods-App-WhatsNew.debug.xcconfig"; sourceTree = ""; }; + 14769D3D2B99713800AB36D4 /* WhatsNewAnalytics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WhatsNewAnalytics.swift; sourceTree = ""; }; 1E3F4487E7D3A48F5FD12DDA /* Pods-App-WhatsNew-WhatsNewTests.releasestage.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-WhatsNew-WhatsNewTests.releasestage.xcconfig"; path = "Target Support Files/Pods-App-WhatsNew-WhatsNewTests/Pods-App-WhatsNew-WhatsNewTests.releasestage.xcconfig"; sourceTree = ""; }; 34C1F2BEAF7F0DCB8E630F33 /* Pods-App-WhatsNew.releasestage.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-WhatsNew.releasestage.xcconfig"; path = "Target Support Files/Pods-App-WhatsNew/Pods-App-WhatsNew.releasestage.xcconfig"; sourceTree = ""; }; 365FD817D70DFBCBDE2EAE5F /* Pods-App-WhatsNew-WhatsNewTests.releaseprod.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-WhatsNew-WhatsNewTests.releaseprod.xcconfig"; path = "Target Support Files/Pods-App-WhatsNew-WhatsNewTests/Pods-App-WhatsNew-WhatsNewTests.releaseprod.xcconfig"; sourceTree = ""; }; @@ -187,6 +189,7 @@ 02E640782ADFF5920079AEDA /* WhatsNewView.swift */, 02E640802ADFFE440079AEDA /* WhatsNewViewModel.swift */, 02B54E102AE061C100C56962 /* WhatsNewRouter.swift */, + 14769D3D2B99713800AB36D4 /* WhatsNewAnalytics.swift */, ); path = Presentation; sourceTree = ""; @@ -438,6 +441,7 @@ 020A7B5F2AE131A9000BAF70 /* WhatsNewModel.swift in Sources */, 02B54E0F2AE0337800C56962 /* PageControl.swift in Sources */, 02EC90AC2AE90C64007DE1E0 /* WhatsNewPage.swift in Sources */, + 14769D3E2B99713800AB36D4 /* WhatsNewAnalytics.swift in Sources */, 02EC90AA2AE904E1007DE1E0 /* WhatsNewStorage.swift in Sources */, 02E6408A2AE004300079AEDA /* Strings.swift in Sources */, 02E640792ADFF5920079AEDA /* WhatsNewView.swift in Sources */, diff --git a/WhatsNew/WhatsNew/Presentation/WhatsNewAnalytics.swift b/WhatsNew/WhatsNew/Presentation/WhatsNewAnalytics.swift new file mode 100644 index 000000000..547afd85c --- /dev/null +++ b/WhatsNew/WhatsNew/Presentation/WhatsNewAnalytics.swift @@ -0,0 +1,23 @@ +// +// WhatsNewAnalytics.swift +// WhatsNew +// +// Created by Saeed Bashir on 3/7/24. +// + +import Foundation + +//sourcery: AutoMockable +public protocol WhatsNewAnalytics { + func whatsnewPopup() + func whatsnewDone(totalScreens: Int) + func whatsnewClose(totalScreens: Int, currentScreen: Int) +} + +#if DEBUG +class WhatsNewAnalyticsMock: WhatsNewAnalytics { + public func whatsnewPopup() {} + public func whatsnewDone(totalScreens: Int) {} + public func whatsnewClose(totalScreens: Int, currentScreen: Int) {} +} +#endif diff --git a/WhatsNew/WhatsNew/Presentation/WhatsNewView.swift b/WhatsNew/WhatsNew/Presentation/WhatsNewView.swift index 782315d43..4fbd03c4e 100644 --- a/WhatsNew/WhatsNew/Presentation/WhatsNewView.swift +++ b/WhatsNew/WhatsNew/Presentation/WhatsNewView.swift @@ -112,6 +112,10 @@ public struct WhatsNewView: View { } else { router.showMainOrWhatsNewScreen(sourceScreen: viewModel.sourceScreen) } + + if viewModel.index == viewModel.newItems.count - 1 { + viewModel.logWhatsNewDone() + } } ) .accessibilityIdentifier("next_button") @@ -143,6 +147,7 @@ public struct WhatsNewView: View { ToolbarItem(placement: .navigationBarTrailing, content: { Button(action: { router.showMainOrWhatsNewScreen(sourceScreen: viewModel.sourceScreen) + viewModel.logWhatsNewClose() }, label: { Image(systemName: "xmark") .foregroundColor(Theme.Colors.accentXColor) @@ -150,6 +155,9 @@ public struct WhatsNewView: View { .accessibilityIdentifier("close_button") }) } + .onFirstAppear { + viewModel.logWhatsNewPopup() + } } } @@ -161,7 +169,10 @@ struct WhatsNewView_Previews: PreviewProvider { static var previews: some View { WhatsNewView( router: WhatsNewRouterMock(), - viewModel: WhatsNewViewModel(storage: WhatsNewStorageMock()) + viewModel: WhatsNewViewModel( + storage: WhatsNewStorageMock(), + analytics: WhatsNewAnalyticsMock() + ) ) .loadFonts() } diff --git a/WhatsNew/WhatsNew/Presentation/WhatsNewViewModel.swift b/WhatsNew/WhatsNew/Presentation/WhatsNewViewModel.swift index 12eb34b78..212ea92fb 100644 --- a/WhatsNew/WhatsNew/Presentation/WhatsNewViewModel.swift +++ b/WhatsNew/WhatsNew/Presentation/WhatsNewViewModel.swift @@ -14,10 +14,16 @@ public class WhatsNewViewModel: ObservableObject { @Published var newItems: [WhatsNewPage] = [] private let storage: WhatsNewStorage var sourceScreen: LogistrationSourceScreen + let analytics: WhatsNewAnalytics - public init(storage: WhatsNewStorage, sourceScreen: LogistrationSourceScreen = .default) { + public init( + storage: WhatsNewStorage, + sourceScreen: LogistrationSourceScreen = .default, + analytics: WhatsNewAnalytics + ) { self.storage = storage self.sourceScreen = sourceScreen + self.analytics = analytics newItems = loadWhatsNew() } @@ -72,4 +78,21 @@ public class WhatsNewViewModel: ObservableObject { return nil } } + + private let appVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "" + + func logWhatsNewPopup() { + analytics.whatsnewPopup() + } + + func logWhatsNewDone() { + let total = newItems.count + analytics.whatsnewDone(totalScreens: total) + } + + func logWhatsNewClose() { + let total = newItems.count + + analytics.whatsnewClose(totalScreens: total, currentScreen: index + 1) + } } diff --git a/WhatsNew/WhatsNewTests/Presentation/WhatsNewTests.swift b/WhatsNew/WhatsNewTests/Presentation/WhatsNewTests.swift index 7c6542b16..f7453a662 100644 --- a/WhatsNew/WhatsNewTests/Presentation/WhatsNewTests.swift +++ b/WhatsNew/WhatsNewTests/Presentation/WhatsNewTests.swift @@ -12,14 +12,20 @@ import Core final class WhatsNewTests: XCTestCase { func testGetVersion() throws { - let viewModel = WhatsNewViewModel(storage: WhatsNewStorageMock()) + let viewModel = WhatsNewViewModel( + storage: WhatsNewStorageMock(), + analytics: WhatsNewAnalyticsMock() + ) let version = viewModel.getVersion() XCTAssertNotNil(version) XCTAssertTrue(version == "1.0") } func testshouldShowWhatsNew() throws { - let viewModel = WhatsNewViewModel(storage: WhatsNewStorageMock()) + let viewModel = WhatsNewViewModel( + storage: WhatsNewStorageMock(), + analytics: WhatsNewAnalyticsMock() + ) let version = viewModel.getVersion() XCTAssertNotNil(version) XCTAssertTrue(viewModel.shouldShowWhatsNew()) diff --git a/WhatsNew/WhatsNewTests/WhatsNewMock.generated.swift b/WhatsNew/WhatsNewTests/WhatsNewMock.generated.swift index 999f7cc25..dca08a91e 100644 --- a/WhatsNew/WhatsNewTests/WhatsNewMock.generated.swift +++ b/WhatsNew/WhatsNewTests/WhatsNewMock.generated.swift @@ -15,5 +15,212 @@ import SwiftUI import Combine -// SwiftyMocky: no AutoMockable found. -// Please define and inherit from AutoMockable, or annotate protocols to be mocked +// MARK: - WhatsNewAnalytics + +open class WhatsNewAnalyticsMock: WhatsNewAnalytics, Mock { + public init(sequencing sequencingPolicy: SequencingPolicy = .lastWrittenResolvedFirst, stubbing stubbingPolicy: StubbingPolicy = .wrap, file: StaticString = #file, line: UInt = #line) { + SwiftyMockyTestObserver.setup() + self.sequencingPolicy = sequencingPolicy + self.stubbingPolicy = stubbingPolicy + self.file = file + self.line = line + } + + var matcher: Matcher = Matcher.default + var stubbingPolicy: StubbingPolicy = .wrap + var sequencingPolicy: SequencingPolicy = .lastWrittenResolvedFirst + + private var queue = DispatchQueue(label: "com.swiftymocky.invocations", qos: .userInteractive) + private var invocations: [MethodType] = [] + private var methodReturnValues: [Given] = [] + private var methodPerformValues: [Perform] = [] + private var file: StaticString? + private var line: UInt? + + public typealias PropertyStub = Given + public typealias MethodStub = Given + public typealias SubscriptStub = Given + + /// Convenience method - call setupMock() to extend debug information when failure occurs + public func setupMock(file: StaticString = #file, line: UInt = #line) { + self.file = file + self.line = line + } + + /// Clear mock internals. You can specify what to reset (invocations aka verify, givens or performs) or leave it empty to clear all mock internals + public func resetMock(_ scopes: MockScope...) { + let scopes: [MockScope] = scopes.isEmpty ? [.invocation, .given, .perform] : scopes + if scopes.contains(.invocation) { invocations = [] } + if scopes.contains(.given) { methodReturnValues = [] } + if scopes.contains(.perform) { methodPerformValues = [] } + } + + + + + + open func whatsnewPopup() { + addInvocation(.m_whatsnewPopup) + let perform = methodPerformValue(.m_whatsnewPopup) as? () -> Void + perform?() + } + + open func whatsnewDone(totalScreens: Int) { + addInvocation(.m_whatsnewDone__totalScreens_totalScreens(Parameter.value(`totalScreens`))) + let perform = methodPerformValue(.m_whatsnewDone__totalScreens_totalScreens(Parameter.value(`totalScreens`))) as? (Int) -> Void + perform?(`totalScreens`) + } + + open func whatsnewClose(totalScreens: Int, currentScreen: Int) { + addInvocation(.m_whatsnewClose__totalScreens_totalScreenscurrentScreen_currentScreen(Parameter.value(`totalScreens`), Parameter.value(`currentScreen`))) + let perform = methodPerformValue(.m_whatsnewClose__totalScreens_totalScreenscurrentScreen_currentScreen(Parameter.value(`totalScreens`), Parameter.value(`currentScreen`))) as? (Int, Int) -> Void + perform?(`totalScreens`, `currentScreen`) + } + + + fileprivate enum MethodType { + case m_whatsnewPopup + case m_whatsnewDone__totalScreens_totalScreens(Parameter) + case m_whatsnewClose__totalScreens_totalScreenscurrentScreen_currentScreen(Parameter, Parameter) + + static func compareParameters(lhs: MethodType, rhs: MethodType, matcher: Matcher) -> Matcher.ComparisonResult { + switch (lhs, rhs) { + case (.m_whatsnewPopup, .m_whatsnewPopup): return .match + + case (.m_whatsnewDone__totalScreens_totalScreens(let lhsTotalscreens), .m_whatsnewDone__totalScreens_totalScreens(let rhsTotalscreens)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsTotalscreens, rhs: rhsTotalscreens, with: matcher), lhsTotalscreens, rhsTotalscreens, "totalScreens")) + return Matcher.ComparisonResult(results) + + case (.m_whatsnewClose__totalScreens_totalScreenscurrentScreen_currentScreen(let lhsTotalscreens, let lhsCurrentscreen), .m_whatsnewClose__totalScreens_totalScreenscurrentScreen_currentScreen(let rhsTotalscreens, let rhsCurrentscreen)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsTotalscreens, rhs: rhsTotalscreens, with: matcher), lhsTotalscreens, rhsTotalscreens, "totalScreens")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsCurrentscreen, rhs: rhsCurrentscreen, with: matcher), lhsCurrentscreen, rhsCurrentscreen, "currentScreen")) + return Matcher.ComparisonResult(results) + default: return .none + } + } + + func intValue() -> Int { + switch self { + case .m_whatsnewPopup: return 0 + case let .m_whatsnewDone__totalScreens_totalScreens(p0): return p0.intValue + case let .m_whatsnewClose__totalScreens_totalScreenscurrentScreen_currentScreen(p0, p1): return p0.intValue + p1.intValue + } + } + func assertionName() -> String { + switch self { + case .m_whatsnewPopup: return ".whatsnewPopup()" + case .m_whatsnewDone__totalScreens_totalScreens: return ".whatsnewDone(totalScreens:)" + case .m_whatsnewClose__totalScreens_totalScreenscurrentScreen_currentScreen: return ".whatsnewClose(totalScreens:currentScreen:)" + } + } + } + + open class Given: StubbedMethod { + fileprivate var method: MethodType + + private init(method: MethodType, products: [StubProduct]) { + self.method = method + super.init(products) + } + + + } + + public struct Verify { + fileprivate var method: MethodType + + public static func whatsnewPopup() -> Verify { return Verify(method: .m_whatsnewPopup)} + public static func whatsnewDone(totalScreens: Parameter) -> Verify { return Verify(method: .m_whatsnewDone__totalScreens_totalScreens(`totalScreens`))} + public static func whatsnewClose(totalScreens: Parameter, currentScreen: Parameter) -> Verify { return Verify(method: .m_whatsnewClose__totalScreens_totalScreenscurrentScreen_currentScreen(`totalScreens`, `currentScreen`))} + } + + public struct Perform { + fileprivate var method: MethodType + var performs: Any + + public static func whatsnewPopup(perform: @escaping () -> Void) -> Perform { + return Perform(method: .m_whatsnewPopup, performs: perform) + } + public static func whatsnewDone(totalScreens: Parameter, perform: @escaping (Int) -> Void) -> Perform { + return Perform(method: .m_whatsnewDone__totalScreens_totalScreens(`totalScreens`), performs: perform) + } + public static func whatsnewClose(totalScreens: Parameter, currentScreen: Parameter, perform: @escaping (Int, Int) -> Void) -> Perform { + return Perform(method: .m_whatsnewClose__totalScreens_totalScreenscurrentScreen_currentScreen(`totalScreens`, `currentScreen`), performs: perform) + } + } + + public func given(_ method: Given) { + methodReturnValues.append(method) + } + + public func perform(_ method: Perform) { + methodPerformValues.append(method) + methodPerformValues.sort { $0.method.intValue() < $1.method.intValue() } + } + + public func verify(_ method: Verify, count: Count = Count.moreOrEqual(to: 1), file: StaticString = #file, line: UInt = #line) { + let fullMatches = matchingCalls(method, file: file, line: line) + let success = count.matches(fullMatches) + let assertionName = method.method.assertionName() + let feedback: String = { + guard !success else { return "" } + return Utils.closestCallsMessage( + for: self.invocations.map { invocation in + matcher.set(file: file, line: line) + defer { matcher.clearFileAndLine() } + return MethodType.compareParameters(lhs: invocation, rhs: method.method, matcher: matcher) + }, + name: assertionName + ) + }() + MockyAssert(success, "Expected: \(count) invocations of `\(assertionName)`, but was: \(fullMatches).\(feedback)", file: file, line: line) + } + + private func addInvocation(_ call: MethodType) { + self.queue.sync { invocations.append(call) } + } + private func methodReturnValue(_ method: MethodType) throws -> StubProduct { + matcher.set(file: self.file, line: self.line) + defer { matcher.clearFileAndLine() } + let candidates = sequencingPolicy.sorted(methodReturnValues, by: { $0.method.intValue() > $1.method.intValue() }) + let matched = candidates.first(where: { $0.isValid && MethodType.compareParameters(lhs: $0.method, rhs: method, matcher: matcher).isFullMatch }) + guard let product = matched?.getProduct(policy: self.stubbingPolicy) else { throw MockError.notStubed } + return product + } + private func methodPerformValue(_ method: MethodType) -> Any? { + matcher.set(file: self.file, line: self.line) + defer { matcher.clearFileAndLine() } + let matched = methodPerformValues.reversed().first { MethodType.compareParameters(lhs: $0.method, rhs: method, matcher: matcher).isFullMatch } + return matched?.performs + } + private func matchingCalls(_ method: MethodType, file: StaticString?, line: UInt?) -> [MethodType] { + matcher.set(file: file ?? self.file, line: line ?? self.line) + defer { matcher.clearFileAndLine() } + return invocations.filter { MethodType.compareParameters(lhs: $0, rhs: method, matcher: matcher).isFullMatch } + } + private func matchingCalls(_ method: Verify, file: StaticString?, line: UInt?) -> Int { + return matchingCalls(method.method, file: file, line: line).count + } + private func givenGetterValue(_ method: MethodType, _ message: String) -> T { + do { + return try methodReturnValue(method).casted() + } catch { + onFatalFailure(message) + Failure(message) + } + } + private func optionalGivenGetterValue(_ method: MethodType, _ message: String) -> T? { + do { + return try methodReturnValue(method).casted() + } catch { + return nil + } + } + private func onFatalFailure(_ message: String) { + guard let file = self.file, let line = self.line else { return } // Let if fail if cannot handle gratefully + SwiftyMockyTestObserver.handleFatalError(message: message, file: file, line: line) + } +} +