REGULAR_BATTLE previously never completed on its own, hanging any cutscene that reached it - START_BATTLE already waits out the whole encounter and returns to the overworld before this item ever runs, so it's now a completes-immediately hook point for future regular-battle- specific follow-up. Also updates test_saveslot.c for the mapName/partyMembers schema additions: the round-trip test now writes a valid map name for its in-use slot, "missingFieldsUseDefaults" is replaced with a test for the blank-slot short-circuit it was actually hitting, and a new regression test locks in that an in-use slot with no map name is treated as corrupt. Co-Authored-By: Claude Sonnet 5 <[email protected]>
305 lines
9.4 KiB
C
305 lines
9.4 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/savejson.h"
|
|
#include "util/memory.h"
|
|
#include "util/string.h"
|
|
#include <stdlib.h>
|
|
#include <string.h>
|
|
|
|
// ============================================================
|
|
// 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
|
|
// ============================================================
|
|
|
|
static void test_saveSlotInit_defaults(void **state) {
|
|
saveslot_t slot;
|
|
memorySet(&slot, 0xFF, sizeof(slot));
|
|
|
|
saveSlotInit(&slot);
|
|
|
|
assert_int_equal(slot.version, 1);
|
|
assert_int_equal(slot.dataType, 0);
|
|
assert_int_equal(slot.cachedData.name[0], '\0');
|
|
assert_true(slot.cachedData.time.time == 0.0);
|
|
assert_int_equal(slot.cachedData.playerLevel, 0);
|
|
}
|
|
|
|
static void test_saveSlotInit_nullAsserts(void **state) {
|
|
expect_assert_failure(saveSlotInit(NULL));
|
|
}
|
|
|
|
// ============================================================
|
|
// saveSlotInUse / saveSlotHasSaved
|
|
// ============================================================
|
|
|
|
static void test_saveSlotInUse(void **state) {
|
|
saveslot_t slot;
|
|
saveSlotInit(&slot);
|
|
|
|
assert_false(saveSlotInUse(&slot.cachedData));
|
|
|
|
stringCopy(slot.cachedData.name, "Hero", sizeof(slot.cachedData.name));
|
|
assert_true(saveSlotInUse(&slot.cachedData));
|
|
}
|
|
|
|
static void test_saveSlotInUse_nullAsserts(void **state) {
|
|
expect_assert_failure(saveSlotInUse(NULL));
|
|
}
|
|
|
|
static void test_saveSlotHasSaved(void **state) {
|
|
saveslot_t slot;
|
|
saveSlotInit(&slot);
|
|
|
|
assert_false(saveSlotHasSaved(&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(&slot, &json, &len);
|
|
assert_true(errorIsOk(ret));
|
|
assert_true(saveSlotHasSaved(&slot.cachedData));
|
|
|
|
free(json);
|
|
}
|
|
|
|
static void test_saveSlotHasSaved_nullAsserts(void **state) {
|
|
expect_assert_failure(saveSlotHasSaved(NULL));
|
|
}
|
|
|
|
// ============================================================
|
|
// saveSlotWriteJSON
|
|
// ============================================================
|
|
|
|
static void test_saveSlotWriteJSON_nullAsserts(void **state) {
|
|
saveslot_t slot;
|
|
saveSlotInit(&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;
|
|
saveSlotInit(&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) {
|
|
saveslot_t written;
|
|
saveSlotInit(&written);
|
|
written.version = 7;// deliberately non-default, not part of the JSON schema
|
|
stringCopy(written.cachedData.name, "Hero", sizeof(written.cachedData.name));
|
|
written.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(
|
|
written.cachedData.mapName, "overworld",
|
|
sizeof(written.cachedData.mapName)
|
|
);
|
|
|
|
char_t *json;
|
|
size_t len;
|
|
errorret_t writeRet = slotToJSON(&written, &json, &len);
|
|
assert_true(errorIsOk(writeRet));
|
|
|
|
saveslot_t read;
|
|
saveSlotInit(&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 == written.cachedData.time.time);
|
|
|
|
// version/dataType are struct-only bookkeeping, never serialized - the
|
|
// reader keeps whatever saveSlotInit gave it regardless of the writer's
|
|
// version.
|
|
assert_int_equal(read.version, 1);
|
|
|
|
free(json);
|
|
}
|
|
|
|
static void test_saveSlotReadJSON_blankSlot_shortCircuits(void **state) {
|
|
saveslot_t slot;
|
|
saveSlotInit(&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;
|
|
saveSlotInit(&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;
|
|
saveSlotInit(&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;
|
|
saveSlotInit(&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;
|
|
saveSlotInit(&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;
|
|
saveSlotInit(&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;
|
|
saveSlotInit(&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_saveSlotInit_nullAsserts),
|
|
|
|
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, NULL, NULL);
|
|
}
|