diff --git a/Podfile b/Podfile index 024fa03f5a71..71be6daffd65 100644 --- a/Podfile +++ b/Podfile @@ -56,6 +56,7 @@ target :WordPressTest, :exclusive => true do pod 'Expecta', '0.3.2' pod 'Nimble', '~> 3.0.0' pod 'RxSwift', '~> 2.1.0' + pod 'RxTests', '~> 2.1.0' end target 'UITests', :exclusive => true do diff --git a/Podfile.lock b/Podfile.lock index 79e91ac927a5..73dd2c1e79c2 100644 --- a/Podfile.lock +++ b/Podfile.lock @@ -138,6 +138,8 @@ PODS: - RxCocoa (2.1.0): - RxSwift (~> 2.0) - RxSwift (2.1.0) + - RxTests (2.1.0): + - RxSwift (~> 2.0) - Simperium (0.8.10): - Simperium/DiffMatchPach (= 0.8.10) - Simperium/JRSwizzle (= 0.8.10) @@ -210,6 +212,7 @@ DEPENDENCIES: - ReactiveCocoa (~> 2.4.7) - RxCocoa (~> 2.1.0) - RxSwift (~> 2.1.0) + - RxTests (~> 2.1.0) - Simperium (= 0.8.10) - Specta (= 1.0.5) - SVProgressHUD (~> 1.1.3) @@ -282,6 +285,7 @@ SPEC CHECKSUMS: ReactiveCocoa: eb38dee0a0e698f73a9b25e5c1faea2bb4c79240 RxCocoa: 79b5feb8378545336e756a0a33fcf5e95050b71c RxSwift: 110fb07f81c17c2c3b3254d168363057b1880d18 + RxTests: 94c67ffc37c36bd8c7aec90a84601a3db142be94 Simperium: f507d9b400c499048a98fe728a0b2b9956fd14c1 Specta: ac94d110b865115fe60ff2c6d7281053c6f8e8a2 SVProgressHUD: 748080e4f36e603f6c02aec292664239df5279c1 diff --git a/WordPress/Classes/Extensions/Rx.swift b/WordPress/Classes/Extensions/Rx.swift new file mode 100644 index 000000000000..591f1be87f0d --- /dev/null +++ b/WordPress/Classes/Extensions/Rx.swift @@ -0,0 +1,83 @@ +import RxSwift + +// MARK: - pausable + +extension ObservableType { + + /** + Pauses the underlying observable sequence based upon the observable sequence which yields true/false. + + - parameter pauser: The observable sequence used to pause the underlying sequence. + - returns: An observable sequence that subscribes and emits the values of the source observable as long as the last emitted value of the condition observable is true. + */ + public func pausable(pauser: ConditionO) -> Observable { + return Pausable(source: self, pauser: pauser.asObservable()).asObservable() + } +} + +class Pausable: ObservableType { + typealias E = S.E + typealias DisposeKey = CompositeDisposable.DisposeKey + + private let _lock = NSRecursiveLock() + + private let _source: S + private let _pauser: Observable + private let _group = CompositeDisposable() + + private var _connectionKey: DisposeKey? = nil + + init(source: S, pauser: Observable) { + _source = source + _pauser = pauser + } + + func subscribe(observer: O) -> Disposable { + let conn = _source.publish() + let connection = conn.subscribe(observer) + _group.addDisposable(connection) + + let subscription = _pauser + .distinctUntilChanged() + .subscribeNext { active in + self._lock.lock(); defer { self._lock.unlock() } // lock { + if active { + self._connectionKey = self._group.addDisposable(conn.connect()) + } else { + if let connectionKey = self._connectionKey { + self._group.removeDisposable(connectionKey) + self._connectionKey = nil + } + } + // } + } + _group.addDisposable(subscription) + + return _group + } +} + +// MARK: - retryIf + +extension ObservableType { + /** + Repeats the source observable sequence on error if the given condition evaluates true. + + - parameter condition: A closure to be evaluated on error to decide if the source sequence should be retried. It takes two parameters: an incrementing `count` integer, and a `lastError` containing the latest error emitted. + - returns: An observable sequence producing the elements of the given sequence repeatedly until it terminates successfully or the condition evaluates false. + */ + public func retryIf(condition: (count: Int, lastError: NSError) -> Bool) -> Observable { + return retryWhen { (errors: Observable) in + errors.scan((0, nil)) { (accumulator: (Int, NSError!), error) in + (accumulator.0 + 1, error) + } + .flatMap { (count, lastError) -> Observable in + if condition(count: count, lastError: lastError) { + return Observable.just(count) + } else { + return Observable.error(lastError) + } + } + } + } +} diff --git a/WordPress/Classes/Networking/AccountSettingsRemote.swift b/WordPress/Classes/Networking/AccountSettingsRemote.swift index 3e18e2aec014..68cd310605a2 100644 --- a/WordPress/Classes/Networking/AccountSettingsRemote.swift +++ b/WordPress/Classes/Networking/AccountSettingsRemote.swift @@ -1,12 +1,78 @@ +import AFNetworking import Foundation +import RxSwift class AccountSettingsRemote: ServiceRemoteREST { - func getSettings(success success: AccountSettings -> Void, failure: ErrorType -> Void) { + static let remotes = NSMapTable(keyOptions: .StrongMemory, valueOptions: .WeakMemory) + + /// Returns an AccountSettingsRemote with the given api, reusing a previous + /// remote if it exists. + static func remoteWithApi(api: WordPressComApi) -> AccountSettingsRemote { + // We're hashing on the authToken because we don't want duplicate api + // objects for the same account. + // + // In theory this would be taken care of by the fact that the api comes + // from a WPAccount, and since WPAccount is a managed object Core Data + // guarantees there's only one of it. + // + // However it might be possible that the account gets deallocated and + // when it's fetched again it would create a different api object. + let key = api.authToken.hashValue + // FIXME: not thread safe + // @koke 2016-01-21 + if let remote = remotes.objectForKey(key) { + return remote as! AccountSettingsRemote + } else { + let remote = AccountSettingsRemote(api: api) + remotes.setObject(remote, forKey: key) + return remote + } + } + + let settings: Observable + + /// Creates a new AccountSettingsRemote. It is recommended that you use AccountSettingsRemote.remoteWithApi(_) + /// instead. + override init(api: WordPressComApi) { + settings = AccountSettingsRemote.settingsWithApi(api) + super.init(api: api) + } + + private static func settingsWithApi(api: WordPressComApi) -> Observable { + let settings = Observable.create { observer in + let remote = AccountSettingsRemote(api: api) + let operation = remote.getSettings( + success: { settings in + observer.onNext(settings) + observer.onCompleted() + }, failure: { error in + let nserror = error as NSError + if nserror.domain == NSURLErrorDomain && nserror.code == NSURLErrorCancelled { + // If we canceled the operation, don't propagate the error + // This probably means the observable is being disposed + DDLogSwift.logError("Canceled refreshing settings") + } else { + observer.onError(error) + } + }) + return AnonymousDisposable() { + if let operation = operation { + if !operation.finished { + operation.cancel() + } + } + } + } + + return settings + } + + func getSettings(success success: AccountSettings -> Void, failure: ErrorType -> Void) -> AFHTTPRequestOperation? { let endpoint = "me/settings" let parameters = ["context": "edit"] let path = pathForEndpoint(endpoint, withVersion: ServiceRemoteRESTApiVersion_1_1) - api.GET(path, + return api.GET(path, parameters: parameters, success: { operation, responseObject in @@ -85,4 +151,4 @@ class AccountSettingsRemote: ServiceRemoteREST { enum Error: ErrorType { case DecodeError } -} \ No newline at end of file +} diff --git a/WordPress/Classes/Services/AccountSettingsService.swift b/WordPress/Classes/Services/AccountSettingsService.swift index 9f379749485c..bbf1d5253453 100644 --- a/WordPress/Classes/Services/AccountSettingsService.swift +++ b/WordPress/Classes/Services/AccountSettingsService.swift @@ -1,34 +1,127 @@ import Foundation +import Reachability import RxCocoa import RxSwift let AccountSettingsServiceChangeSaveFailedNotification = "AccountSettingsServiceChangeSaveFailed" -struct AccountSettingsService { - let remote: AccountSettingsRemote +protocol AccountSettingsRemoteInterface { + var settings: Observable { get } + func updateSetting(change: AccountSettingsChange, success: () -> Void, failure: ErrorType -> Void) +} + +extension AccountSettingsRemote: AccountSettingsRemoteInterface {} + +class AccountSettingsService { + struct Defaults { + static let stallTimeout = 4.0 + static let maxRetries = 3 + static let pollingInterval = 60.0 + } + + let remote: AccountSettingsRemoteInterface let userID: Int private let context = ContextManager.sharedInstance().mainContext - init(userID: Int, api: WordPressComApi) { - self.remote = AccountSettingsRemote(api: api) - self.userID = userID + var testScheduler: SchedulerType? = nil + private var scheduler: SchedulerType { + return testScheduler ?? MainScheduler.instance } - func refreshSettings(completion: (Bool) -> Void) { - remote.getSettings( - success: { - (settings) -> Void in + convenience init(userID: Int, api: WordPressComApi) { + let remote = AccountSettingsRemote.remoteWithApi(api) + self.init(userID: userID, remote: remote) + } - self.updateSettings(settings) - completion(true) - }, failure: { - (error) -> Void in + init(userID: Int, remote: AccountSettingsRemoteInterface) { + self.userID = userID + self.remote = remote + } - DDLogSwift.logError(String(error)) - completion(false) + var testReachability: Observable? = nil + /// Emits a boolean value each time reachability changes for the internet connection. + private lazy var reachable: Observable = { + return self.testReachability ?? Reachability.internetConnection + }() + + /// Performs a network refresh of settings and emits values with the refresh status. + /// + /// - When it's subscribed, it requests a refresh from the server + /// - If a networking error happens it doesn't emit a new value and will retry the request. + /// - If it reaches the maximum permitted number of retries it will emit an Error. + /// - If an error not related to networking happens, it will emit an Error. + /// - When the data is refreshed, it will emit an `.Idle` value and complete. + private lazy var remoteSettings: Observable = { + return self.remote.settings + .map({ settings -> RefreshStatus in + self.updateSettings(settings) + return .Idle + }) + .retryIf({ (count, error) in + if error.domain == NSURLErrorDomain { + DDLogSwift.logError("Error refreshing settings (attempt \(count)): \(error)") + } else { + DDLogSwift.logError("Error refreshing settings (unrecoverable): \(error)") + } + + return error.domain == NSURLErrorDomain && count < Defaults.maxRetries + }) + }() + + /// Emits one `.Stalled` value after a timeout and then completes + private lazy var stalled: Observable = { + return Observable + .just(.Stalled) + .delaySubscription(Defaults.stallTimeout, scheduler: self.scheduler) + }() + + /// Performs a network refresh of settings and emits values with the refresh status. + /// + /// - When it's subscribed, it requests a refresh from the server + /// - If it takes more than `stallTimeout` to complete, it will emit a `.Stalled` value and continue waiting for the request to finish. + /// - If a networking error happens it doesn't emit a new value and will retry the request. + /// - If it reaches the maximum permitted number of retries it will emit an Error. + /// - If an error not related to networking happens, it will emit an Error. + /// - When the data is refreshed, it will emit an `.Idle` value and complete. + lazy private(set) var request: Observable = { + let remoteSettings = self.remoteSettings.shareReplayLatestWhileConnected() + let stalledSettings = Observable.of(self.stalled, remoteSettings) + .merge() + + return remoteSettings + .amb(stalledSettings) + .startWith(.Refreshing) + }() + + /// Emits values when the refresh status changes. + /// + /// On subscription, this will start refreshing settings, polling each minute, while there's an internet connection. + /// Possible values: + /// - `.Refreshing` when it starts getting remote data. + /// - `.Stalled` when it's getting remote data and hasn't succeeded before `stallTimeout`. + /// - `.Offline` when there is no internet connection. + /// - `.Idle` when the request was successful and it's waiting for the polling interval. + /// - An error when the request couldn't complete. It will stop retrying. + lazy var refresh: Observable = { + // Copy request to avoid capture of self in closure + let request = self.request + + // Convert to a polling request + let polling = Observable + .interval(Defaults.pollingInterval, scheduler: self.scheduler) + .startWith(0) + .flatMapLatest({ _ in request }) + + // Enable only when reachable, otherwise emit .Offline + return self.reachable.flatMapLatest({ reachable -> Observable in + if reachable { + return polling + } else { + return Observable.just(.Offline) + } }) - } + }() func saveChange(change: AccountSettingsChange) { guard let reverse = try? applyChange(change) else { @@ -113,4 +206,25 @@ struct AccountSettingsService { enum Errors: ErrorType { case NotFound } + + enum RefreshStatus { + case Idle + case Refreshing + case Stalled + case Failed + case Offline + + var errorMessage: String? { + switch self { + case Stalled: + return NSLocalizedString("We are having trouble loading data", comment: "Error message displayed when a refresh is taking longer than usual. The refresh hasn't failed and it might still succeed") + case Failed: + return NSLocalizedString("We had trouble loading data", comment: "Error message displayed when a refresh failed") + case Offline: + return NSLocalizedString("You are currently offline", comment: "Error message displayed when the app can't connect to the API servers") + case Idle, Refreshing: + return nil + } + } + } } diff --git a/WordPress/Classes/Utility/Animator.swift b/WordPress/Classes/Utility/Animator.swift new file mode 100644 index 000000000000..b9ef34af4b44 --- /dev/null +++ b/WordPress/Classes/Utility/Animator.swift @@ -0,0 +1,69 @@ +import UIKit + +/// Animator is a helper to build responsive animations. +/// +/// The main benefit of this class are the preamble and cleanup blocks, which +/// are only called before/after all the animations. +/// +/// You should keep a reference to the animator object, and use the same +/// animator for related animations. +/// +/// A very simple example of preamble and cleanup: +/// +/// class MyViewController: UIViewController { +/// lazy var animator = Animator() +/// var showError: Bool = false { +/// didSet { +/// animator.animateWithDuration(0.3, +/// preamble: { [unowned self] in +/// if self.showError { +/// let view = self.createErrorView() +/// self.view.addSubview(view) +/// self.errorView = view +/// self.errorView?.alpha = 0 +/// } +/// }, animations: { [unowned self] in +/// self.errorView?.alpha = 1 +/// }, cleanup: { [unowned self] in +/// if !self.showError { +/// self.errorView?.removeFromSuperview() +/// self.errorView = nil +/// } +/// }) +/// } +/// } +/// +/// func createErrorView() -> UIView { +/// // Create the error view +/// } +/// var errorView: UIView? = nil +/// } +/// +/// Animator is heavily inspired by the final demo on WWDC 2014 Session 236 +/// [Building Interruptible and Responsive Interactions](https://developer.apple.com/videos/play/wwdc2014-236/). +class Animator: NSObject { + private var animationsInProgress = 0 + + /// Animates changes to one or more views using the specified duration. + /// + /// - parameter preamble: A block called before the animations start. It will only be called if there were no previous animations. + /// - parameter animations: A block object containing the changes to commit to the views. + /// - parameter cleanup: A block called after the animations complete if there are no more pending animations. + func animateWithDuration(duration: NSTimeInterval, preamble: (() -> Void)? = nil, animations: () -> Void, cleanup: (() -> Void)? = nil) { + precondition(NSThread.isMainThread(), "Animator only works on the main (UI) thread") + + if animationsInProgress == 0 { + preamble?() + } + + UIView.animateWithDuration(duration, delay: 0, options: .CurveEaseOut, animations: animations) { [unowned self] _ in + self.animationsInProgress -= 1 + + if self.animationsInProgress == 0 { + cleanup?() + } + } + + animationsInProgress += 1 + } +} diff --git a/WordPress/Classes/Utility/ErrorAnimator.swift b/WordPress/Classes/Utility/ErrorAnimator.swift new file mode 100644 index 000000000000..4bfdb5290548 --- /dev/null +++ b/WordPress/Classes/Utility/ErrorAnimator.swift @@ -0,0 +1,107 @@ +import UIKit +import WordPressShared + +/// ErrorAnimator is a helper class to animate error messages. +/// +/// The error messages show at the top of the target view, and are meant to +/// appear to be attached to a navigation bar. The expected usage is to display +/// offline status or requests taking longer than usual. +/// +/// To use an ErrorAnimator, you need to keep a reference to it, and call two +/// methods: +/// +/// - `layout()` from your `UIView.layoutSubviews()` or +/// `UIViewController.viewDidLayoutSubviews()`. Failure to do this won't render +/// the animation correctly. +/// +/// - `animateErrorMessage(_)` when you want to change the error displayed. Pass +/// nil if you want to hide the error view. +/// +class ErrorAnimator: Animator { + let animationDuration = 0.3 + let targetHeight: CGFloat = 40 + + private var errorLabel: PaddedLabel? = nil + private var message: String? = nil + private var showingError: Bool { + return (message != nil) + } + let targetView: UIView + var targetTableView: UITableView? { + return targetView as? UITableView + } + + init(target: UIView) { + targetView = target + super.init() + } + + func layout() { + if let errorLabel = errorLabel { + let errorFrame = errorLabel.frame + var frame = targetView.bounds + frame.size.height = errorFrame.height + errorLabel.frame = frame + } + } + + func animateErrorMessage(message: String?) { + let previouslyShowing = showingError + // Are we showing or hiding the message + self.message = message + + if previouslyShowing != showingError { + animateWithDuration(animationDuration, preamble: preamble, animations: animations, cleanup: cleanup) + } + if showingError { + errorLabel?.label.text = message + } + } + + private func preamble() { + if showingError { + errorLabel = createErrorLabel() + targetView.addSubview(errorLabel!) + errorLabel?.frame.size.height = 0 + errorLabel?.label.alpha = 0 + } + + UIView.performWithoutAnimation { [unowned self] in + self.targetView.layoutIfNeeded() + } + } + + private func animations() { + if showingError { + errorLabel?.frame.size.height = targetHeight + errorLabel?.label.alpha = 1 + + targetTableView?.contentInset.top += targetHeight + if targetTableView?.contentOffset.y == 0 { + targetTableView?.contentOffset.y = -targetHeight + } + } else { + errorLabel?.frame.size.height = 0 + errorLabel?.label.alpha = 0 + + targetTableView?.contentInset.top -= targetHeight + } + targetView.layoutIfNeeded() + } + + private func cleanup() { + if !showingError { + errorLabel?.removeFromSuperview() + errorLabel = nil + } + } + + private func createErrorLabel() -> PaddedLabel { + let paddedLabel = PaddedLabel() + paddedLabel.padding.horizontal = 15 + paddedLabel.label.textColor = UIColor.whiteColor() + paddedLabel.backgroundColor = WPStyleGuide.mediumBlue() + paddedLabel.label.font = WPStyleGuide.regularTextFont() + return paddedLabel + } +} diff --git a/WordPress/Classes/Utility/ImmuTable.swift b/WordPress/Classes/Utility/ImmuTable.swift index 6a4fbd96e6a8..8392fd3bf893 100644 --- a/WordPress/Classes/Utility/ImmuTable.swift +++ b/WordPress/Classes/Utility/ImmuTable.swift @@ -72,6 +72,13 @@ public struct ImmuTable { } } +extension ImmuTable { + /// Alias for an ImmuTable with no sections + static var Empty: ImmuTable { + return ImmuTable(sections: []) + } +} + // MARK: - @@ -242,7 +249,7 @@ public class ImmuTableViewHandler: NSObject, UITableViewDataSource, UITableViewD } /// An ImmuTable object representing the table structure. - public var viewModel = ImmuTable(sections: []) { + public var viewModel = ImmuTable.Empty { didSet { if target.isViewLoaded() { target.tableView.reloadData() diff --git a/WordPress/Classes/Utility/ImmuTableViewController.swift b/WordPress/Classes/Utility/ImmuTableViewController.swift index 1424d5411186..d66b345a2226 100644 --- a/WordPress/Classes/Utility/ImmuTableViewController.swift +++ b/WordPress/Classes/Utility/ImmuTableViewController.swift @@ -4,7 +4,8 @@ import WordPressShared typealias ImmuTableRowControllerGenerator = ImmuTableRow -> UIViewController -protocol ImmuTablePresenter: AnyObject { +protocol ImmuTablePresenter: class { + var visible: Observable { get } func push(controllerGenerator: ImmuTableRowControllerGenerator) -> ImmuTableAction } @@ -18,6 +19,14 @@ extension ImmuTablePresenter where Self: UIViewController { } } +protocol ImmuTableController { + var presenter: ImmuTablePresenter? { get set } + var title: String { get } + var immuTableRows: [ImmuTableRow.Type] { get } + var immuTable: Observable { get } + var errorMessage: Observable { get } +} + /// Generic view controller to present ImmuTable-based tables /// /// Instead of subclassing the view controller, this is designed to be used from @@ -28,14 +37,36 @@ final class ImmuTableViewController: UITableViewController, ImmuTablePresenter { return ImmuTableViewHandler(takeOver: self) }() - private var willAppearSubject: PublishSubject { - return willAppear as! PublishSubject - } + private var visibleSubject = PublishSubject() + + private var errorAnimator: ErrorAnimator! + + let controller: ImmuTableController? + + private let bag = DisposeBag() // MARK: - Table View Controller - init() { + init(controller: ImmuTableController? = nil) { + self.controller = controller super.init(style: .Grouped) + self.controller?.presenter = self + if let controller = self.controller { + title = controller.title + registerRows(controller.immuTableRows) + controller.immuTable + .observeOn(MainScheduler.instance) + .subscribeNext({ [weak self] in + self?.handler.viewModel = $0 + }) + .addDisposableTo(bag) + controller.errorMessage + .observeOn(MainScheduler.instance) + .subscribeNext({ [weak self] in + self?.errorMessage = $0 + }) + .addDisposableTo(bag) + } } required init?(coder aDecoder: NSCoder) { @@ -45,30 +76,45 @@ final class ImmuTableViewController: UITableViewController, ImmuTablePresenter { override func viewDidLoad() { super.viewDidLoad() + errorAnimator = ErrorAnimator(target: view) + WPStyleGuide.resetReadableMarginsForTableView(tableView) WPStyleGuide.configureColorsForView(view, andTableView: tableView) } + override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + errorAnimator.layout() + } + override func viewWillAppear(animated: Bool) { super.viewWillAppear(animated) - willAppearSubject.onNext() + visibleSubject.on(.Next(true)) } - // MARK: - Inputs - - /// Sets the view model for the view controller - func bindViewModel(viewModel: ImmuTable) { - handler.viewModel = viewModel + override func viewDidDisappear(animated: Bool) { + super.viewDidDisappear(animated) + visibleSubject.on(.Next(false)) } + // MARK: - Inputs + /// Registers custom rows /// - seealso: ImmuTable.registerRows(_:tableView) func registerRows(rows: [ImmuTableRow.Type]) { ImmuTable.registerRows(rows, tableView: tableView) } + var errorMessage: String? = nil { + didSet { + errorAnimator.animateErrorMessage(errorMessage) + } + } + // MARK: - Outputs - /// Emits a value every time viewWillAppear is called - let willAppear: Observable = PublishSubject() + /// Emits a value when the view controller appears or disappears + var visible: Observable { + return visibleSubject + } } diff --git a/WordPress/Classes/Utility/Reachability+Rx.swift b/WordPress/Classes/Utility/Reachability+Rx.swift new file mode 100644 index 000000000000..f53f32709f1c --- /dev/null +++ b/WordPress/Classes/Utility/Reachability+Rx.swift @@ -0,0 +1,20 @@ +import Foundation +import Reachability +import RxSwift + +extension Reachability { + static let internetConnection = Observable.create { observer in + let reach = Reachability.reachabilityForInternetConnection() + reach.reachableBlock = { _ in + observer.onNext(true) + } + reach.unreachableBlock = { _ in + observer.onNext(false) + } + observer.onNext(reach.isReachable()) + reach.startNotifier() + return AnonymousDisposable() { + reach.stopNotifier() + } + }.shareReplayLatestWhileConnected() +} diff --git a/WordPress/Classes/ViewRelated/Me/MyProfileViewController.swift b/WordPress/Classes/ViewRelated/Me/MyProfileViewController.swift index 1e460a6e3400..0dcfd7adad7d 100644 --- a/WordPress/Classes/ViewRelated/Me/MyProfileViewController.swift +++ b/WordPress/Classes/ViewRelated/Me/MyProfileViewController.swift @@ -2,60 +2,88 @@ import UIKit import RxSwift import WordPressShared -class MyProfileController: NSObject { +func MyProfileViewController(account account: WPAccount) -> ImmuTableViewController { + let service = AccountSettingsService(userID: account.userID.integerValue, api: account.restApi) + return MyProfileViewController(service: service) +} + +func MyProfileViewController(service service: AccountSettingsService) -> ImmuTableViewController { + let controller = MyProfileController(service: service) + let viewController = ImmuTableViewController(controller: controller) + assert(viewController.controller?.presenter != nil, "ImmuTableViewController should have set the presenter for MyProfileController") + return viewController +} + +/// MyProfileController requires the `presenter` to be set before using. +/// To avoid problems, it's marked private and should only be initialized using the +/// `MyProfileViewController` factory functions. +private struct MyProfileController: ImmuTableController { + // MARK: - ImmuTableController + + weak var presenter: ImmuTablePresenter? = nil + let title = NSLocalizedString("My Profile", comment: "My Profile view title") - let service: AccountSettingsService - let viewController = ImmuTableViewController() - init(service: AccountSettingsService) { - self.service = service - super.init() - - viewController.title = title - viewController.registerRows(immutableRows) - - _ = viewModel - .observeOn(MainScheduler.instance) - .takeUntil(viewController.rx_deallocated) - .subscribeNext(viewController.bindViewModel) - // Only refresh on first appearance - _ = viewController.willAppear.take(1).subscribeNext { - service.refreshSettings({ _ in }) - } + var immuTableRows: [ImmuTableRow.Type] { + return [EditableTextRow.self] } - convenience init(account: WPAccount) { - self.init(service: AccountSettingsService(userID: account.userID.integerValue, api: account.restApi)) + var immuTable: Observable { + precondition(presenter != nil, "presenter must be set before using") + return service.settings.map(mapViewModel) } - var immutableRows: [ImmuTableRow.Type] { - return [EditableTextRow.self] + var errorMessage: Observable { + precondition(presenter != nil, "presenter must be set before using") + guard let presenter = presenter else { + // This shouldn't happen, but if it does, disabling the error feels + // safer than having it running when the VC is not visible. + return Observable.just(nil) + } + return service.refresh + .pausable(presenter.visible) + // replace errors with .Failed status + .catchErrorJustReturn(.Failed) + // convert status to string + .map({ $0.errorMessage }) } - var viewModel: Observable { - return service.settings.map(mapViewModel) + // MARK: - Initialization + + let service: AccountSettingsService + + init(service: AccountSettingsService) { + self.service = service } + // MARK: - Model mapping + func mapViewModel(settings: AccountSettings?) -> ImmuTable { + precondition(presenter != nil, "presenter must be set before using") + guard let presenter = presenter else { + // This shouldn't happen. If there's no presenter we can't push the + // editText controllers. + return ImmuTable.Empty + } let firstNameRow = EditableTextRow( title: NSLocalizedString("First Name", comment: "My Profile first name label"), value: settings?.firstName ?? "", - action: viewController.push(editText(AccountSettingsChange.FirstName))) + action: presenter.push(editText(AccountSettingsChange.FirstName))) let lastNameRow = EditableTextRow( title: NSLocalizedString("Last Name", comment: "My Profile last name label"), value: settings?.lastName ?? "", - action: viewController.push(editText(AccountSettingsChange.LastName))) + action: presenter.push(editText(AccountSettingsChange.LastName))) let displayNameRow = EditableTextRow( title: NSLocalizedString("Display Name", comment: "My Profile display name label"), value: settings?.displayName ?? "", - action: viewController.push(editText(AccountSettingsChange.DisplayName))) + action: presenter.push(editText(AccountSettingsChange.DisplayName))) let aboutMeRow = EditableTextRow( title: NSLocalizedString("About Me", comment: "My Profile 'About me' label"), value: settings?.aboutMe ?? "", - action: viewController.push(editText(AccountSettingsChange.AboutMe))) + action: presenter.push(editText(AccountSettingsChange.AboutMe))) return ImmuTable(sections: [ ImmuTableSection(rows: [ @@ -67,8 +95,10 @@ class MyProfileController: NSObject { ]) } + // MARK: - Actions + func editText(changeType: (AccountSettingsChangeWithString), hint: String? = nil) -> ImmuTableRowControllerGenerator { - return { [unowned self] row in + return { row in let row = row as! EditableTextRow return self.controllerForEditableText(row, changeType: changeType, hint: hint) } @@ -86,7 +116,6 @@ class MyProfileController: NSObject { controller.title = title controller.onValueChanged = { - [unowned self] value in let change = changeType(value) diff --git a/WordPress/Classes/ViewRelated/MeViewController.swift b/WordPress/Classes/ViewRelated/MeViewController.swift index 0ca3424bc759..6ffae8eb9671 100644 --- a/WordPress/Classes/ViewRelated/MeViewController.swift +++ b/WordPress/Classes/ViewRelated/MeViewController.swift @@ -187,8 +187,8 @@ class MeViewController: UITableViewController, UIViewControllerRestoration { } WPAppAnalytics.track(.OpenedMyProfile) - let controller = MyProfileController(account: account) - self.navigationController?.pushViewController(controller.viewController, animated: true) + let controller = MyProfileViewController(account: account) + self.navigationController?.pushViewController(controller, animated: true) } } diff --git a/WordPress/Classes/ViewRelated/Views/PaddedLabel.swift b/WordPress/Classes/ViewRelated/Views/PaddedLabel.swift new file mode 100644 index 000000000000..636bd3325f0e --- /dev/null +++ b/WordPress/Classes/ViewRelated/Views/PaddedLabel.swift @@ -0,0 +1,27 @@ +import UIKit + +class PaddedLabel: UIView { + var padding: (horizontal: CGFloat, vertical: CGFloat) = (0,0) { + didSet { + setNeedsLayout() + } + } + + let label = UILabel() + + override init(frame: CGRect) { + super.init(frame: frame) + addSubview(label) + } + + required init?(coder aDecoder: NSCoder) { + super.init(coder: aDecoder) + addSubview(label) + } + + override func layoutSubviews() { + super.layoutSubviews() + + label.frame = CGRectInset(bounds, padding.horizontal, padding.vertical) + } +} diff --git a/WordPress/WordPress.xcodeproj/project.pbxproj b/WordPress/WordPress.xcodeproj/project.pbxproj index 4007091b7cc2..011680a62dcc 100644 --- a/WordPress/WordPress.xcodeproj/project.pbxproj +++ b/WordPress/WordPress.xcodeproj/project.pbxproj @@ -448,7 +448,9 @@ E10B3655158F2D7800419A93 /* CoreGraphics.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 834CE7371256D0F60046A4A3 /* CoreGraphics.framework */; }; E10B5ACF1C4518E100F6A390 /* AccountService+Rx.swift in Sources */ = {isa = PBXBuildFile; fileRef = E10B5ACE1C4518E100F6A390 /* AccountService+Rx.swift */; }; E11330511A13BAA300D36D84 /* me-sites-with-jetpack.json in Resources */ = {isa = PBXBuildFile; fileRef = E11330501A13BAA300D36D84 /* me-sites-with-jetpack.json */; }; + E11450DF1C4E47E600A6BD0F /* ErrorAnimator.swift in Sources */ = {isa = PBXBuildFile; fileRef = E11450DE1C4E47E600A6BD0F /* ErrorAnimator.swift */; }; E114D79A153D85A800984182 /* WPError.m in Sources */ = {isa = PBXBuildFile; fileRef = E114D799153D85A800984182 /* WPError.m */; }; + E116D4411C50D5F400DC5593 /* Rx.swift in Sources */ = {isa = PBXBuildFile; fileRef = E116D4401C50D5F400DC5593 /* Rx.swift */; }; E1209FA41BB4978B00D69778 /* PeopleService.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1209FA31BB4978B00D69778 /* PeopleService.swift */; }; E120D90E1B09D8C300FB9A6E /* JetpackState.m in Sources */ = {isa = PBXBuildFile; fileRef = E120D90D1B09D8C300FB9A6E /* JetpackState.m */; }; E1249B4319408C910035E895 /* RemoteComment.m in Sources */ = {isa = PBXBuildFile; fileRef = E1249B4219408C910035E895 /* RemoteComment.m */; }; @@ -460,6 +462,8 @@ E1266D2D1BBE8B9A00FCB6B6 /* Gravatar.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1266D2C1BBE8B9A00FCB6B6 /* Gravatar.swift */; }; E1266D2F1BBEC37B00FCB6B6 /* GravatarTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1266D2E1BBEC37B00FCB6B6 /* GravatarTest.swift */; }; E127A0F11C43B7CB00085129 /* SiteServiceRemoteREST.m in Sources */ = {isa = PBXBuildFile; fileRef = E127A0F01C43B7CB00085129 /* SiteServiceRemoteREST.m */; }; + E12BE5EE1C5235DB000FD5CA /* get-me-settings-v1.1.json in Resources */ = {isa = PBXBuildFile; fileRef = E12BE5ED1C5235DB000FD5CA /* get-me-settings-v1.1.json */; }; + E12BE5F01C524FC9000FD5CA /* AccountSettingsServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E12BE5EF1C524FC9000FD5CA /* AccountSettingsServiceTests.swift */; }; E12DB07B1C48D1C200A6C1D4 /* WPAccount+AccountSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = E12DB07A1C48D1C200A6C1D4 /* WPAccount+AccountSettings.swift */; }; E12E6E331C21BA170033C5D0 /* FeatureFlag.swift in Sources */ = {isa = PBXBuildFile; fileRef = E12E6E321C21BA170033C5D0 /* FeatureFlag.swift */; }; E12E6E381C21E75F0033C5D0 /* FeatureFlagTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = E12E6E371C21E75F0033C5D0 /* FeatureFlagTest.swift */; }; @@ -469,6 +473,7 @@ E131CB5816CACFB4004B0314 /* get-user-blogs_doesnt-have-blog.json in Resources */ = {isa = PBXBuildFile; fileRef = E131CB5716CACFB4004B0314 /* get-user-blogs_doesnt-have-blog.json */; }; E131F5351C2930FC00D2D975 /* String+Helpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = E131F5341C2930FC00D2D975 /* String+Helpers.swift */; }; E13A8C9B1C3E6EF2005BB1C1 /* ImmuTable+WordPress.swift in Sources */ = {isa = PBXBuildFile; fileRef = E13A8C9A1C3E6EF2005BB1C1 /* ImmuTable+WordPress.swift */; }; + E13BF2CA1C522A1300275BE9 /* AccountSettingsRemoteTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E13BF2C91C522A1300275BE9 /* AccountSettingsRemoteTests.swift */; }; E13EB7A5157D230000885780 /* WordPressComApi.m in Sources */ = {isa = PBXBuildFile; fileRef = E13EB7A4157D230000885780 /* WordPressComApi.m */; }; E13F23C314FE84600081D9CC /* NSMutableDictionary+Helpers.m in Sources */ = {isa = PBXBuildFile; fileRef = E13F23C214FE84600081D9CC /* NSMutableDictionary+Helpers.m */; }; E14200781C117A2E00B3B115 /* ManagedAccountSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = E14200771C117A2E00B3B115 /* ManagedAccountSettings.swift */; }; @@ -478,6 +483,7 @@ E149D64E19349E69006A843D /* AccountServiceRemoteREST.m in Sources */ = {isa = PBXBuildFile; fileRef = E149D64619349E69006A843D /* AccountServiceRemoteREST.m */; }; E149D65019349E69006A843D /* MediaServiceRemoteREST.m in Sources */ = {isa = PBXBuildFile; fileRef = E149D64B19349E69006A843D /* MediaServiceRemoteREST.m */; }; E149D65119349E69006A843D /* MediaServiceRemoteXMLRPC.m in Sources */ = {isa = PBXBuildFile; fileRef = E149D64D19349E69006A843D /* MediaServiceRemoteXMLRPC.m */; }; + E14B13C31C4E7675009DD68F /* Reachability+Rx.swift in Sources */ = {isa = PBXBuildFile; fileRef = E14B13C21C4E7675009DD68F /* Reachability+Rx.swift */; }; E1556CF2193F6FE900FC52EA /* CommentService.m in Sources */ = {isa = PBXBuildFile; fileRef = E1556CF1193F6FE900FC52EA /* CommentService.m */; }; E15618FD16DB8677006532C4 /* UIKitTestHelper.m in Sources */ = {isa = PBXBuildFile; fileRef = E15618FC16DB8677006532C4 /* UIKitTestHelper.m */; }; E15618FF16DBA983006532C4 /* xmlrpc-response-newpost.xml in Resources */ = {isa = PBXBuildFile; fileRef = E15618FE16DBA983006532C4 /* xmlrpc-response-newpost.xml */; }; @@ -517,6 +523,7 @@ E19DF741141F7BDD000002F3 /* libz.dylib in Frameworks */ = {isa = PBXBuildFile; fileRef = E19DF740141F7BDD000002F3 /* libz.dylib */; }; E1A03EE217422DCF0085D192 /* BlogToAccount.m in Sources */ = {isa = PBXBuildFile; fileRef = E1A03EE117422DCE0085D192 /* BlogToAccount.m */; }; E1A03F48174283E10085D192 /* BlogToJetpackAccount.m in Sources */ = {isa = PBXBuildFile; fileRef = E1A03F47174283E00085D192 /* BlogToJetpackAccount.m */; }; + E1A0AC821C560F3A00070E2B /* RxTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1A0AC811C560F3A00070E2B /* RxTests.swift */; }; E1A0FAE7162F11CF0063B098 /* UIDevice+Helpers.m in Sources */ = {isa = PBXBuildFile; fileRef = E1A0FAE6162F11CE0063B098 /* UIDevice+Helpers.m */; }; E1A386C814DB05C300954CF8 /* AVFoundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = E1A386C714DB05C300954CF8 /* AVFoundation.framework */; }; E1A386CA14DB05F700954CF8 /* CoreMedia.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = E1A386C914DB05F700954CF8 /* CoreMedia.framework */; }; @@ -538,6 +545,8 @@ E1B9128B1BB0129C003C25B9 /* WPStyleGuide+People.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1B9128A1BB0129C003C25B9 /* WPStyleGuide+People.swift */; }; E1B9128F1BB05B1D003C25B9 /* PeopleCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1B912841BB01266003C25B9 /* PeopleCell.swift */; }; E1B921BC1C0ED5A3003EA3CB /* MediaSizeSliderCellTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1B921BB1C0ED5A3003EA3CB /* MediaSizeSliderCellTest.swift */; }; + E1BEEC631C4E35A8000B4FA0 /* Animator.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1BEEC621C4E35A8000B4FA0 /* Animator.swift */; }; + E1BEEC651C4E3978000B4FA0 /* PaddedLabel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1BEEC641C4E3978000B4FA0 /* PaddedLabel.swift */; }; E1C265C91BECFCDD00DC4C6B /* WPCrashlyticsLogger.m in Sources */ = {isa = PBXBuildFile; fileRef = E1C265C81BECFCDD00DC4C6B /* WPCrashlyticsLogger.m */; }; E1C9AA511C10419200732665 /* Math.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1C9AA501C10419200732665 /* Math.swift */; }; E1C9AA561C10427100732665 /* MathTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1C9AA551C10427100732665 /* MathTest.swift */; }; @@ -1458,9 +1467,11 @@ E10B3653158F2D4500419A93 /* UIKit.framework */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = wrapper.framework; name = UIKit.framework; path = System/Library/Frameworks/UIKit.framework; sourceTree = SDKROOT; }; E10B5ACE1C4518E100F6A390 /* AccountService+Rx.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "AccountService+Rx.swift"; sourceTree = ""; }; E11330501A13BAA300D36D84 /* me-sites-with-jetpack.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = "me-sites-with-jetpack.json"; sourceTree = ""; }; + E11450DE1C4E47E600A6BD0F /* ErrorAnimator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ErrorAnimator.swift; sourceTree = ""; }; E114D798153D85A800984182 /* WPError.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = WPError.h; sourceTree = ""; }; E114D799153D85A800984182 /* WPError.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = WPError.m; sourceTree = ""; }; E115F2D116776A2900CCF00D /* WordPress 8.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "WordPress 8.xcdatamodel"; sourceTree = ""; }; + E116D4401C50D5F400DC5593 /* Rx.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Rx.swift; sourceTree = ""; }; E1209FA31BB4978B00D69778 /* PeopleService.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PeopleService.swift; sourceTree = ""; }; E120D90C1B09D8C300FB9A6E /* JetpackState.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = JetpackState.h; sourceTree = ""; }; E120D90D1B09D8C300FB9A6E /* JetpackState.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = JetpackState.m; sourceTree = ""; }; @@ -1485,6 +1496,8 @@ E127A0EF1C43B7CB00085129 /* SiteServiceRemoteREST.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = SiteServiceRemoteREST.h; sourceTree = ""; }; E127A0F01C43B7CB00085129 /* SiteServiceRemoteREST.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = SiteServiceRemoteREST.m; sourceTree = ""; }; E12963A8174654B2002E7744 /* ru */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ru; path = ru.lproj/Localizable.strings; sourceTree = ""; }; + E12BE5ED1C5235DB000FD5CA /* get-me-settings-v1.1.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = "get-me-settings-v1.1.json"; sourceTree = ""; }; + E12BE5EF1C524FC9000FD5CA /* AccountSettingsServiceTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AccountSettingsServiceTests.swift; sourceTree = ""; }; E12DB07A1C48D1C200A6C1D4 /* WPAccount+AccountSettings.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "WPAccount+AccountSettings.swift"; sourceTree = ""; }; E12E6E321C21BA170033C5D0 /* FeatureFlag.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FeatureFlag.swift; sourceTree = ""; }; E12E6E371C21E75F0033C5D0 /* FeatureFlagTest.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FeatureFlagTest.swift; sourceTree = ""; }; @@ -1498,6 +1511,7 @@ E131F5341C2930FC00D2D975 /* String+Helpers.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "String+Helpers.swift"; sourceTree = ""; }; E133DB40137AE180003C0AF9 /* he */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = he; path = he.lproj/Localizable.strings; sourceTree = ""; }; E13A8C9A1C3E6EF2005BB1C1 /* ImmuTable+WordPress.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "ImmuTable+WordPress.swift"; sourceTree = ""; }; + E13BF2C91C522A1300275BE9 /* AccountSettingsRemoteTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AccountSettingsRemoteTests.swift; sourceTree = ""; }; E13EB7A3157D230000885780 /* WordPressComApi.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = WordPressComApi.h; sourceTree = ""; }; E13EB7A4157D230000885780 /* WordPressComApi.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = WordPressComApi.m; sourceTree = ""; }; E13F23C114FE84600081D9CC /* NSMutableDictionary+Helpers.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = "NSMutableDictionary+Helpers.h"; sourceTree = ""; }; @@ -1517,6 +1531,7 @@ E149D64B19349E69006A843D /* MediaServiceRemoteREST.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = MediaServiceRemoteREST.m; sourceTree = ""; }; E149D64C19349E69006A843D /* MediaServiceRemoteXMLRPC.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = MediaServiceRemoteXMLRPC.h; sourceTree = ""; }; E149D64D19349E69006A843D /* MediaServiceRemoteXMLRPC.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = MediaServiceRemoteXMLRPC.m; sourceTree = ""; }; + E14B13C21C4E7675009DD68F /* Reachability+Rx.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Reachability+Rx.swift"; sourceTree = ""; }; E14D65C717E09663007E3EA4 /* Social.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Social.framework; path = System/Library/Frameworks/Social.framework; sourceTree = SDKROOT; }; E150520B16CAC5C400D3DDDC /* BlogJetpackTest.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = BlogJetpackTest.m; sourceTree = ""; }; E150520D16CAC75A00D3DDDC /* CoreDataTestHelper.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = CoreDataTestHelper.h; sourceTree = ""; }; @@ -1573,6 +1588,7 @@ E1A03EE117422DCE0085D192 /* BlogToAccount.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = BlogToAccount.m; sourceTree = ""; }; E1A03F46174283DF0085D192 /* BlogToJetpackAccount.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = BlogToJetpackAccount.h; sourceTree = ""; }; E1A03F47174283E00085D192 /* BlogToJetpackAccount.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = BlogToJetpackAccount.m; sourceTree = ""; }; + E1A0AC811C560F3A00070E2B /* RxTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RxTests.swift; sourceTree = ""; }; E1A0FAE5162F11CE0063B098 /* UIDevice+Helpers.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; lineEnding = 0; path = "UIDevice+Helpers.h"; sourceTree = ""; xcLanguageSpecificationIdentifier = xcode.lang.objcpp; }; E1A0FAE6162F11CE0063B098 /* UIDevice+Helpers.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; lineEnding = 0; path = "UIDevice+Helpers.m"; sourceTree = ""; xcLanguageSpecificationIdentifier = xcode.lang.objc; }; E1A386C714DB05C300954CF8 /* AVFoundation.framework */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = wrapper.framework; name = AVFoundation.framework; path = System/Library/Frameworks/AVFoundation.framework; sourceTree = SDKROOT; }; @@ -1603,6 +1619,8 @@ E1B9128A1BB0129C003C25B9 /* WPStyleGuide+People.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "WPStyleGuide+People.swift"; sourceTree = ""; }; E1B921BB1C0ED5A3003EA3CB /* MediaSizeSliderCellTest.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MediaSizeSliderCellTest.swift; sourceTree = ""; }; E1BCFBC51C0626C5004BDADF /* WordPress 43.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "WordPress 43.xcdatamodel"; sourceTree = ""; }; + E1BEEC621C4E35A8000B4FA0 /* Animator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Animator.swift; sourceTree = ""; }; + E1BEEC641C4E3978000B4FA0 /* PaddedLabel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PaddedLabel.swift; sourceTree = ""; }; E1C265C71BECFCDD00DC4C6B /* WPCrashlyticsLogger.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = WPCrashlyticsLogger.h; sourceTree = ""; }; E1C265C81BECFCDD00DC4C6B /* WPCrashlyticsLogger.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = WPCrashlyticsLogger.m; sourceTree = ""; }; E1C807471696F72E00E545A6 /* WordPress 9.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "WordPress 9.xcdatamodel"; sourceTree = ""; }; @@ -1920,6 +1938,7 @@ C58349C31806F95100B64089 /* IOS7CorrectedTextView.h */, C58349C41806F95100B64089 /* IOS7CorrectedTextView.m */, B5B410B51B1772B000CFCF8D /* NavigationTitleView.swift */, + E1BEEC641C4E3978000B4FA0 /* PaddedLabel.swift */, B57B92BC1B73B08100DFF00B /* SeparatorsView.swift */, 37022D8F1981BF9200F322B7 /* VerticallyStackedButton.h */, 37022D901981BF9200F322B7 /* VerticallyStackedButton.m */, @@ -2215,6 +2234,7 @@ isa = PBXGroup; children = ( 591CFB051B28A960009E61B3 /* AccountServiceRemoteRESTTests.m */, + E13BF2C91C522A1300275BE9 /* AccountSettingsRemoteTests.swift */, 591CFB081B28AC8C009E61B3 /* BlogServiceRemoteRESTTests.m */, FA9E74471C50382D00C6B1D2 /* SiteManagementServiceRemoteTests.swift */, 59E2AAEB1B20E5CE0051DC06 /* PostServiceRemoteRESTTests.m */, @@ -2676,6 +2696,7 @@ 852416CC1A12EAF70030700C /* Ratings */, E1523EB216D3B2EE002C5A36 /* Sharing */, B526DC241B1E473B002A8C5F /* WebViewController */, + E1BEEC621C4E35A8000B4FA0 /* Animator.swift */, C545E0A01811B9880020844C /* ContextManager.h */, 93EF094B19ED4F1100C89770 /* ContextManager-Internals.h */, C545E0A11811B9880020844C /* ContextManager.m */, @@ -2683,14 +2704,16 @@ FD9A948B12FAEA2300438F94 /* DateUtils.m */, 93A379D919FE6D3000415023 /* DDLogSwift.h */, 93A379DA19FE6D3000415023 /* DDLogSwift.m */, - 313692771A5D6F7900EBE645 /* HelpshiftUtils.h */, + E11450DE1C4E47E600A6BD0F /* ErrorAnimator.swift */, E1266D2C1BBE8B9A00FCB6B6 /* Gravatar.swift */, + 313692771A5D6F7900EBE645 /* HelpshiftUtils.h */, 313692781A5D6F7900EBE645 /* HelpshiftUtils.m */, E1EBC36E1C118EA500F638E0 /* ImmuTable.swift */, E1E49CE31C4902EE002393A4 /* ImmuTableViewController.swift */, E13A8C9A1C3E6EF2005BB1C1 /* ImmuTable+WordPress.swift */, 5DB4683918A2E718004A89A9 /* LocationService.h */, 5DB4683A18A2E718004A89A9 /* LocationService.m */, + E14B13C21C4E7675009DD68F /* Reachability+Rx.swift */, 5D3E334C15EEBB6B005FC6F2 /* ReachabilityUtils.h */, 5D3E334D15EEBB6B005FC6F2 /* ReachabilityUtils.m */, 85D239B41AE5A6170074768D /* ReachabilityFacade.h */, @@ -3134,6 +3157,7 @@ FFB1FA9F1BF0EC4E0090C761 /* PHAsset+Exporters.swift */, E1C9AA501C10419200732665 /* Math.swift */, E131F5341C2930FC00D2D975 /* String+Helpers.swift */, + E116D4401C50D5F400DC5593 /* Rx.swift */, ); path = Extensions; sourceTree = ""; @@ -3169,6 +3193,7 @@ 5DE8A0401912D95B00B2FF59 /* ReaderPostServiceTest.m */, E66969C71B9E0A6800EC9C00 /* ReaderTopicServiceTest.swift */, 59FBD5611B5684F300734466 /* ThemeServiceTests.m */, + E12BE5EF1C524FC9000FD5CA /* AccountSettingsServiceTests.swift */, ); name = Services; sourceTree = ""; @@ -3666,6 +3691,7 @@ B5AEEC781ACACFDA008BF2A4 /* notifications-replied-comment.json */, B5EFB1D01B33630C007608A3 /* notifications-settings.json */, 93CD939219099BE70049096E /* authtoken.json */, + E12BE5ED1C5235DB000FD5CA /* get-me-settings-v1.1.json */, FAFB84051BBF3638000BBA8E /* get-multiple-themes-v1.2.json */, 59F9C1581B9DD3D600885CC1 /* get-purchased-themes-v1.1.json */, 59F9C1561B9DCE4E00885CC1 /* get-single-theme-v1.1.json */, @@ -3741,6 +3767,7 @@ isa = PBXGroup; children = ( E1C9AA551C10427100732665 /* MathTest.swift */, + E1A0AC811C560F3A00070E2B /* RxTests.swift */, ); path = Extensions; sourceTree = ""; @@ -4119,6 +4146,7 @@ buildActionMask = 2147483647; files = ( E1EBC3751C118EDE00F638E0 /* ImmuTableTestViewCellWithNib.xib in Resources */, + E12BE5EE1C5235DB000FD5CA /* get-me-settings-v1.1.json in Resources */, B5A6BB8C1BF4DF38002F6A96 /* rest-site-settings.json in Resources */, E16AB93414D978240047A2E5 /* InfoPlist.strings in Resources */, 93594BD5191D2F5A0079E6B2 /* stats-batch.json in Resources */, @@ -4497,6 +4525,7 @@ F1A0C49C1AF65B02001B544C /* MFMessageComposeViewController+StatusBarStyle.m in Sources */, ACBAB6860E1247F700F38795 /* PostPreviewViewController.m in Sources */, 5D2FB2861AE98C6600F1D4ED /* RestorePostTableViewCell.m in Sources */, + E1BEEC651C4E3978000B4FA0 /* PaddedLabel.swift in Sources */, E1A6DBE519DC7D230071AC1E /* PostService.m in Sources */, C58349C51806F95100B64089 /* IOS7CorrectedTextView.m in Sources */, 594DB2951AB891A200E2E456 /* WPUserAgent.m in Sources */, @@ -4684,6 +4713,7 @@ B532D4EE199D4418006E4DF6 /* NoteBlockImageTableViewCell.swift in Sources */, E10B5ACF1C4518E100F6A390 /* AccountService+Rx.swift in Sources */, 93FA59DD18D88C1C001446BC /* PostCategoryService.m in Sources */, + E1BEEC631C4E35A8000B4FA0 /* Animator.swift in Sources */, 5DCC4CD819A50CC0003E548C /* ReaderSite.m in Sources */, 93C4864F181043D700A24725 /* ActivityLogDetailViewController.m in Sources */, 859F761D18F2159800EF8D5D /* WPAnalyticsTrackerMixpanelInstructionsForStat.m in Sources */, @@ -4691,6 +4721,7 @@ E1E49CE41C4902EE002393A4 /* ImmuTableViewController.swift in Sources */, E616E4B31C480896002C024E /* SharingService.swift in Sources */, E1FD45E01C030B3800750F4C /* AccountSettingsService.swift in Sources */, + E116D4411C50D5F400DC5593 /* Rx.swift in Sources */, E1D0D81616D3B86800E33F4C /* SafariActivity.m in Sources */, E603C7701BC94AED00AD49D7 /* WordPress-37-38.xcmappingmodel in Sources */, FF0AAE0D1A16550D0089841D /* WPMediaProgressTableViewController.m in Sources */, @@ -4830,12 +4861,14 @@ 74D5FFD619ACDF6700389E8F /* WPLegacyEditPostViewController.m in Sources */, B54E1DF41A0A7BBF00807537 /* NotificationMediaDownloader.swift in Sources */, E174F6E6172A73960004F23A /* WPAccount.m in Sources */, + E11450DF1C4E47E600A6BD0F /* ErrorAnimator.swift in Sources */, E100C6BB1741473000AE48D8 /* WordPress-11-12.xcmappingmodel in Sources */, E6431DE71C4E892900FD8D90 /* SharingViewController.m in Sources */, E1A03EE217422DCF0085D192 /* BlogToAccount.m in Sources */, 5D44EB381986D8BA008B7175 /* ReaderSiteService.m in Sources */, E1A03F48174283E10085D192 /* BlogToJetpackAccount.m in Sources */, B522C4F81B3DA79B00E47B59 /* NotificationSettingsViewController.swift in Sources */, + E14B13C31C4E7675009DD68F /* Reachability+Rx.swift in Sources */, B587797C19B799D800E57C5A /* NSParagraphStyle+Helpers.swift in Sources */, 5D6C4B121B604190005E3C43 /* RichTextView.swift in Sources */, 082AB9DD1C4F035E000CA523 /* PostTag.m in Sources */, @@ -4903,6 +4936,7 @@ 59E2AAE81B20E3EA0051DC06 /* ServiceRemoteRESTTests.m in Sources */, E61084C41B9DC09C008050C5 /* ReaderPostServiceRemoteTests.m in Sources */, BEC8A3FF1B4BAA2C001CB8C3 /* BlogListViewControllerTests.m in Sources */, + E12BE5F01C524FC9000FD5CA /* AccountSettingsServiceTests.swift in Sources */, E6B9B8AD1B94EACA0001B92F /* ReaderHelperTests.swift in Sources */, E66969CD1B9E2EBF00EC9C00 /* SafeReaderTopicToReaderTopic.m in Sources */, E66969C81B9E0A6800EC9C00 /* ReaderTopicServiceTest.swift in Sources */, @@ -4937,12 +4971,14 @@ B5D689FD1A5EBC900063D9E5 /* NotificationsManager+TestHelper.m in Sources */, BEA0E4851BD83565000AEE81 /* WP3DTouchShortcutCreatorTests.swift in Sources */, 85F8E19B1B017AA6000859BB /* PushAuthenticationServiceRemoteTests.swift in Sources */, + E13BF2CA1C522A1300275BE9 /* AccountSettingsRemoteTests.swift in Sources */, 59FBD5621B5684F300734466 /* ThemeServiceTests.m in Sources */, 85F8E19D1B018698000859BB /* PushAuthenticationServiceTests.swift in Sources */, 931D270019EDAE8600114F17 /* CoreDataMigrationTests.m in Sources */, E6B9B8AA1B94E1FE0001B92F /* ReaderPostTest.m in Sources */, 85D239C11AE5A7020074768D /* LoginViewModelTests.m in Sources */, 85D790AC1AE5D95E0033AE83 /* MixpanelProxyTests.m in Sources */, + E1A0AC821C560F3A00070E2B /* RxTests.swift in Sources */, 85F8E19F1B0186D0000859BB /* MockWordPressComApi.swift in Sources */, 931D26F619ED7F7000114F17 /* BlogServiceTest.m in Sources */, 852416D21A12ED690030700C /* AppRatingUtilityTests.m in Sources */, diff --git a/WordPress/WordPressTest/AccountSettingsRemoteTests.swift b/WordPress/WordPressTest/AccountSettingsRemoteTests.swift new file mode 100644 index 000000000000..5e2c579913bd --- /dev/null +++ b/WordPress/WordPressTest/AccountSettingsRemoteTests.swift @@ -0,0 +1,128 @@ +import XCTest +import Nimble +import OHHTTPStubs +import RxSwift +@testable import WordPress + +class AccountSettingsRemoteTests: XCTestCase { + + override func setUp() { + super.setUp() + } + + override func tearDown() { + // It should be already empty if we did memory management right + // But let's be safe + AccountSettingsRemote.remotes.removeAllObjects() + OHHTTPStubs.removeAllStubs() + + super.tearDown() + } + + func testRemoteWithApiDoesntDuplicateRemotes() { + let api = WordPressComApi(OAuthToken: "authtoken1") + let remote1 = AccountSettingsRemote.remoteWithApi(api) + let remote2 = AccountSettingsRemote.remoteWithApi(api) + expect(remote1).to(beIdenticalTo(remote2)) + expect(remote1.settings).to(beIdenticalTo(remote2.settings)) + + let duplicatedApi = WordPressComApi(OAuthToken: "authtoken1") + let remote3 = AccountSettingsRemote.remoteWithApi(duplicatedApi) + expect(remote1).to(beIdenticalTo(remote3)) + } + + func testSettingsSuccessful() { + stub(isGetSettings()) { request in + let stubPath = OHPathForFile("get-me-settings-v1.1.json", self.dynamicType) + return fixture(stubPath!, headers: ["Content-Type": "application/json"]) + } + + let events = subscribeToSettingsAndWait() + + expect(events.count).to(equal(2)) + expect(events[0].element).toNot(beNil()) + expect(events[1].isCompleted).to(beTrue()) + guard let settings = events[0].element else { + XCTFail("First emitted value should be settings") + return + } + expect(settings.firstName).to(equal("Jorge")) + expect(settings.lastName).to(equal("Bernal")) + expect(settings.displayName).to(equal("Jorge Bernal")) + expect(settings.aboutMe).to(equal("A description of me")) + } + + func testSettingsFail() { + stub(isGetSettings()) { request in + let error = NSError(domain: NSURLErrorDomain, code: NSURLErrorTimedOut, userInfo: nil) + return OHHTTPStubsResponse(error: error) + } + + let events = subscribeToSettingsAndWait() + + expect(events.count).to(equal(1)) + expect(events[0].isError).to(beTrue()) + } + + // MARK: - Helpers + + func settingsObservable() -> Observable { + let api = WordPressComApi(OAuthToken: "authtoken") + let remote = AccountSettingsRemote(api: api) + return remote.settings + } + + func subscribeToSettingsAndWait() -> [Event] { + var events = [Event]() + let expectation = expectationWithDescription("settings completed or errored") + let subscription = settingsObservable().subscribe { (event) -> Void in + events.append(event) + + switch event { + case .Next(_): + break + case .Completed, .Error(_): + expectation.fulfill() + } + } + defer { + subscription.dispose() + } + waitForExpectationsWithTimeout(5, handler: nil) + return events + } + + func isGetSettings() -> OHHTTPStubsTestBlock { + return isMethodGET() && isMeSettingsEndpoint() + } + + func isUpdateSettings() -> OHHTTPStubsTestBlock { + return isMethodPOST() && isMeSettingsEndpoint() + } + + func isMeSettingsEndpoint() -> OHHTTPStubsTestBlock { + return { request in + return request.URL?.path?.hasSuffix("me/settings") ?? false + } + } + + +} + +extension Event { + private var isCompleted: Bool { + if case .Completed = self { + return true + } else { + return false + } + } + + private var isError: Bool { + if case .Error = self { + return true + } else { + return false + } + } +} diff --git a/WordPress/WordPressTest/AccountSettingsServiceTests.swift b/WordPress/WordPressTest/AccountSettingsServiceTests.swift new file mode 100644 index 000000000000..0c08c0e22414 --- /dev/null +++ b/WordPress/WordPressTest/AccountSettingsServiceTests.swift @@ -0,0 +1,309 @@ +import XCTest +import RxSwift +import RxTests +@testable import WordPress + +class AccountSettingsServiceTests: XCTestCase { + struct TestData { + static let sampleSettings = AccountSettings( + firstName: "Jorge", + lastName: "Bernal", + displayName: "Jorge Bernal", + aboutMe: "A description about me", + username: "koketest", + email: "koke@example.com", + primarySiteID: 16764956, + webAddress: "http://koke.me", + language: "es" + ) + } + + override func setUp() { + super.setUp() + // Put setup code here. This method is called before the invocation of each test method in the class. + } + + override func tearDown() { + // Put teardown code here. This method is called after the invocation of each test method in the class. + super.tearDown() + } + + // MARK: - request + + func testRequestSuccessful() { + let scheduler = TestScheduler(initialClock: 0, resolution: 0.01) + let remoteSettings: TestableObservable = scheduler.createColdObservable([ + next(10, TestData.sampleSettings), + completed(10) + ]) + let mockRemote = MockAccountSettingsRemote() + mockRemote.settings = remoteSettings.asObservable() + + let service = AccountSettingsService(userID: 123, remote: mockRemote) + service.testScheduler = scheduler + + let res = scheduler.start { + service.request + } + + XCTAssertEqual(remoteSettings.subscriptions.count, 1) + XCTAssertEqual(res.events, [ + next(200, .Refreshing), + next(210, .Idle), + completed(210) + ]) + } + + func testRequestOneNetworkErrorShouldRetry() { + let scheduler = TestScheduler(initialClock: 0, resolution: 0.01) + let mockRemote = MockAccountSettingsRemote() + var requestCount = 0 + mockRemote.settings = Observable.create { observer in + requestCount += 1 + return scheduler.scheduleRelativeVirtual(requestCount, dueTime: 10, action: { _ in + if requestCount == 1 { + let error = NSError(domain: NSURLErrorDomain, code: NSURLErrorNetworkConnectionLost, userInfo: nil) + observer.on(.Error(error)) + } else { + observer.on(.Next(TestData.sampleSettings)) + observer.on(.Completed) + } + return NopDisposable.instance + }) + } + + let service = AccountSettingsService(userID: 123, remote: mockRemote) + service.testScheduler = scheduler + + let res = scheduler.start { + service.request + } + + XCTAssertEqual(requestCount, 2) + XCTAssertEqual(res.events, [ + next(200, .Refreshing), + next(220, .Idle), + completed(220) + ]) + } + + func testRequestFourNetworkErrorsShouldFail() { + let scheduler = TestScheduler(initialClock: 0, resolution: 0.01) + let connectionLost = NSError(domain: NSURLErrorDomain, code: NSURLErrorNetworkConnectionLost, userInfo: nil) + let remoteSettings: TestableObservable = scheduler.createColdObservable([ + error(10, connectionLost) + ]) + let mockRemote = MockAccountSettingsRemote() + mockRemote.settings = remoteSettings.asObservable() + + let service = AccountSettingsService(userID: 123, remote: mockRemote) + service.testScheduler = scheduler + + let res = scheduler.start { + service.request + } + + XCTAssertEqual(remoteSettings.subscriptions.count, 3) + XCTAssertEqual(res.events, [ + next(200, .Refreshing), + error(230, connectionLost) + ]) + } + + func testRequestUnrecoverableErrorsShouldFailImmediately() { + let scheduler = TestScheduler(initialClock: 0, resolution: 0.01) + let unexpected = NSError(domain: "Unexpected", code: -999, userInfo: nil) + let remoteSettings: TestableObservable = scheduler.createColdObservable([ + error(10, unexpected) + ]) + let mockRemote = MockAccountSettingsRemote() + mockRemote.settings = remoteSettings.asObservable() + + let service = AccountSettingsService(userID: 123, remote: mockRemote) + service.testScheduler = scheduler + + let res = scheduler.start { + service.request + } + + XCTAssertEqual(remoteSettings.subscriptions.count, 1) + XCTAssertEqual(res.events, [ + next(200, .Refreshing), + error(210, unexpected) + ]) + } + + func testRequestEmitsStalledValue() { + let scheduler = TestScheduler(initialClock: 0, resolution: 0.01) + let remoteSettings: TestableObservable = scheduler.createColdObservable([ + next(500, TestData.sampleSettings), + completed(500) + ]) + let mockRemote = MockAccountSettingsRemote() + mockRemote.settings = remoteSettings.asObservable() + + let service = AccountSettingsService(userID: 123, remote: mockRemote) + service.testScheduler = scheduler + + let res = scheduler.start { + service.request + } + + XCTAssertEqual(remoteSettings.subscriptions.count, 1) + XCTAssertEqual(res.events, [ + next(200, .Refreshing), + next(600, .Stalled), + next(700, .Idle), + completed(700) + ]) + } + + // MARK: - refresh + + func testRefreshRepeatsSuccessfulRequest() { + let scheduler = TestScheduler(initialClock: 0, resolution: 0.1) + let remoteSettings: TestableObservable = scheduler.createColdObservable([ + next(10, TestData.sampleSettings), + completed(10) + ]) + let mockRemote = MockAccountSettingsRemote() + mockRemote.settings = remoteSettings.asObservable() + + let service = AccountSettingsService(userID: 123, remote: mockRemote) + service.testScheduler = scheduler + + let res = scheduler.start { + service.refresh + } + + XCTAssertEqual(remoteSettings.subscriptions.count, 2) + XCTAssertEqual(res.events, [ + next(200, .Refreshing), + next(210, .Idle), + next(800, .Refreshing), + next(810, .Idle) + ]) + } + + func testRefreshDoesntRepeatFailedRequest() { + let scheduler = TestScheduler(initialClock: 0, resolution: 0.1) + let unexpected = NSError(domain: "Unexpected", code: -999, userInfo: nil) + let remoteSettings: TestableObservable = scheduler.createColdObservable([ + error(10, unexpected) + ]) + let mockRemote = MockAccountSettingsRemote() + mockRemote.settings = remoteSettings.asObservable() + + let service = AccountSettingsService(userID: 123, remote: mockRemote) + service.testScheduler = scheduler + + let res = scheduler.start { + service.refresh + } + + XCTAssertEqual(remoteSettings.subscriptions.count, 1) + XCTAssertEqual(res.events, [ + next(200, .Refreshing), + error(210, unexpected) + ]) + } + + func testRefreshDoesntRequestIfUnreachable() { + let scheduler = TestScheduler(initialClock: 0, resolution: 0.1) + let remoteSettings: TestableObservable = scheduler.createColdObservable([ + next(10, TestData.sampleSettings), + completed(10) + ]) + let mockRemote = MockAccountSettingsRemote() + mockRemote.settings = remoteSettings.asObservable() + + let service = AccountSettingsService(userID: 123, remote: mockRemote) + service.testScheduler = scheduler + service.testReachability = Observable.create { observer in + return scheduler.scheduleAbsoluteVirtual((), time: 200, action: { _ in + observer.on(.Next(false)) + return NopDisposable.instance + }) + } + + let res = scheduler.start { + service.refresh + } + + XCTAssertEqual(remoteSettings.subscriptions.count, 0) + XCTAssertEqual(res.events, [ + next(200, .Offline), + ]) + + } + + func testRefreshRetriesWhenReachable() { + let scheduler = TestScheduler(initialClock: 0, resolution: 0.1) + let remoteSettings: TestableObservable = scheduler.createColdObservable([ + next(10, TestData.sampleSettings), + completed(10) + ]) + let mockRemote = MockAccountSettingsRemote() + mockRemote.settings = remoteSettings.asObservable() + + let service = AccountSettingsService(userID: 123, remote: mockRemote) + service.testScheduler = scheduler + service.testReachability = scheduler.createColdObservable([ + next(0, false), + next(300, true), + next(600, false) + ]).asObservable() + + let res = scheduler.start { + service.refresh + } + + XCTAssertEqual(remoteSettings.subscriptions.count, 1) + XCTAssertEqual(res.events, [ + next(200, .Offline), + next(500, .Refreshing), + next(510, .Idle), + next(800, .Offline) + ]) + } + + func testRefreshDoesntRepeatFailedRequestAfterReachable() { + let scheduler = TestScheduler(initialClock: 0, resolution: 0.1) + let unexpected = NSError(domain: "Unexpected", code: -999, userInfo: nil) + let remoteSettings: TestableObservable = scheduler.createColdObservable([ + error(10, unexpected) + ]) + let mockRemote = MockAccountSettingsRemote() + mockRemote.settings = remoteSettings.asObservable() + + let service = AccountSettingsService(userID: 123, remote: mockRemote) + service.testScheduler = scheduler + service.testReachability = scheduler.createColdObservable([ + next(0, false), + next(300, true), + next(600, false) + ]).asObservable() + + let res = scheduler.start { + service.refresh + } + + XCTAssertEqual(remoteSettings.subscriptions.count, 1) + XCTAssertEqual(res.events, [ + next(200, .Offline), + next(500, .Refreshing), + error(510, unexpected) + ]) + } + +} + +class MockAccountSettingsRemote: AccountSettingsRemoteInterface { + var settings: Observable = Observable.never() + + var mockUpdateSetting: (AccountSettingsChange, () -> Void, ErrorType -> Void) -> Void = { _, _, _ in } + + func updateSetting(change: AccountSettingsChange, success: () -> Void, failure: ErrorType -> Void) { + mockUpdateSetting(change, success, failure) + } +} diff --git a/WordPress/WordPressTest/Extensions/RxTests.swift b/WordPress/WordPressTest/Extensions/RxTests.swift new file mode 100644 index 000000000000..a3927a04aa7d --- /dev/null +++ b/WordPress/WordPressTest/Extensions/RxTests.swift @@ -0,0 +1,171 @@ +import XCTest +import RxSwift +import RxTests +@testable import WordPress + +class RxTests: XCTestCase { + + override func setUp() { + super.setUp() + // Put setup code here. This method is called before the invocation of each test method in the class. + } + + override func tearDown() { + // Put teardown code here. This method is called after the invocation of each test method in the class. + super.tearDown() + } + + func testPausable_simple1() { + let scheduler = TestScheduler(initialClock: 0) + + let xs = scheduler.createHotObservable([ + next(90, 1), + next(180, 2), + next(250, 3), + next(260, 4), + next(310, 5), + next(360, 6), + completed(390) + ]) + + let ys = scheduler.createHotObservable([ + next(210, true), + next(300, false), + next(350, true), + completed(400) + ]) + + let res = scheduler.start { + xs.pausable(ys) + } + + XCTAssertEqual(res.events, [ + next(250, 3), + next(260, 4), + next(360, 6), + completed(390) + ]) + + XCTAssertEqual(xs.subscriptions, [ + Subscription(210, 300), + Subscription(350, 390) + ]) + + XCTAssertEqual(ys.subscriptions, [ + Subscription(200, 390) + ]) + + } + + func testPausable_PauserCompleteContinuesEmittingIfLastValueTrue() { + let scheduler = TestScheduler(initialClock: 0) + + let xs = scheduler.createHotObservable([ + next(90, 1), + next(180, 2), + next(250, 3), + next(260, 4), + next(310, 5), + next(360, 6), + completed(390) + ]) + + let ys = scheduler.createHotObservable([ + next(290, true), + completed(300) + ]) + + let res = scheduler.start { + xs.pausable(ys) + } + + XCTAssertEqual(res.events, [ + next(310, 5), + next(360, 6), + completed(390) + ]) + + XCTAssertEqual(xs.subscriptions, [ + Subscription(290, 390) + ]) + + XCTAssertEqual(ys.subscriptions, [ + Subscription(200, 300) + ]) + + } + + func testPausable_PauserCompleteDoesntEmitIfLastValueFalse() { + let scheduler = TestScheduler(initialClock: 0) + + let xs = scheduler.createHotObservable([ + next(90, 1), + next(180, 2), + next(250, 3), + next(260, 4), + next(310, 5), + next(360, 6), + completed(390) + ]) + + let ys = scheduler.createHotObservable([ + next(240, true), + next(290, false), + completed(320) + ]) + + let res = scheduler.start { + xs.pausable(ys) + } + + XCTAssertEqual(res.events, [ + next(250, 3), + next(260, 4), + ]) + + XCTAssertEqual(xs.subscriptions, [ + Subscription(240, 290) + ]) + + XCTAssertEqual(ys.subscriptions, [ + Subscription(200, 320) + ]) + + } + + func testRetryIf() { + let scheduler = TestScheduler(initialClock: 0) + + let xs = scheduler.createColdObservable([ + next(10, 1), + next(20, 2), + error(30, testError) + ]) + + let res = scheduler.start { + xs.retryIf({ (count, lastError) -> Bool in + return count < 3 + }) + } + + let correct = [ + next(210, 1), + next(220, 2), + next(240, 1), + next(250, 2), + next(270, 1), + next(280, 2), + error(290, testError) + ] + + XCTAssertEqual(res.events, correct) + + XCTAssertEqual(xs.subscriptions, [ + Subscription(200, 230), + Subscription(230, 260), + Subscription(260, 290) + ]) + } + + let testError = NSError(domain: "dummyError", code: -232, userInfo: nil) +} diff --git a/WordPress/WordPressTest/Test Data/get-me-settings-v1.1.json b/WordPress/WordPressTest/Test Data/get-me-settings-v1.1.json new file mode 100644 index 000000000000..fb4137598ad4 --- /dev/null +++ b/WordPress/WordPressTest/Test Data/get-me-settings-v1.1.json @@ -0,0 +1,33 @@ +{ + "enable_translator": true, + "surprise_me": true, + "post_post_flag": true, + "holidaysnow": true, + "user_login": "koketest", + "password": "", + "display_name": "Jorge Bernal", + "first_name": "Jorge", + "last_name": "Bernal", + "description": "A description of me", + "user_email": "koke@example.com", + "user_email_change_pending": false, + "new_user_email": "", + "user_URL": "http:\/\/koke.me", + "language": "es", + "avatar_URL": "https:\/\/2.gravatar.com\/avatar\/e0ebc5cd3f08c8f7cf3e4a0e703fedee?s=200&d=mm", + "primary_site_ID": 16764956, + "comment_like_notification": true, + "mentions_notification": true, + "subscription_delivery_email_default": "never", + "subscription_delivery_jabber_default": false, + "subscription_delivery_mail_option": "html", + "subscription_delivery_day": 1, + "subscription_delivery_hour": 6, + "subscription_delivery_email_blocked": false, + "two_step_enabled": false, + "two_step_sms_enabled": false, + "two_step_backup_codes_printed": false, + "two_step_sms_country": "ES", + "two_step_sms_phone_number": "600123456", + "user_login_can_be_changed": true, +} \ No newline at end of file