diff --git a/jindong-core/api/jindong-core.api b/jindong-core/api/jindong-core.api index 86c4681..43d7d11 100644 --- a/jindong-core/api/jindong-core.api +++ b/jindong-core/api/jindong-core.api @@ -186,6 +186,18 @@ public final class io/github/compose/jindong/core/model/IosHapticParameters { public fun toString ()Ljava/lang/String; } +public final class io/github/compose/jindong/core/model/RepeatedKt { + public static final fun repeated (Lio/github/compose/jindong/core/model/HapticPattern;I)Lio/github/compose/jindong/core/model/HapticPattern; +} + +public final class io/github/compose/jindong/core/model/ReversedKt { + public static final fun reversed (Lio/github/compose/jindong/core/model/HapticPattern;)Lio/github/compose/jindong/core/model/HapticPattern; +} + +public final class io/github/compose/jindong/core/model/ScaleIntensityKt { + public static final fun scaleIntensity (Lio/github/compose/jindong/core/model/HapticPattern;F)Lio/github/compose/jindong/core/model/HapticPattern; +} + public final class io/github/compose/jindong/core/model/ScheduledHapticEvent { public fun (JJLio/github/compose/jindong/core/model/HapticIntensity;Lio/github/compose/jindong/core/model/IosHapticParameters;)V public synthetic fun (JJLio/github/compose/jindong/core/model/HapticIntensity;Lio/github/compose/jindong/core/model/IosHapticParameters;ILkotlin/jvm/internal/DefaultConstructorMarker;)V @@ -204,3 +216,12 @@ public final class io/github/compose/jindong/core/model/ScheduledHapticEvent { public fun toString ()Ljava/lang/String; } +public final class io/github/compose/jindong/core/model/ThenKt { + public static final fun plus (Lio/github/compose/jindong/core/model/HapticPattern;Lio/github/compose/jindong/core/model/HapticPattern;)Lio/github/compose/jindong/core/model/HapticPattern; + public static final fun then (Lio/github/compose/jindong/core/model/HapticPattern;Lio/github/compose/jindong/core/model/HapticPattern;)Lio/github/compose/jindong/core/model/HapticPattern; +} + +public final class io/github/compose/jindong/core/model/TimeStretchKt { + public static final fun timeStretch (Lio/github/compose/jindong/core/model/HapticPattern;F)Lio/github/compose/jindong/core/model/HapticPattern; +} + diff --git a/jindong-core/api/jindong-core.klib.api b/jindong-core/api/jindong-core.klib.api index 5cc58cf..c321100 100644 --- a/jindong-core/api/jindong-core.klib.api +++ b/jindong-core/api/jindong-core.klib.api @@ -226,6 +226,12 @@ final val io.github.compose.jindong.core/ms // io.github.compose.jindong.core/ms final val io.github.compose.jindong.core/ms // io.github.compose.jindong.core/ms|@kotlin.Long{}ms[0] final fun (kotlin/Long).(): kotlin.time/Duration // io.github.compose.jindong.core/ms.|@kotlin.Long(){}[0] +final fun (io.github.compose.jindong.core.model/HapticPattern).io.github.compose.jindong.core.model/plus(io.github.compose.jindong.core.model/HapticPattern): io.github.compose.jindong.core.model/HapticPattern // io.github.compose.jindong.core.model/plus|plus@io.github.compose.jindong.core.model.HapticPattern(io.github.compose.jindong.core.model.HapticPattern){}[0] +final fun (io.github.compose.jindong.core.model/HapticPattern).io.github.compose.jindong.core.model/repeated(kotlin/Int): io.github.compose.jindong.core.model/HapticPattern // io.github.compose.jindong.core.model/repeated|repeated@io.github.compose.jindong.core.model.HapticPattern(kotlin.Int){}[0] +final fun (io.github.compose.jindong.core.model/HapticPattern).io.github.compose.jindong.core.model/reversed(): io.github.compose.jindong.core.model/HapticPattern // io.github.compose.jindong.core.model/reversed|reversed@io.github.compose.jindong.core.model.HapticPattern(){}[0] +final fun (io.github.compose.jindong.core.model/HapticPattern).io.github.compose.jindong.core.model/scaleIntensity(kotlin/Float): io.github.compose.jindong.core.model/HapticPattern // io.github.compose.jindong.core.model/scaleIntensity|scaleIntensity@io.github.compose.jindong.core.model.HapticPattern(kotlin.Float){}[0] +final fun (io.github.compose.jindong.core.model/HapticPattern).io.github.compose.jindong.core.model/then(io.github.compose.jindong.core.model/HapticPattern): io.github.compose.jindong.core.model/HapticPattern // io.github.compose.jindong.core.model/then|then@io.github.compose.jindong.core.model.HapticPattern(io.github.compose.jindong.core.model.HapticPattern){}[0] +final fun (io.github.compose.jindong.core.model/HapticPattern).io.github.compose.jindong.core.model/timeStretch(kotlin/Float): io.github.compose.jindong.core.model/HapticPattern // io.github.compose.jindong.core.model/timeStretch|timeStretch@io.github.compose.jindong.core.model.HapticPattern(kotlin.Float){}[0] final fun (io.github.compose.jindong.core.model/HapticPattern).io.github.compose.jindong.core/playAsync(): io.github.compose.jindong.core.executor/HapticHandle // io.github.compose.jindong.core/playAsync|playAsync@io.github.compose.jindong.core.model.HapticPattern(){}[0] final fun io.github.compose.jindong.core.dsl/buildHapticPattern(kotlin/Function1): io.github.compose.jindong.core.model/HapticPattern // io.github.compose.jindong.core.dsl/buildHapticPattern|buildHapticPattern(kotlin.Function1){}[0] final fun io.github.compose.jindong.core.executor/createHapticExecutor(kotlin/Any? = ...): io.github.compose.jindong.core.executor/HapticExecutor // io.github.compose.jindong.core.executor/createHapticExecutor|createHapticExecutor(kotlin.Any?){}[0] diff --git a/jindong-core/src/commonMain/kotlin/io/github/compose/jindong/core/element/PatternElement.kt b/jindong-core/src/commonMain/kotlin/io/github/compose/jindong/core/element/PatternElement.kt index efdcd6a..743e2fe 100644 --- a/jindong-core/src/commonMain/kotlin/io/github/compose/jindong/core/element/PatternElement.kt +++ b/jindong-core/src/commonMain/kotlin/io/github/compose/jindong/core/element/PatternElement.kt @@ -17,6 +17,7 @@ package io.github.compose.jindong.core.element import io.github.compose.jindong.core.model.HapticPattern import io.github.compose.jindong.core.model.ScheduledHapticEvent +import io.github.compose.jindong.core.model.spanMs /** * An element that includes an existing [HapticPattern]. @@ -52,12 +53,7 @@ class PatternElement( } } - override fun totalDurationMs(startTimeMs: Long): Long { - if (pattern.events.isEmpty()) return 0L - - // Find the maximum end time of all events in the pattern - return pattern.events.maxOf { event -> - event.startTimeMs + event.durationMs - } - } + // An included pattern's own span is its duration; startTimeMs is the caller's offset and is not + // folded in here (unlike sequential tree-sum elements). + override fun totalDurationMs(startTimeMs: Long): Long = pattern.spanMs() } diff --git a/jindong-core/src/commonMain/kotlin/io/github/compose/jindong/core/executor/RawSpanMs.kt b/jindong-core/src/commonMain/kotlin/io/github/compose/jindong/core/executor/RawSpanMs.kt index fce9793..10356d8 100644 --- a/jindong-core/src/commonMain/kotlin/io/github/compose/jindong/core/executor/RawSpanMs.kt +++ b/jindong-core/src/commonMain/kotlin/io/github/compose/jindong/core/executor/RawSpanMs.kt @@ -16,6 +16,7 @@ package io.github.compose.jindong.core.executor import io.github.compose.jindong.core.model.HapticPattern +import io.github.compose.jindong.core.model.spanMs /** * The raw span of a pattern: the latest event end on its timeline, or 0 for an empty pattern. @@ -24,4 +25,4 @@ import io.github.compose.jindong.core.model.HapticPattern * (Android compat segments, actuator quirks). Each executor derives its own playback length from this * according to its physics — see the platform `playbackDurationMs()` helpers. */ -internal fun HapticPattern.rawSpanMs(): Long = events.maxOfOrNull { it.startTimeMs + it.durationMs } ?: 0L +internal fun HapticPattern.rawSpanMs(): Long = spanMs() diff --git a/jindong-core/src/commonMain/kotlin/io/github/compose/jindong/core/model/PatternSpan.kt b/jindong-core/src/commonMain/kotlin/io/github/compose/jindong/core/model/PatternSpan.kt new file mode 100644 index 0000000..54869f8 --- /dev/null +++ b/jindong-core/src/commonMain/kotlin/io/github/compose/jindong/core/model/PatternSpan.kt @@ -0,0 +1,25 @@ +/* + * Copyright (C) 2026 compose-jindong + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.github.compose.jindong.core.model + +/** + * The span of a pattern: the latest event end on its timeline, or 0 for an empty pattern. + * + * This is the raw timeline extent of the scheduled events, independent of any tree structure or + * platform post-processing. It is the shared basis for [reversed] mirroring, [then] offsetting, + * executor playback length, and [io.github.compose.jindong.core.element.PatternElement] duration. + */ +internal fun HapticPattern.spanMs(): Long = events.maxOfOrNull { it.startTimeMs + it.durationMs } ?: 0L diff --git a/jindong-core/src/commonMain/kotlin/io/github/compose/jindong/core/model/Repeated.kt b/jindong-core/src/commonMain/kotlin/io/github/compose/jindong/core/model/Repeated.kt new file mode 100644 index 0000000..112229e --- /dev/null +++ b/jindong-core/src/commonMain/kotlin/io/github/compose/jindong/core/model/Repeated.kt @@ -0,0 +1,30 @@ +/* + * Copyright (C) 2026 compose-jindong + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.github.compose.jindong.core.model + +/** + * Returns a new pattern that plays this pattern back-to-back [times] times. + * + * Defined by folding [then], so `repeated(n)` equals `this then this then ...` n copies: + * `repeated(1)` is this pattern, `repeated(0)` is [HapticPattern.Empty], and each copy after the + * first is offset by one span so the copies never overlap. + * + * @param times non-negative repeat count + */ +public fun HapticPattern.repeated(times: Int): HapticPattern { + require(times >= 0) { "times must be non-negative, was $times" } + return (1 until times).fold(if (times == 0) HapticPattern.Empty else this) { acc, _ -> acc then this } +} diff --git a/jindong-core/src/commonMain/kotlin/io/github/compose/jindong/core/model/Reversed.kt b/jindong-core/src/commonMain/kotlin/io/github/compose/jindong/core/model/Reversed.kt new file mode 100644 index 0000000..d876371 --- /dev/null +++ b/jindong-core/src/commonMain/kotlin/io/github/compose/jindong/core/model/Reversed.kt @@ -0,0 +1,35 @@ +/* + * Copyright (C) 2026 compose-jindong + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.github.compose.jindong.core.model + +/** + * Returns a new pattern with events mirrored across the pattern span. + * + * Each event is reflected on `[0, span]`: `newStart = span - (start + duration)`, keeping its + * duration. Intensity and iOS parameters are unchanged. Interior gaps make the mirror overlap + * neighbors; overlaps are left as-is and resolved at playback by the serial merge (highest intensity + * wins), so this stays a pure timeline reflection. Compiled patterns are anchored at 0, so reflecting + * twice recovers the original; a leading gap (earliest event not at 0) would fold events off the + * front and is not a shape the builder produces. + */ +public fun HapticPattern.reversed(): HapticPattern { + val span = spanMs() + return copy( + events = events.map { event -> + event.copy(startTimeMs = span - (event.startTimeMs + event.durationMs)) + }, + ) +} diff --git a/jindong-core/src/commonMain/kotlin/io/github/compose/jindong/core/model/ScaleIntensity.kt b/jindong-core/src/commonMain/kotlin/io/github/compose/jindong/core/model/ScaleIntensity.kt new file mode 100644 index 0000000..ddff882 --- /dev/null +++ b/jindong-core/src/commonMain/kotlin/io/github/compose/jindong/core/model/ScaleIntensity.kt @@ -0,0 +1,34 @@ +/* + * Copyright (C) 2026 compose-jindong + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.github.compose.jindong.core.model + +/** + * Returns a new pattern with every event's intensity multiplied by [factor]. + * + * Every level is mapped to [HapticIntensity.Custom], since a scaled predefined level (a dimmed + * `STRONG`, say) has no predefined equivalent. `Custom` clamps to [0, 1], so `factor` above 1 is + * safe and saturates rather than overflowing. Timing and iOS parameters are unchanged. + * + * @param factor non-negative intensity multiplier + */ +public fun HapticPattern.scaleIntensity(factor: Float): HapticPattern { + require(factor >= 0f) { "factor must be non-negative, was $factor" } + return copy( + events = events.map { event -> + event.copy(intensity = HapticIntensity.Custom(event.intensity.value * factor)) + }, + ) +} diff --git a/jindong-core/src/commonMain/kotlin/io/github/compose/jindong/core/model/Then.kt b/jindong-core/src/commonMain/kotlin/io/github/compose/jindong/core/model/Then.kt new file mode 100644 index 0000000..7fa3fe6 --- /dev/null +++ b/jindong-core/src/commonMain/kotlin/io/github/compose/jindong/core/model/Then.kt @@ -0,0 +1,37 @@ +/* + * Copyright (C) 2026 compose-jindong + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.github.compose.jindong.core.model + +/** + * Returns a new pattern that plays this pattern, then [other] appended after it. + * + * Every event of [other] is shifted by this pattern's span, so the two timelines never overlap and + * `span(a then b) == span(a) + span(b)`. Intensity and iOS parameters are carried unchanged. The + * empty pattern is the identity for [then]. + */ +public infix fun HapticPattern.then(other: HapticPattern): HapticPattern { + val offset = spanMs() + return copy( + events = events + other.events.map { event -> + event.copy(startTimeMs = event.startTimeMs + offset) + }, + ) +} + +/** + * Operator alias for [then], so patterns can be concatenated with `a + b`. + */ +public operator fun HapticPattern.plus(other: HapticPattern): HapticPattern = this then other diff --git a/jindong-core/src/commonMain/kotlin/io/github/compose/jindong/core/model/TimeStretch.kt b/jindong-core/src/commonMain/kotlin/io/github/compose/jindong/core/model/TimeStretch.kt new file mode 100644 index 0000000..180282c --- /dev/null +++ b/jindong-core/src/commonMain/kotlin/io/github/compose/jindong/core/model/TimeStretch.kt @@ -0,0 +1,41 @@ +/* + * Copyright (C) 2026 compose-jindong + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.github.compose.jindong.core.model + +import kotlin.math.roundToLong + +/** + * Returns a new pattern with every event's timeline scaled by [factor]. + * + * Each event's edges are scaled and rounded independently, then the duration is derived as the gap + * between them: `newDuration = round((start + duration) * f) - round(start * f)`. Rounding the two + * edges rather than start-and-duration keeps adjacent event boundaries aligned — a shared boundary + * rounds to one value, so events that touched before still touch after. Intensity and iOS parameters + * (including ADSR envelope times) are carried unchanged: the envelope shapes each event internally + * and is independent of where the event sits on the pattern timeline. + * + * @param factor positive timeline multiplier + */ +public fun HapticPattern.timeStretch(factor: Float): HapticPattern { + require(factor > 0f) { "factor must be positive, was $factor" } + return copy( + events = events.map { event -> + val newStart = (event.startTimeMs * factor).roundToLong() + val newEnd = ((event.startTimeMs + event.durationMs) * factor).roundToLong() + event.copy(startTimeMs = newStart, durationMs = newEnd - newStart) + }, + ) +} diff --git a/jindong-core/src/commonTest/kotlin/io/github/compose/jindong/core/model/PatternArb.kt b/jindong-core/src/commonTest/kotlin/io/github/compose/jindong/core/model/PatternArb.kt new file mode 100644 index 0000000..6a1e20d --- /dev/null +++ b/jindong-core/src/commonTest/kotlin/io/github/compose/jindong/core/model/PatternArb.kt @@ -0,0 +1,47 @@ +/* + * Copyright (C) 2026 compose-jindong + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.github.compose.jindong.core.model + +import io.kotest.property.Arb +import io.kotest.property.arbitrary.arbitrary +import io.kotest.property.arbitrary.bind +import io.kotest.property.arbitrary.float +import io.kotest.property.arbitrary.int +import io.kotest.property.arbitrary.long + +/** + * Generates arbitrary haptic patterns for property tests: 0..5 events, each with a positive duration + * (1..200ms), intensities in [0, 1], and interior offsets that may overlap or leave gaps — exactly + * what the transforms must tolerate. + * + * The timeline is anchored at 0 (the earliest event starts at 0), which is what compiled patterns + * look like: `buildHapticPattern` collects events from `startTimeMs = 0`. `reversed()` reflects on + * `[0, span]`, so its involution `reversed().reversed() == p` holds precisely for anchored patterns; + * a leading gap would let a reflection fold events off the front and lose the anchor. + */ +internal fun patterns(): Arb = arbitrary { rs -> + val count = Arb.int(0..5).bind() + val offsets = List(count) { Arb.long(0L..500L).bind() } + val floor = offsets.minOrNull() ?: 0L + val events = offsets.map { offset -> + ScheduledHapticEvent( + startTimeMs = offset - floor, + durationMs = Arb.long(1L..200L).bind(), + intensity = HapticIntensity.Custom(Arb.float(0f..1f).bind()), + ) + } + HapticPattern(events) +} diff --git a/jindong-core/src/commonTest/kotlin/io/github/compose/jindong/core/model/RepeatedTest.kt b/jindong-core/src/commonTest/kotlin/io/github/compose/jindong/core/model/RepeatedTest.kt new file mode 100644 index 0000000..c6172eb --- /dev/null +++ b/jindong-core/src/commonTest/kotlin/io/github/compose/jindong/core/model/RepeatedTest.kt @@ -0,0 +1,54 @@ +/* + * Copyright (C) 2026 compose-jindong + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.github.compose.jindong.core.model + +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.shouldBe +import io.kotest.property.Arb +import io.kotest.property.arbitrary.int +import io.kotest.property.checkAll + +private fun repeatCounts(): Arb = Arb.int(0..5) + +class RepeatedTest : + FunSpec({ + test("repeating once is the identity") { + checkAll(patterns()) { pattern -> + pattern.repeated(1) shouldBe pattern + } + } + + test("repeating zero times is the empty pattern") { + checkAll(patterns()) { pattern -> + pattern.repeated(0) shouldBe HapticPattern.Empty + } + } + + test("repeated event count is the source count times the repeat count") { + checkAll(patterns(), repeatCounts()) { pattern, times -> + pattern.repeated(times).events.size shouldBe pattern.events.size * times + } + } + + test("repeated equals folding then n times") { + checkAll(patterns(), repeatCounts()) { pattern, times -> + val folded = (1 until times).fold( + if (times == 0) HapticPattern.Empty else pattern, + ) { acc, _ -> acc then pattern } + pattern.repeated(times) shouldBe folded + } + } + }) diff --git a/jindong-core/src/commonTest/kotlin/io/github/compose/jindong/core/model/ReversedTest.kt b/jindong-core/src/commonTest/kotlin/io/github/compose/jindong/core/model/ReversedTest.kt new file mode 100644 index 0000000..1b9afac --- /dev/null +++ b/jindong-core/src/commonTest/kotlin/io/github/compose/jindong/core/model/ReversedTest.kt @@ -0,0 +1,43 @@ +/* + * Copyright (C) 2026 compose-jindong + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.github.compose.jindong.core.model + +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.shouldBe +import io.kotest.property.checkAll + +class ReversedTest : + FunSpec({ + test("reversing twice is the identity") { + checkAll(patterns()) { pattern -> + pattern.reversed().reversed() shouldBe pattern + } + } + + test("reversing preserves the span") { + checkAll(patterns()) { pattern -> + pattern.reversed().spanMs() shouldBe pattern.spanMs() + } + } + + test("reversing preserves event count and durations") { + checkAll(patterns()) { pattern -> + val reversed = pattern.reversed() + reversed.events.size shouldBe pattern.events.size + reversed.events.map { it.durationMs }.sorted() shouldBe pattern.events.map { it.durationMs }.sorted() + } + } + }) diff --git a/jindong-core/src/commonTest/kotlin/io/github/compose/jindong/core/model/ScaleIntensityTest.kt b/jindong-core/src/commonTest/kotlin/io/github/compose/jindong/core/model/ScaleIntensityTest.kt new file mode 100644 index 0000000..ee1863c --- /dev/null +++ b/jindong-core/src/commonTest/kotlin/io/github/compose/jindong/core/model/ScaleIntensityTest.kt @@ -0,0 +1,59 @@ +/* + * Copyright (C) 2026 compose-jindong + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.github.compose.jindong.core.model + +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.floats.plusOrMinus +import io.kotest.matchers.floats.shouldBeGreaterThanOrEqual +import io.kotest.matchers.floats.shouldBeLessThanOrEqual +import io.kotest.matchers.shouldBe +import io.kotest.property.Arb +import io.kotest.property.arbitrary.float +import io.kotest.property.checkAll + +private fun factors(): Arb = Arb.float(0f..4f) + +class ScaleIntensityTest : + FunSpec({ + test("scaled intensity always stays within [0, 1]") { + checkAll(patterns(), factors()) { pattern, factor -> + pattern.scaleIntensity(factor).events.forEach { event -> + event.intensity.value shouldBeGreaterThanOrEqual 0f + event.intensity.value shouldBeLessThanOrEqual 1f + } + } + } + + test("scaling by 1 preserves every intensity value") { + checkAll(patterns()) { pattern -> + val scaled = pattern.scaleIntensity(1f) + scaled.events.zip(pattern.events).forEach { (s, p) -> + s.intensity.value shouldBe (p.intensity.value plusOrMinus 1e-6f) + } + } + } + + test("scaling leaves timing and iOS parameters untouched") { + checkAll(patterns(), factors()) { pattern, factor -> + val scaled = pattern.scaleIntensity(factor) + scaled.events.zip(pattern.events).forEach { (s, p) -> + s.startTimeMs shouldBe p.startTimeMs + s.durationMs shouldBe p.durationMs + s.iosParameters shouldBe p.iosParameters + } + } + } + }) diff --git a/jindong-core/src/commonTest/kotlin/io/github/compose/jindong/core/model/ThenTest.kt b/jindong-core/src/commonTest/kotlin/io/github/compose/jindong/core/model/ThenTest.kt new file mode 100644 index 0000000..98bb4d8 --- /dev/null +++ b/jindong-core/src/commonTest/kotlin/io/github/compose/jindong/core/model/ThenTest.kt @@ -0,0 +1,56 @@ +/* + * Copyright (C) 2026 compose-jindong + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.github.compose.jindong.core.model + +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.shouldBe +import io.kotest.property.checkAll + +class ThenTest : + FunSpec({ + test("then is associative on the resulting event timeline") { + checkAll(patterns(), patterns(), patterns()) { a, b, c -> + val left = (a then b) then c + val right = a then (b then c) + left.events shouldBe right.events + } + } + + test("the empty pattern is a left and right identity for then") { + checkAll(patterns()) { pattern -> + (HapticPattern.Empty then pattern).events shouldBe pattern.events + (pattern then HapticPattern.Empty).events shouldBe pattern.events + } + } + + test("appended span is the sum of the two spans") { + checkAll(patterns(), patterns()) { a, b -> + (a then b).spanMs() shouldBe a.spanMs() + b.spanMs() + } + } + + test("appended event count is the sum of the two counts") { + checkAll(patterns(), patterns()) { a, b -> + (a then b).events.size shouldBe a.events.size + b.events.size + } + } + + test("plus is an alias for then") { + checkAll(patterns(), patterns()) { a, b -> + (a + b) shouldBe (a then b) + } + } + }) diff --git a/jindong-core/src/commonTest/kotlin/io/github/compose/jindong/core/model/TimeStretchTest.kt b/jindong-core/src/commonTest/kotlin/io/github/compose/jindong/core/model/TimeStretchTest.kt new file mode 100644 index 0000000..8be799d --- /dev/null +++ b/jindong-core/src/commonTest/kotlin/io/github/compose/jindong/core/model/TimeStretchTest.kt @@ -0,0 +1,68 @@ +/* + * Copyright (C) 2026 compose-jindong + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.github.compose.jindong.core.model + +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.longs.shouldBeGreaterThanOrEqual +import io.kotest.matchers.longs.shouldBeLessThanOrEqual +import io.kotest.matchers.shouldBe +import io.kotest.property.Arb +import io.kotest.property.arbitrary.float +import io.kotest.property.checkAll +import kotlin.math.abs +import kotlin.math.ceil +import kotlin.math.roundToLong + +private fun factors(): Arb = Arb.float(0.1f..5f) + +class TimeStretchTest : + FunSpec({ + test("stretching by 1 is the identity") { + checkAll(patterns()) { pattern -> + pattern.timeStretch(1f) shouldBe pattern + } + } + + test("stretching preserves event count and never yields a negative duration") { + checkAll(patterns(), factors()) { pattern, factor -> + val stretched = pattern.timeStretch(factor) + stretched.events.size shouldBe pattern.events.size + stretched.events.forEach { it.durationMs shouldBeGreaterThanOrEqual 0L } + } + } + + test("composing two stretches equals stretching by the product, within rounding drift") { + checkAll(patterns(), factors(), factors()) { pattern, a, b -> + val composed = pattern.timeStretch(a).timeStretch(b) + val combined = pattern.timeStretch(a * b) + // The composed path rounds each edge with factor a (±0.5) and then scales that error by b and + // rounds again (±0.5), so per-edge drift is bounded by b * 0.5 + 0.5. Round up and add a 1ms + // float-slack margin; duration is the difference of two edges, so it gets twice the bound. + val edgeTolerance = ceil(b * 0.5 + 0.5).toLong() + 1L + composed.events.zip(combined.events).forEach { (c, k) -> + abs(c.startTimeMs - k.startTimeMs) shouldBeLessThanOrEqual edgeTolerance + abs(c.durationMs - k.durationMs) shouldBeLessThanOrEqual 2 * edgeTolerance + } + } + } + + test("stretched span equals the rounded scaled span, within rounding drift") { + checkAll(patterns(), factors()) { pattern, factor -> + val expected = (pattern.spanMs() * factor).roundToLong() + abs(pattern.timeStretch(factor).spanMs() - expected) shouldBeLessThanOrEqual 1L + } + } + })