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) <noreply@anthropic.com>
This commit is contained in:
2026-07-30 02:03:21 -04:00
parent 6f6bd2d563
commit 22162db2da
11 changed files with 2757 additions and 43 deletions

View File

@@ -119,27 +119,34 @@ add_library(akgl SHARED
add_library(akgl::akgl ALIAS akgl) add_library(akgl::akgl ALIAS akgl)
add_executable(charviewer util/charviewer.c) 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_executable(test_semver_unit deps/semver/semver_unit.c)
add_test(NAME actor COMMAND test_actor)
add_test(NAME bitmasks COMMAND test_bitmasks) # Every suite here is a standalone C program named tests/<name>.c, built as
add_test(NAME character COMMAND test_character) # test_<name> and registered with CTest under <name>.
add_test(NAME registry COMMAND test_registry) set(AKGL_TEST_SUITES
add_test(NAME sprite COMMAND test_sprite) actor
add_test(NAME staticstring COMMAND test_staticstring) bitmasks
add_test(NAME tilemap COMMAND test_tilemap) character
add_test(NAME util COMMAND test_util) 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) add_test(NAME semver_unit COMMAND test_semver_unit)
set_tests_properties( 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 PROPERTIES WORKING_DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}/tests" TIMEOUT 30
) )
@@ -164,8 +171,10 @@ if(AKGL_COVERAGE)
--delete --delete
) )
set_tests_properties(coverage_reset PROPERTIES FIXTURES_SETUP akgl_coverage) 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( set_tests_properties(
actor bitmasks character registry sprite staticstring tilemap util semver_unit ${AKGL_TEST_SUITES} semver_unit
PROPERTIES FIXTURES_REQUIRED akgl_coverage PROPERTIES FIXTURES_REQUIRED akgl_coverage
) )
@@ -198,17 +207,63 @@ target_link_libraries(akgl
jansson::jansson 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) foreach(suite IN LISTS AKGL_TEST_SUITES)
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_${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_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_include_directories(test_${suite} PRIVATE "${CMAKE_CURRENT_SOURCE_DIR}/tests")
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) endforeach()
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)
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) 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
"$<TARGET_FILE_DIR:SDL3::SDL3>"
"$<TARGET_FILE_DIR:SDL3_image::SDL3_image>"
"$<TARGET_FILE_DIR:SDL3_ttf::SDL3_ttf>"
"$<TARGET_FILE_DIR:SDL3_mixer::SDL3_mixer>"
"$<TARGET_FILE_DIR:akerror::akerror>"
"$<TARGET_FILE_DIR:akstdlib::akstdlib>"
)
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 # 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 # source change at a time, and verifies that the passing tests detect it. The
# intentionally failing character test is excluded by the harness. # intentionally failing character test is excluded by the harness.

View File

@@ -127,7 +127,7 @@ akerr_ErrorContext *akgl_actor_logic_movement(akgl_Actor *actor, float32_t dt)
{ {
PREPARE_ERROR(errctx); PREPARE_ERROR(errctx);
FAIL_ZERO_RETURN(errctx, actor, AKERR_NULLPOINTER, "actor"); 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->sx = actor->basechar->sx;
actor->sy = actor->basechar->sy; actor->sy = actor->basechar->sy;
actor->sz = actor->basechar->sz; 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); PREPARE_ERROR(errctx);
FAIL_ZERO_RETURN(errctx, obj, AKERR_NULLPOINTER, "NULL actor"); FAIL_ZERO_RETURN(errctx, obj, AKERR_NULLPOINTER, "NULL actor");
FAIL_ZERO_RETURN(errctx, event, AKERR_NULLPOINTER, "NULL event"); 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); obj->ay = -(obj->basechar->ay);
AKGL_BITMASK_DEL(obj->state, (AKGL_ACTOR_STATE_FACE_ALL | AKGL_ACTOR_STATE_MOVING_ALL)); 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)); 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); PREPARE_ERROR(errctx);
FAIL_ZERO_RETURN(errctx, obj, AKERR_NULLPOINTER, "NULL actor"); FAIL_ZERO_RETURN(errctx, obj, AKERR_NULLPOINTER, "NULL actor");
FAIL_ZERO_RETURN(errctx, event, AKERR_NULLPOINTER, "NULL event"); 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); //SDL_Log("event %d (button %d / key %d) moves actor down", event->type, event->gbutton.which, event->key.key);
obj->ay = obj->basechar->ay; obj->ay = obj->basechar->ay;
AKGL_BITMASK_DEL(obj->state, (AKGL_ACTOR_STATE_FACE_ALL | AKGL_ACTOR_STATE_MOVING_ALL)); AKGL_BITMASK_DEL(obj->state, (AKGL_ACTOR_STATE_FACE_ALL | AKGL_ACTOR_STATE_MOVING_ALL));

View File

