Files
dusk/test/save/test_saveslot.c
YourWishesandClaude Sonnet 5 7a9a2b9832 Add order-based party roster, back-navigation in battle selection, fix win-state cutscene leak
- Battle selection menu can now go back a fighter to re-decide their move,
  and battle win/loss no longer leaks the round's remaining queued move
  cutscenes into the ending state (cutsceneSystemDiscardQueued).
- Party roster is now driven by party.jsonc: every defined member always
  exists (partyInit), while party->order alone determines who's currently
  active - partyAddMember/partyRemoveMember (and their cutscene items) now
  activate/deactivate an existing roster member by index instead of
  creating/destroying one, with removal compacting order to stay packed.
- New PARTY_ADD_MEMBER/PARTY_REMOVE_MEMBER/PARTY_HEAL_MEMBER/PARTY_HEAL_ALL
  cutscene items.
- saveSlotInit/partyInit now return errorret_t and take their target
  struct directly (saveslot_t*/party_t*) instead of implicitly operating
  on SAVE.slot; saveSlotReadJSON resets through saveSlotInit rather than
  duplicating the reset logic.
- Test coverage: stub the JSON asset loader in save test fixtures so
  partyInit's real party.jsonc load runs against an in-memory doc instead
  of hanging with no asset system initialized.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-21 22:27:46 -05:00

315 lines
10 KiB
C

