diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index 67450807..c09ae827 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -12,5 +12,6 @@ add_subdirectory(thread) add_subdirectory(display) add_subdirectory(rpg) add_subdirectory(item) +add_subdirectory(save) add_subdirectory(time) add_subdirectory(util) \ No newline at end of file diff --git a/test/save/CMakeLists.txt b/test/save/CMakeLists.txt new file mode 100644 index 00000000..52c3a9b0 --- /dev/null +++ b/test/save/CMakeLists.txt @@ -0,0 +1,19 @@ +# Copyright (c) 2026 Dominic Masters +# +# This software is released under the MIT License. +# https://opensource.org/licenses/MIT + +include(dusktest) + +# Tests +dusktest(test_savesettings.c) +dusktest(test_saveslot.c) + +dusktest(test_savedevicelinux.c) +target_sources(test_savedevicelinux PRIVATE savetestfixture.c) + +dusktest(test_savedevice.c) +target_sources(test_savedevice PRIVATE savetestfixture.c) + +dusktest(test_save.c) +target_sources(test_save PRIVATE savetestfixture.c) diff --git a/test/save/savetestfixture.c b/test/save/savetestfixture.c new file mode 100644 index 00000000..7b3d78a2 --- /dev/null +++ b/test/save/savetestfixture.c @@ -0,0 +1,81 @@ +/** + * Copyright (c) 2026 Dominic Masters + * + * This software is released under the MIT License. + * https://opensource.org/licenses/MIT + */ + +// nftw()/FTW_DEPTH/FTW_PHYS are glibc extensions gated behind this. +#define _XOPEN_SOURCE 700 + +#include "savetestfixture.h" +#include "assert/assert.h" +#include "util/memory.h" +#include "util/string.h" +#include +#include +#include +#include +#include + +static char_t SAVE_TEST_FIXTURE_HOME[PATH_MAX]; +static char_t SAVE_TEST_FIXTURE_REAL_HOME[PATH_MAX]; +static bool_t SAVE_TEST_FIXTURE_HAD_REAL_HOME; + +static int_t saveTestFixtureRemoveEntry( + const char *path, + const struct stat *statBuffer, + int typeFlag, + struct FTW *ftwBuffer +) { + return remove(path); +} + +int saveTestFixtureSetup(void **state) { + const char_t *realHome = getenv("HOME"); + SAVE_TEST_FIXTURE_HAD_REAL_HOME = realHome != NULL; + if(realHome != NULL) { + stringCopy( + SAVE_TEST_FIXTURE_REAL_HOME, realHome, sizeof(SAVE_TEST_FIXTURE_REAL_HOME) - 1 + ); + } + + stringCopy( + SAVE_TEST_FIXTURE_HOME, "/tmp/dusk-save-test-XXXXXX", + sizeof(SAVE_TEST_FIXTURE_HOME) - 1 + ); + assertNotNull(mkdtemp(SAVE_TEST_FIXTURE_HOME), "Failed to create temp $HOME"); + + assertTrue(setenv("HOME", SAVE_TEST_FIXTURE_HOME, 1) == 0, "setenv failed"); + + errorret_t ret = saveInit(); + assertTrue(errorIsOk(ret), "saveInit failed in test fixture"); + + return 0; +} + +int saveTestFixtureTeardown(void **state) { + nftw(SAVE_TEST_FIXTURE_HOME, saveTestFixtureRemoveEntry, 16, FTW_DEPTH | FTW_PHYS); + + if(SAVE_TEST_FIXTURE_HAD_REAL_HOME) { + setenv("HOME", SAVE_TEST_FIXTURE_REAL_HOME, 1); + } else { + unsetenv("HOME"); + } + + memoryZero(&SAVE, sizeof(SAVE)); + + return 0; +} + +void saveTestFixtureBlockSaveDirectory(void) { + const char_t *home = getenv("HOME"); + assertNotNull(home, "Fixture $HOME must be set before blocking it"); + + char_t path[PATH_MAX]; + stringFormat(path, sizeof(path), "%s/.dusk", home); + + FILE *file = fopen(path, "w"); + assertNotNull(file, "Failed to create blocking file"); + fclose(file); +} diff --git a/test/save/savetestfixture.h b/test/save/savetestfixture.h new file mode 100644 index 00000000..e4ed7c03 --- /dev/null +++ b/test/save/savetestfixture.h @@ -0,0 +1,37 @@ +/** + * Copyright (c) 2026 Dominic Masters + * + * This software is released under the MIT License. + * https://opensource.org/licenses/MIT + */ + +#pragma once +#include "save/save.h" + +/** + * Points $HOME at a fresh, empty temporary directory and resets the global + * SAVE state via the real saveInit(). Save tests run against the real Linux + * filesystem backend (there is no fake/mockable platform layer to swap in - + * the platform hooks are compile-time macros, not function pointers), so + * this sandboxing is what keeps tests from touching the real user's + * $HOME/.dusk/saves. Matches cmocka's CMUnitTestSetup signature - pass + * directly to cmocka_unit_test_setup_teardown. + */ +int saveTestFixtureSetup(void **state); + +/** + * Recursively removes the temporary $HOME directory tree created by + * saveTestFixtureSetup and restores the real $HOME. Matches cmocka's + * CMUnitTestTeardown signature - pass directly to + * cmocka_unit_test_setup_teardown. + */ +int saveTestFixtureTeardown(void **state); + +/** + * Places a regular file where the ".dusk" directory needs to go, so a + * subsequent availability check's mkdirp() call fails with ENOTDIR instead + * of succeeding. Used to deterministically exercise the "device unavailable + * because its directory couldn't be created" path without relying on real + * filesystem permission tricks. Must be called after saveTestFixtureSetup. + */ +void saveTestFixtureBlockSaveDirectory(void); diff --git a/test/save/test_save.c b/test/save/test_save.c new file mode 100644 index 00000000..beaa39ab --- /dev/null +++ b/test/save/test_save.c @@ -0,0 +1,602 @@ +/** + * 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 +#include + +// ============================================================ +// 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_int_equal(SAVE.settings.someSetting, 0); + 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.someSetting = 123; + + 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_int_equal(onDisk.someSetting, 0);// 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.someSetting = 55; + + 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_int_equal(onDisk.someSetting, 55); +} + +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.someSetting = 99; + errorret_t writeRet = saveDeviceSettingsWrite(&SAVE.devices[0], &onDisk); + assert_true(errorIsOk(writeRet)); + + SAVE.settingsDirty = true; + SAVE.settings.someSetting = 0; + + errorret_t ret = saveLoadSettings(); + assert_true(errorIsOk(ret)); + assert_int_equal(SAVE.settings.someSetting, 99); + 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(&SAVE.slot); + 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); + + saveslot_t onDisk; + saveSlotInit(&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(); + + saveslot_t onDisk; + saveSlotInit(&onDisk); + stringCopy(onDisk.cachedData.name, "Zelda", sizeof(onDisk.cachedData.name)); + onDisk.cachedData.playerLevel = 30; + errorret_t writeRet = saveDeviceSlotWrite(&SAVE.devices[0], &onDisk, 2); + assert_true(errorIsOk(writeRet)); + + SAVE.slotCurrent = 2; + saveSlotInit(&SAVE.slot); + 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.slot); + 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); + 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); + 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(&SAVE.slot); + 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.someSetting = 321; + 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_int_equal(onDisk.someSetting, 321); +} + +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); +} diff --git a/test/save/test_savedevice.c b/test/save/test_savedevice.c new file mode 100644 index 00000000..3eb43dd4 --- /dev/null +++ b/test/save/test_savedevice.c @@ -0,0 +1,323 @@ +/** + * 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/savedevice.h" +#include "save/saveslot.h" +#include "save/savesettings.h" +#include "util/memory.h" +#include "util/string.h" + +// ============================================================ +// saveDeviceInit +// ============================================================ + +static void test_saveDeviceInit_nullAsserts(void **state) { + expect_assert_failure(saveDeviceInit(NULL)); +} + +static void test_saveDeviceInit_setsUnknownState(void **state) { + savedevice_t device; + memorySet(&device, 0xAB, sizeof(device)); + + errorret_t ret = saveDeviceInit(&device); + assert_true(errorIsOk(ret)); + assert_int_equal(device.state, SAVE_DEVICE_STATE_UNKNOWN); + assert_false(device.fireCallback); +} + +// ============================================================ +// saveDeviceUpdate / saveDeviceFireCallback +// ============================================================ + +static void test_saveDeviceUpdate_nullAsserts(void **state) { + expect_assert_failure(saveDeviceUpdate(NULL)); +} + +static bool_t g_updateCallbackFired; +static void *g_updateCallbackUser; + +static void updateCallback(savedevice_t *device, void *user) { + g_updateCallbackFired = true; + g_updateCallbackUser = user; +} + +static void test_saveDeviceUpdate_firesQueuedCallbackOnce(void **state) { + savedevice_t device; + errorret_t initRet = saveDeviceInit(&device); + assert_true(errorIsOk(initRet)); + + device.stateCallback = updateCallback; + device.user = (void *)0x1234; + device.fireCallback = true; + + g_updateCallbackFired = false; + g_updateCallbackUser = NULL; + + errorret_t ret = saveDeviceUpdate(&device); + assert_true(errorIsOk(ret)); + assert_true(g_updateCallbackFired); + assert_ptr_equal(g_updateCallbackUser, (void *)0x1234); + assert_false(device.fireCallback); + + // A second update with nothing queued must not re-fire the callback. + g_updateCallbackFired = false; + ret = saveDeviceUpdate(&device); + assert_true(errorIsOk(ret)); + assert_false(g_updateCallbackFired); +} + +static void test_saveDeviceFireCallback_nullAsserts(void **state) { + expect_assert_failure(saveDeviceFireCallback(NULL)); +} + +static void test_saveDeviceFireCallback_setsFlag(void **state) { + savedevice_t device; + errorret_t initRet = saveDeviceInit(&device); + assert_true(errorIsOk(initRet)); + + saveDeviceFireCallback(&device); + assert_true(device.fireCallback); +} + +static void test_saveDeviceFireCallback_assertsIfAlreadyQueued(void **state) { + savedevice_t device; + errorret_t initRet = saveDeviceInit(&device); + assert_true(errorIsOk(initRet)); + + saveDeviceFireCallback(&device); + expect_assert_failure(saveDeviceFireCallback(&device)); +} + +// ============================================================ +// saveDeviceCheckAvailability +// ============================================================ + +static void test_saveDeviceCheckAvailability_nullAsserts(void **state) { + savedevice_t device; + errorret_t initRet = saveDeviceInit(&device); + assert_true(errorIsOk(initRet)); + + expect_assert_failure( + saveDeviceCheckAvailability(NULL, updateCallback, NULL) + ); + expect_assert_failure(saveDeviceCheckAvailability(&device, NULL, NULL)); +} + +static void test_saveDeviceCheckAvailability_assertsWhileAlreadyChecking( + void **state +) { + savedevice_t device; + errorret_t initRet = saveDeviceInit(&device); + assert_true(errorIsOk(initRet)); + + // Directly force the device into a mid-check state without going through + // the real (synchronous, on this platform) availability check - this + // simulates what a genuinely async platform would look like mid-flight. + device.state = SAVE_DEVICE_STATE_CHECKING_AVAILABILITY; + + expect_assert_failure( + saveDeviceCheckAvailability(&device, updateCallback, NULL) + ); +} + +static void test_saveDeviceCheckAvailability_resolvesSynchronouslyOnLinux( + void **state +) { + savedevice_t device; + errorret_t initRet = saveDeviceInit(&device); + assert_true(errorIsOk(initRet)); + + g_updateCallbackFired = false; + saveDeviceCheckAvailability(&device, updateCallback, NULL); + + // The Linux backend resolves availability synchronously inside the check + // call itself, but the callback is only *queued* (fireCallback), not + // fired, until an explicit saveDeviceUpdate() drains it. + assert_int_equal(device.state, SAVE_DEVICE_STATE_AVAILABLE); + assert_true(device.fireCallback); + assert_false(g_updateCallbackFired); + + errorret_t updateRet = saveDeviceUpdate(&device); + assert_true(errorIsOk(updateRet)); + assert_true(g_updateCallbackFired); +} + +// ============================================================ +// saveDeviceSlotWrite / saveDeviceSlotRead / saveDeviceSettingsWrite / +// saveDeviceSettingsRead - dispatch to the platform's four hooks. +// ============================================================ + +static void test_saveDeviceSlotWrite_nullAsserts(void **state) { + savedevice_t device; + saveslot_t slot; + saveSlotInit(&slot); + + expect_assert_failure(saveDeviceSlotWrite(NULL, &slot, 0)); + expect_assert_failure(saveDeviceSlotWrite(&device, NULL, 0)); +} + +static void test_saveDeviceSlotRead_nullAsserts(void **state) { + savedevice_t device; + saveslot_t slot; + saveSlotInit(&slot); + + expect_assert_failure(saveDeviceSlotRead(NULL, &slot, 0)); + expect_assert_failure(saveDeviceSlotRead(&device, NULL, 0)); +} + +static void test_saveDeviceSettingsWrite_nullAsserts(void **state) { + savedevice_t device; + savesettings_t settings; + saveSettingsInit(&settings); + + expect_assert_failure(saveDeviceSettingsWrite(NULL, &settings)); + expect_assert_failure(saveDeviceSettingsWrite(&device, NULL)); +} + +static void test_saveDeviceSettingsRead_nullAsserts(void **state) { + savedevice_t device; + savesettings_t settings; + saveSettingsInit(&settings); + + expect_assert_failure(saveDeviceSettingsRead(NULL, &settings)); + expect_assert_failure(saveDeviceSettingsRead(&device, NULL)); +} + +static void test_saveDeviceSlot_dispatchesToPlatform(void **state) { + savedevice_t device; + errorret_t initRet = saveDeviceInit(&device); + assert_true(errorIsOk(initRet)); + // The save directory must exist before a write can succeed - normally + // saveDeviceCheckAvailability does this via the platform's mkdirp. + saveDeviceCheckAvailability(&device, updateCallback, NULL); + saveDeviceUpdate(&device); + + saveslot_t written; + saveSlotInit(&written); + written.cachedData.playerLevel = 21; + + errorret_t writeRet = saveDeviceSlotWrite(&device, &written, 0); + assert_true(errorIsOk(writeRet)); + + saveslot_t read; + saveSlotInit(&read); + errorret_t readRet = saveDeviceSlotRead(&device, &read, 0); + assert_true(errorIsOk(readRet)); + + assert_int_equal(read.cachedData.playerLevel, 21); +} + +static void test_saveDeviceSettings_dispatchesToPlatform(void **state) { + savedevice_t device; + errorret_t initRet = saveDeviceInit(&device); + assert_true(errorIsOk(initRet)); + // The save directory must exist before a write can succeed - normally + // saveDeviceCheckAvailability does this via the platform's mkdirp. + saveDeviceCheckAvailability(&device, updateCallback, NULL); + saveDeviceUpdate(&device); + + savesettings_t written; + saveSettingsInit(&written); + written.someSetting = 88; + + errorret_t writeRet = saveDeviceSettingsWrite(&device, &written); + assert_true(errorIsOk(writeRet)); + + savesettings_t read; + saveSettingsInit(&read); + errorret_t readRet = saveDeviceSettingsRead(&device, &read); + assert_true(errorIsOk(readRet)); + + assert_int_equal(read.someSetting, 88); +} + +// ============================================================ +// saveDeviceDispose +// ============================================================ + +static void test_saveDeviceDispose_nullAsserts(void **state) { + expect_assert_failure(saveDeviceDispose(NULL)); +} + +static void test_saveDeviceDispose_succeeds(void **state) { + savedevice_t device; + errorret_t initRet = saveDeviceInit(&device); + assert_true(errorIsOk(initRet)); + + errorret_t ret = saveDeviceDispose(&device); + assert_true(errorIsOk(ret)); +} + +int main(void) { + assertInit(); + const struct CMUnitTest tests[] = { + cmocka_unit_test_setup_teardown( + test_saveDeviceInit_nullAsserts, saveTestFixtureSetup, saveTestFixtureTeardown + ), + cmocka_unit_test_setup_teardown( + test_saveDeviceInit_setsUnknownState, saveTestFixtureSetup, saveTestFixtureTeardown + ), + + cmocka_unit_test_setup_teardown( + test_saveDeviceUpdate_nullAsserts, saveTestFixtureSetup, saveTestFixtureTeardown + ), + cmocka_unit_test_setup_teardown( + test_saveDeviceUpdate_firesQueuedCallbackOnce, saveTestFixtureSetup, saveTestFixtureTeardown + ), + cmocka_unit_test_setup_teardown( + test_saveDeviceFireCallback_nullAsserts, saveTestFixtureSetup, saveTestFixtureTeardown + ), + cmocka_unit_test_setup_teardown( + test_saveDeviceFireCallback_setsFlag, saveTestFixtureSetup, saveTestFixtureTeardown + ), + cmocka_unit_test_setup_teardown( + test_saveDeviceFireCallback_assertsIfAlreadyQueued, saveTestFixtureSetup, saveTestFixtureTeardown + ), + + cmocka_unit_test_setup_teardown( + test_saveDeviceCheckAvailability_nullAsserts, saveTestFixtureSetup, saveTestFixtureTeardown + ), + cmocka_unit_test_setup_teardown( + test_saveDeviceCheckAvailability_assertsWhileAlreadyChecking, + saveTestFixtureSetup, saveTestFixtureTeardown + ), + cmocka_unit_test_setup_teardown( + test_saveDeviceCheckAvailability_resolvesSynchronouslyOnLinux, + saveTestFixtureSetup, saveTestFixtureTeardown + ), + + cmocka_unit_test_setup_teardown( + test_saveDeviceSlotWrite_nullAsserts, saveTestFixtureSetup, saveTestFixtureTeardown + ), + cmocka_unit_test_setup_teardown( + test_saveDeviceSlotRead_nullAsserts, saveTestFixtureSetup, saveTestFixtureTeardown + ), + cmocka_unit_test_setup_teardown( + test_saveDeviceSettingsWrite_nullAsserts, saveTestFixtureSetup, saveTestFixtureTeardown + ), + cmocka_unit_test_setup_teardown( + test_saveDeviceSettingsRead_nullAsserts, saveTestFixtureSetup, saveTestFixtureTeardown + ), + cmocka_unit_test_setup_teardown( + test_saveDeviceSlot_dispatchesToPlatform, saveTestFixtureSetup, saveTestFixtureTeardown + ), + cmocka_unit_test_setup_teardown( + test_saveDeviceSettings_dispatchesToPlatform, saveTestFixtureSetup, saveTestFixtureTeardown + ), + + cmocka_unit_test_setup_teardown( + test_saveDeviceDispose_nullAsserts, saveTestFixtureSetup, saveTestFixtureTeardown + ), + cmocka_unit_test_setup_teardown( + test_saveDeviceDispose_succeeds, saveTestFixtureSetup, saveTestFixtureTeardown + ), + }; + + return cmocka_run_group_tests(tests, NULL, NULL); +} diff --git a/test/save/test_savedevicelinux.c b/test/save/test_savedevicelinux.c new file mode 100644 index 00000000..b5950751 --- /dev/null +++ b/test/save/test_savedevicelinux.c @@ -0,0 +1,428 @@ +/** + * 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/savedevice.h" +#include "save/savedevicelinux.h" +#include "save/saveslot.h" +#include "save/savesettings.h" +#include "util/memory.h" +#include "util/string.h" +#include +#include + +// ============================================================ +// Helper: drives a device through saveDeviceCheckAvailability + +// saveDeviceUpdate (the real public flow), returning once the callback has +// fired. +// ============================================================ + +static bool_t g_callbackFired; +static savedevice_t *g_callbackDevice; + +static void checkAvailabilityCallback(savedevice_t *device, void *user) { + g_callbackFired = true; + g_callbackDevice = device; +} + +static void checkAvailabilityAndDrain(savedevice_t *device) { + g_callbackFired = false; + g_callbackDevice = NULL; + saveDeviceCheckAvailability(device, checkAvailabilityCallback, NULL); + saveDeviceUpdate(device); +} + +// ============================================================ +// saveDeviceLinuxGetDirectory / GetSettingsPath / GetSlotPath +// ============================================================ + +static void test_getDirectory_success(void **state) { + char_t path[512]; + errorret_t ret = saveDeviceLinuxGetDirectory(path, sizeof(path)); + assert_true(errorIsOk(ret)); + + char_t expected[512]; + stringFormat(expected, sizeof(expected), "%s/.dusk/saves", getenv("HOME")); + assert_true(stringEquals(path, expected)); +} + +static void test_getDirectory_homeUnset_errors(void **state) { + unsetenv("HOME"); + + char_t path[512]; + errorret_t ret = saveDeviceLinuxGetDirectory(path, sizeof(path)); + assert_true(errorIsNotOk(ret)); + errorCatch(ret); +} + +static void test_getDirectory_nullAsserts(void **state) { + expect_assert_failure(saveDeviceLinuxGetDirectory(NULL, 512)); +} + +static void test_getSettingsPath_success(void **state) { + char_t path[512]; + errorret_t ret = saveDeviceLinuxGetSettingsPath(path, sizeof(path)); + assert_true(errorIsOk(ret)); + + char_t expected[512]; + stringFormat( + expected, sizeof(expected), "%s/.dusk/saves/settings.json", getenv("HOME") + ); + assert_true(stringEquals(path, expected)); +} + +static void test_getSettingsPath_homeUnset_errors(void **state) { + unsetenv("HOME"); + + char_t path[512]; + errorret_t ret = saveDeviceLinuxGetSettingsPath(path, sizeof(path)); + assert_true(errorIsNotOk(ret)); + errorCatch(ret); +} + +static void test_getSlotPath_success(void **state) { + char_t path[512]; + errorret_t ret = saveDeviceLinuxGetSlotPath(path, sizeof(path), 2); + assert_true(errorIsOk(ret)); + + char_t expected[512]; + stringFormat( + expected, sizeof(expected), "%s/.dusk/saves/slot2.json", getenv("HOME") + ); + assert_true(stringEquals(path, expected)); +} + +static void test_getSlotPath_homeUnset_errors(void **state) { + unsetenv("HOME"); + + char_t path[512]; + errorret_t ret = saveDeviceLinuxGetSlotPath(path, sizeof(path), 0); + assert_true(errorIsNotOk(ret)); + errorCatch(ret); +} + +// ============================================================ +// saveDeviceLinuxCheckAvailability +// ============================================================ + +static void test_checkAvailability_wrongStateAsserts(void **state) { + savedevice_t device; + memoryZero(&device, sizeof(device)); + device.state = SAVE_DEVICE_STATE_UNKNOWN; + + expect_assert_failure(saveDeviceLinuxCheckAvailability(&device)); +} + +static void test_checkAvailability_nullAsserts(void **state) { + expect_assert_failure(saveDeviceLinuxCheckAvailability(NULL)); +} + +static void test_checkAvailability_success(void **state) { + savedevice_t device; + memoryZero(&device, sizeof(device)); + device.state = SAVE_DEVICE_STATE_UNKNOWN; + + checkAvailabilityAndDrain(&device); + + assert_int_equal(device.state, SAVE_DEVICE_STATE_AVAILABLE); + assert_null(device.reasonKey); + assert_true(g_callbackFired); + assert_ptr_equal(g_callbackDevice, &device); + + // The directory should now actually exist on disk (under the sandboxed + // $HOME the fixture set up). + char_t path[512]; + errorret_t pathRet = saveDeviceLinuxGetDirectory(path, sizeof(path)); + assert_true(errorIsOk(pathRet)); + FILE *dir = fopen(path, "r"); + // Directories can't be fopen'd for reading data, but a NULL here would + // mean the path doesn't exist at all - a non-NULL/EISDIR failure both + // indicate the directory exists. + if(dir != NULL) fclose(dir); +} + +static void test_checkAvailability_homeUnset(void **state) { + unsetenv("HOME"); + + savedevice_t device; + memoryZero(&device, sizeof(device)); + device.state = SAVE_DEVICE_STATE_UNKNOWN; + + checkAvailabilityAndDrain(&device); + + assert_int_equal(device.state, SAVE_DEVICE_STATE_UNAVAILABLE); + assert_true(stringEquals(device.reasonKey, "save.linux.no_home")); + assert_true(g_callbackFired); +} + +static void test_checkAvailability_mkdirpFails(void **state) { + saveTestFixtureBlockSaveDirectory(); + + savedevice_t device; + memoryZero(&device, sizeof(device)); + device.state = SAVE_DEVICE_STATE_UNKNOWN; + + checkAvailabilityAndDrain(&device); + + assert_int_equal(device.state, SAVE_DEVICE_STATE_UNAVAILABLE); + assert_true(stringEquals(device.reasonKey, "save.linux.mkdirp_failed")); + assert_true(g_callbackFired); +} + +// ============================================================ +// saveDeviceLinuxSlotWrite / saveDeviceLinuxSlotRead +// ============================================================ + +static void test_slotWrite_nullAsserts(void **state) { + savedevice_t device; + saveslot_t slot; + saveSlotInit(&slot); + + expect_assert_failure(saveDeviceLinuxSlotWrite(NULL, &slot, 0)); + expect_assert_failure(saveDeviceLinuxSlotWrite(&device, NULL, 0)); +} + +static void test_slotRead_nullAsserts(void **state) { + savedevice_t device; + saveslot_t slot; + saveSlotInit(&slot); + + expect_assert_failure(saveDeviceLinuxSlotRead(NULL, &slot, 0)); + expect_assert_failure(saveDeviceLinuxSlotRead(&device, NULL, 0)); +} + +static void test_slotWriteRead_roundTrip(void **state) { + savedevice_t device; + memoryZero(&device, sizeof(device)); + // The save directory must exist before a write can succeed - normally + // saveDeviceLinuxCheckAvailability does this via mkdirp. + checkAvailabilityAndDrain(&device); + + saveslot_t written; + saveSlotInit(&written); + stringCopy(written.cachedData.name, "Hero", sizeof(written.cachedData.name)); + written.cachedData.playerLevel = 9; + + errorret_t writeRet = saveDeviceLinuxSlotWrite(&device, &written, 1); + assert_true(errorIsOk(writeRet)); + + saveslot_t read; + saveSlotInit(&read); + + errorret_t readRet = saveDeviceLinuxSlotRead(&device, &read, 1); + assert_true(errorIsOk(readRet)); + + assert_true(stringEquals(read.cachedData.name, "Hero")); + assert_int_equal(read.cachedData.playerLevel, 9); +} + +static void test_slotRead_noFileYet_leavesSlotUntouched(void **state) { + savedevice_t device; + memoryZero(&device, sizeof(device)); + + saveslot_t slot; + saveSlotInit(&slot); + stringCopy(slot.cachedData.name, "sentinel", sizeof(slot.cachedData.name)); + slot.cachedData.playerLevel = 555; + + errorret_t ret = saveDeviceLinuxSlotRead(&device, &slot, 2); + assert_true(errorIsOk(ret)); + + assert_true(stringEquals(slot.cachedData.name, "sentinel")); + assert_int_equal(slot.cachedData.playerLevel, 555); +} + +static void test_slotRead_corruptFile_errors(void **state) { + savedevice_t device; + memoryZero(&device, sizeof(device)); + + // The directory must exist before we can drop a file into it - normally + // saveDeviceLinuxCheckAvailability does this via mkdirp. + savedevice_t tmpDevice; + memoryZero(&tmpDevice, sizeof(tmpDevice)); + tmpDevice.state = SAVE_DEVICE_STATE_CHECKING_AVAILABILITY; + saveDeviceLinuxCheckAvailability(&tmpDevice); + assert_int_equal(tmpDevice.state, SAVE_DEVICE_STATE_AVAILABLE); + + 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); + + saveslot_t slot; + saveSlotInit(&slot); + + errorret_t ret = saveDeviceLinuxSlotRead(&device, &slot, 0); + assert_true(errorIsNotOk(ret)); + errorCatch(ret); +} + +// ============================================================ +// saveDeviceLinuxSettingsWrite / saveDeviceLinuxSettingsRead +// ============================================================ + +static void test_settingsWrite_nullAsserts(void **state) { + savedevice_t device; + savesettings_t settings; + saveSettingsInit(&settings); + + expect_assert_failure(saveDeviceLinuxSettingsWrite(NULL, &settings)); + expect_assert_failure(saveDeviceLinuxSettingsWrite(&device, NULL)); +} + +static void test_settingsRead_nullAsserts(void **state) { + savedevice_t device; + savesettings_t settings; + saveSettingsInit(&settings); + + expect_assert_failure(saveDeviceLinuxSettingsRead(NULL, &settings)); + expect_assert_failure(saveDeviceLinuxSettingsRead(&device, NULL)); +} + +static void test_settingsWriteRead_roundTrip(void **state) { + savedevice_t device; + memoryZero(&device, sizeof(device)); + // The save directory must exist before a write can succeed - normally + // saveDeviceLinuxCheckAvailability does this via mkdirp. + checkAvailabilityAndDrain(&device); + + savesettings_t written; + saveSettingsInit(&written); + written.someSetting = 314; + + errorret_t writeRet = saveDeviceLinuxSettingsWrite(&device, &written); + assert_true(errorIsOk(writeRet)); + + savesettings_t read; + saveSettingsInit(&read); + + errorret_t readRet = saveDeviceLinuxSettingsRead(&device, &read); + assert_true(errorIsOk(readRet)); + assert_int_equal(read.someSetting, 314); +} + +static void test_settingsRead_noFileYet_leavesUntouched(void **state) { + savedevice_t device; + memoryZero(&device, sizeof(device)); + + savesettings_t settings; + saveSettingsInit(&settings); + settings.someSetting = 777; + + errorret_t ret = saveDeviceLinuxSettingsRead(&device, &settings); + assert_true(errorIsOk(ret)); + assert_int_equal(settings.someSetting, 777); +} + +static void test_settingsRead_corruptFile_errors(void **state) { + savedevice_t device; + memoryZero(&device, sizeof(device)); + + savedevice_t tmpDevice; + memoryZero(&tmpDevice, sizeof(tmpDevice)); + tmpDevice.state = SAVE_DEVICE_STATE_CHECKING_AVAILABILITY; + saveDeviceLinuxCheckAvailability(&tmpDevice); + assert_int_equal(tmpDevice.state, SAVE_DEVICE_STATE_AVAILABLE); + + char_t settingsPath[512]; + errorret_t pathRet = saveDeviceLinuxGetSettingsPath(settingsPath, sizeof(settingsPath)); + assert_true(errorIsOk(pathRet)); + + FILE *file = fopen(settingsPath, "w"); + assert_non_null(file); + fprintf(file, "not valid json {{{"); + fclose(file); + + savesettings_t settings; + saveSettingsInit(&settings); + + errorret_t ret = saveDeviceLinuxSettingsRead(&device, &settings); + assert_true(errorIsNotOk(ret)); + errorCatch(ret); +} + +int main(void) { + assertInit(); + const struct CMUnitTest tests[] = { + cmocka_unit_test_setup_teardown( + test_getDirectory_success, saveTestFixtureSetup, saveTestFixtureTeardown + ), + cmocka_unit_test_setup_teardown( + test_getDirectory_homeUnset_errors, saveTestFixtureSetup, saveTestFixtureTeardown + ), + cmocka_unit_test_setup_teardown( + test_getDirectory_nullAsserts, saveTestFixtureSetup, saveTestFixtureTeardown + ), + cmocka_unit_test_setup_teardown( + test_getSettingsPath_success, saveTestFixtureSetup, saveTestFixtureTeardown + ), + cmocka_unit_test_setup_teardown( + test_getSettingsPath_homeUnset_errors, saveTestFixtureSetup, saveTestFixtureTeardown + ), + cmocka_unit_test_setup_teardown( + test_getSlotPath_success, saveTestFixtureSetup, saveTestFixtureTeardown + ), + cmocka_unit_test_setup_teardown( + test_getSlotPath_homeUnset_errors, saveTestFixtureSetup, saveTestFixtureTeardown + ), + + cmocka_unit_test_setup_teardown( + test_checkAvailability_wrongStateAsserts, saveTestFixtureSetup, saveTestFixtureTeardown + ), + cmocka_unit_test_setup_teardown( + test_checkAvailability_nullAsserts, saveTestFixtureSetup, saveTestFixtureTeardown + ), + cmocka_unit_test_setup_teardown( + test_checkAvailability_success, saveTestFixtureSetup, saveTestFixtureTeardown + ), + cmocka_unit_test_setup_teardown( + test_checkAvailability_homeUnset, saveTestFixtureSetup, saveTestFixtureTeardown + ), + cmocka_unit_test_setup_teardown( + test_checkAvailability_mkdirpFails, saveTestFixtureSetup, saveTestFixtureTeardown + ), + + cmocka_unit_test_setup_teardown( + test_slotWrite_nullAsserts, saveTestFixtureSetup, saveTestFixtureTeardown + ), + cmocka_unit_test_setup_teardown( + test_slotRead_nullAsserts, saveTestFixtureSetup, saveTestFixtureTeardown + ), + cmocka_unit_test_setup_teardown( + test_slotWriteRead_roundTrip, saveTestFixtureSetup, saveTestFixtureTeardown + ), + cmocka_unit_test_setup_teardown( + test_slotRead_noFileYet_leavesSlotUntouched, saveTestFixtureSetup, saveTestFixtureTeardown + ), + cmocka_unit_test_setup_teardown( + test_slotRead_corruptFile_errors, saveTestFixtureSetup, saveTestFixtureTeardown + ), + + cmocka_unit_test_setup_teardown( + test_settingsWrite_nullAsserts, saveTestFixtureSetup, saveTestFixtureTeardown + ), + cmocka_unit_test_setup_teardown( + test_settingsRead_nullAsserts, saveTestFixtureSetup, saveTestFixtureTeardown + ), + cmocka_unit_test_setup_teardown( + test_settingsWriteRead_roundTrip, saveTestFixtureSetup, saveTestFixtureTeardown + ), + cmocka_unit_test_setup_teardown( + test_settingsRead_noFileYet_leavesUntouched, saveTestFixtureSetup, saveTestFixtureTeardown + ), + cmocka_unit_test_setup_teardown( + test_settingsRead_corruptFile_errors, saveTestFixtureSetup, saveTestFixtureTeardown + ), + }; + + return cmocka_run_group_tests(tests, NULL, NULL); +} diff --git a/test/save/test_savesettings.c b/test/save/test_savesettings.c new file mode 100644 index 00000000..cee69d7a --- /dev/null +++ b/test/save/test_savesettings.c @@ -0,0 +1,147 @@ +/** + * Copyright (c) 2026 Dominic Masters + * + * This software is released under the MIT License. + * https://opensource.org/licenses/MIT + */ + +#include "dusktest.h" +#include "save/savesettings.h" +#include "save/savejson.h" +#include "util/memory.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 settingsToJSON( + savesettings_t *settings, + char_t **outJson, + size_t *outLen +) { + writeInit(); + + errorret_t writeResult = saveSettingsWriteJSON(settings, 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 settings JSON"); + + errorOk(); +} + +static errorret_t settingsFromJSON(savesettings_t *settings, const char_t *json) { + readInit(json, strlen(json)); + errorret_t readResult = saveSettingsReadJSON(settings, object); + yyjson_doc_free(jsonDoc); + errorChain(readResult); + errorOk(); +} + +// ============================================================ +// saveSettingsInit +// ============================================================ + +static void test_saveSettingsInit_zeroesSomeSetting(void **state) { + savesettings_t settings; + memoryZero(&settings, sizeof(settings)); + settings.someSetting = 42; + + saveSettingsInit(&settings); + + assert_int_equal(settings.someSetting, 0); +} + +static void test_saveSettingsInit_nullAsserts(void **state) { + expect_assert_failure(saveSettingsInit(NULL)); +} + +// ============================================================ +// saveSettingsWriteJSON / saveSettingsReadJSON +// ============================================================ + +static void test_saveSettingsWriteJSON_nullAsserts(void **state) { + savesettings_t settings; + saveSettingsInit(&settings); + writeInit(); + + expect_assert_failure(saveSettingsWriteJSON(NULL, doc, object)); + expect_assert_failure(saveSettingsWriteJSON(&settings, NULL, object)); + expect_assert_failure(saveSettingsWriteJSON(&settings, doc, NULL)); + + yyjson_mut_doc_free(doc); +} + +static void test_saveSettingsReadJSON_nullAsserts(void **state) { + savesettings_t settings; + saveSettingsInit(&settings); + writeInit(); + yyjson_mut_obj_add_int(doc, object, "someSetting", 1); + + size_t len; + char_t *json = yyjson_mut_write(doc, 0, &len); + yyjson_mut_doc_free(doc); + assert_non_null(json); + + yyjson_doc *readDoc = yyjson_read(json, len, 0); + yyjson_val *readObject = yyjson_doc_get_root(readDoc); + + expect_assert_failure(saveSettingsReadJSON(NULL, readObject)); + expect_assert_failure(saveSettingsReadJSON(&settings, NULL)); + + yyjson_doc_free(readDoc); + free(json); +} + +static void test_saveSettingsWriteReadJSON_roundTrip(void **state) { + savesettings_t written; + saveSettingsInit(&written); + written.someSetting = 12345; + + char_t *json; + size_t len; + errorret_t writeRet = settingsToJSON(&written, &json, &len); + assert_true(errorIsOk(writeRet)); + + savesettings_t read; + saveSettingsInit(&read); + read.someSetting = -1;// sentinel, should be overwritten + + errorret_t readRet = settingsFromJSON(&read, json); + assert_true(errorIsOk(readRet)); + assert_int_equal(read.someSetting, 12345); + + free(json); +} + +static void test_saveSettingsReadJSON_missingKeyDefaultsToZero(void **state) { + savesettings_t settings; + saveSettingsInit(&settings); + settings.someSetting = 999;// sentinel + + errorret_t ret = settingsFromJSON(&settings, "{}"); + assert_true(errorIsOk(ret)); + assert_int_equal(settings.someSetting, 0); +} + +int main(void) { + assertInit(); + const struct CMUnitTest tests[] = { + cmocka_unit_test(test_saveSettingsInit_zeroesSomeSetting), + cmocka_unit_test(test_saveSettingsInit_nullAsserts), + + cmocka_unit_test(test_saveSettingsWriteJSON_nullAsserts), + cmocka_unit_test(test_saveSettingsReadJSON_nullAsserts), + cmocka_unit_test(test_saveSettingsWriteReadJSON_roundTrip), + cmocka_unit_test(test_saveSettingsReadJSON_missingKeyDefaultsToZero), + }; + + return cmocka_run_group_tests(tests, NULL, NULL); +} diff --git a/test/save/test_saveslot.c b/test/save/test_saveslot.c new file mode 100644 index 00000000..d40df04b --- /dev/null +++ b/test/save/test_saveslot.c @@ -0,0 +1,238 @@ +/** + * 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 +#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 +// ============================================================ + +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); +}