mirror of
https://github.com/GeWuYou/GFramework.git
synced 2026-05-07 17:21:16 +08:00
386 lines
12 KiB
C#
386 lines
12 KiB
C#
// Copyright (c) 2025-2026 GeWuYou
|
|
// SPDX-License-Identifier: Apache-2.0
|
|
|
|
using System.Reflection;
|
|
using System.Threading;
|
|
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<FakeSettingsDataRepository>(locationProvider, repository);
|
|
((IContextAware)model).SetContext(new Mock<IArchitectureContext>(MockBehavior.Loose).Object);
|
|
|
|
((IInitializable)model).Initialize();
|
|
|
|
_ = model.GetData<TestSettingsData>();
|
|
|
|
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<FakeSettingsDataRepository>(locationProvider, repository);
|
|
((IContextAware)model).SetContext(new Mock<IArchitectureContext>(MockBehavior.Loose).Object);
|
|
|
|
_ = model.GetData<TestSettingsData>();
|
|
((IInitializable)model).Initialize();
|
|
|
|
repository.Stored["TestSettingsData"] = new TestSettingsData
|
|
{
|
|
Version = 1,
|
|
Value = "legacy"
|
|
};
|
|
|
|
await model.InitializeAsync();
|
|
Assert.That(model.GetData<TestSettingsData>().Version, Is.EqualTo(1));
|
|
|
|
model.GetData<TestSettingsData>().Version = 2;
|
|
model.RegisterMigration(new TestSettingsMigration());
|
|
|
|
repository.Stored["TestSettingsData"] = new TestSettingsData
|
|
{
|
|
Version = 1,
|
|
Value = "legacy"
|
|
};
|
|
|
|
await model.InitializeAsync();
|
|
|
|
var current = model.GetData<TestSettingsData>();
|
|
Assert.Multiple(() =>
|
|
{
|
|
Assert.That(current.Version, Is.EqualTo(2));
|
|
Assert.That(current.Value, Is.EqualTo("legacy-migrated"));
|
|
});
|
|
}
|
|
|
|
[Test]
|
|
public void RegisterMigration_Should_Reject_Duplicate_FromVersion_For_Same_SettingsType()
|
|
{
|
|
var locationProvider = new TestDataLocationProvider();
|
|
var repository = new FakeSettingsDataRepository();
|
|
var model = new SettingsModel<FakeSettingsDataRepository>(locationProvider, repository);
|
|
|
|
model.RegisterMigration(new TestSettingsMigration());
|
|
|
|
var exception = Assert.Throws<InvalidOperationException>(() => model.RegisterMigration(new TestSettingsMigration()));
|
|
|
|
Assert.That(exception!.Message, Does.Contain("Duplicate settings migration registration"));
|
|
}
|
|
|
|
[Test]
|
|
public async Task InitializeAsync_Should_Keep_Current_Instance_When_Migration_Chain_Is_Incomplete()
|
|
{
|
|
var locationProvider = new TestDataLocationProvider();
|
|
var repository = new FakeSettingsDataRepository();
|
|
var model = new SettingsModel<FakeSettingsDataRepository>(locationProvider, repository);
|
|
((IContextAware)model).SetContext(new Mock<IArchitectureContext>(MockBehavior.Loose).Object);
|
|
|
|
_ = model.GetData<TestLatestSettingsData>();
|
|
((IInitializable)model).Initialize();
|
|
|
|
repository.Stored["TestLatestSettingsData"] = new TestLatestSettingsData
|
|
{
|
|
Version = 1,
|
|
Value = "legacy"
|
|
};
|
|
|
|
model.RegisterMigration(new TestLatestSettingsMigrationV1ToV2());
|
|
|
|
await model.InitializeAsync();
|
|
|
|
var current = model.GetData<TestLatestSettingsData>();
|
|
Assert.Multiple(() =>
|
|
{
|
|
Assert.That(current.Version, Is.EqualTo(3));
|
|
Assert.That(current.Value, Is.EqualTo("default-v3"));
|
|
});
|
|
}
|
|
|
|
[Test]
|
|
public async Task RegisterMigration_During_Cache_Rebuild_Should_Not_Leave_Stale_Type_Cache()
|
|
{
|
|
var locationProvider = new TestDataLocationProvider();
|
|
var repository = new FakeSettingsDataRepository();
|
|
var model = new SettingsModel<FakeSettingsDataRepository>(locationProvider, repository);
|
|
((IContextAware)model).SetContext(new Mock<IArchitectureContext>(MockBehavior.Loose).Object);
|
|
|
|
_ = model.GetData<TestLatestSettingsData>();
|
|
((IInitializable)model).Initialize();
|
|
|
|
model.RegisterMigration(new TestLatestSettingsMigrationV1ToV2());
|
|
|
|
repository.Stored["TestLatestSettingsData"] = new TestLatestSettingsData
|
|
{
|
|
Version = 1,
|
|
Value = "legacy"
|
|
};
|
|
|
|
var lockField = typeof(SettingsModel<FakeSettingsDataRepository>)
|
|
.GetField("_migrationMapLock", BindingFlags.Instance | BindingFlags.NonPublic);
|
|
Assert.That(lockField, Is.Not.Null);
|
|
|
|
var migrationMapLock = lockField!.GetValue(model);
|
|
Assert.That(migrationMapLock, Is.Not.Null);
|
|
|
|
var tasks = WithSynchronizationLockHeld(migrationMapLock!, () =>
|
|
{
|
|
var initializeTask = Task.Run(() => model.InitializeAsync());
|
|
var registerTask = Task.Run(() => model.RegisterMigration(new TestLatestSettingsMigrationV2ToV3()));
|
|
|
|
Thread.Sleep(50);
|
|
|
|
Assert.Multiple(() =>
|
|
{
|
|
Assert.That(initializeTask.IsCompleted, Is.False);
|
|
Assert.That(registerTask.IsCompleted, Is.False);
|
|
});
|
|
|
|
return (initializeTask, registerTask);
|
|
});
|
|
|
|
var (initializeTask, registerTask) = tasks;
|
|
|
|
await Task.WhenAll(initializeTask, registerTask);
|
|
|
|
repository.Stored["TestLatestSettingsData"] = new TestLatestSettingsData
|
|
{
|
|
Version = 1,
|
|
Value = "legacy"
|
|
};
|
|
|
|
await model.InitializeAsync();
|
|
|
|
var current = model.GetData<TestLatestSettingsData>();
|
|
Assert.Multiple(() =>
|
|
{
|
|
Assert.That(current.Version, Is.EqualTo(3));
|
|
Assert.That(current.Value, Is.EqualTo("legacy-migrated-v3"));
|
|
});
|
|
}
|
|
|
|
/// <summary>
|
|
/// 以与被测代码相同的同步原语持有反射获取到的锁对象,避免在 .NET 9+ 上把 <see cref="System.Threading.Lock" />
|
|
/// 退化成 <see cref="Monitor" /> 语义,导致并发测试误判。
|
|
/// </summary>
|
|
/// <param name="syncRoot">通过反射读取到的私有锁字段。</param>
|
|
/// <typeparam name="TResult">持锁代码返回的结果类型。</typeparam>
|
|
/// <param name="action">持锁期间执行的断言与并发调度逻辑。</param>
|
|
/// <returns>持锁代码的返回值。</returns>
|
|
private static TResult WithSynchronizationLockHeld<TResult>(object syncRoot, Func<TResult> action)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(syncRoot);
|
|
ArgumentNullException.ThrowIfNull(action);
|
|
|
|
#if NET9_0_OR_GREATER
|
|
if (syncRoot is System.Threading.Lock typedLock)
|
|
{
|
|
using (typedLock.EnterScope())
|
|
{
|
|
return action();
|
|
}
|
|
}
|
|
#endif
|
|
|
|
lock (syncRoot)
|
|
{
|
|
return action();
|
|
}
|
|
}
|
|
|
|
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 TestLatestSettingsData : ISettingsData
|
|
{
|
|
public string Value { get; set; } = "default-v3";
|
|
|
|
public int Version { get; set; } = 3;
|
|
|
|
public DateTime LastModified { get; } = DateTime.UtcNow;
|
|
|
|
public void Reset()
|
|
{
|
|
Value = "default-v3";
|
|
Version = 3;
|
|
}
|
|
|
|
public void LoadFrom(ISettingsData source)
|
|
{
|
|
if (source is not TestLatestSettingsData data)
|
|
{
|
|
return;
|
|
}
|
|
|
|
Value = data.Value;
|
|
Version = data.Version;
|
|
}
|
|
}
|
|
|
|
private sealed class TestLatestSettingsMigrationV1ToV2 : ISettingsMigration
|
|
{
|
|
public Type SettingsType => typeof(TestLatestSettingsData);
|
|
|
|
public int FromVersion => 1;
|
|
|
|
public int ToVersion => 2;
|
|
|
|
public ISettingsSection Migrate(ISettingsSection oldData)
|
|
{
|
|
var data = (TestLatestSettingsData)oldData;
|
|
return new TestLatestSettingsData
|
|
{
|
|
Version = 2,
|
|
Value = $"{data.Value}-migrated"
|
|
};
|
|
}
|
|
}
|
|
|
|
private sealed class TestLatestSettingsMigrationV2ToV3 : ISettingsMigration
|
|
{
|
|
public Type SettingsType => typeof(TestLatestSettingsData);
|
|
|
|
public int FromVersion => 2;
|
|
|
|
public int ToVersion => 3;
|
|
|
|
public ISettingsSection Migrate(ISettingsSection oldData)
|
|
{
|
|
var data = (TestLatestSettingsData)oldData;
|
|
return new TestLatestSettingsData
|
|
{
|
|
Version = 3,
|
|
Value = $"{data.Value}-v3"
|
|
};
|
|
}
|
|
}
|
|
|
|
private sealed class FakeSettingsDataRepository : ISettingsDataRepository
|
|
{
|
|
public Dictionary<string, Type> RegisteredTypes { get; } = new(StringComparer.Ordinal);
|
|
|
|
public Dictionary<string, IData> Stored { get; } = new(StringComparer.Ordinal);
|
|
|
|
public Task<T> LoadAsync<T>(IDataLocation location) where T : class, IData, new()
|
|
{
|
|
return Task.FromResult(Stored.TryGetValue(location.Key, out var data) ? (T)data : new T());
|
|
}
|
|
|
|
public Task SaveAsync<T>(IDataLocation location, T data) where T : class, IData
|
|
{
|
|
Stored[location.Key] = data;
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
public Task<bool> 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<IDictionary<string, IData>> LoadAllAsync()
|
|
{
|
|
IDictionary<string, IData> snapshot = new Dictionary<string, IData>(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<string, string>? Metadata => null;
|
|
}
|
|
}
|