Files
libakgl/tests/renderer.c
Andrew Kesterson 9c2f80bcb9 Give back every pooled string and sprite reference the loaders take
Closes Defects items 20, 22, 23 and 29, and the residual of item 38.

The tilemap load leak was five pooled strings per load; the property-lookup
fix in an earlier commit took it to two, and the last two were each a claim
with no matching release -- the string every layer's `type` was read into, and
the dirname the map's relative paths resolve against. tests/tilemap.c asserts
the pool is exactly where it started after one load/release cycle and after 64.

Finding those two was a matter of dumping the contents of every still-claimed
slot after a cycle rather than reading the code again; 'tilelayer' and an
assets directory named themselves immediately.

Same file, same class, fixed with it: akgl_tilemap_load_layer_objects released
its scratch string after reading each object's name and then kept using the
slot, because akgl_get_json_string_value reuses a non-NULL destination without
taking another reference. The slot was free while still live, so any other
claim could have been handed it.

akgl_character_sprite_add wrote over an existing binding without releasing the
sprite it displaced, so a character that rebinds a state while alive leaked a
sprite slot per rebind -- teardown only gives back what the map holds at the
end. The new reference is taken before the write and given back if the write
fails, so there is no window where a sprite is bound with nothing behind it.
The write was unchecked too.

Three failure-path leaks moved into CLEANUP blocks: akgl_render_2d_init's two
pooled strings, akgl_controller_open_gamepads' enumeration array, and
akgl_text_rendertextat's surface and texture -- the last being a leak per frame
on a HUD line.

akgl_text_unloadallfonts() closes every font in the registry and destroys it,
which is what item 38 left open. Deliberately not a whole akgl_game_shutdown:
tearing down the mixer, SDL_ttf and SDL in the right order is a design
question, and this is the part that was simply missing. It is a new public
symbol, which 0.5.0 already covers -- this release has not shipped.

Every fix has a test that fails against the old code.

25/25 pass, memcheck clean, reindent --check, check_api_surface and
check_error_protocol all clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 07:33:55 -04:00

288 lines
10 KiB
C

