Files
dusk/test/save/test_save.c
T
YourWishesandClaude Sonnet 5 b8cbd8ff6a Make party and save-slot init/new-game operate on SAVE.slot directly
party.c's functions no longer take a party_t* - they always operate on
SAVE.slot.party, since gameplay only ever has one active party. That
forced the same change on saveSlotInit/saveSlotNewGame (they called
partyInit/partyAddMember internally), so those are now parameterless
too, always resetting/seeding SAVE.slot. Callers building a save at a
specific index (new-game creation, slot deletion) now set
SAVE.slotCurrent first instead of building an independent local struct -
safe since the select-save screen is only ever reached before any
gameplay starts.

saveSlotWriteJSON/saveSlotReadJSON deliberately keep their explicit
saveslot_t* parameter - they're genuine serialization primitives used
by the save-device layer and tests against arbitrary structs, unrelated
to the single-current-party assumption. saveSlotReadJSON's internal
reset is now a plain memset instead of delegating to the now-global-only
saveSlotInit().

Updates test_save/test_savedevice/test_savedevicelinux/test_saveslot for
the new signatures. Note: test_savedevice/test_savedevicelinux/test_save
have pre-existing failures in this sandbox unrelated to this change -
savetestfixture.c swaps $HOME, but saveDeviceLinuxGetDirectory actually
derives the save path from ASSET.baseDirectory (the executable's own
location), so the fixture's sandboxing never actually applies. test_
saveslot.c (which doesn't depend on that fixture) passes 15/15.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-12 21:30:08 -05:00

621 lines
20 KiB
C

/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#include "dusktest.h"
#include "savetestfixture.h"
#include "save/save.h"
#include "save/savedevicelinux.h"
#include "util/memory.h"
#include "util/string.h"
#include <stdio.h>
#include <stdlib.h>
// ============================================================
// Helpers
// ============================================================
static void noopAvailabilityCallback(savedevice_t *device, void *user) {}
// Skips saveFindAvailableDevice()'s bookkeeping (findingAvailableDevice
// etc.) and goes straight to "device 0 is current" - used by tests that
// only care about behavior once a device has been selected, not how it got
// selected. Still runs the real availability check underneath, since that
// is what actually creates the on-disk save directory via mkdirp - reads
// and writes fail without it.
static void makeDeviceAvailable(void) {
saveDeviceCheckAvailability(&SAVE.devices[0], noopAvailabilityCallback, NULL);
saveDeviceUpdate(&SAVE.devices[0]);
assert_int_equal(SAVE.devices[0].state, SAVE_DEVICE_STATE_AVAILABLE);
SAVE.deviceCurrent = 0;
}
static bool_t g_findCallbackFired;
static savedevice_t *g_findCallbackDevice;
static void findAvailableCallback(savedevice_t *device, void *user) {
g_findCallbackFired = true;
g_findCallbackDevice = device;
}
static void resetFindCallbackState(void) {
g_findCallbackFired = false;
g_findCallbackDevice = NULL;
}
// ============================================================
// saveInit
// ============================================================
static void test_saveInit_setsDefaults(void **state) {
// saveTestFixtureSetup already called the real saveInit() - just assert
// on the state it left behind.
assert_int_equal(SAVE.deviceCurrent, 0xFF);
assert_int_equal(SAVE.slotCurrent, 0xFF);
assert_false(SAVE.findingAvailableDevice);
assert_false(SAVE.noAvailableDeviceFound);
assert_true(SAVE.settings.audioMasterVolume == 1.0f);
assert_int_equal(SAVE.devices[0].state, SAVE_DEVICE_STATE_UNKNOWN);
for(uint8_t i = 0; i < SAVE_SLOT_COUNT; i++) {
assert_int_equal(SAVE.caches[i].name[0], '\0');
assert_int_equal(SAVE.caches[i].playerLevel, 0);
}
}
// ============================================================
// saveUpdate - no-op path
// ============================================================
static void test_saveUpdate_noopWhenNotFinding(void **state) {
errorret_t ret = saveUpdate();
assert_true(errorIsOk(ret));
assert_int_equal(SAVE.deviceCurrent, 0xFF);
assert_false(SAVE.findingAvailableDevice);
}
// ============================================================
// saveFindAvailableDevice / saveUpdate - discovery flow
// ============================================================
static void test_saveFindAvailableDevice_nullCallbackAsserts(void **state) {
expect_assert_failure(saveFindAvailableDevice(NULL, NULL));
}
static void test_saveFindAvailableDevice_reentrantAsserts(void **state) {
resetFindCallbackState();
saveFindAvailableDevice(findAvailableCallback, NULL);
expect_assert_failure(saveFindAvailableDevice(findAvailableCallback, NULL));
}
static void test_saveFindAvailableDevice_deviceAlreadyAvailable(void **state) {
resetFindCallbackState();
// Mark the device available ahead of time via a real check (this is also
// what creates the on-disk save directory that the "found device" flow
// below needs to write settings back out to).
saveDeviceCheckAvailability(&SAVE.devices[0], noopAvailabilityCallback, NULL);
saveDeviceUpdate(&SAVE.devices[0]);
assert_int_equal(SAVE.devices[0].state, SAVE_DEVICE_STATE_AVAILABLE);
saveFindAvailableDevice(findAvailableCallback, NULL);
// Set synchronously inside the call itself - no need to wait for update.
assert_int_equal(SAVE.deviceCurrent, 0);
assert_false(g_findCallbackFired);
errorret_t ret = saveUpdate();
assert_true(errorIsOk(ret));
assert_true(g_findCallbackFired);
assert_ptr_equal(g_findCallbackDevice, &SAVE.devices[0]);
assert_false(SAVE.findingAvailableDevice);
}
static void test_saveFindAvailableDevice_coldPath_oneUpdateResolves(
void **state
) {
resetFindCallbackState();
saveFindAvailableDevice(findAvailableCallback, NULL);
// Nothing resolved synchronously yet - the Linux availability check is
// synchronous, but the callback it queues is only drained by an explicit
// saveDeviceUpdate() inside saveUpdate().
assert_int_equal(SAVE.deviceCurrent, 0xFF);
assert_false(g_findCallbackFired);
errorret_t ret = saveUpdate();
assert_true(errorIsOk(ret));
assert_true(g_findCallbackFired);
assert_ptr_equal(g_findCallbackDevice, &SAVE.devices[0]);
assert_int_equal(SAVE.deviceCurrent, 0);
assert_false(SAVE.findingAvailableDevice);
}
static void test_saveFindAvailableDevice_allUnavailable_firesNullCallback(
void **state
) {
unsetenv("HOME");
resetFindCallbackState();
saveFindAvailableDevice(findAvailableCallback, NULL);
errorret_t ret = saveUpdate();
assert_true(errorIsOk(ret));
assert_true(g_findCallbackFired);
assert_null(g_findCallbackDevice);
assert_int_equal(SAVE.deviceCurrent, 0xFF);
assert_false(SAVE.findingAvailableDevice);
}
// ============================================================
// saveOnDeviceAvailabilityChecked
// ============================================================
static void test_saveOnDeviceAvailabilityChecked_nullDeviceAsserts(
void **state
) {
SAVE.findingAvailableDevice = true;
expect_assert_failure(saveOnDeviceAvailabilityChecked(NULL, NULL));
}
static void test_saveOnDeviceAvailabilityChecked_notFindingAsserts(
void **state
) {
SAVE.findingAvailableDevice = false;
expect_assert_failure(
saveOnDeviceAvailabilityChecked(&SAVE.devices[0], NULL)
);
}
static void test_saveOnDeviceAvailabilityChecked_ignoresIfAlreadyResolved(
void **state
) {
SAVE.findingAvailableDevice = true;
SAVE.deviceCurrent = 0;
SAVE.devices[0].state = SAVE_DEVICE_STATE_UNAVAILABLE;
saveOnDeviceAvailabilityChecked(&SAVE.devices[0], NULL);
// Already resolved - unavailable status on a re-delivered callback must
// not clobber the winning device or flip noAvailableDeviceFound.
assert_int_equal(SAVE.deviceCurrent, 0);
assert_false(SAVE.noAvailableDeviceFound);
}
static void test_saveOnDeviceAvailabilityChecked_singleDeviceUnavailable(
void **state
) {
SAVE.findingAvailableDevice = true;
SAVE.deviceCurrent = 0xFF;
SAVE.devices[0].state = SAVE_DEVICE_STATE_UNAVAILABLE;
saveOnDeviceAvailabilityChecked(&SAVE.devices[0], NULL);
// SAVE_DEVICE_COUNT is 1 on Linux, so an unavailable device 0 is always
// "the last device" - there's no next device to chain to.
assert_true(SAVE.noAvailableDeviceFound);
assert_int_equal(SAVE.deviceCurrent, 0xFF);
}
static void test_saveOnDeviceAvailabilityChecked_deviceAvailable(
void **state
) {
SAVE.findingAvailableDevice = true;
SAVE.deviceCurrent = 0xFF;
SAVE.devices[0].state = SAVE_DEVICE_STATE_AVAILABLE;
saveOnDeviceAvailabilityChecked(&SAVE.devices[0], NULL);
assert_int_equal(SAVE.deviceCurrent, 0);
assert_false(SAVE.noAvailableDeviceFound);
}
// ============================================================
// saveSaveSettings / saveLoadSettings
// ============================================================
static void test_saveSaveSettings_noCurrentDeviceAsserts(void **state) {
expect_assert_failure(saveSaveSettings());
}
static void test_saveSaveSettings_notDirty_isNoop(void **state) {
// Documents a real, currently-shipping bug: nothing anywhere in the
// codebase ever sets SAVE.settingsDirty = true, so saveSaveSettings() is
// permanently a no-op in practice. This test locks in that (surprising)
// behavior rather than the presumably-intended one, so it fails loudly
// if someone changes the no-op condition without meaning to.
makeDeviceAvailable();
assert_false(SAVE.settingsDirty);
SAVE.settings.audioMasterVolume = 12.0f;
errorret_t ret = saveSaveSettings();
assert_true(errorIsOk(ret));
savesettings_t onDisk;
saveSettingsInit(&onDisk);
errorret_t readRet = saveDeviceSettingsRead(&SAVE.devices[0], &onDisk);
assert_true(errorIsOk(readRet));
assert_true(onDisk.audioMasterVolume == 1.0f);// never actually written
}
static void test_saveSaveSettings_dirtyForced_writesAndClearsFlag(
void **state
) {
makeDeviceAvailable();
SAVE.settingsDirty = true;// nothing in real code sets this - forced here
SAVE.settings.audioMasterVolume = 5.5f;
errorret_t ret = saveSaveSettings();
assert_true(errorIsOk(ret));
assert_false(SAVE.settingsDirty);
savesettings_t onDisk;
saveSettingsInit(&onDisk);
errorret_t readRet = saveDeviceSettingsRead(&SAVE.devices[0], &onDisk);
assert_true(errorIsOk(readRet));
assert_true(onDisk.audioMasterVolume == 5.5f);
}
static void test_saveLoadSettings_noCurrentDeviceAsserts(void **state) {
expect_assert_failure(saveLoadSettings());
}
static void test_saveLoadSettings_readsAndClearsDirtyFlag(void **state) {
makeDeviceAvailable();
savesettings_t onDisk;
saveSettingsInit(&onDisk);
onDisk.audioMasterVolume = 9.25f;
errorret_t writeRet = saveDeviceSettingsWrite(&SAVE.devices[0], &onDisk);
assert_true(errorIsOk(writeRet));
SAVE.settingsDirty = true;
SAVE.settings.audioMasterVolume = 0.0f;
errorret_t ret = saveLoadSettings();
assert_true(errorIsOk(ret));
assert_true(SAVE.settings.audioMasterVolume == 9.25f);
assert_false(SAVE.settingsDirty);
}
// ============================================================
// saveSaveSlot / saveLoadSlot
// ============================================================
static void test_saveSaveSlot_noCurrentDeviceAsserts(void **state) {
expect_assert_failure(saveSaveSlot());
}
static void test_saveSaveSlot_invalidSlotIndexAsserts(void **state) {
makeDeviceAvailable();
// slotCurrent is still 0xFF (unset) from saveInit().
expect_assert_failure(saveSaveSlot());
}
static void test_saveSaveSlot_success(void **state) {
makeDeviceAvailable();
SAVE.slotCurrent = 1;
saveSlotInit();
stringCopy(
SAVE.slot.cachedData.name, "Hero", sizeof(SAVE.slot.cachedData.name)
);
SAVE.slot.cachedData.playerLevel = 12;
SAVE.slotDirty = true;
errorret_t ret = saveSaveSlot();
assert_true(errorIsOk(ret));
assert_false(SAVE.slotDirty);
assert_true(stringEquals(SAVE.caches[1].name, "Hero"));
assert_int_equal(SAVE.caches[1].playerLevel, 12);
// No pre-init needed - saveSlotReadJSON() (via saveDeviceSlotRead) fully
// resets/overwrites its destination struct regardless of prior content.
saveslot_t onDisk;
errorret_t readRet = saveDeviceSlotRead(&SAVE.devices[0], &onDisk, 1);
assert_true(errorIsOk(readRet));
assert_true(stringEquals(onDisk.cachedData.name, "Hero"));
assert_int_equal(onDisk.cachedData.playerLevel, 12);
}
static void test_saveLoadSlot_noCurrentDeviceAsserts(void **state) {
expect_assert_failure(saveLoadSlot());
}
static void test_saveLoadSlot_invalidSlotIndexAsserts(void **state) {
makeDeviceAvailable();
expect_assert_failure(saveLoadSlot());
}
static void test_saveLoadSlot_success(void **state) {
makeDeviceAvailable();
// Write slot 2's on-disk file via SAVE.slot itself (saveSlotInit() only
// ever operates on SAVE.slot now), then reset it again below - the
// write and the load-under-test don't overlap in time.
SAVE.slotCurrent = 2;
saveSlotInit();
stringCopy(
SAVE.slot.cachedData.name, "Zelda", sizeof(SAVE.slot.cachedData.name)
);
SAVE.slot.cachedData.playerLevel = 30;
errorret_t writeRet = saveDeviceSlotWrite(&SAVE.devices[0], &SAVE.slot, 2);
assert_true(errorIsOk(writeRet));
SAVE.slotCurrent = 2;
saveSlotInit();
SAVE.slotDirty = true;
errorret_t ret = saveLoadSlot();
assert_true(errorIsOk(ret));
assert_false(SAVE.slotDirty);
assert_true(stringEquals(SAVE.slot.cachedData.name, "Zelda"));
assert_int_equal(SAVE.slot.cachedData.playerLevel, 30);
assert_true(stringEquals(SAVE.caches[2].name, "Zelda"));
assert_int_equal(SAVE.caches[2].playerLevel, 30);
}
static void test_saveLoadSlot_deviceReadFails_cacheStaysStale(void **state) {
makeDeviceAvailable();
char_t slotPath[512];
errorret_t pathRet = saveDeviceLinuxGetSlotPath(
slotPath, sizeof(slotPath), 0
);
assert_true(errorIsOk(pathRet));
FILE *file = fopen(slotPath, "w");
assert_non_null(file);
fprintf(file, "not valid json {{{");
fclose(file);
SAVE.slotCurrent = 0;
saveSlotInit();
SAVE.caches[0].playerLevel = 77;// sentinel
errorret_t ret = saveLoadSlot();
assert_true(errorIsNotOk(ret));
errorCatch(ret);
// The device read failed before the cache-sync line ever ran.
assert_int_equal(SAVE.caches[0].playerLevel, 77);
}
// ============================================================
// saveLoadAllSlots
// ============================================================
static void test_saveLoadAllSlots_loadsAllAndEndsAtLastIndex(void **state) {
makeDeviceAvailable();
for(uint8_t i = 0; i < SAVE_SLOT_COUNT; i++) {
SAVE.slotCurrent = i;
saveSlotInit();
SAVE.slot.cachedData.playerLevel = (int32_t)(i + 1);
errorret_t saveRet = saveSaveSlot();
assert_true(errorIsOk(saveRet));
}
errorret_t ret = saveLoadAllSlots();
assert_true(errorIsOk(ret));
assert_int_equal(SAVE.slotCurrent, SAVE_SLOT_COUNT - 1);
for(uint8_t i = 0; i < SAVE_SLOT_COUNT; i++) {
assert_int_equal(SAVE.caches[i].playerLevel, (int32_t)(i + 1));
}
}
static void test_saveLoadAllSlots_missingFileLeavesInitDefaults(void **state) {
makeDeviceAvailable();
SAVE.slotCurrent = 0;
saveSlotInit();
SAVE.slot.cachedData.playerLevel = 5;
errorret_t saveRet = saveSaveSlot();
assert_true(errorIsOk(saveRet));
// Slots 1 and 2 are never written - no file exists for them.
errorret_t ret = saveLoadAllSlots();
assert_true(errorIsOk(ret));
assert_int_equal(SAVE.caches[0].playerLevel, 5);
// Missing-file slots fall back to saveSlotInit()'s default (0), not the
// JSON-missing-field default of 1 that a corrupt-but-present file with no
// playerLevel key would produce - these are two different defaults.
assert_int_equal(SAVE.caches[1].playerLevel, 0);
assert_int_equal(SAVE.caches[2].playerLevel, 0);
}
static void test_saveLoadAllSlots_middleSlotCorrupt_stopsAtFailingIndex(
void **state
) {
makeDeviceAvailable();
SAVE.slotCurrent = 0;
saveSlotInit();
errorret_t saveRet = saveSaveSlot();
assert_true(errorIsOk(saveRet));
char_t slotPath[512];
errorret_t pathRet = saveDeviceLinuxGetSlotPath(
slotPath, sizeof(slotPath), 1
);
assert_true(errorIsOk(pathRet));
FILE *file = fopen(slotPath, "w");
assert_non_null(file);
fprintf(file, "not valid json {{{");
fclose(file);
errorret_t ret = saveLoadAllSlots();
assert_true(errorIsNotOk(ret));
errorCatch(ret);
// The loop bails out as soon as slot 1 fails - it never reaches slot 2.
assert_int_equal(SAVE.slotCurrent, 1);
}
// ============================================================
// saveDispose
// ============================================================
static void test_saveDispose_succeeds(void **state) {
errorret_t ret = saveDispose();
assert_true(errorIsOk(ret));
}
// ============================================================
// saveUpdate - the unconditional settings write-back on device found
// ============================================================
static void test_saveUpdate_foundDevice_rewritesSettingsUnconditionally(
void **state
) {
// Documents another surprising bit of current behavior: as soon as a
// device is found, saveUpdate() writes SAVE.settings straight back out to
// it, regardless of settingsDirty - independent of (and unguarded by) the
// same dirty-flag check saveSaveSettings() itself honors.
resetFindCallbackState();
SAVE.settings.audioMasterVolume = 3.25f;
assert_false(SAVE.settingsDirty);
saveFindAvailableDevice(findAvailableCallback, NULL);
errorret_t ret = saveUpdate();
assert_true(errorIsOk(ret));
assert_true(g_findCallbackFired);
savesettings_t onDisk;
saveSettingsInit(&onDisk);
errorret_t readRet = saveDeviceSettingsRead(&SAVE.devices[0], &onDisk);
assert_true(errorIsOk(readRet));
assert_true(onDisk.audioMasterVolume == 3.25f);
}
int main(void) {
assertInit();
const struct CMUnitTest tests[] = {
cmocka_unit_test_setup_teardown(
test_saveInit_setsDefaults, saveTestFixtureSetup, saveTestFixtureTeardown
),
cmocka_unit_test_setup_teardown(
test_saveUpdate_noopWhenNotFinding,
saveTestFixtureSetup, saveTestFixtureTeardown
),
cmocka_unit_test_setup_teardown(
test_saveFindAvailableDevice_nullCallbackAsserts,
saveTestFixtureSetup, saveTestFixtureTeardown
),
cmocka_unit_test_setup_teardown(
test_saveFindAvailableDevice_reentrantAsserts,
saveTestFixtureSetup, saveTestFixtureTeardown
),
cmocka_unit_test_setup_teardown(
test_saveFindAvailableDevice_deviceAlreadyAvailable,
saveTestFixtureSetup, saveTestFixtureTeardown
),
cmocka_unit_test_setup_teardown(
test_saveFindAvailableDevice_coldPath_oneUpdateResolves,
saveTestFixtureSetup, saveTestFixtureTeardown
),
cmocka_unit_test_setup_teardown(
test_saveFindAvailableDevice_allUnavailable_firesNullCallback,
saveTestFixtureSetup, saveTestFixtureTeardown
),
cmocka_unit_test_setup_teardown(
test_saveOnDeviceAvailabilityChecked_nullDeviceAsserts,
saveTestFixtureSetup, saveTestFixtureTeardown
),
cmocka_unit_test_setup_teardown(
test_saveOnDeviceAvailabilityChecked_notFindingAsserts,
saveTestFixtureSetup, saveTestFixtureTeardown
),
cmocka_unit_test_setup_teardown(
test_saveOnDeviceAvailabilityChecked_ignoresIfAlreadyResolved,
saveTestFixtureSetup, saveTestFixtureTeardown
),
cmocka_unit_test_setup_teardown(
test_saveOnDeviceAvailabilityChecked_singleDeviceUnavailable,
saveTestFixtureSetup, saveTestFixtureTeardown
),
cmocka_unit_test_setup_teardown(
test_saveOnDeviceAvailabilityChecked_deviceAvailable,
saveTestFixtureSetup, saveTestFixtureTeardown
),
cmocka_unit_test_setup_teardown(
test_saveSaveSettings_noCurrentDeviceAsserts,
saveTestFixtureSetup, saveTestFixtureTeardown
),
cmocka_unit_test_setup_teardown(
test_saveSaveSettings_notDirty_isNoop,
saveTestFixtureSetup, saveTestFixtureTeardown
),
cmocka_unit_test_setup_teardown(
test_saveSaveSettings_dirtyForced_writesAndClearsFlag,
saveTestFixtureSetup, saveTestFixtureTeardown
),
cmocka_unit_test_setup_teardown(
test_saveLoadSettings_noCurrentDeviceAsserts,
saveTestFixtureSetup, saveTestFixtureTeardown
),
cmocka_unit_test_setup_teardown(
test_saveLoadSettings_readsAndClearsDirtyFlag,
saveTestFixtureSetup, saveTestFixtureTeardown
),
cmocka_unit_test_setup_teardown(
test_saveSaveSlot_noCurrentDeviceAsserts,
saveTestFixtureSetup, saveTestFixtureTeardown
),
cmocka_unit_test_setup_teardown(
test_saveSaveSlot_invalidSlotIndexAsserts,
saveTestFixtureSetup, saveTestFixtureTeardown
),
cmocka_unit_test_setup_teardown(
test_saveSaveSlot_success, saveTestFixtureSetup, saveTestFixtureTeardown
),
cmocka_unit_test_setup_teardown(
test_saveLoadSlot_noCurrentDeviceAsserts,
saveTestFixtureSetup, saveTestFixtureTeardown
),
cmocka_unit_test_setup_teardown(
test_saveLoadSlot_invalidSlotIndexAsserts,
saveTestFixtureSetup, saveTestFixtureTeardown
),
cmocka_unit_test_setup_teardown(
test_saveLoadSlot_success, saveTestFixtureSetup, saveTestFixtureTeardown
),
cmocka_unit_test_setup_teardown(
test_saveLoadSlot_deviceReadFails_cacheStaysStale,
saveTestFixtureSetup, saveTestFixtureTeardown
),
cmocka_unit_test_setup_teardown(
test_saveLoadAllSlots_loadsAllAndEndsAtLastIndex,
saveTestFixtureSetup, saveTestFixtureTeardown
),
cmocka_unit_test_setup_teardown(
test_saveLoadAllSlots_missingFileLeavesInitDefaults,
saveTestFixtureSetup, saveTestFixtureTeardown
),
cmocka_unit_test_setup_teardown(
test_saveLoadAllSlots_middleSlotCorrupt_stopsAtFailingIndex,
saveTestFixtureSetup, saveTestFixtureTeardown
),
cmocka_unit_test_setup_teardown(
test_saveDispose_succeeds, saveTestFixtureSetup, saveTestFixtureTeardown
),
cmocka_unit_test_setup_teardown(
test_saveUpdate_foundDevice_rewritesSettingsUnconditionally,
saveTestFixtureSetup, saveTestFixtureTeardown
),
};
return cmocka_run_group_tests(tests, NULL, NULL);
}