/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#include "dusktest.h"
#include "save/slot/saveslot.h"
#include "save/save.h"
#include "save/savejson.h"
#include "savetestfixture.h"
#include "util/memory.h"
#include "util/string.h"
#include <stdlib.h>
#include <string.h>
// saveSlotInit() seeds the party roster via partyInit(), which loads
// party.jsonc through the real asset system - stub it out (see
// savetestfixture.h) rather than needing a real asset bundle here.
static int assetGroupSetup(void **state) {
saveTestFixtureStubPartyAsset();
return 0;
}
static int assetGroupTeardown(void **state) {
saveTestFixtureUnstubPartyAsset();
return 0;
}
// ============================================================
// Helpers - wrap the writeInit/readInit macros (which can only be used
// inside a function returning errorret_t) so tests can call them plainly.
// ============================================================
static errorret_t slotToJSON(
saveslot_t *slot, char_t **outJson, size_t *outLen
) {
writeInit();
errorret_t writeResult = saveSlotWriteJSON(slot, doc, object);
if(errorIsNotOk(writeResult)) {
yyjson_mut_doc_free(doc);
errorChain(writeResult);
}
*outJson = yyjson_mut_write(doc, 0, outLen);
yyjson_mut_doc_free(doc);
assertNotNull(*outJson, "Failed to write slot JSON");
errorOk();
}
static errorret_t slotFromJSON(saveslot_t *slot, const char_t *json) {
readInit(json, strlen(json));
errorret_t readResult = saveSlotReadJSON(slot, object);
yyjson_doc_free(jsonDoc);
errorChain(readResult);
errorOk();
}
// ============================================================
// saveSlotInit - only ever operates on SAVE.slot now.
// ============================================================
static void test_saveSlotInit_defaults(void **state) {
memorySet(&SAVE.slot, 0xFF, sizeof(SAVE.slot));
saveSlotInit(&SAVE.slot);
assert_int_equal(SAVE.slot.version, 1);
assert_int_equal(SAVE.slot.dataType, 0);
assert_int_equal(SAVE.slot.cachedData.name[0], '\0');
assert_true(SAVE.slot.cachedData.time.time == 0.0);
assert_int_equal(SAVE.slot.cachedData.playerLevel, 0);
}
// ============================================================
// saveSlotInUse / saveSlotHasSaved
// ============================================================
static void test_saveSlotInUse(void **state) {
saveSlotInit(&SAVE.slot);
assert_false(saveSlotInUse(&SAVE.slot.cachedData));
stringCopy(
SAVE.slot.cachedData.name, "Hero", sizeof(SAVE.slot.cachedData.name)
);
assert_true(saveSlotInUse(&SAVE.slot.cachedData));
}
static void test_saveSlotInUse_nullAsserts(void **state) {
expect_assert_failure(saveSlotInUse(NULL));
}
static void test_saveSlotHasSaved(void **state) {
saveSlotInit(&SAVE.slot);
assert_false(saveSlotHasSaved(&SAVE.slot.cachedData));
// Writing to JSON stamps the current time as a side effect, so even a
// pure serialize (no device write) flips "has ever saved" to true.
char_t *json;
size_t len;
errorret_t ret = slotToJSON(&SAVE.slot, &json, &len);
assert_true(errorIsOk(ret));
assert_true(saveSlotHasSaved(&SAVE.slot.cachedData));
free(json);
}
static void test_saveSlotHasSaved_nullAsserts(void **state) {
expect_assert_failure(saveSlotHasSaved(NULL));
}
// ============================================================
// saveSlotWriteJSON
// ============================================================
static void test_saveSlotWriteJSON_nullAsserts(void **state) {
// Content is never touched - both calls assert on a NULL doc/object
// argument before slot itself would actually be read.
saveslot_t slot;
writeInit();
expect_assert_failure(saveSlotWriteJSON(NULL, doc, object));
expect_assert_failure(saveSlotWriteJSON(&slot, NULL, object));
expect_assert_failure(saveSlotWriteJSON(&slot, doc, NULL));
yyjson_mut_doc_free(doc);
}
// ============================================================
// saveSlotReadJSON
// ============================================================
static void test_saveSlotReadJSON_nullAsserts(void **state) {
saveslot_t slot;
yyjson_doc *readDoc = yyjson_read("{}", 2, 0);
yyjson_val *readObject = yyjson_doc_get_root(readDoc);
expect_assert_failure(saveSlotReadJSON(NULL, readObject));
expect_assert_failure(saveSlotReadJSON(&slot, NULL));
yyjson_doc_free(readDoc);
}
static void test_saveSlotReadJSON_roundTrip(void **state) {
// saveSlotInit() only ever operates on SAVE.slot now, so that's the
// write source here.
saveSlotInit(&SAVE.slot);
SAVE.slot.version = 7;// deliberately non-default, not part of the JSON schema
stringCopy(
SAVE.slot.cachedData.name, "Hero", sizeof(SAVE.slot.cachedData.name)
);
SAVE.slot.cachedData.playerLevel = 42;
// An in-use slot (non-empty name) requires a non-empty map name too -
// see saveSlotNewGame/requireStrMin("mapName", 1) in saveSlotVer1ReadJSON.
stringCopy(
SAVE.slot.cachedData.mapName, "overworld",
sizeof(SAVE.slot.cachedData.mapName)
);
char_t *json;
size_t len;
errorret_t writeRet = slotToJSON(&SAVE.slot, &json, &len);
assert_true(errorIsOk(writeRet));
const dusktimeepoch_t writtenTime = SAVE.slot.cachedData.time;
// No pre-init needed - saveSlotReadJSON() fully resets/overwrites its
// destination struct regardless of prior content.
saveslot_t read;
errorret_t readRet = slotFromJSON(&read, json);
assert_true(errorIsOk(readRet));
assert_true(stringEquals(read.cachedData.name, "Hero"));
assert_int_equal(read.cachedData.playerLevel, 42);
assert_true(stringEquals(read.cachedData.mapName, "overworld"));
assert_true(read.cachedData.time.time == writtenTime.time);
// version/dataType are struct-only bookkeeping, never serialized - the
// reader resets them to the default regardless of the writer's version.
assert_int_equal(read.version, 1);
free(json);
}
static void test_saveSlotReadJSON_blankSlot_shortCircuits(void **state) {
// Deliberately not saveSlotInit()'d - saveSlotReadJSON() fully resets its
// destination struct itself before parsing, so these sentinel values are
// only here to prove they get overwritten, not preserved.
saveslot_t slot;
memorySet(&slot, 0, sizeof(slot));
stringCopy(slot.cachedData.name, "sentinel", sizeof(slot.cachedData.name));
slot.cachedData.playerLevel = 999;
// No "name" key (or an empty one) means a blank/never-used slot (see
// saveSlotInUse) - the reader returns right after reading that, without
// needing or validating time/mapName/partyMembers/partyOrder at all, so
// this succeeds despite omitting fields that would otherwise be required.
errorret_t ret = slotFromJSON(&slot, "{\"version\":1}");
assert_true(errorIsOk(ret));
assert_int_equal(slot.cachedData.name[0], '\0');
assert_int_equal(slot.cachedData.mapName[0], '\0');
// Not readInt32's own default of 1 - the early return happens before
// playerLevel is ever read, leaving the zero-init saveSlotCurrentReadJSON
// seeds ver1 with (see the memoryZero in saveSlotCurrentReadJSON).
assert_int_equal(slot.cachedData.playerLevel, 0);
}
static void test_saveSlotReadJSON_inUseMissingMapName_errors(void **state) {
saveslot_t slot;
// "name" is present (in use) but "mapName" is missing - a real save
// (via saveSlotNewGame) always has both, so this is corrupt.
errorret_t ret = slotFromJSON(
&slot,
"{\"version\":1,\"name\":\"Hero\","
"\"time\":{\"time\":1.0,\"timeZone\":0.0,\"offsetTime\":0.0}}"
);
assert_true(errorIsNotOk(ret));
errorCatch(ret);
}
static void test_saveSlotReadJSON_missingTimeKey_errors(void **state) {
saveslot_t slot;
errorret_t ret = slotFromJSON(
&slot, "{\"version\":1,\"name\":\"Hero\",\"playerLevel\":5}"
);
assert_true(errorIsNotOk(ret));
errorCatch(ret);
}
static void test_saveSlotReadJSON_nameTooLong_errors(void **state) {
saveslot_t slot;
errorret_t ret = slotFromJSON(
&slot,
"{\"version\":1,\"name\":\"WayTooLongAName\","
"\"time\":{\"time\":1.0,\"timeZone\":0.0,\"offsetTime\":0.0}}"
);
assert_true(errorIsNotOk(ret));
errorCatch(ret);
}
static void test_saveSlotReadJSON_nonObjectRoot_errors(void **state) {
saveslot_t slot;
// A non-object root has no keys, so every field falls back to its
// default - except "version" and "time", which are required and error
// instead.
errorret_t ret = slotFromJSON(&slot, "[]");
assert_true(errorIsNotOk(ret));
errorCatch(ret);
}
// ============================================================
// saveSlotReadJSON - version enforcement
// ============================================================
static void test_saveSlotReadJSON_versionMissing_errors(void **state) {
saveslot_t slot;
errorret_t ret = slotFromJSON(
&slot,
"{\"name\":\"Hero\","
"\"time\":{\"time\":1.0,\"timeZone\":0.0,\"offsetTime\":0.0}}"
);
assert_true(errorIsNotOk(ret));
errorCatch(ret);
}
static void test_saveSlotReadJSON_versionMismatch_errors(void **state) {
saveslot_t slot;
errorret_t ret = slotFromJSON(
&slot,
"{\"version\":999,\"name\":\"Hero\","
"\"time\":{\"time\":1.0,\"timeZone\":0.0,\"offsetTime\":0.0}}"
);
assert_true(errorIsNotOk(ret));
errorCatch(ret);
}
int main(void) {
assertInit();
const struct CMUnitTest tests[] = {
cmocka_unit_test(test_saveSlotInit_defaults),
cmocka_unit_test(test_saveSlotInUse),
cmocka_unit_test(test_saveSlotInUse_nullAsserts),
cmocka_unit_test(test_saveSlotHasSaved),
cmocka_unit_test(test_saveSlotHasSaved_nullAsserts),
cmocka_unit_test(test_saveSlotWriteJSON_nullAsserts),
cmocka_unit_test(test_saveSlotReadJSON_nullAsserts),
cmocka_unit_test(test_saveSlotReadJSON_roundTrip),
cmocka_unit_test(test_saveSlotReadJSON_blankSlot_shortCircuits),
cmocka_unit_test(test_saveSlotReadJSON_inUseMissingMapName_errors),
cmocka_unit_test(test_saveSlotReadJSON_missingTimeKey_errors),
cmocka_unit_test(test_saveSlotReadJSON_nameTooLong_errors),
cmocka_unit_test(test_saveSlotReadJSON_nonObjectRoot_errors),
cmocka_unit_test(test_saveSlotReadJSON_versionMissing_errors),
cmocka_unit_test(test_saveSlotReadJSON_versionMismatch_errors),
};
return cmocka_run_group_tests(tests, assetGroupSetup, assetGroupTeardown);
}