/** * Copyright (c) 2026 Dominic Masters * * This software is released under the MIT License. * https://opensource.org/licenses/MIT */ #include "dusktest.h" #include "asset/asset.h" #include "asset/loader/assetloader.h" #include "asset/loader/assetentry.h" #include "asset/loader/dmf/assetmodelloader.h" #include "asset/loader/dmf/assetmeshloader.h" #include "display/mesh/meshvertex.h" #include "display/color.h" #include "util/memory.h" #include #include // ============================================================ // This drives the REAL asset system end-to-end (real background loading // thread, real JSON/MESH/MODEL loaders) rather than hand-stepping a single // loader. assetModelLoaderSync blocks on assetRequireLoaded for its JSON // and mesh sub-entries, which only resolves if something is actually // servicing ASSET_ENTRY_STATE_PENDING_ASYNC on a separate thread -- exactly // like production. Every mesh fixture uses vertCount == 0 so the mesh's // own sync phase never touches the GPU. // ============================================================ typedef struct { uint8_t magic[3]; uint8_t pad; uint32_t version; uint32_t vertCount; } dmfheader_t; static size_t buildEmptyMeshFixture(uint8_t *out) { dmfheader_t header = { .magic = { 'D', 'M', 'F' }, .pad = 0, .version = ASSET_MESH_FILE_VERSION, .vertCount = 0, }; memcpy(out, &header, sizeof(header)); return sizeof(header); } static const char_t *JSON_VALID = "{\"mesh\":\"shared.mesh\"}"; static const char_t *JSON_WITH_COLOR = "{\"mesh\":\"shared.mesh\",\"color\":[10,20,30,40]}"; static const char_t *JSON_MISSING_MESH_FIELD = "{\"notmesh\":\"x\"}"; static const char_t *JSON_MESH_NOT_FOUND = "{\"mesh\":\"nonexistent.mesh\"}"; static zip_t *g_zip = NULL; static int model_zip_add( zip_t *za, const char_t *name, const void *data, size_t len ) { zip_source_t *s = zip_source_buffer(za, data, len, 0); return (int)zip_file_add(za, name, s, ZIP_FL_OVERWRITE); } static int model_setup(void **state) { zip_error_t err; zip_error_init(&err); zip_source_t *write_src = zip_source_buffer_create(NULL, 0, 1, &err); if(!write_src) return -1; zip_t *za = zip_open_from_source(write_src, ZIP_TRUNCATE, &err); if(!za) { zip_source_free(write_src); return -1; } static uint8_t meshBuf[64]; size_t meshLen = buildEmptyMeshFixture(meshBuf); if( model_zip_add(za, "shared.mesh", meshBuf, meshLen) < 0 || model_zip_add(za, "valid.model", JSON_VALID, strlen(JSON_VALID)) < 0 || model_zip_add(za, "color.model", JSON_WITH_COLOR, strlen(JSON_WITH_COLOR)) < 0 || model_zip_add(za, "nomeshfield.model", JSON_MISSING_MESH_FIELD, strlen(JSON_MISSING_MESH_FIELD)) < 0 || model_zip_add(za, "meshnotfound.model", JSON_MESH_NOT_FOUND, strlen(JSON_MESH_NOT_FOUND)) < 0 || model_zip_add(za, "modelA.model", JSON_VALID, strlen(JSON_VALID)) < 0 || model_zip_add(za, "modelB.model", JSON_VALID, strlen(JSON_VALID)) < 0 ) { zip_close(za); return -1; } zip_source_keep(write_src); if(zip_close(za) != 0) { zip_source_free(write_src); return -1; } zip_stat_t zs; memset(&zs, 0, sizeof(zs)); if(zip_source_stat(write_src, &zs) != 0 || !(zs.valid & ZIP_STAT_SIZE)) { zip_source_free(write_src); return -1; } void *zipbuf = malloc((size_t)zs.size); if(!zipbuf) { zip_source_free(write_src); return -1; } if(zip_source_open(write_src) != 0) { free(zipbuf); zip_source_free(write_src); return -1; } zip_source_read(write_src, zipbuf, (zip_uint64_t)zs.size); zip_source_close(write_src); zip_source_free(write_src); zip_error_init(&err); zip_source_t *read_src = zip_source_buffer_create( zipbuf, (zip_uint64_t)zs.size, 1, &err ); if(!read_src) { free(zipbuf); return -1; } g_zip = zip_open_from_source(read_src, 0, &err); if(!g_zip) { zip_source_free(read_src); return -1; } memoryZero(&ASSET, sizeof(ASSET)); ASSET.zip = g_zip; for(size_t i = 0; i < ASSET_LOADING_COUNT_MAX; i++) { threadMutexInit(&ASSET.loading[i].mutex); } threadInit(&ASSET.loadThread, assetUpdateAsync); threadStart(&ASSET.loadThread); return 0; } static int model_teardown(void **state) { threadStop(&ASSET.loadThread); for(int i = 0; i < ASSET_ENTRY_COUNT_MAX; i++) { if(ASSET.entries[i].type != ASSET_LOADER_TYPE_NULL) { errorret_t ret = assetEntryDispose(&ASSET.entries[i]); if(errorIsNotOk(ret)) errorCatch(ret); } } for(size_t i = 0; i < ASSET_LOADING_COUNT_MAX; i++) { threadMutexDispose(&ASSET.loading[i].mutex); } if(g_zip) { zip_close(g_zip); g_zip = NULL; } ASSET.zip = NULL; memoryZero(&ASSET, sizeof(ASSET)); return 0; } // ============================================================ // Tests // ============================================================ static void test_model_valid_loads_mesh_with_default_color(void **state) { assetentry_t *model = assetLock( "valid.model", ASSET_LOADER_TYPE_MODEL, NULL ); errorret_t ret = assetRequireLoaded(model); assert_true(errorIsOk(ret)); assert_int_equal(model->state, ASSET_ENTRY_STATE_LOADED); assetmodeloutput_t *out = &model->data.model; assert_non_null(out->meshEntry); assert_int_equal(out->meshEntry->state, ASSET_ENTRY_STATE_LOADED); assert_null(out->texEntry); assert_int_equal((int)out->color.r, (int)COLOR_WHITE.r); assert_int_equal((int)out->color.g, (int)COLOR_WHITE.g); assert_int_equal((int)out->color.b, (int)COLOR_WHITE.b); assert_int_equal((int)out->color.a, (int)COLOR_WHITE.a); assetUnlockEntry(model); errorret_t reapRet = assetReapUnused(); assert_true(errorIsOk(reapRet)); assert_int_equal(memoryGetAllocatedCount(), 0); } static void test_model_parses_optional_color(void **state) { assetentry_t *model = assetLock( "color.model", ASSET_LOADER_TYPE_MODEL, NULL ); errorret_t ret = assetRequireLoaded(model); assert_true(errorIsOk(ret)); assetmodeloutput_t *out = &model->data.model; assert_int_equal((int)out->color.r, 10); assert_int_equal((int)out->color.g, 20); assert_int_equal((int)out->color.b, 30); assert_int_equal((int)out->color.a, 40); assetUnlockEntry(model); errorret_t reapRet = assetReapUnused(); assert_true(errorIsOk(reapRet)); assert_int_equal(memoryGetAllocatedCount(), 0); } static void test_model_missing_mesh_field_errors(void **state) { assetentry_t *model = assetLock( "nomeshfield.model", ASSET_LOADER_TYPE_MODEL, NULL ); errorret_t ret = assetRequireLoaded(model); assert_true(errorIsNotOk(ret)); errorCatch(ret); assert_int_equal(model->state, ASSET_ENTRY_STATE_ERROR); assetUnlockEntry(model); errorret_t reapRet = assetReapUnused(); assert_true(errorIsOk(reapRet)); assert_int_equal(memoryGetAllocatedCount(), 0); } static void test_model_mesh_not_found_errors(void **state) { assetentry_t *model = assetLock( "meshnotfound.model", ASSET_LOADER_TYPE_MODEL, NULL ); errorret_t ret = assetRequireLoaded(model); assert_true(errorIsNotOk(ret)); errorCatch(ret); assert_int_equal(model->state, ASSET_ENTRY_STATE_ERROR); assetUnlockEntry(model); errorret_t reapRet = assetReapUnused(); assert_true(errorIsOk(reapRet)); assert_int_equal(memoryGetAllocatedCount(), 0); } // ------------------------------------------------------------ // Caching: two models sharing one mesh sub-asset // ------------------------------------------------------------ static void test_two_models_share_one_cached_mesh(void **state) { assetentry_t *modelA = assetLock( "modelA.model", ASSET_LOADER_TYPE_MODEL, NULL ); assetentry_t *modelB = assetLock( "modelB.model", ASSET_LOADER_TYPE_MODEL, NULL ); errorret_t retA = assetRequireLoaded(modelA); assert_true(errorIsOk(retA)); errorret_t retB = assetRequireLoaded(modelB); assert_true(errorIsOk(retB)); assetentry_t *meshA = modelA->data.model.meshEntry; assetentry_t *meshB = modelB->data.model.meshEntry; // Both models reference "shared.mesh" -- the asset cache must have // dedupe'd this to a single loaded entry, not two independent loads. assert_ptr_equal(meshA, meshB); assert_int_equal((int)meshA->refs.count, 2); // Disposing modelA must release its lock on the shared mesh but leave the // mesh itself intact, since modelB still holds a reference to it. assetUnlockEntry(modelA); errorret_t reapRet = assetReapUnused(); assert_true(errorIsOk(reapRet)); assert_int_equal((int)meshB->type, (int)ASSET_LOADER_TYPE_MESH); assert_int_equal((int)meshB->refs.count, 1); assert_int_equal(meshB->state, ASSET_ENTRY_STATE_LOADED); // Now release the second model too -- the mesh becomes reapable. assetUnlockEntry(modelB); reapRet = assetReapUnused(); assert_true(errorIsOk(reapRet)); assert_int_equal(memoryGetAllocatedCount(), 0); } // ============================================================ // main // ============================================================ int main(void) { assertInit(); const struct CMUnitTest tests[] = { cmocka_unit_test_setup_teardown(test_model_valid_loads_mesh_with_default_color, model_setup, model_teardown), cmocka_unit_test_setup_teardown(test_model_parses_optional_color, model_setup, model_teardown), cmocka_unit_test_setup_teardown(test_model_missing_mesh_field_errors, model_setup, model_teardown), cmocka_unit_test_setup_teardown(test_model_mesh_not_found_errors, model_setup, model_teardown), cmocka_unit_test_setup_teardown(test_two_models_share_one_cached_mesh, model_setup, model_teardown), }; return cmocka_run_group_tests(tests, NULL, NULL); }