Files
zeling_v2/Docs/Verification/02_Unit_SaveSystem.md

336 lines
12 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 单元测试 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.02.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 迁移 |