diff --git a/GFramework.Game.Tests/Data/PersistenceTestUtilities.cs b/GFramework.Game.Tests/Data/PersistenceTestUtilities.cs new file mode 100644 index 00000000..9324b7b8 --- /dev/null +++ b/GFramework.Game.Tests/Data/PersistenceTestUtilities.cs @@ -0,0 +1,20 @@ +using GFramework.Game.Abstractions.Data; +using GFramework.Game.Abstractions.Enums; + +namespace GFramework.Game.Tests.Data; + +internal sealed record TestDataLocation( + string Key, + StorageKinds Kinds = StorageKinds.Local, + string? Namespace = null, + IReadOnlyDictionary? Metadata = null) : IDataLocation; + +internal sealed class TestSaveData : IData +{ + public string Name { get; set; } = string.Empty; +} + +internal sealed class TestSimpleData : IData +{ + public int Value { get; set; } +} \ No newline at end of file diff --git a/GFramework.Game.Tests/Data/PersistenceTests.cs b/GFramework.Game.Tests/Data/PersistenceTests.cs new file mode 100644 index 00000000..c5937034 --- /dev/null +++ b/GFramework.Game.Tests/Data/PersistenceTests.cs @@ -0,0 +1,95 @@ +using System.IO; +using GFramework.Game.Abstractions.Data; +using GFramework.Game.Data; +using GFramework.Game.Serializer; +using GFramework.Game.Storage; + +namespace GFramework.Game.Tests.Data; + +[TestFixture] +public class PersistenceTests +{ + private static string CreateTempRoot() + { + var path = Path.Combine(Path.GetTempPath(), "gframework-persistence", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(path); + return path; + } + + [Test] + public async Task FileStorage_PersistsDataAndRejectsIllegalKeys() + { + var root = CreateTempRoot(); + using var storage = new FileStorage(root, new JsonSerializer(), ".json"); + + var saved = new TestSimpleData { Value = 5 }; + await storage.WriteAsync("folder/item", saved); + + var loaded = await storage.ReadAsync("folder/item"); + Assert.That(loaded.Value, Is.EqualTo(saved.Value)); + + Assert.ThrowsAsync(async () => await storage.WriteAsync("../escape", new TestSimpleData())); + } + + [Test] + public async Task SaveRepository_ManagesSlots() + { + var root = CreateTempRoot(); + using var storage = new FileStorage(root, new JsonSerializer()); + var config = new SaveConfiguration + { + SaveRoot = "saves", + SaveSlotPrefix = "slot_", + SaveFileName = "save.json" + }; + + var repository = new SaveRepository(storage, config); + var data = new TestSaveData { Name = "hero" }; + + await repository.SaveAsync(1, data); + Assert.That(await repository.ExistsAsync(1)); + + var loaded = await repository.LoadAsync(1); + Assert.That(loaded.Name, Is.EqualTo(data.Name)); + + var slots = await repository.ListSlotsAsync(); + Assert.That(slots, Is.EqualTo(new[] { 1 })); + + await repository.DeleteAsync(1); + Assert.That(await repository.ExistsAsync(1), Is.False); + } + + [Test] + public async Task UnifiedSettingsDataRepository_RoundTripsDataAndLoadAll() + { + var root = CreateTempRoot(); + using var storage = new FileStorage(root, new JsonSerializer()); + var serializer = new JsonSerializer(); + var repo = new UnifiedSettingsDataRepository( + storage, + serializer, + new DataRepositoryOptions { EnableEvents = false }, + "settings.json"); + + var location = new TestDataLocation("settings/choice"); + repo.RegisterDataType(location, typeof(TestSimpleData)); + + var data = new TestSimpleData { Value = 42 }; + await repo.SaveAsync(location, data); + + using var storage2 = new FileStorage(root, new JsonSerializer()); + var repo2 = new UnifiedSettingsDataRepository( + storage2, + serializer, + new DataRepositoryOptions { EnableEvents = false }, + "settings.json"); + repo2.RegisterDataType(location, typeof(TestSimpleData)); + + var loaded = await repo2.LoadAsync(location); + Assert.That(loaded.Value, Is.EqualTo(data.Value)); + + var all = await repo2.LoadAllAsync(); + Assert.That(all.Keys, Contains.Item(location.Key)); + Assert.That(all[location.Key], Is.TypeOf()); + } +} \ No newline at end of file diff --git a/GFramework.Game.Tests/Serializer/JsonSerializerTests.cs b/GFramework.Game.Tests/Serializer/JsonSerializerTests.cs new file mode 100644 index 00000000..d79f48b4 --- /dev/null +++ b/GFramework.Game.Tests/Serializer/JsonSerializerTests.cs @@ -0,0 +1,140 @@ +using Newtonsoft.Json; +using GameJsonSerializer = GFramework.Game.Serializer.JsonSerializer; + +namespace GFramework.Game.Tests.Serializer; + +[TestFixture] +public sealed class JsonSerializerTests +{ + [Test] + public void Serialize_And_Deserialize_Should_RoundTrip_Object() + { + var serializer = new GameJsonSerializer(); + var original = new PlayerStateStub + { + Name = "Player1", + Level = 7 + }; + + var json = serializer.Serialize(original); + var restored = serializer.Deserialize(json); + + Assert.Multiple(() => + { + Assert.That(restored.Name, Is.EqualTo("Player1")); + Assert.That(restored.Level, Is.EqualTo(7)); + }); + } + + [Test] + public void Serialize_Should_Honor_Injected_Settings() + { + var serializer = new GameJsonSerializer(new JsonSerializerSettings + { + Formatting = Formatting.Indented, + NullValueHandling = NullValueHandling.Ignore + }); + + var json = serializer.Serialize(new OptionalStateStub + { + Name = "Configured", + Description = null + }); + + Assert.Multiple(() => + { + Assert.That(json, Does.Contain(Environment.NewLine)); + Assert.That(json, Does.Contain("\"Name\": \"Configured\"")); + Assert.That(json, Does.Not.Contain("Description")); + }); + } + + [Test] + public void Converters_Should_Be_Used_For_Serialization_And_Deserialization() + { + var serializer = new GameJsonSerializer(); + serializer.Converters.Add(new CoordinateStubConverter()); + + var json = serializer.Serialize(new CoordinateStub { X = 3, Y = 9 }); + var restored = serializer.Deserialize(json); + + Assert.Multiple(() => + { + Assert.That(json, Is.EqualTo("\"3:9\"")); + Assert.That(restored.X, Is.EqualTo(3)); + Assert.That(restored.Y, Is.EqualTo(9)); + }); + } + + [Test] + public void Deserialize_Should_Throw_With_Target_Type_Context_When_Json_Is_Invalid() + { + var serializer = new GameJsonSerializer(); + + var exception = + Assert.Throws(() => serializer.Deserialize("{invalid json}")); + + Assert.Multiple(() => + { + Assert.That(exception, Is.Not.Null); + Assert.That(exception!.Message, Does.Contain(typeof(PlayerStateStub).FullName)); + Assert.That(exception.InnerException, Is.Not.Null); + }); + } + + [Test] + public void Deserialize_With_Runtime_Type_Should_Return_Target_Runtime_Type() + { + var serializer = new GameJsonSerializer(); + + var restored = serializer.Deserialize("{\"Name\":\"Runtime\",\"Level\":11}", typeof(PlayerStateStub)); + + Assert.That(restored, Is.TypeOf()); + Assert.That(((PlayerStateStub)restored).Level, Is.EqualTo(11)); + } + + private sealed class PlayerStateStub + { + public string Name { get; set; } = string.Empty; + + public int Level { get; set; } + } + + private sealed class OptionalStateStub + { + public string Name { get; set; } = string.Empty; + + public string? Description { get; set; } + } + + private sealed class CoordinateStub + { + public int X { get; set; } + + public int Y { get; set; } + } + + private sealed class CoordinateStubConverter : JsonConverter + { + public override void WriteJson(JsonWriter writer, CoordinateStub? value, JsonSerializer serializer) + { + writer.WriteValue($"{value?.X}:{value?.Y}"); + } + + public override CoordinateStub? ReadJson( + JsonReader reader, + Type objectType, + CoordinateStub? existingValue, + bool hasExistingValue, + JsonSerializer serializer) + { + var raw = (string?)reader.Value ?? throw new JsonSerializationException("Coordinate value cannot be null."); + var parts = raw.Split(':'); + return new CoordinateStub + { + X = int.Parse(parts[0]), + Y = int.Parse(parts[1]) + }; + } + } +} \ No newline at end of file diff --git a/GFramework.Game.Tests/Setting/SettingsModelTests.cs b/GFramework.Game.Tests/Setting/SettingsModelTests.cs new file mode 100644 index 00000000..984c517c --- /dev/null +++ b/GFramework.Game.Tests/Setting/SettingsModelTests.cs @@ -0,0 +1,181 @@ +using GFramework.Core.Abstractions.Architectures; +using GFramework.Core.Abstractions.Lifecycle; +using GFramework.Core.Abstractions.Rule; +using GFramework.Game.Abstractions.Data; +using GFramework.Game.Abstractions.Enums; +using GFramework.Game.Abstractions.Setting; +using GFramework.Game.Setting; + +namespace GFramework.Game.Tests.Setting; + +[TestFixture] +public sealed class SettingsModelTests +{ + [Test] + public void GetData_After_Initialize_Should_Register_New_Type_In_Repository() + { + var locationProvider = new TestDataLocationProvider(); + var repository = new FakeSettingsDataRepository(); + var model = new SettingsModel(locationProvider, repository); + ((IContextAware)model).SetContext(new Mock(MockBehavior.Loose).Object); + + ((IInitializable)model).Initialize(); + + _ = model.GetData(); + + Assert.That(repository.RegisteredTypes, Contains.Key("TestSettingsData")); + Assert.That(repository.RegisteredTypes["TestSettingsData"], Is.EqualTo(typeof(TestSettingsData))); + } + + [Test] + public async Task RegisterMigration_After_Cache_Warmup_Should_Invalidate_Type_Cache() + { + var locationProvider = new TestDataLocationProvider(); + var repository = new FakeSettingsDataRepository(); + var model = new SettingsModel(locationProvider, repository); + ((IContextAware)model).SetContext(new Mock(MockBehavior.Loose).Object); + + _ = model.GetData(); + ((IInitializable)model).Initialize(); + + repository.Stored["TestSettingsData"] = new TestSettingsData + { + Version = 1, + Value = "legacy" + }; + + await model.InitializeAsync(); + Assert.That(model.GetData().Version, Is.EqualTo(1)); + + model.RegisterMigration(new TestSettingsMigration()); + + repository.Stored["TestSettingsData"] = new TestSettingsData + { + Version = 1, + Value = "legacy" + }; + + await model.InitializeAsync(); + + var current = model.GetData(); + Assert.Multiple(() => + { + Assert.That(current.Version, Is.EqualTo(2)); + Assert.That(current.Value, Is.EqualTo("legacy-migrated")); + }); + } + + private sealed class TestSettingsData : ISettingsData + { + public string Value { get; set; } = "default"; + + public int Version { get; set; } = 1; + + public DateTime LastModified { get; } = DateTime.UtcNow; + + public void Reset() + { + Value = "default"; + Version = 1; + } + + public void LoadFrom(ISettingsData source) + { + if (source is not TestSettingsData data) + { + return; + } + + Value = data.Value; + Version = data.Version; + } + } + + private sealed class TestSettingsMigration : ISettingsMigration + { + public Type SettingsType => typeof(TestSettingsData); + + public int FromVersion => 1; + + public int ToVersion => 2; + + public ISettingsSection Migrate(ISettingsSection oldData) + { + var data = (TestSettingsData)oldData; + return new TestSettingsData + { + Version = 2, + Value = $"{data.Value}-migrated" + }; + } + } + + private sealed class FakeSettingsDataRepository : ISettingsDataRepository + { + public Dictionary RegisteredTypes { get; } = new(StringComparer.Ordinal); + + public Dictionary Stored { get; } = new(StringComparer.Ordinal); + + public Task LoadAsync(IDataLocation location) where T : class, IData, new() + { + return Task.FromResult(Stored.TryGetValue(location.Key, out var data) ? (T)data : new T()); + } + + public Task SaveAsync(IDataLocation location, T data) where T : class, IData + { + Stored[location.Key] = data; + return Task.CompletedTask; + } + + public Task ExistsAsync(IDataLocation location) + { + return Task.FromResult(Stored.ContainsKey(location.Key)); + } + + public Task DeleteAsync(IDataLocation location) + { + Stored.Remove(location.Key); + return Task.CompletedTask; + } + + public Task SaveAllAsync(IEnumerable<(IDataLocation location, IData data)> dataList) + { + foreach (var (location, data) in dataList) + { + Stored[location.Key] = data; + } + + return Task.CompletedTask; + } + + public Task> LoadAllAsync() + { + IDictionary snapshot = new Dictionary(Stored, StringComparer.Ordinal); + return Task.FromResult(snapshot); + } + + public void RegisterDataType(IDataLocation location, Type type) + { + RegisteredTypes[location.Key] = type; + } + } + + private sealed class TestDataLocationProvider : IDataLocationProvider + { + public IDataLocation GetLocation(Type type) + { + return new TestDataLocation(type.Name); + } + } + + private sealed class TestDataLocation(string key) : IDataLocation + { + public string Key { get; } = key; + + public StorageKinds Kinds => StorageKinds.Memory; + + public string? Namespace => "tests"; + + public IReadOnlyDictionary? Metadata => null; + } +} \ No newline at end of file diff --git a/GFramework.Game/Data/UnifiedSettingsDataRepository.cs b/GFramework.Game/Data/UnifiedSettingsDataRepository.cs index 56db1cbe..8fd693a7 100644 --- a/GFramework.Game/Data/UnifiedSettingsDataRepository.cs +++ b/GFramework.Game/Data/UnifiedSettingsDataRepository.cs @@ -80,11 +80,10 @@ public class UnifiedSettingsDataRepository( public async Task SaveAsync(IDataLocation location, T data) where T : class, IData { + await EnsureLoadedAsync(); await _lock.WaitAsync(); try { - await EnsureLoadedAsync(); - var key = location.Key; var serialized = Serializer.Serialize(data); @@ -211,7 +210,9 @@ public class UnifiedSettingsDataRepository( var key = UnifiedKey; - _file = await Storage.ExistsAsync(key) ? await Storage.ReadAsync(key) : new UnifiedSettingsFile { Version = 1 }; + _file = await Storage.ExistsAsync(key) + ? await Storage.ReadAsync(key) + : new UnifiedSettingsFile { Version = 1 }; _loaded = true; } diff --git a/GFramework.Game/Serializer/JsonSerializer.cs b/GFramework.Game/Serializer/JsonSerializer.cs index df761850..1157b2fd 100644 --- a/GFramework.Game/Serializer/JsonSerializer.cs +++ b/GFramework.Game/Serializer/JsonSerializer.cs @@ -1,5 +1,6 @@ using GFramework.Core.Abstractions.Serializer; using Newtonsoft.Json; +using NewtonsoftJsonException = Newtonsoft.Json.JsonException; namespace GFramework.Game.Serializer; @@ -9,6 +10,27 @@ namespace GFramework.Game.Serializer; public sealed class JsonSerializer : IRuntimeTypeSerializer { + private readonly JsonSerializerSettings _settings; + + /// + /// 初始化 JSON 序列化器。 + /// + /// 可选的 Newtonsoft.Json 配置;不提供时使用默认配置。 + public JsonSerializer(JsonSerializerSettings? settings = null) + { + _settings = settings ?? new JsonSerializerSettings(); + } + + /// + /// 获取当前序列化器使用的 Newtonsoft.Json 配置实例。 + /// + public JsonSerializerSettings Settings => _settings; + + /// + /// 获取当前序列化器使用的自定义转换器集合。 + /// + public IList Converters => _settings.Converters; + /// /// 将指定类型的对象序列化为JSON字符串 /// @@ -17,21 +39,9 @@ public sealed class JsonSerializer /// 序列化后的JSON字符串 public string Serialize(T value) { - return JsonConvert.SerializeObject(value); + return JsonConvert.SerializeObject(value, _settings); } - /// - /// 将JSON字符串反序列化为指定类型的对象 - /// - /// 要反序列化的目标类型 - /// 要反序列化的JSON字符串数据 - /// 反序列化后的对象实例 - /// 当无法反序列化数据时抛出 - public T Deserialize(string data) - { - return JsonConvert.DeserializeObject(data) - ?? throw new ArgumentException("Cannot deserialize data"); - } /// /// 将对象序列化为JSON字符串(使用运行时类型) @@ -41,7 +51,25 @@ public sealed class JsonSerializer /// 序列化后的JSON字符串 public string Serialize(object obj, Type type) { - return JsonConvert.SerializeObject(obj, type, null); + ArgumentNullException.ThrowIfNull(obj); + ArgumentNullException.ThrowIfNull(type); + + return JsonConvert.SerializeObject(obj, type, _settings); + } + + /// + /// 将JSON字符串反序列化为指定类型的对象 + /// + /// 要反序列化的目标类型 + /// 要反序列化的JSON字符串数据 + /// 反序列化后的对象实例 + /// 当无法反序列化数据时抛出 + public T Deserialize(string data) + { + return (T)DeserializeCore( + data, + typeof(T), + static (json, type, settings) => JsonConvert.DeserializeObject(json, settings)); } /// @@ -50,10 +78,40 @@ public sealed class JsonSerializer /// 要反序列化的JSON字符串数据 /// 反序列化目标类型 /// 反序列化后的对象实例 - /// 当无法反序列化到指定类型时抛出 + /// 当无法反序列化到指定类型时抛出 public object Deserialize(string data, Type type) { - return JsonConvert.DeserializeObject(data, type) - ?? throw new ArgumentException($"Cannot deserialize to {type.Name}"); + return DeserializeCore( + data, + type, + static (json, targetType, settings) => JsonConvert.DeserializeObject(json, targetType, settings)); + } + + private object DeserializeCore( + string data, + Type targetType, + Func deserialize) + { + ArgumentException.ThrowIfNullOrWhiteSpace(data); + ArgumentNullException.ThrowIfNull(targetType); + ArgumentNullException.ThrowIfNull(deserialize); + + try + { + var result = deserialize(data, targetType, _settings); + if (result == null) + { + throw new InvalidOperationException( + $"Deserialization returned null for target type '{targetType.FullName}'."); + } + + return result; + } + catch (Exception ex) when (ex is NewtonsoftJsonException or InvalidCastException or ArgumentException) + { + throw new InvalidOperationException( + $"Failed to deserialize JSON to target type '{targetType.FullName}'.", + ex); + } } } \ No newline at end of file diff --git a/GFramework.Game/Setting/SettingsModel.cs b/GFramework.Game/Setting/SettingsModel.cs index d480589c..1c8f8915 100644 --- a/GFramework.Game/Setting/SettingsModel.cs +++ b/GFramework.Game/Setting/SettingsModel.cs @@ -59,7 +59,9 @@ public class SettingsModel(IDataLocationProvider? locationProvider, public T GetData() where T : class, ISettingsData, new() { // 使用_data字典获取或添加指定类型的实例,确保唯一性 - return (T)_data.GetOrAdd(typeof(T), _ => new T()); + var data = (T)_data.GetOrAdd(typeof(T), _ => new T()); + TryRegisterDataType(typeof(T)); + return data; } /// @@ -84,6 +86,7 @@ public class SettingsModel(IDataLocationProvider? locationProvider, { _applicators[typeof(T)] = applicator; _data[applicator.DataType] = applicator.Data; + TryRegisterDataType(applicator.DataType); return this; } @@ -114,6 +117,7 @@ public class SettingsModel(IDataLocationProvider? locationProvider, public ISettingsModel RegisterMigration(ISettingsMigration migration) { _migrations[(migration.SettingsType, migration.FromVersion)] = migration; + _migrationCache.TryRemove(migration.SettingsType, out _); return this; } @@ -258,11 +262,21 @@ public class SettingsModel(IDataLocationProvider? locationProvider, // 遍历所有已知的数据类型,为其分配位置并注册到数据仓库中 foreach (var type in _data.Keys) { - var location = _locationProvider.GetLocation(type); - DataRepository.RegisterDataType(location, type); + TryRegisterDataType(type); } } + private void TryRegisterDataType(Type type) + { + if (_repository == null || _locationProvider == null) + { + return; + } + + var location = _locationProvider.GetLocation(type); + _repository.RegisterDataType(location, type); + } + private ISettingsData MigrateIfNeeded(ISettingsData data) { if (data is not IVersionedData versioned)