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:
@@ -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:
|
||||
Reference in New Issue
Block a user