Files
dusk/test/save/test_saveslot.c
T
YourWishes 7a858cc424 Add a real test suite for the save system (previously had none)
Covers save.c (device discovery/orchestration), savedevice.c (the
generic device state machine and platform dispatch), and the Linux
platform backend (path building, availability checks, JSON read/write,
corrupt/missing-file handling), plus saveslot.c/savesettings.c JSON
round-trips. 88 tests across 5 files, all run against the real Linux
filesystem backend sandboxed to a temp $HOME (there's no mockable
platform layer - the hooks are compile-time macros, not function
pointers).

Deliberately locks in two existing behaviors rather than working around
them: saveSaveSettings() is a permanent no-op because nothing anywhere
ever sets SAVE.settingsDirty = true, and saveUpdate() unconditionally
rewrites settings back out the moment a device is found regardless of
that same dirty flag. Both are pre-existing, not introduced here.

Does not cover the SAVE_DEVICE_DATA_RAW blob codec (PSP/GameCube/Wii
only) or GameCube's 2-device fallback chain - neither compiles into the
Linux host test build.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-30 12:33:20 -05:00

239 lines
7.1 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/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;
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(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_missingFieldsUseDefaults(void **state) {
saveslot_t slot;
saveSlotInit(&slot);
stringCopy(slot.cachedData.name, "sentinel", sizeof(slot.cachedData.name));
slot.cachedData.playerLevel = 999;
errorret_t ret = slotFromJSON(
&slot, "{\"time\":{\"time\":1.0,\"timeZone\":0.0,\"offsetTime\":0.0}}"
);
assert_true(errorIsOk(ret));
assert_int_equal(slot.cachedData.name[0], '\0');
assert_int_equal(slot.cachedData.playerLevel, 1);
}
static void test_saveSlotReadJSON_missingTimeKey_errors(void **state) {
saveslot_t slot;
saveSlotInit(&slot);
errorret_t ret = slotFromJSON(&slot, "{\"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,
"{\"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 "time", which is not optional and errors instead.
errorret_t ret = slotFromJSON(&slot, "[]");
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_missingFieldsUseDefaults),
cmocka_unit_test(test_saveSlotReadJSON_missingTimeKey_errors),
cmocka_unit_test(test_saveSlotReadJSON_nameTooLong_errors),
cmocka_unit_test(test_saveSlotReadJSON_nonObjectRoot_errors),
};
return cmocka_run_group_tests(tests, NULL, NULL);
}