/** * 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 "util/memory.h" #include "util/string.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 asset_setup(void **state) { // Save real callbacks so we can restore them in teardown. memoryCopy(saved_callbacks, ASSET_LOADER_CALLBACKS, sizeof(saved_callbacks)); // Manually init ASSET — no thread, no ZIP. memoryZero(&ASSET, sizeof(ASSET)); for(size_t i = 0; i < ASSET_LOADING_COUNT_MAX; i++) { threadMutexInit(&ASSET.loading[i].mutex); } // Replace all loader callbacks with stubs. 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 asset_teardown(void **state) { // Dispose any entries that tests left behind. 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); } // Restore real callbacks before zeroing state. memoryCopy(ASSET_LOADER_CALLBACKS, saved_callbacks, sizeof(saved_callbacks)); memoryZero(&ASSET, sizeof(ASSET)); return 0; } // ============================================================ // Helper: find which loading slot owns a given entry // ============================================================ static bool_t loading_slot_has_entry(const assetentry_t *entry) { for(size_t i = 0; i < ASSET_LOADING_COUNT_MAX; i++) { if(ASSET.loading[i].entry == entry) return true; } return false; } // ============================================================ // assetGetEntry tests // ============================================================ static void test_getEntry_creates_new(void **state) { assetentry_t *entry = assetGetEntry("test.locale", ASSET_LOADER_TYPE_LOCALE, NULL); assert_non_null(entry); assert_int_equal(entry->state, ASSET_ENTRY_STATE_NOT_STARTED); assert_int_equal(entry->type, ASSET_LOADER_TYPE_LOCALE); assert_true(stringEquals(entry->name, "test.locale")); assert_int_equal(memoryGetAllocatedCount(), 0); } static void test_getEntry_dedup(void **state) { assetentry_t *a = assetGetEntry("test.locale", ASSET_LOADER_TYPE_LOCALE, NULL); assetentry_t *b = assetGetEntry("test.locale", ASSET_LOADER_TYPE_LOCALE, NULL); assert_ptr_equal(a, b); assert_int_equal(memoryGetAllocatedCount(), 0); } static void test_getEntry_distinct_names(void **state) { assetentry_t *a = assetGetEntry("a.locale", ASSET_LOADER_TYPE_LOCALE, NULL); assetentry_t *b = assetGetEntry("b.locale", ASSET_LOADER_TYPE_LOCALE, NULL); assert_ptr_not_equal(a, b); assert_int_equal(memoryGetAllocatedCount(), 0); } // ============================================================ // assetUpdate — state machine tests // ============================================================ static void test_update_entry_reaches_loaded(void **state) { assetentry_t *entry = assetGetEntry("test.locale", ASSET_LOADER_TYPE_LOCALE, NULL); assert_int_equal(entry->state, ASSET_ENTRY_STATE_NOT_STARTED); errorret_t ret = assetUpdate(); assert_true(errorIsOk(ret)); assert_int_equal(entry->state, ASSET_ENTRY_STATE_LOADED); assert_int_equal(memoryGetAllocatedCount(), 0); } static void test_update_slot_occupied_after_first_update(void **state) { assetentry_t *entry = assetGetEntry("test.locale", ASSET_LOADER_TYPE_LOCALE, NULL); assetUpdate(); assert_int_equal(entry->state, ASSET_ENTRY_STATE_LOADED); // Slot not cleared until the second update processes the LOADED case. assert_true(loading_slot_has_entry(entry)); assert_int_equal(memoryGetAllocatedCount(), 0); } static void test_update_slot_cleared_after_second_update(void **state) { assetentry_t *entry = assetGetEntry("test.locale", ASSET_LOADER_TYPE_LOCALE, NULL); assetUpdate(); assetUpdate(); assert_false(loading_slot_has_entry(entry)); assert_int_equal(entry->state, ASSET_ENTRY_STATE_LOADED); assert_int_equal(memoryGetAllocatedCount(), 0); } static void test_update_four_slots_fill_independently(void **state) { // ASSET_LOADING_COUNT_MAX concurrent entries should all load in one pass. assetentry_t *entries[ASSET_LOADING_COUNT_MAX]; for(int i = 0; i < ASSET_LOADING_COUNT_MAX; i++) { char_t name[ASSET_FILE_NAME_MAX]; snprintf(name, sizeof(name), "asset%d.locale", i); entries[i] = assetGetEntry(name, ASSET_LOADER_TYPE_LOCALE, NULL); } errorret_t ret = assetUpdate(); assert_true(errorIsOk(ret)); for(int i = 0; i < ASSET_LOADING_COUNT_MAX; i++) { assert_int_equal(entries[i]->state, ASSET_ENTRY_STATE_LOADED); } assert_int_equal(memoryGetAllocatedCount(), 0); } static void test_update_error_state(void **state) { ASSET_LOADER_CALLBACKS[ASSET_LOADER_TYPE_LOCALE].loadSync = stub_load_fail; assetentry_t *entry = assetGetEntry("fail.locale", ASSET_LOADER_TYPE_LOCALE, NULL); // First update: dispatches and calls the failing stub. // assetUpdate itself returns OK here; the error from loadSync is caught internally. errorret_t ret = assetUpdate(); assert_true(errorIsOk(ret)); assert_int_equal(entry->state, ASSET_ENTRY_STATE_ERROR); // Second update: sees ERROR state and propagates it. ret = assetUpdate(); assert_true(errorIsNotOk(ret)); errorCatch(ret); assert_int_equal(memoryGetAllocatedCount(), 0); } static void test_update_noop_on_empty_table(void **state) { errorret_t ret = assetUpdate(); assert_true(errorIsOk(ret)); assert_int_equal(memoryGetAllocatedCount(), 0); } static void test_update_loaded_entry_not_redispatched(void **state) { assetentry_t *entry = assetGetEntry("test.locale", ASSET_LOADER_TYPE_LOCALE, NULL); assetUpdate(); assetUpdate(); // slot freed assert_int_equal(entry->state, ASSET_ENTRY_STATE_LOADED); // Further updates must not re-dispatch or modify a LOADED entry. assetUpdate(); assetUpdate(); assetUpdate(); assert_int_equal(entry->state, ASSET_ENTRY_STATE_LOADED); assert_false(loading_slot_has_entry(entry)); assert_int_equal(memoryGetAllocatedCount(), 0); } static void test_update_overflow_queues_entries(void **state) { // Create one more entry than there are loading slots. const int TOTAL = ASSET_LOADING_COUNT_MAX + 1; assetentry_t *entries[ASSET_LOADING_COUNT_MAX + 1]; for(int i = 0; i < TOTAL; i++) { char_t name[ASSET_FILE_NAME_MAX]; snprintf(name, sizeof(name), "asset%d.locale", i); entries[i] = assetGetEntry(name, ASSET_LOADER_TYPE_LOCALE, NULL); } // Update 1: fills all slots, first ASSET_LOADING_COUNT_MAX entries reach LOADED. // The overflow entry has no slot yet and stays NOT_STARTED. errorret_t ret = assetUpdate(); assert_true(errorIsOk(ret)); for(int i = 0; i < ASSET_LOADING_COUNT_MAX; i++) { assert_int_equal(entries[i]->state, ASSET_ENTRY_STATE_LOADED); } assert_int_equal(entries[ASSET_LOADING_COUNT_MAX]->state, ASSET_ENTRY_STATE_NOT_STARTED); // Update 2: LOADED slots are freed. Overflow entry still NOT_STARTED because // the dispatch phase ran before the slots were cleared this turn. ret = assetUpdate(); assert_true(errorIsOk(ret)); assert_int_equal(entries[ASSET_LOADING_COUNT_MAX]->state, ASSET_ENTRY_STATE_NOT_STARTED); // Update 3: now a slot is available; the overflow entry is dispatched and loaded. ret = assetUpdate(); assert_true(errorIsOk(ret)); assert_int_equal(entries[ASSET_LOADING_COUNT_MAX]->state, ASSET_ENTRY_STATE_LOADED); assert_int_equal(memoryGetAllocatedCount(), 0); } static void test_update_error_slot_stays_occupied(void **state) { ASSET_LOADER_CALLBACKS[ASSET_LOADER_TYPE_LOCALE].loadSync = stub_load_fail; assetentry_t *entry = assetGetEntry("fail.locale", ASSET_LOADER_TYPE_LOCALE, NULL); assetUpdate(); assert_int_equal(entry->state, ASSET_ENTRY_STATE_ERROR); // Unlike LOADED, the ERROR case does NOT clear the slot — it throws instead. assert_true(loading_slot_has_entry(entry)); errorret_t ret = assetUpdate(); assert_true(errorIsNotOk(ret)); errorCatch(ret); assert_int_equal(memoryGetAllocatedCount(), 0); } // ============================================================ // assetEntryInit — input copy // ============================================================ static void test_getEntry_null_input_stays_null(void **state) { assetentry_t *entry = assetGetEntry("test.locale", ASSET_LOADER_TYPE_LOCALE, NULL); assert_null(entry->input); assert_int_equal(memoryGetAllocatedCount(), 0); } static void test_getEntry_input_copied_into_entry(void **state) { assetloaderinput_t input; memoryZero(&input, sizeof(input)); input.texture = (textureformat_t)42; assetentry_t *entry = assetGetEntry("test.texture", ASSET_LOADER_TYPE_TEXTURE, &input); // input must have been copied — entry->input must point inside the entry. assert_non_null(entry->input); assert_ptr_equal(entry->input, &entry->inputData); assert_int_equal((int)entry->input->texture, 42); assert_int_equal(memoryGetAllocatedCount(), 0); } // ============================================================ // assetUpdate — re-entrant sync loader // ============================================================ static assetentry_t *reentrant_inner_entry = NULL; static bool_t reentrant_inner_loaded = false; static errorret_t reentrant_stub_load(assetloading_t *loading) { if(reentrant_inner_entry == NULL) { // Simulate a script loading another asset from within its own sync step. reentrant_inner_entry = assetGetEntry( "inner.json", ASSET_LOADER_TYPE_JSON, NULL ); errorret_t inner_ret = assetRequireLoaded(reentrant_inner_entry); reentrant_inner_loaded = errorIsOk(inner_ret); if(errorIsNotOk(inner_ret)) errorChain(inner_ret); } loading->entry->state = ASSET_ENTRY_STATE_LOADED; errorOk(); } static void test_update_reentrant_sync_loader(void **state) { reentrant_inner_entry = NULL; reentrant_inner_loaded = false; // LOCALE uses the re-entrant loader; JSON keeps the default stub_load_success. ASSET_LOADER_CALLBACKS[ASSET_LOADER_TYPE_LOCALE].loadSync = reentrant_stub_load; assetentry_t *outer = assetGetEntry("outer.locale", ASSET_LOADER_TYPE_LOCALE, NULL); errorret_t ret = assetRequireLoaded(outer); assert_true(errorIsOk(ret)); assert_int_equal(outer->state, ASSET_ENTRY_STATE_LOADED); // Verify the re-entrant load ran and completed successfully. // (The inner entry may have been reaped by the time we check here, // so we capture the result inside the callback rather than reading state.) assert_non_null(reentrant_inner_entry); assert_true(reentrant_inner_loaded); assert_int_equal(memoryGetAllocatedCount(), 0); } // ============================================================ // assetGetEntry — dedup against non-NOT_STARTED entries // ============================================================ static void test_getEntry_returns_loaded_entry(void **state) { assetentry_t *a = assetGetEntry("test.locale", ASSET_LOADER_TYPE_LOCALE, NULL); assetUpdate(); assert_int_equal(a->state, ASSET_ENTRY_STATE_LOADED); // A second request for the same name must return the same entry even though // it is already LOADED rather than NOT_STARTED. assetentry_t *b = assetGetEntry("test.locale", ASSET_LOADER_TYPE_LOCALE, NULL); assert_ptr_equal(a, b); assert_int_equal(b->state, ASSET_ENTRY_STATE_LOADED); assert_int_equal(memoryGetAllocatedCount(), 0); } // ============================================================ // assetEntryDispose tests // ============================================================ static void test_entry_dispose_clears_entry(void **state) { assetentry_t *entry = assetGetEntry("test.locale", ASSET_LOADER_TYPE_LOCALE, NULL); assetUpdate(); assetUpdate(); // ensure loading slot is freed before disposing assert_int_equal(entry->state, ASSET_ENTRY_STATE_LOADED); errorret_t ret = assetEntryDispose(entry); assert_true(errorIsOk(ret)); assert_int_equal(entry->type, ASSET_LOADER_TYPE_NULL); assert_int_equal(memoryGetAllocatedCount(), 0); } static void test_entry_dispose_slot_reusable(void **state) { assetentry_t *a = assetGetEntry("a.locale", ASSET_LOADER_TYPE_LOCALE, NULL); assetUpdate(); assetUpdate(); assert_false(loading_slot_has_entry(a)); assetEntryDispose(a); assert_int_equal(a->type, ASSET_LOADER_TYPE_NULL); // The freed slot should now accept a new entry. assetentry_t *b = assetGetEntry("b.locale", ASSET_LOADER_TYPE_LOCALE, NULL); assert_non_null(b); assert_int_equal(b->state, ASSET_ENTRY_STATE_NOT_STARTED); errorret_t ret = assetUpdate(); assert_true(errorIsOk(ret)); assert_int_equal(b->state, ASSET_ENTRY_STATE_LOADED); assert_int_equal(memoryGetAllocatedCount(), 0); } // ============================================================ // assetRequireLoaded tests // ============================================================ static void test_requireLoaded_already_loaded(void **state) { assetentry_t *entry = assetGetEntry("test.locale", ASSET_LOADER_TYPE_LOCALE, NULL); assetUpdate(); assert_int_equal(entry->state, ASSET_ENTRY_STATE_LOADED); // Should return immediately without calling assetUpdate again. errorret_t ret = assetRequireLoaded(entry); assert_true(errorIsOk(ret)); assert_int_equal(memoryGetAllocatedCount(), 0); } static void test_requireLoaded_spins_to_loaded(void **state) { assetentry_t *entry = assetGetEntry("test.locale", ASSET_LOADER_TYPE_LOCALE, NULL); assert_int_equal(entry->state, ASSET_ENTRY_STATE_NOT_STARTED); // requireLoaded calls assetUpdate internally until LOADED. errorret_t ret = assetRequireLoaded(entry); assert_true(errorIsOk(ret)); assert_int_equal(entry->state, ASSET_ENTRY_STATE_LOADED); assert_int_equal(memoryGetAllocatedCount(), 0); } static void test_requireLoaded_propagates_error(void **state) { ASSET_LOADER_CALLBACKS[ASSET_LOADER_TYPE_LOCALE].loadSync = stub_load_fail; assetentry_t *entry = assetGetEntry("fail.locale", ASSET_LOADER_TYPE_LOCALE, NULL); // requireLoaded spins assetUpdate until LOADED — but the loader always fails, // so the second assetUpdate sees ERROR and throws, which errorChain propagates. errorret_t ret = assetRequireLoaded(entry); assert_true(errorIsNotOk(ret)); errorCatch(ret); assert_int_equal(entry->state, ASSET_ENTRY_STATE_ERROR); assert_int_equal(memoryGetAllocatedCount(), 0); } // ============================================================ // main // ============================================================ int main(void) { const struct CMUnitTest tests[] = { // getEntry cmocka_unit_test_setup_teardown(test_getEntry_creates_new, asset_setup, asset_teardown), cmocka_unit_test_setup_teardown(test_getEntry_dedup, asset_setup, asset_teardown), cmocka_unit_test_setup_teardown(test_getEntry_distinct_names, asset_setup, asset_teardown), cmocka_unit_test_setup_teardown(test_getEntry_returns_loaded_entry, asset_setup, asset_teardown), cmocka_unit_test_setup_teardown(test_getEntry_null_input_stays_null, asset_setup, asset_teardown), cmocka_unit_test_setup_teardown(test_getEntry_input_copied_into_entry, asset_setup, asset_teardown), // assetUpdate — state machine cmocka_unit_test_setup_teardown(test_update_noop_on_empty_table, asset_setup, asset_teardown), cmocka_unit_test_setup_teardown(test_update_entry_reaches_loaded, asset_setup, asset_teardown), cmocka_unit_test_setup_teardown(test_update_slot_occupied_after_first_update, asset_setup, asset_teardown), cmocka_unit_test_setup_teardown(test_update_slot_cleared_after_second_update, asset_setup, asset_teardown), cmocka_unit_test_setup_teardown(test_update_four_slots_fill_independently, asset_setup, asset_teardown), cmocka_unit_test_setup_teardown(test_update_loaded_entry_not_redispatched, asset_setup, asset_teardown), cmocka_unit_test_setup_teardown(test_update_overflow_queues_entries, asset_setup, asset_teardown), cmocka_unit_test_setup_teardown(test_update_error_state, asset_setup, asset_teardown), cmocka_unit_test_setup_teardown(test_update_error_slot_stays_occupied, asset_setup, asset_teardown), cmocka_unit_test_setup_teardown(test_update_reentrant_sync_loader, asset_setup, asset_teardown), // assetEntryDispose cmocka_unit_test_setup_teardown(test_entry_dispose_clears_entry, asset_setup, asset_teardown), cmocka_unit_test_setup_teardown(test_entry_dispose_slot_reusable, asset_setup, asset_teardown), // assetRequireLoaded cmocka_unit_test_setup_teardown(test_requireLoaded_already_loaded, asset_setup, asset_teardown), cmocka_unit_test_setup_teardown(test_requireLoaded_spins_to_loaded, asset_setup, asset_teardown), cmocka_unit_test_setup_teardown(test_requireLoaded_propagates_error, asset_setup, asset_teardown), }; return cmocka_run_group_tests(tests, NULL, NULL); }