/** * 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 "util/memory.h" #include "util/string.h" #include #include // ============================================================ // 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(); 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(); 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(); 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.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, NULL, NULL); }