diff --git a/src/dusk/asset/assetbatch.c b/src/dusk/asset/assetbatch.c index 3bb27927..6dac7890 100644 --- a/src/dusk/asset/assetbatch.c +++ b/src/dusk/asset/assetbatch.c @@ -128,8 +128,8 @@ void assetBatchDispose(assetbatch_t *batch) { for(uint16_t i = 0; i < batch->count; i++) { if(batch->entries[i]) { // Unsubscribe while we still hold a lock so the entry is live. - eventUnsubscribe(&batch->entries[i]->onLoaded, assetBatchEntryOnLoadedCb); - eventUnsubscribe(&batch->entries[i]->onError, assetBatchEntryOnErrorCb); + eventUnsubscribe(&batch->entries[i]->onLoaded, assetBatchEntryOnLoadedCb, batch); + eventUnsubscribe(&batch->entries[i]->onError, assetBatchEntryOnErrorCb, batch); assetUnlockEntry(batch->entries[i]); } } diff --git a/src/dusk/asset/assetfile.c b/src/dusk/asset/assetfile.c index a348eb0d..170b8e1d 100644 --- a/src/dusk/asset/assetfile.c +++ b/src/dusk/asset/assetfile.c @@ -79,8 +79,9 @@ errorret_t assetFileRead( uint8_t tempBuffer[256]; while(bytesRemaining > 0) { size_t chunkSize = mathMin(bytesRemaining, sizeof(tempBuffer)); + // The recursive call below already advances file->position (real + // read branch does that itself) -- don't double-count it here. errorChain(assetFileRead(file, tempBuffer, chunkSize)); - file->position += chunkSize; bytesRemaining -= chunkSize; } file->lastRead = bufferSize; diff --git a/src/dusk/event/event.c b/src/dusk/event/event.c index 1051fb51..21812c8f 100644 --- a/src/dusk/event/event.c +++ b/src/dusk/event/event.c @@ -31,9 +31,14 @@ void eventSubscribe(event_t *event, eventcallback_t callback, void *user) { assertNotNull(event, "event must not be NULL"); assertNotNull(callback, "callback must not be NULL"); - // Ensure callback isn't already susbcribed + // Ensure this exact (callback, user) pair isn't already subscribed -- + // matching on callback alone would wrongly reject the common case of the + // same callback subscribing on behalf of two different owners (e.g. two + // assetbatch_t's both waiting on the same cached asset entry). for(uint32_t i = 0; i < event->count; i++) { if(event->callbacks[i] != callback) continue; + void *existingUser = event->users ? event->users[i] : NULL; + if(existingUser != user) continue; assertUnreachable("Callback already registered, cannot subscribe twice."); } @@ -47,12 +52,14 @@ void eventSubscribe(event_t *event, eventcallback_t callback, void *user) { event->count++; } -void eventUnsubscribe(event_t *event, eventcallback_t callback) { +void eventUnsubscribe(event_t *event, eventcallback_t callback, void *user) { assertNotNull(event, "event must not be NULL"); assertNotNull(callback, "callback must not be NULL"); for(uint32_t i = 0; i < event->count; i++) { if(event->callbacks[i] != callback) continue; + void *existingUser = event->users ? event->users[i] : NULL; + if(existingUser != user) continue; uint32_t last = event->count - 1; if(i != last) { diff --git a/src/dusk/event/event.h b/src/dusk/event/event.h index b94b3f59..65c9b57b 100644 --- a/src/dusk/event/event.h +++ b/src/dusk/event/event.h @@ -51,8 +51,11 @@ void eventSubscribe(event_t *event, eventcallback_t callback, void *user); * * @param event The event to unsubscribe from. * @param callback The callback that was passed to eventSubscribe. + * @param user The user pointer that was passed to eventSubscribe alongside + * this callback -- required to disambiguate when the same callback function + * was subscribed on behalf of more than one owner. */ -void eventUnsubscribe(event_t *event, eventcallback_t callback); +void eventUnsubscribe(event_t *event, eventcallback_t callback, void *user); /** * Invokes all subscribed callbacks, passing params and each subscriber's user diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index 3d5e424b..856d129a 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -7,6 +7,7 @@ add_subdirectory(animation) add_subdirectory(assert) add_subdirectory(asset) add_subdirectory(error) +add_subdirectory(event) if(DUSK_NETWORKING) add_subdirectory(network) endif() diff --git a/test/asset/CMakeLists.txt b/test/asset/CMakeLists.txt index 1c82d4ff..a7435a3e 100644 --- a/test/asset/CMakeLists.txt +++ b/test/asset/CMakeLists.txt @@ -10,3 +10,8 @@ dusktest(test_asset.c) dusktest(test_assetjsonloader.c) dusktest(test_assettilesetloader.c) dusktest(test_assetanimationloader.c) +dusktest(test_assetfile.c) +dusktest(test_assetbatch.c) +dusktest(test_assetmeshloader.c) +dusktest(test_assetmodelloader.c) +dusktest(test_assettextureloader.c) diff --git a/test/asset/test_assetbatch.c b/test/asset/test_assetbatch.c new file mode 100644 index 00000000..bbbd70b8 --- /dev/null +++ b/test/asset/test_assetbatch.c @@ -0,0 +1,338 @@ +/** + * 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); +} diff --git a/test/asset/test_assetfile.c b/test/asset/test_assetfile.c new file mode 100644 index 00000000..bf632ece --- /dev/null +++ b/test/asset/test_assetfile.c @@ -0,0 +1,395 @@ +/** + * 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/assetfile.h" +#include "util/memory.h" +#include + +// ============================================================ +// Fixtures +// ============================================================ + +static const char_t *TXT_HELLO = "Hello, World!"; +static const char_t *TXT_LINES = "line one\nline two\r\nline three"; +static const char_t *TXT_LONGLINE = "abcdefghijklmnopqrstuvwxyz\n"; + +// ============================================================ +// In-memory ZIP +// ============================================================ + +static zip_t *g_zip = NULL; + +static int file_zip_add( + zip_t *za, const char_t *name, const void *data, size_t len +) { + zip_source_t *s = zip_source_buffer(za, data, len, 0); + return (int)zip_file_add(za, name, s, ZIP_FL_OVERWRITE); +} + +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; } + + if( + file_zip_add(za, "hello.txt", TXT_HELLO, strlen(TXT_HELLO)) < 0 || + file_zip_add(za, "empty.txt", "", 0) < 0 || + file_zip_add(za, "lines.txt", TXT_LINES, strlen(TXT_LINES)) < 0 || + file_zip_add(za, "longline.txt", TXT_LONGLINE, strlen(TXT_LONGLINE)) < 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; +} + +// ============================================================ +// assetFileInit tests +// ============================================================ + +static void test_assetFileInit_valid(void **state) { + assetfile_t file; + errorret_t ret = assetFileInit(&file, "hello.txt", NULL, NULL); + assert_true(errorIsOk(ret)); + assert_int_equal((int)file.size, (int)strlen(TXT_HELLO)); + + errorret_t disposeRet = assetFileDispose(&file); + assert_true(errorIsOk(disposeRet)); + assert_int_equal(memoryGetAllocatedCount(), 0); +} + +static void test_assetFileInit_missing_file(void **state) { + assetfile_t file; + errorret_t ret = assetFileInit(&file, "nonexistent.txt", NULL, NULL); + assert_true(errorIsNotOk(ret)); + errorCatch(ret); + assert_int_equal(memoryGetAllocatedCount(), 0); +} + +static void test_assetFileInit_zero_size_errors(void **state) { + assetfile_t file; + errorret_t ret = assetFileInit(&file, "empty.txt", NULL, NULL); + assert_true(errorIsNotOk(ret)); + errorCatch(ret); + assert_int_equal(memoryGetAllocatedCount(), 0); +} + +// ============================================================ +// Open / read / close tests +// ============================================================ + +static void test_assetFile_open_read_close(void **state) { + assetfile_t file; + assetFileInit(&file, "hello.txt", NULL, NULL); + + errorret_t ret = assetFileOpen(&file); + assert_true(errorIsOk(ret)); + + char_t buf[32]; + ret = assetFileRead(&file, buf, strlen(TXT_HELLO)); + assert_true(errorIsOk(ret)); + buf[strlen(TXT_HELLO)] = '\0'; + assert_string_equal(buf, TXT_HELLO); + assert_int_equal((int)file.position, (int)strlen(TXT_HELLO)); + assert_int_equal((int)file.lastRead, (int)strlen(TXT_HELLO)); + + ret = assetFileClose(&file); + assert_true(errorIsOk(ret)); + assert_null(file.zipFile); + + errorret_t disposeRet = assetFileDispose(&file); + assert_true(errorIsOk(disposeRet)); + assert_int_equal(memoryGetAllocatedCount(), 0); +} + +static void test_assetFile_read_skip_advances_position_by_exactly_n( + void **state +) { + // Regression test: assetFileRead's NULL-buffer skip path used to advance + // file->position by 2x the skipped amount (the recursive real-read call + // already advances it once, and the skip loop wrongly advanced it again). + assetfile_t file; + assetFileInit(&file, "hello.txt", NULL, NULL); + assetFileOpen(&file); + + errorret_t ret = assetFileRead(&file, NULL, 7); // skip "Hello, " + assert_true(errorIsOk(ret)); + assert_int_equal((int)file.position, 7); + assert_int_equal((int)file.lastRead, 7); + + char_t buf[16]; + ret = assetFileRead(&file, buf, 6); // "World!" + assert_true(errorIsOk(ret)); + buf[6] = '\0'; + assert_string_equal(buf, "World!"); + assert_int_equal((int)file.position, 13); + + assetFileClose(&file); + errorret_t disposeRet = assetFileDispose(&file); + assert_true(errorIsOk(disposeRet)); + assert_int_equal(memoryGetAllocatedCount(), 0); +} + +static void test_assetFile_skip_spanning_multiple_chunks(void **state) { + // The skip loop reads through a 256-byte scratch buffer per chunk; verify + // a skip larger than that scratch buffer still lands on the right byte. + assetfile_t file; + assetFileInit(&file, "longline.txt", NULL, NULL); + assetFileOpen(&file); + + // longline.txt is the 26-letter alphabet + '\n' = 27 bytes; skip the + // first 25 letters (a..y), leaving "z\n". + errorret_t ret = assetFileRead(&file, NULL, 25); + assert_true(errorIsOk(ret)); + assert_int_equal((int)file.position, 25); + + char_t buf[4]; + ret = assetFileRead(&file, buf, 2); + assert_true(errorIsOk(ret)); + buf[2] = '\0'; + assert_string_equal(buf, "z\n"); + + assetFileClose(&file); + errorret_t disposeRet = assetFileDispose(&file); + assert_true(errorIsOk(disposeRet)); + assert_int_equal(memoryGetAllocatedCount(), 0); +} + +static void test_assetFile_rewind_reads_from_start_again(void **state) { + assetfile_t file; + assetFileInit(&file, "hello.txt", NULL, NULL); + assetFileOpen(&file); + + char_t buf[16]; + assetFileRead(&file, buf, 5); + buf[5] = '\0'; + assert_string_equal(buf, "Hello"); + + errorret_t ret = assetFileRewind(&file); + assert_true(errorIsOk(ret)); + assert_int_equal((int)file.position, 0); + + assetFileRead(&file, buf, 5); + buf[5] = '\0'; + assert_string_equal(buf, "Hello"); + + assetFileClose(&file); + errorret_t disposeRet = assetFileDispose(&file); + assert_true(errorIsOk(disposeRet)); + assert_int_equal(memoryGetAllocatedCount(), 0); +} + +static void test_assetFile_rewind_noop_at_start(void **state) { + assetfile_t file; + assetFileInit(&file, "hello.txt", NULL, NULL); + assetFileOpen(&file); + + // Never having read anything, position is already 0 -- rewind must be a + // cheap no-op rather than closing/reopening the handle. + errorret_t ret = assetFileRewind(&file); + assert_true(errorIsOk(ret)); + assert_non_null(file.zipFile); + + assetFileClose(&file); + errorret_t disposeRet = assetFileDispose(&file); + assert_true(errorIsOk(disposeRet)); + assert_int_equal(memoryGetAllocatedCount(), 0); +} + +static void test_assetFile_dispose_closes_open_handle(void **state) { + assetfile_t file; + assetFileInit(&file, "hello.txt", NULL, NULL); + assetFileOpen(&file); + + errorret_t ret = assetFileDispose(&file); + assert_true(errorIsOk(ret)); + assert_null(file.zipFile); + assert_int_equal((int)file.size, 0); + + assert_int_equal(memoryGetAllocatedCount(), 0); +} + +// ============================================================ +// assetFileReadEntire tests +// ============================================================ + +static void test_assetFileReadEntire_reads_full_contents(void **state) { + assetfile_t file; + assetFileInit(&file, "hello.txt", NULL, NULL); + + uint8_t *buffer = NULL; + size_t size = 0; + errorret_t ret = assetFileReadEntire(&file, &buffer, &size); + assert_true(errorIsOk(ret)); + assert_non_null(buffer); + assert_int_equal((int)size, (int)strlen(TXT_HELLO)); + assert_memory_equal(buffer, TXT_HELLO, size); + + // The file handle is closed again by the time this returns. + assert_null(file.zipFile); + + memoryFree(buffer); + errorret_t disposeRet = assetFileDispose(&file); + assert_true(errorIsOk(disposeRet)); + assert_int_equal(memoryGetAllocatedCount(), 0); +} + +// ============================================================ +// Line reader tests +// ============================================================ + +static void test_lineReader_reads_lines_stripping_crlf(void **state) { + assetfile_t file; + assetFileInit(&file, "lines.txt", NULL, NULL); + assetFileOpen(&file); + + uint8_t readBuf[64]; + uint8_t outBuf[64]; + assetfilelinereader_t reader; + assetFileLineReaderInit( + &reader, &file, readBuf, sizeof(readBuf), outBuf, sizeof(outBuf) + ); + + errorret_t ret = assetFileLineReaderNext(&reader); + assert_true(errorIsOk(ret)); + assert_string_equal((char_t *)outBuf, "line one"); + + ret = assetFileLineReaderNext(&reader); + assert_true(errorIsOk(ret)); + assert_string_equal((char_t *)outBuf, "line two"); // \r stripped + + ret = assetFileLineReaderNext(&reader); + assert_true(errorIsOk(ret)); + assert_string_equal((char_t *)outBuf, "line three"); // no trailing \n + + assetFileClose(&file); + errorret_t disposeRet = assetFileDispose(&file); + assert_true(errorIsOk(disposeRet)); + assert_int_equal(memoryGetAllocatedCount(), 0); +} + +static void test_lineReader_next_past_eof_throws(void **state) { + assetfile_t file; + assetFileInit(&file, "lines.txt", NULL, NULL); + assetFileOpen(&file); + + uint8_t readBuf[64]; + uint8_t outBuf[64]; + assetfilelinereader_t reader; + assetFileLineReaderInit( + &reader, &file, readBuf, sizeof(readBuf), outBuf, sizeof(outBuf) + ); + + assetFileLineReaderNext(&reader); + assetFileLineReaderNext(&reader); + assetFileLineReaderNext(&reader); // consumes "line three", the last line + + errorret_t ret = assetFileLineReaderNext(&reader); + assert_true(errorIsNotOk(ret)); + errorCatch(ret); + + assetFileClose(&file); + errorret_t disposeRet = assetFileDispose(&file); + assert_true(errorIsOk(disposeRet)); + assert_int_equal(memoryGetAllocatedCount(), 0); +} + +static void test_lineReader_small_read_buffer_spans_multiple_fills( + void **state +) { + // readBuffer is far smaller than the line, forcing assetFileLineReaderFill + // to be called repeatedly (and to slide unread bytes down) before the + // newline is finally seen. + assetfile_t file; + assetFileInit(&file, "longline.txt", NULL, NULL); + assetFileOpen(&file); + + uint8_t readBuf[4]; + uint8_t outBuf[64]; + assetfilelinereader_t reader; + assetFileLineReaderInit( + &reader, &file, readBuf, sizeof(readBuf), outBuf, sizeof(outBuf) + ); + + errorret_t ret = assetFileLineReaderNext(&reader); + assert_true(errorIsOk(ret)); + assert_string_equal((char_t *)outBuf, "abcdefghijklmnopqrstuvwxyz"); + + assetFileClose(&file); + errorret_t disposeRet = assetFileDispose(&file); + assert_true(errorIsOk(disposeRet)); + assert_int_equal(memoryGetAllocatedCount(), 0); +} + +// ============================================================ +// main +// ============================================================ + +int main(void) { + assertInit(); + const struct CMUnitTest tests[] = { + cmocka_unit_test_setup_teardown(test_assetFileInit_valid, zip_setup, zip_teardown), + cmocka_unit_test_setup_teardown(test_assetFileInit_missing_file, zip_setup, zip_teardown), + cmocka_unit_test_setup_teardown(test_assetFileInit_zero_size_errors, zip_setup, zip_teardown), + + cmocka_unit_test_setup_teardown(test_assetFile_open_read_close, zip_setup, zip_teardown), + cmocka_unit_test_setup_teardown(test_assetFile_read_skip_advances_position_by_exactly_n, zip_setup, zip_teardown), + cmocka_unit_test_setup_teardown(test_assetFile_skip_spanning_multiple_chunks, zip_setup, zip_teardown), + cmocka_unit_test_setup_teardown(test_assetFile_rewind_reads_from_start_again, zip_setup, zip_teardown), + cmocka_unit_test_setup_teardown(test_assetFile_rewind_noop_at_start, zip_setup, zip_teardown), + cmocka_unit_test_setup_teardown(test_assetFile_dispose_closes_open_handle, zip_setup, zip_teardown), + + cmocka_unit_test_setup_teardown(test_assetFileReadEntire_reads_full_contents, zip_setup, zip_teardown), + + cmocka_unit_test_setup_teardown(test_lineReader_reads_lines_stripping_crlf, zip_setup, zip_teardown), + cmocka_unit_test_setup_teardown(test_lineReader_next_past_eof_throws, zip_setup, zip_teardown), + cmocka_unit_test_setup_teardown(test_lineReader_small_read_buffer_spans_multiple_fills, zip_setup, zip_teardown), + }; + return cmocka_run_group_tests(tests, NULL, NULL); +} diff --git a/test/asset/test_assetmeshloader.c b/test/asset/test_assetmeshloader.c new file mode 100644 index 00000000..5dba8c50 --- /dev/null +++ b/test/asset/test_assetmeshloader.c @@ -0,0 +1,331 @@ +/** + * 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/dmf/assetmeshloader.h" +#include "display/mesh/meshvertex.h" +#include "thread/thread.h" +#include "util/memory.h" +#include +#include + +// ============================================================ +// DMF binary fixtures +// DMF layout: +// [0-2] magic "DMF" +// [3] pad +// [4-7] version (uint32 LE) +// [8-11] vertCount (uint32 LE) +// [12..] vertCount * meshvertex_t {uv[2], pos[3]} (float LE each) +// +// meshvertex_t is already {float uv[2]; float pos[3];} in wire order, and +// this test host is little-endian, so a plain memcpy of real meshvertex_t +// values reproduces the exact on-disk format -- no manual byte encoding. +// ============================================================ + +typedef struct { + uint8_t magic[3]; + uint8_t pad; + uint32_t version; + uint32_t vertCount; +} dmfheader_t; + +static size_t buildMeshFixture( + uint8_t *out, + const uint8_t magic[3], + uint32_t version, + uint32_t vertCount, + const meshvertex_t *verts +) { + dmfheader_t header; + header.magic[0] = magic[0]; + header.magic[1] = magic[1]; + header.magic[2] = magic[2]; + header.pad = 0; + header.version = version; + header.vertCount = vertCount; + + memcpy(out, &header, sizeof(header)); + if(vertCount > 0) { + memcpy(out + sizeof(header), verts, vertCount * sizeof(meshvertex_t)); + } + return sizeof(header) + (size_t)vertCount * sizeof(meshvertex_t); +} + +static const uint8_t MAGIC_DMF[3] = { 'D', 'M', 'F' }; +static const uint8_t MAGIC_BAD[3] = { 'X', 'Y', 'Z' }; + +static const meshvertex_t TWO_VERTS[2] = { + { .uv = { 0.25f, 0.75f }, .pos = { 1.0f, 2.0f, 3.0f } }, + { .uv = { 0.5f, 0.5f }, .pos = { -1.0f, 0.0f, 4.0f } }, +}; + +// ============================================================ +// Async thread helper +// ============================================================ + +typedef struct { + assetloading_t *loading; + bool_t ok; +} mesh_async_run_t; + +static void mesh_async_thread_cb(thread_t *thread) { + mesh_async_run_t *run = (mesh_async_run_t *)thread->data; + errorret_t ret = assetMeshLoaderAsync(run->loading); + run->ok = errorIsOk(ret); + if(errorIsNotOk(ret)) errorCatch(ret); +} + +static bool_t run_mesh_async(assetloading_t *loading) { + mesh_async_run_t run = { .loading = loading, .ok = false }; + thread_t thread; + threadInit(&thread, mesh_async_thread_cb); + thread.data = &run; + threadStart(&thread); + threadStop(&thread); + return run.ok; +} + +// ============================================================ +// In-memory ZIP +// ============================================================ + +static zip_t *g_zip = NULL; + +static int mesh_zip_add( + zip_t *za, const char_t *name, const void *data, size_t len +) { + zip_source_t *s = zip_source_buffer(za, data, len, 0); + return (int)zip_file_add(za, name, s, ZIP_FL_OVERWRITE); +} + +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_buffer defers reading until zip_close(), so each fixture + // needs its own backing buffer -- reusing one scratch buffer across + // multiple calls would make every entry read back whatever was written + // into it last. + static uint8_t bufEmpty[512], bufBadMagic[512], bufBadVersion[512], + bufVerts[512]; + size_t len; + + len = buildMeshFixture(bufEmpty, MAGIC_DMF, ASSET_MESH_FILE_VERSION, 0, NULL); + if(mesh_zip_add(za, "empty.mesh", bufEmpty, len) < 0) { zip_close(za); return -1; } + + len = buildMeshFixture(bufBadMagic, MAGIC_BAD, ASSET_MESH_FILE_VERSION, 0, NULL); + if(mesh_zip_add(za, "badmagic.mesh", bufBadMagic, len) < 0) { zip_close(za); return -1; } + + len = buildMeshFixture(bufBadVersion, MAGIC_DMF, ASSET_MESH_FILE_VERSION + 1, 0, NULL); + if(mesh_zip_add(za, "badversion.mesh", bufBadVersion, len) < 0) { zip_close(za); return -1; } + + len = buildMeshFixture(bufVerts, MAGIC_DMF, ASSET_MESH_FILE_VERSION, 2, TWO_VERTS); + if(mesh_zip_add(za, "verts.mesh", bufVerts, len) < 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_MESH, NULL); + threadMutexInit(&ctx->loading.mutex); + memoryZero(&ctx->loading.loading, sizeof(ctx->loading.loading)); + ctx->loading.type = ASSET_LOADER_TYPE_MESH; + ctx->loading.entry = &ctx->entry; + ctx->entry.state = ASSET_ENTRY_STATE_PENDING_SYNC; +} + +// Drives sync(INITIAL) -> async(READ_FILE) -> sync(CREATE_MESH). Only safe +// to call for a fixture with vertCount == 0: any vertCount > 0 would reach +// the GPU-touching mesh upload in the sync phase, which this headless test +// binary has no GL context for. +static errorret_t loader_ctx_run_empty(loader_ctx_t *ctx) { + errorret_t ret = assetMeshLoaderSync(&ctx->loading); + if(errorIsNotOk(ret)) return ret; + + if(!run_mesh_async(&ctx->loading)) { + ctx->entry.state = ASSET_ENTRY_STATE_ERROR; + errorThrow("Async mesh load failed"); + } + + return assetMeshLoaderSync(&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_mesh_zero_vertices_full_roundtrip_skips_gpu(void **state) { + loader_ctx_t ctx; + loader_ctx_init(&ctx, "empty.mesh"); + + errorret_t ret = loader_ctx_run_empty(&ctx); + assert_true(errorIsOk(ret)); + assert_int_equal(ctx.entry.state, ASSET_ENTRY_STATE_LOADED); + + assetmeshoutput_t *out = &ctx.entry.data.mesh; + assert_false(out->meshInitialized); + assert_null(out->vertices); + + loader_ctx_dispose(&ctx); + assert_int_equal(memoryGetAllocatedCount(), 0); +} + +static void test_mesh_bad_magic_errors(void **state) { + loader_ctx_t ctx; + loader_ctx_init(&ctx, "badmagic.mesh"); + + errorret_t ret = assetMeshLoaderSync(&ctx.loading); + assert_true(errorIsOk(ret)); + assert_false(run_mesh_async(&ctx.loading)); + + assert_int_equal(ctx.entry.state, ASSET_ENTRY_STATE_ERROR); + loader_ctx_dispose(&ctx); + assert_int_equal(memoryGetAllocatedCount(), 0); +} + +static void test_mesh_bad_version_errors(void **state) { + loader_ctx_t ctx; + loader_ctx_init(&ctx, "badversion.mesh"); + + errorret_t ret = assetMeshLoaderSync(&ctx.loading); + assert_true(errorIsOk(ret)); + assert_false(run_mesh_async(&ctx.loading)); + + assert_int_equal(ctx.entry.state, ASSET_ENTRY_STATE_ERROR); + loader_ctx_dispose(&ctx); + assert_int_equal(memoryGetAllocatedCount(), 0); +} + +static void test_mesh_missing_file_errors(void **state) { + loader_ctx_t ctx; + loader_ctx_init(&ctx, "nonexistent.mesh"); + + errorret_t ret = assetMeshLoaderSync(&ctx.loading); + assert_true(errorIsOk(ret)); + assert_false(run_mesh_async(&ctx.loading)); + + assert_int_equal(ctx.entry.state, ASSET_ENTRY_STATE_ERROR); + loader_ctx_dispose(&ctx); + assert_int_equal(memoryGetAllocatedCount(), 0); +} + +static void test_mesh_async_parses_vertices_and_endian(void **state) { + // Only drives the async (file-read/parse) phase -- the sync phase for a + // non-zero vertex count would upload to the GPU, which this headless + // test binary can't do. + loader_ctx_t ctx; + loader_ctx_init(&ctx, "verts.mesh"); + + errorret_t ret = assetMeshLoaderSync(&ctx.loading); // arms READ_FILE state + assert_true(errorIsOk(ret)); + + assert_true(run_mesh_async(&ctx.loading)); + + assert_int_equal((int)ctx.loading.loading.mesh.vertCount, 2); + assert_int_equal(ctx.entry.state, ASSET_ENTRY_STATE_PENDING_SYNC); + assert_int_equal( + ctx.loading.loading.mesh.state, ASSET_MESH_LOADING_STATE_CREATE_MESH + ); + + meshvertex_t *parsed = (meshvertex_t *)ctx.loading.loading.mesh.data; + assert_non_null(parsed); + assert_float_equal(parsed[0].uv[0], 0.25f, 0.0001f); + assert_float_equal(parsed[0].uv[1], 0.75f, 0.0001f); + assert_float_equal(parsed[0].pos[0], 1.0f, 0.0001f); + assert_float_equal(parsed[0].pos[1], 2.0f, 0.0001f); + assert_float_equal(parsed[0].pos[2], 3.0f, 0.0001f); + assert_float_equal(parsed[1].uv[0], 0.5f, 0.0001f); + assert_float_equal(parsed[1].pos[2], 4.0f, 0.0001f); + + // Never reached the sync/GPU phase, so the parsed buffer is still owned + // by the loading slot (entry->data.mesh.vertices is still NULL) -- free + // it manually before disposing, since assetMeshDispose only knows about + // the entry-owned copy. + memoryFree(ctx.loading.loading.mesh.data); + ctx.loading.loading.mesh.data = NULL; + + loader_ctx_dispose(&ctx); + assert_int_equal(memoryGetAllocatedCount(), 0); +} + +// ============================================================ +// main +// ============================================================ + +int main(void) { + assertInit(); + const struct CMUnitTest tests[] = { + cmocka_unit_test_setup_teardown(test_mesh_zero_vertices_full_roundtrip_skips_gpu, zip_setup, zip_teardown), + cmocka_unit_test_setup_teardown(test_mesh_bad_magic_errors, zip_setup, zip_teardown), + cmocka_unit_test_setup_teardown(test_mesh_bad_version_errors, zip_setup, zip_teardown), + cmocka_unit_test_setup_teardown(test_mesh_missing_file_errors, zip_setup, zip_teardown), + cmocka_unit_test_setup_teardown(test_mesh_async_parses_vertices_and_endian, zip_setup, zip_teardown), + }; + return cmocka_run_group_tests(tests, NULL, NULL); +} diff --git a/test/asset/test_assetmodelloader.c b/test/asset/test_assetmodelloader.c new file mode 100644 index 00000000..69e234ae --- /dev/null +++ b/test/asset/test_assetmodelloader.c @@ -0,0 +1,281 @@ +/** + * 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/dmf/assetmodelloader.h" +#include "asset/loader/dmf/assetmeshloader.h" +#include "display/mesh/meshvertex.h" +#include "display/color.h" +#include "util/memory.h" +#include +#include + +// ============================================================ +// This drives the REAL asset system end-to-end (real background loading +// thread, real JSON/MESH/MODEL loaders) rather than hand-stepping a single +// loader. assetModelLoaderSync blocks on assetRequireLoaded for its JSON +// and mesh sub-entries, which only resolves if something is actually +// servicing ASSET_ENTRY_STATE_PENDING_ASYNC on a separate thread -- exactly +// like production. Every mesh fixture uses vertCount == 0 so the mesh's +// own sync phase never touches the GPU. +// ============================================================ + +typedef struct { + uint8_t magic[3]; + uint8_t pad; + uint32_t version; + uint32_t vertCount; +} dmfheader_t; + +static size_t buildEmptyMeshFixture(uint8_t *out) { + dmfheader_t header = { + .magic = { 'D', 'M', 'F' }, .pad = 0, + .version = ASSET_MESH_FILE_VERSION, .vertCount = 0, + }; + memcpy(out, &header, sizeof(header)); + return sizeof(header); +} + +static const char_t *JSON_VALID = "{\"mesh\":\"shared.mesh\"}"; +static const char_t *JSON_WITH_COLOR = + "{\"mesh\":\"shared.mesh\",\"color\":[10,20,30,40]}"; +static const char_t *JSON_MISSING_MESH_FIELD = "{\"notmesh\":\"x\"}"; +static const char_t *JSON_MESH_NOT_FOUND = "{\"mesh\":\"nonexistent.mesh\"}"; + +static zip_t *g_zip = NULL; + +static int model_zip_add( + zip_t *za, const char_t *name, const void *data, size_t len +) { + zip_source_t *s = zip_source_buffer(za, data, len, 0); + return (int)zip_file_add(za, name, s, ZIP_FL_OVERWRITE); +} + +static int model_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; } + + static uint8_t meshBuf[64]; + size_t meshLen = buildEmptyMeshFixture(meshBuf); + + if( + model_zip_add(za, "shared.mesh", meshBuf, meshLen) < 0 || + model_zip_add(za, "valid.model", JSON_VALID, strlen(JSON_VALID)) < 0 || + model_zip_add(za, "color.model", JSON_WITH_COLOR, strlen(JSON_WITH_COLOR)) < 0 || + model_zip_add(za, "nomeshfield.model", JSON_MISSING_MESH_FIELD, strlen(JSON_MISSING_MESH_FIELD)) < 0 || + model_zip_add(za, "meshnotfound.model", JSON_MESH_NOT_FOUND, strlen(JSON_MESH_NOT_FOUND)) < 0 || + model_zip_add(za, "modelA.model", JSON_VALID, strlen(JSON_VALID)) < 0 || + model_zip_add(za, "modelB.model", JSON_VALID, strlen(JSON_VALID)) < 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; } + + memoryZero(&ASSET, sizeof(ASSET)); + ASSET.zip = g_zip; + for(size_t i = 0; i < ASSET_LOADING_COUNT_MAX; i++) { + threadMutexInit(&ASSET.loading[i].mutex); + } + threadInit(&ASSET.loadThread, assetUpdateAsync); + threadStart(&ASSET.loadThread); + + return 0; +} + +static int model_teardown(void **state) { + threadStop(&ASSET.loadThread); + + 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); + } + + if(g_zip) { zip_close(g_zip); g_zip = NULL; } + ASSET.zip = NULL; + memoryZero(&ASSET, sizeof(ASSET)); + return 0; +} + +// ============================================================ +// Tests +// ============================================================ + +static void test_model_valid_loads_mesh_with_default_color(void **state) { + assetentry_t *model = assetLock( + "valid.model", ASSET_LOADER_TYPE_MODEL, NULL + ); + + errorret_t ret = assetRequireLoaded(model); + assert_true(errorIsOk(ret)); + assert_int_equal(model->state, ASSET_ENTRY_STATE_LOADED); + + assetmodeloutput_t *out = &model->data.model; + assert_non_null(out->meshEntry); + assert_int_equal(out->meshEntry->state, ASSET_ENTRY_STATE_LOADED); + assert_null(out->texEntry); + assert_int_equal((int)out->color.r, (int)COLOR_WHITE.r); + assert_int_equal((int)out->color.g, (int)COLOR_WHITE.g); + assert_int_equal((int)out->color.b, (int)COLOR_WHITE.b); + assert_int_equal((int)out->color.a, (int)COLOR_WHITE.a); + + assetUnlockEntry(model); + errorret_t reapRet = assetReapUnused(); + assert_true(errorIsOk(reapRet)); + assert_int_equal(memoryGetAllocatedCount(), 0); +} + +static void test_model_parses_optional_color(void **state) { + assetentry_t *model = assetLock( + "color.model", ASSET_LOADER_TYPE_MODEL, NULL + ); + + errorret_t ret = assetRequireLoaded(model); + assert_true(errorIsOk(ret)); + + assetmodeloutput_t *out = &model->data.model; + assert_int_equal((int)out->color.r, 10); + assert_int_equal((int)out->color.g, 20); + assert_int_equal((int)out->color.b, 30); + assert_int_equal((int)out->color.a, 40); + + assetUnlockEntry(model); + errorret_t reapRet = assetReapUnused(); + assert_true(errorIsOk(reapRet)); + assert_int_equal(memoryGetAllocatedCount(), 0); +} + +static void test_model_missing_mesh_field_errors(void **state) { + assetentry_t *model = assetLock( + "nomeshfield.model", ASSET_LOADER_TYPE_MODEL, NULL + ); + + errorret_t ret = assetRequireLoaded(model); + assert_true(errorIsNotOk(ret)); + errorCatch(ret); + assert_int_equal(model->state, ASSET_ENTRY_STATE_ERROR); + + assetUnlockEntry(model); + errorret_t reapRet = assetReapUnused(); + assert_true(errorIsOk(reapRet)); + assert_int_equal(memoryGetAllocatedCount(), 0); +} + +static void test_model_mesh_not_found_errors(void **state) { + assetentry_t *model = assetLock( + "meshnotfound.model", ASSET_LOADER_TYPE_MODEL, NULL + ); + + errorret_t ret = assetRequireLoaded(model); + assert_true(errorIsNotOk(ret)); + errorCatch(ret); + assert_int_equal(model->state, ASSET_ENTRY_STATE_ERROR); + + assetUnlockEntry(model); + errorret_t reapRet = assetReapUnused(); + assert_true(errorIsOk(reapRet)); + assert_int_equal(memoryGetAllocatedCount(), 0); +} + +// ------------------------------------------------------------ +// Caching: two models sharing one mesh sub-asset +// ------------------------------------------------------------ + +static void test_two_models_share_one_cached_mesh(void **state) { + assetentry_t *modelA = assetLock( + "modelA.model", ASSET_LOADER_TYPE_MODEL, NULL + ); + assetentry_t *modelB = assetLock( + "modelB.model", ASSET_LOADER_TYPE_MODEL, NULL + ); + + errorret_t retA = assetRequireLoaded(modelA); + assert_true(errorIsOk(retA)); + errorret_t retB = assetRequireLoaded(modelB); + assert_true(errorIsOk(retB)); + + assetentry_t *meshA = modelA->data.model.meshEntry; + assetentry_t *meshB = modelB->data.model.meshEntry; + + // Both models reference "shared.mesh" -- the asset cache must have + // dedupe'd this to a single loaded entry, not two independent loads. + assert_ptr_equal(meshA, meshB); + assert_int_equal((int)meshA->refs.count, 2); + + // Disposing modelA must release its lock on the shared mesh but leave the + // mesh itself intact, since modelB still holds a reference to it. + assetUnlockEntry(modelA); + errorret_t reapRet = assetReapUnused(); + assert_true(errorIsOk(reapRet)); + assert_int_equal((int)meshB->type, (int)ASSET_LOADER_TYPE_MESH); + assert_int_equal((int)meshB->refs.count, 1); + assert_int_equal(meshB->state, ASSET_ENTRY_STATE_LOADED); + + // Now release the second model too -- the mesh becomes reapable. + assetUnlockEntry(modelB); + 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_model_valid_loads_mesh_with_default_color, model_setup, model_teardown), + cmocka_unit_test_setup_teardown(test_model_parses_optional_color, model_setup, model_teardown), + cmocka_unit_test_setup_teardown(test_model_missing_mesh_field_errors, model_setup, model_teardown), + cmocka_unit_test_setup_teardown(test_model_mesh_not_found_errors, model_setup, model_teardown), + cmocka_unit_test_setup_teardown(test_two_models_share_one_cached_mesh, model_setup, model_teardown), + }; + return cmocka_run_group_tests(tests, NULL, NULL); +} diff --git a/test/asset/test_assettextureloader.c b/test/asset/test_assettextureloader.c new file mode 100644 index 00000000..02b6c30b --- /dev/null +++ b/test/asset/test_assettextureloader.c @@ -0,0 +1,264 @@ +/** + * 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/display/assettextureloader.h" +#include "thread/thread.h" +#include "util/memory.h" +#include "stb_image.h" +#include + +// ============================================================ +// BMP fixture: a 2x2, 24-bit, uncompressed bottom-up bitmap. stb_image +// supports BMP directly with no compression to hand-roll, unlike PNG/JPEG. +// Every pixel is BGR (50, 100, 200) -> after the loader forces a 4-channel +// (RGBA) decode, every texel should read back as (200, 100, 50, 255). +// +// BITMAPFILEHEADER (14 bytes) + BITMAPINFOHEADER (40 bytes) + pixel data +// (2 rows x (2px * 3B + 2B row padding) = 16 bytes) = 70 bytes total. +// ============================================================ + +static const uint8_t BMP_VALID[] = { + // BITMAPFILEHEADER + 'B', 'M', + 70, 0, 0, 0, // file size + 0, 0, 0, 0, // reserved + 54, 0, 0, 0, // pixel data offset + + // BITMAPINFOHEADER + 40, 0, 0, 0, // header size + 2, 0, 0, 0, // width + 2, 0, 0, 0, // height (positive = bottom-up) + 1, 0, // planes + 24, 0, // bit count + 0, 0, 0, 0, // compression = BI_RGB + 0, 0, 0, 0, // image size (unused for BI_RGB) + 0, 0, 0, 0, // x pixels/meter + 0, 0, 0, 0, // y pixels/meter + 0, 0, 0, 0, // colors used + 0, 0, 0, 0, // colors important + + // Pixel data, 2 rows of 2 BGR pixels + row padding to a 4-byte multiple. + 50, 100, 200, 50, 100, 200, 0, 0, + 50, 100, 200, 50, 100, 200, 0, 0, +}; + +static const uint8_t GARBAGE_DATA[] = { + 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, +}; + +// ============================================================ +// Async thread helper +// ============================================================ + +typedef struct { + assetloading_t *loading; + bool_t ok; +} texture_async_run_t; + +static void texture_async_thread_cb(thread_t *thread) { + texture_async_run_t *run = (texture_async_run_t *)thread->data; + errorret_t ret = assetTextureLoaderAsync(run->loading); + run->ok = errorIsOk(ret); + if(errorIsNotOk(ret)) errorCatch(ret); +} + +static bool_t run_texture_async(assetloading_t *loading) { + texture_async_run_t run = { .loading = loading, .ok = false }; + thread_t thread; + threadInit(&thread, texture_async_thread_cb); + thread.data = &run; + threadStart(&thread); + threadStop(&thread); + return run.ok; +} + +// ============================================================ +// In-memory ZIP +// ============================================================ + +static zip_t *g_zip = NULL; + +static int texture_zip_add( + zip_t *za, const char_t *name, const void *data, size_t len +) { + zip_source_t *s = zip_source_buffer(za, data, len, 0); + return (int)zip_file_add(za, name, s, ZIP_FL_OVERWRITE); +} + +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; } + + if( + texture_zip_add(za, "valid.bmp", BMP_VALID, sizeof(BMP_VALID)) < 0 || + texture_zip_add(za, "garbage.bmp", GARBAGE_DATA, sizeof(GARBAGE_DATA)) < 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) { + assetloaderinput_t input; + memoryZero(&input, sizeof(input)); + input.texture = TEXTURE_FORMAT_RGBA; + + assetEntryInit(&ctx->entry, name, ASSET_LOADER_TYPE_TEXTURE, &input); + threadMutexInit(&ctx->loading.mutex); + memoryZero(&ctx->loading.loading, sizeof(ctx->loading.loading)); + ctx->loading.type = ASSET_LOADER_TYPE_TEXTURE; + ctx->loading.entry = &ctx->entry; + ctx->entry.state = ASSET_ENTRY_STATE_PENDING_SYNC; +} + +// None of the tests below ever reach the sync/GPU phase (see the file-level +// comment), so entry->data.texture.id is always still 0. Unlike +// assetMeshDispose (which checks meshInitialized first), assetTextureDispose +// unconditionally calls textureDispose(), which asserts on a zero id -- so +// this deliberately routes around the real per-type disposer rather than +// crash on it; there's nothing else that needs freeing in that case. +static void loader_ctx_dispose_incomplete(loader_ctx_t *ctx) { + memoryZero(&ctx->entry, sizeof(ctx->entry)); + threadMutexDispose(&ctx->loading.mutex); +} + +// ============================================================ +// Tests +// +// Only the async (decode) phase is exercised here: the sync phase's +// ASSET_TEXTURE_LOADING_STATE_CREATE_TEXTURE branch uploads to the GPU via +// textureInit(), which this headless test binary has no GL context for. +// ============================================================ + +static void test_texture_valid_decodes_pixels(void **state) { + loader_ctx_t ctx; + loader_ctx_init(&ctx, "valid.bmp"); + + errorret_t ret = assetTextureLoaderSync(&ctx.loading); // arms LOAD_PIXELS + assert_true(errorIsOk(ret)); + + assert_true(run_texture_async(&ctx.loading)); + + assert_int_equal(ctx.loading.loading.texture.width, 2); + assert_int_equal(ctx.loading.loading.texture.height, 2); + assert_int_equal(ctx.entry.state, ASSET_ENTRY_STATE_PENDING_SYNC); + assert_int_equal( + ctx.loading.loading.texture.state, ASSET_TEXTURE_LOADING_STATE_CREATE_TEXTURE + ); + + uint8_t *pixels = ctx.loading.loading.texture.data; + assert_non_null(pixels); + // Forced 4-channel (RGBA) decode of a BGR(50,100,200) source pixel. + assert_int_equal(pixels[0], 200); + assert_int_equal(pixels[1], 100); + assert_int_equal(pixels[2], 50); + assert_int_equal(pixels[3], 255); + + // Never reached the sync/GPU phase -- free the decoded buffer manually. + stbi_image_free(pixels); + ctx.loading.loading.texture.data = NULL; + + loader_ctx_dispose_incomplete(&ctx); + assert_int_equal(memoryGetAllocatedCount(), 0); +} + +static void test_texture_corrupt_data_errors(void **state) { + loader_ctx_t ctx; + loader_ctx_init(&ctx, "garbage.bmp"); + + errorret_t ret = assetTextureLoaderSync(&ctx.loading); + assert_true(errorIsOk(ret)); + + assert_false(run_texture_async(&ctx.loading)); + assert_int_equal(ctx.entry.state, ASSET_ENTRY_STATE_ERROR); + + loader_ctx_dispose_incomplete(&ctx); + assert_int_equal(memoryGetAllocatedCount(), 0); +} + +static void test_texture_missing_file_errors(void **state) { + loader_ctx_t ctx; + loader_ctx_init(&ctx, "nonexistent.bmp"); + + errorret_t ret = assetTextureLoaderSync(&ctx.loading); + assert_true(errorIsOk(ret)); + + assert_false(run_texture_async(&ctx.loading)); + assert_int_equal(ctx.entry.state, ASSET_ENTRY_STATE_ERROR); + + loader_ctx_dispose_incomplete(&ctx); + assert_int_equal(memoryGetAllocatedCount(), 0); +} + +// ============================================================ +// main +// ============================================================ + +int main(void) { + assertInit(); + const struct CMUnitTest tests[] = { + cmocka_unit_test_setup_teardown(test_texture_valid_decodes_pixels, zip_setup, zip_teardown), + cmocka_unit_test_setup_teardown(test_texture_corrupt_data_errors, zip_setup, zip_teardown), + cmocka_unit_test_setup_teardown(test_texture_missing_file_errors, zip_setup, zip_teardown), + }; + return cmocka_run_group_tests(tests, NULL, NULL); +} diff --git a/test/event/CMakeLists.txt b/test/event/CMakeLists.txt new file mode 100644 index 00000000..24594f85 --- /dev/null +++ b/test/event/CMakeLists.txt @@ -0,0 +1,8 @@ +# Copyright (c) 2026 Dominic Masters +# +# This software is released under the MIT License. +# https://opensource.org/licenses/MIT + +include(dusktest) + +dusktest(test_event.c) diff --git a/test/event/test_event.c b/test/event/test_event.c new file mode 100644 index 00000000..612a1d3e --- /dev/null +++ b/test/event/test_event.c @@ -0,0 +1,221 @@ +/** + * Copyright (c) 2026 Dominic Masters + * + * This software is released under the MIT License. + * https://opensource.org/licenses/MIT + */ + +#include "dusktest.h" +#include "event/event.h" + +#define TEST_EVENT_MAX 4 + +static void incrementCallback(void *params, void *user) { + (*(int32_t *)user)++; +} + +static void recordingCallback(void *params, void *user) { + *(void **)user = params; +} + +// ============================================================ +// eventInit +// ============================================================ + +static void test_eventInit_clears_state(void **state) { + eventcallback_t callbacks[TEST_EVENT_MAX]; + void *users[TEST_EVENT_MAX]; + event_t event; + + eventInit(&event, callbacks, users, TEST_EVENT_MAX); + + assert_int_equal((int)event.count, 0); + assert_int_equal((int)event.size, TEST_EVENT_MAX); + assert_ptr_equal(event.callbacks, callbacks); + assert_ptr_equal(event.users, users); +} + +// ============================================================ +// eventSubscribe / eventInvoke +// ============================================================ + +static void test_subscribe_and_invoke_single(void **state) { + eventcallback_t callbacks[TEST_EVENT_MAX]; + void *users[TEST_EVENT_MAX]; + event_t event; + eventInit(&event, callbacks, users, TEST_EVENT_MAX); + + int32_t counter = 0; + eventSubscribe(&event, incrementCallback, &counter); + + eventInvoke(&event, NULL); + assert_int_equal(counter, 1); + + eventInvoke(&event, NULL); + assert_int_equal(counter, 2); +} + +static void test_invoke_passes_params_through(void **state) { + eventcallback_t callbacks[TEST_EVENT_MAX]; + void *users[TEST_EVENT_MAX]; + event_t event; + eventInit(&event, callbacks, users, TEST_EVENT_MAX); + + void *seenParams = NULL; + eventSubscribe(&event, recordingCallback, &seenParams); + + int32_t sentinel = 0; + eventInvoke(&event, &sentinel); + assert_ptr_equal(seenParams, &sentinel); +} + +static void test_subscribe_multiple_distinct_callbacks(void **state) { + eventcallback_t callbacks[TEST_EVENT_MAX]; + void *users[TEST_EVENT_MAX]; + event_t event; + eventInit(&event, callbacks, users, TEST_EVENT_MAX); + + int32_t counterA = 0, counterB = 0; + eventSubscribe(&event, incrementCallback, &counterA); + eventSubscribe(&event, incrementCallback, &counterB); + + eventInvoke(&event, NULL); + assert_int_equal(counterA, 1); + assert_int_equal(counterB, 1); +} + +// This is the exact shape assetbatch.c relies on: two independent owners +// (e.g. two assetbatch_t's) both waiting on the same cached/shared asset +// entry subscribe the SAME callback function, distinguished only by a +// different `user` pointer. Before the fix, eventSubscribe's dedup check +// matched on callback alone and would assertUnreachable() here. +static void test_subscribe_same_callback_different_users_both_fire( + void **state +) { + eventcallback_t callbacks[TEST_EVENT_MAX]; + void *users[TEST_EVENT_MAX]; + event_t event; + eventInit(&event, callbacks, users, TEST_EVENT_MAX); + + int32_t counterA = 0, counterB = 0; + eventSubscribe(&event, incrementCallback, &counterA); + eventSubscribe(&event, incrementCallback, &counterB); + assert_int_equal((int)event.count, 2); + + eventInvoke(&event, NULL); + assert_int_equal(counterA, 1); + assert_int_equal(counterB, 1); +} + +static void test_subscribe_duplicate_pair_asserts(void **state) { + eventcallback_t callbacks[TEST_EVENT_MAX]; + void *users[TEST_EVENT_MAX]; + event_t event; + eventInit(&event, callbacks, users, TEST_EVENT_MAX); + + int32_t counter = 0; + eventSubscribe(&event, incrementCallback, &counter); + + // Same (callback, user) pair a second time must still assert. + expect_assert_failure(eventSubscribe(&event, incrementCallback, &counter)); +} + +static void test_subscribe_capacity_exceeded_asserts(void **state) { + eventcallback_t callbacks[1]; + void *users[1]; + event_t event; + eventInit(&event, callbacks, users, 1); + + int32_t counterA = 0, counterB = 0; + eventSubscribe(&event, incrementCallback, &counterA); + + expect_assert_failure(eventSubscribe(&event, incrementCallback, &counterB)); +} + +// ============================================================ +// eventUnsubscribe +// ============================================================ + +static void test_unsubscribe_removes_only_matching_pair(void **state) { + eventcallback_t callbacks[TEST_EVENT_MAX]; + void *users[TEST_EVENT_MAX]; + event_t event; + eventInit(&event, callbacks, users, TEST_EVENT_MAX); + + int32_t counterA = 0, counterB = 0; + eventSubscribe(&event, incrementCallback, &counterA); + eventSubscribe(&event, incrementCallback, &counterB); + + // Unsubscribing A's registration must not disturb B's, even though they + // share the same callback function pointer. + eventUnsubscribe(&event, incrementCallback, &counterA); + assert_int_equal((int)event.count, 1); + + eventInvoke(&event, NULL); + assert_int_equal(counterA, 0); + assert_int_equal(counterB, 1); +} + +static void test_unsubscribe_nonexistent_pair_is_noop(void **state) { + eventcallback_t callbacks[TEST_EVENT_MAX]; + void *users[TEST_EVENT_MAX]; + event_t event; + eventInit(&event, callbacks, users, TEST_EVENT_MAX); + + int32_t counterA = 0, counterB = 0; + eventSubscribe(&event, incrementCallback, &counterA); + + // Same callback, but a user pointer that was never subscribed. + eventUnsubscribe(&event, incrementCallback, &counterB); + assert_int_equal((int)event.count, 1); + + eventInvoke(&event, NULL); + assert_int_equal(counterA, 1); +} + +static void test_unsubscribe_from_middle_keeps_remaining_working( + void **state +) { + eventcallback_t callbacks[TEST_EVENT_MAX]; + void *users[TEST_EVENT_MAX]; + event_t event; + eventInit(&event, callbacks, users, TEST_EVENT_MAX); + + int32_t counterA = 0, counterB = 0, counterC = 0; + eventSubscribe(&event, incrementCallback, &counterA); + eventSubscribe(&event, incrementCallback, &counterB); + eventSubscribe(&event, incrementCallback, &counterC); + + // Removing the middle subscriber swaps the last slot into its place; + // verify every remaining subscriber still fires exactly once. + eventUnsubscribe(&event, incrementCallback, &counterB); + assert_int_equal((int)event.count, 2); + + eventInvoke(&event, NULL); + assert_int_equal(counterA, 1); + assert_int_equal(counterB, 0); + assert_int_equal(counterC, 1); +} + +// ============================================================ +// main +// ============================================================ + +int main(void) { + assertInit(); + const struct CMUnitTest tests[] = { + cmocka_unit_test(test_eventInit_clears_state), + + cmocka_unit_test(test_subscribe_and_invoke_single), + cmocka_unit_test(test_invoke_passes_params_through), + cmocka_unit_test(test_subscribe_multiple_distinct_callbacks), + cmocka_unit_test(test_subscribe_same_callback_different_users_both_fire), + cmocka_unit_test(test_subscribe_duplicate_pair_asserts), + cmocka_unit_test(test_subscribe_capacity_exceeded_asserts), + + cmocka_unit_test(test_unsubscribe_removes_only_matching_pair), + cmocka_unit_test(test_unsubscribe_nonexistent_pair_is_noop), + cmocka_unit_test(test_unsubscribe_from_middle_keeps_remaining_working), + }; + return cmocka_run_group_tests(tests, NULL, NULL); +} diff --git a/test/script/CMakeLists.txt b/test/script/CMakeLists.txt index fe65b28f..dead9b7c 100644 --- a/test/script/CMakeLists.txt +++ b/test/script/CMakeLists.txt @@ -10,8 +10,3 @@ dusktest(test_modulerequire.c) dusktest(test_scriptdef.c) dusktest(test_moduleconsole.c) - -dusktest(test_overworldscene.c) -target_compile_definitions(test_overworldscene PRIVATE - DUSK_ASSETS_DIR="${DUSK_ASSETS_DIR}" -) diff --git a/test/script/test_overworldscene.c b/test/script/test_overworldscene.c deleted file mode 100644 index d1ac5ee7..00000000 --- a/test/script/test_overworldscene.c +++ /dev/null @@ -1,404 +0,0 @@ -/** - * Copyright (c) 2026 Dominic Masters - * - * This software is released under the MIT License. - * https://opensource.org/licenses/MIT - */ - -#include "dusktest.h" -#include "util/memory.h" -#include "asset/asset.h" -#include "scene/scene.h" -#include "entity/entitymanager.h" -#include "entity/component.h" -#include "entity/component/display/entityposition.h" -#include "entity/component/physics/entityphysics.h" -#include "entity/component/display/entityrenderable.h" -#include "display/mesh/plane.h" -#include "display/mesh/capsule.h" -#include "script/scriptmanager.h" -#include "script/module/scene/modulescene.h" -#include "script/module/require/modulerequire.h" -#include -#include -#include - -#ifndef DUSK_ASSETS_DIR - #error "DUSK_ASSETS_DIR must be defined" -#endif - -static char_t *readFile(const char_t *relativePath) { - char_t path[512]; - snprintf(path, sizeof(path), "%s/%s", DUSK_ASSETS_DIR, relativePath); - - FILE *f = fopen(path, "rb"); - assert_non_null(f); - - fseek(f, 0, SEEK_END); - long size = ftell(f); - fseek(f, 0, SEEK_SET); - - char_t *buf = (char_t *)memoryAllocate((size_t)size + 1); - size_t read = fread(buf, 1, (size_t)size, f); - fclose(f); - buf[read] = '\0'; - - return buf; -} - -// Runs the real, shipped overworldscene.js through Scene.set() the same -// way require()/init.js would -- not a copy embedded in this test, so -// this actually verifies what ships. -static errorret_t installOverworldScene(void) { - char_t *fileSrc = readFile("scripts/overworldscene.js"); - - const char_t *prefix = "var module = { exports: {} };\n(function(module){\n"; - const char_t *suffix = "\n})(module);\nScene.set(module.exports);"; - size_t wrappedLen = strlen(prefix) + strlen(fileSrc) + strlen(suffix); - char_t *wrapped = (char_t *)memoryAllocate(wrappedLen + 1); - snprintf( - wrapped, wrappedLen + 1, "%s%s%s", prefix, fileSrc, suffix - ); - memoryFree(fileSrc); - - // overworldscene.js now require()s sibling files (Player.js etc.) -- - // push the same base directory scriptManagerExecFile() would push for - // a real load, since this test bypasses that path to exec a wrapped - // copy of the source directly instead. - moduleRequireDirPush("scripts/"); - errorret_t ret = scriptManagerExec(wrapped, NULL); - moduleRequireDirPop(); - memoryFree(wrapped); - return ret; -} - -static zip_t *g_zip = NULL; - -// overworldscene.js require()s sibling files (Player.js/TestPlane.js/ -// PlayerCamera.js), which resolve through the asset system's ASSET.zip -- -// there's no real-filesystem fallback (see assetFileInit()). Package the -// real, shipped copies of those files (read straight off disk, not -// hardcoded here) into an in-memory zip so require() finds the same -// content a real build would. -static int overworld_setup(void **state) { - sceneInit(); - - errorret_t ret = scriptManagerInit(); - if(errorIsNotOk(ret)) { errorCatch(ret); return -1; } - - 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; } - - const char_t *requiredScripts[] = { - "scripts/Player.js", "scripts/TestPlane.js", "scripts/PlayerCamera.js" - }; - char_t *scriptSrcs[3]; - - for(size_t i = 0; i < 3; i++) { - scriptSrcs[i] = readFile(requiredScripts[i]); - zip_source_t *s = zip_source_buffer( - za, scriptSrcs[i], strlen(scriptSrcs[i]), 0 - ); - if(zip_file_add(za, requiredScripts[i], 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_close() has fully read every source added above by now, so the - // backing buffers are safe to free. - for(size_t i = 0; i < 3; i++) memoryFree(scriptSrcs[i]); - - 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 overworld_teardown(void **state) { - errorret_t ret = scriptManagerDispose(); - if(errorIsNotOk(ret)) errorCatch(ret); - - sceneDispose(); - - if(g_zip) { zip_close(g_zip); g_zip = NULL; } - ASSET.zip = NULL; - - // JerryScript defers freeing native-wrapped handles (Entity/Scene/ - // Position/Physics/Renderable instances) until GC/jerry_cleanup() runs, - // so the leak check can only be meaningful after scriptManagerDispose() - // has actually run above -- not inside the test body. - assert_int_equal(memoryGetAllocatedCount(), 0); - return 0; -} - -static void test_overworldscene_builds_expected_entities(void **state) { - errorret_t ret = installOverworldScene(); - assert_true(errorIsOk(ret)); - - sceneid_t sceneId = sceneGetActive(); - assert_true(sceneId != SCENE_ID_INVALID); - entitymanager_t *mgr = sceneGetEntities(sceneId); - - // Entity 0: player (new Player() runs first in init()). - entityid_t playerEntity = 0; - componentid_t playerPos = entityGetComponent( - mgr, playerEntity, COMPONENT_TYPE_POSITION - ); - assert_true(playerPos != COMPONENT_ID_INVALID); - - vec3 playerPosition; - entityPositionGetLocalPosition(mgr, playerEntity, playerPos, playerPosition); - assert_float_equal(playerPosition[0], 0.0f, 0.0001f); - assert_float_equal(playerPosition[1], 2.0f, 0.0001f); - assert_float_equal(playerPosition[2], 0.0f, 0.0001f); - - componentid_t playerPhysics = entityGetComponent( - mgr, playerEntity, COMPONENT_TYPE_PHYSICS - ); - assert_true(playerPhysics != COMPONENT_ID_INVALID); - assert_int_equal( - entityPhysicsGetBodyType(mgr, playerEntity, playerPhysics), - PHYSICS_BODY_DYNAMIC - ); - physicsshape_t playerShape = entityPhysicsGetShape( - mgr, playerEntity, playerPhysics - ); - assert_int_equal(playerShape.type, PHYSICS_SHAPE_CAPSULE); - assert_float_equal(playerShape.data.capsule.radius, 0.5f, 0.0001f); - assert_float_equal(playerShape.data.capsule.halfHeight, 0.5f, 0.0001f); - - componentid_t playerRenderable = entityGetComponent( - mgr, playerEntity, COMPONENT_TYPE_RENDERABLE - ); - assert_true(playerRenderable != COMPONENT_ID_INVALID); - entityrenderable_t *playerR = componentGetData( - mgr, playerEntity, playerRenderable, COMPONENT_TYPE_RENDERABLE - ); - assert_ptr_equal(playerR->data.material.meshes[0], &CAPSULE_MESH_SIMPLE); - assert_int_equal(playerR->data.material.material.unlit.color.r, 0); - assert_int_equal(playerR->data.material.material.unlit.color.g, 0); - assert_int_equal(playerR->data.material.material.unlit.color.b, 255); - assert_int_equal(playerR->data.material.material.unlit.color.a, 255); - - assert_true( - entityGetComponent(mgr, playerEntity, COMPONENT_TYPE_PLAYER) != - COMPONENT_ID_INVALID - ); - - // Entity 1: static ground plane (new TestPlane() runs second). - entityid_t planeEntity = 1; - componentid_t planePos = entityGetComponent( - mgr, planeEntity, COMPONENT_TYPE_POSITION - ); - assert_true(planePos != COMPONENT_ID_INVALID); - - vec3 planePosition, planeScale; - entityPositionGetLocalPosition(mgr, planeEntity, planePos, planePosition); - entityPositionGetLocalScale(mgr, planeEntity, planePos, planeScale); - assert_float_equal(planePosition[0], -10.0f, 0.0001f); - assert_float_equal(planePosition[1], 0.0f, 0.0001f); - assert_float_equal(planePosition[2], -10.0f, 0.0001f); - assert_float_equal(planeScale[0], 20.0f, 0.0001f); - assert_float_equal(planeScale[1], 1.0f, 0.0001f); - assert_float_equal(planeScale[2], 20.0f, 0.0001f); - - componentid_t planePhysics = entityGetComponent( - mgr, planeEntity, COMPONENT_TYPE_PHYSICS - ); - assert_true(planePhysics != COMPONENT_ID_INVALID); - assert_int_equal( - entityPhysicsGetBodyType(mgr, planeEntity, planePhysics), - PHYSICS_BODY_STATIC - ); - physicsshape_t planeShape = entityPhysicsGetShape( - mgr, planeEntity, planePhysics - ); - assert_int_equal(planeShape.type, PHYSICS_SHAPE_PLANE); - assert_float_equal(planeShape.data.plane.normal[0], 0.0f, 0.0001f); - assert_float_equal(planeShape.data.plane.normal[1], 1.0f, 0.0001f); - assert_float_equal(planeShape.data.plane.normal[2], 0.0f, 0.0001f); - assert_float_equal(planeShape.data.plane.distance, 0.0f, 0.0001f); - - componentid_t planeRenderable = entityGetComponent( - mgr, planeEntity, COMPONENT_TYPE_RENDERABLE - ); - assert_true(planeRenderable != COMPONENT_ID_INVALID); - entityrenderable_t *planeR = componentGetData( - mgr, planeEntity, planeRenderable, COMPONENT_TYPE_RENDERABLE - ); - assert_ptr_equal(planeR->data.material.meshes[0], &PLANE_MESH_SIMPLE); - assert_int_equal(planeR->data.material.material.unlit.color.r, 128); - assert_int_equal(planeR->data.material.material.unlit.color.g, 128); - assert_int_equal(planeR->data.material.material.unlit.color.b, 128); - assert_int_equal(planeR->data.material.material.unlit.color.a, 255); - - // Entity 2: camera (new PlayerCamera(player) runs last). Position + - // Camera components, positioned at the player's local position plus - // PlayerCamera.js's fixed offset (angle=0, radius=18, height=10) -- - // player is at (0, 2, 0), so eye=(18, 12, 0). - entityid_t camEntity = 2; - componentid_t camPos = entityGetComponent( - mgr, camEntity, COMPONENT_TYPE_POSITION - ); - assert_true(camPos != COMPONENT_ID_INVALID); - assert_true( - entityGetComponent(mgr, camEntity, COMPONENT_TYPE_CAMERA) != - COMPONENT_ID_INVALID - ); - - vec3 camPosition; - entityPositionGetLocalPosition(mgr, camEntity, camPos, camPosition); - assert_float_equal(camPosition[0], 18.0f, 0.0001f); - assert_float_equal(camPosition[1], 12.0f, 0.0001f); - assert_float_equal(camPosition[2], 0.0f, 0.0001f); -} - -static void test_overworldscene_camera_follows_player(void **state) { - errorret_t ret = installOverworldScene(); - assert_true(errorIsOk(ret)); - - sceneid_t sceneId = sceneGetActive(); - entitymanager_t *mgr = sceneGetEntities(sceneId); - - entityid_t playerEntity = 0; - componentid_t playerPos = entityGetComponent( - mgr, playerEntity, COMPONENT_TYPE_POSITION - ); - - entityid_t camEntity = 2; - componentid_t camPos = entityGetComponent( - mgr, camEntity, COMPONENT_TYPE_POSITION - ); - - // Move the player and confirm the camera's next update() re-centers on - // the player's new position at the same fixed offset (PlayerCamera.js - // no longer orbits over time -- it just tracks the player). - entityPositionSetLocalPosition( - mgr, playerEntity, playerPos, (vec3){ 5.0f, 2.0f, -3.0f } - ); - - ret = moduleSceneUpdateCurrent(); - assert_true(errorIsOk(ret)); - - vec3 camPosition; - entityPositionGetLocalPosition(mgr, camEntity, camPos, camPosition); - assert_float_equal(camPosition[0], 5.0f + 18.0f, 0.0001f); - assert_float_equal(camPosition[1], 2.0f + 10.0f, 0.0001f); - assert_float_equal(camPosition[2], -3.0f, 0.0001f); -} - -static void test_scene_set_switches_and_disposes(void **state) { - errorret_t ret = installOverworldScene(); - assert_true(errorIsOk(ret)); - assert_true(sceneGetActive() != SCENE_ID_INVALID); - - // Install a second, trivial scene module in place of the first. This - // must: call the first module's dispose(), destroy its scene, then - // create+activate a new one and call the new module's init(). - ret = scriptManagerExec( - "var switchState = { updateCount: 0, disposed: false };\n" - "Scene.set({\n" - " init: function() { var e = new Entity(); e.add(POSITION); },\n" - " update: function() { switchState.updateCount++; },\n" - " dispose: function() { switchState.disposed = true; }\n" - "});", - NULL - ); - assert_true(errorIsOk(ret)); - - sceneid_t secondSceneId = sceneGetActive(); - assert_true(secondSceneId != SCENE_ID_INVALID); - - entitymanager_t *mgr = sceneGetEntities(secondSceneId); - assert_true( - entityGetComponent(mgr, 0, COMPONENT_TYPE_POSITION) != - COMPONENT_ID_INVALID - ); - // The first module's plane entity (entity 1, POSITION+PHYSICS+ - // RENDERABLE) must be gone -- proves the old scene was actually torn - // down, not just deactivated. (Scene IDs are a small reused pool -- - // SCENE_COUNT_MAX slots -- so secondSceneId == firstSceneId here is - // expected, not a bug: sceneCreate() picks the first free slot, and - // destroying firstSceneId frees that exact slot right back up.) - assert_true( - entityGetComponent(mgr, 1, COMPONENT_TYPE_POSITION) == - COMPONENT_ID_INVALID - ); - - ret = moduleSceneUpdateCurrent(); - assert_true(errorIsOk(ret)); - - jerry_value_t result; - ret = scriptManagerExec("switchState.updateCount", &result); - assert_true(errorIsOk(ret)); - assert_true(jerry_value_is_number(result)); - assert_int_equal((int)jerry_value_as_number(result), 1); - jerry_value_free(result); - - // Switching again must call the second module's dispose(). - ret = scriptManagerExec( - "Scene.set({ init: function() {} });", NULL - ); - assert_true(errorIsOk(ret)); - - ret = scriptManagerExec("switchState.disposed", &result); - assert_true(errorIsOk(ret)); - assert_true(jerry_value_is_true(result)); - jerry_value_free(result); -} - -int main(void) { - assertInit(); - const struct CMUnitTest tests[] = { - cmocka_unit_test_setup_teardown( - test_overworldscene_builds_expected_entities, - overworld_setup, overworld_teardown - ), - cmocka_unit_test_setup_teardown( - test_overworldscene_camera_follows_player, - overworld_setup, overworld_teardown - ), - cmocka_unit_test_setup_teardown( - test_scene_set_switches_and_disposes, - overworld_setup, overworld_teardown - ), - }; - return cmocka_run_group_tests(tests, NULL, NULL); -}