Skip to content

Commit 34b8143

Browse files
committed
test(settings) : try to fix flakiness
1 parent 865f877 commit 34b8143

File tree

4 files changed

+119
-122
lines changed

4 files changed

+119
-122
lines changed

features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsPresenter.kt

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import io.element.android.features.logout.api.LogoutUseCase
2323
import io.element.android.features.preferences.impl.tasks.ClearCacheUseCase
2424
import io.element.android.features.preferences.impl.tasks.ComputeCacheSizeUseCase
2525
import io.element.android.features.rageshake.api.preferences.RageshakePreferencesState
26+
import io.element.android.libraries.architecture.AsyncAction
2627
import io.element.android.libraries.architecture.AsyncData
2728
import io.element.android.libraries.architecture.Presenter
2829
import io.element.android.libraries.architecture.runCatchingUpdatingState
@@ -63,7 +64,7 @@ class DeveloperSettingsPresenter @Inject constructor(
6364
mutableStateOf<AsyncData<String>>(AsyncData.Uninitialized)
6465
}
6566
val clearCacheAction = remember {
66-
mutableStateOf<AsyncData<Unit>>(AsyncData.Uninitialized)
67+
mutableStateOf<AsyncAction<Unit>>(AsyncAction.Uninitialized)
6768
}
6869
val customElementCallBaseUrl by appPreferencesStore
6970
.getCustomElementCallBaseUrlFlow()
@@ -94,7 +95,7 @@ class DeveloperSettingsPresenter @Inject constructor(
9495
val featureUiModels = createUiModels(features, enabledFeatures)
9596
val coroutineScope = rememberCoroutineScope()
9697
// Compute cache size each time the clear cache action value is changed
97-
LaunchedEffect(clearCacheAction.value) {
98+
LaunchedEffect(clearCacheAction.value.isSuccess()) {
9899
computeCacheSize(cacheSize)
99100
}
100101

@@ -180,7 +181,7 @@ class DeveloperSettingsPresenter @Inject constructor(
180181
}.runCatchingUpdatingState(cacheSize)
181182
}
182183

183-
private fun CoroutineScope.clearCache(clearCacheAction: MutableState<AsyncData<Unit>>) = launch {
184+
private fun CoroutineScope.clearCache(clearCacheAction: MutableState<AsyncAction<Unit>>) = launch {
184185
suspend {
185186
clearCacheUseCase()
186187
}.runCatchingUpdatingState(clearCacheAction)

features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsState.kt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
package io.element.android.features.preferences.impl.developer
99

1010
import io.element.android.features.rageshake.api.preferences.RageshakePreferencesState
11+
import io.element.android.libraries.architecture.AsyncAction
1112
import io.element.android.libraries.architecture.AsyncData
1213
import io.element.android.libraries.featureflag.ui.model.FeatureUiModel
1314
import kotlinx.collections.immutable.ImmutableList
@@ -16,7 +17,7 @@ data class DeveloperSettingsState(
1617
val features: ImmutableList<FeatureUiModel>,
1718
val cacheSize: AsyncData<String>,
1819
val rageshakeState: RageshakePreferencesState,
19-
val clearCacheAction: AsyncData<Unit>,
20+
val clearCacheAction: AsyncAction<Unit>,
2021
val customElementCallBaseUrlState: CustomElementCallBaseUrlState,
2122
val isSimpleSlidingSyncEnabled: Boolean,
2223
val hideImagesAndVideos: Boolean,

features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsStateProvider.kt

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ package io.element.android.features.preferences.impl.developer
99

1010
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
1111
import io.element.android.features.rageshake.api.preferences.aRageshakePreferencesState
12+
import io.element.android.libraries.architecture.AsyncAction
1213
import io.element.android.libraries.architecture.AsyncData
1314
import io.element.android.libraries.featureflag.ui.model.aFeatureUiModelList
1415

@@ -17,7 +18,7 @@ open class DeveloperSettingsStateProvider : PreviewParameterProvider<DeveloperSe
1718
get() = sequenceOf(
1819
aDeveloperSettingsState(),
1920
aDeveloperSettingsState(
20-
clearCacheAction = AsyncData.Loading()
21+
clearCacheAction = AsyncAction.Loading
2122
),
2223
aDeveloperSettingsState(
2324
customElementCallBaseUrlState = aCustomElementCallBaseUrlState(
@@ -28,7 +29,7 @@ open class DeveloperSettingsStateProvider : PreviewParameterProvider<DeveloperSe
2829
}
2930

3031
fun aDeveloperSettingsState(
31-
clearCacheAction: AsyncData<Unit> = AsyncData.Uninitialized,
32+
clearCacheAction: AsyncAction<Unit> = AsyncAction.Uninitialized,
3233
customElementCallBaseUrlState: CustomElementCallBaseUrlState = aCustomElementCallBaseUrlState(),
3334
isSimplifiedSlidingSyncEnabled: Boolean = false,
3435
hideImagesAndVideos: Boolean = false,

features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsPresenterTest.kt

Lines changed: 110 additions & 116 deletions
Original file line numberDiff line numberDiff line change
@@ -5,17 +5,17 @@
55
* Please see LICENSE in the repository root for full details.
66
*/
77

8+
@file:OptIn(ExperimentalCoroutinesApi::class)
9+
810
package io.element.android.features.preferences.impl.developer
911

10-
import app.cash.molecule.RecompositionMode
11-
import app.cash.molecule.moleculeFlow
12-
import app.cash.turbine.test
1312
import com.google.common.truth.Truth.assertThat
1413
import io.element.android.appconfig.ElementCallConfig
1514
import io.element.android.features.logout.test.FakeLogoutUseCase
1615
import io.element.android.features.preferences.impl.tasks.FakeClearCacheUseCase
1716
import io.element.android.features.preferences.impl.tasks.FakeComputeCacheSizeUseCase
1817
import io.element.android.features.rageshake.api.preferences.aRageshakePreferencesState
18+
import io.element.android.libraries.architecture.AsyncAction
1919
import io.element.android.libraries.architecture.AsyncData
2020
import io.element.android.libraries.core.meta.BuildMeta
2121
import io.element.android.libraries.core.meta.BuildType
@@ -24,10 +24,11 @@ import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
2424
import io.element.android.libraries.matrix.test.core.aBuildMeta
2525
import io.element.android.libraries.preferences.test.InMemoryAppPreferencesStore
2626
import io.element.android.tests.testutils.WarmUpRule
27-
import io.element.android.tests.testutils.awaitLastSequentialItem
2827
import io.element.android.tests.testutils.lambda.lambdaRecorder
29-
import kotlinx.coroutines.CompletableDeferred
28+
import io.element.android.tests.testutils.test
29+
import kotlinx.coroutines.ExperimentalCoroutinesApi
3030
import kotlinx.coroutines.flow.first
31+
import kotlinx.coroutines.test.advanceUntilIdle
3132
import kotlinx.coroutines.test.runTest
3233
import org.junit.Rule
3334
import org.junit.Test
@@ -37,115 +38,110 @@ class DeveloperSettingsPresenterTest {
3738
val warmUpRule = WarmUpRule()
3839

3940
@Test
40-
fun `present - ensures initial state is correct`() = runTest {
41+
fun `present - ensures initial states are correct`() = runTest {
4142
val presenter = createDeveloperSettingsPresenter()
42-
moleculeFlow(RecompositionMode.Immediate) {
43-
presenter.present()
44-
}.test {
45-
val initialState = awaitItem()
46-
assertThat(initialState.features).isEmpty()
47-
assertThat(initialState.clearCacheAction).isEqualTo(AsyncData.Uninitialized)
48-
assertThat(initialState.cacheSize).isEqualTo(AsyncData.Uninitialized)
49-
assertThat(initialState.customElementCallBaseUrlState).isNotNull()
50-
assertThat(initialState.customElementCallBaseUrlState.baseUrl).isNull()
51-
assertThat(initialState.isSimpleSlidingSyncEnabled).isFalse()
52-
assertThat(initialState.hideImagesAndVideos).isFalse()
53-
val loadedState = awaitItem()
54-
assertThat(loadedState.rageshakeState.isEnabled).isFalse()
55-
assertThat(loadedState.rageshakeState.isSupported).isTrue()
56-
assertThat(loadedState.rageshakeState.sensitivity).isEqualTo(0.3f)
57-
cancelAndIgnoreRemainingEvents()
58-
}
59-
}
60-
61-
@Test
62-
fun `present - ensures feature list is loaded`() = runTest {
63-
val presenter = createDeveloperSettingsPresenter()
64-
moleculeFlow(RecompositionMode.Immediate) {
65-
presenter.present()
66-
}.test {
67-
val state = awaitLastSequentialItem()
68-
val numberOfModifiableFeatureFlags = FeatureFlags.entries.count { it.isFinished.not() }
69-
assertThat(state.features).hasSize(numberOfModifiableFeatureFlags)
70-
cancelAndIgnoreRemainingEvents()
43+
presenter.test {
44+
awaitItem().also { state ->
45+
assertThat(state.features).isEmpty()
46+
assertThat(state.clearCacheAction).isEqualTo(AsyncAction.Uninitialized)
47+
assertThat(state.cacheSize).isEqualTo(AsyncData.Uninitialized)
48+
assertThat(state.customElementCallBaseUrlState).isNotNull()
49+
assertThat(state.customElementCallBaseUrlState.baseUrl).isNull()
50+
assertThat(state.isSimpleSlidingSyncEnabled).isFalse()
51+
assertThat(state.hideImagesAndVideos).isFalse()
52+
assertThat(state.rageshakeState.isEnabled).isFalse()
53+
assertThat(state.rageshakeState.isSupported).isTrue()
54+
assertThat(state.rageshakeState.sensitivity).isEqualTo(0.3f)
55+
}
56+
awaitItem().also { state ->
57+
assertThat(state.features).isNotEmpty()
58+
val numberOfModifiableFeatureFlags = FeatureFlags.entries.count { it.isFinished.not() }
59+
assertThat(state.features).hasSize(numberOfModifiableFeatureFlags)
60+
}
61+
awaitItem().also { state ->
62+
assertThat(state.cacheSize).isInstanceOf(AsyncData.Success::class.java)
63+
}
7164
}
7265
}
7366

7467
@Test
7568
fun `present - ensures Room directory search is not present on release Google Play builds`() = runTest {
7669
val buildMeta = aBuildMeta(buildType = BuildType.RELEASE, flavorDescription = "GooglePlay")
7770
val presenter = createDeveloperSettingsPresenter(buildMeta = buildMeta)
78-
moleculeFlow(RecompositionMode.Immediate) {
79-
presenter.present()
80-
}.test {
81-
val state = awaitLastSequentialItem()
82-
assertThat(state.features).doesNotContain(FeatureFlags.RoomDirectorySearch)
83-
cancelAndIgnoreRemainingEvents()
71+
presenter.test {
72+
skipItems(2)
73+
awaitItem().also { state ->
74+
assertThat(state.features).doesNotContain(FeatureFlags.RoomDirectorySearch)
75+
}
8476
}
8577
}
8678

8779
@Test
8880
fun `present - ensures state is updated when enabled feature event is triggered`() = runTest {
8981
val presenter = createDeveloperSettingsPresenter()
90-
moleculeFlow(RecompositionMode.Immediate) {
91-
presenter.present()
92-
}.test {
93-
skipItems(1)
94-
val stateBeforeEvent = awaitItem()
95-
val featureBeforeEvent = stateBeforeEvent.features.first()
96-
stateBeforeEvent.eventSink(DeveloperSettingsEvents.UpdateEnabledFeature(featureBeforeEvent, !featureBeforeEvent.isEnabled))
97-
val stateAfterEvent = awaitItem()
98-
val featureAfterEvent = stateAfterEvent.features.first()
99-
assertThat(featureBeforeEvent.key).isEqualTo(featureAfterEvent.key)
100-
assertThat(featureBeforeEvent.isEnabled).isNotEqualTo(featureAfterEvent.isEnabled)
101-
cancelAndIgnoreRemainingEvents()
82+
presenter.test {
83+
skipItems(2)
84+
awaitItem().also { state ->
85+
val feature = state.features.first()
86+
state.eventSink(DeveloperSettingsEvents.UpdateEnabledFeature(feature, !feature.isEnabled))
87+
}
88+
awaitItem().also { state ->
89+
val feature = state.features.first()
90+
assertThat(feature.isEnabled).isTrue()
91+
assertThat(feature.key).isEqualTo(feature.key)
92+
}
10293
}
10394
}
10495

10596
@Test
10697
fun `present - clear cache`() = runTest {
10798
val clearCacheUseCase = FakeClearCacheUseCase()
10899
val presenter = createDeveloperSettingsPresenter(clearCacheUseCase = clearCacheUseCase)
109-
moleculeFlow(RecompositionMode.Immediate) {
110-
presenter.present()
111-
}.test {
112-
skipItems(1)
113-
val initialState = awaitItem()
100+
presenter.test {
101+
skipItems(2)
114102
assertThat(clearCacheUseCase.executeHasBeenCalled).isFalse()
115-
initialState.eventSink(DeveloperSettingsEvents.ClearCache)
116-
val stateAfterEvent = awaitItem()
117-
assertThat(stateAfterEvent.clearCacheAction).isInstanceOf(AsyncData.Loading::class.java)
118-
skipItems(1)
119-
assertThat(awaitItem().clearCacheAction).isInstanceOf(AsyncData.Success::class.java)
120-
assertThat(clearCacheUseCase.executeHasBeenCalled).isTrue()
121-
cancelAndIgnoreRemainingEvents()
103+
awaitItem().also { state ->
104+
state.eventSink(DeveloperSettingsEvents.ClearCache)
105+
}
106+
awaitItem().also { state ->
107+
assertThat(state.clearCacheAction).isInstanceOf(AsyncAction.Loading::class.java)
108+
}
109+
awaitItem().also { state ->
110+
assertThat(state.clearCacheAction).isInstanceOf(AsyncAction.Success::class.java)
111+
assertThat(clearCacheUseCase.executeHasBeenCalled).isTrue()
112+
}
113+
awaitItem().also { state ->
114+
assertThat(state.cacheSize).isInstanceOf(AsyncData.Loading::class.java)
115+
}
116+
awaitItem().also { state ->
117+
assertThat(state.cacheSize).isInstanceOf(AsyncData.Success::class.java)
118+
}
122119
}
123120
}
124121

125122
@Test
126123
fun `present - custom element call base url`() = runTest {
127124
val preferencesStore = InMemoryAppPreferencesStore()
128125
val presenter = createDeveloperSettingsPresenter(preferencesStore = preferencesStore)
129-
moleculeFlow(RecompositionMode.Immediate) {
130-
presenter.present()
131-
}.test {
132-
skipItems(1)
133-
val initialState = awaitItem()
134-
assertThat(initialState.customElementCallBaseUrlState.baseUrl).isNull()
135-
initialState.eventSink(DeveloperSettingsEvents.SetCustomElementCallBaseUrl("https://call.element.ahoy"))
136-
val updatedItem = awaitItem()
137-
assertThat(updatedItem.customElementCallBaseUrlState.baseUrl).isEqualTo("https://call.element.ahoy")
138-
assertThat(updatedItem.customElementCallBaseUrlState.defaultUrl).isEqualTo(ElementCallConfig.DEFAULT_BASE_URL)
126+
presenter.test {
127+
skipItems(2)
128+
awaitItem().also { state ->
129+
assertThat(state.customElementCallBaseUrlState.baseUrl).isNull()
130+
state.eventSink(DeveloperSettingsEvents.SetCustomElementCallBaseUrl("https://call.element.ahoy"))
131+
}
132+
awaitItem().also { state ->
133+
assertThat(state.customElementCallBaseUrlState.baseUrl).isEqualTo("https://call.element.ahoy")
134+
assertThat(state.customElementCallBaseUrlState.defaultUrl).isEqualTo(ElementCallConfig.DEFAULT_BASE_URL)
135+
}
139136
}
140137
}
141138

142139
@Test
143140
fun `present - custom element call base url validator needs at least an HTTP scheme and host`() = runTest {
144141
val presenter = createDeveloperSettingsPresenter()
145-
moleculeFlow(RecompositionMode.Immediate) {
146-
presenter.present()
147-
}.test {
148-
val urlValidator = awaitLastSequentialItem().customElementCallBaseUrlState.validator
142+
presenter.test {
143+
skipItems(2)
144+
val urlValidator = awaitItem().customElementCallBaseUrlState.validator
149145
assertThat(urlValidator("")).isTrue() // We allow empty string to clear the value and use the default one
150146
assertThat(urlValidator("test")).isFalse()
151147
assertThat(urlValidator("http://")).isFalse()
@@ -156,53 +152,51 @@ class DeveloperSettingsPresenterTest {
156152

157153
@Test
158154
fun `present - toggling simplified sliding sync changes the preferences and logs out the user`() = runTest {
159-
val latch1 = CompletableDeferred<Unit>()
160-
val latch2 = CompletableDeferred<Unit>()
161-
val logoutCallRecorder = lambdaRecorder<Boolean, String?> {
162-
if (latch1.isActive) {
163-
latch1.complete(Unit)
164-
} else {
165-
latch2.complete(Unit)
166-
}
167-
""
168-
}
155+
val logoutCallRecorder = lambdaRecorder<Boolean, String?> { "" }
169156
val logoutUseCase = FakeLogoutUseCase(logoutLambda = logoutCallRecorder)
170157
val preferences = InMemoryAppPreferencesStore()
171158
val presenter = createDeveloperSettingsPresenter(preferencesStore = preferences, logoutUseCase = logoutUseCase)
172-
moleculeFlow(RecompositionMode.Immediate) {
173-
presenter.present()
174-
}.test {
175-
val initialState = awaitLastSequentialItem()
176-
assertThat(initialState.isSimpleSlidingSyncEnabled).isFalse()
177-
178-
initialState.eventSink(DeveloperSettingsEvents.SetSimplifiedSlidingSyncEnabled(true))
179-
assertThat(awaitItem().isSimpleSlidingSyncEnabled).isTrue()
180-
assertThat(preferences.isSimplifiedSlidingSyncEnabledFlow().first()).isTrue()
181-
latch1.await()
182-
logoutCallRecorder.assertions().isCalledOnce()
183-
initialState.eventSink(DeveloperSettingsEvents.SetSimplifiedSlidingSyncEnabled(false))
184-
assertThat(awaitItem().isSimpleSlidingSyncEnabled).isFalse()
185-
assertThat(preferences.isSimplifiedSlidingSyncEnabledFlow().first()).isFalse()
186-
latch2.await()
187-
logoutCallRecorder.assertions().isCalledExactly(times = 2)
159+
presenter.test {
160+
skipItems(2)
161+
awaitItem().also { state ->
162+
assertThat(state.isSimpleSlidingSyncEnabled).isFalse()
163+
state.eventSink(DeveloperSettingsEvents.SetSimplifiedSlidingSyncEnabled(true))
164+
}
165+
awaitItem().also { state ->
166+
assertThat(state.isSimpleSlidingSyncEnabled).isTrue()
167+
assertThat(preferences.isSimplifiedSlidingSyncEnabledFlow().first()).isTrue()
168+
advanceUntilIdle()
169+
logoutCallRecorder.assertions().isCalledOnce()
170+
state.eventSink(DeveloperSettingsEvents.SetSimplifiedSlidingSyncEnabled(false))
171+
}
172+
awaitItem().also { state ->
173+
assertThat(state.isSimpleSlidingSyncEnabled).isFalse()
174+
assertThat(preferences.isSimplifiedSlidingSyncEnabledFlow().first()).isFalse()
175+
advanceUntilIdle()
176+
logoutCallRecorder.assertions().isCalledExactly(2)
177+
}
188178
}
189179
}
190180

191181
@Test
192182
fun `present - toggling hide image and video`() = runTest {
193183
val preferences = InMemoryAppPreferencesStore()
194184
val presenter = createDeveloperSettingsPresenter(preferencesStore = preferences)
195-
moleculeFlow(RecompositionMode.Immediate) {
196-
presenter.present()
197-
}.test {
198-
val initialState = awaitLastSequentialItem()
199-
assertThat(initialState.hideImagesAndVideos).isFalse()
200-
initialState.eventSink(DeveloperSettingsEvents.SetHideImagesAndVideos(true))
201-
assertThat(awaitItem().hideImagesAndVideos).isTrue()
202-
assertThat(preferences.doesHideImagesAndVideosFlow().first()).isTrue()
203-
initialState.eventSink(DeveloperSettingsEvents.SetHideImagesAndVideos(false))
204-
assertThat(awaitItem().hideImagesAndVideos).isFalse()
205-
assertThat(preferences.doesHideImagesAndVideosFlow().first()).isFalse()
185+
presenter.test {
186+
skipItems(2)
187+
awaitItem().also { state ->
188+
assertThat(state.hideImagesAndVideos).isFalse()
189+
state.eventSink(DeveloperSettingsEvents.SetHideImagesAndVideos(true))
190+
}
191+
awaitItem().also { state ->
192+
assertThat(state.hideImagesAndVideos).isTrue()
193+
assertThat(preferences.doesHideImagesAndVideosFlow().first()).isTrue()
194+
state.eventSink(DeveloperSettingsEvents.SetHideImagesAndVideos(false))
195+
}
196+
awaitItem().also { state ->
197+
assertThat(state.hideImagesAndVideos).isFalse()
198+
assertThat(preferences.doesHideImagesAndVideosFlow().first()).isFalse()
199+
}
206200
}
207201
}
208202

0 commit comments

Comments
 (0)