@@ -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) akerr_ErrorContext AKERR_NOIGNORE *akgl_game_save_actors(FILE *fp)
{ {
PREPARE_ERROR(e); 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 { ATTEMPT {
FAIL_ZERO_BREAK(e, fp, AKERR_NULLPOINTER, "NULL file pointer"); FAIL_ZERO_BREAK(e, fp, AKERR_NULLPOINTER, "NULL file pointer");
// write the actor name pointer table // write the actor name pointer table
@@ -272,29 +281,29 @@ akerr_ErrorContext AKERR_NOIGNORE *akgl_game_save_actors(FILE *fp)
AKGL_REGISTRY_ACTOR, AKGL_REGISTRY_ACTOR,
&akgl_game_save_actorname_iterator, &akgl_game_save_actorname_iterator,
(void *)fp); (void *)fp);
CATCH(e, aksl_fwrite((void *)&nullval, 1, AKGL_ACTOR_MAX_NAME_LENGTH, fp)); CATCH(e, aksl_fwrite((void *)&nullbuf, 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, sizeof(akgl_Actor *), fp));
// write the sprite name pointer table // write the sprite name pointer table
SDL_EnumerateProperties( SDL_EnumerateProperties(
AKGL_REGISTRY_SPRITE, AKGL_REGISTRY_SPRITE,
&akgl_game_save_spritename_iterator, &akgl_game_save_spritename_iterator,
(void *)fp); (void *)fp);
CATCH(e, aksl_fwrite((void *)&nullval, 1, AKGL_SPRITE_MAX_NAME_LENGTH, fp)); CATCH(e, aksl_fwrite((void *)&nullbuf, 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, sizeof(akgl_Sprite *), fp));
// write the spritesheet name pointer table // write the spritesheet name pointer table
SDL_EnumerateProperties( SDL_EnumerateProperties(
AKGL_REGISTRY_SPRITESHEET, AKGL_REGISTRY_SPRITESHEET,
&akgl_game_save_spritesheetname_iterator, &akgl_game_save_spritesheetname_iterator,
(void *)fp); (void *)fp);
CATCH(e, aksl_fwrite((void *)&nullval, 1, AKGL_SPRITE_SHEET_MAX_FILENAME_LENGTH, fp)); CATCH(e, aksl_fwrite((void *)&nullbuf, 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, sizeof(akgl_SpriteSheet *), fp));
// write the character name pointer table // write the character name pointer table
SDL_EnumerateProperties( SDL_EnumerateProperties(
AKGL_REGISTRY_CHARACTER, AKGL_REGISTRY_CHARACTER,
&akgl_game_save_charactername_iterator, &akgl_game_save_charactername_iterator,
(void *)fp); (void *)fp);
CATCH(e, aksl_fwrite((void *)&nullval, 1, AKGL_SPRITE_MAX_CHARACTER_NAME_LENGTH, fp)); CATCH(e, aksl_fwrite((void *)&nullbuf, 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, sizeof(akgl_Character *), fp));
} CLEANUP { } CLEANUP {
} PROCESS(e) { } PROCESS(e) {
} FINISH(e, true); } 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_fopen(fpath, "wb", &fp));
CATCH(e, aksl_fwrite(&game, 1, sizeof(akgl_Game), fp)); CATCH(e, aksl_fwrite(&game, 1, sizeof(akgl_Game), fp));
CATCH(e, akgl_game_save_actors(fp)); CATCH(e, akgl_game_save_actors(fp));
} PROCESS(e) {
} CLEANUP { } 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 ) if ( fp != NULL )
fclose(fp); fclose(fp);
} PROCESS(e) {
} FINISH(e, true); } FINISH(e, true);
SUCCEED_RETURN(e); // SUCCEED_NORETURN if in main(). 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 ptrstring[32];
char objname[namelength]; char objname[namelength];
int retval = 0; int retval = 0;
bool done = false;
PREPARE_ERROR(e); 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 *)&objname, 1, namelength, fp));
CATCH(e, aksl_fread((void *)&ptr, 1, ptrlength, fp)); CATCH(e, aksl_fread((void *)&ptr, 1, ptrlength, fp));
// End of the map // End of the map
if ( ptr == 0x00 && objname[0] == 0x00 ) { if ( ptr == 0x00 && objname[0] == 0x00 ) {
done = true;
break; break;
} }
// The map allows us to say "Object X has a reference to object Y at // 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, map,
ptrstring, ptrstring,
SDL_GetPointerProperty(registry, objname, NULL)); SDL_GetPointerProperty(registry, objname, NULL));
} CLEANUP {
} PROCESS(e) {
} FINISH(e, true);
}; };
SUCCEED_RETURN(e); SUCCEED_RETURN(e);
} }

View File

