using System; using System.Collections; using System.Collections.Generic; using System.IO; using System.Linq; using Cysharp.Threading.Tasks; using Newtonsoft.Json.Linq; using NUnit.Framework; using UnityEngine; using UnityEngine.TestTools; using Object = UnityEngine.Object; namespace ShrinkDataSaver.Tests { [TestFixture] public class ShrinkSaveTests { private MockStorageProvider _storage; private ShrinkDataSaverSettings _settings; [Serializable] private class PlayerData { public string Name = "TestPlayer"; public int Level = 1; public int Coins = 100; } private PlayerData _playerData; [SetUp] public void SetUp() { _storage = new MockStorageProvider(); _settings = ScriptableObject.CreateInstance(); _settings.maxSlots = 10; ShrinkDataSaverSettings.Instance = _settings; ShrinkSave.ResetForTesting(); MigrationChain.Clear(); ShrinkSave.Initialize(_storage, "saves", ".sav", 1); _playerData = new PlayerData(); } [TearDown] public void TearDown() { ShrinkSave.ResetForTesting(); MigrationChain.Clear(); if (_settings != null) Object.DestroyImmediate(_settings); } // ── 模块注册 ── [Test] public void RegisterModule_Lambda() { ShrinkSave.RegisterModule("player", () => _playerData, d => _playerData = d); Assert.IsTrue(ShrinkSave.HasModule("player")); } [Test] public void RegisterModule_WithConfig() { var config = new ModuleConfig { EnableCloudSync = false, AutoSaveIntervalSeconds = 30f, CriticalModule = true }; ShrinkSave.RegisterModule("player", () => _playerData, d => _playerData = d, config); var restored = ShrinkSave.GetModuleConfig("player"); Assert.IsFalse(restored.EnableCloudSync); Assert.AreEqual(30f, restored.AutoSaveIntervalSeconds); Assert.IsTrue(restored.CriticalModule); } [Test] public void UnregisterModule_RemovesModuleAndConfig() { ShrinkSave.RegisterModule("player", () => _playerData, d => _playerData = d, new ModuleConfig { CriticalModule = true }); ShrinkSave.UnregisterModule("player"); Assert.IsFalse(ShrinkSave.HasModule("player")); Assert.IsNull(ShrinkSave.GetModuleConfig("player")); } [Test] public void GetModuleConfig_NonExistent_ReturnsNull() { Assert.IsNull(ShrinkSave.GetModuleConfig("ghost")); } [Test] public void HasModule_ReturnsFalse_WhenNotRegistered() { Assert.IsFalse(ShrinkSave.HasModule("nonexistent")); } [Test] public void GetRegisteredModuleNames_ReturnsAll() { ShrinkSave.RegisterModule("a", () => 1, _ => { }); ShrinkSave.RegisterModule("b", () => 2, _ => { }); var names = ShrinkSave.GetRegisteredModuleNames(); Assert.AreEqual(2, names.Count); Assert.IsTrue(names.Contains("a")); Assert.IsTrue(names.Contains("b")); } // ── 保存 / 加载 / 删除 ── [UnityTest] public IEnumerator SaveSlotAsync_ThenExists() => UniTask.ToCoroutine(async () => { ShrinkSave.RegisterModule("player", () => _playerData, d => _playerData = d); await ShrinkSave.SaveSlotAsync(0, new SaveOptions { SlotName = "TestSave" }); Assert.IsTrue(await ShrinkSave.SlotExistsAsync(0)); }); [UnityTest] public IEnumerator SaveLoad_RoundTrip_DataIntegrity() => UniTask.ToCoroutine(async () => { _playerData = new PlayerData { Name = "Alice", Level = 10, Coins = 500 }; ShrinkSave.RegisterModule("player", () => _playerData, d => _playerData = d); await ShrinkSave.SaveSlotAsync(0, new SaveOptions { SlotName = "TestSave" }); // 清除内存数据 _playerData = new PlayerData(); Assert.AreEqual("TestPlayer", _playerData.Name); // 重新加载 await ShrinkSave.LoadSlotAsync(0); Assert.AreEqual("Alice", _playerData.Name); Assert.AreEqual(10, _playerData.Level); Assert.AreEqual(500, _playerData.Coins); }); [UnityTest] public IEnumerator SaveLoad_Encrypted_RoundTrip() => UniTask.ToCoroutine(async () => { _playerData = new PlayerData { Name = "Encrypted", Level = 99, Coins = 9999 }; ShrinkSave.RegisterModule("player", () => _playerData, d => _playerData = d); await ShrinkSave.SaveSlotAsync(0, new SaveOptions { SlotName = "Encrypted Save", Encrypt = true, EncryptionKey = "secret123" }); _playerData = new PlayerData(); await ShrinkSave.LoadSlotAsync(0, "secret123"); Assert.AreEqual("Encrypted", _playerData.Name); Assert.AreEqual(99, _playerData.Level); }); [UnityTest] public IEnumerator DeleteSlotAsync_RemovesSlot() => UniTask.ToCoroutine(async () => { ShrinkSave.RegisterModule("player", () => _playerData, d => _playerData = d); await ShrinkSave.SaveSlotAsync(0); Assert.IsTrue(await ShrinkSave.SlotExistsAsync(0)); await ShrinkSave.DeleteSlotAsync(0); Assert.IsFalse(await ShrinkSave.SlotExistsAsync(0)); }); [UnityTest] public IEnumerator LoadSlotAsync_NonExistent_ThrowsAndFiresLoadFailed() => UniTask.ToCoroutine(async () => { LoadFailedEventArgs failArgs = null; ShrinkSave.OnLoadFailed += args => failArgs = args; try { await ShrinkSave.LoadSlotAsync(99); Assert.Fail("Should have thrown"); } catch (System.IO.FileNotFoundException) { } Assert.IsNotNull(failArgs); Assert.AreEqual(99, failArgs.SlotIndex); Assert.IsTrue(failArgs.ErrorMessage.Contains("99")); }); [UnityTest] public IEnumerator SaveSlotAsync_NegativeIndex_Throws() => UniTask.ToCoroutine(async () => { ShrinkSave.RegisterModule("player", () => _playerData, d => _playerData = d); try { await ShrinkSave.SaveSlotAsync(-1); Assert.Fail("Should have thrown"); } catch (ArgumentOutOfRangeException) { } }); [UnityTest] public IEnumerator SaveSlotAsync_ExceedsMaxSlots_Throws() => UniTask.ToCoroutine(async () => { _settings.maxSlots = 3; ShrinkSave.RegisterModule("player", () => _playerData, d => _playerData = d); try { await ShrinkSave.SaveSlotAsync(5); Assert.Fail("Should have thrown"); } catch (ArgumentOutOfRangeException) { } }); // ── 事件 ── [UnityTest] public IEnumerator Events_SaveStarted_FiredBeforeSave() => UniTask.ToCoroutine(async () => { ShrinkSave.RegisterModule("player", () => _playerData, d => _playerData = d); SaveStartedEventArgs startArgs = null; ShrinkSave.OnSaveStarted += args => startArgs = args; await ShrinkSave.SaveSlotAsync(0); Assert.IsNotNull(startArgs); Assert.AreEqual(0, startArgs.SlotIndex); Assert.IsTrue(startArgs.Timestamp > 0); }); [UnityTest] public IEnumerator Events_SaveCompleted_ContainsModuleNames() => UniTask.ToCoroutine(async () => { ShrinkSave.RegisterModule("player", () => _playerData, d => _playerData = d); ShrinkSave.RegisterModule("settings", () => new { volume = 1f }, _ => { }); SaveCompletedEventArgs completedArgs = null; ShrinkSave.OnSaveCompleted += args => completedArgs = args; await ShrinkSave.SaveSlotAsync(0); Assert.IsNotNull(completedArgs); Assert.AreEqual(0, completedArgs.SlotIndex); Assert.AreEqual(2, completedArgs.ModuleNames.Length); Assert.IsTrue(completedArgs.Timestamp > 0); }); [UnityTest] public IEnumerator Events_LoadCompleted_ContainsVersionAndModules() => UniTask.ToCoroutine(async () => { ShrinkSave.RegisterModule("player", () => _playerData, d => _playerData = d); await ShrinkSave.SaveSlotAsync(0); LoadCompletedEventArgs loadArgs = null; ShrinkSave.OnLoadCompleted += args => loadArgs = args; await ShrinkSave.LoadSlotAsync(0); Assert.IsNotNull(loadArgs); Assert.AreEqual(0, loadArgs.SlotIndex); Assert.AreEqual(1, loadArgs.Version); Assert.IsTrue(loadArgs.ModuleNames.Length > 0); }); [UnityTest] public IEnumerator Events_LoadStarted_Fired() => UniTask.ToCoroutine(async () => { ShrinkSave.RegisterModule("player", () => _playerData, d => _playerData = d); await ShrinkSave.SaveSlotAsync(0); LoadStartedEventArgs startArgs = null; ShrinkSave.OnLoadStarted += args => startArgs = args; await ShrinkSave.LoadSlotAsync(0); Assert.IsNotNull(startArgs); Assert.AreEqual(0, startArgs.SlotIndex); }); [UnityTest] public IEnumerator Events_DeleteCompleted_Fired() => UniTask.ToCoroutine(async () => { ShrinkSave.RegisterModule("player", () => _playerData, d => _playerData = d); await ShrinkSave.SaveSlotAsync(0); SlotDeletedEventArgs deleteArgs = null; ShrinkSave.OnDeleteCompleted += args => deleteArgs = args; await ShrinkSave.DeleteSlotAsync(0); Assert.IsNotNull(deleteArgs); Assert.AreEqual(0, deleteArgs.SlotIndex); }); // ── 关键模块 ── [UnityTest] public IEnumerator CriticalModule_FailedSerialize_AbortsSave() => UniTask.ToCoroutine(async () => { // 注册一个会抛异常的关键模块 ShrinkSave.RegisterModule("broken", () => throw new Exception("boom"), _ => { }, new ModuleConfig { CriticalModule = true }); SaveFailedEventArgs failArgs = null; ShrinkSave.OnSaveFailed += args => failArgs = args; try { await ShrinkSave.SaveSlotAsync(0); Assert.Fail("Should have thrown"); } catch (InvalidOperationException) { } Assert.IsNotNull(failArgs); Assert.IsTrue(failArgs.ErrorMessage.Contains("broken")); }); [UnityTest] public IEnumerator NonCriticalModule_FailedSerialize_ContinuesSave() => UniTask.ToCoroutine(async () => { // 正常模块 ShrinkSave.RegisterModule("good", () => _playerData, d => _playerData = d); // 会失败的非关键模块 ShrinkSave.RegisterModule("bad", () => throw new Exception("oops"), _ => { }, new ModuleConfig { CriticalModule = false }); SaveCompletedEventArgs completedArgs = null; ShrinkSave.OnSaveCompleted += args => completedArgs = args; LogAssert.Expect(LogType.Error, "[ShrinkDataSaver] 模块 'bad' 序列化失败(已跳过): oops"); await ShrinkSave.SaveSlotAsync(0); // 保存应该成功(跳过了失败的非关键模块) Assert.IsNotNull(completedArgs); Assert.IsTrue(await ShrinkSave.SlotExistsAsync(0)); }); // ── 版本迁移事件 ── [UnityTest] public IEnumerator MigrationCompleted_FiredOnVersionMismatch() => UniTask.ToCoroutine(async () => { // 以 v1 保存 ShrinkSave.SetCurrentSaveVersion(1); ShrinkSave.RegisterModule("player", () => _playerData, d => _playerData = d); await ShrinkSave.SaveSlotAsync(0); // 设置迁移并升级到 v2 MigrationChain.Register(1, 2, data => { data["migrated"] = true; return data; }); ShrinkSave.SetCurrentSaveVersion(2); MigrationCompletedEventArgs migArgs = null; ShrinkSave.OnMigrationCompleted += args => migArgs = args; await ShrinkSave.LoadSlotAsync(0); Assert.IsNotNull(migArgs); Assert.AreEqual(0, migArgs.SlotIndex); Assert.AreEqual(1, migArgs.FromVersion); Assert.AreEqual(2, migArgs.ToVersion); }); // ── 跨模块查询 ── [Test] public void QueryModule_ReturnsCurrentData() { _playerData = new PlayerData { Name = "Query", Level = 5, Coins = 200 }; ShrinkSave.RegisterModule("player", () => _playerData, d => _playerData = d); var result = ShrinkSave.QueryModule("player"); Assert.IsNotNull(result); Assert.AreEqual("Query", result.Name); Assert.AreEqual(5, result.Level); } [Test] public void QueryModule_NonExistentModule_ReturnsDefault() { var result = ShrinkSave.QueryModule("ghost"); Assert.IsNull(result); } [UnityTest] public IEnumerator HasKey_AfterLoad_ChecksModuleData() => UniTask.ToCoroutine(async () => { _playerData = new PlayerData { Name = "KeyTest", Level = 1, Coins = 0 }; ShrinkSave.RegisterModule("player", () => _playerData, d => _playerData = d); await ShrinkSave.SaveSlotAsync(0); await ShrinkSave.LoadSlotAsync(0); Assert.IsTrue(ShrinkSave.HasKey("player", "Name")); Assert.IsTrue(ShrinkSave.HasKey("player", "Level")); Assert.IsFalse(ShrinkSave.HasKey("player", "NonExistentField")); Assert.IsFalse(ShrinkSave.HasKey("ghost_module", "Name")); }); [Test] public void HasKey_BeforeLoad_ReturnsFalse() { Assert.IsFalse(ShrinkSave.HasKey("player", "Name")); } // ── 自动保存间隔 ── [Test] public void GetMinAutoSaveInterval_NoModules_ReturnsZero() { Assert.AreEqual(0f, ShrinkSave.GetMinAutoSaveInterval()); } [Test] public void GetMinAutoSaveInterval_ReturnsMinimum() { ShrinkSave.RegisterModule("a", () => 1, _ => { }, new ModuleConfig { AutoSaveIntervalSeconds = 60f }); ShrinkSave.RegisterModule("b", () => 2, _ => { }, new ModuleConfig { AutoSaveIntervalSeconds = 30f }); ShrinkSave.RegisterModule("c", () => 3, _ => { }, new ModuleConfig { AutoSaveIntervalSeconds = 0f }); // 不参与 Assert.AreEqual(30f, ShrinkSave.GetMinAutoSaveInterval()); } // ── 元数据 ── [UnityTest] public IEnumerator GetMetaAsync_ReturnsMeta() => UniTask.ToCoroutine(async () => { ShrinkSave.RegisterModule("player", () => _playerData, d => _playerData = d); await ShrinkSave.SaveSlotAsync(0, new SaveOptions { SlotName = "MetaTest" }); var meta = await ShrinkSave.GetMetaAsync(0); Assert.IsNotNull(meta); Assert.AreEqual(0, meta.SlotIndex); Assert.AreEqual("MetaTest", meta.SlotName); Assert.AreEqual(1, meta.SaveVersion); Assert.IsFalse(meta.IsEncrypted); }); [UnityTest] public IEnumerator GetMetaAsync_NonExistent_ReturnsNull() => UniTask.ToCoroutine(async () => { var meta = await ShrinkSave.GetMetaAsync(99); Assert.IsNull(meta); }); [UnityTest] public IEnumerator GetAllMetaAsync_ReturnsAllSlots() => UniTask.ToCoroutine(async () => { ShrinkSave.RegisterModule("player", () => _playerData, d => _playerData = d); await ShrinkSave.SaveSlotAsync(0, new SaveOptions { SlotName = "Slot0" }); await ShrinkSave.SaveSlotAsync(1, new SaveOptions { SlotName = "Slot1" }); var allMeta = await ShrinkSave.GetAllMetaAsync(); Assert.AreEqual(2, allMeta.Length); Assert.AreEqual(0, allMeta[0].SlotIndex); Assert.AreEqual(1, allMeta[1].SlotIndex); }); // ── LoadedSlot ── [Test] public void LoadedSlot_InitiallyNegative() { Assert.AreEqual(-1, ShrinkSave.LoadedSlot); } [UnityTest] public IEnumerator LoadedSlot_UpdatedAfterLoad() => UniTask.ToCoroutine(async () => { ShrinkSave.RegisterModule("player", () => _playerData, d => _playerData = d); await ShrinkSave.SaveSlotAsync(0); await ShrinkSave.LoadSlotAsync(0); Assert.AreEqual(0, ShrinkSave.LoadedSlot); }); [UnityTest] public IEnumerator LoadedSlot_ResetAfterDelete() => UniTask.ToCoroutine(async () => { ShrinkSave.RegisterModule("player", () => _playerData, d => _playerData = d); await ShrinkSave.SaveSlotAsync(0); await ShrinkSave.LoadSlotAsync(0); Assert.AreEqual(0, ShrinkSave.LoadedSlot); await ShrinkSave.DeleteSlotAsync(0); Assert.AreEqual(-1, ShrinkSave.LoadedSlot); }); // ── 多模块 ── [UnityTest] public IEnumerator MultipleModules_SaveLoad_AllRestored() => UniTask.ToCoroutine(async () => { var inventory = new int[] { 1, 2, 3 }; var questFlag = new bool[] { true, false, true }; int[] loadedInv = null; bool[] loadedQuest = null; ShrinkSave.RegisterModule("inventory", () => inventory, d => loadedInv = d); ShrinkSave.RegisterModule("quests", () => questFlag, d => loadedQuest = d); await ShrinkSave.SaveSlotAsync(0); await ShrinkSave.LoadSlotAsync(0); Assert.AreEqual(new[] { 1, 2, 3 }, loadedInv); Assert.AreEqual(new[] { true, false, true }, loadedQuest); }); [UnityTest] public IEnumerator LoadSlotAsync_FallsBackToBackup_WhenPrimaryCorrupted() => UniTask.ToCoroutine(async () => { ShrinkSave.RegisterModule("player", () => _playerData, d => _playerData = d); _playerData = new PlayerData { Name = "BackupPlayer", Level = 7, Coins = 321 }; await ShrinkSave.SaveSlotAsync(0, new SaveOptions { SlotName = "BackupSlot" }); var backupBytes = await _storage.ReadAsync("saves/slot_0.sav"); await _storage.WriteAsync("saves/slot_0.sav.bak1", backupBytes); await _storage.WriteAsync("saves/slot_0.sav", DataSerializer.Serialize(new { broken = true })); _playerData = new PlayerData(); await ShrinkSave.LoadSlotAsync(0); Assert.AreEqual("BackupPlayer", _playerData.Name); var repairedPrimary = await _storage.ReadAsync("saves/slot_0.sav"); CollectionAssert.AreEqual(backupBytes, repairedPrimary); }); [UnityTest] public IEnumerator GetAllMetaAsync_UsesBackupOnlySlot_WhenPrimaryMissing() => UniTask.ToCoroutine(async () => { ShrinkSave.RegisterModule("player", () => _playerData, d => _playerData = d); await ShrinkSave.SaveSlotAsync(2, new SaveOptions { SlotName = "BackupOnly" }); var backupBytes = await _storage.ReadAsync("saves/slot_2.sav"); await _storage.WriteAsync("saves/slot_2.sav.bak1", backupBytes); await _storage.DeleteAsync("saves/slot_2.sav"); var metas = await ShrinkSave.GetAllMetaAsync(); Assert.AreEqual(1, metas.Length); Assert.AreEqual(2, metas[0].SlotIndex); Assert.AreEqual("BackupOnly", metas[0].SlotName); Assert.IsTrue(await _storage.ExistsAsync("saves/slot_2.sav")); }); [UnityTest] public IEnumerator GetRecommendedContinueSlotAsync_PrefersRecentAndSelfHeals() => UniTask.ToCoroutine(async () => { ShrinkSave.RegisterModule("player", () => _playerData, d => _playerData = d); _playerData = new PlayerData { Name = "Slot0", Level = 1, Coins = 10 }; await ShrinkSave.SaveSlotAsync(0, new SaveOptions { SlotName = "Slot0" }); _playerData = new PlayerData { Name = "Slot3", Level = 3, Coins = 30 }; await ShrinkSave.SaveSlotAsync(3, new SaveOptions { SlotName = "Slot3" }); await ShrinkSettings.LoadAsync(); ShrinkSettings.Set("ShrinkDataSaver.RecentSlotIndex", 3); await ShrinkSettings.SaveAsync(); var preferred = await ShrinkSave.GetRecommendedContinueSlotAsync(); Assert.AreEqual(3, preferred); await ShrinkSave.DeleteSlotAsync(3); preferred = await ShrinkSave.GetRecommendedContinueSlotAsync(); Assert.AreEqual(0, preferred); Assert.AreEqual(0, ShrinkSave.GetRecentSlotIndex()); }); } [TestFixture] public class LocalStorageProviderTests { private string _rootPath; private LocalStorageProvider _provider; [SetUp] public void SetUp() { _rootPath = Path.Combine(Path.GetTempPath(), $"ShrinkDataSaverTests_{Guid.NewGuid():N}"); Directory.CreateDirectory(_rootPath); _provider = new LocalStorageProvider(_rootPath); } [TearDown] public void TearDown() { if (Directory.Exists(_rootPath)) { Directory.Delete(_rootPath, true); } } [UnityTest] public IEnumerator WriteAsync_RotatesTwoBackupsAtomically() => UniTask.ToCoroutine(async () => { var relativePath = "saves/sample.sav"; await _provider.WriteAsync(relativePath, DataSerializer.Serialize(new { version = 1 })); await _provider.WriteAsync(relativePath, DataSerializer.Serialize(new { version = 2 })); await _provider.WriteAsync(relativePath, DataSerializer.Serialize(new { version = 3 })); var primary = DataSerializer.Deserialize(await _provider.ReadAsync(relativePath)); var backup1 = DataSerializer.Deserialize(await _provider.ReadAsync(relativePath + ".bak1")); var backup2 = DataSerializer.Deserialize(await _provider.ReadAsync(relativePath + ".bak2")); Assert.AreEqual(3, primary["version"]?.Value()); Assert.AreEqual(2, backup1["version"]?.Value()); Assert.AreEqual(1, backup2["version"]?.Value()); }); } }