P5 Streams Q+R: TaskAnimations + AnimationTestingScreen
Port iOS TaskAnimations.swift specs (completion checkmark, card transitions, priority pulse) + AnimationTestingView as dev screen. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
+79
@@ -0,0 +1,79 @@
|
||||
package com.tt.honeyDue.ui.animation
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNotNull
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* P5 Stream R — unit tests for [AnimationTestingScreenState].
|
||||
*
|
||||
* Pure state-machine tests (no Compose runtime). Mirrors the behavior of
|
||||
* the iOS `AnimationTestingView` — a dev-only screen that lists each
|
||||
* registered animation and lets the user pick/play one.
|
||||
*/
|
||||
class AnimationTestingScreenStateTest {
|
||||
|
||||
@Test
|
||||
fun initial_noAnimationSelected_playCountZero() {
|
||||
val state = AnimationTestingScreenState()
|
||||
assertNull(state.selected, "no animation selected on launch")
|
||||
assertEquals(0, state.playCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun availableAnimations_matchesRegistry() {
|
||||
val state = AnimationTestingScreenState()
|
||||
// Screen must list every animation that TaskAnimations registers.
|
||||
assertEquals(TaskAnimations.all.size, state.available.size)
|
||||
assertTrue(state.available.any { it.name == "completionCheckmark" })
|
||||
assertTrue(state.available.any { it.name == "cardEnter" })
|
||||
assertTrue(state.available.any { it.name == "cardDismiss" })
|
||||
assertTrue(state.available.any { it.name == "priorityPulse" })
|
||||
assertTrue(state.available.any { it.name == "honeycombLoop" })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun onRowTap_setsSelected() {
|
||||
val state = AnimationTestingScreenState()
|
||||
state.onRowTap(TaskAnimations.cardEnter)
|
||||
assertNotNull(state.selected)
|
||||
assertEquals("cardEnter", state.selected?.name)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun onRowTap_swappingSelection_replaces() {
|
||||
val state = AnimationTestingScreenState()
|
||||
state.onRowTap(TaskAnimations.cardEnter)
|
||||
state.onRowTap(TaskAnimations.cardDismiss)
|
||||
assertEquals("cardDismiss", state.selected?.name)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun onPlay_withoutSelection_isNoop() {
|
||||
val state = AnimationTestingScreenState()
|
||||
state.onPlay()
|
||||
assertEquals(0, state.playCount, "play is a no-op when nothing is selected")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun onPlay_incrementsCounterOnlyWhenSelected() {
|
||||
val state = AnimationTestingScreenState()
|
||||
state.onRowTap(TaskAnimations.priorityPulse)
|
||||
state.onPlay()
|
||||
state.onPlay()
|
||||
state.onPlay()
|
||||
assertEquals(3, state.playCount, "play count increments for each trigger")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun onReset_clearsSelectionAndCounter() {
|
||||
val state = AnimationTestingScreenState()
|
||||
state.onRowTap(TaskAnimations.honeycombLoop)
|
||||
state.onPlay()
|
||||
state.onReset()
|
||||
assertNull(state.selected)
|
||||
assertEquals(0, state.playCount)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
package com.tt.honeyDue.ui.animation
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNotNull
|
||||
import kotlin.test.assertTrue
|
||||
import kotlin.math.abs
|
||||
|
||||
/**
|
||||
* P5 Stream Q — unit tests for [TaskAnimations] spec objects.
|
||||
*
|
||||
* Pure-Kotlin tests that assert iOS-parity timings and deterministic
|
||||
* keyframe output. The specs in [TaskAnimations] expose a [sample] method
|
||||
* that returns a frame value at a fixed time offset in milliseconds — no
|
||||
* Compose runtime is involved, so the specs are fully testable in
|
||||
* commonTest.
|
||||
*
|
||||
* iOS reference:
|
||||
* `iosApp/iosApp/Profile/AnimationTesting/TaskAnimations.swift`
|
||||
* — the implode/firework/starburst/ripple checkmark phases all use
|
||||
* `.spring(response: 0.4, dampingFraction: 0.6)` or similar, preceded
|
||||
* by ease-in shrinks of 0.25-0.3s and trailing 1.5s holds + 0.3-0.4s
|
||||
* fade-outs.
|
||||
*/
|
||||
class TaskAnimationsTest {
|
||||
|
||||
private val tol = 0.001f
|
||||
|
||||
// ---- Duration assertions (matches iOS) ----
|
||||
|
||||
@Test
|
||||
fun completionCheckmark_springDuration_matchesIos() {
|
||||
// iOS: .spring(response: 0.4, dampingFraction: 0.6)
|
||||
// "response" in SwiftUI is roughly the period in seconds (400ms).
|
||||
val spec = TaskAnimations.completionCheckmark
|
||||
assertEquals(400, spec.springResponseMillis, "spring response = 400ms")
|
||||
assertTrue(abs(spec.dampingRatio - 0.6f) < tol, "dampingFraction = 0.6")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun cardEnter_duration_matchesIos() {
|
||||
// iOS uses ~350ms spring for the "entering" phase (300ms delay +
|
||||
// 200ms reset window after the move).
|
||||
val spec = TaskAnimations.cardEnter
|
||||
assertEquals(350, spec.durationMillis, "cardEnter = 350ms")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun cardDismiss_duration_matchesIos() {
|
||||
// iOS: easeIn(duration: 0.3) for the shrink-out phase on the card.
|
||||
val spec = TaskAnimations.cardDismiss
|
||||
assertEquals(300, spec.durationMillis, "cardDismiss = 300ms")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun priorityPulse_period_matchesIos() {
|
||||
// iOS starburst pulse ring uses easeOut(duration: 0.6) cycled;
|
||||
// we encode it as a 1200ms reversing pulse so urgent indicators
|
||||
// breathe at ~50bpm.
|
||||
val spec = TaskAnimations.priorityPulse
|
||||
assertEquals(1200, spec.periodMillis, "priorityPulse = 1200ms per cycle")
|
||||
assertTrue(spec.reverses, "pulse must reverse (breathing effect)")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun honeycombLoop_period_matchesIos() {
|
||||
// iOS doesn't ship a dedicated honeycomb blob loop, so we match
|
||||
// the WarmGradientBackground blob rotation cadence (~8s).
|
||||
val spec = TaskAnimations.honeycombLoop
|
||||
assertEquals(8000, spec.periodMillis, "honeycombLoop = 8000ms")
|
||||
assertFalse_notReversing(spec.reverses)
|
||||
}
|
||||
|
||||
// ---- Easing / curve assertions ----
|
||||
|
||||
@Test
|
||||
fun cardDismiss_usesEaseIn() {
|
||||
assertEquals(TaskAnimations.Easing.EASE_IN, TaskAnimations.cardDismiss.easing)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun cardEnter_usesSpring() {
|
||||
assertEquals(TaskAnimations.Easing.SPRING, TaskAnimations.cardEnter.easing)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun priorityPulse_usesEaseInOut() {
|
||||
assertEquals(TaskAnimations.Easing.EASE_IN_OUT, TaskAnimations.priorityPulse.easing)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun honeycombLoop_usesLinear() {
|
||||
assertEquals(TaskAnimations.Easing.LINEAR, TaskAnimations.honeycombLoop.easing)
|
||||
}
|
||||
|
||||
// ---- Determinism assertions ----
|
||||
|
||||
@Test
|
||||
fun cardDismiss_sampleAtStart_isOne() {
|
||||
// At t=0 a 1.0→0.0 dismiss should output exactly 1.0.
|
||||
val v = TaskAnimations.cardDismiss.sample(timeMillis = 0, from = 1f, to = 0f)
|
||||
assertTrue(abs(v - 1f) < tol, "t=0 must equal `from`; was $v")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun cardDismiss_sampleAtEnd_isZero() {
|
||||
val spec = TaskAnimations.cardDismiss
|
||||
val v = spec.sample(timeMillis = spec.durationMillis, from = 1f, to = 0f)
|
||||
assertTrue(abs(v - 0f) < tol, "t=end must equal `to`; was $v")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun cardDismiss_isDeterministic() {
|
||||
// Same inputs → same frame value every call.
|
||||
val a = TaskAnimations.cardDismiss.sample(150, 1f, 0f)
|
||||
val b = TaskAnimations.cardDismiss.sample(150, 1f, 0f)
|
||||
val c = TaskAnimations.cardDismiss.sample(150, 1f, 0f)
|
||||
assertEquals(a, b, "sample must be pure")
|
||||
assertEquals(b, c, "sample must be pure")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun cardDismiss_monotonicallyDecreasingForShrink() {
|
||||
// Easing is ease-in on a 1→0 range: output decreases as t grows.
|
||||
val spec = TaskAnimations.cardDismiss
|
||||
val v0 = spec.sample(0, 1f, 0f)
|
||||
val v100 = spec.sample(100, 1f, 0f)
|
||||
val v200 = spec.sample(200, 1f, 0f)
|
||||
val v300 = spec.sample(300, 1f, 0f)
|
||||
assertTrue(v0 >= v100, "t=0 ($v0) should be ≥ t=100 ($v100)")
|
||||
assertTrue(v100 >= v200, "t=100 ($v100) should be ≥ t=200 ($v200)")
|
||||
assertTrue(v200 >= v300, "t=200 ($v200) should be ≥ t=300 ($v300)")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun priorityPulse_samplePhase_periodicity() {
|
||||
// After one full period the pulse must return to its start value.
|
||||
val spec = TaskAnimations.priorityPulse
|
||||
val start = spec.sample(0, from = 1f, to = 1.1f)
|
||||
val afterCycle = spec.sample(spec.periodMillis * 2, from = 1f, to = 1.1f)
|
||||
assertTrue(
|
||||
abs(start - afterCycle) < 0.01f,
|
||||
"pulse should return to start after full reverse cycle; start=$start end=$afterCycle"
|
||||
)
|
||||
}
|
||||
|
||||
// ---- Registry / enumeration ----
|
||||
|
||||
@Test
|
||||
fun registry_listsAllAnimations() {
|
||||
val names = TaskAnimations.all.map { it.name }
|
||||
assertTrue("completionCheckmark" in names)
|
||||
assertTrue("cardEnter" in names)
|
||||
assertTrue("cardDismiss" in names)
|
||||
assertTrue("priorityPulse" in names)
|
||||
assertTrue("honeycombLoop" in names)
|
||||
assertEquals(5, TaskAnimations.all.size, "registry has 5 entries")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun registry_lookupByName_returnsSameInstance() {
|
||||
val byName = TaskAnimations.byName("cardDismiss")
|
||||
assertNotNull(byName)
|
||||
// byName returns the common interface type; downcast to assert
|
||||
// the lookup returns the same OneShot spec we expect.
|
||||
val spec = byName as TaskAnimations.AnimationSpecValues
|
||||
assertEquals(TaskAnimations.cardDismiss.durationMillis, spec.durationMillis)
|
||||
}
|
||||
|
||||
// Kotlin-test doesn't ship an assertFalse helper on this version; wrap
|
||||
// the boolean assertion we need as a helper to keep signatures small.
|
||||
private fun assertFalse_notReversing(reverses: Boolean) {
|
||||
assertTrue(!reverses, "honeycombLoop must not reverse (continuous loop)")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user