feat(game): 添加存档仓库实现和持久化测试

- 实现基于槽位的存档仓库功能,支持存档的保存、加载、删除和列举操作
- 添加存档版本迁移机制,支持自动升级旧版本存档数据
- 实现文件存储和统一设置仓库的基础持久化功能
- 添加完整的单元测试覆盖存档仓库的各种使用场景
- 创建测试工具类和测试数据模型用于验证持久化行为
- 添加项目AI代理行为规范文档,确保代码质量和一致性
This commit is contained in:
GeWuYou 2026-04-06 11:33:35 +08:00
parent 7ad80f54d3
commit 25e4965817
4 changed files with 174 additions and 7 deletions

View File

@ -193,6 +193,14 @@ bash scripts/validate-csharp-naming.sh
- If a framework abstraction changes meaning or intended usage, update the explanatory comments in code as part of the
same change.
### Task Tracking
- When working from a tracked implementation plan, contributors MUST update the corresponding tracking document under
`local-plan/todos/` in the same change.
- Tracking updates MUST reflect completed work, newly discovered issues, validation results, and the next recommended
recovery point.
- Completing code changes without updating the active tracking document is considered incomplete work.
### Repository Documentation
- Update the relevant `README.md` or `docs/` page when behavior, setup steps, architecture guidance, or user-facing

View File

@ -4,31 +4,100 @@ using GFramework.Game.Abstractions.Enums;
namespace GFramework.Game.Tests.Data;
internal sealed record TestDataLocation(
string Key,
StorageKinds Kinds = StorageKinds.Local,
string? Namespace = null,
IReadOnlyDictionary<string, string>? Metadata = null) : IDataLocation;
/// <summary>
/// 为持久化测试提供稳定的测试数据位置实现。
/// </summary>
internal sealed class TestDataLocation : IDataLocation
{
/// <summary>
/// 初始化测试数据位置。
/// </summary>
/// <param name="key">测试使用的存储键。</param>
/// <param name="kinds">测试使用的存储类型。</param>
/// <param name="namespaceValue">测试使用的命名空间。</param>
/// <param name="metadata">附加测试元数据。</param>
public TestDataLocation(
string key,
StorageKinds kinds = StorageKinds.Local,
string? namespaceValue = null,
IReadOnlyDictionary<string, string>? metadata = null)
{
Key = key;
Kinds = kinds;
Namespace = namespaceValue;
Metadata = metadata;
}
/// <summary>
/// 获取测试数据对应的存储键。
/// </summary>
public string Key { get; }
/// <summary>
/// 获取测试数据使用的存储类型。
/// </summary>
public StorageKinds Kinds { get; }
/// <summary>
/// 获取测试数据使用的命名空间。
/// </summary>
public string? Namespace { get; }
/// <summary>
/// 获取附加到测试位置上的元数据。
/// </summary>
public IReadOnlyDictionary<string, string>? Metadata { get; }
}
/// <summary>
/// 为基础存档仓库测试提供的简单存档模型。
/// </summary>
internal sealed class TestSaveData : IData
{
/// <summary>
/// 获取或设置测试存档中的名称字段。
/// </summary>
public string Name { get; set; } = string.Empty;
}
/// <summary>
/// 为存档迁移测试提供的版本化存档模型。
/// </summary>
internal sealed class TestVersionedSaveData : IVersionedData
{
/// <summary>
/// 获取或设置测试存档中的名称字段。
/// </summary>
public string Name { get; set; } = string.Empty;
/// <summary>
/// 获取或设置测试存档中的等级字段。
/// </summary>
public int Level { get; set; }
/// <summary>
/// 获取或设置测试存档中的经验字段。
/// </summary>
public int Experience { get; set; }
/// <summary>
/// 获取或设置当前测试存档的版本号。
/// </summary>
public int Version { get; set; } = 3;
/// <summary>
/// 获取或设置测试存档的最后修改时间。
/// </summary>
public DateTime LastModified { get; set; } = DateTime.UtcNow;
}
/// <summary>
/// 为通用持久化测试提供的简单数据模型。
/// </summary>
internal sealed class TestSimpleData : IData
{
/// <summary>
/// 获取或设置测试数据中的整数值。
/// </summary>
public int Value { get; set; }
}

View File

