/** * 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/assetbatch.h" #include "asset/loader/assetloader.h" #include "asset/loader/assetentry.h" #include "util/memory.h" // ============================================================ // Stub loader callbacks // ============================================================ static errorret_t stub_load_success(assetloading_t *loading) { loading->entry->state = ASSET_ENTRY_STATE_LOADED; errorOk(); } static errorret_t stub_load_fail(assetloading_t *loading) { loading->entry->state = ASSET_ENTRY_STATE_ERROR; errorThrow("Stub loader failed"); } static errorret_t stub_dispose(assetentry_t *entry) { errorOk(); } // ============================================================ // Per-test setup / teardown // ============================================================ static assetloadercallbacks_t saved_callbacks[ASSET_LOADER_TYPE_COUNT]; static int batch_setup(void **state) { memoryCopy(saved_callbacks, ASSET_LOADER_CALLBACKS, sizeof(saved_callbacks)); memoryZero(&ASSET, sizeof(ASSET)); for(size_t i = 0; i < ASSET_LOADING_COUNT_MAX; i++) { threadMutexInit(&ASSET.loading[i].mutex); } for(int i = 0; i < ASSET_LOADER_TYPE_COUNT; i++) { ASSET_LOADER_CALLBACKS[i].loadSync = stub_load_success; ASSET_LOADER_CALLBACKS[i].dispose = stub_dispose; } return 0; } static int batch_teardown(void **state) { for(int i = 0; i < ASSET_ENTRY_COUNT_MAX; i++) { if(ASSET.entries[i].type != ASSET_LOADER_TYPE_NULL) { errorret_t ret = assetEntryDispose(&ASSET.entries[i]); if(errorIsNotOk(ret)) errorCatch(ret); } } for(size_t i = 0; i < ASSET_LOADING_COUNT_MAX; i++) { threadMutexDispose(&ASSET.loading[i].mutex); } memoryCopy(ASSET_LOADER_CALLBACKS, saved_callbacks, sizeof(saved_callbacks)); memoryZero(&ASSET, sizeof(ASSET)); return 0; } // ============================================================ // Event counters // ============================================================ static int32_t g_onLoadedCount; static int32_t g_onErrorCount; static int32_t g_onEntryLoadedCount; static int32_t g_onEntryErrorCount; static void resetCounters(void) { g_onLoadedCount = 0; g_onErrorCount = 0; g_onEntryLoadedCount = 0; g_onEntryErrorCount = 0; } static void onLoadedCb(void *params, void *user) { g_onLoadedCount++; } static void onErrorCb(void *params, void *user) { g_onErrorCount++; } static void onEntryLoadedCb(void *params, void *user) { g_onEntryLoadedCount++; } static void onEntryErrorCb(void *params, void *user) { g_onEntryErrorCount++; } // ============================================================ // Basic lifecycle // ============================================================ static void test_batch_single_entry_loads_and_fires_events(void **state) { resetCounters(); assetbatchdesc_t descs[1] = { { .path = "a.locale", .type = ASSET_LOADER_TYPE_LOCALE }, }; assetbatch_t batch; assetBatchInit(&batch, 1, descs); eventSubscribe(&batch.onLoaded, onLoadedCb, NULL); eventSubscribe(&batch.onEntryLoaded, onEntryLoadedCb, NULL); assert_false(assetBatchIsLoaded(&batch)); errorret_t ret = assetUpdate(); assert_true(errorIsOk(ret)); assert_true(assetBatchIsLoaded(&batch)); assert_int_equal(g_onLoadedCount, 1); assert_int_equal(g_onEntryLoadedCount, 1); assetBatchDispose(&batch); assert_int_equal(memoryGetAllocatedCount(), 0); } static void test_batch_multiple_entries_fire_onLoaded_once(void **state) { resetCounters(); assetbatchdesc_t descs[3] = { { .path = "a.locale", .type = ASSET_LOADER_TYPE_LOCALE }, { .path = "b.locale", .type = ASSET_LOADER_TYPE_LOCALE }, { .path = "c.locale", .type = ASSET_LOADER_TYPE_LOCALE }, }; assetbatch_t batch; assetBatchInit(&batch, 3, descs); eventSubscribe(&batch.onLoaded, onLoadedCb, NULL); eventSubscribe(&batch.onEntryLoaded, onEntryLoadedCb, NULL); errorret_t ret = assetUpdate(); assert_true(errorIsOk(ret)); assert_true(assetBatchIsLoaded(&batch)); assert_int_equal(g_onEntryLoadedCount, 3); assert_int_equal(g_onLoadedCount, 1); // batch-level: fires once, not per-entry assetBatchDispose(&batch); assert_int_equal(memoryGetAllocatedCount(), 0); } static void test_batch_already_loaded_entry_counts_immediately(void **state) { // Pre-load an entry directly before the batch ever sees it. assetentry_t *preloaded = assetGetEntry( "preloaded.locale", ASSET_LOADER_TYPE_LOCALE, NULL ); assetEntryLock(preloaded); assetUpdate(); assert_int_equal(preloaded->state, ASSET_ENTRY_STATE_LOADED); assetbatchdesc_t descs[1] = { { .path = "preloaded.locale", .type = ASSET_LOADER_TYPE_LOCALE }, }; assetbatch_t batch; assetBatchInit(&batch, 1, descs); // assetBatchInit must recognize the already-LOADED entry synchronously, // without waiting on an assetUpdate to discover it. assert_true(assetBatchIsLoaded(&batch)); assetEntryUnlock(preloaded); assetBatchDispose(&batch); assert_int_equal(memoryGetAllocatedCount(), 0); } // ============================================================ // Error handling // ============================================================ static void test_batch_error_entry_sets_hasError_and_fires_events( void **state ) { resetCounters(); ASSET_LOADER_CALLBACKS[ASSET_LOADER_TYPE_LOCALE].loadSync = stub_load_fail; assetbatchdesc_t descs[1] = { { .path = "fail.locale", .type = ASSET_LOADER_TYPE_LOCALE }, }; assetbatch_t batch; assetBatchInit(&batch, 1, descs); eventSubscribe(&batch.onError, onErrorCb, NULL); eventSubscribe(&batch.onEntryError, onEntryErrorCb, NULL); // First update: the sync loader fails and sets ERROR, but the loading // slot isn't cleared (and the entry's onError doesn't fire) until the // NEXT update sees the already-errored slot -- matches assetUpdate's // documented two-step error handling (see test_asset.c). errorret_t ret = assetUpdate(); assert_true(errorIsOk(ret)); assert_int_equal(g_onErrorCount, 0); ret = assetUpdate(); assert_true(errorIsNotOk(ret)); errorCatch(ret); assert_true(assetBatchHasError(&batch)); assert_int_equal(g_onErrorCount, 1); assert_int_equal(g_onEntryErrorCount, 1); assetBatchDispose(&batch); assert_int_equal(memoryGetAllocatedCount(), 0); } // ============================================================ // assetBatchRequireLoaded // ============================================================ static void test_batch_requireLoaded_blocks_until_loaded(void **state) { assetbatchdesc_t descs[2] = { { .path = "a.locale", .type = ASSET_LOADER_TYPE_LOCALE }, { .path = "b.locale", .type = ASSET_LOADER_TYPE_LOCALE }, }; assetbatch_t batch; assetBatchInit(&batch, 2, descs); errorret_t ret = assetBatchRequireLoaded(&batch); assert_true(errorIsOk(ret)); assert_true(assetBatchIsLoaded(&batch)); assetBatchDispose(&batch); assert_int_equal(memoryGetAllocatedCount(), 0); } static void test_batch_requireLoaded_returns_error_on_failed_entry( void **state ) { ASSET_LOADER_CALLBACKS[ASSET_LOADER_TYPE_LOCALE].loadSync = stub_load_fail; assetbatchdesc_t descs[1] = { { .path = "fail.locale", .type = ASSET_LOADER_TYPE_LOCALE }, }; assetbatch_t batch; assetBatchInit(&batch, 1, descs); errorret_t ret = assetBatchRequireLoaded(&batch); assert_true(errorIsNotOk(ret)); errorCatch(ret); assetBatchDispose(&batch); assert_int_equal(memoryGetAllocatedCount(), 0); } // ============================================================ // assetBatchLock / assetBatchUnlock // ============================================================ static void test_batch_lock_unlock_adjust_ref_counts(void **state) { assetbatchdesc_t descs[1] = { { .path = "a.locale", .type = ASSET_LOADER_TYPE_LOCALE }, }; assetbatch_t batch; assetBatchInit(&batch, 1, descs); assert_int_equal((int)batch.entries[0]->refs.count, 1); assetBatchLock(&batch); assert_int_equal((int)batch.entries[0]->refs.count, 2); assetBatchUnlock(&batch); assert_int_equal((int)batch.entries[0]->refs.count, 1); assetBatchDispose(&batch); assert_int_equal(memoryGetAllocatedCount(), 0); } // ============================================================ // Caching: sharing a single cached entry across independent consumers // ============================================================ // Regression coverage for a real bug found while writing these tests: // eventSubscribe/eventUnsubscribe used to match on `callback` alone, so two // independent assetbatch_t's both waiting on the same cached asset entry // (identical assetBatchEntryOnLoadedCb function pointer, different `batch` // as the `user`) would hit assertUnreachable() inside assetBatchInit. Now // fixed to match on the (callback, user) pair. static void test_two_batches_share_one_cached_entry(void **state) { resetCounters(); assetbatchdesc_t descsA[1] = { { .path = "shared.locale", .type = ASSET_LOADER_TYPE_LOCALE }, }; assetbatchdesc_t descsB[1] = { { .path = "shared.locale", .type = ASSET_LOADER_TYPE_LOCALE }, }; assetbatch_t batchA, batchB; assetBatchInit(&batchA, 1, descsA); assetBatchInit(&batchB, 1, descsB); // must not abort // Cache hit: the second batch must resolve to the exact same entry // rather than triggering a second, independent load. assert_ptr_equal(batchA.entries[0], batchB.entries[0]); assert_int_equal((int)batchA.entries[0]->refs.count, 2); eventSubscribe(&batchA.onLoaded, onLoadedCb, NULL); eventSubscribe(&batchB.onLoaded, onLoadedCb, NULL); errorret_t ret = assetUpdate(); assert_true(errorIsOk(ret)); // Both batches observe completion of the single underlying load. assert_true(assetBatchIsLoaded(&batchA)); assert_true(assetBatchIsLoaded(&batchB)); assert_int_equal(g_onLoadedCount, 2); assetBatchDispose(&batchA); // Still referenced by batchB -- must survive batchA's dispose untouched. assert_int_equal((int)batchB.entries[0]->type, (int)ASSET_LOADER_TYPE_LOCALE); assert_int_equal((int)batchB.entries[0]->refs.count, 1); assetBatchDispose(&batchB); errorret_t reapRet = assetReapUnused(); assert_true(errorIsOk(reapRet)); assert_int_equal(memoryGetAllocatedCount(), 0); } // ============================================================ // main // ============================================================ int main(void) { assertInit(); const struct CMUnitTest tests[] = { cmocka_unit_test_setup_teardown(test_batch_single_entry_loads_and_fires_events, batch_setup, batch_teardown), cmocka_unit_test_setup_teardown(test_batch_multiple_entries_fire_onLoaded_once, batch_setup, batch_teardown), cmocka_unit_test_setup_teardown(test_batch_already_loaded_entry_counts_immediately, batch_setup, batch_teardown), cmocka_unit_test_setup_teardown(test_batch_error_entry_sets_hasError_and_fires_events, batch_setup, batch_teardown), cmocka_unit_test_setup_teardown(test_batch_requireLoaded_blocks_until_loaded, batch_setup, batch_teardown), cmocka_unit_test_setup_teardown(test_batch_requireLoaded_returns_error_on_failed_entry, batch_setup, batch_teardown), cmocka_unit_test_setup_teardown(test_batch_lock_unlock_adjust_ref_counts, batch_setup, batch_teardown), cmocka_unit_test_setup_teardown(test_two_batches_share_one_cached_entry, batch_setup, batch_teardown), }; return cmocka_run_group_tests(tests, NULL, NULL); }