Files
libakgl/tests/character.c
Andrew Kesterson 2242cfac9f
Some checks failed
libakgl CI Build / cmake_build (push) Failing after 8m4s
libakgl CI Build / memory_check (push) Has been cancelled
libakgl CI Build / mutation_test (push) Has been cancelled
libakgl CI Build / performance (push) Has been cancelled
Unbreak CI: https submodule URLs, and three suites that assumed a display
CI has never gone green in the retained run history, and every failure is
the same 20-second death during checkout: six submodules -- SDL, SDL_image,
SDL_mixer, SDL_ttf, jansson, semver -- were registered with git@github.com:
SSH URLs, and the runner has no GitHub key. The https submodules (libccd,
tg, clay, and the two starfort ones) clone fine in the same run. All six now
use https, which is what every stanza added since already did; these are
public upstream repositories nothing here pushes to, so SSH bought nothing.

Second problem, waiting right behind the first: the character, sprite and
tilemap suites were the only three of seventeen that never set the dummy
video/audio/software-render hints, so on a headless runner they die in
SDL_Init with "No available video device" before testing anything. They now
set the same three hints the other fourteen suites set. (How this survived:
every local run happens on a machine with a display, and CI never got far
enough to run a test.)

Pre-flighted locally against every job the workflow defines, headless with
no driver variables in the environment, exactly as the runner would: Debug
with -Werror and coverage builds clean and all 37 tests pass including the
coverage fixtures; RelWithDebInfo with -Werror builds clean and the perf
suites pass their budgets at full scale; doxygen Doxyfile exits 0 under
FAIL_ON_WARNINGS; and the new ui suite runs under valgrind with zero errors
and no leaks.

Co-Authored-By: Claude Code (Claude Fable 5, claude-fable-5) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KzBDV2fqgnUAcqCKqKvc71
2026-08-02 13:39:17 -04:00

318 lines
12 KiB
C