@@ -128,13 +128,18 @@ akerr_ErrorContext AKERR_NOIGNORE *akgl_physics_simulate(akgl_PhysicsBackend *se
.flags = 0, .flags = 0,
.layerid = 0 .layerid = 0
}; };
SDL_Time curtime = SDL_GetTicksNS(); SDL_Time curtime = 0;
float32_t dt = (float32_t)(curtime - self->gravity_time) / (float32_t)AKGL_TIME_ONESEC_NS; float32_t dt = 0;
akgl_Actor *actor = NULL; akgl_Actor *actor = NULL;
FAIL_ZERO_RETURN(e, self, AKERR_NULLPOINTER, "self"); FAIL_ZERO_RETURN(e, self, AKERR_NULLPOINTER, "self");
FAIL_ZERO_RETURN(e, self->move, AKERR_NULLPOINTER, "self->move"); 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 ) { if ( opflags == NULL ) {
opflags = &defflags; opflags = &defflags;
} }

View File

@@ -13,8 +13,13 @@
#include <akgl/iterator.h> #include <akgl/iterator.h>
#include <akgl/registry.h> #include <akgl/registry.h>
#include <akgl/actor.h> #include <akgl/actor.h>
#include <akgl/character.h>
#include <akgl/sprite.h>
#include <akgl/game.h>
#include <akgl/heap.h> #include <akgl/heap.h>
#include "testutil.h"
int UNHANDLED_ERROR_BEHAVIOR; int UNHANDLED_ERROR_BEHAVIOR;
akerr_ErrorContext *unhandled_error_context; akerr_ErrorContext *unhandled_error_context;
@@ -308,12 +313,595 @@ _test_actor_addchild_heaprelease_cleanup:
SUCCEED_RETURN(errctx); 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) int main(void)
{ {
akgl_actor_updated = 0; akgl_actor_updated = 0;
akgl_actor_rendered = 0; akgl_actor_rendered = 0;
UNHANDLED_ERROR_BEHAVIOR = UNHANDLED_ERROR_EXIT; UNHANDLED_ERROR_BEHAVIOR = UNHANDLED_ERROR_EXIT;
PREPARE_ERROR(errctx); PREPARE_ERROR(errctx);
SDL_SetHint(SDL_HINT_VIDEO_DRIVER, "dummy");
SDL_SetHint(SDL_HINT_AUDIO_DRIVER, "dummy");
ATTEMPT { ATTEMPT {
CATCH(errctx, akgl_registry_init_actor()); CATCH(errctx, akgl_registry_init_actor());
CATCH(errctx, akgl_registry_init_sprite()); CATCH(errctx, akgl_registry_init_sprite());
@@ -325,6 +913,16 @@ int main(void)
CATCH(errctx, test_registry_actor_iterator_updaterender()); CATCH(errctx, test_registry_actor_iterator_updaterender());
CATCH(errctx, test_akgl_actor_set_character()); CATCH(errctx, test_akgl_actor_set_character());
CATCH(errctx, test_actor_manage_children()); 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 { } CLEANUP {
} PROCESS(errctx) { } PROCESS(errctx) {
} FINISH_NORETURN(errctx); } FINISH_NORETURN(errctx);

View File

@@ -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 }]
}

421
tests/game.c Normal file
View File

@@ -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 <SDL3/SDL.h>
#include <stdio.h>
#include <string.h>
#include <unistd.h>
#include <akerror.h>
#include <akgl/error.h>
#include <akgl/game.h>
#include <akgl/actor.h>
#include <akgl/character.h>
#include <akgl/heap.h>
#include <akgl/registry.h>
#include <akgl/sprite.h>
#include <akgl/staticstring.h>
#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);
}

378
tests/heap.c Normal file
View File

@@ -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 <SDL3/SDL.h>
#include <string.h>
#include <akerror.h>
#include <akgl/error.h>
#include <akgl/heap.h>
#include <akgl/actor.h>
#include <akgl/character.h>
#include <akgl/sprite.h>
#include <akgl/registry.h>
#include <akgl/staticstring.h>
#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);
}

366
tests/json_helpers.c Normal file
View File

@@ -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 <SDL3/SDL.h>
#include <jansson.h>
#include <string.h>
#include <akerror.h>
#include <akgl/error.h>
#include <akgl/json_helpers.h>
#include <akgl/heap.h>
#include <akgl/registry.h>
#include <akgl/staticstring.h>
#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);
}

747
tests/physics.c Normal file
View File

@@ -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 <SDL3/SDL.h>
#include <string.h>
#include <akerror.h>
#include <akgl/error.h>
#include <akgl/physics.h>
#include <akgl/actor.h>
#include <akgl/character.h>
#include <akgl/game.h>
#include <akgl/heap.h>
#include <akgl/registry.h>
#include <akgl/staticstring.h>
#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);
}

99
tests/testutil.h Normal file
View File

@@ -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 <math.h>
#include <SDL3/SDL.h>
#include <akerror.h>
#include <akgl/error.h>
/** @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_