93ab7690ba
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>
339 lines
11 KiB
C
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);
|
|
}
|