Files
zeling_v2/Docs/Verification/02_Unit_SaveSystem.md

12 KiB
Raw Permalink Blame History

单元测试 02 · 存档系统SaveSystem

测试类型EditMode 单元测试NUnit
测试文件Assets/Tests/EditMode/SaveSystemTests.cs
被测程序集BaseGames.Core.Save
asmdef 依赖BaseGames.Tests.EditMode.asmdef 需引用 BaseGames.Core.Save


目录

  1. 测试覆盖范围
  2. SaveMigrator 版本迁移测试
  3. SaveData 序列化/反序列化测试
  4. Checksum 完整性测试
  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.02.1CurrentVersion

关键验证点:

  • null 输入安全返回 null
  • Meta == null 的输入安全返回原对象
  • 空版本号 → 迁移至 2.1,填充 Tutorial/Settings/EventChains/ChallengeRooms
  • "2.0" 版本 → 迁移至 2.1Map.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

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.asmdefreferences 中确认包含:

"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 迁移