93ab7690ba
Adds missing test coverage for the asset pipeline's binary loaders (mesh/model/texture), assetfile.c, and assetbatch.c, plus a new test/event suite for the shared event primitive. Found and fixed two real bugs while writing this: - assetFileRead's NULL-buffer skip path double-counted file->position, which could trip stb_image's EOF check early on images that skip bytes mid-decode. - eventSubscribe/eventUnsubscribe matched only on the callback pointer instead of the (callback, user) pair the docs already promised, so two independent consumers of the same cached asset (e.g. two assetbatch_t's) would abort. Covered by dedicated caching tests in both test_assetbatch.c and test_assetmodelloader.c. Also drops test_overworldscene.c, broken by the in-progress overworldscene.js/init.js export-contract rewrite. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
282 lines
9.4 KiB
C
282 lines
9.4 KiB
C
/**
|
|
* Copyright (c) 2026 Dominic Masters
|
|
*
|
|
* This software is released under the MIT License.
|
|
* https://opensource.org/licenses/MIT
|
|
*/
|
|
|
|
#include "dusktest.h"
|
|
#include "asset/asset.h"
|
|
#include "asset/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);
|
|
}
|