/** * @file game.c * @brief Unit tests for savegame serialization, version gating, and frame accounting. * * akgl_game_init() and akgl_game_update() need a window and a live frame loop, * so they are out of scope here. Everything else in the game module is either * pure logic or file IO and is covered below. */ #include #include #include #include #include #include #include #include #include #include #include #include #include #include "testutil.h" /* * Exported by src/game.c but not declared in akgl/game.h, because they are * savegame internals rather than part of the supported surface. Declared here * so the tests can reach them without widening the public API. */ akerr_ErrorContext AKERR_NOIGNORE *akgl_game_save_actors(FILE *fp); akerr_ErrorContext AKERR_NOIGNORE *akgl_game_load_versioncmp(char *versiontype, char *newversion, char *curversion); /** @brief Scratch savegame path, created and removed by the tests that use it. */ static char savepath[] = "akgl_test_savegame.bin"; /** @brief Scratch path for deliberately malformed savegames. */ static char truncatedpath[] = "akgl_test_truncated.bin"; /** @brief Populate the process-wide game record with a valid identity. */ static void set_game_identity(void) { memset(&game, 0x00, sizeof(akgl_Game)); strncpy((char *)&game.libversion, AKGL_VERSION, 31); strncpy((char *)&game.version, "1.2.3", 31); strncpy((char *)&game.name, "libakgl test game", 255); strncpy((char *)&game.uri, "https://example.invalid/akgl-test", 255); } akerr_ErrorContext *test_game_load_versioncmp_matching(void) { PREPARE_ERROR(e); ATTEMPT { TEST_EXPECT_OK(e, akgl_game_load_versioncmp("library", "1.2.3", "1.2.3"), "identical versions must be compatible"); TEST_EXPECT_OK(e, akgl_game_load_versioncmp("library", "0.1.0", "0.1.0"), "identical zero-major versions must be compatible"); TEST_EXPECT_OK(e, akgl_game_load_versioncmp("game", "10.20.30", "10.20.30"), "identical multi-digit versions must be compatible"); } CLEANUP { } PROCESS(e) { } FINISH(e, true); SUCCEED_RETURN(e); } akerr_ErrorContext *test_game_load_versioncmp_mismatched(void) { PREPARE_ERROR(e); ATTEMPT { // A savegame from a different build is refused on any component. TEST_EXPECT_STATUS(e, AKERR_API, akgl_game_load_versioncmp("library", "2.2.3", "1.2.3"), "a differing major version must be refused"); TEST_EXPECT_STATUS(e, AKERR_API, akgl_game_load_versioncmp("library", "1.3.3", "1.2.3"), "a differing minor version must be refused"); TEST_EXPECT_STATUS(e, AKERR_API, akgl_game_load_versioncmp("library", "1.2.4", "1.2.3"), "a differing patch version must be refused"); // Unparseable versions are a value error, distinct from a mismatch. TEST_EXPECT_STATUS(e, AKERR_VALUE, akgl_game_load_versioncmp("library", "1.2.3", "not-a-version"), "an unparseable current version must be refused"); TEST_EXPECT_STATUS(e, AKERR_VALUE, akgl_game_load_versioncmp("library", "not-a-version", "1.2.3"), "an unparseable savegame version must be refused"); TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_game_load_versioncmp(NULL, "1.2.3", "1.2.3"), "versioncmp with a NULL version type"); TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_game_load_versioncmp("library", NULL, "1.2.3"), "versioncmp with a NULL new version"); TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_game_load_versioncmp("library", "1.2.3", NULL), "versioncmp with a NULL current version"); } CLEANUP { } PROCESS(e) { } FINISH(e, true); SUCCEED_RETURN(e); } akerr_ErrorContext *test_game_load_versioncmp_releases_semver(void) { PREPARE_ERROR(e); int i = 0; bool leaked = false; ATTEMPT { // semver_parse allocates; the comparison must free both sides on the // success and the failure path or a long session will drift. for ( i = 0; i < 2000; i++ ) { akerr_ErrorContext *result = akgl_game_load_versioncmp("library", "1.2.3", "1.2.3"); if ( result != NULL ) { result->handled = true; result = akerr_release_error(result); leaked = true; } result = akgl_game_load_versioncmp("library", "9.9.9", "1.2.3"); if ( result != NULL ) { result->handled = true; result = akerr_release_error(result); } } TEST_ASSERT(e, leaked == false, "a matching version comparison started failing partway through a long run"); } CLEANUP { } PROCESS(e) { } FINISH(e, true); SUCCEED_RETURN(e); } akerr_ErrorContext *test_game_save_roundtrip(void) { PREPARE_ERROR(e); akgl_Game expected; ATTEMPT { CATCH(e, akgl_registry_init()); CATCH(e, akgl_heap_init()); set_game_identity(); game.fps = 60; game.framesSinceUpdate = 7; memcpy(&expected, &game, sizeof(akgl_Game)); TEST_EXPECT_OK(e, akgl_game_save((char *)&savepath), "saving a game"); // Scribble over the live state so a successful load has to restore it. game.fps = 0; game.framesSinceUpdate = 0; TEST_EXPECT_OK(e, akgl_game_load((char *)&savepath), "loading the game back"); TEST_ASSERT(e, game.fps == 60, "fps restored as %d, expected 60", game.fps); TEST_ASSERT(e, game.framesSinceUpdate == 7, "framesSinceUpdate restored as %d, expected 7", game.framesSinceUpdate); TEST_ASSERT(e, strncmp((char *)&game.name, (char *)&expected.name, 256) == 0, "the game name was not preserved across a save and load"); TEST_ASSERT(e, strncmp((char *)&game.version, (char *)&expected.version, 32) == 0, "the game version was not preserved across a save and load"); } CLEANUP { unlink((char *)&savepath); } PROCESS(e) { } FINISH(e, true); SUCCEED_RETURN(e); } akerr_ErrorContext *test_game_load_rejects_foreign_saves(void) { PREPARE_ERROR(e); ATTEMPT { CATCH(e, akgl_registry_init()); CATCH(e, akgl_heap_init()); // A save written by a different game must not load into this one. set_game_identity(); CATCH(e, akgl_game_save((char *)&savepath)); strncpy((char *)&game.name, "a completely different game", 255); TEST_EXPECT_STATUS(e, AKERR_API, akgl_game_load((char *)&savepath), "a savegame with a foreign game name must be refused"); unlink((char *)&savepath); // Same for a differing URI. set_game_identity(); CATCH(e, akgl_game_save((char *)&savepath)); strncpy((char *)&game.uri, "https://example.invalid/other", 255); TEST_EXPECT_STATUS(e, AKERR_API, akgl_game_load((char *)&savepath), "a savegame with a foreign URI must be refused"); unlink((char *)&savepath); // A save written against a different library version must be refused. set_game_identity(); strncpy((char *)&game.libversion, "99.98.97", 31); CATCH(e, akgl_game_save((char *)&savepath)); set_game_identity(); TEST_EXPECT_STATUS(e, AKERR_API, akgl_game_load((char *)&savepath), "a savegame from a different library version must be refused"); unlink((char *)&savepath); // And one written against a different game version. set_game_identity(); strncpy((char *)&game.version, "4.5.6", 31); CATCH(e, akgl_game_save((char *)&savepath)); set_game_identity(); TEST_EXPECT_STATUS(e, AKERR_API, akgl_game_load((char *)&savepath), "a savegame from a different game version must be refused"); } CLEANUP { unlink((char *)&savepath); } PROCESS(e) { } FINISH(e, true); SUCCEED_RETURN(e); } akerr_ErrorContext *test_game_save_load_nullpointers(void) { PREPARE_ERROR(e); ATTEMPT { TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_game_save(NULL), "akgl_game_save(NULL)"); TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_game_load(NULL), "akgl_game_load(NULL)"); TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_game_save_actors(NULL), "akgl_game_save_actors(NULL)"); // A path under a directory that does not exist cannot be opened. TEST_EXPECT_ANY_ERROR(e, akgl_game_save("no_such_directory/save.bin"), "saving into a nonexistent directory"); TEST_EXPECT_ANY_ERROR(e, akgl_game_load("no_such_file_anywhere.bin"), "loading a nonexistent savegame"); } CLEANUP { } PROCESS(e) { } FINISH(e, true); SUCCEED_RETURN(e); } akerr_ErrorContext *test_game_load_truncated_table(void) { PREPARE_ERROR(e); FILE *fp = NULL; char partial[64]; ATTEMPT { CATCH(e, akgl_registry_init()); CATCH(e, akgl_heap_init()); set_game_identity(); // A valid header followed by a table that ends before its sentinel. The // name-map reader loops until it sees the sentinel, so it has to notice // EOF instead of spinning. memset(&partial, 0x00, sizeof(partial)); fp = fopen((char *)&truncatedpath, "wb"); FAIL_ZERO_BREAK(e, fp, AKERR_IO, "unable to create the truncated savegame fixture"); FAIL_ZERO_BREAK(e, fwrite(&game, 1, sizeof(akgl_Game), fp), AKERR_IO, "unable to write the truncated savegame header"); FAIL_ZERO_BREAK(e, fwrite(&partial, 1, sizeof(partial), fp), AKERR_IO, "unable to write the truncated savegame body"); fclose(fp); fp = NULL; TEST_EXPECT_ANY_ERROR(e, akgl_game_load((char *)&truncatedpath), "loading a savegame whose name table is truncated"); } CLEANUP { if ( fp != NULL ) { fclose(fp); } unlink((char *)&truncatedpath); } PROCESS(e) { } FINISH(e, true); SUCCEED_RETURN(e); } akerr_ErrorContext *test_game_save_writes_name_tables(void) { PREPARE_ERROR(e); akgl_Actor *actor = NULL; FILE *fp = NULL; long filesize = 0; long minimum = 0; ATTEMPT { CATCH(e, akgl_registry_init()); CATCH(e, akgl_heap_init()); set_game_identity(); // One registered actor, so the actor table has a real entry ahead of its // terminating sentinel. CATCH(e, akgl_heap_next_actor(&actor)); CATCH(e, akgl_actor_initialize(actor, "saved_actor")); TEST_EXPECT_OK(e, akgl_game_save((char *)&savepath), "saving a game with one actor"); fp = fopen((char *)&savepath, "rb"); FAIL_ZERO_BREAK(e, fp, AKERR_IO, "unable to reopen the savegame"); fseek(fp, 0, SEEK_END); filesize = ftell(fp); // The header, then four name tables each ending in a name-sized and a // pointer-sized sentinel, plus the one real actor entry. minimum = (long)sizeof(akgl_Game) + (long)(AKGL_ACTOR_MAX_NAME_LENGTH + sizeof(akgl_Actor *)) * 2 + (long)(AKGL_SPRITE_MAX_NAME_LENGTH + sizeof(akgl_Sprite *)) + (long)(AKGL_SPRITE_SHEET_MAX_FILENAME_LENGTH + sizeof(akgl_SpriteSheet *)) + (long)(AKGL_SPRITE_MAX_CHARACTER_NAME_LENGTH + sizeof(akgl_Character *)); TEST_ASSERT(e, filesize >= minimum, "the savegame is %ld bytes, expected at least %ld for the header and four name tables", filesize, minimum); } CLEANUP { if ( fp != NULL ) { fclose(fp); } unlink((char *)&savepath); } PROCESS(e) { } FINISH(e, true); SUCCEED_RETURN(e); } akerr_ErrorContext *test_game_state_lock(void) { PREPARE_ERROR(e); ATTEMPT { set_game_identity(); game.statelock = SDL_CreateMutex(); FAIL_ZERO_BREAK(e, game.statelock, AKGL_ERR_SDL, "unable to create the state mutex"); TEST_EXPECT_OK(e, akgl_game_state_lock(), "taking the state lock"); TEST_EXPECT_OK(e, akgl_game_state_unlock(), "releasing the state lock"); // The lock is reusable after a matched unlock. TEST_EXPECT_OK(e, akgl_game_state_lock(), "retaking the state lock"); TEST_EXPECT_OK(e, akgl_game_state_unlock(), "releasing the state lock again"); } CLEANUP { if ( game.statelock != NULL ) { SDL_DestroyMutex(game.statelock); game.statelock = NULL; } } PROCESS(e) { } FINISH(e, true); SUCCEED_RETURN(e); } /** @brief Counts calls made to the low-FPS callback. */ static int lowfps_calls = 0; /** @brief Low-FPS callback stub that only records that it fired. */ static void stub_lowfps(void) { lowfps_calls += 1; } akerr_ErrorContext *test_game_updateFPS(void) { PREPARE_ERROR(e); int16_t framesbefore = 0; ATTEMPT { set_game_identity(); game.lowfpsfunc = &stub_lowfps; // Below the 30 FPS floor, every update notifies the callback. game.fps = 10; game.lastFPSTime = SDL_GetTicksNS(); lowfps_calls = 0; framesbefore = game.framesSinceUpdate; akgl_game_updateFPS(); TEST_ASSERT(e, lowfps_calls == 1, "a sub-30 FPS update fired the low-FPS callback %d times, expected 1", lowfps_calls); TEST_ASSERT(e, game.framesSinceUpdate == (framesbefore + 1), "updateFPS did not count the frame (%d, expected %d)", game.framesSinceUpdate, framesbefore + 1); TEST_ASSERT(e, game.lastIterTime != 0, "updateFPS did not stamp lastIterTime"); // At or above the floor, the callback stays quiet. game.fps = 60; game.lastFPSTime = SDL_GetTicksNS(); lowfps_calls = 0; akgl_game_updateFPS(); TEST_ASSERT(e, lowfps_calls == 0, "a 60 FPS update fired the low-FPS callback %d times, expected 0", lowfps_calls); // Once a full second has elapsed, the frame counter rolls into fps. game.fps = 60; game.framesSinceUpdate = 45; game.lastFPSTime = SDL_GetTicksNS() - (2 * (SDL_Time)AKGL_TIME_ONESEC_NS); akgl_game_updateFPS(); TEST_ASSERT(e, game.fps == 45, "after a second elapsed, fps rolled over as %d, expected 45", game.fps); TEST_ASSERT(e, game.framesSinceUpdate == 1, "the frame counter restarted at %d, expected 1", game.framesSinceUpdate); // The shipped default callback only logs, so it just has to not crash. game.fps = 1; akgl_game_lowfps(); } CLEANUP { } PROCESS(e) { } FINISH(e, true); SUCCEED_RETURN(e); } int main(void) { PREPARE_ERROR(errctx); SDL_SetHint(SDL_HINT_VIDEO_DRIVER, "dummy"); SDL_SetHint(SDL_HINT_AUDIO_DRIVER, "dummy"); ATTEMPT { CATCH(errctx, akgl_heap_init()); CATCH(errctx, akgl_registry_init()); CATCH(errctx, test_game_load_versioncmp_matching()); CATCH(errctx, test_game_load_versioncmp_mismatched()); CATCH(errctx, test_game_load_versioncmp_releases_semver()); CATCH(errctx, test_game_save_roundtrip()); CATCH(errctx, test_game_load_rejects_foreign_saves()); CATCH(errctx, test_game_save_load_nullpointers()); CATCH(errctx, test_game_load_truncated_table()); CATCH(errctx, test_game_save_writes_name_tables()); CATCH(errctx, test_game_state_lock()); CATCH(errctx, test_game_updateFPS()); } CLEANUP { } PROCESS(errctx) { } FINISH_NORETURN(errctx); }