Skip to content

Commit fd5f1ae

Browse files
authored
feat: Implement RepeatNode (#14)
* feat: implement RepeatNode * test: add repeat node test * feat: add edge case - if count is below zero or zero * test: edge case * Apply suggestion from @l2hyunwoo * refactor: be more concise * Update jindong/src/commonTest/kotlin/io/github/jindong/node/RepeatNodeTest.kt * Update jindong/src/commonTest/kotlin/io/github/jindong/node/RepeatNodeTest.kt
1 parent c2d350e commit fd5f1ae

2 files changed

Lines changed: 301 additions & 0 deletions

File tree

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
/*
2+
* Copyright (C) 2026 compose-jindong
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package io.github.jindong.node
17+
18+
/**
19+
* A container node that repeats its children N times sequentially.
20+
*
21+
* Each repetition starts after the previous one completes. The timing is calculated
22+
* by tracking the maximum end time of events from each iteration.
23+
*
24+
* Special handling for [DelayNode]: advances time without generating events.
25+
*
26+
* Example:
27+
* ```
28+
* Repeat(count = 3) {
29+
* Haptic(50.ms) // starts at 0ms, 100ms, 200ms
30+
* Delay(50.ms) // advances to 100ms, 200ms, 300ms
31+
* }
32+
* ```
33+
*
34+
* @property count Number of times to repeat the children
35+
*/
36+
internal class RepeatNode(
37+
val count: Int,
38+
) : HapticNode {
39+
override val children: MutableList<HapticNode> = mutableListOf()
40+
41+
override fun collectEvents(startTimeMs: Long): List<ScheduledHapticEvent> {
42+
require(count >= 0) { "count must be non-negative, but was $count" }
43+
44+
return buildList {
45+
var currentTime = startTimeMs
46+
47+
repeat(count) {
48+
val (events, endTimeMs) = collectIterationEvents(currentTime)
49+
addAll(events)
50+
currentTime = endTimeMs
51+
}
52+
}
53+
}
54+
55+
/**
56+
* Collects events from one iteration of children.
57+
* Returns both the events and the end time of this iteration (including delays).
58+
*/
59+
private fun collectIterationEvents(startTimeMs: Long): IterationResult {
60+
val (currentTimeMs, events) = children.fold(
61+
initial = ChildState(startTimeMs, emptyList()),
62+
) { state, child ->
63+
when (child) {
64+
is DelayNode -> state.copy(currentTimeMs = state.currentTimeMs + child.durationMs)
65+
66+
else -> {
67+
val childEvents = child.collectEvents(state.currentTimeMs)
68+
val nextTime = childEvents
69+
.maxOfOrNull { it.startTimeMs + it.durationMs }
70+
?: state.currentTimeMs
71+
ChildState(
72+
currentTimeMs = nextTime,
73+
events = state.events + childEvents,
74+
)
75+
}
76+
}
77+
}
78+
return IterationResult(events = events, endTimeMs = currentTimeMs)
79+
}
80+
81+
private data class ChildState(
82+
val currentTimeMs: Long,
83+
val events: List<ScheduledHapticEvent>,
84+
)
85+
86+
private data class IterationResult(
87+
val events: List<ScheduledHapticEvent>,
88+
val endTimeMs: Long,
89+
)
90+
}
Lines changed: 211 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,211 @@
1+
/*
2+
* Copyright (C) 2026 compose-jindong
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package io.github.jindong.node
17+
18+
import io.kotest.assertions.assertSoftly
19+
import io.kotest.assertions.throwables.shouldThrow
20+
import io.kotest.core.spec.style.FunSpec
21+
import io.kotest.matchers.collections.shouldBeEmpty
22+
import io.kotest.matchers.collections.shouldHaveSize
23+
import io.kotest.matchers.shouldBe
24+
import io.kotest.matchers.string.shouldContain
25+
26+
class RepeatNodeTest :
27+
FunSpec({
28+
test("collectEvents should return empty list when no children") {
29+
val node = RepeatNode(count = 3)
30+
31+
val events = node.collectEvents(startTimeMs = 0)
32+
33+
events.shouldBeEmpty()
34+
}
35+
36+
test("collectEvents should return empty list when count is 0") {
37+
val node = RepeatNode(count = 0)
38+
node.children.add(HapticEventNode(durationMs = 100, intensity = 0.8f))
39+
40+
val events = node.collectEvents(startTimeMs = 0)
41+
42+
events.shouldBeEmpty()
43+
}
44+
45+
test("collectEvents should throw IllegalArgumentException when count is negative") {
46+
val node = RepeatNode(count = -1)
47+
node.children.add(HapticEventNode(durationMs = 100, intensity = 0.8f))
48+
49+
val exception = shouldThrow<IllegalArgumentException> {
50+
node.collectEvents(startTimeMs = 0)
51+
}
52+
exception.message shouldContain "count must be non-negative"
53+
}
54+
55+
test("collectEvents should repeat single child correctly") {
56+
val node = RepeatNode(count = 3)
57+
node.children.add(HapticEventNode(durationMs = 50, intensity = 0.8f))
58+
59+
val events = node.collectEvents(startTimeMs = 0)
60+
61+
assertSoftly {
62+
events shouldHaveSize 3
63+
events[0].startTimeMs shouldBe 0
64+
events[1].startTimeMs shouldBe 50
65+
events[2].startTimeMs shouldBe 100
66+
events.forEach { it.durationMs shouldBe 50 }
67+
events.forEach { it.intensity shouldBe 0.8f }
68+
}
69+
}
70+
71+
test("collectEvents should repeat multiple children sequentially") {
72+
val node = RepeatNode(count = 2)
73+
node.children.add(HapticEventNode(durationMs = 50, intensity = 1.0f))
74+
node.children.add(HapticEventNode(durationMs = 30, intensity = 0.5f))
75+
76+
val events = node.collectEvents(startTimeMs = 0)
77+
78+
assertSoftly {
79+
events shouldHaveSize 4
80+
81+
assertSoftly(events[0]) {
82+
startTimeMs shouldBe 0
83+
durationMs shouldBe 50
84+
}
85+
assertSoftly(events[1]) {
86+
startTimeMs shouldBe 50
87+
durationMs shouldBe 30
88+
}
89+
assertSoftly(events[2]) {
90+
startTimeMs shouldBe 80
91+
durationMs shouldBe 50
92+
}
93+
assertSoftly(events[3]) {
94+
startTimeMs shouldBe 130
95+
durationMs shouldBe 30
96+
}
97+
}
98+
}
99+
100+
test("collectEvents should offset next iteration start time by delay duration") {
101+
val node = RepeatNode(count = 2)
102+
node.children.add(HapticEventNode(durationMs = 50, intensity = 0.8f))
103+
node.children.add(DelayNode(durationMs = 100))
104+
105+
val events = node.collectEvents(startTimeMs = 0)
106+
107+
assertSoftly {
108+
events shouldHaveSize 2
109+
assertSoftly(events[0]) {
110+
startTimeMs shouldBe 0
111+
durationMs shouldBe 50
112+
}
113+
assertSoftly(events[1]) {
114+
startTimeMs shouldBe 150
115+
durationMs shouldBe 50
116+
}
117+
}
118+
}
119+
120+
test("collectEvents should not produce events for pure delay nodes") {
121+
val node = RepeatNode(count = 3)
122+
node.children.add(DelayNode(durationMs = 100))
123+
124+
val events = node.collectEvents(startTimeMs = 0)
125+
126+
events.shouldBeEmpty()
127+
}
128+
129+
test("collectEvents should preserve custom startTimeMs") {
130+
val node = RepeatNode(count = 2)
131+
node.children.add(HapticEventNode(durationMs = 50, intensity = 0.8f))
132+
133+
val events = node.collectEvents(startTimeMs = 500)
134+
135+
assertSoftly {
136+
events shouldHaveSize 2
137+
events[0].startTimeMs shouldBe 500
138+
events[1].startTimeMs shouldBe 550
139+
}
140+
}
141+
142+
test("collectEvents should handle nested SequenceNode") {
143+
val innerSequence = SequenceNode()
144+
innerSequence.children.add(HapticEventNode(durationMs = 30, intensity = 0.5f))
145+
innerSequence.children.add(HapticEventNode(durationMs = 20, intensity = 0.5f))
146+
147+
val node = RepeatNode(count = 2)
148+
node.children.add(innerSequence)
149+
150+
val events = node.collectEvents(startTimeMs = 0)
151+
152+
assertSoftly {
153+
events shouldHaveSize 4
154+
events[0].startTimeMs shouldBe 0
155+
events[1].startTimeMs shouldBe 30
156+
// starts at 50ms = 30 + 20
157+
events[2].startTimeMs shouldBe 50
158+
events[3].startTimeMs shouldBe 80
159+
}
160+
}
161+
162+
test("collectEvents should preserve iOS parameters") {
163+
val iosParams = IosHapticParameters(sharpness = 0.9f)
164+
val node = RepeatNode(count = 2)
165+
node.children.add(
166+
HapticEventNode(durationMs = 100, intensity = 0.8f, iosParameters = iosParams),
167+
)
168+
169+
val events = node.collectEvents(startTimeMs = 0)
170+
171+
assertSoftly {
172+
events shouldHaveSize 2
173+
events[0].iosParameters?.sharpness shouldBe 0.9f
174+
events[1].iosParameters?.sharpness shouldBe 0.9f
175+
}
176+
}
177+
178+
test("collectEvents should match documentation example - Repeat with Haptic and Delay") {
179+
// Example from docs:
180+
// Repeat(count = 3) {
181+
// Haptic(50.ms)
182+
// Delay(50.ms)
183+
// }
184+
val node = RepeatNode(count = 3)
185+
node.children.add(HapticEventNode(durationMs = 50, intensity = 1.0f))
186+
node.children.add(DelayNode(durationMs = 50))
187+
188+
val events = node.collectEvents(startTimeMs = 0)
189+
190+
assertSoftly {
191+
events shouldHaveSize 3
192+
events[0].startTimeMs shouldBe 0
193+
events[1].startTimeMs shouldBe 100
194+
events[2].startTimeMs shouldBe 200
195+
events.forEach { it.durationMs shouldBe 50 }
196+
}
197+
}
198+
199+
test("collectEvents with count = 1 should execute children once") {
200+
val node = RepeatNode(count = 1)
201+
node.children.add(HapticEventNode(durationMs = 100, intensity = 0.8f))
202+
203+
val events = node.collectEvents(startTimeMs = 0)
204+
205+
assertSoftly {
206+
events shouldHaveSize 1
207+
events[0].startTimeMs shouldBe 0
208+
events[0].durationMs shouldBe 100
209+
}
210+
}
211+
})

0 commit comments

Comments
 (0)