@@ -7,80 +7,66 @@ import kotlinx.coroutines.flow.toList
77import kotlinx.coroutines.launch
88import kotlinx.coroutines.runBlocking
99import kotlinx.coroutines.test.runTest
10+ import kotlinx.coroutines.yield
1011import org.junit.Assert.*
1112import org.junit.Test
1213
1314/* *
1415 * Unit tests for ProgressReporter.
1516 *
16- * Tests:
17- * - Progress reporting with valid values
18- * - Progress clamping (0-100)
19- * - Message formatting
20- * - Step-based progress calculation
21- * - Flow emission and collection
22- * - Thread safety
17+ * NOTE: ProgressReporter uses SharedFlow(replay=0). Tests must yield() after
18+ * launching a collector to ensure subscription is active before emitting.
2319 */
2420class ProgressReporterTest {
2521
2622 @Test
2723 fun `reportProgress emits valid progress update` () = runTest {
28- val taskId = " test-task-1"
24+ val taskId = " test-task-progress- 1"
2925 val progress = 50
3026 val message = " Processing..."
3127
32- // Collect first emission
3328 val job = launch {
3429 val update = ProgressReporter .progressFlow.first()
35-
3630 assertEquals(taskId, update.taskId)
3731 assertEquals(progress, update.progress)
3832 assertEquals(message, update.message)
3933 }
4034
41- // Emit progress
42- ProgressReporter .reportProgress(
43- taskId = taskId,
44- progress = progress,
45- message = message
46- )
35+ yield () // Let the collector subscribe before emitting
4736
37+ ProgressReporter .reportProgress(taskId = taskId, progress = progress, message = message)
4838 job.join()
4939 }
5040
5141 @Test
5242 fun `reportProgress clamps progress to 0-100 range` () = runTest {
53- val taskId = " test-task-2 "
43+ val taskId = " test-task-clamp-1 "
5444
55- // Test over 100
5645 val job1 = launch {
5746 val update = ProgressReporter .progressFlow.first()
5847 assertEquals(100 , update.progress)
5948 }
49+ yield ()
6050 ProgressReporter .reportProgress(taskId, progress = 150 )
6151 job1.join()
6252
63- // Test under 0
53+ val taskId2 = " test-task-clamp-2 "
6454 val job2 = launch {
6555 val update = ProgressReporter .progressFlow.first()
6656 assertEquals(0 , update.progress)
6757 }
68- ProgressReporter .reportProgress(taskId, progress = - 50 )
58+ yield ()
59+ ProgressReporter .reportProgress(taskId2, progress = - 50 )
6960 job2.join()
7061 }
7162
7263 @Test
7364 fun `reportProgress validates progress range` () = runTest {
7465 try {
75- // This should work (boundary)
76- ProgressReporter .reportProgress(" task" , progress = 0 )
77- ProgressReporter .reportProgress(" task" , progress = 100 )
78-
79- // This should clamp but not throw
80- ProgressReporter .reportProgress(" task" , progress = 101 )
81- ProgressReporter .reportProgress(" task" , progress = - 1 )
82-
83- // If we get here, clamping worked
66+ ProgressReporter .reportProgress(" task-validate" , progress = 0 )
67+ ProgressReporter .reportProgress(" task-validate-b" , progress = 100 )
68+ ProgressReporter .reportProgress(" task-validate-c" , progress = 101 )
69+ ProgressReporter .reportProgress(" task-validate-d" , progress = - 1 )
8470 assertTrue(true )
8571 } catch (e: Exception ) {
8672 fail(" Progress should be clamped, not throw: ${e.message} " )
@@ -89,76 +75,73 @@ class ProgressReporterTest {
8975
9076 @Test
9177 fun `reportStep calculates progress correctly` () = runTest {
92- val taskId = " test-task-3 "
78+ val taskId = " test-task-step-1 "
9379 val totalSteps = 10
9480
95- // Test 0%
9681 val job1 = launch {
9782 val update = ProgressReporter .progressFlow.first()
9883 assertEquals(0 , update.progress)
9984 assertEquals(0 , update.currentStep)
10085 assertEquals(totalSteps, update.totalSteps)
10186 }
87+ yield ()
10288 ProgressReporter .reportStep(taskId, currentStep = 0 , totalSteps = totalSteps)
10389 job1.join()
10490
105- // Test 50%
91+ val taskId2 = " test-task-step-2 "
10692 val job2 = launch {
10793 val update = ProgressReporter .progressFlow.first()
10894 assertEquals(50 , update.progress)
10995 assertEquals(5 , update.currentStep)
11096 }
111- ProgressReporter .reportStep(taskId, currentStep = 5 , totalSteps = totalSteps)
97+ yield ()
98+ ProgressReporter .reportStep(taskId2, currentStep = 5 , totalSteps = totalSteps)
11299 job2.join()
113100
114- // Test 100%
101+ val taskId3 = " test-task-step-3 "
115102 val job3 = launch {
116103 val update = ProgressReporter .progressFlow.first()
117104 assertEquals(100 , update.progress)
118105 assertEquals(10 , update.currentStep)
119106 }
120- ProgressReporter .reportStep(taskId, currentStep = 10 , totalSteps = totalSteps)
107+ yield ()
108+ ProgressReporter .reportStep(taskId3, currentStep = 10 , totalSteps = totalSteps)
121109 job3.join()
122110 }
123111
124112 @Test
125113 fun `reportStep handles zero totalSteps gracefully` () = runTest {
126- val taskId = " test-task-4 "
114+ val taskId = " test-task-zero-steps "
127115
128116 val job = launch {
129117 val update = ProgressReporter .progressFlow.first()
130- assertEquals(0 , update.progress) // Should default to 0
118+ assertEquals(0 , update.progress)
131119 assertEquals(5 , update.currentStep)
132120 assertEquals(0 , update.totalSteps)
133121 }
122+ yield ()
134123
135- ProgressReporter .reportStep(
136- taskId = taskId,
137- currentStep = 5 ,
138- totalSteps = 0 // Invalid but should handle gracefully
139- )
140-
124+ ProgressReporter .reportStep(taskId = taskId, currentStep = 5 , totalSteps = 0 )
141125 job.join()
142126 }
143127
144128 @Test
145129 fun `multiple progress updates are emitted in order` () = runTest {
146- val taskId = " test-task-5 "
130+ val taskId = " test-task-order "
147131 val progressValues = listOf (0 , 25 , 50 , 75 , 100 )
148132
149133 val job = launch {
150134 val updates = ProgressReporter .progressFlow.take(5 ).toList()
151-
152135 assertEquals(5 , updates.size)
153136 updates.forEachIndexed { index, update ->
154137 assertEquals(taskId, update.taskId)
155138 assertEquals(progressValues[index], update.progress)
156139 }
157140 }
141+ yield ()
158142
159- // Emit multiple progress updates
160- progressValues.forEach { progress ->
161- ProgressReporter .reportProgress(taskId, progress)
143+ progressValues.forEach { p ->
144+ ProgressReporter .reportProgress(taskId, p)
162145 }
163146
164147 job.join()
@@ -167,7 +150,7 @@ class ProgressReporterTest {
167150 @Test
168151 fun `toMap converts ProgressUpdate correctly` () = runBlocking {
169152 val update = ProgressReporter .ProgressUpdate (
170- taskId = " test-task-6 " ,
153+ taskId = " test-task-tomap " ,
171154 progress = 75 ,
172155 message = " Processing file" ,
173156 currentStep = 3 ,
@@ -176,7 +159,7 @@ class ProgressReporterTest {
176159
177160 val map = update.toMap()
178161
179- assertEquals(" test-task-6 " , map[" taskId" ])
162+ assertEquals(" test-task-tomap " , map[" taskId" ])
180163 assertEquals(75 , map[" progress" ])
181164 assertEquals(" Processing file" , map[" message" ])
182165 assertEquals(3 , map[" currentStep" ])
@@ -186,7 +169,7 @@ class ProgressReporterTest {
186169 @Test
187170 fun `toMap handles null optional fields` () = runBlocking {
188171 val update = ProgressReporter .ProgressUpdate (
189- taskId = " test-task-7 " ,
172+ taskId = " test-task-nullmap " ,
190173 progress = 50 ,
191174 message = null ,
192175 currentStep = null ,
@@ -195,7 +178,7 @@ class ProgressReporterTest {
195178
196179 val map = update.toMap()
197180
198- assertEquals(" test-task-7 " , map[" taskId" ])
181+ assertEquals(" test-task-nullmap " , map[" taskId" ])
199182 assertEquals(50 , map[" progress" ])
200183 assertFalse(map.containsKey(" message" ))
201184 assertFalse(map.containsKey(" currentStep" ))
@@ -204,31 +187,26 @@ class ProgressReporterTest {
204187
205188 @Test
206189 fun `concurrent progress updates are handled safely` () = runTest {
207- val taskIds = ( 1 .. 100 ).map { " task- $it " }
208- val expectedUpdates = 100
190+ val count = 100
191+ val taskIds = ( 1 .. count).map { " concurrent-task- $it " }
209192
210193 val job = launch {
211- val updates = ProgressReporter .progressFlow.take(expectedUpdates).toList()
212- assertEquals(expectedUpdates, updates.size)
213-
214- // All task IDs should be present
194+ val updates = ProgressReporter .progressFlow.take(count).toList()
195+ assertEquals(count, updates.size)
215196 val receivedIds = updates.map { it.taskId }.toSet()
216- assertEquals(expectedUpdates , receivedIds.size)
197+ assertEquals(count , receivedIds.size)
217198 }
199+ yield ()
218200
219- // Emit from multiple "concurrent" coroutines
220201 taskIds.forEach { taskId ->
221- launch {
222- ProgressReporter .reportProgress(taskId, progress = 50 )
223- }
202+ launch { ProgressReporter .reportProgress(taskId, progress = 50 ) }
224203 }
225204
226205 job.join()
227206 }
228207
229208 @Test
230209 fun `progress message formatting` () = runTest {
231- val taskId = " test-task-8"
232210 val messages = listOf (
233211 " Starting..." ,
234212 " Processing file 1 of 10" ,
@@ -238,14 +216,15 @@ class ProgressReporterTest {
238216
239217 val job = launch {
240218 val updates = ProgressReporter .progressFlow.take(messages.size).toList()
241-
242219 updates.forEachIndexed { index, update ->
243220 assertEquals(messages[index], update.message)
244221 }
245222 }
223+ yield ()
246224
247- messages.forEach { message ->
248- ProgressReporter .reportProgress(taskId, progress = 25 , message = message)
225+ // Use unique taskId per message to bypass the 1%-change de-bounce filter
226+ messages.forEachIndexed { index, message ->
227+ ProgressReporter .reportProgress(" msg-task-$index " , progress = 25 , message = message)
249228 }
250229
251230 job.join()
0 commit comments