/**
* @file renderer.c
* @brief Unit tests for the 2D render backend's vtable and its entry points.
*
* None of this needs a display. akgl_render_2d_bind() installs the backend's
* methods and nothing else, so it can be checked against a backend that has no
* SDL_Renderer at all; the entry points that do draw are checked against a
* software renderer under the dummy video driver, the same way tests/draw.c
* does it.
*
* akgl_render_2d_init() is not covered here: it creates a window from the
* property registry and writes the `camera` global, which is what the offscreen
* harness described in TODO.md exists to make testable.
*/
#include <SDL3/SDL.h>
#include <string.h>
#include <akerror.h>
#include <akgl/error.h>
#include <akgl/game.h>
#include <akgl/renderer.h>
#include <akgl/registry.h>
#include "testutil.h"
/** @brief Width and height of the offscreen target the drawing tests use. */
#define TEST_TARGET_SIZE 32
/** @brief A backend bound to a live software renderer, built by main(). */
static akgl_RenderBackend bound;
akerr_ErrorContext *test_render_bind2d(void)
{
PREPARE_ERROR(errctx);
akgl_RenderBackend backend;
ATTEMPT {
// The state a host is in when it owns its own window: a zeroed backend
// with somebody else's SDL_Renderer in it. Binding must fill in the six
// methods and leave that renderer alone -- creating a second window is
// precisely what an embedded interpreter must not do.
memset(&backend, 0x00, sizeof(akgl_RenderBackend));
backend.sdl_renderer = (SDL_Renderer *)&bound;
TEST_EXPECT_OK(errctx, akgl_render_2d_bind(&backend), "binding the 2D backend");
TEST_ASSERT(errctx, backend.sdl_renderer == (SDL_Renderer *)&bound,
"binding replaced the caller's SDL_Renderer");
TEST_ASSERT(errctx, backend.shutdown == &akgl_render_2d_shutdown,
"binding did not install shutdown");
TEST_ASSERT(errctx, backend.frame_start == &akgl_render_2d_frame_start,
"binding did not install frame_start");
TEST_ASSERT(errctx, backend.frame_end == &akgl_render_2d_frame_end,
"binding did not install frame_end");
TEST_ASSERT(errctx, backend.draw_texture == &akgl_render_2d_draw_texture,
"binding did not install draw_texture");
TEST_ASSERT(errctx, backend.draw_mesh == &akgl_render_2d_draw_mesh,
"binding did not install draw_mesh");
TEST_ASSERT(errctx, backend.draw_world == &akgl_render_2d_draw_world,
"binding did not install draw_world");
// Binding a backend that has no renderer yet is legal -- the two halves
// are separable in both directions -- and leaves it NULL rather than
// inventing one.
memset(&backend, 0x00, sizeof(akgl_RenderBackend));
TEST_EXPECT_OK(errctx, akgl_render_2d_bind(&backend),
"binding a backend with no SDL renderer");
TEST_ASSERT(errctx, backend.sdl_renderer == NULL,
"binding invented an SDL_Renderer");
TEST_EXPECT_STATUS(errctx, AKERR_NULLPOINTER, akgl_render_2d_bind(NULL),
"binding a NULL backend");
} CLEANUP {
} PROCESS(errctx) {
} FINISH(errctx, true);
SUCCEED_RETURN(errctx);
}
akerr_ErrorContext *test_render_backend_without_a_renderer(void)
{
PREPARE_ERROR(errctx);
akgl_RenderBackend empty;
ATTEMPT {
// Bound but never given an SDL_Renderer. Every entry point that draws
// has to report that rather than dereference it.
memset(&empty, 0x00, sizeof(akgl_RenderBackend));
CATCH(errctx, akgl_render_2d_bind(&empty));
TEST_EXPECT_STATUS(errctx, AKERR_NULLPOINTER, empty.frame_start(&empty),
"starting a frame on a backend with no renderer");
TEST_EXPECT_STATUS(errctx, AKERR_NULLPOINTER, empty.frame_end(&empty),
"ending a frame on a backend with no renderer");
// And a NULL backend outright. frame_start and frame_end used to read
// self->sdl_renderer with no check on self at all, which is a segfault
// rather than an error, while draw_texture beside them checked it.
TEST_EXPECT_STATUS(errctx, AKERR_NULLPOINTER, akgl_render_2d_frame_start(NULL),
"starting a frame on a NULL backend");
TEST_EXPECT_STATUS(errctx, AKERR_NULLPOINTER, akgl_render_2d_frame_end(NULL),
"ending a frame on a NULL backend");
TEST_EXPECT_STATUS(errctx, AKERR_NULLPOINTER, akgl_render_2d_shutdown(NULL),
"shutting down a NULL backend");
// Shutdown is the one that has nothing to release yet, so it succeeds.
TEST_EXPECT_OK(errctx, empty.shutdown(&empty), "shutting down an unused backend");
} CLEANUP {
} PROCESS(errctx) {
} FINISH(errctx, true);
SUCCEED_RETURN(errctx);
}
akerr_ErrorContext *test_render_frames_and_textures(void)
{
PREPARE_ERROR(errctx);
SDL_Surface *surface = NULL;
SDL_Texture *texture = NULL;
SDL_FRect dest;
SDL_FPoint center;
ATTEMPT {
dest.x = 0.0f;
dest.y = 0.0f;
dest.w = 8.0f;
dest.h = 8.0f;
center.x = 4.0f;
center.y = 4.0f;
surface = SDL_CreateSurface(8, 8, SDL_PIXELFORMAT_RGBA32);
FAIL_ZERO_BREAK(errctx, surface, AKGL_ERR_SDL, "%s", SDL_GetError());
texture = SDL_CreateTextureFromSurface(bound.sdl_renderer, surface);
FAIL_ZERO_BREAK(errctx, texture, AKGL_ERR_SDL, "%s", SDL_GetError());
TEST_EXPECT_OK(errctx, bound.frame_start(&bound), "starting a frame");
TEST_EXPECT_OK(errctx,
bound.draw_texture(&bound, texture, NULL, &dest, 0, NULL, SDL_FLIP_NONE),
"blitting a texture unrotated");
// A non-zero angle takes the rotated path, which is the one that
// consults the pivot and the flip mode.
TEST_EXPECT_OK(errctx,
bound.draw_texture(&bound, texture, NULL, &dest, 90.0, &center, SDL_FLIP_HORIZONTAL),
"blitting a texture rotated");
TEST_EXPECT_OK(errctx, bound.frame_end(&bound), "ending a frame");
// A rotation with no pivot is refused: SDL's "NULL means the centre of
// dest" convention is not offered here, so a NULL is a caller mistake
// rather than a default.
TEST_EXPECT_STATUS(errctx, AKERR_NULLPOINTER,
bound.draw_texture(&bound, texture, NULL, &dest, 90.0, NULL, SDL_FLIP_NONE),
"blitting rotated with no pivot");
TEST_EXPECT_STATUS(errctx, AKERR_NULLPOINTER,
akgl_render_2d_draw_texture(NULL, texture, NULL, &dest, 0, NULL, SDL_FLIP_NONE),
"blitting through a NULL backend");
TEST_EXPECT_STATUS(errctx, AKERR_NULLPOINTER,
bound.draw_texture(&bound, NULL, NULL, &dest, 0, NULL, SDL_FLIP_NONE),
"blitting a NULL texture");
} CLEANUP {
if ( texture != NULL ) {
SDL_DestroyTexture(texture);
}
if ( surface != NULL ) {
SDL_DestroySurface(surface);
}
} PROCESS(errctx) {
} FINISH(errctx, true);
SUCCEED_RETURN(errctx);
}
akerr_ErrorContext *test_render_unimplemented_and_guards(void)
{
PREPARE_ERROR(errctx);
ATTEMPT {
// The 3D hook is not a stub that quietly does nothing. It refuses, so a
// caller that reaches it finds out on the first frame.
TEST_EXPECT_STATUS(errctx, AKERR_API, bound.draw_mesh(&bound),
"drawing a mesh through the 2D backend");
TEST_EXPECT_STATUS(errctx, AKERR_NULLPOINTER, akgl_render_2d_draw_world(NULL, NULL),
"drawing the world through a NULL backend");
// Shutting the 2D backend down releases nothing today, and says so.
TEST_EXPECT_OK(errctx, bound.shutdown(&bound), "shutting the backend down");
} CLEANUP {
} PROCESS(errctx) {
} FINISH(errctx, true);
SUCCEED_RETURN(errctx);
}
/**
* @brief akgl_render_2d_init must give back its pooled strings when a parse fails.
*
* It reads game.screenwidth and game.screenheight into two pool strings and
* used to release them only after both had parsed, so a non-numeric value
* returned past both and leaked two of the pool's 256 entries -- every time a
* host started with a bad configuration and retried.
*
* The window creation after the parse is what makes the success path need a
* display, so this only drives the failure path. That is the one that leaked.
*/
akerr_ErrorContext *test_render_2d_init_releases_strings_on_failure(void)
{
PREPARE_ERROR(errctx);
akgl_RenderBackend backend;
int baseline = 0;
int i = 0;
ATTEMPT {
CATCH(errctx, akgl_registry_init_properties());
memset(&backend, 0x00, sizeof(akgl_RenderBackend));
CATCH(errctx, akgl_set_property("game.screenwidth", "not-a-number"));
CATCH(errctx, akgl_set_property("game.screenheight", "480"));
baseline = test_string_pool_used();
for ( i = 0; i < (AKGL_MAX_HEAP_STRING * 2); i++ ) {
TEST_EXPECT_ANY_ERROR(errctx, akgl_render_2d_init(&backend),
"initializing a 2D renderer from an unparseable width");
}
TEST_ASSERT(errctx, test_string_pool_used() == baseline,
"%d failed initializations left %d pool strings claimed, expected %d",
(AKGL_MAX_HEAP_STRING * 2), test_string_pool_used(), baseline);
// The second property is the one read after the first parse, so fail on
// it too and check the other order.
CATCH(errctx, akgl_set_property("game.screenwidth", "640"));
CATCH(errctx, akgl_set_property("game.screenheight", "also-not-a-number"));
baseline = test_string_pool_used();
for ( i = 0; i < (AKGL_MAX_HEAP_STRING * 2); i++ ) {
TEST_EXPECT_ANY_ERROR(errctx, akgl_render_2d_init(&backend),
"initializing a 2D renderer from an unparseable height");
}
TEST_ASSERT(errctx, test_string_pool_used() == baseline,
"%d failed initializations left %d pool strings claimed, expected %d",
(AKGL_MAX_HEAP_STRING * 2), test_string_pool_used(), baseline);
} CLEANUP {
} 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());
TEST_TRAP_UNHANDLED_ERRORS();
memset(&bound, 0x00, sizeof(akgl_RenderBackend));
FAIL_ZERO_BREAK(
errctx,
SDL_Init(SDL_INIT_VIDEO),
AKGL_ERR_SDL,
"Couldn't initialize SDL: %s",
SDL_GetError());
FAIL_ZERO_BREAK(
errctx,
SDL_CreateWindowAndRenderer(
"net/aklabs/libakgl/test_renderer",
TEST_TARGET_SIZE,
TEST_TARGET_SIZE,
0,
&akgl_window,
&bound.sdl_renderer),
AKGL_ERR_SDL,
"Couldn't create window/renderer: %s",
SDL_GetError());
// The host owns the window; libakgl only ever binds to it. This is the
// arrangement akgl_render_2d_bind exists for.
CATCH(errctx, akgl_render_2d_bind(&bound));
CATCH(errctx, test_render_bind2d());
CATCH(errctx, test_render_backend_without_a_renderer());
CATCH(errctx, test_render_frames_and_textures());
CATCH(errctx, test_render_unimplemented_and_guards());
CATCH(errctx, test_render_2d_init_releases_strings_on_failure());
} CLEANUP {
SDL_Quit();
} PROCESS(errctx) {
} FINISH_NORETURN(errctx);
}