/** * Copyright (c) 2026 Dominic Masters * * This software is released under the MIT License. * https://opensource.org/licenses/MIT */ #include "dusktest.h" #include "animation/animation.h" #include "util/memory.h" typedef struct { uint16_t updateCount; uint16_t loopCount; uint16_t completeCount; float_t lastValues[8]; } animationtestcallbacks_t; static void testOnUpdate( const uint16_t layer, const float_t value, void *user ) { animationtestcallbacks_t *cb = (animationtestcallbacks_t *)user; cb->updateCount++; cb->lastValues[layer] = value; } static void testOnLoop(void *user) { ((animationtestcallbacks_t *)user)->loopCount++; } static void testOnComplete(void *user) { ((animationtestcallbacks_t *)user)->completeCount++; } static void animationTestInit( animation_t *anim, animationtestcallbacks_t *cb, keyframe_t *keyframes, uint16_t *keyframeCounts, const uint16_t layerCount ) { memoryZero(cb, sizeof(animationtestcallbacks_t)); animationInit(anim, keyframes, keyframeCounts, layerCount); anim->user = cb; anim->onUpdate = testOnUpdate; anim->onLoop = testOnLoop; anim->onComplete = testOnComplete; } static void test_animationInitSingleLayerDuration(void **state) { keyframe_t keyframes[3] = { { .time = 0.0f, .value = 0.0f, .easing = EASING_LINEAR }, { .time = 1.0f, .value = 10.0f, .easing = EASING_LINEAR }, { .time = 2.0f, .value = 20.0f, .easing = EASING_LINEAR }, }; uint16_t keyframeCounts[1] = { 3 }; animation_t anim; animationInit(&anim, keyframes, keyframeCounts, 1); assert_float_equal(anim.duration, 2.0f, 0.0001f); assert_int_equal(anim.layerCount, 1); assert_float_equal(anim.time, 0.0f, 0.0001f); assert_int_equal(anim.flags, 0); } static void test_animationInitMultiLayerDurationIsMax(void **state) { // Both layers must share the same keyframe count - animationInit/ // animationGetLayerValue flatten the array as layer * keyframeCounts[layer], // which only produces the correct offset when every layer's count matches. keyframe_t keyframes[4] = { { .time = 0.0f, .value = 0.0f, .easing = EASING_LINEAR }, { .time = 1.0f, .value = 10.0f, .easing = EASING_LINEAR }, { .time = 0.0f, .value = 100.0f, .easing = EASING_LINEAR }, { .time = 3.0f, .value = 300.0f, .easing = EASING_LINEAR }, }; uint16_t keyframeCounts[2] = { 2, 2 }; animation_t anim; animationInit(&anim, keyframes, keyframeCounts, 2); // Layer 1's last keyframe (time=3) is later than layer 0's (time=1). assert_float_equal(anim.duration, 3.0f, 0.0001f); } static void test_animationInitNullAsserts(void **state) { keyframe_t keyframes[1] = { { .time = 0.0f, .value = 0.0f, .easing = EASING_LINEAR } }; uint16_t keyframeCounts[1] = { 1 }; animation_t anim; expect_assert_failure(animationInit(NULL, keyframes, keyframeCounts, 1)); expect_assert_failure(animationInit(&anim, NULL, keyframeCounts, 1)); expect_assert_failure(animationInit(&anim, keyframes, NULL, 1)); } static void test_animationInitZeroLayerCountAsserts(void **state) { keyframe_t keyframes[1] = { { .time = 0.0f, .value = 0.0f, .easing = EASING_LINEAR } }; uint16_t keyframeCounts[1] = { 1 }; animation_t anim; expect_assert_failure(animationInit(&anim, keyframes, keyframeCounts, 0)); } static void test_animationInitUnsortedKeyframesAsserts(void **state) { keyframe_t keyframes[2] = { { .time = 1.0f, .value = 10.0f, .easing = EASING_LINEAR }, { .time = 0.0f, .value = 0.0f, .easing = EASING_LINEAR }, }; uint16_t keyframeCounts[1] = { 2 }; animation_t anim; expect_assert_failure(animationInit(&anim, keyframes, keyframeCounts, 1)); } static void test_animationGetLayerValueSamplesEachLayerIndependently(void **state) { keyframe_t keyframes[4] = { { .time = 0.0f, .value = 0.0f, .easing = EASING_LINEAR }, { .time = 2.0f, .value = 20.0f, .easing = EASING_LINEAR }, { .time = 0.0f, .value = 100.0f, .easing = EASING_LINEAR }, { .time = 2.0f, .value = 0.0f, .easing = EASING_LINEAR }, }; uint16_t keyframeCounts[2] = { 2, 2 }; animation_t anim; animationInit(&anim, keyframes, keyframeCounts, 2); anim.time = 1.0f; assert_float_equal(animationGetLayerValue(&anim, 0), 10.0f, 0.0001f); assert_float_equal(animationGetLayerValue(&anim, 1), 50.0f, 0.0001f); } static void test_animationGetLayerValueOutOfBoundsAsserts(void **state) { keyframe_t keyframes[2] = { { .time = 0.0f, .value = 0.0f, .easing = EASING_LINEAR }, { .time = 1.0f, .value = 10.0f, .easing = EASING_LINEAR }, }; uint16_t keyframeCounts[1] = { 2 }; animation_t anim; animationInit(&anim, keyframes, keyframeCounts, 1); expect_assert_failure(animationGetLayerValue(&anim, 1)); } static void test_animationUpdateAdvancesTimeAndCallsOnUpdate(void **state) { keyframe_t keyframes[2] = { { .time = 0.0f, .value = 0.0f, .easing = EASING_LINEAR }, { .time = 2.0f, .value = 20.0f, .easing = EASING_LINEAR }, }; uint16_t keyframeCounts[1] = { 2 }; animation_t anim; animationtestcallbacks_t cb; animationTestInit(&anim, &cb, keyframes, keyframeCounts, 1); animationUpdate(&anim, 0.5f); assert_float_equal(anim.time, 0.5f, 0.0001f); assert_int_equal(cb.updateCount, 1); assert_float_equal(cb.lastValues[0], 5.0f, 0.0001f); assert_int_equal(cb.completeCount, 0); } static void test_animationUpdatePlainPlayStopsAtEndAndFiresOnceOnly(void **state) { keyframe_t keyframes[2] = { { .time = 0.0f, .value = 0.0f, .easing = EASING_LINEAR }, { .time = 2.0f, .value = 20.0f, .easing = EASING_LINEAR }, }; uint16_t keyframeCounts[1] = { 2 }; animation_t anim; animationtestcallbacks_t cb; animationTestInit(&anim, &cb, keyframes, keyframeCounts, 1); // Overshoot the duration in a single update. animationUpdate(&anim, 5.0f); assert_float_equal(anim.time, 2.0f, 0.0001f); assert_float_equal(cb.lastValues[0], 20.0f, 0.0001f); assert_int_equal(cb.completeCount, 1); // Further updates must not clamp past the end or refire onComplete. animationUpdate(&anim, 1.0f); assert_float_equal(anim.time, 2.0f, 0.0001f); assert_int_equal(cb.completeCount, 1); } static void test_animationUpdateExactLandingOnEndFiresOnComplete(void **state) { keyframe_t keyframes[2] = { { .time = 0.0f, .value = 0.0f, .easing = EASING_LINEAR }, { .time = 2.0f, .value = 20.0f, .easing = EASING_LINEAR }, }; uint16_t keyframeCounts[1] = { 2 }; animation_t anim; animationtestcallbacks_t cb; animationTestInit(&anim, &cb, keyframes, keyframeCounts, 1); // deltaTime exactly matches the remaining distance to the boundary. animationUpdate(&anim, 2.0f); assert_float_equal(anim.time, 2.0f, 0.0001f); assert_int_equal(cb.completeCount, 1); } static void test_animationUpdateLoopWrapsAndFiresOnLoop(void **state) { keyframe_t keyframes[2] = { { .time = 0.0f, .value = 0.0f, .easing = EASING_LINEAR }, { .time = 2.0f, .value = 20.0f, .easing = EASING_LINEAR }, }; uint16_t keyframeCounts[1] = { 2 }; animation_t anim; animationtestcallbacks_t cb; animationTestInit(&anim, &cb, keyframes, keyframeCounts, 1); anim.flags = ANIMATION_FLAG_LOOP; // 2.5 durations worth of time: wraps twice, lands at 1.0 into the third. animationUpdate(&anim, 5.0f); assert_float_equal(anim.time, 1.0f, 0.0001f); assert_int_equal(cb.loopCount, 2); assert_int_equal(cb.completeCount, 0); } static void test_animationUpdateLoopStopEndCompletesInsteadOfWrapping(void **state) { keyframe_t keyframes[2] = { { .time = 0.0f, .value = 0.0f, .easing = EASING_LINEAR }, { .time = 2.0f, .value = 20.0f, .easing = EASING_LINEAR }, }; uint16_t keyframeCounts[1] = { 2 }; animation_t anim; animationtestcallbacks_t cb; animationTestInit(&anim, &cb, keyframes, keyframeCounts, 1); anim.flags = ANIMATION_FLAG_LOOP | ANIMATION_FLAG_STOP_END; animationUpdate(&anim, 5.0f); assert_float_equal(anim.time, 2.0f, 0.0001f); assert_int_equal(cb.loopCount, 0); assert_int_equal(cb.completeCount, 1); } static void test_animationUpdateReversePlaysBackward(void **state) { keyframe_t keyframes[2] = { { .time = 0.0f, .value = 0.0f, .easing = EASING_LINEAR }, { .time = 2.0f, .value = 20.0f, .easing = EASING_LINEAR }, }; uint16_t keyframeCounts[1] = { 2 }; animation_t anim; animationtestcallbacks_t cb; animationTestInit(&anim, &cb, keyframes, keyframeCounts, 1); anim.flags = ANIMATION_FLAG_REVERSE; anim.time = anim.duration; animationUpdate(&anim, 0.5f); assert_float_equal(anim.time, 1.5f, 0.0001f); assert_float_equal(cb.lastValues[0], 15.0f, 0.0001f); } static void test_animationUpdateReverseStopsAtBeginning(void **state) { keyframe_t keyframes[2] = { { .time = 0.0f, .value = 0.0f, .easing = EASING_LINEAR }, { .time = 2.0f, .value = 20.0f, .easing = EASING_LINEAR }, }; uint16_t keyframeCounts[1] = { 2 }; animation_t anim; animationtestcallbacks_t cb; animationTestInit(&anim, &cb, keyframes, keyframeCounts, 1); anim.flags = ANIMATION_FLAG_REVERSE; anim.time = anim.duration; animationUpdate(&anim, 10.0f); assert_float_equal(anim.time, 0.0f, 0.0001f); assert_float_equal(cb.lastValues[0], 0.0f, 0.0001f); assert_int_equal(cb.completeCount, 1); } static void test_animationUpdatePingpongBouncesForeverWithoutStopFlags(void **state) { keyframe_t keyframes[2] = { { .time = 0.0f, .value = 0.0f, .easing = EASING_LINEAR }, { .time = 2.0f, .value = 20.0f, .easing = EASING_LINEAR }, }; uint16_t keyframeCounts[1] = { 2 }; animation_t anim; animationtestcallbacks_t cb; animationTestInit(&anim, &cb, keyframes, keyframeCounts, 1); anim.flags = ANIMATION_FLAG_PINGPONG; // 0 -> 2 (2s consumed), reflect, backward 1s more -> time=1, going backward. animationUpdate(&anim, 3.0f); assert_float_equal(anim.time, 1.0f, 0.0001f); assert_true((anim.flags & ANIMATION_FLAG_INTERNAL_PINGPONG_BACKWARD) != 0); assert_int_equal(cb.completeCount, 0); // Continue backward to 0 (1s), reflect, forward 1s more -> time=1, forward. animationUpdate(&anim, 2.0f); assert_float_equal(anim.time, 1.0f, 0.0001f); assert_true((anim.flags & ANIMATION_FLAG_INTERNAL_PINGPONG_BACKWARD) == 0); assert_int_equal(cb.completeCount, 0); } static void test_animationUpdatePingpongStopEndCompletesAtEnd(void **state) { keyframe_t keyframes[2] = { { .time = 0.0f, .value = 0.0f, .easing = EASING_LINEAR }, { .time = 2.0f, .value = 20.0f, .easing = EASING_LINEAR }, }; uint16_t keyframeCounts[1] = { 2 }; animation_t anim; animationtestcallbacks_t cb; animationTestInit(&anim, &cb, keyframes, keyframeCounts, 1); anim.flags = ANIMATION_FLAG_PINGPONG | ANIMATION_FLAG_STOP_END; animationUpdate(&anim, 10.0f); assert_float_equal(anim.time, 2.0f, 0.0001f); assert_int_equal(cb.completeCount, 1); } static void test_animationUpdatePingpongStopBeginningCompletesAtStart(void **state) { keyframe_t keyframes[2] = { { .time = 0.0f, .value = 0.0f, .easing = EASING_LINEAR }, { .time = 2.0f, .value = 20.0f, .easing = EASING_LINEAR }, }; uint16_t keyframeCounts[1] = { 2 }; animation_t anim; animationtestcallbacks_t cb; animationTestInit(&anim, &cb, keyframes, keyframeCounts, 1); anim.flags = ANIMATION_FLAG_PINGPONG | ANIMATION_FLAG_STOP_BEGINNING; anim.time = anim.duration; anim.flags |= ANIMATION_FLAG_INTERNAL_PINGPONG_BACKWARD; animationUpdate(&anim, 10.0f); assert_float_equal(anim.time, 0.0f, 0.0001f); assert_int_equal(cb.completeCount, 1); } static void test_animationUpdateMultiLayerCallsOnUpdatePerLayer(void **state) { keyframe_t keyframes[4] = { { .time = 0.0f, .value = 0.0f, .easing = EASING_LINEAR }, { .time = 2.0f, .value = 20.0f, .easing = EASING_LINEAR }, { .time = 0.0f, .value = 100.0f, .easing = EASING_LINEAR }, { .time = 2.0f, .value = 0.0f, .easing = EASING_LINEAR }, }; uint16_t keyframeCounts[2] = { 2, 2 }; animation_t anim; animationtestcallbacks_t cb; animationTestInit(&anim, &cb, keyframes, keyframeCounts, 2); animationUpdate(&anim, 1.0f); assert_int_equal(cb.updateCount, 2); assert_float_equal(cb.lastValues[0], 10.0f, 0.0001f); assert_float_equal(cb.lastValues[1], 50.0f, 0.0001f); } static void test_animationUpdateNullAsserts(void **state) { expect_assert_failure(animationUpdate(NULL, 1.0f)); } static void test_animationUpdateNegativeDeltaTimeAsserts(void **state) { keyframe_t keyframes[2] = { { .time = 0.0f, .value = 0.0f, .easing = EASING_LINEAR }, { .time = 1.0f, .value = 10.0f, .easing = EASING_LINEAR }, }; uint16_t keyframeCounts[1] = { 2 }; animation_t anim; animationInit(&anim, keyframes, keyframeCounts, 1); expect_assert_failure(animationUpdate(&anim, -1.0f)); } static void test_animationInitZeroDurationAsserts(void **state) { keyframe_t keyframes[1] = { { .time = 0.0f, .value = 5.0f, .easing = EASING_LINEAR } }; uint16_t keyframeCounts[1] = { 1 }; animation_t anim; expect_assert_failure(animationInit(&anim, keyframes, keyframeCounts, 1)); } static void test_animationUpdateLoopAndPingpongTogetherAsserts(void **state) { keyframe_t keyframes[2] = { { .time = 0.0f, .value = 0.0f, .easing = EASING_LINEAR }, { .time = 1.0f, .value = 10.0f, .easing = EASING_LINEAR }, }; uint16_t keyframeCounts[1] = { 2 }; animation_t anim; animationInit(&anim, keyframes, keyframeCounts, 1); anim.flags = ANIMATION_FLAG_LOOP | ANIMATION_FLAG_PINGPONG; expect_assert_failure(animationUpdate(&anim, 0.1f)); } int main(int argc, char **argv) { const struct CMUnitTest tests[] = { cmocka_unit_test(test_animationInitSingleLayerDuration), cmocka_unit_test(test_animationInitMultiLayerDurationIsMax), cmocka_unit_test(test_animationInitNullAsserts), cmocka_unit_test(test_animationInitZeroLayerCountAsserts), cmocka_unit_test(test_animationInitUnsortedKeyframesAsserts), cmocka_unit_test(test_animationGetLayerValueSamplesEachLayerIndependently), cmocka_unit_test(test_animationGetLayerValueOutOfBoundsAsserts), cmocka_unit_test(test_animationUpdateAdvancesTimeAndCallsOnUpdate), cmocka_unit_test(test_animationUpdatePlainPlayStopsAtEndAndFiresOnceOnly), cmocka_unit_test(test_animationUpdateExactLandingOnEndFiresOnComplete), cmocka_unit_test(test_animationUpdateLoopWrapsAndFiresOnLoop), cmocka_unit_test(test_animationUpdateLoopStopEndCompletesInsteadOfWrapping), cmocka_unit_test(test_animationUpdateReversePlaysBackward), cmocka_unit_test(test_animationUpdateReverseStopsAtBeginning), cmocka_unit_test(test_animationUpdatePingpongBouncesForeverWithoutStopFlags), cmocka_unit_test(test_animationUpdatePingpongStopEndCompletesAtEnd), cmocka_unit_test(test_animationUpdatePingpongStopBeginningCompletesAtStart), cmocka_unit_test(test_animationUpdateMultiLayerCallsOnUpdatePerLayer), cmocka_unit_test(test_animationUpdateNullAsserts), cmocka_unit_test(test_animationUpdateNegativeDeltaTimeAsserts), cmocka_unit_test(test_animationInitZeroDurationAsserts), cmocka_unit_test(test_animationUpdateLoopAndPingpongTogetherAsserts), }; return cmocka_run_group_tests(tests, NULL, NULL); }