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