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>
422 lines
14 KiB
C
422 lines
14 KiB
C
/**
|
|
* @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);
|
|
}
|