feat(packages): 内置 SDK 包并完善 ContextLoader 集成

- 将 ShrinkEventBus、ShrinkDataSaver 及其 EventBus 集成从 gitlink 转为仓库直接维护的完整 UPM 包,补齐运行时、编辑器工具、测试与文档
- 新增 Command 和 Network 的 App 集成组件,支持 ContextLoader 服务发布、可逆注销及 Network Loopback 生命周期管理
- 更新 Starter 与演示组合逻辑,缺失模块时可注册、已有兼容安装器时可覆盖,并补充宿主启动断言
- 升级内部包依赖与 Shared CodeGen 包定义,放宽 Integration.App 包的 Git 忽略规则
- 将独立服务器生成器改为基于已编译程序集的语义扫描,支持 partial、复杂泛型、命名冲突检测及模板 SHA-256 覆写保护
- 新增 Network 语义扫描、模板保护和 App 组件生命周期测试
- 新增真实 UPM 消费工程验证脚本,校验内部版本一致性、程序集加载及 EditMode 测试
- 重构当前架构文档并归档已完成的 Cordis 迁移与旧代码地图
This commit is contained in:
2026-08-18 18:06:34 +08:00
parent 517c4cf46e
commit d74c2f08ca
240 changed files with 13647 additions and 545 deletions
@@ -0,0 +1,81 @@
using System;
using NUnit.Framework;
namespace ShrinkDataSaver.Tests
{
[TestFixture]
public class DataSerializerTests
{
[Serializable]
private class SimpleData
{
public string Name = "test";
public int Score = 42;
}
[Serializable]
private class NestedData
{
public string Id = "root";
public SimpleData Child = new();
public int[] Numbers = { 1, 2, 3 };
}
[Test]
public void Serialize_Deserialize_SimpleObject()
{
var original = new SimpleData { Name = "Alice", Score = 100 };
var bytes = DataSerializer.Serialize(original);
var restored = DataSerializer.Deserialize<SimpleData>(bytes);
Assert.AreEqual("Alice", restored.Name);
Assert.AreEqual(100, restored.Score);
}
[Test]
public void Serialize_Deserialize_NestedObject()
{
var original = new NestedData
{
Id = "parent",
Child = new SimpleData { Name = "child", Score = 99 },
Numbers = new[] { 10, 20, 30 }
};
var bytes = DataSerializer.Serialize(original);
var restored = DataSerializer.Deserialize<NestedData>(bytes);
Assert.AreEqual("parent", restored.Id);
Assert.AreEqual("child", restored.Child.Name);
Assert.AreEqual(99, restored.Child.Score);
Assert.AreEqual(new[] { 10, 20, 30 }, restored.Numbers);
}
[Test]
public void ToJObject_FromJObject_RoundTrip()
{
var original = new SimpleData { Name = "Bob", Score = 77 };
var bytes = DataSerializer.Serialize(original);
var jObj = DataSerializer.ToJObject(bytes);
Assert.AreEqual("Bob", jObj["Name"].ToString());
Assert.AreEqual(77, (int)jObj["Score"]);
var bytesBack = DataSerializer.FromJObject(jObj);
var restored = DataSerializer.Deserialize<SimpleData>(bytesBack);
Assert.AreEqual("Bob", restored.Name);
Assert.AreEqual(77, restored.Score);
}
[Test]
public void Serialize_NullFields_Ignored()
{
var data = new SimpleData { Name = null, Score = 5 };
var bytes = DataSerializer.Serialize(data);
var json = System.Text.Encoding.UTF8.GetString(bytes);
Assert.IsFalse(json.Contains("Name"));
Assert.IsTrue(json.Contains("Score"));
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 818cff0034bbd7d4aa0c64c46fc28343
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,125 @@
using System;
using Newtonsoft.Json.Linq;
using NUnit.Framework;
using UnityEngine;
using UnityEngine.TestTools;
namespace ShrinkDataSaver.Tests
{
[TestFixture]
public class MigrationChainTests
{
[SetUp]
public void SetUp() => MigrationChain.Clear();
[TearDown]
public void TearDown() => MigrationChain.Clear();
[Test]
public void Register_ValidVersions_Succeeds()
{
Assert.DoesNotThrow(() =>
MigrationChain.Register(1, 2, data => data));
}
[Test]
public void Register_InvalidRange_Throws()
{
Assert.Throws<ArgumentException>(() =>
MigrationChain.Register(2, 1, data => data));
Assert.Throws<ArgumentException>(() =>
MigrationChain.Register(1, 1, data => data));
}
[Test]
public void Apply_SingleMigration()
{
MigrationChain.Register(1, 2, data =>
{
data["newField"] = "added";
return data;
});
var input = new JObject { ["existing"] = "value" };
var (result, version) = MigrationChain.Apply(input, 1, 2);
Assert.AreEqual(2, version);
Assert.AreEqual("value", result["existing"].ToString());
Assert.AreEqual("added", result["newField"].ToString());
}
[Test]
public void Apply_ChainMigration_1To3()
{
MigrationChain.Register(1, 2, data =>
{
data["coins"] = data["gold"];
data.Remove("gold");
return data;
});
MigrationChain.Register(2, 3, data =>
{
data["version3Field"] = 42;
return data;
});
var input = new JObject { ["gold"] = 100 };
var (result, version) = MigrationChain.Apply(input, 1, 3);
Assert.AreEqual(3, version);
Assert.AreEqual(100, result["coins"].Value<int>());
Assert.IsFalse(result.ContainsKey("gold"));
Assert.AreEqual(42, result["version3Field"].Value<int>());
}
[Test]
public void Apply_MissingStep_StopsEarly()
{
MigrationChain.Register(1, 2, data => data);
// 缺少 2→3 的迁移
var input = new JObject { ["data"] = "test" };
var (result, version) = MigrationChain.Apply(input, 1, 3);
// 应该停在 v2,因为没有 2→3 的迁移
Assert.AreEqual(2, version);
}
[Test]
public void Apply_FailedMigration_Rollback()
{
MigrationChain.Register(1, 2, data =>
{
data["step1"] = "done";
return data;
});
MigrationChain.Register(2, 3, data =>
{
throw new Exception("Migration failed!");
});
var input = new JObject { ["original"] = "data" };
LogAssert.Expect(LogType.Error, "[ShrinkDataSaver] 迁移 v2 → v3 失败: Migration failed!,已回滚至 v1");
var (result, version) = MigrationChain.Apply(input, 1, 3);
// 应该回滚到 v1 的备份数据
Assert.AreEqual(1, version);
Assert.AreEqual("data", result["original"].ToString());
// 回滚意味着 step1 的修改不会存在
Assert.IsFalse(result.ContainsKey("step1"));
}
[Test]
public void Apply_NoMigrationNeeded_ReturnsSameData()
{
var input = new JObject { ["data"] = "unchanged" };
var (result, version) = MigrationChain.Apply(input, 3, 3);
Assert.AreEqual(3, version);
Assert.AreEqual("unchanged", result["data"].ToString());
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: dba29246ec289aa4d9798abaef89a7bb
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,54 @@
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading;
using Cysharp.Threading.Tasks;
namespace ShrinkDataSaver.Tests
{
/// <summary>
/// 内存模拟存储,用于单元测试,无需文件 I/O。
/// </summary>
public class MockStorageProvider : IStorageProvider
{
private readonly Dictionary<string, byte[]> _store = new();
public UniTask WriteAsync(string path, byte[] data, CancellationToken ct = default)
{
_store[NormalizePath(path)] = data;
return UniTask.CompletedTask;
}
public UniTask<byte[]> ReadAsync(string path, CancellationToken ct = default)
{
var key = NormalizePath(path);
if (!_store.TryGetValue(key, out var data))
throw new FileNotFoundException($"MockStorage: {key}");
return UniTask.FromResult(data);
}
public UniTask<bool> ExistsAsync(string path, CancellationToken ct = default)
=> UniTask.FromResult(_store.ContainsKey(NormalizePath(path)));
public UniTask DeleteAsync(string path, CancellationToken ct = default)
{
_store.Remove(NormalizePath(path));
return UniTask.CompletedTask;
}
public UniTask<string[]> ListAsync(string prefix = "", CancellationToken ct = default)
{
var norm = NormalizePath(prefix);
var results = _store.Keys
.Where(k => string.IsNullOrEmpty(norm) || k.StartsWith(norm))
.ToArray();
return UniTask.FromResult(results);
}
public void Clear() => _store.Clear();
public int Count => _store.Count;
private static string NormalizePath(string path)
=> path.Replace('\\', '/');
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: b20797d49fc7bfc47a22c7be5bbd6cf2
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,89 @@
using System;
using System.Security.Cryptography;
using System.Text;
using NUnit.Framework;
namespace ShrinkDataSaver.Tests
{
[TestFixture]
public class SaveEncryptorTests
{
[Test]
public void Encrypt_Decrypt_RoundTrip()
{
var original = Encoding.UTF8.GetBytes("Hello, ShrinkDataSaver!");
var password = "TestPassword123";
var encrypted = SaveEncryptor.Encrypt(original, password);
var decrypted = SaveEncryptor.Decrypt(encrypted, password);
Assert.AreEqual(original, decrypted);
}
[Test]
public void Encrypt_Decrypt_LargeData()
{
var original = new byte[10000];
new System.Random(42).NextBytes(original);
var password = "LargeDataKey";
var encrypted = SaveEncryptor.Encrypt(original, password);
var decrypted = SaveEncryptor.Decrypt(encrypted, password);
Assert.AreEqual(original, decrypted);
}
[Test]
public void Decrypt_WrongPassword_Throws()
{
var original = Encoding.UTF8.GetBytes("secret data");
var encrypted = SaveEncryptor.Encrypt(original, "correct");
Assert.Throws<CryptographicException>(() =>
SaveEncryptor.Decrypt(encrypted, "wrong"));
}
[Test]
public void Encrypt_ProducesDifferentOutput_EachTime()
{
var data = Encoding.UTF8.GetBytes("same data");
var enc1 = SaveEncryptor.Encrypt(data, "key");
var enc2 = SaveEncryptor.Encrypt(data, "key");
// 因为随机 salt/IV,两次加密结果不同
Assert.AreNotEqual(enc1, enc2);
// 但两次都能正确解密
Assert.AreEqual(data, SaveEncryptor.Decrypt(enc1, "key"));
Assert.AreEqual(data, SaveEncryptor.Decrypt(enc2, "key"));
}
[Test]
public void Encrypt_NullData_Throws()
{
Assert.Throws<ArgumentNullException>(() =>
SaveEncryptor.Encrypt(null, "key"));
}
[Test]
public void Encrypt_EmptyPassword_Throws()
{
Assert.Throws<ArgumentException>(() =>
SaveEncryptor.Encrypt(new byte[] { 1, 2, 3 }, ""));
}
[Test]
public void Decrypt_NullData_Throws()
{
Assert.Throws<ArgumentNullException>(() =>
SaveEncryptor.Decrypt(null, "key"));
}
[Test]
public void Decrypt_DataTooShort_Throws()
{
Assert.Throws<ArgumentException>(() =>
SaveEncryptor.Decrypt(new byte[] { 1, 2, 3 }, "key"));
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: e5040f6e0d2203349aea6b1d2c85a46d
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,134 @@
using System;
using Newtonsoft.Json.Linq;
using NUnit.Framework;
using UnityEngine;
namespace ShrinkDataSaver.Tests
{
[TestFixture]
public class SaveTypesTests
{
// ── SaveMeta ──
[Test]
public void SaveMeta_LastModifiedTime_ConvertsCorrectly()
{
var meta = new SaveMeta
{
LastModified = 1700000000 // 2023-11-14 22:13:20 UTC
};
var dt = meta.LastModifiedTime;
Assert.AreEqual(2023, dt.Year);
Assert.AreEqual(11, dt.Month);
}
[Test]
public void SaveMeta_Defaults()
{
var meta = new SaveMeta();
Assert.AreEqual("New Save", meta.SlotName);
Assert.AreEqual(1, meta.SaveVersion);
Assert.IsFalse(meta.IsEncrypted);
Assert.IsNull(meta.ScreenshotBase64);
}
// ── ModuleConfig ──
[Test]
public void ModuleConfig_Defaults()
{
var cfg = new ModuleConfig();
Assert.IsTrue(cfg.EnableCloudSync);
Assert.AreEqual(0f, cfg.AutoSaveIntervalSeconds);
Assert.IsFalse(cfg.CriticalModule);
}
// ── SaveOptions ──
[Test]
public void SaveOptions_Defaults()
{
var opt = new SaveOptions();
Assert.AreEqual("New Save", opt.SlotName);
Assert.IsFalse(opt.CaptureScreenshot);
Assert.IsNull(opt.Screenshot);
Assert.IsFalse(opt.Encrypt);
Assert.IsNull(opt.EncryptionKey);
}
// ── LambdaSaveModule ──
[Test]
public void LambdaSaveModule_Serialize_CallsFactory()
{
var data = new TestData { Value = 42 };
var module = new LambdaSaveModule<TestData>("test", () => data, d => { });
Assert.AreEqual("test", module.Key);
var result = module.Serialize();
Assert.AreEqual(42, result.Value);
}
[Test]
public void LambdaSaveModule_Deserialize_CallsConsumer()
{
TestData received = null;
var module = new LambdaSaveModule<TestData>("test", () => null, d => received = d);
((ISaveModule)module).DeserializeRaw(JToken.FromObject(new TestData { Value = 99 }));
Assert.IsNotNull(received);
Assert.AreEqual(99, received.Value);
}
// ── 事件参数 ──
[Test]
public void SaveCompletedEventArgs_Fields()
{
var args = new SaveCompletedEventArgs
{
SlotIndex = 2,
ModuleNames = new[] { "inventory", "quests" },
Timestamp = 1234567890
};
Assert.AreEqual(2, args.SlotIndex);
Assert.AreEqual(2, args.ModuleNames.Length);
Assert.AreEqual(1234567890, args.Timestamp);
}
[Test]
public void LoadCompletedEventArgs_Fields()
{
var args = new LoadCompletedEventArgs
{
SlotIndex = 1,
ModuleNames = new[] { "player" },
Version = 3,
Timestamp = 9999
};
Assert.AreEqual(1, args.SlotIndex);
Assert.AreEqual(3, args.Version);
}
[Test]
public void MigrationCompletedEventArgs_Fields()
{
var args = new MigrationCompletedEventArgs
{
SlotIndex = 0, FromVersion = 1, ToVersion = 3
};
Assert.AreEqual(1, args.FromVersion);
Assert.AreEqual(3, args.ToVersion);
}
[Serializable]
public class TestData
{
public int Value;
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: c4fec6ba5af68dd46ae44776da037c69
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,25 @@
{
"name": "ShrinkDataSaver.Tests",
"rootNamespace": "ShrinkDataSaver.Tests",
"references": [
"ShrinkDataSaver.Runtime",
"UniTask",
"UnityEngine.TestRunner",
"UnityEditor.TestRunner"
],
"includePlatforms": [
"Editor"
],
"excludePlatforms": [],
"allowUnsafeCode": false,
"overrideReferences": true,
"precompiledReferences": [
"Newtonsoft.Json.dll"
],
"autoReferenced": false,
"defineConstraints": [
"UNITY_INCLUDE_TESTS"
],
"versionDefines": [],
"noEngineReferences": false
}
@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: ea4bb7f0bb4396d4e9454b19f4630613
AssemblyDefinitionImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,629 @@
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<ShrinkDataSaverSettings>();
_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<object>("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<object>("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<PlayerData>("player");
Assert.IsNotNull(result);
Assert.AreEqual("Query", result.Name);
Assert.AreEqual(5, result.Level);
}
[Test]
public void QueryModule_NonExistentModule_ReturnsDefault()
{
var result = ShrinkSave.QueryModule<PlayerData>("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<JObject>(await _provider.ReadAsync(relativePath));
var backup1 = DataSerializer.Deserialize<JObject>(await _provider.ReadAsync(relativePath + ".bak1"));
var backup2 = DataSerializer.Deserialize<JObject>(await _provider.ReadAsync(relativePath + ".bak2"));
Assert.AreEqual(3, primary["version"]?.Value<int>());
Assert.AreEqual(2, backup1["version"]?.Value<int>());
Assert.AreEqual(1, backup2["version"]?.Value<int>());
});
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: e2cf2ecb1a4173846bbcfb49d584d8bb
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,212 @@
using System.Collections;
using System.Collections.Generic;
using Cysharp.Threading.Tasks;
using NUnit.Framework;
using UnityEngine;
using UnityEngine.TestTools;
namespace ShrinkDataSaver.Tests
{
[TestFixture]
public class ShrinkSettingsTests
{
private MockStorageProvider _storage;
private ShrinkDataSaverSettings _settings;
[SetUp]
public void SetUp()
{
_storage = new MockStorageProvider();
_settings = ScriptableObject.CreateInstance<ShrinkDataSaverSettings>();
ShrinkDataSaverSettings.Instance = _settings;
ShrinkSettings.ResetForTesting();
ShrinkSettings.Initialize(_storage, "settings.json");
}
[TearDown]
public void TearDown()
{
ShrinkSettings.ResetForTesting();
if (_settings != null) Object.DestroyImmediate(_settings);
}
// ── Get / Set ──
[Test]
public void Set_Get_String()
{
ShrinkSettings.Set("name", "Alice");
Assert.AreEqual("Alice", ShrinkSettings.Get<string>("name"));
}
[Test]
public void Set_Get_Int()
{
ShrinkSettings.Set("score", 42);
Assert.AreEqual(42, ShrinkSettings.Get<int>("score"));
}
[Test]
public void Set_Get_Float()
{
ShrinkSettings.Set("volume", 0.75f);
Assert.AreEqual(0.75f, ShrinkSettings.Get<float>("volume"), 0.001f);
}
[Test]
public void Set_Get_Bool()
{
ShrinkSettings.Set("muted", true);
Assert.IsTrue(ShrinkSettings.Get<bool>("muted"));
}
[Test]
public void Set_Get_ComplexObject()
{
var data = new Dictionary<string, int> { { "a", 1 }, { "b", 2 } };
ShrinkSettings.Set("map", data);
var restored = ShrinkSettings.Get<Dictionary<string, int>>("map");
Assert.AreEqual(1, restored["a"]);
Assert.AreEqual(2, restored["b"]);
}
[Test]
public void Get_NonExistentKey_ReturnsDefault()
{
Assert.AreEqual(0, ShrinkSettings.Get<int>("missing"));
Assert.IsNull(ShrinkSettings.Get<string>("missing"));
Assert.AreEqual(99, ShrinkSettings.Get("missing", 99));
}
// ── Has / Remove ──
[Test]
public void Has_ExistingKey_ReturnsTrue()
{
ShrinkSettings.Set("key", "value");
Assert.IsTrue(ShrinkSettings.Has("key"));
}
[Test]
public void Has_NonExistentKey_ReturnsFalse()
{
Assert.IsFalse(ShrinkSettings.Has("ghost"));
}
[Test]
public void Remove_ExistingKey()
{
ShrinkSettings.Set("temp", 123);
Assert.IsTrue(ShrinkSettings.Has("temp"));
ShrinkSettings.Remove("temp");
Assert.IsFalse(ShrinkSettings.Has("temp"));
}
[Test]
public void Remove_NonExistentKey_NoError()
{
Assert.DoesNotThrow(() => ShrinkSettings.Remove("ghost"));
}
// ── GetAllRaw ──
[Test]
public void GetAllRaw_ReturnsAllSettings()
{
ShrinkSettings.Set("a", 1);
ShrinkSettings.Set("b", "two");
ShrinkSettings.Set("c", true);
var all = ShrinkSettings.GetAllRaw();
Assert.AreEqual(3, all.Count);
Assert.IsTrue(all.ContainsKey("a"));
Assert.IsTrue(all.ContainsKey("b"));
Assert.IsTrue(all.ContainsKey("c"));
}
// ── 事件 ──
[Test]
public void OnChanged_FiredOnSet()
{
string receivedKey = null;
object receivedValue = null;
ShrinkSettings.OnChanged += (k, v) => { receivedKey = k; receivedValue = v; };
ShrinkSettings.Set("volume", 0.5f);
Assert.AreEqual("volume", receivedKey);
}
[Test]
public void OnChanged_FiredOnRemove()
{
ShrinkSettings.Set("temp", "data");
string removedKey = null;
ShrinkSettings.OnChanged += (k, v) => { removedKey = k; };
ShrinkSettings.Remove("temp");
Assert.AreEqual("temp", removedKey);
}
// ── 持久化 ──
[UnityTest]
public IEnumerator SaveAsync_LoadAsync_RoundTrip() => UniTask.ToCoroutine(async () =>
{
ShrinkSettings.Set("persist_str", "hello");
ShrinkSettings.Set("persist_int", 42);
await ShrinkSettings.SaveAsync();
// 重置内存数据后重新加载
ShrinkSettings.ResetForTesting();
ShrinkSettings.Initialize(_storage, "settings.json");
await ShrinkSettings.LoadAsync();
Assert.AreEqual("hello", ShrinkSettings.Get<string>("persist_str"));
Assert.AreEqual(42, ShrinkSettings.Get<int>("persist_int"));
});
[UnityTest]
public IEnumerator LoadAsync_EmptyStorage_GivesCleanState() => UniTask.ToCoroutine(async () =>
{
await ShrinkSettings.LoadAsync();
Assert.AreEqual(0, ShrinkSettings.GetAllRaw().Count);
});
[UnityTest]
public IEnumerator LoadAsync_FallsBackToBackup_WhenPrimaryCorrupted() => UniTask.ToCoroutine(async () =>
{
ShrinkSettings.Set("lang", "zh_cn");
await ShrinkSettings.SaveAsync();
var backupBytes = await _storage.ReadAsync("settings.json");
await _storage.WriteAsync("settings.json.bak1", backupBytes);
// 用无法解析的字节模拟主文件损坏:结构合法但字段不符的 JSON 无法与“合法空设置”区分
await _storage.WriteAsync("settings.json", System.Text.Encoding.UTF8.GetBytes("{ \"lang\": \"zh_cn\" , broken"));
ShrinkSettings.ResetForTesting();
ShrinkSettings.Initialize(_storage, "settings.json");
await ShrinkSettings.LoadAsync();
Assert.AreEqual("zh_cn", ShrinkSettings.Get<string>("lang"));
var repairedPrimary = await _storage.ReadAsync("settings.json");
CollectionAssert.AreEqual(backupBytes, repairedPrimary);
});
// ── Overwrite ──
[Test]
public void Set_OverwriteExistingKey()
{
ShrinkSettings.Set("key", "first");
ShrinkSettings.Set("key", "second");
Assert.AreEqual("second", ShrinkSettings.Get<string>("key"));
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 40ae83c4b82a9c24dbd209f7a03f08e1
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant: