Finish animation update loop with loop/pingpong/reverse/stop flags, clean up keyframe sampling, add coverage

- animationUpdate now advances and resolves boundary crossings for
  ANIMATION_FLAG_LOOP, ANIMATION_FLAG_PINGPONG, ANIMATION_FLAG_REVERSE, and
  the STOP_BEGINNING/STOP_END flags, firing onLoop/onComplete appropriately;
  guards against LOOP+PINGPONG being set together and moves the
  duration-must-be-positive check into animationInit
- keyframeGetValue clamps to the last keyframe's value instead of dividing
  by zero once time reaches it, and its keyframe walk drops a branch that's
  unreachable after that clamp
- Adds test/animation/test_animation.c covering init, per-layer sampling,
  and the full animationUpdate flag matrix
This commit is contained in:
2026-08-06 15:29:14 -05:00
parent 1bd73d69fe
commit 36fb359aa2
5 changed files with 603 additions and 53 deletions
+1
View File
@@ -7,3 +7,4 @@ include(dusktest)
# Tests
dusktest(test_keyframe.c)
dusktest(test_animation.c)
+425
View File
@@ -0,0 +1,425 @@
/**
* 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);
}