From 22162db2daecc8e238cf0f0ee7e6e3e6665d7885 Mon Sep 17 00:00:00 2001 From: Andrew Kesterson Date: Thu, 30 Jul 2026 02:03:21 -0400 Subject: [PATCH] Add physics, heap, json_helpers, game, and actor test suites Raise line coverage from 39.6 to 61.8 percent with four new suites and an extension to the actor suite, and register every suite through a single CMake list so a new test file cannot be left out of the coverage fixture. Give the test targets a build-tree RPATH and prepend the build tree to LD_LIBRARY_PATH for CTest, so a developer with a previously installed libakgl.so exercises the library that was just compiled. Fix six defects the new tests exposed: - akgl_physics_simulate read self->gravity_time before its NULL check, so a NULL backend crashed instead of reporting AKERR_NULLPOINTER. - akgl_game_save transposed CLEANUP and PROCESS, which placed the fclose inside the PROCESS switch. An ordinary save never flushed or closed its stream and produced an empty file. - akgl_game_save_actors wrote each name table terminator from the address of a single char, emitting stack contents into the save file and a sentinel the loader could not recognize. - akgl_game_load_objectnamemap used CATCH directly inside while(1), where the break leaves the loop rather than propagating, so a truncated name table loaded as a successful game. - akgl_Actor_cmhf_up_on and _down_on dereferenced actor->basechar with no NULL check, unlike their left and right counterparts. - akgl_actor_logic_movement checked actor twice instead of checking actor->basechar before dereferencing it. Co-Authored-By: Claude Opus 5 (1M context) --- CMakeLists.txt | 107 ++- src/actor.c | 6 +- src/game.c | 50 +- src/physics.c | 9 +- tests/actor.c | 598 +++++++++++++++ tests/assets/snippets/test_json_helpers.json | 19 + tests/game.c | 421 +++++++++++ tests/heap.c | 378 ++++++++++ tests/json_helpers.c | 366 +++++++++ tests/physics.c | 747 +++++++++++++++++++ tests/testutil.h | 99 +++ 11 files changed, 2757 insertions(+), 43 deletions(-) create mode 100644 tests/assets/snippets/test_json_helpers.json create mode 100644 tests/game.c create mode 100644 tests/heap.c create mode 100644 tests/json_helpers.c create mode 100644 tests/physics.c create mode 100644 tests/testutil.h diff --git a/CMakeLists.txt b/CMakeLists.txt index 511fcce..c2db4e4 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -119,27 +119,34 @@ add_library(akgl SHARED add_library(akgl::akgl ALIAS akgl) add_executable(charviewer util/charviewer.c) -add_executable(test_actor tests/actor.c) -add_executable(test_bitmasks tests/bitmasks.c) -add_executable(test_character tests/character.c) -add_executable(test_registry tests/registry.c) -add_executable(test_sprite tests/sprite.c) -add_executable(test_staticstring tests/staticstring.c) -add_executable(test_tilemap tests/tilemap.c) -add_executable(test_util tests/util.c) add_executable(test_semver_unit deps/semver/semver_unit.c) -add_test(NAME actor COMMAND test_actor) -add_test(NAME bitmasks COMMAND test_bitmasks) -add_test(NAME character COMMAND test_character) -add_test(NAME registry COMMAND test_registry) -add_test(NAME sprite COMMAND test_sprite) -add_test(NAME staticstring COMMAND test_staticstring) -add_test(NAME tilemap COMMAND test_tilemap) -add_test(NAME util COMMAND test_util) + +# Every suite here is a standalone C program named tests/.c, built as +# test_ and registered with CTest under . +set(AKGL_TEST_SUITES + actor + bitmasks + character + game + heap + json_helpers + physics + registry + sprite + staticstring + tilemap + util +) + +foreach(suite IN LISTS AKGL_TEST_SUITES) + add_executable(test_${suite} tests/${suite}.c) + add_test(NAME ${suite} COMMAND test_${suite}) +endforeach() + add_test(NAME semver_unit COMMAND test_semver_unit) set_tests_properties( - actor bitmasks character registry sprite staticstring tilemap util semver_unit + ${AKGL_TEST_SUITES} semver_unit PROPERTIES WORKING_DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}/tests" TIMEOUT 30 ) @@ -164,8 +171,10 @@ if(AKGL_COVERAGE) --delete ) set_tests_properties(coverage_reset PROPERTIES FIXTURES_SETUP akgl_coverage) + # Any suite missing from this list runs outside the fixture and has its + # counters discarded by coverage_reset. set_tests_properties( - actor bitmasks character registry sprite staticstring tilemap util semver_unit + ${AKGL_TEST_SUITES} semver_unit PROPERTIES FIXTURES_REQUIRED akgl_coverage ) @@ -198,17 +207,63 @@ target_link_libraries(akgl jansson::jansson ) -target_link_libraries(test_actor PRIVATE akstdlib::akstdlib akerror::akerror akgl SDL3::SDL3 SDL3_ttf::SDL3_ttf SDL3_image::SDL3_image SDL3_mixer::SDL3_mixer jansson::jansson -lm) -target_link_libraries(test_bitmasks PRIVATE akstdlib::akstdlib akerror::akerror akgl SDL3::SDL3 SDL3_ttf::SDL3_ttf SDL3_image::SDL3_image SDL3_mixer::SDL3_mixer jansson::jansson -lm) -target_link_libraries(test_character PRIVATE akstdlib::akstdlib akerror::akerror akgl SDL3::SDL3 SDL3_ttf::SDL3_ttf SDL3_image::SDL3_image SDL3_mixer::SDL3_mixer jansson::jansson -lm) -target_link_libraries(test_registry PRIVATE akstdlib::akstdlib akerror::akerror akgl SDL3::SDL3 SDL3_ttf::SDL3_ttf SDL3_image::SDL3_image SDL3_mixer::SDL3_mixer jansson::jansson -lm) -target_link_libraries(test_sprite PRIVATE akstdlib::akstdlib akerror::akerror akgl SDL3::SDL3 SDL3_ttf::SDL3_ttf SDL3_image::SDL3_image SDL3_mixer::SDL3_mixer jansson::jansson -lm) -target_link_libraries(test_staticstring PRIVATE akstdlib::akstdlib akerror::akerror akgl SDL3::SDL3 SDL3_ttf::SDL3_ttf SDL3_image::SDL3_image SDL3_mixer::SDL3_mixer jansson::jansson -lm) -target_link_libraries(test_tilemap PRIVATE akstdlib::akstdlib akerror::akerror akgl SDL3::SDL3 SDL3_ttf::SDL3_ttf SDL3_image::SDL3_image SDL3_mixer::SDL3_mixer jansson::jansson -lm) -target_link_libraries(test_util PRIVATE akstdlib::akstdlib akerror::akerror akgl SDL3::SDL3 SDL3_ttf::SDL3_ttf SDL3_image::SDL3_image SDL3_mixer::SDL3_mixer jansson::jansson -lm) +foreach(suite IN LISTS AKGL_TEST_SUITES) + target_link_libraries(test_${suite} PRIVATE akstdlib::akstdlib akerror::akerror akgl SDL3::SDL3 SDL3_ttf::SDL3_ttf SDL3_image::SDL3_image SDL3_mixer::SDL3_mixer jansson::jansson -lm) + target_include_directories(test_${suite} PRIVATE "${CMAKE_CURRENT_SOURCE_DIR}/tests") +endforeach() target_link_libraries(charviewer PRIVATE akstdlib::akstdlib akerror::akerror akgl SDL3::SDL3 SDL3_ttf::SDL3_ttf SDL3_image::SDL3_image SDL3_mixer::SDL3_mixer jansson::jansson -lm) +# When the vendored SDL satellite libraries are built in-tree they land in per- +# project subdirectories that are not on the loader's default search path, so a +# freshly built test aborts before main() with "cannot open shared object file". +# Bake those directories into the build-tree RPATH. Installed builds resolve the +# same libraries through find_package and need no help. +if(CMAKE_SOURCE_DIR STREQUAL CMAKE_CURRENT_SOURCE_DIR) + set(AKGL_VENDORED_RPATH + "$" + "$" + "$" + "$" + "$" + "$" + ) + foreach(suite IN LISTS AKGL_TEST_SUITES) + set_target_properties(test_${suite} PROPERTIES BUILD_RPATH "${AKGL_VENDORED_RPATH}") + endforeach() + set_target_properties(charviewer akgl PROPERTIES BUILD_RPATH "${AKGL_VENDORED_RPATH}") + + # RPATH alone is not enough: LD_LIBRARY_PATH is searched first, so a developer + # who has previously run rebuild.sh has an installed libakgl.so ahead of the + # one under test. Prepend the build tree for the CTest run so the suite always + # exercises what was just compiled. + set(AKGL_TEST_LIBPATH + "${CMAKE_CURRENT_BINARY_DIR}" + "${CMAKE_CURRENT_BINARY_DIR}/deps/SDL" + "${CMAKE_CURRENT_BINARY_DIR}/deps/SDL_image" + "${CMAKE_CURRENT_BINARY_DIR}/deps/SDL_ttf" + "${CMAKE_CURRENT_BINARY_DIR}/deps/SDL_mixer" + "${CMAKE_CURRENT_BINARY_DIR}/deps/libakerror" + "${CMAKE_CURRENT_BINARY_DIR}/deps/libakstdlib" + ) + if(CMAKE_VERSION VERSION_GREATER_EQUAL "3.22") + set(AKGL_TEST_ENV_MOD "") + foreach(dir IN LISTS AKGL_TEST_LIBPATH) + list(APPEND AKGL_TEST_ENV_MOD "LD_LIBRARY_PATH=path_list_prepend:${dir}") + endforeach() + set_tests_properties( + ${AKGL_TEST_SUITES} + PROPERTIES ENVIRONMENT_MODIFICATION "${AKGL_TEST_ENV_MOD}" + ) + else() + string(REPLACE ";" ":" AKGL_TEST_LIBPATH_JOINED "${AKGL_TEST_LIBPATH}") + set_tests_properties( + ${AKGL_TEST_SUITES} + PROPERTIES ENVIRONMENT "LD_LIBRARY_PATH=${AKGL_TEST_LIBPATH_JOINED}:$ENV{LD_LIBRARY_PATH}" + ) + endif() +endif() + # Mutation testing copies the repository to scratch space, applies one small # source change at a time, and verifies that the passing tests detect it. The # intentionally failing character test is excluded by the harness. diff --git a/src/actor.c b/src/actor.c index 3e99ade..f520e80 100644 --- a/src/actor.c +++ b/src/actor.c @@ -127,7 +127,7 @@ akerr_ErrorContext *akgl_actor_logic_movement(akgl_Actor *actor, float32_t dt) { PREPARE_ERROR(errctx); FAIL_ZERO_RETURN(errctx, actor, AKERR_NULLPOINTER, "actor"); - FAIL_ZERO_RETURN(errctx, actor, AKERR_NULLPOINTER, "actor->basechar"); + FAIL_ZERO_RETURN(errctx, actor->basechar, AKERR_NULLPOINTER, "actor->basechar"); actor->sx = actor->basechar->sx; actor->sy = actor->basechar->sy; actor->sz = actor->basechar->sz; @@ -381,7 +381,8 @@ akerr_ErrorContext AKERR_NOIGNORE *akgl_Actor_cmhf_up_on(akgl_Actor *obj, SDL_Ev PREPARE_ERROR(errctx); FAIL_ZERO_RETURN(errctx, obj, AKERR_NULLPOINTER, "NULL actor"); FAIL_ZERO_RETURN(errctx, event, AKERR_NULLPOINTER, "NULL event"); - //SDL_Log("event %d (button %d / key %d) moves actor up", event->type, event->gbutton.which, event->key.key); + FAIL_ZERO_RETURN(errctx, obj->basechar, AKERR_NULLPOINTER, "actor->basechar"); + //SDL_Log("event %d (button %d / key %d) moves actor up", event->type, event->gbutton.which, event->key.key); obj->ay = -(obj->basechar->ay); AKGL_BITMASK_DEL(obj->state, (AKGL_ACTOR_STATE_FACE_ALL | AKGL_ACTOR_STATE_MOVING_ALL)); AKGL_BITMASK_ADD(obj->state, (AKGL_ACTOR_STATE_FACE_UP | AKGL_ACTOR_STATE_MOVING_UP)); @@ -409,6 +410,7 @@ akerr_ErrorContext AKERR_NOIGNORE *akgl_Actor_cmhf_down_on(akgl_Actor *obj, SDL_ PREPARE_ERROR(errctx); FAIL_ZERO_RETURN(errctx, obj, AKERR_NULLPOINTER, "NULL actor"); FAIL_ZERO_RETURN(errctx, event, AKERR_NULLPOINTER, "NULL event"); + FAIL_ZERO_RETURN(errctx, obj->basechar, AKERR_NULLPOINTER, "actor->basechar"); //SDL_Log("event %d (button %d / key %d) moves actor down", event->type, event->gbutton.which, event->key.key); obj->ay = obj->basechar->ay; AKGL_BITMASK_DEL(obj->state, (AKGL_ACTOR_STATE_FACE_ALL | AKGL_ACTOR_STATE_MOVING_ALL)); diff --git a/src/game.c b/src/game.c index 6f561c2..e34ae21 100644 --- a/src/game.c +++ b/src/game.c @@ -263,8 +263,17 @@ void akgl_game_save_charactername_iterator(void *userdata, SDL_PropertiesID prop akerr_ErrorContext AKERR_NOIGNORE *akgl_game_save_actors(FILE *fp) { PREPARE_ERROR(e); - char nullval = 0x00; - + // Each name table ends with a zeroed name field and a zeroed pointer, which + // is what akgl_game_load_objectnamemap() looks for to stop reading. The + // terminator has to come from a buffer at least as long as the longest name + // field: writing N bytes from the address of a single char would emit N-1 + // bytes of whatever happened to follow it on the stack, which both leaks + // stack contents into the save file and produces a sentinel the loader + // cannot recognize. + char nullbuf[AKGL_SPRITE_SHEET_MAX_FILENAME_LENGTH]; + + memset((void *)&nullbuf, 0x00, sizeof(nullbuf)); + ATTEMPT { FAIL_ZERO_BREAK(e, fp, AKERR_NULLPOINTER, "NULL file pointer"); // write the actor name pointer table @@ -272,29 +281,29 @@ akerr_ErrorContext AKERR_NOIGNORE *akgl_game_save_actors(FILE *fp) AKGL_REGISTRY_ACTOR, &akgl_game_save_actorname_iterator, (void *)fp); - CATCH(e, aksl_fwrite((void *)&nullval, 1, AKGL_ACTOR_MAX_NAME_LENGTH, fp)); - CATCH(e, aksl_fwrite((void *)&nullval, 1, sizeof(akgl_Actor *), fp)); + CATCH(e, aksl_fwrite((void *)&nullbuf, 1, AKGL_ACTOR_MAX_NAME_LENGTH, fp)); + CATCH(e, aksl_fwrite((void *)&nullbuf, 1, sizeof(akgl_Actor *), fp)); // write the sprite name pointer table SDL_EnumerateProperties( AKGL_REGISTRY_SPRITE, &akgl_game_save_spritename_iterator, (void *)fp); - CATCH(e, aksl_fwrite((void *)&nullval, 1, AKGL_SPRITE_MAX_NAME_LENGTH, fp)); - CATCH(e, aksl_fwrite((void *)&nullval, 1, sizeof(akgl_Sprite *), fp)); + CATCH(e, aksl_fwrite((void *)&nullbuf, 1, AKGL_SPRITE_MAX_NAME_LENGTH, fp)); + CATCH(e, aksl_fwrite((void *)&nullbuf, 1, sizeof(akgl_Sprite *), fp)); // write the spritesheet name pointer table SDL_EnumerateProperties( AKGL_REGISTRY_SPRITESHEET, &akgl_game_save_spritesheetname_iterator, (void *)fp); - CATCH(e, aksl_fwrite((void *)&nullval, 1, AKGL_SPRITE_SHEET_MAX_FILENAME_LENGTH, fp)); - CATCH(e, aksl_fwrite((void *)&nullval, 1, sizeof(akgl_SpriteSheet *), fp)); + CATCH(e, aksl_fwrite((void *)&nullbuf, 1, AKGL_SPRITE_SHEET_MAX_FILENAME_LENGTH, fp)); + CATCH(e, aksl_fwrite((void *)&nullbuf, 1, sizeof(akgl_SpriteSheet *), fp)); // write the character name pointer table SDL_EnumerateProperties( AKGL_REGISTRY_CHARACTER, &akgl_game_save_charactername_iterator, (void *)fp); - CATCH(e, aksl_fwrite((void *)&nullval, 1, AKGL_SPRITE_MAX_CHARACTER_NAME_LENGTH, fp)); - CATCH(e, aksl_fwrite((void *)&nullval, 1, sizeof(akgl_Character *), fp)); + CATCH(e, aksl_fwrite((void *)&nullbuf, 1, AKGL_SPRITE_MAX_CHARACTER_NAME_LENGTH, fp)); + CATCH(e, aksl_fwrite((void *)&nullbuf, 1, sizeof(akgl_Character *), fp)); } CLEANUP { } PROCESS(e) { } FINISH(e, true); @@ -311,10 +320,14 @@ akerr_ErrorContext AKERR_NOIGNORE *akgl_game_save(char *fpath) CATCH(e, aksl_fopen(fpath, "wb", &fp)); CATCH(e, aksl_fwrite(&game, 1, sizeof(akgl_Game), fp)); CATCH(e, akgl_game_save_actors(fp)); - } PROCESS(e) { } CLEANUP { + // CLEANUP must precede PROCESS: with the two transposed, the fclose + // lands inside the PROCESS switch and only runs when an error context + // exists and reports success, so an ordinary save never flushed or + // closed its stream. if ( fp != NULL ) fclose(fp); + } PROCESS(e) { } FINISH(e, true); SUCCEED_RETURN(e); // SUCCEED_NORETURN if in main(). } @@ -335,13 +348,21 @@ akerr_ErrorContext AKERR_NOIGNORE *akgl_game_load_objectnamemap(FILE *fp, SDL_Pr char ptrstring[32]; char objname[namelength]; int retval = 0; - + bool done = false; + PREPARE_ERROR(e); - while ( 1 ) { + // The ATTEMPT block sits inside the loop on purpose. CATCH reports a + // failure by breaking, and a break binds to the innermost enclosing switch + // or loop, so a CATCH written directly inside `while` would leave the loop + // and then fall through to SUCCEED_RETURN -- reporting a truncated or + // corrupt name table as a successful load. + while ( done == false ) { + ATTEMPT { CATCH(e, aksl_fread((void *)&objname, 1, namelength, fp)); CATCH(e, aksl_fread((void *)&ptr, 1, ptrlength, fp)); // End of the map if ( ptr == 0x00 && objname[0] == 0x00 ) { + done = true; break; } // The map allows us to say "Object X has a reference to object Y at @@ -358,6 +379,9 @@ akerr_ErrorContext AKERR_NOIGNORE *akgl_game_load_objectnamemap(FILE *fp, SDL_Pr map, ptrstring, SDL_GetPointerProperty(registry, objname, NULL)); + } CLEANUP { + } PROCESS(e) { + } FINISH(e, true); }; SUCCEED_RETURN(e); } diff --git a/src/physics.c b/src/physics.c index f461435..dd5296f 100644 --- a/src/physics.c +++ b/src/physics.c @@ -128,13 +128,18 @@ akerr_ErrorContext AKERR_NOIGNORE *akgl_physics_simulate(akgl_PhysicsBackend *se .flags = 0, .layerid = 0 }; - SDL_Time curtime = SDL_GetTicksNS(); - float32_t dt = (float32_t)(curtime - self->gravity_time) / (float32_t)AKGL_TIME_ONESEC_NS; + SDL_Time curtime = 0; + float32_t dt = 0; akgl_Actor *actor = NULL; FAIL_ZERO_RETURN(e, self, AKERR_NULLPOINTER, "self"); FAIL_ZERO_RETURN(e, self->move, AKERR_NULLPOINTER, "self->move"); + // Reading the elapsed time requires self, so it cannot be hoisted above + // the null check. + curtime = SDL_GetTicksNS(); + dt = (float32_t)(curtime - self->gravity_time) / (float32_t)AKGL_TIME_ONESEC_NS; + if ( opflags == NULL ) { opflags = &defflags; } diff --git a/tests/actor.c b/tests/actor.c index 03d793b..82ddd1e 100644 --- a/tests/actor.c +++ b/tests/actor.c @@ -13,8 +13,13 @@ #include #include #include +#include +#include +#include #include +#include "testutil.h" + int UNHANDLED_ERROR_BEHAVIOR; akerr_ErrorContext *unhandled_error_context; @@ -308,12 +313,595 @@ _test_actor_addchild_heaprelease_cleanup: SUCCEED_RETURN(errctx); } +/** + * @brief Build an actor bound to a character, without touching the renderer. + * + * The sprite and spritesheet are taken straight off the heap and populated by + * hand, because akgl_sprite_load_json() would need a live renderer to build a + * texture and none of the logic under test reads one. + */ +static akerr_ErrorContext *make_bound_actor( + akgl_Actor **actor, + akgl_Character **basechar, + akgl_Sprite **sprite, + char *name, + int state) +{ + PREPARE_ERROR(e); + akgl_SpriteSheet *sheet = NULL; + char spritename[AKGL_SPRITE_MAX_NAME_LENGTH]; + + ATTEMPT { + CATCH(e, akgl_heap_next_character(basechar)); + CATCH(e, akgl_character_initialize(*basechar, name)); + + CATCH(e, akgl_heap_next_spritesheet(&sheet)); + sheet->refcount += 1; + + snprintf((char *)&spritename, AKGL_SPRITE_MAX_NAME_LENGTH, "%s_sprite", name); + CATCH(e, akgl_heap_next_sprite(sprite)); + CATCH(e, akgl_sprite_initialize(*sprite, (char *)&spritename, sheet)); + (*sprite)->width = 32; + (*sprite)->height = 32; + (*sprite)->frames = 4; + (*sprite)->speed = 100; + CATCH(e, akgl_character_sprite_add(*basechar, *sprite, state)); + + CATCH(e, akgl_heap_next_actor(actor)); + CATCH(e, akgl_actor_initialize(*actor, name)); + (*actor)->basechar = *basechar; + (*actor)->state = state; + } CLEANUP { + } PROCESS(e) { + } FINISH(e, true); + SUCCEED_RETURN(e); +} + +akerr_ErrorContext *test_actor_control_handlers_on(void) +{ + PREPARE_ERROR(e); + akgl_Actor actor; + akgl_Character basechar; + SDL_Event event; + + ATTEMPT { + memset(&event, 0x00, sizeof(SDL_Event)); + memset(&basechar, 0x00, sizeof(akgl_Character)); + basechar.ax = 7.0f; + basechar.ay = 11.0f; + + // Left: clears every facing and movement bit, then sets its own pair and + // takes acceleration from the base character with the sign reversed. + memset(&actor, 0x00, sizeof(akgl_Actor)); + actor.basechar = &basechar; + actor.state = (AKGL_ACTOR_STATE_FACE_RIGHT | AKGL_ACTOR_STATE_MOVING_RIGHT | AKGL_ACTOR_STATE_ALIVE); + TEST_EXPECT_OK(e, akgl_Actor_cmhf_left_on(&actor, &event), "left on"); + TEST_ASSERT(e, AKGL_BITMASK_HAS(actor.state, AKGL_ACTOR_STATE_MOVING_LEFT), + "left on did not set MOVING_LEFT (state %d)", actor.state); + TEST_ASSERT(e, AKGL_BITMASK_HAS(actor.state, AKGL_ACTOR_STATE_FACE_LEFT), + "left on did not set FACE_LEFT (state %d)", actor.state); + TEST_ASSERT(e, AKGL_BITMASK_HASNOT(actor.state, AKGL_ACTOR_STATE_MOVING_RIGHT), + "left on left MOVING_RIGHT set (state %d)", actor.state); + TEST_ASSERT(e, AKGL_BITMASK_HASNOT(actor.state, AKGL_ACTOR_STATE_FACE_RIGHT), + "left on left FACE_RIGHT set (state %d)", actor.state); + TEST_ASSERT(e, AKGL_BITMASK_HAS(actor.state, AKGL_ACTOR_STATE_ALIVE), + "left on cleared an unrelated state bit (state %d)", actor.state); + TEST_ASSERT_FEQ(e, actor.ax, -7.0f, "left on set ax to %f, expected -7", actor.ax); + + // Right: same, with the sign preserved. + memset(&actor, 0x00, sizeof(akgl_Actor)); + actor.basechar = &basechar; + TEST_EXPECT_OK(e, akgl_Actor_cmhf_right_on(&actor, &event), "right on"); + TEST_ASSERT(e, AKGL_BITMASK_HAS(actor.state, AKGL_ACTOR_STATE_MOVING_RIGHT), + "right on did not set MOVING_RIGHT (state %d)", actor.state); + TEST_ASSERT(e, AKGL_BITMASK_HAS(actor.state, AKGL_ACTOR_STATE_FACE_RIGHT), + "right on did not set FACE_RIGHT (state %d)", actor.state); + TEST_ASSERT_FEQ(e, actor.ax, 7.0f, "right on set ax to %f, expected 7", actor.ax); + + // Up reverses the Y acceleration, because Y grows downward. + memset(&actor, 0x00, sizeof(akgl_Actor)); + actor.basechar = &basechar; + TEST_EXPECT_OK(e, akgl_Actor_cmhf_up_on(&actor, &event), "up on"); + TEST_ASSERT(e, AKGL_BITMASK_HAS(actor.state, AKGL_ACTOR_STATE_MOVING_UP), + "up on did not set MOVING_UP (state %d)", actor.state); + TEST_ASSERT(e, AKGL_BITMASK_HAS(actor.state, AKGL_ACTOR_STATE_FACE_UP), + "up on did not set FACE_UP (state %d)", actor.state); + TEST_ASSERT_FEQ(e, actor.ay, -11.0f, "up on set ay to %f, expected -11", actor.ay); + + memset(&actor, 0x00, sizeof(akgl_Actor)); + actor.basechar = &basechar; + TEST_EXPECT_OK(e, akgl_Actor_cmhf_down_on(&actor, &event), "down on"); + TEST_ASSERT(e, AKGL_BITMASK_HAS(actor.state, AKGL_ACTOR_STATE_MOVING_DOWN), + "down on did not set MOVING_DOWN (state %d)", actor.state); + TEST_ASSERT(e, AKGL_BITMASK_HAS(actor.state, AKGL_ACTOR_STATE_FACE_DOWN), + "down on did not set FACE_DOWN (state %d)", actor.state); + TEST_ASSERT_FEQ(e, actor.ay, 11.0f, "down on set ay to %f, expected 11", actor.ay); + + // Each direction is exclusive: turning right after left leaves only right. + memset(&actor, 0x00, sizeof(akgl_Actor)); + actor.basechar = &basechar; + TEST_EXPECT_OK(e, akgl_Actor_cmhf_left_on(&actor, &event), "left on before turning"); + TEST_EXPECT_OK(e, akgl_Actor_cmhf_right_on(&actor, &event), "right on after left"); + TEST_ASSERT(e, AKGL_BITMASK_HASNOT(actor.state, AKGL_ACTOR_STATE_MOVING_LEFT), + "turning right left MOVING_LEFT set (state %d)", actor.state); + } CLEANUP { + } PROCESS(e) { + } FINISH(e, true); + SUCCEED_RETURN(e); +} + +akerr_ErrorContext *test_actor_control_handlers_off(void) +{ + PREPARE_ERROR(e); + akgl_Actor actor; + akgl_Character basechar; + SDL_Event event; + + ATTEMPT { + memset(&event, 0x00, sizeof(SDL_Event)); + memset(&basechar, 0x00, sizeof(akgl_Character)); + basechar.ax = 7.0f; + basechar.ay = 11.0f; + + // Releasing a direction zeroes that axis outright: acceleration, thrust, + // environmental force, and velocity all go to zero together. + memset(&actor, 0x00, sizeof(akgl_Actor)); + actor.basechar = &basechar; + actor.ax = 5; actor.ex = 5; actor.tx = 5; actor.vx = 5; + actor.ay = 5; actor.ey = 5; actor.ty = 5; actor.vy = 5; + actor.state = (AKGL_ACTOR_STATE_MOVING_LEFT | AKGL_ACTOR_STATE_FACE_LEFT); + TEST_EXPECT_OK(e, akgl_Actor_cmhf_left_off(&actor, &event), "left off"); + TEST_ASSERT_FEQ(e, actor.ax, 0.0f, "left off left ax at %f", actor.ax); + TEST_ASSERT_FEQ(e, actor.ex, 0.0f, "left off left ex at %f", actor.ex); + TEST_ASSERT_FEQ(e, actor.tx, 0.0f, "left off left tx at %f", actor.tx); + TEST_ASSERT_FEQ(e, actor.vx, 0.0f, "left off left vx at %f", actor.vx); + TEST_ASSERT(e, AKGL_BITMASK_HASNOT(actor.state, AKGL_ACTOR_STATE_MOVING_LEFT), + "left off did not clear MOVING_LEFT (state %d)", actor.state); + // Facing is deliberately sticky, so an idle actor keeps looking where it was. + TEST_ASSERT(e, AKGL_BITMASK_HAS(actor.state, AKGL_ACTOR_STATE_FACE_LEFT), + "left off cleared FACE_LEFT, which should persist (state %d)", actor.state); + // The Y axis is untouched by a horizontal release. + TEST_ASSERT_FEQ(e, actor.vy, 5.0f, "left off disturbed vy (%f)", actor.vy); + + memset(&actor, 0x00, sizeof(akgl_Actor)); + actor.ax = 5; actor.ex = 5; actor.tx = 5; actor.vx = 5; + actor.state = AKGL_ACTOR_STATE_MOVING_RIGHT; + TEST_EXPECT_OK(e, akgl_Actor_cmhf_right_off(&actor, &event), "right off"); + TEST_ASSERT_FEQ(e, actor.vx, 0.0f, "right off left vx at %f", actor.vx); + TEST_ASSERT(e, AKGL_BITMASK_HASNOT(actor.state, AKGL_ACTOR_STATE_MOVING_RIGHT), + "right off did not clear MOVING_RIGHT (state %d)", actor.state); + + memset(&actor, 0x00, sizeof(akgl_Actor)); + actor.ay = 5; actor.ey = 5; actor.ty = 5; actor.vy = 5; + actor.state = AKGL_ACTOR_STATE_MOVING_UP; + TEST_EXPECT_OK(e, akgl_Actor_cmhf_up_off(&actor, &event), "up off"); + TEST_ASSERT_FEQ(e, actor.vy, 0.0f, "up off left vy at %f", actor.vy); + TEST_ASSERT(e, AKGL_BITMASK_HASNOT(actor.state, AKGL_ACTOR_STATE_MOVING_UP), + "up off did not clear MOVING_UP (state %d)", actor.state); + + memset(&actor, 0x00, sizeof(akgl_Actor)); + actor.ay = 5; actor.ey = 5; actor.ty = 5; actor.vy = 5; + actor.state = AKGL_ACTOR_STATE_MOVING_DOWN; + TEST_EXPECT_OK(e, akgl_Actor_cmhf_down_off(&actor, &event), "down off"); + TEST_ASSERT_FEQ(e, actor.vy, 0.0f, "down off left vy at %f", actor.vy); + TEST_ASSERT(e, AKGL_BITMASK_HASNOT(actor.state, AKGL_ACTOR_STATE_MOVING_DOWN), + "down off did not clear MOVING_DOWN (state %d)", actor.state); + } CLEANUP { + } PROCESS(e) { + } FINISH(e, true); + SUCCEED_RETURN(e); +} + +akerr_ErrorContext *test_actor_control_handlers_nullpointers(void) +{ + PREPARE_ERROR(e); + akgl_Actor actor; + SDL_Event event; + + ATTEMPT { + memset(&event, 0x00, sizeof(SDL_Event)); + memset(&actor, 0x00, sizeof(akgl_Actor)); + + TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_Actor_cmhf_left_on(NULL, &event), "left on, NULL actor"); + TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_Actor_cmhf_left_off(NULL, &event), "left off, NULL actor"); + TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_Actor_cmhf_right_on(NULL, &event), "right on, NULL actor"); + TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_Actor_cmhf_right_off(NULL, &event), "right off, NULL actor"); + TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_Actor_cmhf_up_on(NULL, &event), "up on, NULL actor"); + TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_Actor_cmhf_up_off(NULL, &event), "up off, NULL actor"); + TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_Actor_cmhf_down_on(NULL, &event), "down on, NULL actor"); + TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_Actor_cmhf_down_off(NULL, &event), "down off, NULL actor"); + + TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_Actor_cmhf_left_on(&actor, NULL), "left on, NULL event"); + TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_Actor_cmhf_left_off(&actor, NULL), "left off, NULL event"); + TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_Actor_cmhf_right_on(&actor, NULL), "right on, NULL event"); + TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_Actor_cmhf_right_off(&actor, NULL), "right off, NULL event"); + TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_Actor_cmhf_up_on(&actor, NULL), "up on, NULL event"); + TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_Actor_cmhf_up_off(&actor, NULL), "up off, NULL event"); + TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_Actor_cmhf_down_on(&actor, NULL), "down on, NULL event"); + TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_Actor_cmhf_down_off(&actor, NULL), "down off, NULL event"); + + // A movement handler reads acceleration off the base character, so an + // actor without one has to be reported rather than dereferenced. + TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_Actor_cmhf_left_on(&actor, &event), + "left on, actor with no base character"); + TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_Actor_cmhf_right_on(&actor, &event), + "right on, actor with no base character"); + TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_Actor_cmhf_up_on(&actor, &event), + "up on, actor with no base character"); + TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_Actor_cmhf_down_on(&actor, &event), + "down on, actor with no base character"); + } CLEANUP { + } PROCESS(e) { + } FINISH(e, true); + SUCCEED_RETURN(e); +} + +akerr_ErrorContext *test_actor_automatic_face(void) +{ + PREPARE_ERROR(e); + akgl_Actor actor; + + ATTEMPT { + // With the flag off, facing is left entirely to the caller. + memset(&actor, 0x00, sizeof(akgl_Actor)); + actor.movement_controls_face = false; + actor.state = (AKGL_ACTOR_STATE_MOVING_LEFT | AKGL_ACTOR_STATE_FACE_RIGHT); + TEST_EXPECT_OK(e, akgl_actor_automatic_face(&actor), "automatic face with the flag off"); + TEST_ASSERT(e, AKGL_BITMASK_HAS(actor.state, AKGL_ACTOR_STATE_FACE_RIGHT), + "automatic face changed facing while disabled (state %d)", actor.state); + + // With the flag on, facing follows movement. + memset(&actor, 0x00, sizeof(akgl_Actor)); + actor.movement_controls_face = true; + actor.state = (AKGL_ACTOR_STATE_MOVING_LEFT | AKGL_ACTOR_STATE_FACE_RIGHT); + TEST_EXPECT_OK(e, akgl_actor_automatic_face(&actor), "automatic face while moving left"); + TEST_ASSERT(e, AKGL_BITMASK_HAS(actor.state, AKGL_ACTOR_STATE_FACE_LEFT), + "automatic face did not turn the actor left (state %d)", actor.state); + TEST_ASSERT(e, AKGL_BITMASK_HASNOT(actor.state, AKGL_ACTOR_STATE_FACE_RIGHT), + "automatic face left the stale facing set (state %d)", actor.state); + + memset(&actor, 0x00, sizeof(akgl_Actor)); + actor.movement_controls_face = true; + actor.state = AKGL_ACTOR_STATE_MOVING_RIGHT; + TEST_EXPECT_OK(e, akgl_actor_automatic_face(&actor), "automatic face while moving right"); + TEST_ASSERT(e, AKGL_BITMASK_HAS(actor.state, AKGL_ACTOR_STATE_FACE_RIGHT), + "automatic face did not turn the actor right (state %d)", actor.state); + + memset(&actor, 0x00, sizeof(akgl_Actor)); + actor.movement_controls_face = true; + actor.state = AKGL_ACTOR_STATE_MOVING_UP; + TEST_EXPECT_OK(e, akgl_actor_automatic_face(&actor), "automatic face while moving up"); + TEST_ASSERT(e, AKGL_BITMASK_HAS(actor.state, AKGL_ACTOR_STATE_FACE_UP), + "automatic face did not turn the actor up (state %d)", actor.state); + + memset(&actor, 0x00, sizeof(akgl_Actor)); + actor.movement_controls_face = true; + actor.state = AKGL_ACTOR_STATE_MOVING_DOWN; + TEST_EXPECT_OK(e, akgl_actor_automatic_face(&actor), "automatic face while moving down"); + TEST_ASSERT(e, AKGL_BITMASK_HAS(actor.state, AKGL_ACTOR_STATE_FACE_DOWN), + "automatic face did not turn the actor down (state %d)", actor.state); + + // Left wins over up when both are set, per the order of the checks. + memset(&actor, 0x00, sizeof(akgl_Actor)); + actor.movement_controls_face = true; + actor.state = (AKGL_ACTOR_STATE_MOVING_LEFT | AKGL_ACTOR_STATE_MOVING_UP); + TEST_EXPECT_OK(e, akgl_actor_automatic_face(&actor), "automatic face while moving diagonally"); + TEST_ASSERT(e, AKGL_BITMASK_HAS(actor.state, AKGL_ACTOR_STATE_FACE_LEFT), + "diagonal movement did not resolve to the left facing (state %d)", actor.state); + TEST_ASSERT(e, AKGL_BITMASK_HASNOT(actor.state, AKGL_ACTOR_STATE_FACE_UP), + "diagonal movement set two facings at once (state %d)", actor.state); + + // A stationary actor ends up facing nowhere. + memset(&actor, 0x00, sizeof(akgl_Actor)); + actor.movement_controls_face = true; + actor.state = (AKGL_ACTOR_STATE_ALIVE | AKGL_ACTOR_STATE_FACE_UP); + TEST_EXPECT_OK(e, akgl_actor_automatic_face(&actor), "automatic face while stationary"); + TEST_ASSERT(e, AKGL_BITMASK_HASNOT(actor.state, AKGL_ACTOR_STATE_FACE_ALL), + "a stationary actor kept a facing bit (state %d)", actor.state); + TEST_ASSERT(e, AKGL_BITMASK_HAS(actor.state, AKGL_ACTOR_STATE_ALIVE), + "automatic face cleared an unrelated state bit (state %d)", actor.state); + + TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_actor_automatic_face(NULL), + "automatic face with a NULL actor"); + } CLEANUP { + } PROCESS(e) { + } FINISH(e, true); + SUCCEED_RETURN(e); +} + +akerr_ErrorContext *test_actor_logic_movement(void) +{ + PREPARE_ERROR(e); + akgl_Actor actor; + akgl_Character basechar; + + ATTEMPT { + memset(&basechar, 0x00, sizeof(akgl_Character)); + basechar.ax = 3.0f; basechar.ay = 4.0f; + basechar.sx = 30.0f; basechar.sy = 40.0f; basechar.sz = 50.0f; + + // Max speed is always refreshed from the base character. + memset(&actor, 0x00, sizeof(akgl_Actor)); + actor.basechar = &basechar; + TEST_EXPECT_OK(e, akgl_actor_logic_movement(&actor, 1.0f), "movement logic while idle"); + TEST_ASSERT_FEQ(e, actor.sx, 30.0f, "sx copied as %f, expected 30", actor.sx); + TEST_ASSERT_FEQ(e, actor.sy, 40.0f, "sy copied as %f, expected 40", actor.sy); + TEST_ASSERT_FEQ(e, actor.sz, 50.0f, "sz copied as %f, expected 50", actor.sz); + + actor.state = AKGL_ACTOR_STATE_MOVING_LEFT; + TEST_EXPECT_OK(e, akgl_actor_logic_movement(&actor, 1.0f), "movement logic while moving left"); + TEST_ASSERT_FEQ(e, actor.ax, -3.0f, "moving left set ax to %f, expected -3", actor.ax); + + actor.state = AKGL_ACTOR_STATE_MOVING_RIGHT; + TEST_EXPECT_OK(e, akgl_actor_logic_movement(&actor, 1.0f), "movement logic while moving right"); + TEST_ASSERT_FEQ(e, actor.ax, 3.0f, "moving right set ax to %f, expected 3", actor.ax); + + actor.state = AKGL_ACTOR_STATE_MOVING_UP; + TEST_EXPECT_OK(e, akgl_actor_logic_movement(&actor, 1.0f), "movement logic while moving up"); + TEST_ASSERT_FEQ(e, actor.ay, -4.0f, "moving up set ay to %f, expected -4", actor.ay); + + actor.state = AKGL_ACTOR_STATE_MOVING_DOWN; + TEST_EXPECT_OK(e, akgl_actor_logic_movement(&actor, 1.0f), "movement logic while moving down"); + TEST_ASSERT_FEQ(e, actor.ay, 4.0f, "moving down set ay to %f, expected 4", actor.ay); + + // Both axes at once. + actor.state = (AKGL_ACTOR_STATE_MOVING_LEFT | AKGL_ACTOR_STATE_MOVING_DOWN); + TEST_EXPECT_OK(e, akgl_actor_logic_movement(&actor, 1.0f), "movement logic while moving diagonally"); + TEST_ASSERT_FEQ(e, actor.ax, -3.0f, "diagonal movement set ax to %f, expected -3", actor.ax); + TEST_ASSERT_FEQ(e, actor.ay, 4.0f, "diagonal movement set ay to %f, expected 4", actor.ay); + + TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_actor_logic_movement(NULL, 1.0f), + "movement logic with a NULL actor"); + + // Every value the movement logic writes is read off the base character, + // so an actor without one has to be reported rather than dereferenced. + memset(&actor, 0x00, sizeof(akgl_Actor)); + TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_actor_logic_movement(&actor, 1.0f), + "movement logic with no base character"); + } CLEANUP { + } PROCESS(e) { + } FINISH(e, true); + SUCCEED_RETURN(e); +} + +akerr_ErrorContext *test_actor_logic_changeframe(void) +{ + PREPARE_ERROR(e); + akgl_Actor actor; + akgl_Sprite sprite; + + ATTEMPT { + memset(&sprite, 0x00, sizeof(akgl_Sprite)); + sprite.frames = 4; + + // Mid-animation, the frame simply advances. + memset(&actor, 0x00, sizeof(akgl_Actor)); + actor.curSpriteFrameId = 1; + TEST_EXPECT_OK(e, akgl_actor_logic_changeframe(&actor, &sprite, 0), "advancing mid-animation"); + TEST_ASSERT(e, actor.curSpriteFrameId == 2, + "frame advanced to %d, expected 2", actor.curSpriteFrameId); + + // At the last frame without looping, it wraps to the start. + memset(&actor, 0x00, sizeof(akgl_Actor)); + actor.curSpriteFrameId = 3; + sprite.loop = false; + sprite.loopReverse = false; + TEST_EXPECT_OK(e, akgl_actor_logic_changeframe(&actor, &sprite, 0), "wrapping a non-looping sprite"); + TEST_ASSERT(e, actor.curSpriteFrameId == 0, + "the last frame wrapped to %d, expected 0", actor.curSpriteFrameId); + + // A forward-looping sprite behaves the same way at the end. + memset(&actor, 0x00, sizeof(akgl_Actor)); + actor.curSpriteFrameId = 3; + sprite.loop = true; + sprite.loopReverse = false; + TEST_EXPECT_OK(e, akgl_actor_logic_changeframe(&actor, &sprite, 0), "wrapping a forward-looping sprite"); + TEST_ASSERT(e, actor.curSpriteFrameId == 0, + "the forward loop wrapped to %d, expected 0", actor.curSpriteFrameId); + + // A ping-pong sprite turns around at the end instead of wrapping. + memset(&actor, 0x00, sizeof(akgl_Actor)); + actor.curSpriteFrameId = 3; + sprite.loop = true; + sprite.loopReverse = true; + TEST_EXPECT_OK(e, akgl_actor_logic_changeframe(&actor, &sprite, 0), "reversing at the end"); + TEST_ASSERT(e, actor.curSpriteReversing == true, + "the sprite did not enter its reverse phase at the last frame"); + TEST_ASSERT(e, actor.curSpriteFrameId == 2, + "reversing stepped to frame %d, expected 2", actor.curSpriteFrameId); + + // While reversing, the frame counts back down. + TEST_EXPECT_OK(e, akgl_actor_logic_changeframe(&actor, &sprite, 0), "stepping back while reversing"); + TEST_ASSERT(e, actor.curSpriteFrameId == 1, + "reversing stepped to frame %d, expected 1", actor.curSpriteFrameId); + + // At frame zero it turns around again and resumes going forward. + actor.curSpriteFrameId = 0; + actor.curSpriteReversing = true; + TEST_EXPECT_OK(e, akgl_actor_logic_changeframe(&actor, &sprite, 0), "turning around at frame zero"); + TEST_ASSERT(e, actor.curSpriteReversing == false, + "the sprite stayed in its reverse phase at frame zero"); + TEST_ASSERT(e, actor.curSpriteFrameId == 1, + "turning around stepped to frame %d, expected 1", actor.curSpriteFrameId); + + // A single-frame sprite has nowhere to advance to. + memset(&actor, 0x00, sizeof(akgl_Actor)); + sprite.frames = 1; + sprite.loop = false; + sprite.loopReverse = false; + TEST_EXPECT_OK(e, akgl_actor_logic_changeframe(&actor, &sprite, 0), "advancing a single-frame sprite"); + TEST_ASSERT(e, actor.curSpriteFrameId == 0, + "a single-frame sprite moved to frame %d, expected 0", actor.curSpriteFrameId); + + sprite.frames = 4; + TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_actor_logic_changeframe(NULL, &sprite, 0), + "changeframe with a NULL actor"); + } CLEANUP { + } PROCESS(e) { + } FINISH(e, true); + SUCCEED_RETURN(e); +} + +/** @brief Records calls made to the changeframe stub. */ +static int changeframe_calls = 0; + +/** @brief Changeframe stub that records its invocation without advancing. */ +static akerr_ErrorContext *stub_changeframe(akgl_Actor *obj, akgl_Sprite *curSprite, SDL_Time curtimems) +{ + PREPARE_ERROR(e); + changeframe_calls += 1; + SUCCEED_RETURN(e); +} + +akerr_ErrorContext *test_actor_update(void) +{ + PREPARE_ERROR(e); + akgl_Actor *actor = NULL; + akgl_Character *basechar = NULL; + akgl_Sprite *sprite = NULL; + SDL_Time now = 0; + + ATTEMPT { + CATCH(e, akgl_registry_init_actor()); + CATCH(e, akgl_registry_init_sprite()); + CATCH(e, akgl_registry_init_spritesheet()); + CATCH(e, akgl_registry_init_character()); + CATCH(e, akgl_heap_init()); + + CATCH(e, make_bound_actor(&actor, &basechar, &sprite, "updatable", AKGL_ACTOR_STATE_ALIVE)); + actor->changeframefunc = &stub_changeframe; + + // Long enough since the last frame change, so the sprite advances. + SDL_GetCurrentTime(&now); + sprite->speed = 1; + actor->curSpriteFrameTimer = 0; + changeframe_calls = 0; + TEST_EXPECT_OK(e, akgl_actor_update(actor), "updating an actor whose frame is due"); + TEST_ASSERT(e, changeframe_calls == 1, + "a due frame change fired %d times, expected 1", changeframe_calls); + TEST_ASSERT(e, actor->curSpriteFrameTimer != 0, + "the frame timer was not restamped after a frame change"); + + // Too soon since the last change, so nothing happens. + SDL_GetCurrentTime(&now); + sprite->speed = 1000000000; + actor->curSpriteFrameTimer = now; + changeframe_calls = 0; + TEST_EXPECT_OK(e, akgl_actor_update(actor), "updating an actor whose frame is not due"); + TEST_ASSERT(e, changeframe_calls == 0, + "an early frame change fired %d times, expected 0", changeframe_calls); + + // An actor in a state with no sprite bound is skipped, not failed: the + // missing binding is reported as AKERR_KEY and swallowed by update. + actor->state = AKGL_ACTOR_STATE_DEAD; + changeframe_calls = 0; + TEST_EXPECT_OK(e, akgl_actor_update(actor), + "updating an actor whose state has no sprite"); + TEST_ASSERT(e, changeframe_calls == 0, + "an actor with no sprite for its state still changed frames"); + + TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_actor_update(NULL), + "updating a NULL actor"); + + // An actor with no base character cannot resolve a sprite at all. + actor->basechar = NULL; + TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_actor_update(actor), + "updating an actor with no base character"); + } CLEANUP { + } PROCESS(e) { + } FINISH(e, true); + SUCCEED_RETURN(e); +} + +akerr_ErrorContext *test_actor_character_sprite_binding(void) +{ + PREPARE_ERROR(e); + akgl_Actor *actor = NULL; + akgl_Character *basechar = NULL; + akgl_Sprite *sprite = NULL; + akgl_Sprite *found = NULL; + + ATTEMPT { + CATCH(e, akgl_registry_init_actor()); + CATCH(e, akgl_registry_init_sprite()); + CATCH(e, akgl_registry_init_spritesheet()); + CATCH(e, akgl_registry_init_character()); + CATCH(e, akgl_heap_init()); + + CATCH(e, make_bound_actor(&actor, &basechar, &sprite, "bound", AKGL_ACTOR_STATE_ALIVE)); + + TEST_EXPECT_OK(e, akgl_character_sprite_get(basechar, AKGL_ACTOR_STATE_ALIVE, &found), + "reading back a bound sprite"); + TEST_ASSERT(e, found == sprite, "the bound sprite came back as a different object"); + + // A composite state is a distinct key from either of its components. + TEST_EXPECT_STATUS(e, AKERR_KEY, + akgl_character_sprite_get(basechar, + (AKGL_ACTOR_STATE_ALIVE | AKGL_ACTOR_STATE_FACE_UP), + &found), + "reading a composite state that was never bound"); + TEST_EXPECT_STATUS(e, AKERR_KEY, akgl_character_sprite_get(basechar, 0, &found), + "reading state zero, which was never bound"); + + // Binding a composite state makes it resolvable. + TEST_EXPECT_OK(e, akgl_character_sprite_add(basechar, sprite, + (AKGL_ACTOR_STATE_ALIVE | AKGL_ACTOR_STATE_FACE_UP)), + "binding a sprite to a composite state"); + TEST_EXPECT_OK(e, akgl_character_sprite_get(basechar, + (AKGL_ACTOR_STATE_ALIVE | AKGL_ACTOR_STATE_FACE_UP), + &found), + "reading back the composite binding"); + TEST_ASSERT(e, found == sprite, "the composite binding resolved to a different sprite"); + + TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_character_sprite_get(NULL, 0, &found), + "sprite_get with a NULL character"); + TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, + akgl_character_sprite_get(basechar, AKGL_ACTOR_STATE_ALIVE, NULL), + "sprite_get with a NULL destination"); + TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_character_sprite_add(NULL, sprite, 0), + "sprite_add with a NULL character"); + TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_character_sprite_add(basechar, NULL, 0), + "sprite_add with a NULL sprite"); + } CLEANUP { + } PROCESS(e) { + } FINISH(e, true); + SUCCEED_RETURN(e); +} + +akerr_ErrorContext *test_actor_sprite_sheet_coords(void) +{ + PREPARE_ERROR(e); + akgl_Sprite sprite; + SDL_FRect coords; + + ATTEMPT { + memset(&sprite, 0x00, sizeof(akgl_Sprite)); + sprite.width = 32; + sprite.height = 32; + + TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_sprite_sheet_coords_for_frame(NULL, &coords, 0), + "sheet coords with a NULL sprite"); + TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_sprite_sheet_coords_for_frame(&sprite, NULL, 0), + "sheet coords with a NULL rectangle"); + // The sprite has no sheet attached, so there is no texture to measure against. + TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_sprite_sheet_coords_for_frame(&sprite, &coords, 0), + "sheet coords with a NULL spritesheet"); + } CLEANUP { + } PROCESS(e) { + } FINISH(e, true); + SUCCEED_RETURN(e); +} + int main(void) { akgl_actor_updated = 0; akgl_actor_rendered = 0; UNHANDLED_ERROR_BEHAVIOR = UNHANDLED_ERROR_EXIT; PREPARE_ERROR(errctx); + + SDL_SetHint(SDL_HINT_VIDEO_DRIVER, "dummy"); + SDL_SetHint(SDL_HINT_AUDIO_DRIVER, "dummy"); + ATTEMPT { CATCH(errctx, akgl_registry_init_actor()); CATCH(errctx, akgl_registry_init_sprite()); @@ -325,6 +913,16 @@ int main(void) CATCH(errctx, test_registry_actor_iterator_updaterender()); CATCH(errctx, test_akgl_actor_set_character()); CATCH(errctx, test_actor_manage_children()); + + CATCH(errctx, test_actor_control_handlers_on()); + CATCH(errctx, test_actor_control_handlers_off()); + CATCH(errctx, test_actor_control_handlers_nullpointers()); + CATCH(errctx, test_actor_automatic_face()); + CATCH(errctx, test_actor_logic_movement()); + CATCH(errctx, test_actor_logic_changeframe()); + CATCH(errctx, test_actor_update()); + CATCH(errctx, test_actor_character_sprite_binding()); + CATCH(errctx, test_actor_sprite_sheet_coords()); } CLEANUP { } PROCESS(errctx) { } FINISH_NORETURN(errctx); diff --git a/tests/assets/snippets/test_json_helpers.json b/tests/assets/snippets/test_json_helpers.json new file mode 100644 index 0000000..b9308ff --- /dev/null +++ b/tests/assets/snippets/test_json_helpers.json @@ -0,0 +1,19 @@ +{ + "name": "json helper fixture", + "count": 42, + "ratio": 2.5, + "negative": -17, + "enabled": true, + "disabled": false, + "nested": { + "inner": "value", + "innercount": 7 + }, + "integers": [10, 20, 30], + "strings": ["alpha", "beta"], + "objects": [ + { "id": 1 }, + { "id": 2 } + ], + "mixed": [1, "two", { "three": 3 }] +} diff --git a/tests/game.c b/tests/game.c new file mode 100644 index 0000000..c98941a --- /dev/null +++ b/tests/game.c @@ -0,0 +1,421 @@ +/** + * @file game.c + * @brief Unit tests for savegame serialization, version gating, and frame accounting. + * + * akgl_game_init() and akgl_game_update() need a window and a live frame loop, + * so they are out of scope here. Everything else in the game module is either + * pure logic or file IO and is covered below. + */ + +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "testutil.h" + +/* + * Exported by src/game.c but not declared in akgl/game.h, because they are + * savegame internals rather than part of the supported surface. Declared here + * so the tests can reach them without widening the public API. + */ +akerr_ErrorContext AKERR_NOIGNORE *akgl_game_save_actors(FILE *fp); +akerr_ErrorContext AKERR_NOIGNORE *akgl_game_load_versioncmp(char *versiontype, char *newversion, char *curversion); + +/** @brief Scratch savegame path, created and removed by the tests that use it. */ +static char savepath[] = "akgl_test_savegame.bin"; +/** @brief Scratch path for deliberately malformed savegames. */ +static char truncatedpath[] = "akgl_test_truncated.bin"; + +/** @brief Populate the process-wide game record with a valid identity. */ +static void set_game_identity(void) +{ + memset(&game, 0x00, sizeof(akgl_Game)); + strncpy((char *)&game.libversion, AKGL_VERSION, 31); + strncpy((char *)&game.version, "1.2.3", 31); + strncpy((char *)&game.name, "libakgl test game", 255); + strncpy((char *)&game.uri, "https://example.invalid/akgl-test", 255); +} + +akerr_ErrorContext *test_game_load_versioncmp_matching(void) +{ + PREPARE_ERROR(e); + + ATTEMPT { + TEST_EXPECT_OK(e, akgl_game_load_versioncmp("library", "1.2.3", "1.2.3"), + "identical versions must be compatible"); + TEST_EXPECT_OK(e, akgl_game_load_versioncmp("library", "0.1.0", "0.1.0"), + "identical zero-major versions must be compatible"); + TEST_EXPECT_OK(e, akgl_game_load_versioncmp("game", "10.20.30", "10.20.30"), + "identical multi-digit versions must be compatible"); + } CLEANUP { + } PROCESS(e) { + } FINISH(e, true); + SUCCEED_RETURN(e); +} + +akerr_ErrorContext *test_game_load_versioncmp_mismatched(void) +{ + PREPARE_ERROR(e); + + ATTEMPT { + // A savegame from a different build is refused on any component. + TEST_EXPECT_STATUS(e, AKERR_API, akgl_game_load_versioncmp("library", "2.2.3", "1.2.3"), + "a differing major version must be refused"); + TEST_EXPECT_STATUS(e, AKERR_API, akgl_game_load_versioncmp("library", "1.3.3", "1.2.3"), + "a differing minor version must be refused"); + TEST_EXPECT_STATUS(e, AKERR_API, akgl_game_load_versioncmp("library", "1.2.4", "1.2.3"), + "a differing patch version must be refused"); + + // Unparseable versions are a value error, distinct from a mismatch. + TEST_EXPECT_STATUS(e, AKERR_VALUE, akgl_game_load_versioncmp("library", "1.2.3", "not-a-version"), + "an unparseable current version must be refused"); + TEST_EXPECT_STATUS(e, AKERR_VALUE, akgl_game_load_versioncmp("library", "not-a-version", "1.2.3"), + "an unparseable savegame version must be refused"); + + TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_game_load_versioncmp(NULL, "1.2.3", "1.2.3"), + "versioncmp with a NULL version type"); + TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_game_load_versioncmp("library", NULL, "1.2.3"), + "versioncmp with a NULL new version"); + TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_game_load_versioncmp("library", "1.2.3", NULL), + "versioncmp with a NULL current version"); + } CLEANUP { + } PROCESS(e) { + } FINISH(e, true); + SUCCEED_RETURN(e); +} + +akerr_ErrorContext *test_game_load_versioncmp_releases_semver(void) +{ + PREPARE_ERROR(e); + int i = 0; + bool leaked = false; + + ATTEMPT { + // semver_parse allocates; the comparison must free both sides on the + // success and the failure path or a long session will drift. + for ( i = 0; i < 2000; i++ ) { + akerr_ErrorContext *result = akgl_game_load_versioncmp("library", "1.2.3", "1.2.3"); + if ( result != NULL ) { + result->handled = true; + result = akerr_release_error(result); + leaked = true; + } + result = akgl_game_load_versioncmp("library", "9.9.9", "1.2.3"); + if ( result != NULL ) { + result->handled = true; + result = akerr_release_error(result); + } + } + TEST_ASSERT(e, leaked == false, + "a matching version comparison started failing partway through a long run"); + } CLEANUP { + } PROCESS(e) { + } FINISH(e, true); + SUCCEED_RETURN(e); +} + +akerr_ErrorContext *test_game_save_roundtrip(void) +{ + PREPARE_ERROR(e); + akgl_Game expected; + + ATTEMPT { + CATCH(e, akgl_registry_init()); + CATCH(e, akgl_heap_init()); + set_game_identity(); + game.fps = 60; + game.framesSinceUpdate = 7; + memcpy(&expected, &game, sizeof(akgl_Game)); + + TEST_EXPECT_OK(e, akgl_game_save((char *)&savepath), "saving a game"); + + // Scribble over the live state so a successful load has to restore it. + game.fps = 0; + game.framesSinceUpdate = 0; + + TEST_EXPECT_OK(e, akgl_game_load((char *)&savepath), "loading the game back"); + TEST_ASSERT(e, game.fps == 60, "fps restored as %d, expected 60", game.fps); + TEST_ASSERT(e, game.framesSinceUpdate == 7, + "framesSinceUpdate restored as %d, expected 7", game.framesSinceUpdate); + TEST_ASSERT(e, strncmp((char *)&game.name, (char *)&expected.name, 256) == 0, + "the game name was not preserved across a save and load"); + TEST_ASSERT(e, strncmp((char *)&game.version, (char *)&expected.version, 32) == 0, + "the game version was not preserved across a save and load"); + } CLEANUP { + unlink((char *)&savepath); + } PROCESS(e) { + } FINISH(e, true); + SUCCEED_RETURN(e); +} + +akerr_ErrorContext *test_game_load_rejects_foreign_saves(void) +{ + PREPARE_ERROR(e); + + ATTEMPT { + CATCH(e, akgl_registry_init()); + CATCH(e, akgl_heap_init()); + + // A save written by a different game must not load into this one. + set_game_identity(); + CATCH(e, akgl_game_save((char *)&savepath)); + strncpy((char *)&game.name, "a completely different game", 255); + TEST_EXPECT_STATUS(e, AKERR_API, akgl_game_load((char *)&savepath), + "a savegame with a foreign game name must be refused"); + unlink((char *)&savepath); + + // Same for a differing URI. + set_game_identity(); + CATCH(e, akgl_game_save((char *)&savepath)); + strncpy((char *)&game.uri, "https://example.invalid/other", 255); + TEST_EXPECT_STATUS(e, AKERR_API, akgl_game_load((char *)&savepath), + "a savegame with a foreign URI must be refused"); + unlink((char *)&savepath); + + // A save written against a different library version must be refused. + set_game_identity(); + strncpy((char *)&game.libversion, "99.98.97", 31); + CATCH(e, akgl_game_save((char *)&savepath)); + set_game_identity(); + TEST_EXPECT_STATUS(e, AKERR_API, akgl_game_load((char *)&savepath), + "a savegame from a different library version must be refused"); + unlink((char *)&savepath); + + // And one written against a different game version. + set_game_identity(); + strncpy((char *)&game.version, "4.5.6", 31); + CATCH(e, akgl_game_save((char *)&savepath)); + set_game_identity(); + TEST_EXPECT_STATUS(e, AKERR_API, akgl_game_load((char *)&savepath), + "a savegame from a different game version must be refused"); + } CLEANUP { + unlink((char *)&savepath); + } PROCESS(e) { + } FINISH(e, true); + SUCCEED_RETURN(e); +} + +akerr_ErrorContext *test_game_save_load_nullpointers(void) +{ + PREPARE_ERROR(e); + + ATTEMPT { + TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_game_save(NULL), + "akgl_game_save(NULL)"); + TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_game_load(NULL), + "akgl_game_load(NULL)"); + TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_game_save_actors(NULL), + "akgl_game_save_actors(NULL)"); + + // A path under a directory that does not exist cannot be opened. + TEST_EXPECT_ANY_ERROR(e, akgl_game_save("no_such_directory/save.bin"), + "saving into a nonexistent directory"); + TEST_EXPECT_ANY_ERROR(e, akgl_game_load("no_such_file_anywhere.bin"), + "loading a nonexistent savegame"); + } CLEANUP { + } PROCESS(e) { + } FINISH(e, true); + SUCCEED_RETURN(e); +} + +akerr_ErrorContext *test_game_load_truncated_table(void) +{ + PREPARE_ERROR(e); + FILE *fp = NULL; + char partial[64]; + + ATTEMPT { + CATCH(e, akgl_registry_init()); + CATCH(e, akgl_heap_init()); + set_game_identity(); + + // A valid header followed by a table that ends before its sentinel. The + // name-map reader loops until it sees the sentinel, so it has to notice + // EOF instead of spinning. + memset(&partial, 0x00, sizeof(partial)); + fp = fopen((char *)&truncatedpath, "wb"); + FAIL_ZERO_BREAK(e, fp, AKERR_IO, "unable to create the truncated savegame fixture"); + FAIL_ZERO_BREAK(e, fwrite(&game, 1, sizeof(akgl_Game), fp), AKERR_IO, + "unable to write the truncated savegame header"); + FAIL_ZERO_BREAK(e, fwrite(&partial, 1, sizeof(partial), fp), AKERR_IO, + "unable to write the truncated savegame body"); + fclose(fp); + fp = NULL; + + TEST_EXPECT_ANY_ERROR(e, akgl_game_load((char *)&truncatedpath), + "loading a savegame whose name table is truncated"); + } CLEANUP { + if ( fp != NULL ) { + fclose(fp); + } + unlink((char *)&truncatedpath); + } PROCESS(e) { + } FINISH(e, true); + SUCCEED_RETURN(e); +} + +akerr_ErrorContext *test_game_save_writes_name_tables(void) +{ + PREPARE_ERROR(e); + akgl_Actor *actor = NULL; + FILE *fp = NULL; + long filesize = 0; + long minimum = 0; + + ATTEMPT { + CATCH(e, akgl_registry_init()); + CATCH(e, akgl_heap_init()); + set_game_identity(); + + // One registered actor, so the actor table has a real entry ahead of its + // terminating sentinel. + CATCH(e, akgl_heap_next_actor(&actor)); + CATCH(e, akgl_actor_initialize(actor, "saved_actor")); + + TEST_EXPECT_OK(e, akgl_game_save((char *)&savepath), "saving a game with one actor"); + + fp = fopen((char *)&savepath, "rb"); + FAIL_ZERO_BREAK(e, fp, AKERR_IO, "unable to reopen the savegame"); + fseek(fp, 0, SEEK_END); + filesize = ftell(fp); + + // The header, then four name tables each ending in a name-sized and a + // pointer-sized sentinel, plus the one real actor entry. + minimum = (long)sizeof(akgl_Game) + + (long)(AKGL_ACTOR_MAX_NAME_LENGTH + sizeof(akgl_Actor *)) * 2 + + (long)(AKGL_SPRITE_MAX_NAME_LENGTH + sizeof(akgl_Sprite *)) + + (long)(AKGL_SPRITE_SHEET_MAX_FILENAME_LENGTH + sizeof(akgl_SpriteSheet *)) + + (long)(AKGL_SPRITE_MAX_CHARACTER_NAME_LENGTH + sizeof(akgl_Character *)); + + TEST_ASSERT(e, filesize >= minimum, + "the savegame is %ld bytes, expected at least %ld for the header and four name tables", + filesize, minimum); + } CLEANUP { + if ( fp != NULL ) { + fclose(fp); + } + unlink((char *)&savepath); + } PROCESS(e) { + } FINISH(e, true); + SUCCEED_RETURN(e); +} + +akerr_ErrorContext *test_game_state_lock(void) +{ + PREPARE_ERROR(e); + + ATTEMPT { + set_game_identity(); + game.statelock = SDL_CreateMutex(); + FAIL_ZERO_BREAK(e, game.statelock, AKGL_ERR_SDL, "unable to create the state mutex"); + + TEST_EXPECT_OK(e, akgl_game_state_lock(), "taking the state lock"); + TEST_EXPECT_OK(e, akgl_game_state_unlock(), "releasing the state lock"); + + // The lock is reusable after a matched unlock. + TEST_EXPECT_OK(e, akgl_game_state_lock(), "retaking the state lock"); + TEST_EXPECT_OK(e, akgl_game_state_unlock(), "releasing the state lock again"); + } CLEANUP { + if ( game.statelock != NULL ) { + SDL_DestroyMutex(game.statelock); + game.statelock = NULL; + } + } PROCESS(e) { + } FINISH(e, true); + SUCCEED_RETURN(e); +} + +/** @brief Counts calls made to the low-FPS callback. */ +static int lowfps_calls = 0; + +/** @brief Low-FPS callback stub that only records that it fired. */ +static void stub_lowfps(void) +{ + lowfps_calls += 1; +} + +akerr_ErrorContext *test_game_updateFPS(void) +{ + PREPARE_ERROR(e); + int16_t framesbefore = 0; + + ATTEMPT { + set_game_identity(); + game.lowfpsfunc = &stub_lowfps; + + // Below the 30 FPS floor, every update notifies the callback. + game.fps = 10; + game.lastFPSTime = SDL_GetTicksNS(); + lowfps_calls = 0; + framesbefore = game.framesSinceUpdate; + akgl_game_updateFPS(); + TEST_ASSERT(e, lowfps_calls == 1, + "a sub-30 FPS update fired the low-FPS callback %d times, expected 1", lowfps_calls); + TEST_ASSERT(e, game.framesSinceUpdate == (framesbefore + 1), + "updateFPS did not count the frame (%d, expected %d)", + game.framesSinceUpdate, framesbefore + 1); + TEST_ASSERT(e, game.lastIterTime != 0, "updateFPS did not stamp lastIterTime"); + + // At or above the floor, the callback stays quiet. + game.fps = 60; + game.lastFPSTime = SDL_GetTicksNS(); + lowfps_calls = 0; + akgl_game_updateFPS(); + TEST_ASSERT(e, lowfps_calls == 0, + "a 60 FPS update fired the low-FPS callback %d times, expected 0", lowfps_calls); + + // Once a full second has elapsed, the frame counter rolls into fps. + game.fps = 60; + game.framesSinceUpdate = 45; + game.lastFPSTime = SDL_GetTicksNS() - (2 * (SDL_Time)AKGL_TIME_ONESEC_NS); + akgl_game_updateFPS(); + TEST_ASSERT(e, game.fps == 45, + "after a second elapsed, fps rolled over as %d, expected 45", game.fps); + TEST_ASSERT(e, game.framesSinceUpdate == 1, + "the frame counter restarted at %d, expected 1", game.framesSinceUpdate); + + // The shipped default callback only logs, so it just has to not crash. + game.fps = 1; + akgl_game_lowfps(); + } CLEANUP { + } PROCESS(e) { + } FINISH(e, true); + SUCCEED_RETURN(e); +} + +int main(void) +{ + PREPARE_ERROR(errctx); + + SDL_SetHint(SDL_HINT_VIDEO_DRIVER, "dummy"); + SDL_SetHint(SDL_HINT_AUDIO_DRIVER, "dummy"); + + ATTEMPT { + CATCH(errctx, akgl_heap_init()); + CATCH(errctx, akgl_registry_init()); + + CATCH(errctx, test_game_load_versioncmp_matching()); + CATCH(errctx, test_game_load_versioncmp_mismatched()); + CATCH(errctx, test_game_load_versioncmp_releases_semver()); + CATCH(errctx, test_game_save_roundtrip()); + CATCH(errctx, test_game_load_rejects_foreign_saves()); + CATCH(errctx, test_game_save_load_nullpointers()); + CATCH(errctx, test_game_load_truncated_table()); + CATCH(errctx, test_game_save_writes_name_tables()); + CATCH(errctx, test_game_state_lock()); + CATCH(errctx, test_game_updateFPS()); + } CLEANUP { + } PROCESS(errctx) { + } FINISH_NORETURN(errctx); +} diff --git a/tests/heap.c b/tests/heap.c new file mode 100644 index 0000000..042aa65 --- /dev/null +++ b/tests/heap.c @@ -0,0 +1,378 @@ +/** + * @file heap.c + * @brief Unit tests for the fixed-size object pools and their refcounting. + * + * The pools are process-wide arrays, so every test that fills one calls + * akgl_heap_init() first and leaves the heap empty behind it. + */ + +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +#include "testutil.h" + +/** + * @brief Reset every pool and the registries that reference their objects. + * + * The registries hold raw pointers into the pools, so clearing a pool without + * clearing the registry would leave dangling entries for the next test. + */ +static akerr_ErrorContext *reset_all_heaps(void) +{ + PREPARE_ERROR(e); + ATTEMPT { + CATCH(e, akgl_registry_init_actor()); + CATCH(e, akgl_registry_init_sprite()); + CATCH(e, akgl_registry_init_spritesheet()); + CATCH(e, akgl_registry_init_character()); + CATCH(e, akgl_heap_init()); + } CLEANUP { + } PROCESS(e) { + } FINISH(e, true); + SUCCEED_RETURN(e); +} + +akerr_ErrorContext *test_heap_init_clears_every_pool(void) +{ + PREPARE_ERROR(e); + bool clean = true; + int i = 0; + + ATTEMPT { + // Dirty every pool, then require that init scrubs all of them. + for ( i = 0; i < AKGL_MAX_HEAP_ACTOR; i++ ) { + HEAP_ACTOR[i].refcount = 5; + } + for ( i = 0; i < AKGL_MAX_HEAP_SPRITE; i++ ) { + HEAP_SPRITE[i].refcount = 5; + } + for ( i = 0; i < AKGL_MAX_HEAP_SPRITESHEET; i++ ) { + HEAP_SPRITESHEET[i].refcount = 5; + } + for ( i = 0; i < AKGL_MAX_HEAP_CHARACTER; i++ ) { + HEAP_CHARACTER[i].refcount = 5; + } + for ( i = 0; i < AKGL_MAX_HEAP_STRING; i++ ) { + HEAP_STRING[i].refcount = 5; + } + + CATCH(e, akgl_heap_init()); + + for ( i = 0; i < AKGL_MAX_HEAP_ACTOR; i++ ) { + TEST_ASSERT_FLAG(clean, HEAP_ACTOR[i].refcount == 0); + } + TEST_ASSERT(e, clean, "akgl_heap_init left a nonzero refcount in the actor pool"); + + for ( i = 0; i < AKGL_MAX_HEAP_SPRITE; i++ ) { + TEST_ASSERT_FLAG(clean, HEAP_SPRITE[i].refcount == 0); + } + TEST_ASSERT(e, clean, "akgl_heap_init left a nonzero refcount in the sprite pool"); + + for ( i = 0; i < AKGL_MAX_HEAP_SPRITESHEET; i++ ) { + TEST_ASSERT_FLAG(clean, HEAP_SPRITESHEET[i].refcount == 0); + } + TEST_ASSERT(e, clean, "akgl_heap_init left a nonzero refcount in the spritesheet pool"); + + for ( i = 0; i < AKGL_MAX_HEAP_CHARACTER; i++ ) { + TEST_ASSERT_FLAG(clean, HEAP_CHARACTER[i].refcount == 0); + } + TEST_ASSERT(e, clean, "akgl_heap_init left a nonzero refcount in the character pool"); + + for ( i = 0; i < AKGL_MAX_HEAP_STRING; i++ ) { + TEST_ASSERT_FLAG(clean, HEAP_STRING[i].refcount == 0); + } + TEST_ASSERT(e, clean, "akgl_heap_init left a nonzero refcount in the string pool"); + + // akgl_heap_init_actor clears only the actor pool. + HEAP_ACTOR[0].refcount = 9; + HEAP_SPRITE[0].refcount = 9; + CATCH(e, akgl_heap_init_actor()); + TEST_ASSERT(e, HEAP_ACTOR[0].refcount == 0, + "akgl_heap_init_actor did not clear the actor pool"); + TEST_ASSERT(e, HEAP_SPRITE[0].refcount == 9, + "akgl_heap_init_actor cleared the sprite pool as well"); + HEAP_SPRITE[0].refcount = 0; + } CLEANUP { + } PROCESS(e) { + } FINISH(e, true); + SUCCEED_RETURN(e); +} + +akerr_ErrorContext *test_heap_next_string_refcounting(void) +{ + PREPARE_ERROR(e); + akgl_String *first = NULL; + akgl_String *second = NULL; + + ATTEMPT { + CATCH(e, reset_all_heaps()); + + // akgl_heap_next_string is the only acquire function that claims the slot + // it hands out; the others leave refcount at zero for the caller to set. + CATCH(e, akgl_heap_next_string(&first)); + TEST_ASSERT(e, first->refcount == 1, + "akgl_heap_next_string returned a slot with refcount %d, expected 1", + first->refcount); + + CATCH(e, akgl_heap_next_string(&second)); + TEST_ASSERT(e, second != first, + "akgl_heap_next_string handed out the same slot twice"); + + CATCH(e, akgl_heap_release_string(first)); + CATCH(e, akgl_heap_release_string(second)); + TEST_ASSERT(e, first->refcount == 0, + "releasing a string left refcount at %d", first->refcount); + } CLEANUP { + } PROCESS(e) { + } FINISH(e, true); + SUCCEED_RETURN(e); +} + +akerr_ErrorContext *test_heap_exhaustion(void) +{ + PREPARE_ERROR(e); + akgl_Actor *actor = NULL; + akgl_Sprite *sprite = NULL; + akgl_SpriteSheet *sheet = NULL; + akgl_Character *basechar = NULL; + akgl_String *str = NULL; + int i = 0; + + ATTEMPT { + // Actors: the acquire function does not claim the slot, so the test does. + CATCH(e, reset_all_heaps()); + for ( i = 0; i < AKGL_MAX_HEAP_ACTOR; i++ ) { + HEAP_ACTOR[i].refcount = 1; + } + TEST_EXPECT_STATUS(e, AKGL_ERR_HEAP, akgl_heap_next_actor(&actor), + "akgl_heap_next_actor with every slot claimed"); + + CATCH(e, reset_all_heaps()); + for ( i = 0; i < AKGL_MAX_HEAP_SPRITE; i++ ) { + HEAP_SPRITE[i].refcount = 1; + } + TEST_EXPECT_STATUS(e, AKGL_ERR_HEAP, akgl_heap_next_sprite(&sprite), + "akgl_heap_next_sprite with every slot claimed"); + + CATCH(e, reset_all_heaps()); + for ( i = 0; i < AKGL_MAX_HEAP_SPRITESHEET; i++ ) { + HEAP_SPRITESHEET[i].refcount = 1; + } + TEST_EXPECT_STATUS(e, AKGL_ERR_HEAP, akgl_heap_next_spritesheet(&sheet), + "akgl_heap_next_spritesheet with every slot claimed"); + + CATCH(e, reset_all_heaps()); + for ( i = 0; i < AKGL_MAX_HEAP_CHARACTER; i++ ) { + HEAP_CHARACTER[i].refcount = 1; + } + TEST_EXPECT_STATUS(e, AKGL_ERR_HEAP, akgl_heap_next_character(&basechar), + "akgl_heap_next_character with every slot claimed"); + + // Strings claim their own slots, so draining the pool needs no help. + CATCH(e, reset_all_heaps()); + for ( i = 0; i < AKGL_MAX_HEAP_STRING; i++ ) { + CATCH(e, akgl_heap_next_string(&str)); + } + TEST_EXPECT_STATUS(e, AKGL_ERR_HEAP, akgl_heap_next_string(&str), + "akgl_heap_next_string with the pool drained"); + + // A single release makes exactly one slot available again. + CATCH(e, akgl_heap_release_string(&HEAP_STRING[0])); + TEST_EXPECT_OK(e, akgl_heap_next_string(&str), + "akgl_heap_next_string after freeing one slot"); + TEST_ASSERT(e, str == &HEAP_STRING[0], + "the reclaimed string was not the slot that was released"); + + CATCH(e, reset_all_heaps()); + } CLEANUP { + } PROCESS(e) { + } FINISH(e, true); + SUCCEED_RETURN(e); +} + +akerr_ErrorContext *test_heap_release_refcounting(void) +{ + PREPARE_ERROR(e); + akgl_Sprite *sprite = NULL; + + ATTEMPT { + CATCH(e, reset_all_heaps()); + CATCH(e, akgl_heap_next_sprite(&sprite)); + + // A shared object survives until the last reference goes away. + sprite->refcount = 2; + strncpy((char *)&sprite->name, "shared", AKGL_SPRITE_MAX_NAME_LENGTH - 1); + CATCH(e, akgl_heap_release_sprite(sprite)); + TEST_ASSERT(e, sprite->refcount == 1, + "releasing a twice-referenced sprite left refcount %d, expected 1", + sprite->refcount); + TEST_ASSERT(e, sprite->name[0] == 's', + "releasing a still-referenced sprite cleared its data"); + + CATCH(e, akgl_heap_release_sprite(sprite)); + TEST_ASSERT(e, sprite->refcount == 0, + "the final release left refcount %d, expected 0", sprite->refcount); + TEST_ASSERT(e, sprite->name[0] == 0x00, + "the final release did not clear the sprite"); + + // Releasing an already-free object is clamped, not wrapped below zero. + CATCH(e, akgl_heap_release_sprite(sprite)); + TEST_ASSERT(e, sprite->refcount == 0, + "over-releasing drove refcount to %d, expected a clamp at 0", + sprite->refcount); + } CLEANUP { + } PROCESS(e) { + } FINISH(e, true); + SUCCEED_RETURN(e); +} + +akerr_ErrorContext *test_heap_release_actor_children(void) +{ + PREPARE_ERROR(e); + akgl_Actor *parent = NULL; + akgl_Actor *child = NULL; + char namebuf[AKGL_ACTOR_MAX_NAME_LENGTH]; + bool released = true; + int i = 0; + + ATTEMPT { + CATCH(e, reset_all_heaps()); + CATCH(e, akgl_heap_next_actor(&parent)); + CATCH(e, akgl_actor_initialize(parent, "parent")); + + // Fill every child slot, then release the parent once. + for ( i = 0; i < AKGL_ACTOR_MAX_CHILDREN; i++ ) { + snprintf((char *)&namebuf, AKGL_ACTOR_MAX_NAME_LENGTH, "child%d", i); + CATCH(e, akgl_heap_next_actor(&child)); + CATCH(e, akgl_actor_initialize(child, (char *)&namebuf)); + CATCH(e, akgl_actor_add_child(parent, child)); + } + + CATCH(e, akgl_heap_release_actor(parent)); + TEST_ASSERT(e, parent->refcount == 0, + "releasing the parent left refcount %d", parent->refcount); + + // Each child was initialized to 1 and incremented to 2 by add_child, so + // the recursive release should have brought every one of them back to 1. + for ( i = 1; i <= AKGL_ACTOR_MAX_CHILDREN; i++ ) { + TEST_ASSERT_FLAG(released, HEAP_ACTOR[i].refcount == 1); + } + TEST_ASSERT(e, released, + "releasing a parent did not decrement every child exactly once"); + + CATCH(e, reset_all_heaps()); + } CLEANUP { + } PROCESS(e) { + } FINISH(e, true); + SUCCEED_RETURN(e); +} + +akerr_ErrorContext *test_heap_release_clears_registry(void) +{ + PREPARE_ERROR(e); + akgl_Actor *actor = NULL; + akgl_Character *basechar = NULL; + + ATTEMPT { + CATCH(e, reset_all_heaps()); + + CATCH(e, akgl_heap_next_actor(&actor)); + CATCH(e, akgl_actor_initialize(actor, "registered")); + TEST_ASSERT(e, SDL_GetPointerProperty(AKGL_REGISTRY_ACTOR, "registered", NULL) != NULL, + "akgl_actor_initialize did not register the actor"); + CATCH(e, akgl_heap_release_actor(actor)); + TEST_ASSERT(e, SDL_GetPointerProperty(AKGL_REGISTRY_ACTOR, "registered", NULL) == NULL, + "releasing an actor left a dangling registry entry"); + + CATCH(e, akgl_heap_next_character(&basechar)); + CATCH(e, akgl_character_initialize(basechar, "regchar")); + TEST_ASSERT(e, SDL_GetPointerProperty(AKGL_REGISTRY_CHARACTER, "regchar", NULL) != NULL, + "akgl_character_initialize did not register the character"); + CATCH(e, akgl_heap_release_character(basechar)); + TEST_ASSERT(e, SDL_GetPointerProperty(AKGL_REGISTRY_CHARACTER, "regchar", NULL) == NULL, + "releasing a character left a dangling registry entry"); + + CATCH(e, reset_all_heaps()); + } CLEANUP { + } PROCESS(e) { + } FINISH(e, true); + SUCCEED_RETURN(e); +} + +akerr_ErrorContext *test_heap_release_nullpointers(void) +{ + PREPARE_ERROR(e); + + ATTEMPT { + TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_heap_release_actor(NULL), + "akgl_heap_release_actor(NULL)"); + TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_heap_release_sprite(NULL), + "akgl_heap_release_sprite(NULL)"); + TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_heap_release_spritesheet(NULL), + "akgl_heap_release_spritesheet(NULL)"); + TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_heap_release_character(NULL), + "akgl_heap_release_character(NULL)"); + TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_heap_release_string(NULL), + "akgl_heap_release_string(NULL)"); + } CLEANUP { + } PROCESS(e) { + } FINISH(e, true); + SUCCEED_RETURN(e); +} + +akerr_ErrorContext *test_heap_release_spritesheet_texture(void) +{ + PREPARE_ERROR(e); + akgl_SpriteSheet *sheet = NULL; + + ATTEMPT { + CATCH(e, reset_all_heaps()); + CATCH(e, akgl_heap_next_spritesheet(&sheet)); + + // A spritesheet with no texture must still release cleanly; the texture + // branch is exercised by the sprite suite, which has a live renderer. + sheet->refcount = 1; + sheet->texture = NULL; + CATCH(e, akgl_heap_release_spritesheet(sheet)); + TEST_ASSERT(e, sheet->refcount == 0, + "releasing a textureless spritesheet left refcount %d", sheet->refcount); + TEST_ASSERT(e, sheet->texture == NULL, + "releasing a spritesheet left a non-NULL texture pointer"); + } CLEANUP { + } PROCESS(e) { + } FINISH(e, true); + SUCCEED_RETURN(e); +} + +int main(void) +{ + PREPARE_ERROR(errctx); + + SDL_SetHint(SDL_HINT_VIDEO_DRIVER, "dummy"); + SDL_SetHint(SDL_HINT_AUDIO_DRIVER, "dummy"); + + ATTEMPT { + CATCH(errctx, akgl_heap_init()); + CATCH(errctx, akgl_registry_init()); + + CATCH(errctx, test_heap_init_clears_every_pool()); + CATCH(errctx, test_heap_next_string_refcounting()); + CATCH(errctx, test_heap_exhaustion()); + CATCH(errctx, test_heap_release_refcounting()); + CATCH(errctx, test_heap_release_actor_children()); + CATCH(errctx, test_heap_release_clears_registry()); + CATCH(errctx, test_heap_release_nullpointers()); + CATCH(errctx, test_heap_release_spritesheet_texture()); + } CLEANUP { + } PROCESS(errctx) { + } FINISH_NORETURN(errctx); +} diff --git a/tests/json_helpers.c b/tests/json_helpers.c new file mode 100644 index 0000000..b3cc2fa --- /dev/null +++ b/tests/json_helpers.c @@ -0,0 +1,366 @@ +/** + * @file json_helpers.c + * @brief Unit tests for the typed JSON accessors. + * + * These exercise the accessors directly rather than through sprite, character, + * or tilemap loading, so the error paths are reachable without building a + * malformed asset for every case. + */ + +#include +#include +#include +#include + +#include +#include +#include +#include +#include + +#include "testutil.h" + +/** @brief Fixture document shared by every test in this file. */ +static json_t *fixture = NULL; + +/** @brief Load tests/assets/snippets/test_json_helpers.json into @ref fixture. */ +static akerr_ErrorContext *load_fixture(void) +{ + PREPARE_ERROR(e); + json_error_t jsonerr; + akgl_String *pathstr = NULL; + + ATTEMPT { + CATCH(e, akgl_heap_next_string(&pathstr)); + snprintf( + (char *)&pathstr->data, + AKGL_MAX_STRING_LENGTH, + "%s%s", + SDL_GetBasePath(), + "assets/snippets/test_json_helpers.json"); + fixture = json_load_file((char *)&pathstr->data, 0, &jsonerr); + FAIL_ZERO_BREAK(e, fixture, AKERR_IO, + "Unable to load the JSON fixture: %s", (char *)jsonerr.text); + } CLEANUP { + IGNORE(akgl_heap_release_string(pathstr)); + } PROCESS(e) { + } FINISH(e, true); + SUCCEED_RETURN(e); +} + +akerr_ErrorContext *test_json_scalar_accessors(void) +{ + PREPARE_ERROR(e); + int intval = 0; + float floatval = 0; + bool boolval = false; + json_t *objval = NULL; + json_t *arrayval = NULL; + + ATTEMPT { + TEST_EXPECT_OK(e, akgl_get_json_integer_value(fixture, "count", &intval), "read count"); + TEST_ASSERT(e, intval == 42, "count read as %d, expected 42", intval); + + TEST_EXPECT_OK(e, akgl_get_json_integer_value(fixture, "negative", &intval), "read negative"); + TEST_ASSERT(e, intval == -17, "negative read as %d, expected -17", intval); + + TEST_EXPECT_OK(e, akgl_get_json_number_value(fixture, "ratio", &floatval), "read ratio"); + TEST_ASSERT_FEQ(e, floatval, 2.5f, "ratio read as %f, expected 2.5", floatval); + + // A JSON integer is a number, so the float accessor accepts it too. + TEST_EXPECT_OK(e, akgl_get_json_number_value(fixture, "count", &floatval), "read count as a number"); + TEST_ASSERT_FEQ(e, floatval, 42.0f, "count read as number %f, expected 42", floatval); + + TEST_EXPECT_OK(e, akgl_get_json_boolean_value(fixture, "enabled", &boolval), "read enabled"); + TEST_ASSERT(e, boolval == true, "enabled read as false"); + TEST_EXPECT_OK(e, akgl_get_json_boolean_value(fixture, "disabled", &boolval), "read disabled"); + TEST_ASSERT(e, boolval == false, "disabled read as true"); + + TEST_EXPECT_OK(e, akgl_get_json_object_value(fixture, "nested", &objval), "read nested"); + TEST_ASSERT(e, objval != NULL, "nested object came back NULL"); + TEST_EXPECT_OK(e, akgl_get_json_integer_value(objval, "innercount", &intval), "read nested innercount"); + TEST_ASSERT(e, intval == 7, "nested innercount read as %d, expected 7", intval); + + TEST_EXPECT_OK(e, akgl_get_json_array_value(fixture, "integers", &arrayval), "read integers array"); + TEST_ASSERT(e, json_array_size(arrayval) == 3, + "integers array had %d entries, expected 3", (int)json_array_size(arrayval)); + } CLEANUP { + } PROCESS(e) { + } FINISH(e, true); + SUCCEED_RETURN(e); +} + +akerr_ErrorContext *test_json_double_value(void) +{ + PREPARE_ERROR(e); + double dblval = 0; + + ATTEMPT { + TEST_EXPECT_OK(e, akgl_get_json_double_value(fixture, "ratio", &dblval), "read ratio as a double"); + TEST_ASSERT(e, dblval > 2.4999 && dblval < 2.5001, + "ratio read as double %f, expected 2.5", dblval); + + // Integers satisfy json_is_number, so the double accessor takes them. + TEST_EXPECT_OK(e, akgl_get_json_double_value(fixture, "count", &dblval), "read count as a double"); + TEST_ASSERT(e, dblval > 41.999 && dblval < 42.001, + "count read as double %f, expected 42", dblval); + + TEST_EXPECT_STATUS(e, AKERR_KEY, akgl_get_json_double_value(fixture, "absent", &dblval), + "double accessor on a missing key"); + TEST_EXPECT_STATUS(e, AKERR_TYPE, akgl_get_json_double_value(fixture, "name", &dblval), + "double accessor on a string value"); + TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_get_json_double_value(NULL, "ratio", &dblval), + "double accessor on a NULL object"); + } CLEANUP { + } PROCESS(e) { + } FINISH(e, true); + SUCCEED_RETURN(e); +} + +akerr_ErrorContext *test_json_string_accessor(void) +{ + PREPARE_ERROR(e); + akgl_String *allocated = NULL; + akgl_String *reused = NULL; + + ATTEMPT { + // A NULL destination makes the accessor claim a heap string. + TEST_EXPECT_OK(e, akgl_get_json_string_value(fixture, "name", &allocated), + "read name into a NULL destination"); + TEST_ASSERT(e, allocated != NULL, "the accessor did not allocate a destination string"); + TEST_ASSERT(e, strcmp((char *)&allocated->data, "json helper fixture") == 0, + "name read as \"%s\"", (char *)&allocated->data); + + // A caller-supplied destination is written in place, not replaced. + CATCH(e, akgl_heap_next_string(&reused)); + CATCH(e, akgl_string_initialize(reused, "placeholder")); + { + akgl_String *before = reused; + TEST_EXPECT_OK(e, akgl_get_json_string_value(fixture, "name", &reused), + "read name into a preallocated destination"); + TEST_ASSERT(e, reused == before, + "the accessor replaced a caller-supplied destination pointer"); + } + TEST_ASSERT(e, strcmp((char *)&reused->data, "json helper fixture") == 0, + "in-place read produced \"%s\"", (char *)&reused->data); + + TEST_EXPECT_STATUS(e, AKERR_KEY, akgl_get_json_string_value(fixture, "absent", &reused), + "string accessor on a missing key"); + TEST_EXPECT_STATUS(e, AKERR_TYPE, akgl_get_json_string_value(fixture, "count", &reused), + "string accessor on an integer value"); + TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_get_json_string_value(NULL, "name", &reused), + "string accessor on a NULL object"); + TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_get_json_string_value(fixture, NULL, &reused), + "string accessor with a NULL key"); + TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_get_json_string_value(fixture, "name", NULL), + "string accessor with a NULL destination"); + } CLEANUP { + IGNORE(akgl_heap_release_string(allocated)); + IGNORE(akgl_heap_release_string(reused)); + } PROCESS(e) { + } FINISH(e, true); + SUCCEED_RETURN(e); +} + +akerr_ErrorContext *test_json_array_index_accessors(void) +{ + PREPARE_ERROR(e); + json_t *integers = NULL; + json_t *strings = NULL; + json_t *objects = NULL; + json_t *mixed = NULL; + json_t *element = NULL; + akgl_String *strval = NULL; + int intval = 0; + + ATTEMPT { + CATCH(e, akgl_get_json_array_value(fixture, "integers", &integers)); + CATCH(e, akgl_get_json_array_value(fixture, "strings", &strings)); + CATCH(e, akgl_get_json_array_value(fixture, "objects", &objects)); + CATCH(e, akgl_get_json_array_value(fixture, "mixed", &mixed)); + + TEST_EXPECT_OK(e, akgl_get_json_array_index_integer(integers, 0, &intval), "integers[0]"); + TEST_ASSERT(e, intval == 10, "integers[0] read as %d, expected 10", intval); + TEST_EXPECT_OK(e, akgl_get_json_array_index_integer(integers, 2, &intval), "integers[2]"); + TEST_ASSERT(e, intval == 30, "integers[2] read as %d, expected 30", intval); + + TEST_EXPECT_OK(e, akgl_get_json_array_index_string(strings, 1, &strval), "strings[1]"); + TEST_ASSERT(e, strcmp((char *)&strval->data, "beta") == 0, + "strings[1] read as \"%s\", expected \"beta\"", (char *)&strval->data); + + TEST_EXPECT_OK(e, akgl_get_json_array_index_object(objects, 1, &element), "objects[1]"); + TEST_EXPECT_OK(e, akgl_get_json_integer_value(element, "id", &intval), "objects[1].id"); + TEST_ASSERT(e, intval == 2, "objects[1].id read as %d, expected 2", intval); + + // One past the end, and far past the end, are both out of bounds. + TEST_EXPECT_STATUS(e, AKERR_OUTOFBOUNDS, akgl_get_json_array_index_integer(integers, 3, &intval), + "integers[3] is one past the end"); + TEST_EXPECT_STATUS(e, AKERR_OUTOFBOUNDS, akgl_get_json_array_index_integer(integers, 99, &intval), + "integers[99] is far past the end"); + TEST_EXPECT_STATUS(e, AKERR_OUTOFBOUNDS, akgl_get_json_array_index_object(objects, 5, &element), + "objects[5] is past the end"); + TEST_EXPECT_STATUS(e, AKERR_OUTOFBOUNDS, akgl_get_json_array_index_string(strings, 5, &strval), + "strings[5] is past the end"); + + // The mixed array holds one of each type, so every accessor can be shown + // to reject the entries that are not its own. + TEST_EXPECT_STATUS(e, AKERR_TYPE, akgl_get_json_array_index_integer(mixed, 1, &intval), + "integer accessor on a string element"); + TEST_EXPECT_STATUS(e, AKERR_TYPE, akgl_get_json_array_index_string(mixed, 0, &strval), + "string accessor on an integer element"); + TEST_EXPECT_STATUS(e, AKERR_TYPE, akgl_get_json_array_index_object(mixed, 0, &element), + "object accessor on an integer element"); + + TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_get_json_array_index_integer(NULL, 0, &intval), + "integer index accessor on a NULL array"); + TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_get_json_array_index_object(NULL, 0, &element), + "object index accessor on a NULL array"); + TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_get_json_array_index_string(NULL, 0, &strval), + "string index accessor on a NULL array"); + TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_get_json_array_index_string(strings, 0, NULL), + "string index accessor with a NULL destination"); + } CLEANUP { + IGNORE(akgl_heap_release_string(strval)); + } PROCESS(e) { + } FINISH(e, true); + SUCCEED_RETURN(e); +} + +akerr_ErrorContext *test_json_type_and_key_errors(void) +{ + PREPARE_ERROR(e); + int intval = 0; + float floatval = 0; + bool boolval = false; + json_t *objval = NULL; + json_t *arrayval = NULL; + + ATTEMPT { + // Missing keys. + TEST_EXPECT_STATUS(e, AKERR_KEY, akgl_get_json_integer_value(fixture, "absent", &intval), + "integer accessor on a missing key"); + TEST_EXPECT_STATUS(e, AKERR_KEY, akgl_get_json_number_value(fixture, "absent", &floatval), + "number accessor on a missing key"); + TEST_EXPECT_STATUS(e, AKERR_KEY, akgl_get_json_boolean_value(fixture, "absent", &boolval), + "boolean accessor on a missing key"); + TEST_EXPECT_STATUS(e, AKERR_KEY, akgl_get_json_object_value(fixture, "absent", &objval), + "object accessor on a missing key"); + TEST_EXPECT_STATUS(e, AKERR_KEY, akgl_get_json_array_value(fixture, "absent", &arrayval), + "array accessor on a missing key"); + + // Wrong types. + TEST_EXPECT_STATUS(e, AKERR_TYPE, akgl_get_json_integer_value(fixture, "name", &intval), + "integer accessor on a string"); + TEST_EXPECT_STATUS(e, AKERR_TYPE, akgl_get_json_integer_value(fixture, "ratio", &intval), + "integer accessor on a real"); + TEST_EXPECT_STATUS(e, AKERR_TYPE, akgl_get_json_number_value(fixture, "name", &floatval), + "number accessor on a string"); + TEST_EXPECT_STATUS(e, AKERR_TYPE, akgl_get_json_boolean_value(fixture, "count", &boolval), + "boolean accessor on an integer"); + TEST_EXPECT_STATUS(e, AKERR_TYPE, akgl_get_json_object_value(fixture, "integers", &objval), + "object accessor on an array"); + TEST_EXPECT_STATUS(e, AKERR_TYPE, akgl_get_json_array_value(fixture, "nested", &arrayval), + "array accessor on an object"); + + // NULL containers. + TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_get_json_integer_value(NULL, "count", &intval), + "integer accessor on a NULL object"); + TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_get_json_number_value(NULL, "ratio", &floatval), + "number accessor on a NULL object"); + TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_get_json_boolean_value(NULL, "enabled", &boolval), + "boolean accessor on a NULL object"); + TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_get_json_object_value(NULL, "nested", &objval), + "object accessor on a NULL object"); + TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_get_json_array_value(NULL, "integers", &arrayval), + "array accessor on a NULL object"); + } CLEANUP { + } PROCESS(e) { + } FINISH(e, true); + SUCCEED_RETURN(e); +} + +akerr_ErrorContext *test_json_with_default(void) +{ + PREPARE_ERROR(e); + int dest = 0; + int defval = 99; + akerr_ErrorContext *keyerr = NULL; + akerr_ErrorContext *typeerr = NULL; + int junk = 0; + + ATTEMPT { + // A NULL error means the read succeeded, so the default is not applied. + dest = 1; + TEST_EXPECT_OK(e, akgl_get_json_with_default(NULL, (void *)&defval, (void *)&dest, sizeof(int)), + "with_default on a NULL error"); + TEST_ASSERT(e, dest == 1, "with_default overwrote a successful read (dest is now %d)", dest); + + // An AKERR_KEY failure substitutes the default. + dest = 1; + keyerr = akgl_get_json_integer_value(fixture, "absent", &junk); + TEST_ASSERT(e, keyerr != NULL, "the missing-key read unexpectedly succeeded"); + TEST_EXPECT_OK(e, akgl_get_json_with_default(keyerr, (void *)&defval, (void *)&dest, sizeof(int)), + "with_default on an AKERR_KEY error"); + TEST_ASSERT(e, dest == 99, "with_default did not apply the default (dest is %d)", dest); + keyerr = NULL; + + // An unrelated failure is not swallowed, so the caller still sees it. + dest = 1; + typeerr = akgl_get_json_integer_value(fixture, "name", &junk); + TEST_ASSERT(e, typeerr != NULL, "the wrong-type read unexpectedly succeeded"); + TEST_EXPECT_STATUS(e, AKERR_TYPE, + akgl_get_json_with_default(typeerr, (void *)&defval, (void *)&dest, sizeof(int)), + "with_default must propagate an unrelated error"); + TEST_ASSERT(e, dest == 1, "with_default applied the default for an unrelated error"); + typeerr = NULL; + + // NULL arguments alongside a real error are a contract violation. + keyerr = akgl_get_json_integer_value(fixture, "absent", &junk); + TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, + akgl_get_json_with_default(keyerr, NULL, (void *)&dest, sizeof(int)), + "with_default with a NULL default value"); + keyerr = NULL; + keyerr = akgl_get_json_integer_value(fixture, "absent", &junk); + TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, + akgl_get_json_with_default(keyerr, (void *)&defval, NULL, sizeof(int)), + "with_default with a NULL destination"); + keyerr = NULL; + } CLEANUP { + if ( keyerr != NULL ) { + keyerr->handled = true; + keyerr = akerr_release_error(keyerr); + } + if ( typeerr != NULL ) { + typeerr->handled = true; + typeerr = akerr_release_error(typeerr); + } + } PROCESS(e) { + } FINISH(e, true); + SUCCEED_RETURN(e); +} + +int main(void) +{ + PREPARE_ERROR(errctx); + + SDL_SetHint(SDL_HINT_VIDEO_DRIVER, "dummy"); + SDL_SetHint(SDL_HINT_AUDIO_DRIVER, "dummy"); + + ATTEMPT { + CATCH(errctx, akgl_heap_init()); + CATCH(errctx, akgl_registry_init()); + CATCH(errctx, load_fixture()); + + CATCH(errctx, test_json_scalar_accessors()); + CATCH(errctx, test_json_double_value()); + CATCH(errctx, test_json_string_accessor()); + CATCH(errctx, test_json_array_index_accessors()); + CATCH(errctx, test_json_type_and_key_errors()); + CATCH(errctx, test_json_with_default()); + } CLEANUP { + if ( fixture != NULL ) { + json_decref(fixture); + } + } PROCESS(errctx) { + } FINISH_NORETURN(errctx); +} diff --git a/tests/physics.c b/tests/physics.c new file mode 100644 index 0000000..2087c96 --- /dev/null +++ b/tests/physics.c @@ -0,0 +1,747 @@ +/** + * @file physics.c + * @brief Unit tests for the physics backends and the simulation loop. + * + * None of these tests need a renderer or a window. The simulation loop walks + * HEAP_ACTOR directly, so each test rebuilds the actor heap rather than relying + * on state left behind by an earlier one. + */ + +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "testutil.h" + +/** @brief Records whether the stub movement logic ran, and with what dt. */ +static int stub_movement_calls = 0; +static float32_t stub_movement_last_dt = 0; + +/** @brief Movement logic stub that records its invocation and does nothing else. */ +static akerr_ErrorContext *stub_movement_noop(akgl_Actor *actor, float32_t dt) +{ + PREPARE_ERROR(e); + stub_movement_calls += 1; + stub_movement_last_dt = dt; + SUCCEED_RETURN(e); +} + +/** @brief Movement logic stub that raises the interrupt the simulator must swallow. */ +static akerr_ErrorContext *stub_movement_interrupt(akgl_Actor *actor, float32_t dt) +{ + PREPARE_ERROR(e); + stub_movement_calls += 1; + FAIL_RETURN(e, AKGL_ERR_LOGICINTERRUPT, "deliberate interrupt from test stub"); +} + +/** @brief Movement logic stub that raises an error the simulator must propagate. */ +static akerr_ErrorContext *stub_movement_error(akgl_Actor *actor, float32_t dt) +{ + PREPARE_ERROR(e); + stub_movement_calls += 1; + FAIL_RETURN(e, AKERR_VALUE, "deliberate error from test stub"); +} + +/** + * @brief Build a character and an actor bound to it, ready for simulation. + * + * The actor is placed on the heap with a nonzero refcount and a movement logic + * stub, which is the minimum the simulation loop requires to process it. + */ +static akerr_ErrorContext *make_sim_actor(akgl_Actor **actor, akgl_Character **basechar, char *name) +{ + PREPARE_ERROR(e); + ATTEMPT { + CATCH(e, akgl_heap_next_character(basechar)); + CATCH(e, akgl_character_initialize(*basechar, name)); + CATCH(e, akgl_heap_next_actor(actor)); + CATCH(e, akgl_actor_initialize(*actor, name)); + (*actor)->basechar = *basechar; + (*actor)->movementlogicfunc = &stub_movement_noop; + } CLEANUP { + } PROCESS(e) { + } FINISH(e, true); + SUCCEED_RETURN(e); +} + +/** @brief Reset the actor heap so each simulation test starts from a clean slate. */ +static akerr_ErrorContext *reset_sim_heap(void) +{ + PREPARE_ERROR(e); + ATTEMPT { + CATCH(e, akgl_registry_init_actor()); + CATCH(e, akgl_registry_init_character()); + CATCH(e, akgl_heap_init()); + } CLEANUP { + } PROCESS(e) { + } FINISH(e, true); + stub_movement_calls = 0; + stub_movement_last_dt = 0; + SUCCEED_RETURN(e); +} + +akerr_ErrorContext *test_physics_init_null(void) +{ + PREPARE_ERROR(e); + akgl_PhysicsBackend backend; + + ATTEMPT { + memset(&backend, 0x00, sizeof(akgl_PhysicsBackend)); + TEST_EXPECT_OK(e, akgl_physics_init_null(&backend), "akgl_physics_init_null"); + + TEST_ASSERT(e, backend.gravity == &akgl_physics_null_gravity, + "init_null did not install the null gravity function"); + TEST_ASSERT(e, backend.collide == &akgl_physics_null_collide, + "init_null did not install the null collide function"); + TEST_ASSERT(e, backend.move == &akgl_physics_null_move, + "init_null did not install the null move function"); + TEST_ASSERT(e, backend.simulate == &akgl_physics_simulate, + "init_null did not install the shared simulate function"); + + TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_physics_init_null(NULL), + "akgl_physics_init_null(NULL)"); + } CLEANUP { + } PROCESS(e) { + } FINISH(e, true); + SUCCEED_RETURN(e); +} + +akerr_ErrorContext *test_physics_null_backend_is_inert(void) +{ + PREPARE_ERROR(e); + akgl_PhysicsBackend backend; + akgl_Actor actor; + akgl_Actor reference; + + ATTEMPT { + memset(&backend, 0x00, sizeof(akgl_PhysicsBackend)); + memset(&actor, 0x00, sizeof(akgl_Actor)); + TEST_EXPECT_OK(e, akgl_physics_init_null(&backend), "akgl_physics_init_null"); + + actor.x = 3.0f; actor.y = 5.0f; actor.z = 7.0f; + actor.vx = 11.0f; actor.vy = 13.0f; actor.vz = 17.0f; + actor.ex = 19.0f; actor.ey = 23.0f; actor.ez = 29.0f; + memcpy(&reference, &actor, sizeof(akgl_Actor)); + + TEST_EXPECT_OK(e, backend.gravity(&backend, &actor, 1.0f), "null gravity"); + TEST_EXPECT_OK(e, backend.move(&backend, &actor, 1.0f), "null move"); + TEST_EXPECT_OK(e, backend.collide(&backend, &actor, &reference), "null collide"); + + TEST_ASSERT(e, memcmp(&actor, &reference, sizeof(akgl_Actor)) == 0, + "the null backend modified the actor"); + + TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_physics_null_gravity(NULL, &actor, 1.0f), + "null gravity with NULL self"); + TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_physics_null_move(NULL, &actor, 1.0f), + "null move with NULL self"); + TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_physics_null_collide(NULL, &actor, &reference), + "null collide with NULL self"); + } CLEANUP { + } PROCESS(e) { + } FINISH(e, true); + SUCCEED_RETURN(e); +} + +akerr_ErrorContext *test_physics_arcade_gravity(void) +{ + PREPARE_ERROR(e); + akgl_PhysicsBackend backend; + akgl_Actor actor; + + ATTEMPT { + memset(&backend, 0x00, sizeof(akgl_PhysicsBackend)); + + // X gravity subtracts, because the X origin is screen-left. + memset(&actor, 0x00, sizeof(akgl_Actor)); + backend.gravity_x = 10.0; + TEST_EXPECT_OK(e, akgl_physics_arcade_gravity(&backend, &actor, 0.5f), "arcade gravity x"); + TEST_ASSERT_FEQ(e, actor.ex, -5.0f, "gravity_x 10 over dt 0.5 gave ex %f, expected -5", actor.ex); + TEST_ASSERT_FEQ(e, actor.ey, 0.0f, "gravity_x leaked into ey (%f)", actor.ey); + TEST_ASSERT_FEQ(e, actor.ez, 0.0f, "gravity_x leaked into ez (%f)", actor.ez); + + // Y gravity adds, because the Y origin is down-screen. + memset(&actor, 0x00, sizeof(akgl_Actor)); + backend.gravity_x = 0; + backend.gravity_y = 10.0; + TEST_EXPECT_OK(e, akgl_physics_arcade_gravity(&backend, &actor, 0.5f), "arcade gravity y"); + TEST_ASSERT_FEQ(e, actor.ey, 5.0f, "gravity_y 10 over dt 0.5 gave ey %f, expected 5", actor.ey); + TEST_ASSERT_FEQ(e, actor.ex, 0.0f, "gravity_y leaked into ex (%f)", actor.ex); + + // Z gravity subtracts, because the Z origin is behind the camera. + memset(&actor, 0x00, sizeof(akgl_Actor)); + backend.gravity_y = 0; + backend.gravity_z = 4.0; + TEST_EXPECT_OK(e, akgl_physics_arcade_gravity(&backend, &actor, 0.25f), "arcade gravity z"); + TEST_ASSERT_FEQ(e, actor.ez, -1.0f, "gravity_z 4 over dt 0.25 gave ez %f, expected -1", actor.ez); + + // Negative gravity reverses each axis. + memset(&actor, 0x00, sizeof(akgl_Actor)); + backend.gravity_x = -10.0; + backend.gravity_y = -10.0; + backend.gravity_z = -10.0; + TEST_EXPECT_OK(e, akgl_physics_arcade_gravity(&backend, &actor, 1.0f), "arcade gravity negative"); + TEST_ASSERT_FEQ(e, actor.ex, 10.0f, "negative gravity_x gave ex %f, expected 10", actor.ex); + TEST_ASSERT_FEQ(e, actor.ey, -10.0f, "negative gravity_y gave ey %f, expected -10", actor.ey); + TEST_ASSERT_FEQ(e, actor.ez, 10.0f, "negative gravity_z gave ez %f, expected 10", actor.ez); + + // A zero dt applies no force even when gravity is set. + memset(&actor, 0x00, sizeof(akgl_Actor)); + backend.gravity_x = 10.0; + backend.gravity_y = 10.0; + backend.gravity_z = 10.0; + TEST_EXPECT_OK(e, akgl_physics_arcade_gravity(&backend, &actor, 0.0f), "arcade gravity dt 0"); + TEST_ASSERT_FEQ(e, actor.ex, 0.0f, "dt 0 still moved ex (%f)", actor.ex); + TEST_ASSERT_FEQ(e, actor.ey, 0.0f, "dt 0 still moved ey (%f)", actor.ey); + TEST_ASSERT_FEQ(e, actor.ez, 0.0f, "dt 0 still moved ez (%f)", actor.ez); + + // All-zero gravity leaves every axis alone. + memset(&actor, 0x00, sizeof(akgl_Actor)); + memset(&backend, 0x00, sizeof(akgl_PhysicsBackend)); + actor.ex = 1.0f; actor.ey = 2.0f; actor.ez = 3.0f; + TEST_EXPECT_OK(e, akgl_physics_arcade_gravity(&backend, &actor, 1.0f), "arcade gravity all zero"); + TEST_ASSERT_FEQ(e, actor.ex, 1.0f, "zero gravity_x changed ex (%f)", actor.ex); + TEST_ASSERT_FEQ(e, actor.ey, 2.0f, "zero gravity_y changed ey (%f)", actor.ey); + TEST_ASSERT_FEQ(e, actor.ez, 3.0f, "zero gravity_z changed ez (%f)", actor.ez); + + TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_physics_arcade_gravity(NULL, &actor, 1.0f), + "arcade gravity with NULL self"); + TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_physics_arcade_gravity(&backend, NULL, 1.0f), + "arcade gravity with NULL actor"); + } CLEANUP { + } PROCESS(e) { + } FINISH(e, true); + SUCCEED_RETURN(e); +} + +akerr_ErrorContext *test_physics_arcade_move(void) +{ + PREPARE_ERROR(e); + akgl_PhysicsBackend backend; + akgl_Actor actor; + + ATTEMPT { + memset(&backend, 0x00, sizeof(akgl_PhysicsBackend)); + memset(&actor, 0x00, sizeof(akgl_Actor)); + + actor.x = 100.0f; actor.y = 200.0f; actor.z = 300.0f; + actor.vx = 10.0f; actor.vy = -20.0f; actor.vz = 30.0f; + TEST_EXPECT_OK(e, akgl_physics_arcade_move(&backend, &actor, 0.5f), "arcade move"); + TEST_ASSERT_FEQ(e, actor.x, 105.0f, "x moved to %f, expected 105", actor.x); + TEST_ASSERT_FEQ(e, actor.y, 190.0f, "y moved to %f, expected 190", actor.y); + TEST_ASSERT_FEQ(e, actor.z, 315.0f, "z moved to %f, expected 315", actor.z); + + // dt of zero is a no-op regardless of velocity. + TEST_EXPECT_OK(e, akgl_physics_arcade_move(&backend, &actor, 0.0f), "arcade move dt 0"); + TEST_ASSERT_FEQ(e, actor.x, 105.0f, "dt 0 moved x to %f", actor.x); + TEST_ASSERT_FEQ(e, actor.y, 190.0f, "dt 0 moved y to %f", actor.y); + TEST_ASSERT_FEQ(e, actor.z, 315.0f, "dt 0 moved z to %f", actor.z); + + // Zero velocity is a no-op regardless of dt. + actor.vx = 0; actor.vy = 0; actor.vz = 0; + TEST_EXPECT_OK(e, akgl_physics_arcade_move(&backend, &actor, 10.0f), "arcade move zero velocity"); + TEST_ASSERT_FEQ(e, actor.x, 105.0f, "zero velocity moved x to %f", actor.x); + + TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_physics_arcade_move(NULL, &actor, 1.0f), + "arcade move with NULL self"); + TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_physics_arcade_move(&backend, NULL, 1.0f), + "arcade move with NULL actor"); + } CLEANUP { + } PROCESS(e) { + } FINISH(e, true); + SUCCEED_RETURN(e); +} + +akerr_ErrorContext *test_physics_arcade_collide_unimplemented(void) +{ + PREPARE_ERROR(e); + akgl_PhysicsBackend backend; + akgl_Actor a1; + akgl_Actor a2; + + ATTEMPT { + memset(&backend, 0x00, sizeof(akgl_PhysicsBackend)); + memset(&a1, 0x00, sizeof(akgl_Actor)); + memset(&a2, 0x00, sizeof(akgl_Actor)); + + // Pins the stub so that implementing collision has to update this test. + TEST_EXPECT_STATUS(e, AKERR_API, akgl_physics_arcade_collide(&backend, &a1, &a2), + "arcade collide is still a stub"); + TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_physics_arcade_collide(NULL, &a1, &a2), + "arcade collide with NULL self"); + } CLEANUP { + } PROCESS(e) { + } FINISH(e, true); + SUCCEED_RETURN(e); +} + +akerr_ErrorContext *test_physics_factory(void) +{ + PREPARE_ERROR(e); + akgl_PhysicsBackend backend; + akgl_String typestr; + + ATTEMPT { + memset(&backend, 0x00, sizeof(akgl_PhysicsBackend)); + + CATCH(e, akgl_string_initialize(&typestr, "null")); + TEST_EXPECT_OK(e, akgl_physics_factory(&backend, &typestr), "factory(\"null\")"); + TEST_ASSERT(e, backend.move == &akgl_physics_null_move, + "factory(\"null\") did not install the null backend"); + + memset(&backend, 0x00, sizeof(akgl_PhysicsBackend)); + CATCH(e, akgl_string_initialize(&typestr, "arcade")); + TEST_EXPECT_OK(e, akgl_physics_factory(&backend, &typestr), "factory(\"arcade\")"); + TEST_ASSERT(e, backend.move == &akgl_physics_arcade_move, + "factory(\"arcade\") did not install the arcade backend"); + + CATCH(e, akgl_string_initialize(&typestr, "newtonian")); + TEST_EXPECT_STATUS(e, AKERR_KEY, akgl_physics_factory(&backend, &typestr), + "factory with an unknown backend name"); + + // An empty name must not match either prefix. + CATCH(e, akgl_string_initialize(&typestr, "")); + TEST_EXPECT_STATUS(e, AKERR_KEY, akgl_physics_factory(&backend, &typestr), + "factory with an empty backend name"); + + TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_physics_factory(NULL, &typestr), + "factory with NULL self"); + TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_physics_factory(&backend, NULL), + "factory with NULL type"); + } CLEANUP { + } PROCESS(e) { + } FINISH(e, true); + SUCCEED_RETURN(e); +} + +akerr_ErrorContext *test_physics_init_arcade_properties(void) +{ + PREPARE_ERROR(e); + akgl_PhysicsBackend backend; + + ATTEMPT { + CATCH(e, akgl_registry_init_properties()); + + // With nothing configured, every environmental constant defaults to zero. + memset(&backend, 0x00, sizeof(akgl_PhysicsBackend)); + TEST_EXPECT_OK(e, akgl_physics_init_arcade(&backend), "init_arcade with no properties set"); + TEST_ASSERT_FEQ(e, backend.gravity_y, 0.0f, + "unset physics.gravity.y defaulted to %f, expected 0", backend.gravity_y); + TEST_ASSERT_FEQ(e, backend.drag_x, 0.0f, + "unset physics.drag.x defaulted to %f, expected 0", backend.drag_x); + TEST_ASSERT(e, backend.gravity == &akgl_physics_arcade_gravity, + "init_arcade did not install the arcade gravity function"); + TEST_ASSERT(e, backend.simulate == &akgl_physics_simulate, + "init_arcade did not install the shared simulate function"); + + // Configured values are read out of the property registry. + CATCH(e, akgl_set_property("physics.gravity.x", "1.5")); + CATCH(e, akgl_set_property("physics.gravity.y", "9.8")); + CATCH(e, akgl_set_property("physics.gravity.z", "2.25")); + CATCH(e, akgl_set_property("physics.drag.x", "0.5")); + CATCH(e, akgl_set_property("physics.drag.y", "0.25")); + CATCH(e, akgl_set_property("physics.drag.z", "0.125")); + + memset(&backend, 0x00, sizeof(akgl_PhysicsBackend)); + TEST_EXPECT_OK(e, akgl_physics_init_arcade(&backend), "init_arcade with properties set"); + TEST_ASSERT_FEQ(e, backend.gravity_x, 1.5f, "physics.gravity.x read as %f", backend.gravity_x); + TEST_ASSERT_FEQ(e, backend.gravity_y, 9.8f, "physics.gravity.y read as %f", backend.gravity_y); + TEST_ASSERT_FEQ(e, backend.gravity_z, 2.25f, "physics.gravity.z read as %f", backend.gravity_z); + TEST_ASSERT_FEQ(e, backend.drag_x, 0.5f, "physics.drag.x read as %f", backend.drag_x); + TEST_ASSERT_FEQ(e, backend.drag_y, 0.25f, "physics.drag.y read as %f", backend.drag_y); + TEST_ASSERT_FEQ(e, backend.drag_z, 0.125f, "physics.drag.z read as %f", backend.drag_z); + + TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_physics_init_arcade(NULL), + "init_arcade with NULL self"); + } CLEANUP { + } PROCESS(e) { + } FINISH(e, true); + SUCCEED_RETURN(e); +} + +akerr_ErrorContext *test_physics_simulate_skips_inactive(void) +{ + PREPARE_ERROR(e); + akgl_PhysicsBackend backend; + akgl_Actor *actor = NULL; + akgl_Character *basechar = NULL; + + ATTEMPT { + CATCH(e, reset_sim_heap()); + memset(&backend, 0x00, sizeof(akgl_PhysicsBackend)); + CATCH(e, akgl_physics_init_null(&backend)); + + // A released actor (refcount 0) is skipped entirely. + CATCH(e, make_sim_actor(&actor, &basechar, "inactive")); + actor->refcount = 0; + stub_movement_calls = 0; + TEST_EXPECT_OK(e, akgl_physics_simulate(&backend, NULL), "simulate with a released actor"); + TEST_ASSERT(e, stub_movement_calls == 0, + "simulate ran movement logic for an actor with refcount 0 (%d calls)", + stub_movement_calls); + + // An actor with no base character is skipped. + CATCH(e, reset_sim_heap()); + CATCH(e, make_sim_actor(&actor, &basechar, "nochar")); + actor->basechar = NULL; + stub_movement_calls = 0; + TEST_EXPECT_OK(e, akgl_physics_simulate(&backend, NULL), "simulate with a character-less actor"); + TEST_ASSERT(e, stub_movement_calls == 0, + "simulate ran movement logic for an actor with no base character (%d calls)", + stub_movement_calls); + + // A fully wired actor is processed. + CATCH(e, reset_sim_heap()); + CATCH(e, make_sim_actor(&actor, &basechar, "active")); + stub_movement_calls = 0; + TEST_EXPECT_OK(e, akgl_physics_simulate(&backend, NULL), "simulate with an active actor"); + TEST_ASSERT(e, stub_movement_calls == 1, + "simulate ran movement logic %d times for one active actor, expected 1", + stub_movement_calls); + } CLEANUP { + } PROCESS(e) { + } FINISH(e, true); + SUCCEED_RETURN(e); +} + +akerr_ErrorContext *test_physics_simulate_children_follow_parents(void) +{ + PREPARE_ERROR(e); + akgl_PhysicsBackend backend; + akgl_Actor *parent = NULL; + akgl_Actor *child = NULL; + akgl_Character *basechar = NULL; + akgl_Character *childchar = NULL; + + ATTEMPT { + CATCH(e, reset_sim_heap()); + memset(&backend, 0x00, sizeof(akgl_PhysicsBackend)); + CATCH(e, akgl_physics_init_null(&backend)); + + CATCH(e, make_sim_actor(&parent, &basechar, "parent")); + CATCH(e, make_sim_actor(&child, &childchar, "child")); + + parent->x = 100.0f; parent->y = 200.0f; parent->z = 300.0f; + // For a child, vx/vy/vz are read as a fixed offset from the parent. + child->vx = 5.0f; child->vy = -5.0f; child->vz = 2.0f; + child->x = -999.0f; child->y = -999.0f; child->z = -999.0f; + CATCH(e, akgl_actor_add_child(parent, child)); + + stub_movement_calls = 0; + TEST_EXPECT_OK(e, akgl_physics_simulate(&backend, NULL), "simulate with a parented child"); + + TEST_ASSERT_FEQ(e, child->x, 105.0f, "child x resolved to %f, expected parent 100 + offset 5", child->x); + TEST_ASSERT_FEQ(e, child->y, 195.0f, "child y resolved to %f, expected parent 200 - offset 5", child->y); + TEST_ASSERT_FEQ(e, child->z, 302.0f, "child z resolved to %f, expected parent 300 + offset 2", child->z); + + // Only the parent goes through the movement logic; the child is positional only. + TEST_ASSERT(e, stub_movement_calls == 1, + "simulate ran movement logic %d times, expected 1 (parent only)", + stub_movement_calls); + } CLEANUP { + } PROCESS(e) { + } FINISH(e, true); + SUCCEED_RETURN(e); +} + +akerr_ErrorContext *test_physics_simulate_layer_mask(void) +{ + PREPARE_ERROR(e); + akgl_PhysicsBackend backend; + akgl_Actor *layer0 = NULL; + akgl_Actor *layer3 = NULL; + akgl_Character *char0 = NULL; + akgl_Character *char3 = NULL; + akgl_Iterator opflags; + + ATTEMPT { + CATCH(e, reset_sim_heap()); + memset(&backend, 0x00, sizeof(akgl_PhysicsBackend)); + CATCH(e, akgl_physics_init_null(&backend)); + + CATCH(e, make_sim_actor(&layer0, &char0, "onlayer0")); + CATCH(e, make_sim_actor(&layer3, &char3, "onlayer3")); + layer0->layer = 0; + layer3->layer = 3; + + // Masking to layer 3 processes only the layer 3 actor. + opflags.flags = AKGL_ITERATOR_OP_LAYERMASK; + opflags.layerid = 3; + stub_movement_calls = 0; + TEST_EXPECT_OK(e, akgl_physics_simulate(&backend, &opflags), "simulate masked to layer 3"); + TEST_ASSERT(e, stub_movement_calls == 1, + "layer mask 3 processed %d actors, expected 1", stub_movement_calls); + + // Masking to a layer with no actors processes nothing. + opflags.layerid = 7; + stub_movement_calls = 0; + TEST_EXPECT_OK(e, akgl_physics_simulate(&backend, &opflags), "simulate masked to an empty layer"); + TEST_ASSERT(e, stub_movement_calls == 0, + "empty layer mask processed %d actors, expected 0", stub_movement_calls); + + // Without the mask flag, layerid is ignored and both actors are processed. + opflags.flags = 0; + opflags.layerid = 7; + stub_movement_calls = 0; + TEST_EXPECT_OK(e, akgl_physics_simulate(&backend, &opflags), "simulate with the layer mask off"); + TEST_ASSERT(e, stub_movement_calls == 2, + "unmasked simulate processed %d actors, expected 2", stub_movement_calls); + + // A NULL iterator selects the documented defaults, which mask nothing. + stub_movement_calls = 0; + TEST_EXPECT_OK(e, akgl_physics_simulate(&backend, NULL), "simulate with NULL opflags"); + TEST_ASSERT(e, stub_movement_calls == 2, + "NULL opflags processed %d actors, expected 2", stub_movement_calls); + } CLEANUP { + } PROCESS(e) { + } FINISH(e, true); + SUCCEED_RETURN(e); +} + +akerr_ErrorContext *test_physics_simulate_thrust_and_clamp(void) +{ + PREPARE_ERROR(e); + akgl_PhysicsBackend backend; + akgl_Actor *actor = NULL; + akgl_Character *basechar = NULL; + + ATTEMPT { + CATCH(e, reset_sim_heap()); + memset(&backend, 0x00, sizeof(akgl_PhysicsBackend)); + CATCH(e, akgl_physics_init_null(&backend)); + CATCH(e, make_sim_actor(&actor, &basechar, "thruster")); + + // Thrust beyond the actor's max speed clamps to +sx, preserving sign. + actor->sx = 50.0f; actor->sy = 40.0f; actor->sz = 30.0f; + actor->tx = 500.0f; actor->ty = 400.0f; actor->tz = 300.0f; + TEST_EXPECT_OK(e, akgl_physics_simulate(&backend, NULL), "simulate with positive over-thrust"); + TEST_ASSERT_FEQ(e, actor->tx, 50.0f, "tx clamped to %f, expected 50", actor->tx); + TEST_ASSERT_FEQ(e, actor->ty, 40.0f, "ty clamped to %f, expected 40", actor->ty); + TEST_ASSERT_FEQ(e, actor->tz, 30.0f, "tz clamped to %f, expected 30", actor->tz); + + // Negative over-thrust clamps to -sx. + actor->tx = -500.0f; actor->ty = -400.0f; actor->tz = -300.0f; + TEST_EXPECT_OK(e, akgl_physics_simulate(&backend, NULL), "simulate with negative over-thrust"); + TEST_ASSERT_FEQ(e, actor->tx, -50.0f, "tx clamped to %f, expected -50", actor->tx); + TEST_ASSERT_FEQ(e, actor->ty, -40.0f, "ty clamped to %f, expected -40", actor->ty); + TEST_ASSERT_FEQ(e, actor->tz, -30.0f, "tz clamped to %f, expected -30", actor->tz); + + // Thrust inside the limit is left alone. + actor->tx = 10.0f; actor->ty = -10.0f; actor->tz = 5.0f; + TEST_EXPECT_OK(e, akgl_physics_simulate(&backend, NULL), "simulate with in-range thrust"); + TEST_ASSERT_FEQ(e, actor->tx, 10.0f, "in-range tx changed to %f", actor->tx); + TEST_ASSERT_FEQ(e, actor->ty, -10.0f, "in-range ty changed to %f", actor->ty); + TEST_ASSERT_FEQ(e, actor->tz, 5.0f, "in-range tz changed to %f", actor->tz); + + // Velocity is the sum of environmental force and thrust. + actor->ex = 3.0f; actor->ey = 4.0f; actor->ez = 5.0f; + actor->tx = 1.0f; actor->ty = 2.0f; actor->tz = 3.0f; + TEST_EXPECT_OK(e, akgl_physics_simulate(&backend, NULL), "simulate velocity composition"); + TEST_ASSERT_FEQ(e, actor->vx, 4.0f, "vx composed to %f, expected ex 3 + tx 1", actor->vx); + TEST_ASSERT_FEQ(e, actor->vy, 6.0f, "vy composed to %f, expected ey 4 + ty 2", actor->vy); + TEST_ASSERT_FEQ(e, actor->vz, 8.0f, "vz composed to %f, expected ez 5 + tz 3", actor->vz); + } CLEANUP { + } PROCESS(e) { + } FINISH(e, true); + SUCCEED_RETURN(e); +} + +akerr_ErrorContext *test_physics_simulate_drag(void) +{ + PREPARE_ERROR(e); + akgl_PhysicsBackend backend; + akgl_Actor *actor = NULL; + akgl_Character *basechar = NULL; + + ATTEMPT { + CATCH(e, reset_sim_heap()); + memset(&backend, 0x00, sizeof(akgl_PhysicsBackend)); + CATCH(e, akgl_physics_init_null(&backend)); + CATCH(e, make_sim_actor(&actor, &basechar, "dragged")); + + // Zero drag leaves environmental velocity untouched. + actor->ex = 100.0f; actor->ey = 100.0f; actor->ez = 100.0f; + actor->sx = 1000.0f; actor->sy = 1000.0f; actor->sz = 1000.0f; + TEST_EXPECT_OK(e, akgl_physics_simulate(&backend, NULL), "simulate with zero drag"); + TEST_ASSERT_FEQ(e, actor->ex, 100.0f, "zero drag changed ex to %f", actor->ex); + TEST_ASSERT_FEQ(e, actor->ey, 100.0f, "zero drag changed ey to %f", actor->ey); + TEST_ASSERT_FEQ(e, actor->ez, 100.0f, "zero drag changed ez to %f", actor->ez); + + // Nonzero drag bleeds off environmental velocity in proportion to dt. + // The simulator derives dt from the wall clock, so assert the direction + // and bounds of the change rather than an exact figure. + backend.drag_x = 0.5; + backend.drag_y = 0.5; + backend.drag_z = 0.5; + backend.gravity_time = SDL_GetTicksNS(); + SDL_Delay(20); + TEST_EXPECT_OK(e, akgl_physics_simulate(&backend, NULL), "simulate with drag applied"); + TEST_ASSERT(e, actor->ex < 100.0f && actor->ex > 0.0f, + "drag left ex at %f, expected a value between 0 and 100", actor->ex); + TEST_ASSERT(e, actor->ey < 100.0f && actor->ey > 0.0f, + "drag left ey at %f, expected a value between 0 and 100", actor->ey); + TEST_ASSERT(e, actor->ez < 100.0f && actor->ez > 0.0f, + "drag left ez at %f, expected a value between 0 and 100", actor->ez); + } CLEANUP { + } PROCESS(e) { + } FINISH(e, true); + SUCCEED_RETURN(e); +} + +akerr_ErrorContext *test_physics_simulate_movement_states(void) +{ + PREPARE_ERROR(e); + akgl_PhysicsBackend backend; + akgl_Actor *actor = NULL; + akgl_Character *basechar = NULL; + + ATTEMPT { + CATCH(e, reset_sim_heap()); + memset(&backend, 0x00, sizeof(akgl_PhysicsBackend)); + CATCH(e, akgl_physics_init_null(&backend)); + CATCH(e, make_sim_actor(&actor, &basechar, "mover")); + + actor->sx = 1000.0f; actor->sy = 1000.0f; actor->sz = 1000.0f; + actor->ax = 10.0f; actor->ay = 20.0f; + + // An idle actor accumulates no thrust on either axis. + actor->state = 0; + actor->tx = 0; actor->ty = 0; + backend.gravity_time = SDL_GetTicksNS(); + SDL_Delay(10); + TEST_EXPECT_OK(e, akgl_physics_simulate(&backend, NULL), "simulate an idle actor"); + TEST_ASSERT_FEQ(e, actor->tx, 0.0f, "idle actor accumulated tx %f", actor->tx); + TEST_ASSERT_FEQ(e, actor->ty, 0.0f, "idle actor accumulated ty %f", actor->ty); + + // Moving horizontally accumulates thrust on X only. + actor->state = AKGL_ACTOR_STATE_MOVING_LEFT; + actor->tx = 0; actor->ty = 0; + backend.gravity_time = SDL_GetTicksNS(); + SDL_Delay(10); + TEST_EXPECT_OK(e, akgl_physics_simulate(&backend, NULL), "simulate a horizontally moving actor"); + TEST_ASSERT(e, actor->tx > 0.0f, "MOVING_LEFT accumulated tx %f, expected a positive value", actor->tx); + TEST_ASSERT_FEQ(e, actor->ty, 0.0f, "MOVING_LEFT leaked thrust into ty (%f)", actor->ty); + + // Moving vertically accumulates thrust on Y only. + actor->state = AKGL_ACTOR_STATE_MOVING_DOWN; + actor->tx = 0; actor->ty = 0; + backend.gravity_time = SDL_GetTicksNS(); + SDL_Delay(10); + TEST_EXPECT_OK(e, akgl_physics_simulate(&backend, NULL), "simulate a vertically moving actor"); + TEST_ASSERT(e, actor->ty > 0.0f, "MOVING_DOWN accumulated ty %f, expected a positive value", actor->ty); + TEST_ASSERT_FEQ(e, actor->tx, 0.0f, "MOVING_DOWN leaked thrust into tx (%f)", actor->tx); + + // The right and up states drive the same accumulators. + actor->state = (AKGL_ACTOR_STATE_MOVING_RIGHT | AKGL_ACTOR_STATE_MOVING_UP); + actor->tx = 0; actor->ty = 0; + backend.gravity_time = SDL_GetTicksNS(); + SDL_Delay(10); + TEST_EXPECT_OK(e, akgl_physics_simulate(&backend, NULL), "simulate a diagonally moving actor"); + TEST_ASSERT(e, actor->tx > 0.0f, "MOVING_RIGHT accumulated tx %f, expected a positive value", actor->tx); + TEST_ASSERT(e, actor->ty > 0.0f, "MOVING_UP accumulated ty %f, expected a positive value", actor->ty); + } CLEANUP { + } PROCESS(e) { + } FINISH(e, true); + SUCCEED_RETURN(e); +} + +akerr_ErrorContext *test_physics_simulate_logic_interrupt(void) +{ + PREPARE_ERROR(e); + akgl_PhysicsBackend backend; + akgl_Actor *first = NULL; + akgl_Actor *second = NULL; + akgl_Character *char1 = NULL; + akgl_Character *char2 = NULL; + + ATTEMPT { + CATCH(e, reset_sim_heap()); + memset(&backend, 0x00, sizeof(akgl_PhysicsBackend)); + CATCH(e, akgl_physics_init_null(&backend)); + + CATCH(e, make_sim_actor(&first, &char1, "interrupter")); + CATCH(e, make_sim_actor(&second, &char2, "follower")); + first->movementlogicfunc = &stub_movement_interrupt; + + // AKGL_ERR_LOGICINTERRUPT means "skip me this frame", not "abort the frame", + // so the second actor must still be reached. + stub_movement_calls = 0; + TEST_EXPECT_OK(e, akgl_physics_simulate(&backend, NULL), + "simulate must swallow AKGL_ERR_LOGICINTERRUPT"); + TEST_ASSERT(e, stub_movement_calls == 2, + "an interrupting actor stopped the loop after %d of 2 actors", + stub_movement_calls); + + // An unrelated error is not swallowed. + CATCH(e, reset_sim_heap()); + CATCH(e, make_sim_actor(&first, &char1, "failer")); + first->movementlogicfunc = &stub_movement_error; + TEST_EXPECT_STATUS(e, AKERR_VALUE, akgl_physics_simulate(&backend, NULL), + "simulate must propagate a non-interrupt movement error"); + } CLEANUP { + } PROCESS(e) { + } FINISH(e, true); + SUCCEED_RETURN(e); +} + +akerr_ErrorContext *test_physics_simulate_nullpointers(void) +{ + PREPARE_ERROR(e); + akgl_PhysicsBackend backend; + + ATTEMPT { + CATCH(e, reset_sim_heap()); + + // A backend with no move function cannot simulate. + memset(&backend, 0x00, sizeof(akgl_PhysicsBackend)); + TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_physics_simulate(&backend, NULL), + "simulate with no move function installed"); + + // A NULL backend must be reported, not dereferenced. + TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_physics_simulate(NULL, NULL), + "simulate with NULL self"); + } CLEANUP { + } PROCESS(e) { + } FINISH(e, true); + SUCCEED_RETURN(e); +} + +int main(void) +{ + PREPARE_ERROR(errctx); + + SDL_SetHint(SDL_HINT_VIDEO_DRIVER, "dummy"); + SDL_SetHint(SDL_HINT_AUDIO_DRIVER, "dummy"); + + ATTEMPT { + CATCH(errctx, akgl_heap_init()); + CATCH(errctx, akgl_registry_init()); + CATCH(errctx, akgl_registry_init_properties()); + + CATCH(errctx, test_physics_init_null()); + CATCH(errctx, test_physics_null_backend_is_inert()); + CATCH(errctx, test_physics_arcade_gravity()); + CATCH(errctx, test_physics_arcade_move()); + CATCH(errctx, test_physics_arcade_collide_unimplemented()); + CATCH(errctx, test_physics_factory()); + CATCH(errctx, test_physics_init_arcade_properties()); + CATCH(errctx, test_physics_simulate_skips_inactive()); + CATCH(errctx, test_physics_simulate_children_follow_parents()); + CATCH(errctx, test_physics_simulate_layer_mask()); + CATCH(errctx, test_physics_simulate_thrust_and_clamp()); + CATCH(errctx, test_physics_simulate_drag()); + CATCH(errctx, test_physics_simulate_movement_states()); + CATCH(errctx, test_physics_simulate_logic_interrupt()); + CATCH(errctx, test_physics_simulate_nullpointers()); + } CLEANUP { + } PROCESS(errctx) { + } FINISH_NORETURN(errctx); +} diff --git a/tests/testutil.h b/tests/testutil.h new file mode 100644 index 0000000..2e59ded --- /dev/null +++ b/tests/testutil.h @@ -0,0 +1,99 @@ +/** + * @file testutil.h + * @brief Shared assertion helpers and headless bootstrap for the libakgl test suites. + * + * The akerror ATTEMPT/CATCH/PROCESS/FINISH macros are verbose when what a test + * wants to say is "this call must fail with exactly this status". These helpers + * wrap that pattern. + * + * All of the TEST_* assertion macros expand to a `break` on failure, so they + * must be used directly inside an ATTEMPT block. Inside a `for` or `while` + * nested in an ATTEMPT they would break the inner loop instead; use + * TEST_ASSERT_FLAG in that case and check the flag after the loop. + */ + +#ifndef _AKGL_TESTUTIL_H_ +#define _AKGL_TESTUTIL_H_ + +#include +#include +#include +#include + +/** @brief Fail the enclosing ATTEMPT block unless @p cond holds. */ +#define TEST_ASSERT(e, cond, ...) \ + if ( ! (cond) ) { \ + FAIL_BREAK(e, AKGL_ERR_BEHAVIOR, __VA_ARGS__); \ + } + +/** + * @brief Run @p stmt and require that it reports exactly @p expected. + * + * Releases whatever context @p stmt returns, so a test can assert many failure + * paths in a row without draining AKERR_ARRAY_ERROR. Pass 0 for @p expected to + * require success. + */ +#define TEST_EXPECT_STATUS(e, expected, stmt, desc) \ + { \ + akerr_ErrorContext *__tec = (stmt); \ + int __tst = ( __tec == NULL ) ? 0 : __tec->status; \ + if ( __tec != NULL ) { \ + __tec->handled = true; \ + __tec = akerr_release_error(__tec); \ + } \ + if ( __tst != (expected) ) { \ + FAIL_BREAK( \ + e, \ + AKGL_ERR_BEHAVIOR, \ + "%s: expected status %d (%s), got %d (%s)", \ + desc, \ + (int)(expected), \ + akerr_name_for_status((int)(expected), NULL), \ + __tst, \ + akerr_name_for_status(__tst, NULL)); \ + } \ + } + +/** @brief Require that @p stmt succeeds, reporting @p desc if it does not. */ +#define TEST_EXPECT_OK(e, stmt, desc) \ + TEST_EXPECT_STATUS(e, 0, stmt, desc) + +/** + * @brief Require that @p stmt fails, without pinning which status it reports. + * + * For paths that are delegated to a dependency, where the exact status is that + * dependency's business and asserting it would make the test brittle. + */ +#define TEST_EXPECT_ANY_ERROR(e, stmt, desc) \ + { \ + akerr_ErrorContext *__tec = (stmt); \ + int __tst = ( __tec == NULL ) ? 0 : __tec->status; \ + if ( __tec != NULL ) { \ + __tec->handled = true; \ + __tec = akerr_release_error(__tec); \ + } \ + if ( __tst == 0 ) { \ + FAIL_BREAK(e, AKGL_ERR_BEHAVIOR, "%s: expected a failure, got success", desc); \ + } \ + } + +/** @brief Require that two floats agree to within AKGL_TEST_EPSILON. */ +#define AKGL_TEST_EPSILON 0.0001f + +#define TEST_ASSERT_FEQ(e, actual, expected, ...) \ + if ( fabsf((float)(actual) - (float)(expected)) > AKGL_TEST_EPSILON ) { \ + FAIL_BREAK(e, AKGL_ERR_BEHAVIOR, __VA_ARGS__); \ + } + +/** + * @brief Record a failure into a flag instead of breaking. + * + * For assertions inside a loop nested in an ATTEMPT block, where `break` would + * only leave the loop. + */ +#define TEST_ASSERT_FLAG(flag, cond) \ + if ( ! (cond) ) { \ + (flag) = false; \ + } + +#endif // _AKGL_TESTUTIL_H_