/** * Copyright (c) 2026 Dominic Masters * * This software is released under the MIT License. * https://opensource.org/licenses/MIT */ #include "dusktest.h" #include "asset/asset.h" #include "asset/loader/assetloader.h" #include "asset/loader/assetentry.h" #include "asset/loader/animation/assetanimationloader.h" #include "thread/thread.h" #include "util/memory.h" #include // ============================================================ // Fixtures // ============================================================ static const char_t *ANIMATION_VALID = "{" " \"loop\": true," " \"speed\": 2.0," " \"channels\": [" " [" " { \"time\": 0.0, \"value\": 0.0, \"easing\": \"LINEAR\" }," " { \"time\": 1.0, \"value\": 10.0 }" " ]," " [" " { \"time\": 0.0, \"value\": 100.0, \"easing\": \"IN_QUAD\" }," " { \"time\": 2.0, \"value\": 200.0 }" " ]" " ]" "}"; static const char_t *ANIMATION_INVALID_JSON = "{ this is definitely not valid json !!!"; static const char_t *ANIMATION_MISSING_CHANNELS = "{ \"loop\": false }"; static const char_t *ANIMATION_EMPTY_CHANNELS = "{ \"channels\": [] }"; static const char_t *ANIMATION_BAD_EASING = "{" " \"channels\": [" " [" " { \"time\": 0.0, \"value\": 0.0, \"easing\": \"NOT_A_REAL_EASING\" }," " { \"time\": 1.0, \"value\": 10.0 }" " ]" " ]" "}"; // ============================================================ // Async thread helper // ============================================================ typedef struct { assetloading_t *loading; bool_t ok; } animation_async_run_t; static void animation_async_thread_cb(thread_t *thread) { animation_async_run_t *run = (animation_async_run_t *)thread->data; errorret_t ret = assetAnimationLoaderAsync(run->loading); run->ok = errorIsOk(ret); if(errorIsNotOk(ret)) errorCatch(ret); } static bool_t run_animation_async(assetloading_t *loading) { animation_async_run_t run = { .loading = loading, .ok = false }; thread_t thread; threadInit(&thread, animation_async_thread_cb); thread.data = &run; threadStart(&thread); threadStop(&thread); return run.ok; } // ============================================================ // In-memory ZIP // ============================================================ static zip_t *g_zip = NULL; static int zip_setup(void **state) { zip_error_t err; zip_error_init(&err); zip_source_t *write_src = zip_source_buffer_create(NULL, 0, 1, &err); if(!write_src) return -1; zip_t *za = zip_open_from_source(write_src, ZIP_TRUNCATE, &err); if(!za) { zip_source_free(write_src); return -1; } zip_source_t *s; s = zip_source_buffer(za, ANIMATION_VALID, strlen(ANIMATION_VALID), 0); if(zip_file_add(za, "valid.anim.json", s, ZIP_FL_OVERWRITE) < 0) { zip_close(za); return -1; } s = zip_source_buffer( za, ANIMATION_INVALID_JSON, strlen(ANIMATION_INVALID_JSON), 0 ); if(zip_file_add(za, "invalid.anim.json", s, ZIP_FL_OVERWRITE) < 0) { zip_close(za); return -1; } s = zip_source_buffer( za, ANIMATION_MISSING_CHANNELS, strlen(ANIMATION_MISSING_CHANNELS), 0 ); if(zip_file_add(za, "missingchannels.anim.json", s, ZIP_FL_OVERWRITE) < 0) { zip_close(za); return -1; } s = zip_source_buffer( za, ANIMATION_EMPTY_CHANNELS, strlen(ANIMATION_EMPTY_CHANNELS), 0 ); if(zip_file_add(za, "emptychannels.anim.json", s, ZIP_FL_OVERWRITE) < 0) { zip_close(za); return -1; } s = zip_source_buffer( za, ANIMATION_BAD_EASING, strlen(ANIMATION_BAD_EASING), 0 ); if(zip_file_add(za, "badeasing.anim.json", s, ZIP_FL_OVERWRITE) < 0) { zip_close(za); return -1; } zip_source_keep(write_src); if(zip_close(za) != 0) { zip_source_free(write_src); return -1; } zip_stat_t zs; memset(&zs, 0, sizeof(zs)); if(zip_source_stat(write_src, &zs) != 0 || !(zs.valid & ZIP_STAT_SIZE)) { zip_source_free(write_src); return -1; } void *zipbuf = malloc((size_t)zs.size); if(!zipbuf) { zip_source_free(write_src); return -1; } if(zip_source_open(write_src) != 0) { free(zipbuf); zip_source_free(write_src); return -1; } zip_source_read(write_src, zipbuf, (zip_uint64_t)zs.size); zip_source_close(write_src); zip_source_free(write_src); zip_error_init(&err); zip_source_t *read_src = zip_source_buffer_create( zipbuf, (zip_uint64_t)zs.size, 1, &err ); if(!read_src) { free(zipbuf); return -1; } g_zip = zip_open_from_source(read_src, 0, &err); if(!g_zip) { zip_source_free(read_src); return -1; } ASSET.zip = g_zip; return 0; } static int zip_teardown(void **state) { if(g_zip) { zip_close(g_zip); g_zip = NULL; } ASSET.zip = NULL; return 0; } // ============================================================ // Loader pipeline helper // ============================================================ typedef struct { assetentry_t entry; assetloading_t loading; } loader_ctx_t; static void loader_ctx_init(loader_ctx_t *ctx, const char_t *name) { assetEntryInit(&ctx->entry, name, ASSET_LOADER_TYPE_ANIMATION, NULL); threadMutexInit(&ctx->loading.mutex); memoryZero(&ctx->loading.loading, sizeof(ctx->loading.loading)); ctx->loading.type = ASSET_LOADER_TYPE_ANIMATION; ctx->loading.entry = &ctx->entry; ctx->entry.state = ASSET_ENTRY_STATE_PENDING_SYNC; } // Drives sync(INITIAL) -> async(READ) -> sync(PARSE). static errorret_t loader_ctx_run(loader_ctx_t *ctx) { errorret_t ret = assetAnimationLoaderSync(&ctx->loading); if(errorIsNotOk(ret)) return ret; if(!run_animation_async(&ctx->loading)) { ctx->entry.state = ASSET_ENTRY_STATE_ERROR; errorThrow("Async animation load failed"); } return assetAnimationLoaderSync(&ctx->loading); } static void loader_ctx_dispose(loader_ctx_t *ctx) { if(ctx->entry.type != ASSET_LOADER_TYPE_NULL) { errorret_t ret = assetEntryDispose(&ctx->entry); if(errorIsNotOk(ret)) errorCatch(ret); } threadMutexDispose(&ctx->loading.mutex); } // ============================================================ // Tests // ============================================================ static void test_animation_valid_loads(void **state) { loader_ctx_t ctx; loader_ctx_init(&ctx, "valid.anim.json"); errorret_t ret = loader_ctx_run(&ctx); assert_true(errorIsOk(ret)); assert_int_equal(ctx.entry.state, ASSET_ENTRY_STATE_LOADED); animation_t *anim = &ctx.entry.data.animation; assert_true(anim->loop); assert_float_equal(anim->speed, 2.0f, 0.0001f); assert_int_equal(anim->keyframes.trackCount, 2); assert_float_equal( keyframeSetGetValue(&anim->keyframes, 0, 0.5f), 5.0f, 0.0001f ); // Channel 1's first keyframe uses IN_QUAD -- a segment's easing comes // from its starting keyframe, not its end. easingInQuad(0.5) = 0.25 -> // lerp(100, 200, 0.25) = 125. assert_float_equal( keyframeSetGetValue(&anim->keyframes, 1, 1.0f), 125.0f, 0.0001f ); loader_ctx_dispose(&ctx); assert_int_equal(memoryGetAllocatedCount(), 0); } static void test_animation_parse_error(void **state) { loader_ctx_t ctx; loader_ctx_init(&ctx, "invalid.anim.json"); errorret_t ret = assetAnimationLoaderSync(&ctx.loading); assert_true(errorIsOk(ret)); assert_true(run_animation_async(&ctx.loading)); ret = assetAnimationLoaderSync(&ctx.loading); assert_true(errorIsNotOk(ret)); errorCatch(ret); assert_int_equal(ctx.entry.state, ASSET_ENTRY_STATE_ERROR); loader_ctx_dispose(&ctx); assert_int_equal(memoryGetAllocatedCount(), 0); } static void test_animation_missing_channels(void **state) { loader_ctx_t ctx; loader_ctx_init(&ctx, "missingchannels.anim.json"); errorret_t ret = loader_ctx_run(&ctx); assert_true(errorIsNotOk(ret)); errorCatch(ret); assert_int_equal(ctx.entry.state, ASSET_ENTRY_STATE_ERROR); loader_ctx_dispose(&ctx); assert_int_equal(memoryGetAllocatedCount(), 0); } static void test_animation_empty_channels(void **state) { loader_ctx_t ctx; loader_ctx_init(&ctx, "emptychannels.anim.json"); errorret_t ret = loader_ctx_run(&ctx); assert_true(errorIsNotOk(ret)); errorCatch(ret); assert_int_equal(ctx.entry.state, ASSET_ENTRY_STATE_ERROR); loader_ctx_dispose(&ctx); assert_int_equal(memoryGetAllocatedCount(), 0); } static void test_animation_bad_easing(void **state) { loader_ctx_t ctx; loader_ctx_init(&ctx, "badeasing.anim.json"); errorret_t ret = loader_ctx_run(&ctx); assert_true(errorIsNotOk(ret)); errorCatch(ret); assert_int_equal(ctx.entry.state, ASSET_ENTRY_STATE_ERROR); // Confirms the mid-parse cleanup path frees the channel array it had // already allocated before hitting the bad easing name. loader_ctx_dispose(&ctx); assert_int_equal(memoryGetAllocatedCount(), 0); } static void test_animation_missing_file(void **state) { loader_ctx_t ctx; loader_ctx_init(&ctx, "nonexistent.anim.json"); errorret_t ret = assetAnimationLoaderSync(&ctx.loading); assert_true(errorIsOk(ret)); assert_false(run_animation_async(&ctx.loading)); assert_int_equal(ctx.entry.state, ASSET_ENTRY_STATE_ERROR); loader_ctx_dispose(&ctx); assert_int_equal(memoryGetAllocatedCount(), 0); } // ============================================================ // main // ============================================================ int main(void) { assertInit(); const struct CMUnitTest tests[] = { cmocka_unit_test_setup_teardown( test_animation_valid_loads, zip_setup, zip_teardown ), cmocka_unit_test_setup_teardown( test_animation_parse_error, zip_setup, zip_teardown ), cmocka_unit_test_setup_teardown( test_animation_missing_channels, zip_setup, zip_teardown ), cmocka_unit_test_setup_teardown( test_animation_empty_channels, zip_setup, zip_teardown ), cmocka_unit_test_setup_teardown( test_animation_bad_easing, zip_setup, zip_teardown ), cmocka_unit_test_setup_teardown( test_animation_missing_file, zip_setup, zip_teardown ), }; return cmocka_run_group_tests(tests, NULL, NULL); }