diff --git a/AGENTS.md b/AGENTS.md index d806c5a5..7f9fb3c1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 diff --git a/GFramework.Game.Tests/Data/PersistenceTestUtilities.cs b/GFramework.Game.Tests/Data/PersistenceTestUtilities.cs index 440df286..00ab134a 100644 --- a/GFramework.Game.Tests/Data/PersistenceTestUtilities.cs +++ b/GFramework.Game.Tests/Data/PersistenceTestUtilities.cs @@ -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? Metadata = null) : IDataLocation; +/// +/// 为持久化测试提供稳定的测试数据位置实现。 +/// +internal sealed class TestDataLocation : IDataLocation +{ + /// + /// 初始化测试数据位置。 + /// + /// 测试使用的存储键。 + /// 测试使用的存储类型。 + /// 测试使用的命名空间。 + /// 附加测试元数据。 + public TestDataLocation( + string key, + StorageKinds kinds = StorageKinds.Local, + string? namespaceValue = null, + IReadOnlyDictionary? metadata = null) + { + Key = key; + Kinds = kinds; + Namespace = namespaceValue; + Metadata = metadata; + } + /// + /// 获取测试数据对应的存储键。 + /// + public string Key { get; } + + /// + /// 获取测试数据使用的存储类型。 + /// + public StorageKinds Kinds { get; } + + /// + /// 获取测试数据使用的命名空间。 + /// + public string? Namespace { get; } + + /// + /// 获取附加到测试位置上的元数据。 + /// + public IReadOnlyDictionary? Metadata { get; } +} + +/// +/// 为基础存档仓库测试提供的简单存档模型。 +/// internal sealed class TestSaveData : IData { + /// + /// 获取或设置测试存档中的名称字段。 + /// public string Name { get; set; } = string.Empty; } +/// +/// 为存档迁移测试提供的版本化存档模型。 +/// internal sealed class TestVersionedSaveData : IVersionedData { + /// + /// 获取或设置测试存档中的名称字段。 + /// public string Name { get; set; } = string.Empty; + /// + /// 获取或设置测试存档中的等级字段。 + /// public int Level { get; set; } + /// + /// 获取或设置测试存档中的经验字段。 + /// public int Experience { get; set; } + /// + /// 获取或设置当前测试存档的版本号。 + /// public int Version { get; set; } = 3; + /// + /// 获取或设置测试存档的最后修改时间。 + /// public DateTime LastModified { get; set; } = DateTime.UtcNow; } +/// +/// 为通用持久化测试提供的简单数据模型。 +/// internal sealed class TestSimpleData : IData { + /// + /// 获取或设置测试数据中的整数值。 + /// public int Value { get; set; } } diff --git a/GFramework.Game.Tests/Data/PersistenceTests.cs b/GFramework.Game.Tests/Data/PersistenceTests.cs index 68f64edb..ef2a5e85 100644 --- a/GFramework.Game.Tests/Data/PersistenceTests.cs +++ b/GFramework.Game.Tests/Data/PersistenceTests.cs @@ -6,6 +6,9 @@ using GFramework.Game.Storage; namespace GFramework.Game.Tests.Data; +/// +/// 覆盖文件存储、槽位存档仓库和统一设置仓库的持久化行为测试。 +/// [TestFixture] public class PersistenceTests { @@ -16,6 +19,10 @@ public class PersistenceTests return path; } + /// + /// 验证文件存储能够持久化数据,并拒绝包含路径逃逸的非法键。 + /// + /// 表示异步测试完成的任务。 [Test] public async Task FileStorage_PersistsDataAndRejectsIllegalKeys() { @@ -31,6 +38,10 @@ public class PersistenceTests Assert.ThrowsAsync(async () => await storage.WriteAsync("../escape", new TestSimpleData())); } + /// + /// 验证槽位存档仓库的保存、加载、列举和删除行为。 + /// + /// 表示异步测试完成的任务。 [Test] public async Task SaveRepository_ManagesSlots() { @@ -59,6 +70,10 @@ public class PersistenceTests Assert.That(await repository.ExistsAsync(1), Is.False); } + /// + /// 验证存档仓库在加载旧版本数据时会执行迁移链并回写升级后的最新版本。 + /// + /// 表示异步测试完成的任务。 [Test] public async Task SaveRepository_LoadAsync_Should_Apply_Migrations_And_Persist_Upgraded_Save() { @@ -98,6 +113,10 @@ public class PersistenceTests }); } + /// + /// 验证非版本化存档类型不能注册迁移器,避免构建无效迁移管线。 + /// + /// 当存档类型未实现 时抛出。 [Test] public void SaveRepository_RegisterMigration_For_NonVersioned_Save_Should_Throw() { @@ -109,6 +128,31 @@ public class PersistenceTests Assert.Throws(() => repository.RegisterMigration(new TestNonVersionedMigration())); } + /// + /// 验证同一源版本不能重复注册迁移器,避免迁移链配置被静默覆盖。 + /// + /// 当同一源版本重复注册迁移器时抛出。 + [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(storage, config); + + repository.RegisterMigration(new TestSaveMigrationV1ToV2()); + + var exception = Assert.Throws( + () => repository.RegisterMigration(new TestDuplicateSaveMigrationV1ToV2())); + + Assert.That(exception!.Message, Does.Contain("Duplicate save migration registration")); + } + + /// + /// 验证当迁移链缺少中间版本时,加载旧存档会明确失败而不是静默返回不完整数据。 + /// + /// 表示异步测试完成的任务。 + /// 当从旧版本到当前版本的迁移链不完整时抛出。 [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")); } + /// + /// 验证统一设置仓库能够保存、重新加载并批量读取已注册的设置数据。 + /// + /// 表示异步测试完成的任务。 [Test] public async Task UnifiedSettingsDataRepository_RoundTripsDataAndLoadAll() { @@ -209,6 +257,25 @@ public class PersistenceTests } } + private sealed class TestDuplicateSaveMigrationV1ToV2 : ISaveMigration + { + 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 { public int FromVersion => 1; diff --git a/GFramework.Game/Data/SaveRepository.cs b/GFramework.Game/Data/SaveRepository.cs index b916c79b..e0d440d4 100644 --- a/GFramework.Game/Data/SaveRepository.cs +++ b/GFramework.Game/Data/SaveRepository.cs @@ -32,6 +32,7 @@ public class SaveRepository : AbstractContextUtility, ISaveRepository { private readonly SaveConfiguration _config; private readonly Dictionary> _migrations = new(); + private readonly object _migrationsLock = new(); private readonly IStorage _rootStorage; /// @@ -56,8 +57,13 @@ public class SaveRepository : AbstractContextUtility, ISaveRepository /// /// /// 未实现 ,无法使用版本化迁移。 + /// 或者同一个源版本已经注册过迁移器,导致迁移链配置存在歧义。 /// /// 迁移器的目标版本不大于源版本。 + /// + /// 迁移注册表是可变共享状态。注册与加载可以并发发生,因此所有访问都通过 + /// 串行化,避免读写竞争和“部分可见”的迁移链。 + /// public ISaveRepository RegisterMigration(ISaveMigration migration) { ArgumentNullException.ThrowIfNull(migration); @@ -70,7 +76,17 @@ public class SaveRepository : 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 : AbstractContextUtility, ISaveRepository var migrated = data; // 迁移链按“当前版本 -> 下一个已注册迁移器”推进;任何缺口都表示运行时无法安全解释旧存档。 + // 读取迁移表时使用同一把锁,保证并发注册不会让加载线程看到不一致的链路状态。 while (currentVersion < targetVersion) { - if (!_migrations.TryGetValue(currentVersion, out var migration)) + ISaveMigration? 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}.");