@ -6,6 +6,9 @@ using GFramework.Game.Storage;
namespace GFramework.Game.Tests.Data;
/// <summary>
/// 覆盖文件存储、槽位存档仓库和统一设置仓库的持久化行为测试。
/// </summary>
[TestFixture]
public class PersistenceTests
{
@ -16,6 +19,10 @@ public class PersistenceTests
return path;
}
/// <summary>
/// 验证文件存储能够持久化数据,并拒绝包含路径逃逸的非法键。
/// </summary>
/// <returns>表示异步测试完成的任务。</returns>
[Test]
public async Task FileStorage_PersistsDataAndRejectsIllegalKeys()
{
@ -31,6 +38,10 @@ public class PersistenceTests
Assert.ThrowsAsync<ArgumentException>(async () => await storage.WriteAsync("../escape", new TestSimpleData()));
}
/// <summary>
/// 验证槽位存档仓库的保存、加载、列举和删除行为。
/// </summary>
/// <returns>表示异步测试完成的任务。</returns>
[Test]
public async Task SaveRepository_ManagesSlots()
{
@ -59,6 +70,10 @@ public class PersistenceTests
Assert.That(await repository.ExistsAsync(1), Is.False);
}
/// <summary>
/// 验证存档仓库在加载旧版本数据时会执行迁移链并回写升级后的最新版本。
/// </summary>
/// <returns>表示异步测试完成的任务。</returns>
[Test]
public async Task SaveRepository_LoadAsync_Should_Apply_Migrations_And_Persist_Upgraded_Save()
{
@ -98,6 +113,10 @@ public class PersistenceTests
});
}
/// <summary>
/// 验证非版本化存档类型不能注册迁移器,避免构建无效迁移管线。
/// </summary>
/// <exception cref="InvalidOperationException">当存档类型未实现 <see cref="IVersionedData" /> 时抛出。</exception>
[Test]
public void SaveRepository_RegisterMigration_For_NonVersioned_Save_Should_Throw()
{
@ -109,6 +128,31 @@ public class PersistenceTests
Assert.Throws<InvalidOperationException>(() => repository.RegisterMigration(new TestNonVersionedMigration()));
}
/// <summary>
/// 验证同一源版本不能重复注册迁移器,避免迁移链配置被静默覆盖。
/// </summary>
/// <exception cref="InvalidOperationException">当同一源版本重复注册迁移器时抛出。</exception>
[Test]
public void SaveRepository_RegisterMigration_Should_Reject_Duplicate_FromVersion()
{
var root = CreateTempRoot();
using var storage = new FileStorage(root, new JsonSerializer());
var config = new SaveConfiguration();
var repository = new SaveRepository<TestVersionedSaveData>(storage, config);
repository.RegisterMigration(new TestSaveMigrationV1ToV2());
var exception = Assert.Throws<InvalidOperationException>(
() => repository.RegisterMigration(new TestDuplicateSaveMigrationV1ToV2()));
Assert.That(exception!.Message, Does.Contain("Duplicate save migration registration"));
}
/// <summary>
/// 验证当迁移链缺少中间版本时,加载旧存档会明确失败而不是静默返回不完整数据。
/// </summary>
/// <returns>表示异步测试完成的任务。</returns>
/// <exception cref="InvalidOperationException">当从旧版本到当前版本的迁移链不完整时抛出。</exception>
[Test]
public async Task SaveRepository_LoadAsync_Should_Throw_When_Migration_Chain_Is_Incomplete()
{
@ -137,6 +181,10 @@ public class PersistenceTests
Assert.That(exception!.Message, Does.Contain("from version 2"));
}
/// <summary>
/// 验证统一设置仓库能够保存、重新加载并批量读取已注册的设置数据。
/// </summary>
/// <returns>表示异步测试完成的任务。</returns>
[Test]
public async Task UnifiedSettingsDataRepository_RoundTripsDataAndLoadAll()
{
@ -209,6 +257,25 @@ public class PersistenceTests
}
}
private sealed class TestDuplicateSaveMigrationV1ToV2 : ISaveMigration<TestVersionedSaveData>
{
public int FromVersion => 1;
public int ToVersion => 2;
public TestVersionedSaveData Migrate(TestVersionedSaveData oldData)
{
return new TestVersionedSaveData
{
Name = $"{oldData.Name}-duplicate",
Level = oldData.Level,
Experience = oldData.Experience,
Version = 2,
LastModified = oldData.LastModified
};
}
}
private sealed class TestNonVersionedMigration : ISaveMigration<TestSaveData>
{
public int FromVersion => 1;

View File

@ -32,6 +32,7 @@ public class SaveRepository<TSaveData> : AbstractContextUtility, ISaveRepository
{
private readonly SaveConfiguration _config;
private readonly Dictionary<int, ISaveMigration<TSaveData>> _migrations = new();
private readonly object _migrationsLock = new();
private readonly IStorage _rootStorage;
/// <summary>
@ -56,8 +57,13 @@ public class SaveRepository<TSaveData> : AbstractContextUtility, ISaveRepository
/// <exception cref="ArgumentNullException"><paramref name="migration" /> 为 <see langword="null" />。</exception>
/// <exception cref="InvalidOperationException">
/// <typeparamref name="TSaveData" /> 未实现 <see cref="IVersionedData" />,无法使用版本化迁移。
/// 或者同一个源版本已经注册过迁移器,导致迁移链配置存在歧义。
/// </exception>
/// <exception cref="ArgumentException">迁移器的目标版本不大于源版本。</exception>
/// <remarks>
/// 迁移注册表是可变共享状态。注册与加载可以并发发生,因此所有访问都通过 <see cref="_migrationsLock" />
/// 串行化,避免读写竞争和“部分可见”的迁移链。
/// </remarks>
public ISaveRepository<TSaveData> RegisterMigration(ISaveMigration<TSaveData> migration)
{
ArgumentNullException.ThrowIfNull(migration);
@ -70,7 +76,17 @@ public class SaveRepository<TSaveData> : AbstractContextUtility, ISaveRepository
nameof(migration));
}
_migrations[migration.FromVersion] = migration;
lock (_migrationsLock)
{
if (_migrations.ContainsKey(migration.FromVersion))
{
throw new InvalidOperationException(
$"Duplicate save migration registration for {typeof(TSaveData).Name} from version {migration.FromVersion}.");
}
_migrations.Add(migration.FromVersion, migration);
}
return this;
}
@ -214,9 +230,16 @@ public class SaveRepository<TSaveData> : AbstractContextUtility, ISaveRepository
var migrated = data;
// 迁移链按“当前版本 -> 下一个已注册迁移器”推进;任何缺口都表示运行时无法安全解释旧存档。
// 读取迁移表时使用同一把锁,保证并发注册不会让加载线程看到不一致的链路状态。
while (currentVersion < targetVersion)
{
if (!_migrations.TryGetValue(currentVersion, out var migration))
ISaveMigration<TSaveData>? migration;
lock (_migrationsLock)
{
_migrations.TryGetValue(currentVersion, out migration);
}
if (migration is null)
{
throw new InvalidOperationException(
$"No save migration is registered for {typeof(TSaveData).Name} from version {currentVersion}.");