Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
142 changes: 142 additions & 0 deletions GFramework.Game.Tests/Data/PersistenceTests.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System.IO;
using System.Threading;
using GFramework.Core.Abstractions.Events;
using GFramework.Core.Abstractions.Rule;
using GFramework.Core.Abstractions.Storage;
Expand Down Expand Up @@ -188,6 +189,100 @@ 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));
var persisted = await storage.ReadAsync<TestVersionedSaveData>("saves/slot_1/save");

Assert.Multiple(() =>
{
Assert.That(exception!.Message, Does.Contain("declared target version 2"));
Assert.That(persisted.Version, Is.EqualTo(1));
Assert.That(persisted.Name, Is.EqualTo("legacy"));
Assert.That(persisted.Level, Is.EqualTo(3));
Assert.That(persisted.Experience, Is.EqualTo(0));
});
}

/// <summary>
/// 验证加载流程会在开始迁移前固定迁移表快照,避免并发注册让同一次加载看到变化中的链路。
/// </summary>
/// <returns>表示异步测试完成的任务。</returns>
/// <exception cref="InvalidOperationException">当快照中缺少后续迁移链时抛出。</exception>
[Test]
public async Task SaveRepository_LoadAsync_Should_Use_Migration_Snapshot_When_Registrations_Change_Concurrently()
{
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
});

using var migrationStarted = new ManualResetEventSlim(false);
using var continueMigration = new ManualResetEventSlim(false);

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

var loadTask = repository.LoadAsync(1);

Assert.That(migrationStarted.Wait(TimeSpan.FromSeconds(5)), Is.True, "First migration step did not start in time.");

repository.RegisterMigration(new TestSaveMigrationV2ToV3());
continueMigration.Set();

var exception = Assert.ThrowsAsync<InvalidOperationException>(async () => await loadTask);
var persisted = await storage.ReadAsync<TestVersionedSaveData>("saves/slot_1/save");

Assert.Multiple(() =>
{
Assert.That(exception!.Message, Does.Contain("from version 2"));
Assert.That(persisted.Version, Is.EqualTo(1));
Assert.That(persisted.Name, Is.EqualTo("legacy"));
Assert.That(persisted.Level, Is.EqualTo(3));
Assert.That(persisted.Experience, Is.EqualTo(0));
});
}

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

private sealed class BlockingSaveMigrationV1ToV2(
ManualResetEventSlim migrationStarted,
ManualResetEventSlim continueMigration) : ISaveMigration<TestVersionedSaveData>
{
public int FromVersion => 1;

public int ToVersion => 2;

public TestVersionedSaveData Migrate(TestVersionedSaveData oldData)
{
migrationStarted.Set();

if (!continueMigration.Wait(TimeSpan.FromSeconds(5)))
{
throw new InvalidOperationException("Timed out while waiting to continue the save migration test.");
}

return new TestVersionedSaveData
{
Name = $"{oldData.Name}-v2",
Level = oldData.Level,
Experience = oldData.Level * 100,
Version = 2,
LastModified = oldData.LastModified
};
}
}

private sealed class TestSaveMigrationV2ToV3 : ISaveMigration<TestVersionedSaveData>
{
public int FromVersion => 2;
Expand Down Expand Up @@ -707,6 +830,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;
}
}
}
Loading
Loading