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

@@ -263,8 +263,17 @@ void akgl_game_save_charactername_iterator(void *userdata, SDL_PropertiesID prop
akerr_ErrorContext AKERR_NOIGNORE *akgl_game_save_actors(FILE *fp)
{
PREPARE_ERROR(e);
char nullval = 0x00;
// Each name table ends with a zeroed name field and a zeroed pointer, which
// is what akgl_game_load_objectnamemap() looks for to stop reading. The
// terminator has to come from a buffer at least as long as the longest name
// field: writing N bytes from the address of a single char would emit N-1
// bytes of whatever happened to follow it on the stack, which both leaks
// stack contents into the save file and produces a sentinel the loader
// cannot recognize.
char nullbuf[AKGL_SPRITE_SHEET_MAX_FILENAME_LENGTH];
memset((void *)&nullbuf, 0x00, sizeof(nullbuf));
ATTEMPT {
FAIL_ZERO_BREAK(e, fp, AKERR_NULLPOINTER, "NULL file pointer");
// write the actor name pointer table
@@ -272,29 +281,29 @@ akerr_ErrorContext AKERR_NOIGNORE *akgl_game_save_actors(FILE *fp)
AKGL_REGISTRY_ACTOR,
&akgl_game_save_actorname_iterator,
(void *)fp);
CATCH(e, aksl_fwrite((void *)&nullval, 1, AKGL_ACTOR_MAX_NAME_LENGTH, fp));
CATCH(e, aksl_fwrite((void *)&nullval, 1, sizeof(akgl_Actor *), fp));
CATCH(e, aksl_fwrite((void *)&nullbuf, 1, AKGL_ACTOR_MAX_NAME_LENGTH, fp));
CATCH(e, aksl_fwrite((void *)&nullbuf, 1, sizeof(akgl_Actor *), fp));
// write the sprite name pointer table
SDL_EnumerateProperties(
AKGL_REGISTRY_SPRITE,
&akgl_game_save_spritename_iterator,
(void *)fp);
CATCH(e, aksl_fwrite((void *)&nullval, 1, AKGL_SPRITE_MAX_NAME_LENGTH, fp));
CATCH(e, aksl_fwrite((void *)&nullval, 1, sizeof(akgl_Sprite *), fp));
CATCH(e, aksl_fwrite((void *)&nullbuf, 1, AKGL_SPRITE_MAX_NAME_LENGTH, fp));
CATCH(e, aksl_fwrite((void *)&nullbuf, 1, sizeof(akgl_Sprite *), fp));
// write the spritesheet name pointer table
SDL_EnumerateProperties(
AKGL_REGISTRY_SPRITESHEET,
&akgl_game_save_spritesheetname_iterator,
(void *)fp);
CATCH(e, aksl_fwrite((void *)&nullval, 1, AKGL_SPRITE_SHEET_MAX_FILENAME_LENGTH, fp));
CATCH(e, aksl_fwrite((void *)&nullval, 1, sizeof(akgl_SpriteSheet *), fp));
CATCH(e, aksl_fwrite((void *)&nullbuf, 1, AKGL_SPRITE_SHEET_MAX_FILENAME_LENGTH, fp));
CATCH(e, aksl_fwrite((void *)&nullbuf, 1, sizeof(akgl_SpriteSheet *), fp));
// write the character name pointer table
SDL_EnumerateProperties(
AKGL_REGISTRY_CHARACTER,
&akgl_game_save_charactername_iterator,
(void *)fp);
CATCH(e, aksl_fwrite((void *)&nullval, 1, AKGL_SPRITE_MAX_CHARACTER_NAME_LENGTH, fp));
CATCH(e, aksl_fwrite((void *)&nullval, 1, sizeof(akgl_Character *), fp));
CATCH(e, aksl_fwrite((void *)&nullbuf, 1, AKGL_SPRITE_MAX_CHARACTER_NAME_LENGTH, fp));
CATCH(e, aksl_fwrite((void *)&nullbuf, 1, sizeof(akgl_Character *), fp));
} CLEANUP {
} PROCESS(e) {
} FINISH(e, true);
@@ -311,10 +320,14 @@ akerr_ErrorContext AKERR_NOIGNORE *akgl_game_save(char *fpath)
CATCH(e, aksl_fopen(fpath, "wb", &fp));
CATCH(e, aksl_fwrite(&game, 1, sizeof(akgl_Game), fp));
CATCH(e, akgl_game_save_actors(fp));
} PROCESS(e) {
} CLEANUP {
// CLEANUP must precede PROCESS: with the two transposed, the fclose
// lands inside the PROCESS switch and only runs when an error context
// exists and reports success, so an ordinary save never flushed or
// closed its stream.
if ( fp != NULL )
fclose(fp);
} PROCESS(e) {
} FINISH(e, true);
SUCCEED_RETURN(e); // SUCCEED_NORETURN if in main().
}
@@ -335,13 +348,21 @@ akerr_ErrorContext AKERR_NOIGNORE *akgl_game_load_objectnamemap(FILE *fp, SDL_Pr
char ptrstring[32];
char objname[namelength];
int retval = 0;
bool done = false;
PREPARE_ERROR(e);
while ( 1 ) {
// The ATTEMPT block sits inside the loop on purpose. CATCH reports a
// failure by breaking, and a break binds to the innermost enclosing switch
// or loop, so a CATCH written directly inside `while` would leave the loop
// and then fall through to SUCCEED_RETURN -- reporting a truncated or
// corrupt name table as a successful load.
while ( done == false ) {
ATTEMPT {
CATCH(e, aksl_fread((void *)&objname, 1, namelength, fp));
CATCH(e, aksl_fread((void *)&ptr, 1, ptrlength, fp));
// End of the map
if ( ptr == 0x00 && objname[0] == 0x00 ) {
done = true;
break;
}
// The map allows us to say "Object X has a reference to object Y at
@@ -358,6 +379,9 @@ akerr_ErrorContext AKERR_NOIGNORE *akgl_game_load_objectnamemap(FILE *fp, SDL_Pr
map,
ptrstring,
SDL_GetPointerProperty(registry, objname, NULL));
} CLEANUP {
} PROCESS(e) {
} FINISH(e, true);
};
SUCCEED_RETURN(e);
}