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

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);
}