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:
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user