Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 52 additions & 0 deletions GFramework.Game.Tests/Data/PersistenceTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,39 @@ public async Task SaveRepository_LoadAsync_Should_Throw_When_Migration_Chain_Is_
Assert.That(exception!.Message, Does.Contain("from version 2"));
}

/// <summary>
/// 验证迁移器声明的目标版本必须与返回数据上的实际版本一致,避免错误迁移结果被静默接受。
/// </summary>
/// <returns>表示异步测试完成的任务。</returns>
/// <exception cref="InvalidOperationException">当迁移器返回的版本与声明目标版本不一致时抛出。</exception>
[Test]
public async Task SaveRepository_LoadAsync_Should_Throw_When_Migration_Result_Version_Does_Not_Match_Declaration()
{
var root = CreateTempRoot();
using var storage = new FileStorage(root, new JsonSerializer());
var config = new SaveConfiguration
{
SaveRoot = "saves",
SaveSlotPrefix = "slot_",
SaveFileName = "save"
};

var writer = new SaveRepository<TestVersionedSaveData>(storage, config);
await writer.SaveAsync(1, new TestVersionedSaveData
{
Name = "legacy",
Level = 3,
Experience = 0,
Version = 1
});

var repository = new SaveRepository<TestVersionedSaveData>(storage, config)
.RegisterMigration(new TestSaveMigrationV1ToV2ReturningV3());

var exception = Assert.ThrowsAsync<InvalidOperationException>(async () => await repository.LoadAsync(1));
Assert.That(exception!.Message, Does.Contain("declared target version 2"));
}

/// <summary>
/// 验证统一设置仓库能够保存、重新加载并批量读取已注册的设置数据。
/// </summary>
Expand Down Expand Up @@ -707,6 +740,25 @@ public TestVersionedSaveData Migrate(TestVersionedSaveData oldData)
}
}

private sealed class TestSaveMigrationV1ToV2ReturningV3 : ISaveMigration<TestVersionedSaveData>
{
public int FromVersion => 1;

public int ToVersion => 2;

public TestVersionedSaveData Migrate(TestVersionedSaveData oldData)
{
return new TestVersionedSaveData
{
Name = oldData.Name,
Level = oldData.Level,
Experience = oldData.Experience,
Version = 3,
LastModified = oldData.LastModified
};
}
}

private sealed class TestNonVersionedMigration : ISaveMigration<TestSaveData>
{
public int FromVersion => 1;
Expand Down
15 changes: 14 additions & 1 deletion GFramework.Game.Tests/Serializer/JsonSerializerTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,19 @@ public void Serialize_Should_Honor_Injected_Settings()
});
}

[Test]
public void Settings_And_Converters_Should_Expose_Live_Configuration_Instance()
{
var settings = new JsonSerializerSettings();
var serializer = new GameJsonSerializer(settings);

Assert.Multiple(() =>
{
Assert.That(serializer.Settings, Is.SameAs(settings));
Assert.That(serializer.Converters, Is.SameAs(settings.Converters));
});
}

[Test]
public void Converters_Should_Be_Used_For_Serialization_And_Deserialization()
{
Expand Down Expand Up @@ -174,4 +187,4 @@ public override CoordinateStub ReadJson(
};
}
}
}
}
91 changes: 90 additions & 1 deletion GFramework.Game.Tests/Setting/SettingsModelTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ public async Task RegisterMigration_After_Cache_Warmup_Should_Invalidate_Type_Ca
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
Expand All @@ -65,6 +66,49 @@ public async Task RegisterMigration_After_Cache_Warmup_Should_Invalidate_Type_Ca
});
}

[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"));
});
}

private sealed class TestSettingsData : ISettingsData
{
public string Value { get; set; } = "default";
Expand Down Expand Up @@ -110,6 +154,51 @@ public ISettingsSection Migrate(ISettingsSection oldData)
}
}

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 FakeSettingsDataRepository : ISettingsDataRepository
{
public Dictionary<string, Type> RegisteredTypes { get; } = new(StringComparer.Ordinal);
Expand Down Expand Up @@ -178,4 +267,4 @@ private sealed class TestDataLocation(string key) : IDataLocation

public IReadOnlyDictionary<string, string>? Metadata => null;
}
}
}
76 changes: 22 additions & 54 deletions GFramework.Game/Data/SaveRepository.cs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
using GFramework.Core.Abstractions.Storage;
using GFramework.Core.Utility;
using GFramework.Game.Abstractions.Data;
using GFramework.Game.Internal;
using GFramework.Game.Storage;

namespace GFramework.Game.Data;
Expand Down Expand Up @@ -69,12 +70,12 @@ public ISaveRepository<TSaveData> RegisterMigration(ISaveMigration<TSaveData> mi
ArgumentNullException.ThrowIfNull(migration);
EnsureVersionedSaveType();

if (migration.ToVersion <= migration.FromVersion)
{
throw new ArgumentException(
$"Migration for {typeof(TSaveData).Name} must advance the version number.",
nameof(migration));
}
VersionedMigrationRunner.ValidateForwardOnlyRegistration(
typeof(TSaveData).Name,
"Save migration",
migration.FromVersion,
migration.ToVersion,
nameof(migration));

lock (_migrationsLock)
{
Expand Down Expand Up @@ -227,57 +228,24 @@ private async Task<TSaveData> MigrateIfNeededAsync(int slot, IStorage storage, T

EnsureVersionedSaveType();

var migrated = data;

// 迁移链按“当前版本 -> 下一个已注册迁移器”推进;任何缺口都表示运行时无法安全解释旧存档。
// 读取迁移表时使用同一把锁,保证并发注册不会让加载线程看到不一致的链路状态。
while (currentVersion < targetVersion)
{
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}.");
}

migrated = migration.Migrate(migrated) ??
throw new InvalidOperationException(
$"Save migration for {typeof(TSaveData).Name} from version {currentVersion} returned null.");

if (migrated is not IVersionedData migratedVersionedData)
{
throw new InvalidOperationException(
$"Save migration for {typeof(TSaveData).Name} must return data implementing {nameof(IVersionedData)}.");
}

// 显式校验迁移器声明与实际结果,避免版本号不前进导致死循环或把旧数据错误回写为“已升级”。
if (migration.ToVersion != migratedVersionedData.Version)
{
throw new InvalidOperationException(
$"Save migration for {typeof(TSaveData).Name} declared target version {migration.ToVersion} " +
$"but returned version {migratedVersionedData.Version}.");
}

if (migratedVersionedData.Version <= currentVersion)
{
throw new InvalidOperationException(
$"Save migration for {typeof(TSaveData).Name} must advance beyond version {currentVersion}.");
}

if (migratedVersionedData.Version > targetVersion)
var migrated = VersionedMigrationRunner.MigrateToTargetVersion(
data,
targetVersion,
static saveData => ((IVersionedData)saveData).Version,
fromVersion =>
{
throw new InvalidOperationException(
$"Save migration for {typeof(TSaveData).Name} produced version {migratedVersionedData.Version}, " +
$"which exceeds the current runtime version {targetVersion}.");
}

currentVersion = migratedVersionedData.Version;
}
lock (_migrationsLock)
{
_migrations.TryGetValue(fromVersion, out var migration);
return migration;
}
},
static migration => migration.ToVersion,
static (migration, currentData) => migration.Migrate(currentData),
$"{typeof(TSaveData).Name} in slot {slot}",
"save migration");
Comment thread
coderabbitai[bot] marked this conversation as resolved.

await storage.WriteAsync(_config.SaveFileName, migrated);
return migrated;
Expand Down
Loading
Loading