Skip to content

Commit faa2f9f

Browse files
committed
🐛 [Hooks]: Fix flaky desktop tests
1 parent 7c4e275 commit faa2f9f

6 files changed

Lines changed: 87 additions & 68 deletions

File tree

hooks/src/commonMain/kotlin/xyz/junerver/compose/hooks/useCountdown.kt

Lines changed: 7 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -52,14 +52,6 @@ private fun useCountdown(options: UseCountdownOptions): CountdownHolder {
5252
val targetRef = useRef<Instant?>(null)
5353
val (timeLeft, setTimeLeft) = useGetState(Duration.ZERO)
5454

55-
useEffect(leftTime, targetDate) {
56-
targetRef.current = if (leftTime.asBoolean()) {
57-
currentInstant + leftTime
58-
} else {
59-
targetDate
60-
}
61-
setTimeLeft(calcLeft(targetRef.current))
62-
}
6355
val onEndRef by useLatestRef(value = onEnd)
6456
var pauseRef by useRef(default = {})
6557
val (resume, pause) = useInterval(
@@ -74,19 +66,21 @@ private fun useCountdown(options: UseCountdownOptions): CountdownHolder {
7466
onEndRef?.invoke()
7567
}
7668
}
77-
useEffect(targetRef.current) {
78-
resume()
79-
}
8069
pauseRef = pause
81-
useEffect(interval) {
70+
useEffect(leftTime, targetDate, interval) {
71+
targetRef.current = if (leftTime.asBoolean()) {
72+
currentInstant + leftTime
73+
} else {
74+
targetDate
75+
}
8276
if (!targetRef.current.asBoolean()) {
8377
setTimeLeft(Duration.ZERO)
8478
return@useEffect
8579
}
8680
setTimeLeft(calcLeft(targetRef.current))
8781
resume()
8882
}
89-
val formatResState = useState(timeLeft.value) { parseDuration(timeLeft.value) }
83+
val formatResState = useState(timeLeft) { parseDuration(timeLeft.value) }
9084
return remember { CountdownHolder(timeLeft, formatResState) }
9185
}
9286

hooks/src/commonMain/kotlin/xyz/junerver/compose/hooks/useInterval.kt

Lines changed: 14 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import kotlin.properties.Delegates
99
import kotlin.time.Duration
1010
import kotlin.time.Duration.Companion.seconds
1111
import kotlinx.coroutines.CoroutineScope
12+
import kotlinx.coroutines.Dispatchers
1213
import kotlinx.coroutines.Job
1314
import kotlinx.coroutines.delay
1415
import kotlinx.coroutines.isActive
@@ -55,31 +56,29 @@ private class Interval(private val options: UseIntervalOptions) {
5556
var scope: CoroutineScope by Delegates.notNull()
5657
var isActiveState: MutableState<Boolean>? = null
5758
lateinit var intervalFn: Ref<SuspendAsyncFn>
58-
private lateinit var intervalJob: Job
59+
private var intervalJob: Job? = null
5960

60-
fun isRunning() = this::intervalJob.isInitialized && intervalJob.isActive
61+
fun isRunning() = intervalJob?.isActive == true
6162

6263
fun resume() {
6364
if (ready) {
64-
scope.launch {
65-
if (isRunning()) return@launch
66-
launch {
67-
delay(options.initialDelay)
68-
while (isActive) {
69-
intervalFn.current(this)
70-
delay(options.period)
71-
}
72-
}.also {
73-
intervalJob = it
74-
isActiveState?.value = true
65+
if (isRunning()) return
66+
scope.launch(Dispatchers.Default) {
67+
delay(options.initialDelay)
68+
while (isActive) {
69+
intervalFn.current(this)
70+
delay(options.period)
7571
}
72+
}.also {
73+
intervalJob = it
74+
isActiveState?.value = true
7675
}
7776
}
7877
}
7978

8079
fun pause() {
81-
if (this::intervalJob.isInitialized && intervalJob.isActive) {
82-
intervalJob.cancel()
80+
if (intervalJob?.isActive == true) {
81+
intervalJob?.cancel()
8382
isActiveState?.value = false
8483
}
8584
}

hooks/src/commonMain/kotlin/xyz/junerver/compose/hooks/useTimeoutFn.kt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import kotlin.properties.Delegates
99
import kotlin.time.Duration
1010
import kotlin.time.Duration.Companion.seconds
1111
import kotlinx.coroutines.CoroutineScope
12+
import kotlinx.coroutines.Dispatchers
1213
import kotlinx.coroutines.Job
1314
import kotlinx.coroutines.delay
1415
import kotlinx.coroutines.launch
@@ -79,7 +80,7 @@ private class TimeoutFn(private val options: UseTimeoutFnOptions) {
7980
stop()
8081
}
8182

82-
scope.launch {
83+
scope.launch(Dispatchers.Default) {
8384
isPendingState?.value = true
8485
try {
8586
if (options.immediateCallback) {

hooks/src/desktopTest/kotlin/xyz/junerver/compose/hooks/test/UseCountdownTest.kt

Lines changed: 42 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,11 @@ import androidx.compose.runtime.setValue
66
import androidx.compose.ui.test.ExperimentalTestApi
77
import androidx.compose.ui.test.onNodeWithText
88
import androidx.compose.ui.test.runComposeUiTest
9+
import kotlin.test.AfterTest
910
import kotlin.test.Test
1011
import kotlin.test.assertTrue
1112
import kotlin.time.Clock
13+
import kotlin.time.Instant
1214
import kotlin.time.Duration.Companion.milliseconds
1315
import kotlin.time.Duration.Companion.seconds
1416
import xyz.junerver.compose.hooks.useCountdown
@@ -24,7 +26,19 @@ import xyz.junerver.compose.hooks.utils.instantProvider
2426
*/
2527

2628
class UseCountdownTest {
29+
private var currentInstant: Instant = Clock.System.now()
30+
2731
private fun resetInstantProvider() {
32+
currentInstant = Clock.System.now()
33+
instantProvider = { currentInstant }
34+
}
35+
36+
private fun advanceInstant(duration: kotlin.time.Duration) {
37+
currentInstant += duration
38+
}
39+
40+
@AfterTest
41+
fun tearDown() {
2842
instantProvider = { Clock.System.now() }
2943
}
3044

@@ -40,8 +54,8 @@ class UseCountdownTest {
4054
@Test
4155
fun countdown_with_leftTime_counts_down() = runComposeUiTest {
4256
resetInstantProvider()
43-
val baseInstant = Clock.System.now()
44-
instantProvider = { baseInstant }
57+
val baseInstant = currentInstant
58+
advanceInstant(0.milliseconds)
4559
setContent {
4660
val countdown = useCountdown {
4761
leftTime = 500.milliseconds
@@ -51,7 +65,7 @@ class UseCountdownTest {
5165
Text("left=${countdown.timeLeft.value.inWholeMilliseconds}")
5266
}
5367

54-
instantProvider = { baseInstant + 250.milliseconds }
68+
advanceInstant(250.milliseconds)
5569
val found = waitForCondition {
5670
waitForIdle()
5771
listOf(200, 250, 300).any { ms ->
@@ -65,8 +79,8 @@ class UseCountdownTest {
6579
@Test
6680
fun countdown_with_targetDate_counts_down() = runComposeUiTest {
6781
resetInstantProvider()
68-
val baseInstant = Clock.System.now()
69-
instantProvider = { baseInstant }
82+
val baseInstant = currentInstant
83+
advanceInstant(0.milliseconds)
7084
setContent {
7185
val countdown = useCountdown {
7286
targetDate = baseInstant + 500.milliseconds
@@ -76,7 +90,7 @@ class UseCountdownTest {
7690
Text("left=${countdown.timeLeft.value.inWholeMilliseconds}")
7791
}
7892

79-
instantProvider = { baseInstant + 250.milliseconds }
93+
advanceInstant(250.milliseconds)
8094
val found = waitForCondition {
8195
waitForIdle()
8296
listOf(200, 250, 300).any { ms ->
@@ -90,8 +104,8 @@ class UseCountdownTest {
90104
@Test
91105
fun countdown_reaches_zero_and_stops() = runComposeUiTest {
92106
resetInstantProvider()
93-
val baseInstant = Clock.System.now()
94-
instantProvider = { baseInstant }
107+
val baseInstant = currentInstant
108+
advanceInstant(0.milliseconds)
95109
setContent {
96110
val countdown = useCountdown {
97111
leftTime = 300.milliseconds
@@ -101,14 +115,14 @@ class UseCountdownTest {
101115
Text("left=${countdown.timeLeft.value.inWholeMilliseconds}")
102116
}
103117

104-
instantProvider = { baseInstant + 500.milliseconds }
118+
advanceInstant(500.milliseconds)
105119
val reachedZero = waitForCondition {
106120
waitForIdle()
107121
runCatching { onNodeWithText("left=0").assertExists() }.isSuccess
108122
}
109123
assertTrue(reachedZero, "Expected countdown reaches zero")
110124

111-
instantProvider = { baseInstant + 900.milliseconds }
125+
advanceInstant(900.milliseconds)
112126
val staysZero = waitForCondition {
113127
waitForIdle()
114128
runCatching { onNodeWithText("left=0").assertExists() }.isSuccess
@@ -120,8 +134,8 @@ class UseCountdownTest {
120134
@Test
121135
fun onEnd_callback_fires_when_countdown_finishes() = runComposeUiTest {
122136
resetInstantProvider()
123-
val baseInstant = Clock.System.now()
124-
instantProvider = { baseInstant }
137+
val baseInstant = currentInstant
138+
advanceInstant(0.milliseconds)
125139
setContent {
126140
var endFired by useState(default = false)
127141
val countdown = useCountdown {
@@ -135,7 +149,7 @@ class UseCountdownTest {
135149
Text("left=${countdown.timeLeft.value.inWholeMilliseconds} ended=$endFired")
136150
}
137151

138-
instantProvider = { baseInstant + 500.milliseconds }
152+
advanceInstant(500.milliseconds)
139153
val found = waitForCondition {
140154
waitForIdle()
141155
runCatching { onNodeWithText("left=0 ended=true").assertExists() }.isSuccess
@@ -189,8 +203,8 @@ class UseCountdownTest {
189203
@Test
190204
fun leftTime_takes_priority_over_targetDate() = runComposeUiTest {
191205
resetInstantProvider()
192-
val baseInstant = Clock.System.now()
193-
instantProvider = { baseInstant }
206+
val baseInstant = currentInstant
207+
advanceInstant(0.milliseconds)
194208
setContent {
195209
val countdown = useCountdown {
196210
leftTime = 500.milliseconds
@@ -201,7 +215,7 @@ class UseCountdownTest {
201215
Text("left=${countdown.timeLeft.value.inWholeMilliseconds}")
202216
}
203217

204-
instantProvider = { baseInstant + 250.milliseconds }
218+
advanceInstant(250.milliseconds)
205219
val found = waitForCondition {
206220
waitForIdle()
207221
listOf(200, 250, 300).any { ms ->
@@ -215,8 +229,8 @@ class UseCountdownTest {
215229
@Test
216230
fun countdown_with_past_targetDate_starts_at_zero() = runComposeUiTest {
217231
resetInstantProvider()
218-
val baseInstant = Clock.System.now()
219-
instantProvider = { baseInstant }
232+
val baseInstant = currentInstant
233+
advanceInstant(0.milliseconds)
220234
setContent {
221235
val countdown = useCountdown {
222236
targetDate = baseInstant - 1.seconds
@@ -237,8 +251,8 @@ class UseCountdownTest {
237251
@Test
238252
fun interval_change_restarts_countdown() = runComposeUiTest {
239253
resetInstantProvider()
240-
val baseInstant = Clock.System.now()
241-
instantProvider = { baseInstant }
254+
val baseInstant = currentInstant
255+
advanceInstant(0.milliseconds)
242256
setContent {
243257
var intervalMs by useState(default = 100)
244258
val countdown = useCountdown {
@@ -249,7 +263,7 @@ class UseCountdownTest {
249263
Text("left=${countdown.timeLeft.value.inWholeMilliseconds} interval=$intervalMs")
250264
}
251265

252-
instantProvider = { baseInstant + 250.milliseconds }
266+
advanceInstant(250.milliseconds)
253267
val found = waitForCondition {
254268
waitForIdle()
255269
listOf(200, 250, 300).any { ms ->
@@ -263,8 +277,8 @@ class UseCountdownTest {
263277
@Test
264278
fun targetDate_change_resumes_countdown() = runComposeUiTest {
265279
resetInstantProvider()
266-
val baseInstant = Clock.System.now()
267-
instantProvider = { baseInstant }
280+
val baseInstant = currentInstant
281+
advanceInstant(0.milliseconds)
268282
setContent {
269283
var target by useState(default = baseInstant + 300.milliseconds)
270284
val countdown = useCountdown {
@@ -275,7 +289,7 @@ class UseCountdownTest {
275289
Text("left=${countdown.timeLeft.value.inWholeMilliseconds}")
276290
}
277291

278-
instantProvider = { baseInstant + 400.milliseconds }
292+
advanceInstant(400.milliseconds)
279293
val found = waitForCondition {
280294
waitForIdle()
281295
runCatching { onNodeWithText("left=0").assertExists() }.isSuccess
@@ -287,8 +301,8 @@ class UseCountdownTest {
287301
@Test
288302
fun formatRes_updates_reactively() = runComposeUiTest {
289303
resetInstantProvider()
290-
val baseInstant = Clock.System.now()
291-
instantProvider = { baseInstant }
304+
val baseInstant = currentInstant
305+
advanceInstant(0.milliseconds)
292306
setContent {
293307
val countdown = useCountdown {
294308
leftTime = 2.seconds
@@ -305,12 +319,12 @@ class UseCountdownTest {
305319
}
306320
assertTrue(initialFound, "Expected initial seconds=2")
307321

308-
instantProvider = { baseInstant + 1100.milliseconds }
322+
advanceInstant(1100.milliseconds)
309323
val updatedFound = waitForCondition {
310324
waitForIdle()
311325
runCatching { onNodeWithText("seconds=1").assertExists() }.isSuccess ||
312326
runCatching { onNodeWithText("seconds=0").assertExists() }.isSuccess
313327
}
314328
assertTrue(updatedFound, "Expected seconds to update")
315329
}
316-
}
330+
}

hooks/src/desktopTest/kotlin/xyz/junerver/compose/hooks/test/UseRequestTest.kt

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,14 @@ import xyz.junerver.compose.hooks.userequest.useRequest
3030
Version: v1.0
3131
*/
3232
class UseRequestTest {
33+
private fun waitForCondition(maxAttempts: Int = 80, delayMs: Long = 50, condition: () -> Boolean): Boolean {
34+
for (i in 0 until maxAttempts) {
35+
if (condition()) return true
36+
Thread.sleep(delayMs)
37+
}
38+
return false
39+
}
40+
3341
@OptIn(ExperimentalTestApi::class)
3442
@Test
3543
fun autoRun_runs_once_with_defaultParams_and_not_rerun_on_recompose() = runComposeUiTest {
@@ -297,10 +305,8 @@ class UseRequestTest {
297305
Text("data=${holder.data.value}")
298306
}
299307

300-
waitForIdle()
301-
Thread.sleep(200)
302-
waitForIdle()
303-
308+
val found = waitForCondition { callCount.get() == 1 }
309+
assertTrue(found, "Expected debounce to invoke once")
304310
assertEquals(1, callCount.get())
305311
assertEquals(listOf("abc"), receivedParams)
306312
}

hooks/src/desktopTest/kotlin/xyz/junerver/compose/hooks/test/UseTimeoutPollTest.kt

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,14 @@ import xyz.junerver.compose.hooks.useTimeoutPoll
2121
*/
2222

2323
class UseTimeoutPollTest {
24+
private fun waitForCondition(maxAttempts: Int = 80, delayMs: Long = 50, condition: () -> Boolean): Boolean {
25+
for (i in 0 until maxAttempts) {
26+
if (condition()) return true
27+
Thread.sleep(delayMs)
28+
}
29+
return false
30+
}
31+
2432
@OptIn(ExperimentalTestApi::class)
2533
@Test
2634
fun poll_executes_repeatedly_with_interval() = runComposeUiTest {
@@ -297,15 +305,12 @@ class UseTimeoutPollTest {
297305
}
298306

299307
waitForIdle()
300-
Thread.sleep(450)
301-
waitForIdle()
302-
303-
// Should use updated multiplier after phase 0
304-
val found = runCatching {
305-
(20..50).any { c ->
308+
val found = waitForCondition(maxAttempts = 120, delayMs = 50) {
309+
waitForIdle()
310+
(20..80).any { c ->
306311
runCatching { onNodeWithText("count=$c multiplier=10 phase=1").assertExists() }.isSuccess
307312
}
308-
}.getOrElse { false }
313+
}
309314
assertTrue(found, "Expected count >= 20 with multiplier=10")
310315
}
311316

0 commit comments

Comments
 (0)