#include <SDL3/SDL.h>
#include <akerror.h>
#include <akgl/character.h>
#include <akgl/actor.h>
#include <akgl/error.h>
#include <akgl/heap.h>
#include <akgl/registry.h>
#include <akgl/iterator.h>
#include <akgl/game.h>
#include <akgl/renderer.h>
#include "testutil.h"
/*
* This suite used to declare its own file-scope `window` and `renderer`, back
* when the library's globals carried those bare names too. Both definitions had
* external linkage and the same spelling, so the executable's preempted the
* shared library's: akgl_sprite_load_json read the test's `SDL_Renderer *`
* through an `akgl_RenderBackend *` and happened not to crash. That is the
* collision the akgl_ prefix on exported globals exists to prevent, and it was
* invisible until they were renamed. The suite now binds a real backend, the
* way tests/sprite.c and tests/text.c do.
*/
akerr_ErrorContext *test_akgl_character_initialize()
{
PREPARE_ERROR(errctx);
akgl_Character *testchar = NULL;
ATTEMPT {
CATCH(errctx, akgl_heap_next_character(&testchar));
CATCH(errctx, akgl_character_initialize(testchar, "testchar"));
FAIL_ZERO_BREAK(
errctx,
SDL_GetPointerProperty(AKGL_REGISTRY_CHARACTER, "testchar", NULL),
AKERR_KEY,
"Character was not placed in the registry");
FAIL_NONZERO_BREAK(
errctx,
strcmp((char *)&testchar->name, "testchar"),
AKERR_VALUE,
"Character was not named properly ('testchar' vs '%s')",
(char *)&testchar->name);
FAIL_ZERO_BREAK(
errctx,
testchar->state_sprites,
AKERR_NULLPOINTER,
"Character state sprites map was not initialized");
} CLEANUP {
IGNORE(akgl_heap_release_character(testchar));
} PROCESS(errctx) {
} FINISH(errctx, true);
SUCCEED_RETURN(errctx);
}
akerr_ErrorContext *test_character_sprite_mgmt()
{
akgl_Character *testchar = NULL;
akgl_Sprite *testsprite = NULL;
akgl_Sprite *testsprite2 = NULL;
akgl_Sprite *comparesprite = NULL;
PREPARE_ERROR(errctx);
ATTEMPT {
CATCH(errctx, akgl_heap_next_character(&testchar));
CATCH(errctx, akgl_character_initialize(testchar, "testchar"));
CATCH(errctx, akgl_sprite_load_json("assets/testsprite.json"));
testsprite = SDL_GetPointerProperty(AKGL_REGISTRY_SPRITE, "testsprite", NULL);
FAIL_ZERO_BREAK(
errctx,
testsprite,
AKERR_KEY,
"Sprite loaded from json but not in registry");
CATCH(errctx, akgl_sprite_load_json("assets/testsprite2.json"));
testsprite2 = SDL_GetPointerProperty(AKGL_REGISTRY_SPRITE, "testsprite2", NULL);
FAIL_ZERO_BREAK(
errctx,
testsprite2,
AKERR_KEY,
"Sprite 2 loaded from json but not in registry");
CATCH(errctx, testchar->sprite_add(testchar, testsprite, AKGL_ACTOR_STATE_ALIVE));
CATCH(errctx, testchar->sprite_add(testchar, testsprite2, AKGL_ACTOR_STATE_DEAD));
CATCH(errctx, testchar->sprite_get(testchar, (AKGL_ACTOR_STATE_ALIVE), &comparesprite));
FAIL_ZERO_BREAK(errctx, (comparesprite == testsprite), AKERR_VALUE, "Wrong sprite for state AKGL_ACTOR_STATE_ALIVE | AKGL_ACTOR_STATE_FACE_LEFT");
CATCH(errctx, testchar->sprite_get(testchar, AKGL_ACTOR_STATE_DEAD, &comparesprite));
FAIL_ZERO_BREAK(errctx, (comparesprite == testsprite2), AKERR_VALUE, "Wrong sprite for state AKGL_ACTOR_STATE_DEAD");
} CLEANUP {
IGNORE(akgl_heap_release_sprite(testsprite));
IGNORE(akgl_heap_release_sprite(testsprite2));
IGNORE(akgl_heap_release_character(testchar));
} PROCESS(errctx) {
} FINISH(errctx, true);
SUCCEED_RETURN(errctx);
}
akerr_ErrorContext *test_character_iterate_state_sprites()
{
akgl_Character *testchar = NULL;
akgl_Sprite *testsprite = NULL;
akgl_Sprite *testsprite2 = NULL;
akgl_Iterator opflags = {.flags = AKGL_ITERATOR_OP_RELEASE, .layerid = 0};
PREPARE_ERROR(errctx);
ATTEMPT {
CATCH(errctx, akgl_heap_next_character(&testchar));
CATCH(errctx, akgl_character_initialize(testchar, "testchar"));
CATCH(errctx, akgl_sprite_load_json("assets/testsprite.json"));
testsprite = SDL_GetPointerProperty(AKGL_REGISTRY_SPRITE, "testsprite", NULL);
FAIL_ZERO_BREAK(
errctx,
testsprite,
AKERR_KEY,
"Sprite loaded from json but not in registry");
CATCH(errctx, akgl_sprite_load_json("assets/testsprite2.json"));
testsprite2 = SDL_GetPointerProperty(AKGL_REGISTRY_SPRITE, "testsprite2", NULL);
FAIL_ZERO_BREAK(
errctx,
testsprite2,
AKERR_KEY,
"Sprite 2 loaded from json but not in registry");
CATCH(errctx, testchar->sprite_add(testchar, testsprite, AKGL_ACTOR_STATE_ALIVE));
CATCH(errctx, testchar->sprite_add(testchar, testsprite2, AKGL_ACTOR_STATE_DEAD));
SDL_EnumerateProperties(testchar->state_sprites, &akgl_character_state_sprites_iterate, &opflags);
// This is called by akgl_heap_release_character so we should assume that our property map is being
// deleted soon after this. So we don't care if the sprites have been deleted from the sprite_states.
// We just want to know they've been released.
FAIL_NONZERO_BREAK(
errctx,
(testsprite->refcount > 1),
AKERR_VALUE,
"akgl_heap_release_sprite not called for testsprite from iterator");
FAIL_NONZERO_BREAK(
errctx,
(testsprite2->refcount > 1),
AKERR_VALUE,
"akgl_heap_release_sprite not called for testsprite from iterator");
} CLEANUP {
IGNORE(akgl_heap_release_sprite(testsprite));
IGNORE(akgl_heap_release_sprite(testsprite2));
IGNORE(akgl_heap_release_character(testchar));
} PROCESS(errctx) {
} FINISH(errctx, true);
SUCCEED_RETURN(errctx);
}
akerr_ErrorContext *test_akgl_character_load_json()
{
akgl_Character *testcharacter = NULL;
akgl_Sprite *testsprite = NULL;
akgl_Sprite *testsprite2 = NULL;
akgl_Sprite *comparesprite = NULL;
int tsrc = 0;
int tsrc2 = 0;
PREPARE_ERROR(errctx);
ATTEMPT {
CATCH(errctx, akgl_heap_next_character(&testcharacter));
CATCH(errctx, akgl_sprite_load_json("assets/testsprite.json"));
testsprite = SDL_GetPointerProperty(AKGL_REGISTRY_SPRITE, "testsprite", NULL);
FAIL_ZERO_BREAK(
errctx,
testsprite,
AKERR_KEY,
"Sprite loaded from json but not in registry");
CATCH(errctx, akgl_sprite_load_json("assets/testsprite2.json"));
testsprite2 = SDL_GetPointerProperty(AKGL_REGISTRY_SPRITE, "testsprite2", NULL);
FAIL_ZERO_BREAK(
errctx,
testsprite2,
AKERR_KEY,
"Sprite 2 loaded from json but not in registry");
CATCH(errctx, akgl_character_load_json("assets/testcharacter.json"));
testcharacter = SDL_GetPointerProperty(AKGL_REGISTRY_CHARACTER, "testcharacter", NULL);
FAIL_ZERO_BREAK(
errctx,
testcharacter,
AKERR_KEY,
"Character loaded from json but not in registry");
CATCH(errctx, testcharacter->sprite_get(testcharacter, (AKGL_ACTOR_STATE_ALIVE | AKGL_ACTOR_STATE_FACE_LEFT), &comparesprite));
FAIL_ZERO_BREAK(errctx, (comparesprite == testsprite), AKERR_VALUE, "Wrong sprite for state AKGL_ACTOR_STATE_ALIVE");
CATCH(errctx, testcharacter->sprite_get(testcharacter, AKGL_ACTOR_STATE_DEAD, &comparesprite));
FAIL_ZERO_BREAK(errctx, (comparesprite == testsprite2), AKERR_VALUE, "Wrong sprite for state AKGL_ACTOR_STATE_DEAD");
FAIL_ZERO_BREAK(errctx, (testcharacter->ax != 0.200000003), AKERR_VALUE, "Wrong X acceleration for test character");
FAIL_ZERO_BREAK(errctx, (testcharacter->ay != 0.200000003), AKERR_VALUE, "Wrong Y acceleration for test character");
// Release our handles on the sprites so the character's heap_release can reduce them to 0
CATCH(errctx, akgl_heap_release_sprite(testsprite));
CATCH(errctx, akgl_heap_release_sprite(testsprite2));
tsrc = testsprite->refcount;
tsrc2 = testsprite2->refcount;
CATCH(errctx, akgl_heap_release_character(testcharacter));
FAIL_ZERO_BREAK(
errctx,
((testsprite->refcount < tsrc) || (testsprite2->refcount < tsrc2)),
AKERR_VALUE,
"character did not reduce reference count of its child sprites when released");
} CLEANUP {
} PROCESS(errctx) {
} FINISH(errctx, true);
SUCCEED_RETURN(errctx);
}
/**
* @brief Rebinding a state must release the sprite it displaces.
*
* akgl_character_sprite_add took a reference for every binding and wrote over
* any existing entry without releasing the one it replaced, so a character that
* rebinds a state while alive leaked a sprite slot per rebind. Releasing at
* teardown does not cover it: the map only holds the last binding by then, so
* the earlier ones are unreachable.
*
* The write itself was also unchecked, so a failure to record the binding was
* reported as success -- with the reference already taken.
*/
akerr_ErrorContext *test_character_sprite_rebind_releases_displaced(void)
{
PREPARE_ERROR(errctx);
akgl_Character *testchar = NULL;
akgl_Sprite *first = NULL;
akgl_Sprite *second = NULL;
akgl_Sprite *found = NULL;
int firstbefore = 0;
int secondbefore = 0;
int i = 0;
ATTEMPT {
CATCH(errctx, akgl_heap_next_character(&testchar));
CATCH(errctx, akgl_character_initialize(testchar, "rebindchar"));
// Real sprites out of the fixtures: akgl_sprite_initialize refuses a
// NULL spritesheet, and a hand-built sprite would not be a sprite.
CATCH(errctx, akgl_sprite_load_json("assets/testsprite.json"));
CATCH(errctx, akgl_sprite_load_json("assets/testsprite2.json"));
first = SDL_GetPointerProperty(AKGL_REGISTRY_SPRITE, "testsprite", NULL);
second = SDL_GetPointerProperty(AKGL_REGISTRY_SPRITE, "testsprite2", NULL);
FAIL_ZERO_BREAK(errctx, first, AKERR_KEY, "testsprite is not in the registry");
FAIL_ZERO_BREAK(errctx, second, AKERR_KEY, "testsprite2 is not in the registry");
firstbefore = first->refcount;
secondbefore = second->refcount;
CATCH(errctx, akgl_character_sprite_add(testchar, first, AKGL_ACTOR_STATE_ALIVE));
TEST_ASSERT(errctx, first->refcount == (firstbefore + 1),
"binding a sprite did not take a reference (%d, expected %d)",
first->refcount, firstbefore + 1);
// Rebind the same state to a different sprite.
CATCH(errctx, akgl_character_sprite_add(testchar, second, AKGL_ACTOR_STATE_ALIVE));
TEST_ASSERT(errctx, second->refcount == (secondbefore + 1),
"rebinding did not take a reference on the new sprite (%d, expected %d)",
second->refcount, secondbefore + 1);
TEST_ASSERT(errctx, first->refcount == firstbefore,
"rebinding did not release the displaced sprite (%d, expected %d)",
first->refcount, firstbefore);
CATCH(errctx, testchar->sprite_get(testchar, AKGL_ACTOR_STATE_ALIVE, &found));
TEST_ASSERT(errctx, found == second, "the state is not bound to the new sprite");
// Rebinding a state to the sprite already there must be a no-op on the
// count rather than a release followed by a claim of the same slot.
CATCH(errctx, akgl_character_sprite_add(testchar, second, AKGL_ACTOR_STATE_ALIVE));
TEST_ASSERT(errctx, second->refcount == (secondbefore + 2),
"rebinding a state to the sprite it already held mishandled the count (%d)",
second->refcount);
// And a long run of rebinds must not drift.
for ( i = 0; i < 200; i++ ) {
CATCH(errctx, akgl_character_sprite_add(testchar, first, AKGL_ACTOR_STATE_DEAD));
CATCH(errctx, akgl_character_sprite_add(testchar, second, AKGL_ACTOR_STATE_DEAD));
}
TEST_ASSERT(errctx, first->refcount <= (firstbefore + 1),
"200 rebinds left the first sprite at refcount %d", first->refcount);
} CLEANUP {
if ( testchar != NULL ) {
IGNORE(akgl_heap_release_character(testchar));
}
} PROCESS(errctx) {
} FINISH(errctx, true);
SUCCEED_RETURN(errctx);
}
int main(void)
{
PREPARE_ERROR(errctx);
SDL_SetHint(SDL_HINT_VIDEO_DRIVER, "dummy");
SDL_SetHint(SDL_HINT_AUDIO_DRIVER, "dummy");
SDL_SetHint(SDL_HINT_RENDER_DRIVER, "software");
ATTEMPT {
CATCH(errctx, akgl_error_init());
akgl_renderer = &akgl_default_renderer;
SDL_SetAppMetadata("SDL3-GameTest", "0.1", "net.aklabs.sdl3-gametest");
if (!SDL_Init(SDL_INIT_VIDEO | SDL_INIT_JOYSTICK | SDL_INIT_AUDIO )) {
FAIL_BREAK(errctx, AKGL_ERR_SDL, "Couldn't initialize SDL: %s", SDL_GetError());
}
if (!SDL_CreateWindowAndRenderer("net/aklabs/libakgl/test_character", 640, 480, SDL_WINDOW_HIDDEN, &akgl_window, &akgl_renderer->sdl_renderer)) {
FAIL_BREAK(errctx, AKGL_ERR_SDL, "Couldn't create window/renderer: %s", SDL_GetError());
}
CATCH(errctx, akgl_render_2d_bind(akgl_renderer));
CATCH(errctx, akgl_heap_init());
CATCH(errctx, akgl_registry_init());
CATCH(errctx, test_akgl_character_initialize());
CATCH(errctx, test_character_sprite_mgmt());
CATCH(errctx, test_character_iterate_state_sprites());
CATCH(errctx, test_akgl_character_load_json());
CATCH(errctx, test_character_sprite_rebind_releases_displaced());
} CLEANUP {
} PROCESS(errctx) {
} FINISH_NORETURN(errctx);
return 0;
}