Skip to content

Commit c076c84

Browse files
authored
Merge pull request #4057 from element-hq/feature/bma/fixFlakyTest
Fix flaky test by using CompletableDeferred
2 parents 21d5ff8 + 34b8143 commit c076c84

File tree

4 files changed

+116
-113
lines changed

4 files changed

+116
-113
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: 107 additions & 107 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,8 +24,8 @@ 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
28+
import io.element.android.tests.testutils.test
2929
import kotlinx.coroutines.ExperimentalCoroutinesApi
3030
import kotlinx.coroutines.flow.first
3131
import kotlinx.coroutines.test.advanceUntilIdle
@@ -38,115 +38,110 @@ class DeveloperSettingsPresenterTest {
3838
val warmUpRule = WarmUpRule()
3939

4040
@Test
41-
fun `present - ensures initial state is correct`() = runTest {
42-
val presenter = createDeveloperSettingsPresenter()
43-
moleculeFlow(RecompositionMode.Immediate) {
44-
presenter.present()
45-
}.test {
46-
val initialState = awaitItem()
47-
assertThat(initialState.features).isEmpty()
48-
assertThat(initialState.clearCacheAction).isEqualTo(AsyncData.Uninitialized)
49-
assertThat(initialState.cacheSize).isEqualTo(AsyncData.Uninitialized)
50-
assertThat(initialState.customElementCallBaseUrlState).isNotNull()
51-
assertThat(initialState.customElementCallBaseUrlState.baseUrl).isNull()
52-
assertThat(initialState.isSimpleSlidingSyncEnabled).isFalse()
53-
assertThat(initialState.hideImagesAndVideos).isFalse()
54-
val loadedState = awaitItem()
55-
assertThat(loadedState.rageshakeState.isEnabled).isFalse()
56-
assertThat(loadedState.rageshakeState.isSupported).isTrue()
57-
assertThat(loadedState.rageshakeState.sensitivity).isEqualTo(0.3f)
58-
cancelAndIgnoreRemainingEvents()
59-
}
60-
}
61-
62-
@Test
63-
fun `present - ensures feature list is loaded`() = runTest {
41+
fun `present - ensures initial states are correct`() = runTest {
6442
val presenter = createDeveloperSettingsPresenter()
65-
moleculeFlow(RecompositionMode.Immediate) {
66-
presenter.present()
67-
}.test {
68-
val state = awaitLastSequentialItem()
69-
val numberOfModifiableFeatureFlags = FeatureFlags.entries.count { it.isFinished.not() }
70-
assertThat(state.features).hasSize(numberOfModifiableFeatureFlags)
71-
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+
}
7264
}
7365
}
7466

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

8879
@Test
8980
fun `present - ensures state is updated when enabled feature event is triggered`() = runTest {
9081
val presenter = createDeveloperSettingsPresenter()
91-
moleculeFlow(RecompositionMode.Immediate) {
92-
presenter.present()
93-
}.test {
94-
skipItems(1)
95-
val stateBeforeEvent = awaitItem()
96-
val featureBeforeEvent = stateBeforeEvent.features.first()
97-
stateBeforeEvent.eventSink(DeveloperSettingsEvents.UpdateEnabledFeature(featureBeforeEvent, !featureBeforeEvent.isEnabled))
98-
val stateAfterEvent = awaitItem()
99-
val featureAfterEvent = stateAfterEvent.features.first()
100-
assertThat(featureBeforeEvent.key).isEqualTo(featureAfterEvent.key)
101-
assertThat(featureBeforeEvent.isEnabled).isNotEqualTo(featureAfterEvent.isEnabled)
102-
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+
}
10393
}
10494
}
10595

10696
@Test
10797
fun `present - clear cache`() = runTest {
10898
val clearCacheUseCase = FakeClearCacheUseCase()
10999
val presenter = createDeveloperSettingsPresenter(clearCacheUseCase = clearCacheUseCase)
110-
moleculeFlow(RecompositionMode.Immediate) {
111-
presenter.present()
112-
}.test {
113-
skipItems(1)
114-
val initialState = awaitItem()
100+
presenter.test {
101+
skipItems(2)
115102
assertThat(clearCacheUseCase.executeHasBeenCalled).isFalse()
116-
initialState.eventSink(DeveloperSettingsEvents.ClearCache)
117-
val stateAfterEvent = awaitItem()
118-
assertThat(stateAfterEvent.clearCacheAction).isInstanceOf(AsyncData.Loading::class.java)
119-
skipItems(1)
120-
assertThat(awaitItem().clearCacheAction).isInstanceOf(AsyncData.Success::class.java)
121-
assertThat(clearCacheUseCase.executeHasBeenCalled).isTrue()
122-
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+
}
123119
}
124120
}
125121

