diff --git a/account/build.gradle b/account/build.gradle index 9499119..ec043e7 100644 --- a/account/build.gradle +++ b/account/build.gradle @@ -31,7 +31,11 @@ buildscript { dependencies { implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version" + + api 'io.reactivex.rxjava2:rxjava:2.1.9' + testCompile 'junit:junit:4.12' + testImplementation 'org.amshove.kluent:kluent:1.34' } sourceCompatibility = '1.7' diff --git a/account/src/main/java/io/github/novacrypto/account/AccountModel.kt b/account/src/main/java/io/github/novacrypto/account/AccountModel.kt new file mode 100644 index 0000000..5fccd3c --- /dev/null +++ b/account/src/main/java/io/github/novacrypto/account/AccountModel.kt @@ -0,0 +1,45 @@ +/* + * NovaWallet, Cryptocurrency Wallet for Android + * Copyright (C) 2018 Alan Evans, NovaCrypto + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * Original source: https://github.com/NovaCrypto/NovaWallet + * You can contact the authors via github issues. + */ + +package io.github.novacrypto.account + +import io.reactivex.Observable +import java.util.* + +data class AccountId(val uuid: UUID = UUID.randomUUID()) + +data class AccountModel( + val id: AccountId = AccountId(), + val name: String = "" +) + +fun Observable.toAccountModelStream(): Observable { + return this.scan(AccountModel(), + { model: AccountModel, intent: AccountModelIntent -> + accountModelReducer(intent, model) + }) +} + +fun accountModelReducer(intent: AccountModelIntent, model: AccountModel): AccountModel { + return when (intent) { + is AccountModelIntent.Rename -> model.copy(name = intent.name) + } +} diff --git a/account/src/main/java/io/github/novacrypto/account/AccountModelIntents.kt b/account/src/main/java/io/github/novacrypto/account/AccountModelIntents.kt new file mode 100644 index 0000000..c6dfc88 --- /dev/null +++ b/account/src/main/java/io/github/novacrypto/account/AccountModelIntents.kt @@ -0,0 +1,44 @@ +/* + * NovaWallet, Cryptocurrency Wallet for Android + * Copyright (C) 2018 Alan Evans, NovaCrypto + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * Original source: https://github.com/NovaCrypto/NovaWallet + * You can contact the authors via github issues. + */ + +package io.github.novacrypto.account + +sealed class AccountModelIntent { + class Rename(val name: String) : AccountModelIntent() +} + +fun AccountModel.toAddIntent() = + UserModelIntent.AddAccountIntent(this).forward() + +fun AccountModel.toInsertIntent(index: Int) = + UserModelIntent.AddAccountIntent(this, index).forward() + +fun AccountModel.toRemoveIntent() = + UserModelIntent.RemoveAccountIntent(this.id).forward() + +fun AccountModel.toRenameIntent(newName: String) = + renameAccount(newName).toUserModelIntent(this).forward() + +fun renameAccount(newName: String) = + AccountModelIntent.Rename(newName) + +fun AccountModelIntent.toUserModelIntent(account: AccountModel) = + UserModelIntent.ForwardAccountModelIntent(account.id, this) diff --git a/account/src/main/java/io/github/novacrypto/account/ErrorModel.kt b/account/src/main/java/io/github/novacrypto/account/ErrorModel.kt new file mode 100644 index 0000000..576e823 --- /dev/null +++ b/account/src/main/java/io/github/novacrypto/account/ErrorModel.kt @@ -0,0 +1,28 @@ +/* + * NovaWallet, Cryptocurrency Wallet for Android + * Copyright (C) 2018 Alan Evans, NovaCrypto + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * Original source: https://github.com/NovaCrypto/NovaWallet + * You can contact the authors via github issues. + */ + +package io.github.novacrypto.account + +data class ErrorModel( + val errorMessage: String +) + +class UserReportableException(message: String) : Exception(message) diff --git a/account/src/main/java/io/github/novacrypto/account/UserModel.kt b/account/src/main/java/io/github/novacrypto/account/UserModel.kt new file mode 100644 index 0000000..b135bba --- /dev/null +++ b/account/src/main/java/io/github/novacrypto/account/UserModel.kt @@ -0,0 +1,37 @@ +/* + * NovaWallet, Cryptocurrency Wallet for Android + * Copyright (C) 2018 Alan Evans, NovaCrypto + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * Original source: https://github.com/NovaCrypto/NovaWallet + * You can contact the authors via github issues. + */ + +package io.github.novacrypto.account + +data class UserModel( + val accounts: List = emptyList() +) + +sealed class UserModelIntent { + class AddAccountIntent(val account: AccountModel, val index: Int? = null) : UserModelIntent() + class RemoveAccountIntent(val accountId: AccountId) : UserModelIntent() + class ForwardAccountModelIntent( + val accountId: AccountId, + val intent: AccountModelIntent + ) : UserModelIntent() +} + +fun UserModelIntent.forward() = UserModelViewStateIntent.ForwardUserModelIntent(this) diff --git a/account/src/main/java/io/github/novacrypto/account/UserModelViewState.kt b/account/src/main/java/io/github/novacrypto/account/UserModelViewState.kt new file mode 100644 index 0000000..28ff510 --- /dev/null +++ b/account/src/main/java/io/github/novacrypto/account/UserModelViewState.kt @@ -0,0 +1,110 @@ +/* + * NovaWallet, Cryptocurrency Wallet for Android + * Copyright (C) 2018 Alan Evans, NovaCrypto + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * Original source: https://github.com/NovaCrypto/NovaWallet + * You can contact the authors via github issues. + */ + +package io.github.novacrypto.account + +import io.reactivex.Observable + +data class UserModelViewState( + val userModel: UserModel = UserModel(), + val error: ErrorModel? = null, + val undoModel: UserModelViewStateUndoModel? = null +) + +data class UserModelViewStateUndoModel( + val message: String, + val undoIntent: UserModelViewStateIntent +) + +fun Observable.toUserModelStream(): Observable { + return this.scan(UserModelViewState(), + { model: UserModelViewState, intent: UserModelViewStateIntent -> + model.transformWithIntent(intent) + }) +} + +private fun UserModelViewState.transformWithIntent(intent: UserModelViewStateIntent): UserModelViewState { + return when (intent) { + is UserModelViewStateIntent.DismissError -> copy(error = null) + is UserModelViewStateIntent.ForwardUserModelIntent -> + try { + val (newModel, undoModel) = userModel.transformWithIntent(intent.userModelIntent) + copy(userModel = newModel, undoModel = undoModel) + } catch (e: UserReportableException) { + copy(error = ErrorModel(e.message ?: "")) + } + is UserModelViewStateIntent.Undo -> + undoModel?.undoIntent?.let { + transformWithIntent(it + ) + } ?: this + } +} + +private fun UserModel.transformWithIntent(intent: UserModelIntent) = + when (intent) { + is UserModelIntent.AddAccountIntent -> { + copy(accounts = if (intent.index != null) { + accounts.insert(intent.account, intent.index) + } else { + accounts + intent.account + }).withNoUndo() + } + is UserModelIntent.RemoveAccountIntent -> { + withAccountAndIndexForId(intent.accountId) { account, index -> + Pair(copy(accounts = accounts - account), + UserModelViewStateUndoModel("Removed account '${account.name}'", + account.toInsertIntent(index))) + } + } + is UserModelIntent.ForwardAccountModelIntent -> { + withAccountAndIndexForId(intent.accountId) { account, index -> + val newAccounts = accounts.replace(index, accountModelReducer(intent.intent, account)) + copy(accounts = newAccounts).withNoUndo() + } + } + } + +private fun UserModel.withAccountAndIndexForId(accountId: AccountId, transform: (account: AccountModel, index: Int) -> T): T { + val accountIdx = accounts.indexOfFirst { it.id == accountId }.also { + if (it == -1) throw cannotFind(accountId) + } + val existingAccount = accounts[accountIdx] + return transform(existingAccount, accountIdx) +} + +private fun UserModel.withNoUndo() = Pair(this, null) + +private inline fun List.copyAndModify(modify: MutableList.() -> Unit): List = + toMutableList().apply(modify) + +private fun List.insert(newItem: E, index: Int) = + copyAndModify { + this.add(index, newItem) + } + +private fun List.replace(index: Int, replacement: E) = + copyAndModify { + this[index] = replacement + } + +private fun cannotFind(accountId: AccountId) = + UserReportableException("Cannot find account $accountId") diff --git a/account/src/main/java/io/github/novacrypto/account/UserModelViewStateIntents.kt b/account/src/main/java/io/github/novacrypto/account/UserModelViewStateIntents.kt new file mode 100644 index 0000000..1586e98 --- /dev/null +++ b/account/src/main/java/io/github/novacrypto/account/UserModelViewStateIntents.kt @@ -0,0 +1,32 @@ +/* + * NovaWallet, Cryptocurrency Wallet for Android + * Copyright (C) 2018 Alan Evans, NovaCrypto + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * Original source: https://github.com/NovaCrypto/NovaWallet + * You can contact the authors via github issues. + */ + +package io.github.novacrypto.account + +sealed class UserModelViewStateIntent { + class DismissError : UserModelViewStateIntent() + class Undo : UserModelViewStateIntent() + class ForwardUserModelIntent( + val userModelIntent: UserModelIntent + ) : UserModelViewStateIntent() +} + +fun dismissError() = UserModelViewStateIntent.DismissError() diff --git a/account/src/test/java/io/github/novacrypto/AccountModelTests.kt b/account/src/test/java/io/github/novacrypto/AccountModelTests.kt new file mode 100644 index 0000000..ee0098e --- /dev/null +++ b/account/src/test/java/io/github/novacrypto/AccountModelTests.kt @@ -0,0 +1,67 @@ +/* + * NovaWallet, Cryptocurrency Wallet for Android + * Copyright (C) 2018 Alan Evans, NovaCrypto + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * Original source: https://github.com/NovaCrypto/NovaWallet + * You can contact the authors via github issues. + */ + +package io.github.novacrypto + +import io.github.novacrypto.account.AccountModel +import io.github.novacrypto.account.renameAccount +import io.github.novacrypto.account.toAccountModelStream +import io.reactivex.Observable +import org.amshove.kluent.`should be` +import org.amshove.kluent.`should equal` +import org.amshove.kluent.`should not equal` +import org.junit.Test + +class AccountModelTests { + + @Test + fun `new account gets new id`() { + AccountModel().id `should not equal` AccountModel().id + } + +} + +class AccountModelRenameIntentTests { + + @Test + fun `user can rename account`() { + Observable.just( + renameAccount("My Account") + ).toAccountModelStream() + .assertWithLastElement { + it.name `should equal` "My Account" + } + } + + @Test + fun `user can rename account twice`() { + Observable.just( + renameAccount("My Account"), + renameAccount("My Account 2") + ).toAccountModelStream() + .assertWithLastElement { + it.name `should equal` "My Account 2" + } + .assertWithLastTwoElements { penultimate, ultimate -> + penultimate.id `should be` ultimate.id + } + } +} \ No newline at end of file diff --git a/account/src/test/java/io/github/novacrypto/RxTestUtils.kt b/account/src/test/java/io/github/novacrypto/RxTestUtils.kt new file mode 100644 index 0000000..ad6f0ea --- /dev/null +++ b/account/src/test/java/io/github/novacrypto/RxTestUtils.kt @@ -0,0 +1,59 @@ +/* + * NovaWallet, Cryptocurrency Wallet for Android + * Copyright (C) 2018 Alan Evans, NovaCrypto + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * Original source: https://github.com/NovaCrypto/NovaWallet + * You can contact the authors via github issues. + */ + +package io.github.novacrypto + +import io.reactivex.Observable +import org.amshove.kluent.`should be` + +fun Observable.assertWithLastElement(function: (T) -> Unit): Observable { + this.lastOrError() + .test() + .assertValue { + function(it) + true + } + return this +} + +fun Observable.assertWithLastTwoElements( + function: (penultimate: T, ultimate: T) -> Unit +) { + this.takeLast(2) + .test() + .values() + .also { + it.size `should be` 2 + function(it[0], it[1]) + } +} + +fun Observable.assertWithLastAndThirdToLastElements( + function: (lastMinusTwo: T, last: T) -> Unit +) { + this.takeLast(3) + .test() + .values() + .also { + it.size `should be` 3 + function(it[0], it[2]) + } +} diff --git a/account/src/test/java/io/github/novacrypto/UserModelIntentTests.kt b/account/src/test/java/io/github/novacrypto/UserModelIntentTests.kt new file mode 100644 index 0000000..afcc137 --- /dev/null +++ b/account/src/test/java/io/github/novacrypto/UserModelIntentTests.kt @@ -0,0 +1,349 @@ +/* + * NovaWallet, Cryptocurrency Wallet for Android + * Copyright (C) 2018 Alan Evans, NovaCrypto + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * Original source: https://github.com/NovaCrypto/NovaWallet + * You can contact the authors via github issues. + */ + +package io.github.novacrypto + +import io.github.novacrypto.account.* +import io.reactivex.Observable +import org.amshove.kluent.`should be` +import org.amshove.kluent.`should equal` +import org.amshove.kluent.`should not be` +import org.junit.Test + +class UserModelIntentTests { + + @Test + fun `initial user state`() { + Observable.empty() + .toUserModelStream() + .assertWithLastElement { + it.userModel.accounts `should equal` emptyList() + } + } + + @Test + fun `user can add an account`() { + val account = AccountModel() + Observable.just(account.toAddIntent()) + .toUserModelStream() + .assertWithLastElement { + it.userModel.accounts `should equal` listOf(account) + } + } + + @Test + fun `user can add two accounts`() { + val account1 = AccountModel() + val account2 = AccountModel() + Observable.just(account1.toAddIntent(), account2.toAddIntent()) + .toUserModelStream() + .assertWithLastElement { + it.userModel.accounts `should equal` listOf(account1, account2) + } + } +} + +class UserModelRemoveAccountIntentTests { + + @Test + fun `user can remove only account`() { + val account = AccountModel() + Observable.just( + account.toAddIntent(), + account.toRemoveIntent() + ).toUserModelStream() + .assertWithLastElement { + it.userModel.accounts `should equal` emptyList() + } + } + + @Test + fun `user can remove only account after rename`() { + val account = AccountModel() + Observable.just( + account.toAddIntent(), + account.toRenameIntent("Account 1"), + account.toRemoveIntent() + ).toUserModelStream() + .assertWithLastElement { + it.userModel.accounts `should equal` emptyList() + } + } + + @Test + fun `user can't remove account not added`() { + val account = AccountModel() + Observable.just( + account.toRemoveIntent() + ).toUserModelStream() + .assertWithLastElement { + it.error!!.errorMessage `should equal` "Cannot find account " + account.id + } + } + + @Test + fun `user can remove first of two accounts`() { + val account1 = AccountModel() + val account2 = AccountModel() + Observable.just( + account1.toAddIntent(), + account2.toAddIntent(), + account1.toRemoveIntent() + ).toUserModelStream() + .assertWithLastElement { + it.userModel.accounts `should equal` listOf(account2) + } + } + + @Test + fun `user can remove second of two accounts`() { + val account1 = AccountModel() + val account2 = AccountModel() + Observable.just( + account1.toAddIntent(), + account2.toAddIntent(), + account2.toRemoveIntent() + ).toUserModelStream() + .assertWithLastElement { + it.userModel.accounts `should equal` listOf(account1) + } + } +} + +class AccountModelRenameUserIntentTests { + + @Test + fun `user can rename account in user model`() { + val account = AccountModel() + Observable.just( + account.toAddIntent(), + account.toRenameIntent("My Account") + ).toUserModelStream() + .assertWithLastElement { + it.userModel.accounts.single().name `should equal` "My Account" + } + } + + @Test + fun `user can rename twos account in the user model`() { + val account1 = AccountModel() + val account2 = AccountModel() + Observable.just( + account1.toAddIntent(), + account2.toAddIntent(), + account1.toRenameIntent("My Account 1"), + account2.toRenameIntent("My Account 2") + ).toUserModelStream() + .assertWithLastElement { + it.userModel.accounts.size `should equal` 2 + it.userModel.accounts[0].name `should equal` "My Account 1" + it.userModel.accounts[1].name `should equal` "My Account 2" + } + } + + @Test + fun `user can rename twos account in the user model in reverse order`() { + val account1 = AccountModel() + val account2 = AccountModel() + Observable.just( + account1.toAddIntent(), + account2.toAddIntent(), + account2.toRenameIntent("My Account 2"), + account1.toRenameIntent("My Account 1") + ).toUserModelStream() + .assertWithLastElement { + it.userModel.accounts.size `should equal` 2 + it.userModel.accounts[0].name `should equal` "My Account 1" + it.userModel.accounts[1].name `should equal` "My Account 2" + } + } + + @Test + fun `user can rename one account twice`() { + val account1 = AccountModel() + Observable.just( + account1.toAddIntent(), + account1.toRenameIntent("My Account A"), + account1.toRenameIntent("My Account B") + ).toUserModelStream() + .assertWithLastElement { + it.userModel.accounts.single().name `should equal` "My Account B" + } + } + + @Test + fun `user cant rename account not in list yet`() { + val account1 = AccountModel() + Observable.just( + account1.toRenameIntent("My Account A"), + account1.toAddIntent() + ).toUserModelStream() + .assertWithLastElement { + it.userModel.accounts.single().name `should equal` "" + } + } + + @Test + fun `rename of missing account results in error`() { + val account = AccountModel() + Observable.just( + account.toRenameIntent("My Account A") + ).toUserModelStream() + .assertWithLastElement { + it.error!!.errorMessage `should equal` "Cannot find account " + account.id + } + } + + @Test + fun `second error replaces first`() { + val account1 = AccountModel() + val account2 = AccountModel() + Observable.just( + account1.toRenameIntent("My Account A"), + account2.toRenameIntent("My Account A") + ).toUserModelStream() + .assertWithLastElement { + it.error!!.errorMessage `should equal` "Cannot find account " + account2.id + } + } + + @Test + fun `error is not automatically dismissed by another action`() { + val account = AccountModel() + Observable.just( + account.toRenameIntent("My Account A"), + account.toAddIntent() + ).toUserModelStream() + .assertWithLastElement { + it.error `should not be` null + } + } + + @Test + fun `error should keep the previous state`() { + val account = AccountModel() + val account2 = AccountModel() + Observable.just( + account.toAddIntent(), + account2.toRenameIntent("My Account A") + ).toUserModelStream() + .assertWithLastElement { + it.error `should not be` null + } + .assertWithLastTwoElements { penultimate, ultimate -> + penultimate.userModel `should be` ultimate.userModel + } + } + + @Test + fun `user can dismiss error`() { + val account = AccountModel() + val account2 = AccountModel() + Observable.just( + account.toAddIntent(), + account2.toRenameIntent("My Account A"), + dismissError() + ).toUserModelStream() + .assertWithLastElement { + it.error `should be` null + } + .assertWithLastAndThirdToLastElements { lastMinusTwo, last -> + lastMinusTwo.userModel `should be` last.userModel + } + } + +} + +class UserModelUndo { + + @Test + fun `user gets an undo message`() { + val account = AccountModel() + Observable.just( + account.toAddIntent(), + account.toRenameIntent("My Account A"), + account.toRemoveIntent() + ).toUserModelStream() + .assertWithLastElement { + it.undoModel!!.message `should equal` "Removed account 'My Account A'" + } + } + + @Test + fun `user can undo a remove`() { + val account = AccountModel() + Observable.just( + account.toAddIntent(), + account.toRenameIntent("My Account A"), + account.toRemoveIntent(), + undo() + ).toUserModelStream() + .assertWithLastAndThirdToLastElements { lastMinusTwo, last -> + lastMinusTwo.userModel.accounts `should equal` last.userModel.accounts + } + } + + @Test + fun `an undone remove is in the same position it was before (index 0)`() { + val account1 = AccountModel() + val account2 = AccountModel() + Observable.just( + account1.toAddIntent(), + account2.toAddIntent(), + account1.toRemoveIntent(), + undo() + ).toUserModelStream() + .assertWithLastAndThirdToLastElements { lastMinusTwo, last -> + lastMinusTwo.userModel.accounts `should equal` last.userModel.accounts + } + } + + @Test + fun `an undone remove is in the same position it was before (index 1)`() { + val account1 = AccountModel() + val account2 = AccountModel() + val account3 = AccountModel() + Observable.just( + account1.toAddIntent(), + account2.toAddIntent(), + account3.toAddIntent(), + account2.toRemoveIntent(), + undo() + ).toUserModelStream() + .assertWithLastAndThirdToLastElements { lastMinusTwo, last -> + lastMinusTwo.userModel.accounts `should equal` last.userModel.accounts + } + } + + @Test + fun `if there is nothing to undo, undo does nothing`() { + val account = AccountModel() + Observable.just( + account.toAddIntent(), + undo() + ).toUserModelStream() + .assertWithLastTwoElements { penultimate, ultimate -> + penultimate `should be` ultimate + } + } +} + +private fun undo(): UserModelViewStateIntent = UserModelViewStateIntent.Undo() \ No newline at end of file