Make savegames, optional array elements, empty text and coverage work
Closes Known-and-still-open items 7 and 13, and Defects items 18, 25 and 27. The savegame name tables carry no length prefix, so writer and reader have to agree on a field width exactly. The writer used each object's own maximum name length -- 512 for a spritesheet, a filename -- and the reader used AKGL_ACTOR_MAX_NAME_LENGTH for all four. Four AKGL_GAME_SAVE_*_NAME_WIDTH constants drive both sides now. The failure turned out to be worse than "cannot be read back": a reader stepping the wrong width does not run off anything, it finds a run of zeros inside an entry, stops early, and reports success with silently wrong maps. A test asserting only that the load succeeded passed against the broken reader. So akgl_game_load checks it is at EOF once the tables are read, which turns a width disagreement into AKERR_IO instead of a corruption. That is the assertion the new roundtrip test -- the first with all four registries populated -- hangs on. akgl_get_json_with_default gains a third HANDLE_GROUP for AKERR_OUTOFBOUNDS, which is what the array index accessors report, so "this element is optional" works for an array element and not only for an object member. The arm goes above the one holding the memcpy: HANDLE_GROUP emits no break and every arm falls into that body. That test needed a second attempt. with_default returns *the context it was given* when it does not handle the status, so TEST_EXPECT_OK -- which releases whatever the statement returns -- double-released it against a CLEANUP block that released it too, and a double-released context corrupts the failure rather than reporting it. The first draft passed against the unfixed library. akgl_text_rendertextat returns success without rasterizing for the empty string, matching akgl_text_measure, which has always accepted it. The check sits after the font and backend guards, so drawing nothing still refuses what drawing something refuses. tests/text.c had this case written and unasserted waiting for the two halves of the header to agree. character_load_json_state_int_from_strings guards dest rather than testing states twice. Not asserted: the function is static with one call site that passes a real pointer, so the guard cannot fire, and reaching it from a test would mean giving it external linkage purely for that. Both gcovr invocations take the build tree as an explicit positional search path. gcovr searches --root when given none, which is the source directory, where build trees live; --object-directory does not narrow it. Verified by building two instrumented trees with a source edit between them: the old invocation fails with "Got function write_exact on multiple lines: 46, 48" and exits 64, the new one exits 0 and the full coverage run passes with the stale tree still present. 25/25 pass, reindent --check, check_api_surface and check_error_protocol clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
72
tests/game.c
72
tests/game.c
@@ -513,6 +513,77 @@ akerr_ErrorContext *test_game_updateFPS(void)
|
||||
SUCCEED_RETURN(e);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief A save with a registered spritesheet must read back.
|
||||
*
|
||||
* The four name tables carry no length prefix, so the reader finds each entry
|
||||
* by stepping a fixed width. The writer used each object's own maximum name
|
||||
* length -- 512 for a spritesheet, which is a filename -- and the reader used
|
||||
* AKGL_ACTOR_MAX_NAME_LENGTH for all four. The other three are 128 as well, so
|
||||
* only the spritesheet table was wrong, and that was enough: every entry after
|
||||
* it was read out of the middle of its neighbour.
|
||||
*
|
||||
* The existing roundtrip test passed because empty registries write nothing but
|
||||
* the zeroed sentinel. This one puts a name in each of the four registries, and
|
||||
* a long one in the spritesheet registry, so the widths actually have to agree.
|
||||
*
|
||||
* The registry values are placeholder pointers rather than real objects: the
|
||||
* save tables record name-to-address pairs and the loader looks each name up in
|
||||
* the live registry, so what the pointers point at never matters here.
|
||||
*/
|
||||
akerr_ErrorContext *test_game_save_roundtrip_with_a_spritesheet(void)
|
||||
{
|
||||
PREPARE_ERROR(e);
|
||||
char longsheetname[AKGL_SPRITE_SHEET_MAX_FILENAME_LENGTH];
|
||||
akgl_Actor placeholder_actor;
|
||||
akgl_Sprite placeholder_sprite;
|
||||
akgl_SpriteSheet placeholder_sheet;
|
||||
akgl_Character placeholder_character;
|
||||
int i = 0;
|
||||
|
||||
ATTEMPT {
|
||||
CATCH(e, akgl_registry_init());
|
||||
CATCH(e, akgl_heap_init());
|
||||
set_game_identity();
|
||||
|
||||
// A spritesheet name that does not fit the width the reader used to
|
||||
// assume. Filled to just under the field so the terminator still fits.
|
||||
memset(&longsheetname, 0x00, sizeof(longsheetname));
|
||||
for ( i = 0; i < (AKGL_SPRITE_SHEET_MAX_FILENAME_LENGTH - 1); i++ ) {
|
||||
longsheetname[i] = 'a' + (i % 26);
|
||||
}
|
||||
|
||||
FAIL_ZERO_BREAK(e, SDL_SetPointerProperty(AKGL_REGISTRY_ACTOR, "roundtrip_actor",
|
||||
(void *)&placeholder_actor),
|
||||
AKERR_KEY, "could not register the actor");
|
||||
FAIL_ZERO_BREAK(e, SDL_SetPointerProperty(AKGL_REGISTRY_SPRITE, "roundtrip_sprite",
|
||||
(void *)&placeholder_sprite),
|
||||
AKERR_KEY, "could not register the sprite");
|
||||
FAIL_ZERO_BREAK(e, SDL_SetPointerProperty(AKGL_REGISTRY_SPRITESHEET, (char *)&longsheetname,
|
||||
(void *)&placeholder_sheet),
|
||||
AKERR_KEY, "could not register the spritesheet");
|
||||
FAIL_ZERO_BREAK(e, SDL_SetPointerProperty(AKGL_REGISTRY_CHARACTER, "roundtrip_character",
|
||||
(void *)&placeholder_character),
|
||||
AKERR_KEY, "could not register the character");
|
||||
|
||||
TEST_EXPECT_OK(e, akgl_game_save((char *)&savepath),
|
||||
"saving a game with all four registries populated");
|
||||
|
||||
// The load is what walks the four tables in order. If any width
|
||||
// disagrees with the writer's, the table after it starts mid-entry and
|
||||
// the read runs off the end of the file.
|
||||
TEST_EXPECT_OK(e, akgl_game_load((char *)&savepath),
|
||||
"loading back a save with a registered spritesheet");
|
||||
|
||||
TEST_ASSERT(e, strncmp((char *)&akgl_game.name, "libakgl test game", 256) == 0,
|
||||
"the game identity did not survive the roundtrip");
|
||||
} CLEANUP {
|
||||
unlink((char *)&savepath);
|
||||
} PROCESS(e) {
|
||||
} FINISH(e, true);
|
||||
SUCCEED_RETURN(e);
|
||||
}
|
||||
|
||||
/** @brief Counts akgl_game_update calls into each actor's updatefunc, by actor index. */
|
||||
static int updatecounts[AKGL_MAX_HEAP_ACTOR];
|
||||
|
||||
@@ -669,6 +740,7 @@ int main(void)
|
||||
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_save_roundtrip_with_a_spritesheet());
|
||||
CATCH(errctx, test_game_state_lock());
|
||||
CATCH(errctx, test_game_state_lock_budget());
|
||||
CATCH(errctx, test_game_updateFPS());
|
||||
|
||||
@@ -418,6 +418,9 @@ akerr_ErrorContext *test_json_with_default(void)
|
||||
int defval = 99;
|
||||
akerr_ErrorContext *keyerr = NULL;
|
||||
akerr_ErrorContext *typeerr = NULL;
|
||||
akerr_ErrorContext *defaulted = NULL;
|
||||
json_t *shortarray = NULL;
|
||||
json_t *junkobj = NULL;
|
||||
int junk = 0;
|
||||
|
||||
ATTEMPT {
|
||||
@@ -446,6 +449,51 @@ akerr_ErrorContext *test_json_with_default(void)
|
||||
TEST_ASSERT(e, dest == 1, "with_default applied the default for an unrelated error");
|
||||
typeerr = NULL;
|
||||
|
||||
// A short array. This is the one that did not work: the three array
|
||||
// index accessors report AKERR_OUTOFBOUNDS, which with_default never
|
||||
// handled, so "this element is optional" worked for an object member
|
||||
// and silently did not for an array element. Nothing in tree passed an
|
||||
// array accessor's error here, which is why it went unnoticed.
|
||||
dest = 1;
|
||||
CATCH(e, akgl_get_json_array_value(fixture, "integers", &shortarray));
|
||||
keyerr = akgl_get_json_array_index_integer(shortarray, 99, &junk);
|
||||
TEST_ASSERT(e, keyerr != NULL, "reading past the end of an array unexpectedly succeeded");
|
||||
TEST_ASSERT(e, keyerr->status == AKERR_OUTOFBOUNDS,
|
||||
"a short array reported %d, expected AKERR_OUTOFBOUNDS", keyerr->status);
|
||||
// Not TEST_EXPECT_OK here. That macro releases whatever context the
|
||||
// statement returns, and akgl_get_json_with_default returns *the context
|
||||
// it was given* when it does not handle it -- so on the failing path the
|
||||
// CLEANUP block below would release the same context a second time, and
|
||||
// a double-released context corrupts the failure rather than reporting
|
||||
// it. Ownership passes to with_default either way: it releases through
|
||||
// FINISH when it handles the status, and hands the context back when it
|
||||
// does not.
|
||||
defaulted = akgl_get_json_with_default(keyerr, (void *)&defval, (void *)&dest, sizeof(int));
|
||||
keyerr = NULL;
|
||||
if ( defaulted != NULL ) {
|
||||
defaulted->handled = true;
|
||||
defaulted = akerr_release_error(defaulted);
|
||||
FAIL_BREAK(e, AKGL_ERR_BEHAVIOR,
|
||||
"with_default propagated AKERR_OUTOFBOUNDS instead of applying the default");
|
||||
}
|
||||
TEST_ASSERT(e, dest == 99,
|
||||
"with_default did not apply the default for a short array (dest is %d)", dest);
|
||||
|
||||
// The same through the object index accessor, so the fix is pinned to
|
||||
// the status rather than to one call site.
|
||||
dest = 1;
|
||||
keyerr = akgl_get_json_array_index_object(shortarray, 99, &junkobj);
|
||||
TEST_ASSERT(e, keyerr != NULL, "reading an object past the end unexpectedly succeeded");
|
||||
defaulted = akgl_get_json_with_default(keyerr, (void *)&defval, (void *)&dest, sizeof(int));
|
||||
keyerr = NULL;
|
||||
if ( defaulted != NULL ) {
|
||||
defaulted->handled = true;
|
||||
defaulted = akerr_release_error(defaulted);
|
||||
FAIL_BREAK(e, AKGL_ERR_BEHAVIOR,
|
||||
"with_default propagated a short object array instead of applying the default");
|
||||
}
|
||||
TEST_ASSERT(e, dest == 99, "with_default did not apply the default (dest is %d)", dest);
|
||||
|
||||
// NULL arguments alongside a real error are a contract violation.
|
||||
keyerr = akgl_get_json_integer_value(fixture, "absent", &junk);
|
||||
TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER,
|
||||
|
||||
23
tests/text.c
23
tests/text.c
@@ -310,10 +310,25 @@ akerr_ErrorContext *test_text_rendertextat(void)
|
||||
TEST_EXPECT_OK(errctx,
|
||||
akgl_text_rendertextat(testfont, "one two three", white, TEST_TARGET_SIZE / 2, 0, 0),
|
||||
"drawing wrapped text");
|
||||
// Not asserted here: the empty string, which SDL_ttf refuses with "Text
|
||||
// has zero width" on both paths, so drawing an empty line reports a
|
||||
// failure rather than drawing nothing. That is filed rather than pinned
|
||||
// -- see TODO.md, "Known and still open".
|
||||
// The empty string. SDL_ttf refuses it with "Text has zero width" from
|
||||
// both rasterizers, so this used to report AKERR_NULLPOINTER for what a
|
||||
// caller means as "draw nothing" -- while akgl_text_measure("") is
|
||||
// documented as legal. This case was written and deliberately left
|
||||
// unasserted until the two halves of the header agreed.
|
||||
TEST_EXPECT_OK(errctx, akgl_text_rendertextat(testfont, "", white, 0, 0, 0),
|
||||
"drawing an empty line of text");
|
||||
TEST_EXPECT_OK(errctx, akgl_text_rendertextat(testfont, "", white, TEST_TARGET_SIZE / 2, 0, 0),
|
||||
"drawing an empty line of wrapped text");
|
||||
|
||||
// Drawing nothing must still refuse the things drawing something
|
||||
// refuses, rather than short-circuiting past the checks.
|
||||
TEST_EXPECT_STATUS(errctx, AKERR_NULLPOINTER,
|
||||
akgl_text_rendertextat(NULL, "", white, 0, 0, 0),
|
||||
"drawing an empty line with no font");
|
||||
TEST_EXPECT_STATUS(errctx, AKERR_NULLPOINTER,
|
||||
akgl_text_rendertextat(testfont, NULL, white, 0, 0, 0),
|
||||
"drawing a NULL string");
|
||||
|
||||
TEST_EXPECT_OK(errctx, akgl_renderer->frame_end(akgl_renderer), "ending the frame");
|
||||
} CLEANUP {
|
||||
} PROCESS(errctx) {
|
||||
|
||||
Reference in New Issue
Block a user