Files
libakgl/tests/character.c
Andrew Kesterson e4aa6a5084
Some checks failed
libakgl CI Build / cmake_build (push) Failing after 20s
libakgl CI Build / performance (push) Failing after 19s
libakgl CI Build / memory_check (push) Failing after 15s
libakgl CI Build / mutation_test (push) Failing after 17s
Take libakerror 2.0.1 and drop the workaround it makes obsolete
Every libakgl test suite could report success while failing. libakerror's
default unhandled-error handler ended in exit(errctx->status), an exit
status is one byte wide, and libakgl's band starts at 256 -- so
AKGL_ERR_SDL, the most common failure a library built on SDL can have,
exited 0 and CTest recorded a pass. tests/character.c aborted at its
second of four tests on a bad renderer and was green for months.

0.5.0 worked around that here with TEST_TRAP_UNHANDLED_ERRORS() in
tests/testutil.h, and TODO.md ended the entry saying any consumer's
suites have the same problem and it was worth raising upstream. It was.
2.0.1 fixes it at the source: akerr_exit() owns the mapping and the
default handler calls it, so 0 exits 0, 1 through 255 exit themselves,
and anything else exits AKERR_EXIT_STATUS_UNREPRESENTABLE (125). The
trap and its 21 call sites are gone.

Verified by putting the original failure back rather than by reading the
release notes: a FAIL_BREAK(AKGL_ERR_SDL) in tests/character.c's main
exits 125 and CTest reports a failure. A standalone consumer raising the
same status unhandled exits 125 where it exited 0 before.

tests/actor.c installs its own handler and called exit(errctx->status)
from it, which is the same defect one layer up. It calls akerr_exit()
now.

2.0.0 also makes the error pool and the status registry thread safe,
which libakgl needs more than it knew: audio_stream_callback raises
error contexts on SDL's audio thread. With an unlocked pool that
callback and the main thread could scan AKERR_ARRAY_ERROR at the same
time and be handed the same slot. The comment there says so.

This is a hard dependency floor, not a preference. 2.0.0 moved
__akerr_last_ignored to thread-local storage and made akerr_next_error()
return a context that already holds its reference, and both expand at
libakgl's call sites -- and at a consumer's, because akerror.h is part
of libakgl's public interface. Mixing headers and libraries across that
line double-counts every reference and never returns a pool slot. The
soname moved to libakerror.so.2; include/akgl/error.h now also feature-
tests AKERR_EXIT_STATUS_UNREPRESENTABLE, which is the narrowest probe
for 2.0.1 since libakerror publishes no version macro.

0.7.0 for that reason: libakgl's own ABI is unchanged, but the one it
re-exports through its headers is not.

TODO.md records the pkg-config gap this makes sharper -- akgl.pc names
no dependencies at all, so nothing tells a pkg-config consumer which
libakerror it needs.

Clean build, 26/26 ctest, memcheck clean, warning-clean at -Wall
-Werror. libakgl.so.0.7 links libakerror.so.2.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B8T5FAYXE8HEJqFLCYwNNc
2026-08-01 13:05:43 -04:00

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