/** * 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); }