Files
dusk/test/asset/test_assetbatch.c
YourWishes 93ab7690ba Add asset/event test coverage, fix caching-breaking eventSubscribe bug
Adds missing test coverage for the asset pipeline's binary loaders
(mesh/model/texture), assetfile.c, and assetbatch.c, plus a new
test/event suite for the shared event primitive.

Found and fixed two real bugs while writing this:
- assetFileRead's NULL-buffer skip path double-counted file->position,
  which could trip stb_image's EOF check early on images that skip
  bytes mid-decode.
- eventSubscribe/eventUnsubscribe matched only on the callback pointer
  instead of the (callback, user) pair the docs already promised, so
  two independent consumers of the same cached asset (e.g. two
  assetbatch_t's) would abort. Covered by dedicated caching tests in
  both test_assetbatch.c and test_assetmodelloader.c.

Also drops test_overworldscene.c, broken by the in-progress
overworldscene.js/init.js export-contract rewrite.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-11 14:34:51 -05:00

339 lines
11 KiB
C

/**
* 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);
}