126122
@Test
127123
fun `present - custom element call base url`() = runTest {
128124
val preferencesStore = InMemoryAppPreferencesStore()
129125
val presenter = createDeveloperSettingsPresenter(preferencesStore = preferencesStore)
130-
moleculeFlow(RecompositionMode.Immediate) {
131-
presenter.present()
132-
}.test {
133-
skipItems(1)
134-
val initialState = awaitItem()
135-
assertThat(initialState.customElementCallBaseUrlState.baseUrl).isNull()
136-
initialState.eventSink(DeveloperSettingsEvents.SetCustomElementCallBaseUrl("https://call.element.ahoy"))
137-
val updatedItem = awaitItem()
138-
assertThat(updatedItem.customElementCallBaseUrlState.baseUrl).isEqualTo("https://call.element.ahoy")
139-
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+
}
140136
}
141137
}
142138

143139
@Test
144140
fun `present - custom element call base url validator needs at least an HTTP scheme and host`() = runTest {
145141
val presenter = createDeveloperSettingsPresenter()
146-
moleculeFlow(RecompositionMode.Immediate) {
147-
presenter.present()
148-
}.test {
149-
val urlValidator = awaitLastSequentialItem().customElementCallBaseUrlState.validator
142+
presenter.test {
143+
skipItems(2)
144+
val urlValidator = awaitItem().customElementCallBaseUrlState.validator
150145
assertThat(urlValidator("")).isTrue() // We allow empty string to clear the value and use the default one
151146
assertThat(urlValidator("test")).isFalse()
152147
assertThat(urlValidator("http://")).isFalse()
@@ -155,48 +150,53 @@ class DeveloperSettingsPresenterTest {
155150
}
156151
}
157152

158-
@OptIn(ExperimentalCoroutinesApi::class)
159153
@Test
160154
fun `present - toggling simplified sliding sync changes the preferences and logs out the user`() = runTest {
161155
val logoutCallRecorder = lambdaRecorder<Boolean, String?> { "" }
162156
val logoutUseCase = FakeLogoutUseCase(logoutLambda = logoutCallRecorder)
163157
val preferences = InMemoryAppPreferencesStore()
164158
val presenter = createDeveloperSettingsPresenter(preferencesStore = preferences, logoutUseCase = logoutUseCase)
165-
moleculeFlow(RecompositionMode.Immediate) {
166-
presenter.present()
167-
}.test {
168-
val initialState = awaitLastSequentialItem()
169-
assertThat(initialState.isSimpleSlidingSyncEnabled).isFalse()
170-
171-
initialState.eventSink(DeveloperSettingsEvents.SetSimplifiedSlidingSyncEnabled(true))
172-
assertThat(awaitItem().isSimpleSlidingSyncEnabled).isTrue()
173-
assertThat(preferences.isSimplifiedSlidingSyncEnabledFlow().first()).isTrue()
174-
advanceUntilIdle()
175-
logoutCallRecorder.assertions().isCalledOnce()
176-
177-
initialState.eventSink(DeveloperSettingsEvents.SetSimplifiedSlidingSyncEnabled(false))
178-
assertThat(awaitItem().isSimpleSlidingSyncEnabled).isFalse()
179-
assertThat(preferences.isSimplifiedSlidingSyncEnabledFlow().first()).isFalse()
180-
advanceUntilIdle()
181-
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+
}
182178
}
183179
}
184180

185181
@Test
186182
fun `present - toggling hide image and video`() = runTest {
187183
val preferences = InMemoryAppPreferencesStore()
188184
val presenter = createDeveloperSettingsPresenter(preferencesStore = preferences)
189-
moleculeFlow(RecompositionMode.Immediate) {
190-
presenter.present()
191-
}.test {
192-
val initialState = awaitLastSequentialItem()
193-
assertThat(initialState.hideImagesAndVideos).isFalse()
194-
initialState.eventSink(DeveloperSettingsEvents.SetHideImagesAndVideos(true))
195-
assertThat(awaitItem().hideImagesAndVideos).isTrue()
196-
assertThat(preferences.doesHideImagesAndVideosFlow().first()).isTrue()
197-
initialState.eventSink(DeveloperSettingsEvents.SetHideImagesAndVideos(false))
198-
assertThat(awaitItem().hideImagesAndVideos).isFalse()
199-
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+
}
200200
}
201201
}
202202

0 commit comments

Comments
 (0)