摄像机区域的架构改动
This commit is contained in:
335
Docs/Verification/02_Unit_SaveSystem.md
Normal file
335
Docs/Verification/02_Unit_SaveSystem.md
Normal file
@@ -0,0 +1,335 @@
|
||||
# 单元测试 02 · 存档系统(SaveSystem)
|
||||
|
||||
> **测试类型**:EditMode 单元测试(NUnit)
|
||||
> **测试文件**:`Assets/Tests/EditMode/SaveSystemTests.cs`
|
||||
> **被测程序集**:`BaseGames.Core.Save`
|
||||
> **asmdef 依赖**:`BaseGames.Tests.EditMode.asmdef` 需引用 `BaseGames.Core.Save`
|
||||
|
||||
---
|
||||
|
||||
## 目录
|
||||
|
||||
1. [测试覆盖范围](#1-测试覆盖范围)
|
||||
2. [SaveMigrator 版本迁移测试](#2-savemigrator-版本迁移测试)
|
||||
3. [SaveData 序列化/反序列化测试](#3-savedata-序列化反序列化测试)
|
||||
4. [Checksum 完整性测试](#4-checksum-完整性测试)
|
||||
5. [完整测试代码](#5-完整测试代码)
|
||||
|
||||
---
|
||||
|
||||
## 1. 测试覆盖范围
|
||||
|
||||
| 类 | 测试点 |
|
||||
|----|--------|
|
||||
| `SaveMigrator` | 空数据不崩溃;旧版本迁移至 2.0;2.0 迁移至 2.1;已是最新版本不重复迁移;未知版本输出警告 |
|
||||
| `SaveData` | JSON 序列化后反序列化数据完整;扩展字段(`ExtensionData`)不丢失;`NGPlus = null` 正确处理 |
|
||||
| `SaveMeta` | Version/SlotIndex/Checksum 字段正确序列化 |
|
||||
| `PlayerSaveData` | AbilityFlags 位掩码正确保存/读取;`DeathShade` 嵌套对象完整 |
|
||||
|
||||
---
|
||||
|
||||
## 2. SaveMigrator 版本迁移测试
|
||||
|
||||
### 测试说明
|
||||
|
||||
`SaveMigrator.Migrate()` 是纯静态方法,完全无 Unity 运行时依赖,是最理想的单元测试目标。
|
||||
|
||||
**版本迁移链:** `旧版本` → `2.0` → `2.1`(`CurrentVersion`)
|
||||
|
||||
**关键验证点:**
|
||||
|
||||
- `null` 输入安全返回 `null`
|
||||
- `Meta == null` 的输入安全返回原对象
|
||||
- 空版本号 → 迁移至 2.1,填充 `Tutorial/Settings/EventChains/ChallengeRooms`
|
||||
- `"2.0"` 版本 → 迁移至 2.1,`Map.Pins` 补充为空列表
|
||||
- `"2.1"` 版本 → 无操作,直接返回
|
||||
- 迁移后 `Meta.Version == "2.1"`
|
||||
|
||||
---
|
||||
|
||||
## 3. SaveData 序列化/反序列化测试
|
||||
|
||||
### 测试说明
|
||||
|
||||
使用 `Newtonsoft.Json`(项目依赖)测试完整序列化/反序列化往返(round-trip),确保存档读写等价。
|
||||
|
||||
**关键验证点:**
|
||||
|
||||
- 基本字段往返无损
|
||||
- `ExtensionData`(`[JsonExtensionData]`)保留未知字段
|
||||
- `NGPlus = null` 序列化后反序列化仍为 null
|
||||
- `AbilityFlags` `uint` 类型正确处理(不发生符号扩展错误)
|
||||
|
||||
---
|
||||
|
||||
## 4. Checksum 完整性测试
|
||||
|
||||
### 测试说明
|
||||
|
||||
`SaveManager` 中的 Checksum 是 HMAC-SHA256 值,存储在 `Meta.Checksum`。测试通过反射或辅助方法访问私有计算逻辑。
|
||||
|
||||
> **注意**:若 `ComputeChecksum` 为私有方法,在测试中通过构造相同 JSON 字符串并比较来间接验证,或将方法改为 `internal` 以支持测试。
|
||||
|
||||
---
|
||||
|
||||
## 5. 完整测试代码
|
||||
|
||||
将以下代码保存至 `Assets/Tests/EditMode/SaveSystemTests.cs`:
|
||||
|
||||
```csharp
|
||||
using System.Collections.Generic;
|
||||
using NUnit.Framework;
|
||||
using Newtonsoft.Json;
|
||||
using BaseGames.Core.Save;
|
||||
|
||||
namespace BaseGames.Tests.EditMode
|
||||
{
|
||||
/// <summary>
|
||||
/// 存档系统单元测试(EditMode,纯 C# 逻辑,无 MonoBehaviour 依赖)。
|
||||
/// 覆盖:SaveMigrator 迁移链、SaveData 序列化/反序列化往返、字段完整性。
|
||||
/// </summary>
|
||||
[TestFixture]
|
||||
public class SaveSystemTests
|
||||
{
|
||||
// ── SaveMigrator · 边界输入 ──────────────────────────────────────────
|
||||
|
||||
[Test]
|
||||
public void Migrate_NullInput_ReturnsNull()
|
||||
{
|
||||
var result = SaveMigrator.Migrate(null);
|
||||
Assert.IsNull(result, "null 输入应安全返回 null");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Migrate_NullMeta_ReturnsSameObject()
|
||||
{
|
||||
var data = new SaveData { Meta = null };
|
||||
var result = SaveMigrator.Migrate(data);
|
||||
Assert.AreSame(data, result, "Meta 为 null 时应返回原对象");
|
||||
}
|
||||
|
||||
// ── SaveMigrator · 版本迁移 ──────────────────────────────────────────
|
||||
|
||||
[Test]
|
||||
public void Migrate_EmptyVersion_MigratesTo_2_1()
|
||||
{
|
||||
var data = new SaveData();
|
||||
data.Meta.Version = "";
|
||||
var result = SaveMigrator.Migrate(data);
|
||||
Assert.AreEqual(SaveMigrator.CurrentVersion, result.Meta.Version);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Migrate_OldVersion_FillsMissingSubObjects()
|
||||
{
|
||||
var data = new SaveData();
|
||||
data.Meta.Version = "1.0";
|
||||
data.Tutorial = null;
|
||||
data.Settings = null;
|
||||
data.EventChains = null;
|
||||
data.ChallengeRooms = null;
|
||||
|
||||
var result = SaveMigrator.Migrate(data);
|
||||
|
||||
Assert.IsNotNull(result.Tutorial, "迁移后 Tutorial 不应为 null");
|
||||
Assert.IsNotNull(result.Settings, "迁移后 Settings 不应为 null");
|
||||
Assert.IsNotNull(result.EventChains, "迁移后 EventChains 不应为 null");
|
||||
Assert.IsNotNull(result.ChallengeRooms, "迁移后 ChallengeRooms 不应为 null");
|
||||
Assert.IsNull(result.NGPlus, "迁移后 NGPlus 应为 null(非 NG+ 模式)");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Migrate_OldVersion_FillsPlayerDeathShade()
|
||||
{
|
||||
var data = new SaveData();
|
||||
data.Meta.Version = "1.0";
|
||||
data.Player.DeathShade = null;
|
||||
|
||||
var result = SaveMigrator.Migrate(data);
|
||||
|
||||
Assert.IsNotNull(result.Player.DeathShade, "迁移后 DeathShade 不应为 null");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Migrate_Version2_0_FillsMapPins()
|
||||
{
|
||||
var data = new SaveData();
|
||||
data.Meta.Version = "2.0";
|
||||
data.Map.Pins = null;
|
||||
|
||||
var result = SaveMigrator.Migrate(data);
|
||||
|
||||
Assert.IsNotNull(result.Map.Pins, "从 2.0 迁移后 Map.Pins 不应为 null");
|
||||
Assert.IsEmpty(result.Map.Pins, "Map.Pins 应初始化为空列表");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Migrate_CurrentVersion_NoChange()
|
||||
{
|
||||
var data = new SaveData();
|
||||
data.Meta.Version = SaveMigrator.CurrentVersion;
|
||||
var result = SaveMigrator.Migrate(data);
|
||||
Assert.AreEqual(SaveMigrator.CurrentVersion, result.Meta.Version);
|
||||
}
|
||||
|
||||
// ── SaveData · JSON 序列化往返 ───────────────────────────────────────
|
||||
|
||||
[Test]
|
||||
public void SaveData_SerializeDeserialize_PreservesPlayerData()
|
||||
{
|
||||
var original = new SaveData();
|
||||
original.Player.CurrentHP = 55;
|
||||
original.Player.MaxHP = 100;
|
||||
original.Player.CurrentGeo = 1234;
|
||||
original.Player.Scene = "TestRoom";
|
||||
original.Player.PosX = 3.14f;
|
||||
original.Player.PosY = -2.71f;
|
||||
|
||||
string json = JsonConvert.SerializeObject(original, Formatting.None);
|
||||
var restored = JsonConvert.DeserializeObject<SaveData>(json);
|
||||
|
||||
Assert.AreEqual(original.Player.CurrentHP, restored.Player.CurrentHP);
|
||||
Assert.AreEqual(original.Player.MaxHP, restored.Player.MaxHP);
|
||||
Assert.AreEqual(original.Player.CurrentGeo, restored.Player.CurrentGeo);
|
||||
Assert.AreEqual(original.Player.Scene, restored.Player.Scene);
|
||||
Assert.AreEqual(original.Player.PosX, restored.Player.PosX, 0.0001f);
|
||||
Assert.AreEqual(original.Player.PosY, restored.Player.PosY, 0.0001f);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void SaveData_SerializeDeserialize_PreservesAbilityFlags()
|
||||
{
|
||||
var original = new SaveData();
|
||||
original.Player.AbilityFlags = 0xDEADBEEFu;
|
||||
|
||||
string json = JsonConvert.SerializeObject(original, Formatting.None);
|
||||
var restored = JsonConvert.DeserializeObject<SaveData>(json);
|
||||
|
||||
Assert.AreEqual(original.Player.AbilityFlags, restored.Player.AbilityFlags,
|
||||
"AbilityFlags uint 不应因符号扩展而错误");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void SaveData_SerializeDeserialize_NGPlusNull_StaysNull()
|
||||
{
|
||||
var original = new SaveData();
|
||||
original.NGPlus = null;
|
||||
|
||||
string json = JsonConvert.SerializeObject(original, Formatting.None);
|
||||
var restored = JsonConvert.DeserializeObject<SaveData>(json);
|
||||
|
||||
Assert.IsNull(restored.NGPlus, "NGPlus null 应序列化/反序列化保持 null");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void SaveData_SerializeDeserialize_MetaVersionPreserved()
|
||||
{
|
||||
var original = new SaveData();
|
||||
original.Meta.Version = "2.1";
|
||||
original.Meta.SlotIndex = 2;
|
||||
original.Meta.SaveCount = 42;
|
||||
|
||||
string json = JsonConvert.SerializeObject(original, Formatting.None);
|
||||
var restored = JsonConvert.DeserializeObject<SaveData>(json);
|
||||
|
||||
Assert.AreEqual("2.1", restored.Meta.Version);
|
||||
Assert.AreEqual(2, restored.Meta.SlotIndex);
|
||||
Assert.AreEqual(42, restored.Meta.SaveCount);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void SaveData_ExtensionData_PreservesUnknownFields()
|
||||
{
|
||||
// 模拟未来版本存档包含当前版本未知的字段
|
||||
string futureJson = @"{
|
||||
""Meta"": { ""Version"": ""3.0"", ""SlotIndex"": 0 },
|
||||
""Player"": { ""CurrentHP"": 80 },
|
||||
""FutureFeature"": { ""SomeValue"": 123 }
|
||||
}";
|
||||
|
||||
var restored = JsonConvert.DeserializeObject<SaveData>(futureJson);
|
||||
|
||||
Assert.IsNotNull(restored);
|
||||
Assert.AreEqual(80, restored.Player.CurrentHP);
|
||||
// ExtensionData 应保留 FutureFeature
|
||||
Assert.IsTrue(restored.ExtensionData.ContainsKey("FutureFeature"),
|
||||
"ExtensionData 应保留未知字段,确保向前兼容");
|
||||
}
|
||||
|
||||
// ── SaveMeta · Checksum 字段 ─────────────────────────────────────────
|
||||
|
||||
[Test]
|
||||
public void SaveMeta_Checksum_DefaultIsNull()
|
||||
{
|
||||
var meta = new SaveMeta();
|
||||
Assert.IsNull(meta.Checksum, "新建 SaveMeta 的 Checksum 默认应为 null");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void SaveData_Serialize_WithNullChecksum_NotThrow()
|
||||
{
|
||||
var data = new SaveData();
|
||||
data.Meta.Checksum = null;
|
||||
Assert.DoesNotThrow(
|
||||
() => JsonConvert.SerializeObject(data, Formatting.None));
|
||||
}
|
||||
|
||||
// ── PlayerSaveData · ShieldHP 默认值 ────────────────────────────────
|
||||
|
||||
[Test]
|
||||
public void PlayerSaveData_ShieldHP_DefaultIsMinusOne()
|
||||
{
|
||||
var player = new PlayerSaveData();
|
||||
Assert.AreEqual(-1, player.ShieldHP,
|
||||
"ShieldHP 默认 -1 表示满护盾");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void PlayerSaveData_ShieldIsBroken_DefaultIsFalse()
|
||||
{
|
||||
var player = new PlayerSaveData();
|
||||
Assert.IsFalse(player.ShieldIsBroken);
|
||||
}
|
||||
|
||||
// ── SaveData · IsSteelSoul ────────────────────────────────────────────
|
||||
|
||||
[Test]
|
||||
public void SaveMeta_IsSteelSoul_DefaultIsFalse()
|
||||
{
|
||||
var meta = new SaveMeta();
|
||||
Assert.IsFalse(meta.IsSteelSoul, "IsSteelSoul 默认应为 false");
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## asmdef 配置
|
||||
|
||||
在 `Assets/Tests/EditMode/BaseGames.Tests.EditMode.asmdef` 的 `references` 中确认包含:
|
||||
|
||||
```
|
||||
"BaseGames.Core.Save"
|
||||
```
|
||||
|
||||
并在 `precompiledReferences` 中确认包含:
|
||||
|
||||
```
|
||||
"Newtonsoft.Json.dll"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 预期结果
|
||||
|
||||
所有 **17 个测试** 全部通过(绿色)。
|
||||
|
||||
### 常见问题排查
|
||||
|
||||
| 失败现象 | 排查方向 |
|
||||
|---------|---------|
|
||||
| `SaveMigrator` 找不到 | asmdef 未引用 `BaseGames.Core.Save` |
|
||||
| `JsonConvert` 找不到 | asmdef 的 `precompiledReferences` 未包含 `Newtonsoft.Json.dll` |
|
||||
| `ExtensionData` 测试失败 | 确认 `SaveData.ExtensionData` 字段有 `[JsonExtensionData]` 特性 |
|
||||
| `Map.Pins` 测试失败 | 确认 `MapSaveData` 类有 `Pins` 字段且 `SaveMigrator` 已处理 2.0→2.1 迁移 |
|
||||
Reference in New Issue
Block a user