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>
This commit is contained in:
@@ -128,8 +128,8 @@ void assetBatchDispose(assetbatch_t *batch) {
|
|||||||
for(uint16_t i = 0; i < batch->count; i++) {
|
for(uint16_t i = 0; i < batch->count; i++) {
|
||||||
if(batch->entries[i]) {
|
if(batch->entries[i]) {
|
||||||
// Unsubscribe while we still hold a lock so the entry is live.
|
// Unsubscribe while we still hold a lock so the entry is live.
|
||||||
eventUnsubscribe(&batch->entries[i]->onLoaded, assetBatchEntryOnLoadedCb);
|
eventUnsubscribe(&batch->entries[i]->onLoaded, assetBatchEntryOnLoadedCb, batch);
|
||||||
eventUnsubscribe(&batch->entries[i]->onError, assetBatchEntryOnErrorCb);
|
eventUnsubscribe(&batch->entries[i]->onError, assetBatchEntryOnErrorCb, batch);
|
||||||
assetUnlockEntry(batch->entries[i]);
|
assetUnlockEntry(batch->entries[i]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -79,8 +79,9 @@ errorret_t assetFileRead(
|
|||||||
uint8_t tempBuffer[256];
|
uint8_t tempBuffer[256];
|
||||||
while(bytesRemaining > 0) {
|
while(bytesRemaining > 0) {
|
||||||
size_t chunkSize = mathMin(bytesRemaining, sizeof(tempBuffer));
|
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));
|
errorChain(assetFileRead(file, tempBuffer, chunkSize));
|
||||||
file->position += chunkSize;
|
|
||||||
bytesRemaining -= chunkSize;
|
bytesRemaining -= chunkSize;
|
||||||
}
|
}
|
||||||
file->lastRead = bufferSize;
|
file->lastRead = bufferSize;
|
||||||
|
|||||||
@@ -31,9 +31,14 @@ void eventSubscribe(event_t *event, eventcallback_t callback, void *user) {
|
|||||||
assertNotNull(event, "event must not be NULL");
|
assertNotNull(event, "event must not be NULL");
|
||||||
assertNotNull(callback, "callback 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++) {
|
for(uint32_t i = 0; i < event->count; i++) {
|
||||||
if(event->callbacks[i] != callback) continue;
|
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.");
|
assertUnreachable("Callback already registered, cannot subscribe twice.");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -47,12 +52,14 @@ void eventSubscribe(event_t *event, eventcallback_t callback, void *user) {
|
|||||||
event->count++;
|
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(event, "event must not be NULL");
|
||||||
assertNotNull(callback, "callback must not be NULL");
|
assertNotNull(callback, "callback must not be NULL");
|
||||||
|
|
||||||
for(uint32_t i = 0; i < event->count; i++) {
|
for(uint32_t i = 0; i < event->count; i++) {
|
||||||
if(event->callbacks[i] != callback) continue;
|
if(event->callbacks[i] != callback) continue;
|
||||||
|
void *existingUser = event->users ? event->users[i] : NULL;
|
||||||
|
if(existingUser != user) continue;
|
||||||
|
|
||||||
uint32_t last = event->count - 1;
|
uint32_t last = event->count - 1;
|
||||||
if(i != last) {
|
if(i != last) {
|
||||||
|
|||||||
@@ -51,8 +51,11 @@ void eventSubscribe(event_t *event, eventcallback_t callback, void *user);
|
|||||||
*
|
*
|
||||||
* @param event The event to unsubscribe from.
|
* @param event The event to unsubscribe from.
|
||||||
* @param callback The callback that was passed to eventSubscribe.
|
* @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
|
* Invokes all subscribed callbacks, passing params and each subscriber's user
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ add_subdirectory(animation)
|
|||||||
add_subdirectory(assert)
|
add_subdirectory(assert)
|
||||||
add_subdirectory(asset)
|
add_subdirectory(asset)
|
||||||
add_subdirectory(error)
|
add_subdirectory(error)
|
||||||
|
add_subdirectory(event)
|
||||||
if(DUSK_NETWORKING)
|
if(DUSK_NETWORKING)
|
||||||
add_subdirectory(network)
|
add_subdirectory(network)
|
||||||
endif()
|
endif()
|
||||||
|
|||||||
@@ -10,3 +10,8 @@ dusktest(test_asset.c)
|
|||||||
dusktest(test_assetjsonloader.c)
|
dusktest(test_assetjsonloader.c)
|
||||||
dusktest(test_assettilesetloader.c)
|
dusktest(test_assettilesetloader.c)
|
||||||
dusktest(test_assetanimationloader.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)
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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 <zip.h>
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 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);
|
||||||
|
}
|
||||||
@@ -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 <zip.h>
|
||||||
|
#include <string.h>
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 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);
|
||||||
|
}
|
||||||
@@ -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 <zip.h>
|
||||||
|
#include <string.h>
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 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);
|
||||||
|
}
|
||||||
@@ -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 <zip.h>
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 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);
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -10,8 +10,3 @@ dusktest(test_modulerequire.c)
|
|||||||
dusktest(test_scriptdef.c)
|
dusktest(test_scriptdef.c)
|
||||||
|
|
||||||
dusktest(test_moduleconsole.c)
|
dusktest(test_moduleconsole.c)
|
||||||
|
|
||||||
dusktest(test_overworldscene.c)
|
|
||||||
target_compile_definitions(test_overworldscene PRIVATE
|
|
||||||
DUSK_ASSETS_DIR="${DUSK_ASSETS_DIR}"
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -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 <zip.h>
|
|
||||||
#include <stdio.h>
|
|
||||||
#include <string.h>
|
|
||||||
|
|
||||||
#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);
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user