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:
Trey T
2026-04-18 13:35:32 -05:00
parent cf2aca583b
commit 3069ec41de
6 changed files with 762 additions and 0 deletions
@@ -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)")
}
}