porting viewmodel tests to reducer

This commit is contained in:
Adam Brown 2022-11-03 09:04:25 +00:00
parent 678897e8de
commit 533122f1ab
8 changed files with 317 additions and 222 deletions

View File

@ -45,7 +45,7 @@ class ReducerTestScope<S, E>(
private val actionCaptures = mutableListOf<Action>()
private val reducerScope = object : ReducerScope<S> {
override val coroutineScope = CoroutineScope(UnconfinedTestDispatcher())
override suspend fun dispatch(action: Action) {
override fun dispatch(action: Action) {
actionCaptures.add(action)
}
@ -123,4 +123,20 @@ class ReducerTestScope<S, E>(
assertNoEvents()
assertNoDispatches()
}
}
fun <S, E> ReducerTestScope<S, E>.assertOnlyDispatches(vararg action: Action) {
this.assertOnlyDispatches(action.toList())
}
fun <S, E> ReducerTestScope<S, E>.assertDispatches(vararg action: Action) {
this.assertDispatches(action.toList())
}
fun <S, E> ReducerTestScope<S, E>.assertEvents(vararg event: E) {
this.assertEvents(event.toList())
}
fun <S, E> ReducerTestScope<S, E>.assertOnlyEvents(vararg event: E) {
this.assertOnlyEvents(event.toList())
}

View File

@ -272,7 +272,6 @@ class MessengerReducerTest {
assertNoStateChange()
}
@Test
fun `given text composer with reply, when SendMessage, then clear composer and sends text message`() = runReducerTest {
setState { it.copy(composerState = ComposerState.Text(A_MESSAGE_CONTENT, reply = A_REPLY.message), roomState = Lce.Content(A_MESSENGER_PAGE_STATE)) }

View File

@ -15,6 +15,7 @@ dependencies {
androidImportFixturesWorkaround(project, project(":matrix:common"))
androidImportFixturesWorkaround(project, project(":core"))
androidImportFixturesWorkaround(project, project(":domains:store"))
androidImportFixturesWorkaround(project, project(":domains:state"))
androidImportFixturesWorkaround(project, project(":domains:android:viewmodel"))
androidImportFixturesWorkaround(project, project(":domains:android:stub"))
androidImportFixturesWorkaround(project, project(":chat-engine"))

View File

@ -76,7 +76,6 @@ internal fun settingsReducer(
dispatch(RootActions.FetchProviders)
},
async(RootActions.ImportKeysFromFile::class) { action ->
withPageContext<Page.ImportRoomKey> {
pageDispatch(PageStateChange.UpdatePage(it.copy(importProgress = ImportResult.Update(0))))
@ -95,7 +94,6 @@ internal fun settingsReducer(
.launchIn(coroutineScope)
},
onFailure = {
withPageContext<Page.ImportRoomKey> {
pageDispatch(PageStateChange.UpdatePage(it.copy(importProgress = ImportResult.Error(ImportResult.Error.Type.UnableToOpenFile))))
}
@ -115,6 +113,10 @@ internal fun settingsReducer(
}
},
async(ScreenAction.OpenImportRoom::class) {
dispatch(PageAction.GoTo(SpiderPage(Page.Routes.importRoomKeys, "Import room keys", Page.Routes.encryption, Page.ImportRoomKey())))
},
multi(ScreenAction.OnClick::class) { action ->
val item = action.item
when (item.id) {
@ -133,10 +135,6 @@ internal fun settingsReducer(
eventEmitter.invoke(Toast(message = "Cache deleted"))
}
EventLog -> sideEffect {
eventEmitter.invoke(OpenEventLog)
}
Encryption -> async {
dispatch(PageAction.GoTo(SpiderPage(Page.Routes.encryption, "Encryption", Page.Routes.root, Page.Security)))
}
@ -164,16 +162,16 @@ internal fun settingsReducer(
dispatch(ComponentLifecycle.Visible)
}
EventLog -> sideEffect {
eventEmitter.invoke(OpenEventLog)
}
ToggleSendReadReceipts -> async {
messageOptionsStore.setReadReceiptsDisabled(!messageOptionsStore.isReadReceiptsDisabled())
dispatch(ComponentLifecycle.Visible)
}
}
},
async(ScreenAction.OpenImportRoom::class) {
dispatch(PageAction.GoTo(SpiderPage(Page.Routes.importRoomKeys, "Import room keys", Page.Routes.encryption, Page.ImportRoomKey())))
},
)
}
)

View File

@ -78,5 +78,6 @@ class FakePushRegistrars {
val instance = mockk<PushTokenRegistrars>()
fun givenCurrentSelection() = coEvery { instance.currentSelection() }.delegateReturn()
fun givenOptions() = coEvery { instance.options() }.delegateReturn()
}

View File

@ -0,0 +1,281 @@
package app.dapk.st.settings
import app.dapk.st.core.Lce
import app.dapk.st.core.page.PageAction
import app.dapk.st.core.page.PageContainer
import app.dapk.st.core.page.PageStateChange
import app.dapk.st.design.components.SpiderPage
import app.dapk.st.engine.ImportResult
import app.dapk.st.push.Registrar
import app.dapk.st.settings.state.ComponentLifecycle
import app.dapk.st.settings.state.RootActions
import app.dapk.st.settings.state.ScreenAction
import app.dapk.st.settings.state.settingsReducer
import app.dapk.state.Combined2
import fake.*
import fixture.aRoomId
import internalfake.FakeSettingsItemFactory
import internalfake.FakeUriFilenameResolver
import internalfixture.aImportRoomKeysPage
import internalfixture.aPushProvidersPage
import internalfixture.aSettingTextItem
import kotlinx.coroutines.flow.flowOf
import org.junit.Test
import test.*
private const val APP_PRIVACY_POLICY_URL = "https://ouchadam.github.io/small-talk/privacy/"
private val A_LIST_OF_ROOT_ITEMS = listOf(aSettingTextItem())
private val A_URI = FakeUri()
private const val A_FILENAME = "a-filename.jpg"
private val AN_INITIAL_IMPORT_ROOM_KEYS_PAGE = aImportRoomKeysPage()
private val AN_INITIAL_PUSH_PROVIDERS_PAGE = aPushProvidersPage()
private val A_IMPORT_ROOM_KEYS_PAGE_WITH_SELECTION = aImportRoomKeysPage(
state = Page.ImportRoomKey(selectedFile = NamedUri(A_FILENAME, A_URI.instance))
)
private val A_LIST_OF_ROOM_IDS = listOf(aRoomId())
private val AN_IMPORT_SUCCESS = ImportResult.Success(A_LIST_OF_ROOM_IDS.toSet(), totalImportedKeysCount = 5)
private val AN_IMPORT_FILE_ERROR = ImportResult.Error(ImportResult.Error.Type.UnableToOpenFile)
private val AN_INPUT_STREAM = FakeInputStream()
private const val A_PASSPHRASE = "passphrase"
private val AN_ERROR = RuntimeException()
private val A_REGISTRAR = Registrar("a-registrar-id")
private val A_PUSH_OPTIONS = listOf(Registrar("a-registrar-id"))
internal class SettingsReducerTest {
private val fakeStoreCleaner = FakeStoreCleaner()
private val fakeContentResolver = FakeContentResolver()
private val fakeUriFilenameResolver = FakeUriFilenameResolver()
private val fakePushTokenRegistrars = FakePushRegistrars()
private val fakeSettingsItemFactory = FakeSettingsItemFactory()
private val fakeThemeStore = FakeThemeStore()
private val fakeLoggingStore = FakeLoggingStore()
private val fakeMessageOptionsStore = FakeMessageOptionsStore()
private val fakeChatEngine = FakeChatEngine()
private val fakeJobBag = FakeJobBag()
private val runReducerTest = testReducer { fakeEventSource ->
settingsReducer(
fakeChatEngine,
fakeStoreCleaner,
fakeContentResolver.instance,
fakeUriFilenameResolver.instance,
fakeSettingsItemFactory.instance,
fakePushTokenRegistrars.instance,
fakeThemeStore.instance,
fakeLoggingStore.instance,
fakeMessageOptionsStore.instance,
fakeEventSource,
fakeJobBag.instance,
)
}
@Test
fun `initial state is root with loading`() = runReducerTest {
assertInitialState(
pageState(SpiderPage(Page.Routes.root, "Settings", null, Page.Root(Lce.Loading())))
)
}
@Test
fun `given root content, when Visible, then goes to root page with content`() = runReducerTest {
fakeSettingsItemFactory.givenRoot().returns(A_LIST_OF_ROOT_ITEMS)
fakeJobBag.instance.expect { it.replace("page", any()) }
reduce(ComponentLifecycle.Visible)
assertOnlyDispatches(
PageAction.GoTo(
SpiderPage(
Page.Routes.root,
"Settings",
null,
Page.Root(Lce.Content(A_LIST_OF_ROOT_ITEMS))
)
)
)
}
@Test
fun `when SelectPushProvider, then selects provider and refreshes`() = runReducerTest {
fakePushTokenRegistrars.instance.expect { it.makeSelection(A_REGISTRAR) }
reduce(RootActions.SelectPushProvider(A_REGISTRAR))
assertOnlyDispatches(RootActions.FetchProviders)
}
@Test
fun `when FetchProviders, then selects provider and refreshes`() = runReducerTest {
setState(pageState(aPushProvidersPage()))
fakePushTokenRegistrars.givenOptions().returns(A_PUSH_OPTIONS)
fakePushTokenRegistrars.givenCurrentSelection().returns(A_REGISTRAR)
reduce(RootActions.FetchProviders)
assertOnlyDispatches(
PageStateChange.UpdatePage(
aPushProvidersPage().state.copy(options = Lce.Loading())
),
PageStateChange.UpdatePage(
aPushProvidersPage().state.copy(
selection = A_REGISTRAR,
options = Lce.Content(A_PUSH_OPTIONS)
)
)
)
}
@Test
fun `when SelectKeysFile, then updates ImportRoomKey page with file`() = runReducerTest {
setState(pageState(AN_INITIAL_IMPORT_ROOM_KEYS_PAGE))
fakeUriFilenameResolver.givenFilename(A_URI.instance).returns(A_FILENAME)
reduce(RootActions.SelectKeysFile(A_URI.instance))
assertOnlyDispatches(
PageStateChange.UpdatePage(
AN_INITIAL_IMPORT_ROOM_KEYS_PAGE.state.copy(
selectedFile = NamedUri(A_FILENAME, A_URI.instance)
)
)
)
}
@Test
fun `when Click SignOut, then clears store and signs out`() = runReducerTest {
fakeStoreCleaner.expectUnit { it.cleanCache(removeCredentials = true) }
val aSignOutItem = aSettingTextItem(id = SettingItem.Id.SignOut)
reduce(ScreenAction.OnClick(aSignOutItem))
assertEvents(SettingsEvent.SignedOut)
}
@Test
fun `when Click Encryption, then goes to Encryption page`() = runReducerTest {
val anEncryptionItem = aSettingTextItem(id = SettingItem.Id.Encryption)
reduce(ScreenAction.OnClick(anEncryptionItem))
assertOnlyDispatches(
PageAction.GoTo(
SpiderPage(
Page.Routes.encryption,
"Encryption",
Page.Routes.root,
Page.Security
)
)
)
}
@Test
fun `when Click PrivacyPolicy, then opens privacy policy url`() = runReducerTest {
val aPrivacyPolicyItem = aSettingTextItem(id = SettingItem.Id.PrivacyPolicy)
reduce(ScreenAction.OnClick(aPrivacyPolicyItem))
assertOnlyEvents(SettingsEvent.OpenUrl(APP_PRIVACY_POLICY_URL))
}
@Test
fun `when Click PushProvider, then goes to PushProvider page`() = runReducerTest {
val aPushProviderItem = aSettingTextItem(id = SettingItem.Id.PushProvider)
reduce(ScreenAction.OnClick(aPushProviderItem))
assertOnlyDispatches(PageAction.GoTo(aPushProvidersPage()))
}
@Test
fun `when Click Ignored, then does nothing`() = runReducerTest {
val anIgnoredItem = aSettingTextItem(id = SettingItem.Id.Ignored)
reduce(ScreenAction.OnClick(anIgnoredItem))
assertNoChanges()
}
@Test
fun `when Click ToggleDynamicTheme, then toggles flag, recreates activity and reloads`() = runReducerTest {
val aToggleThemeItem = aSettingTextItem(id = SettingItem.Id.ToggleDynamicTheme)
fakeThemeStore.givenMaterialYouIsEnabled().returns(true)
fakeThemeStore.instance.expect { it.storeMaterialYouEnabled(false) }
reduce(ScreenAction.OnClick(aToggleThemeItem))
assertEvents(SettingsEvent.RecreateActivity)
assertDispatches(ComponentLifecycle.Visible)
assertNoStateChange()
}
@Test
fun `when Click ToggleEnableLogs, then toggles flag and reloads`() = runReducerTest {
val aToggleEnableLogsItem = aSettingTextItem(id = SettingItem.Id.ToggleEnableLogs)
fakeLoggingStore.givenLoggingIsEnabled().returns(true)
fakeLoggingStore.instance.expect { it.setEnabled(false) }
reduce(ScreenAction.OnClick(aToggleEnableLogsItem))
assertOnlyDispatches(ComponentLifecycle.Visible)
}
@Test
fun `when Click EventLog, then opens event log`() = runReducerTest {
val anEventLogItem = aSettingTextItem(id = SettingItem.Id.EventLog)
reduce(ScreenAction.OnClick(anEventLogItem))
assertOnlyEvents(SettingsEvent.OpenEventLog)
}
@Test
fun `when Click ToggleSendReadReceipts, then toggles flag and reloads`() = runReducerTest {
val aToggleReadReceiptsItem = aSettingTextItem(id = SettingItem.Id.ToggleSendReadReceipts)
fakeMessageOptionsStore.givenReadReceiptsDisabled().returns(true)
fakeMessageOptionsStore.instance.expect { it.setReadReceiptsDisabled(false) }
reduce(ScreenAction.OnClick(aToggleReadReceiptsItem))
assertOnlyDispatches(ComponentLifecycle.Visible)
}
@Test
fun `given success, when ImportKeysFromFile, then dispatches progress`() = runReducerTest {
setState(pageState(A_IMPORT_ROOM_KEYS_PAGE_WITH_SELECTION))
fakeContentResolver.givenFile(A_URI.instance).returns(AN_INPUT_STREAM.instance)
fakeChatEngine.givenImportKeys(AN_INPUT_STREAM.instance, A_PASSPHRASE).returns(flowOf(AN_IMPORT_SUCCESS))
reduce(RootActions.ImportKeysFromFile(A_URI.instance, A_PASSPHRASE))
assertOnlyDispatches(
PageStateChange.UpdatePage(
A_IMPORT_ROOM_KEYS_PAGE_WITH_SELECTION.state.copy(importProgress = ImportResult.Update(0L))
),
PageStateChange.UpdatePage(
A_IMPORT_ROOM_KEYS_PAGE_WITH_SELECTION.state.copy(importProgress = AN_IMPORT_SUCCESS)
),
)
}
@Test
fun `given error, when ImportKeysFromFile, then dispatches error`() = runReducerTest {
setState(pageState(A_IMPORT_ROOM_KEYS_PAGE_WITH_SELECTION))
fakeContentResolver.givenFile(A_URI.instance).throws(AN_ERROR)
reduce(RootActions.ImportKeysFromFile(A_URI.instance, A_PASSPHRASE))
assertOnlyDispatches(
PageStateChange.UpdatePage(
A_IMPORT_ROOM_KEYS_PAGE_WITH_SELECTION.state.copy(importProgress = ImportResult.Update(0L))
),
PageStateChange.UpdatePage(
A_IMPORT_ROOM_KEYS_PAGE_WITH_SELECTION.state.copy(importProgress = AN_IMPORT_FILE_ERROR)
),
)
}
}
private fun <P> pageState(page: SpiderPage<out P>) = Combined2(PageContainer(page), Unit)

View File

@ -1,210 +0,0 @@
package app.dapk.st.settings
import ViewModelTest
import app.dapk.st.core.Lce
import app.dapk.st.design.components.SpiderPage
import app.dapk.st.engine.ImportResult
import fake.*
import fixture.aRoomId
import internalfake.FakeSettingsItemFactory
import internalfake.FakeUriFilenameResolver
import internalfixture.aImportRoomKeysPage
import internalfixture.aSettingTextItem
import kotlinx.coroutines.flow.flowOf
import org.junit.Test
private const val APP_PRIVACY_POLICY_URL = "https://ouchadam.github.io/small-talk/privacy/"
private val A_LIST_OF_ROOT_ITEMS = listOf(aSettingTextItem())
private val A_URI = FakeUri()
private const val A_FILENAME = "a-filename.jpg"
private val AN_INITIAL_IMPORT_ROOM_KEYS_PAGE = aImportRoomKeysPage()
private val A_IMPORT_ROOM_KEYS_PAGE_WITH_SELECTION = aImportRoomKeysPage(
state = Page.ImportRoomKey(selectedFile = NamedUri(A_FILENAME, A_URI.instance))
)
private val A_LIST_OF_ROOM_IDS = listOf(aRoomId())
private val AN_IMPORT_SUCCESS = ImportResult.Success(A_LIST_OF_ROOM_IDS.toSet(), totalImportedKeysCount = 5)
private val AN_IMPORT_FILE_ERROR = ImportResult.Error(ImportResult.Error.Type.UnableToOpenFile)
private val AN_INPUT_STREAM = FakeInputStream()
private const val A_PASSPHRASE = "passphrase"
private val AN_ERROR = RuntimeException()
internal class SettingsViewModelTest {
private val runViewModelTest = ViewModelTest()
private val fakeStoreCleaner = FakeStoreCleaner()
private val fakeContentResolver = FakeContentResolver()
private val fakeUriFilenameResolver = FakeUriFilenameResolver()
private val fakePushTokenRegistrars = FakePushRegistrars()
private val fakeSettingsItemFactory = FakeSettingsItemFactory()
private val fakeThemeStore = FakeThemeStore()
private val fakeLoggingStore = FakeLoggingStore()
private val fakeMessageOptionsStore = FakeMessageOptionsStore()
private val fakeChatEngine = FakeChatEngine()
private val viewModel = SettingsViewModel(
fakeChatEngine,
fakeStoreCleaner,
fakeContentResolver.instance,
fakeUriFilenameResolver.instance,
fakeSettingsItemFactory.instance,
fakePushTokenRegistrars.instance,
fakeThemeStore.instance,
fakeLoggingStore.instance,
fakeMessageOptionsStore.instance,
runViewModelTest.testMutableStateFactory(),
)
@Test
fun `when creating view model then initial state is loading Root`() = runViewModelTest {
viewModel.test()
assertInitialState(
SettingsScreenState(SpiderPage(Page.Routes.root, "Settings", null, Page.Root(Lce.Loading())))
)
}
@Test
fun `when starting, then emits root page with content`() = runViewModelTest {
fakeSettingsItemFactory.givenRoot().returns(A_LIST_OF_ROOT_ITEMS)
viewModel.test().start()
assertStates(
SettingsScreenState(
SpiderPage(
Page.Routes.root,
"Settings",
null,
Page.Root(Lce.Content(A_LIST_OF_ROOT_ITEMS))
)
)
)
assertNoEvents<SettingsEvent>()
}
@Test
fun `when sign out clicked, then clears store`() = runViewModelTest {
fakeStoreCleaner.expectUnit { it.cleanCache(removeCredentials = true) }
val aSignOutItem = aSettingTextItem(id = SettingItem.Id.SignOut)
viewModel.test().onClick(aSignOutItem)
assertNoStates<SettingsScreenState>()
assertEvents(SettingsEvent.SignedOut)
verifyExpects()
}
@Test
fun `when event log clicked, then opens event log`() = runViewModelTest {
val anEventLogItem = aSettingTextItem(id = SettingItem.Id.EventLog)
viewModel.test().onClick(anEventLogItem)
assertNoStates<SettingsScreenState>()
assertEvents(SettingsEvent.OpenEventLog)
}
@Test
fun `when encryption clicked, then emits encryption page`() = runViewModelTest {
val anEncryptionItem = aSettingTextItem(id = SettingItem.Id.Encryption)
viewModel.test().onClick(anEncryptionItem)
assertNoEvents<SettingsEvent>()
assertStates(
SettingsScreenState(
SpiderPage(
route = Page.Routes.encryption,
label = "Encryption",
parent = Page.Routes.root,
state = Page.Security
)
)
)
}
@Test
fun `when privacy policy clicked, then opens privacy policy url`() = runViewModelTest {
val aPrivacyPolicyItem = aSettingTextItem(id = SettingItem.Id.PrivacyPolicy)
viewModel.test().onClick(aPrivacyPolicyItem)
assertNoStates<SettingsScreenState>()
assertEvents(SettingsEvent.OpenUrl(APP_PRIVACY_POLICY_URL))
}
@Test
fun `when going to import room, then emits import room keys page`() = runViewModelTest {
viewModel.test().goToImportRoom()
assertStates(
SettingsScreenState(
SpiderPage(
route = Page.Routes.importRoomKeys,
label = "Import room keys",
parent = Page.Routes.encryption,
state = Page.ImportRoomKey()
)
)
)
assertNoEvents<SettingsEvent>()
}
@Test
fun `given on import room keys page, when selecting file, then emits selection`() = runViewModelTest {
fakeUriFilenameResolver.givenFilename(A_URI.instance).returns(A_FILENAME)
viewModel.test(initialState = SettingsScreenState(AN_INITIAL_IMPORT_ROOM_KEYS_PAGE)).fileSelected(A_URI.instance)
assertStates(
SettingsScreenState(
AN_INITIAL_IMPORT_ROOM_KEYS_PAGE.copy(
state = Page.ImportRoomKey(
selectedFile = NamedUri(A_FILENAME, A_URI.instance)
)
)
)
)
assertNoEvents<SettingsEvent>()
}
@Test
fun `given success when importing room keys, then emits progress`() = runViewModelTest {
fakeContentResolver.givenFile(A_URI.instance).returns(AN_INPUT_STREAM.instance)
fakeChatEngine.givenImportKeys(AN_INPUT_STREAM.instance, A_PASSPHRASE).returns(flowOf(AN_IMPORT_SUCCESS))
viewModel
.test(initialState = SettingsScreenState(A_IMPORT_ROOM_KEYS_PAGE_WITH_SELECTION))
.importFromFileKeys(A_URI.instance, A_PASSPHRASE)
assertStates<SettingsScreenState>(
{ copy(page = page.updateState<Page.ImportRoomKey> { copy(importProgress = ImportResult.Update(0L)) }) },
{ copy(page = page.updateState<Page.ImportRoomKey> { copy(importProgress = AN_IMPORT_SUCCESS) }) },
)
assertNoEvents<SettingsEvent>()
verifyExpects()
}
@Test
fun `given error when importing room keys, then emits error`() = runViewModelTest {
fakeContentResolver.givenFile(A_URI.instance).throws(AN_ERROR)
viewModel
.test(initialState = SettingsScreenState(A_IMPORT_ROOM_KEYS_PAGE_WITH_SELECTION))
.importFromFileKeys(A_URI.instance, A_PASSPHRASE)
assertStates<SettingsScreenState>(
{ copy(page = page.updateState<Page.ImportRoomKey> { copy(importProgress = ImportResult.Update(0L)) }) },
{ copy(page = page.updateState<Page.ImportRoomKey> { copy(importProgress = AN_IMPORT_FILE_ERROR) }) },
)
assertNoEvents<SettingsEvent>()
}
}
@Suppress("UNCHECKED_CAST")
private inline fun <reified S : Page> SpiderPage<out Page>.updateState(crossinline block: S.() -> S): SpiderPage<Page> {
require(this.state is S)
return (this as SpiderPage<S>).copy(state = block(this.state)) as SpiderPage<Page>
}

View File

@ -11,3 +11,12 @@ internal fun aImportRoomKeysPage(
parent = Page.Routes.encryption,
state = state
)
internal fun aPushProvidersPage(
state: Page.PushProviders = Page.PushProviders()
) = SpiderPage(
route = Page.Routes.pushProviders,
label = "Push providers",
parent = Page.Routes.root,
state = state
)