Files
zeling_v2/Docs/Verification/04_Unit_GameStateMachine.md

14 KiB
Raw Permalink Blame History

单元测试 04 · 游戏状态机GameStateMachine

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


目录

  1. 测试覆盖范围
  2. GameStateMachine 测试
  3. GameStateId 测试
  4. BuiltinGameStates 测试
  5. 完整测试代码

1. 测试覆盖范围

测试点
GameStateMachine 状态注册;合法转换成功;非法转换拒绝并返回 false未知状态 ID 拒绝OnEnter/OnExit 回调顺序正确;初始状态为 default
GameStateId 值相等性structToString 输出
BuiltinGameStates 8 个内置状态 ID 唯一;GameStates.Gameplay 等工厂属性非 default

2. GameStateMachine 测试

测试说明

GameStateMachine 是纯 C# 类(非 MonoBehaviour完全可以在 EditMode 中实例化测试。

需要创建 IGameState 的 Mock 实现来注册状态:

private class MockState : IGameState
{
    public GameStateId Id { get; }
    public IReadOnlyCollection<GameStateId> ValidNextStates { get; }
    public bool OnEnterCalled { get; private set; }
    public bool OnExitCalled  { get; private set; }
    public GameStateId EnteredFrom { get; private set; }
    public GameStateId ExitedTo    { get; private set; }

    public MockState(GameStateId id, params GameStateId[] validNext)
    {
        Id = id;
        ValidNextStates = new HashSet<GameStateId>(validNext);
    }

    public void OnEnter(GameStateId from) { OnEnterCalled = true; EnteredFrom = from; }
    public void OnExit(GameStateId to)   { OnExitCalled  = true; ExitedTo = to; }
    public void Tick(float dt) { }
}

关键验证点:

  • 初始状态 CurrentStateId == default
  • TransitionTo 未知状态返回 false + error 包含 "未知状态"
  • 合法转换返回 true + error 为 null
  • 非法转换(不在 ValidNextStates返回 false + error 包含 "非法转换"
  • OnExit 在旧状态上调用,传入新状态 ID
  • OnEnter 在新状态上调用,传入旧状态 ID

3. GameStateId 测试

测试说明

GameStateId 是值类型struct验证其相等性语义两个相同 ID 的实例应 == 相等)。


4. BuiltinGameStates 测试

测试说明

BuiltinGameStates(或 GameStates 静态类)提供 8 个内置状态的 GameStateId 常量,验证它们都是有效的非 default 值。


5. 完整测试代码

将以下代码保存至 Assets/Tests/EditMode/GameStateMachineTests.cs

using System.Collections.Generic;
using NUnit.Framework;
using BaseGames.Core;
using BaseGames.Core.Events;

namespace BaseGames.Tests.EditMode
{
    /// <summary>
    /// GameStateMachine + GameStateId 单元测试EditMode
    /// 覆盖:状态注册、合法/非法转换、OnEnter/OnExit 回调顺序。
    /// </summary>
    [TestFixture]
    public class GameStateMachineTests
    {
        // ── Mock 状态实现 ────────────────────────────────────────────────────

        private class MockState : IGameState
        {
            public GameStateId Id { get; }
            public IReadOnlyCollection<GameStateId> ValidNextStates { get; }
            public bool OnEnterCalled { get; private set; }
            public bool OnExitCalled  { get; private set; }
            public GameStateId EnteredFrom { get; private set; }
            public GameStateId ExitedTo    { get; private set; }
            public int TickCount { get; private set; }

            public MockState(GameStateId id, params GameStateId[] validNext)
            {
                Id = id;
                ValidNextStates = new HashSet<GameStateId>(validNext);
            }

            public void OnEnter(GameStateId from) { OnEnterCalled = true; EnteredFrom = from; }
            public void OnExit(GameStateId to)    { OnExitCalled  = true; ExitedTo    = to; }
            public void Tick(float dt) => TickCount++;
        }

        private static readonly GameStateId StateA = new GameStateId("StateA");
        private static readonly GameStateId StateB = new GameStateId("StateB");
        private static readonly GameStateId StateC = new GameStateId("StateC");

        // ── 初始状态 ─────────────────────────────────────────────────────────

        [Test]
        public void GameStateMachine_InitialState_IsDefault()
        {
            var fsm = new GameStateMachine();
            Assert.AreEqual(default(GameStateId), fsm.CurrentStateId,
                "未注册任何状态时CurrentStateId 应为 default");
        }

        // ── 状态注册与转换 ────────────────────────────────────────────────────

        [Test]
        public void TransitionTo_UnknownState_ReturnsFalse()
        {
            var fsm = new GameStateMachine();
            bool result = fsm.TransitionTo(StateA, out string error);
            Assert.IsFalse(result, "未注册的状态转换应返回 false");
            StringAssert.Contains("未知状态", error);
        }

        [Test]
        public void TransitionTo_RegisteredState_FromNull_Succeeds()
        {
            var fsm   = new GameStateMachine();
            var stateA = new MockState(StateA);
            fsm.Register(stateA);

            bool result = fsm.TransitionTo(StateA, out string error);

            Assert.IsTrue(result,   "注册状态后首次转换应成功");
            Assert.IsNull(error,    "成功转换时 error 应为 null");
            Assert.AreEqual(StateA, fsm.CurrentStateId);
        }

        [Test]
        public void TransitionTo_ValidNextState_Succeeds()
        {
            var fsm    = new GameStateMachine();
            var stateA = new MockState(StateA, StateB); // A 可以转换到 B
            var stateB = new MockState(StateB);
            fsm.Register(stateA);
            fsm.Register(stateB);
            fsm.TransitionTo(StateA, out _);

            bool result = fsm.TransitionTo(StateB, out string error);

            Assert.IsTrue(result);
            Assert.IsNull(error);
            Assert.AreEqual(StateB, fsm.CurrentStateId);
        }

        [Test]
        public void TransitionTo_InvalidNextState_ReturnsFalse()
        {
            var fsm    = new GameStateMachine();
            var stateA = new MockState(StateA);        // A 无有效下一状态
            var stateB = new MockState(StateB);
            fsm.Register(stateA);
            fsm.Register(stateB);
            fsm.TransitionTo(StateA, out _);

            bool result = fsm.TransitionTo(StateB, out string error);

            Assert.IsFalse(result, "非法转换应返回 false");
            StringAssert.Contains("非法转换", error);
            Assert.AreEqual(StateA, fsm.CurrentStateId, "非法转换后状态不应改变");
        }

        // ── OnEnter / OnExit 回调 ────────────────────────────────────────────

        [Test]
        public void TransitionTo_CallsOnExit_OnPreviousState()
        {
            var fsm    = new GameStateMachine();
            var stateA = new MockState(StateA, StateB);
            var stateB = new MockState(StateB);
            fsm.Register(stateA);
            fsm.Register(stateB);
            fsm.TransitionTo(StateA, out _);

            fsm.TransitionTo(StateB, out _);

            Assert.IsTrue(stateA.OnExitCalled,    "转出 StateA 时应调用 OnExit");
            Assert.AreEqual(StateB, stateA.ExitedTo, "OnExit 传入的下一状态应为 StateB");
        }

        [Test]
        public void TransitionTo_CallsOnEnter_OnNextState()
        {
            var fsm    = new GameStateMachine();
            var stateA = new MockState(StateA, StateB);
            var stateB = new MockState(StateB);
            fsm.Register(stateA);
            fsm.Register(stateB);
            fsm.TransitionTo(StateA, out _);

            fsm.TransitionTo(StateB, out _);

            Assert.IsTrue(stateB.OnEnterCalled,    "转入 StateB 时应调用 OnEnter");
            Assert.AreEqual(StateA, stateB.EnteredFrom, "OnEnter 传入的前一状态应为 StateA");
        }

        [Test]
        public void TransitionTo_FirstEntry_OnEnterFromIsDefault()
        {
            var fsm    = new GameStateMachine();
            var stateA = new MockState(StateA);
            fsm.Register(stateA);
            fsm.TransitionTo(StateA, out _);

            Assert.AreEqual(default(GameStateId), stateA.EnteredFrom,
                "首次进入状态时OnEnter 的 from 参数应为 default");
        }

        // ── Tick ──────────────────────────────────────────────────────────────

        [Test]
        public void Tick_DelegatesTo_CurrentState()
        {
            var fsm    = new GameStateMachine();
            var stateA = new MockState(StateA);
            fsm.Register(stateA);
            fsm.TransitionTo(StateA, out _);

            fsm.Tick(0.016f);
            fsm.Tick(0.016f);

            Assert.AreEqual(2, stateA.TickCount, "Tick 应转发给当前状态");
        }

        [Test]
        public void Tick_NoCurrentState_DoesNotThrow()
        {
            var fsm = new GameStateMachine();
            Assert.DoesNotThrow(() => fsm.Tick(0.016f));
        }

        // ── 重复注册 ─────────────────────────────────────────────────────────

        [Test]
        public void Register_SameId_OverwritesPreviousState()
        {
            var fsm     = new GameStateMachine();
            var stateA1 = new MockState(StateA);
            var stateA2 = new MockState(StateA, StateB);
            fsm.Register(stateA1);
            fsm.Register(stateA2); // 覆盖
            fsm.TransitionTo(StateA, out _);

            var stateB = new MockState(StateB);
            fsm.Register(stateB);

            bool result = fsm.TransitionTo(StateB, out _);
            Assert.IsTrue(result, "重新注册后的状态(含 ValidNextStates应覆盖旧注册");
        }

        // ── GameStateId 值相等性 ───────────────────────────────────────────────

        [Test]
        public void GameStateId_SameKey_AreEqual()
        {
            var id1 = new GameStateId("TestState");
            var id2 = new GameStateId("TestState");
            Assert.AreEqual(id1, id2, "相同 key 的 GameStateId 应相等");
        }

        [Test]
        public void GameStateId_DifferentKey_AreNotEqual()
        {
            var id1 = new GameStateId("StateA");
            var id2 = new GameStateId("StateB");
            Assert.AreNotEqual(id1, id2);
        }

        [Test]
        public void GameStateId_DefaultIsNotEqualToNamed()
        {
            var named   = new GameStateId("Something");
            var defaultId = default(GameStateId);
            Assert.AreNotEqual(named, defaultId);
        }

        // ── BuiltinGameStates 唯一性 ───────────────────────────────────────────

        [Test]
        public void BuiltinStates_AllIdsAreUnique()
        {
            var ids = new[]
            {
                GameStates.MainMenu,
                GameStates.Gameplay,
                GameStates.Paused,
                GameStates.BossFight,
                GameStates.Cutscene,
                GameStates.Loading,
                GameStates.Dead,
                GameStates.GameOver,
            };

            var set = new HashSet<GameStateId>(ids);
            Assert.AreEqual(ids.Length, set.Count, "8 个内置游戏状态 ID 必须全部唯一");
        }

        [Test]
        public void BuiltinStates_NoneIsDefault()
        {
            var ids = new[]
            {
                GameStates.MainMenu,
                GameStates.Gameplay,
                GameStates.Paused,
                GameStates.BossFight,
                GameStates.Cutscene,
                GameStates.Loading,
                GameStates.Dead,
                GameStates.GameOver,
            };

            foreach (var id in ids)
            {
                Assert.AreNotEqual(default(GameStateId), id,
                    $"内置状态 {id} 不应等于 default(GameStateId)");
            }
        }
    }
}

预期结果

所有 17 个测试 全部通过(绿色)。

常见问题排查

失败现象 排查方向
GameStateId 构造函数找不到 确认 GameStateId 是 struct构造函数参数为 string key
GameStates.MainMenu 找不到 确认 GameStates 静态类(或 BuiltinGameStates)在 BaseGames.Core 命名空间下
IGameState 接口找不到 asmdef 需同时引用 BaseGames.CoreBaseGames.Core.Events
TransitionTo 返回 true 但 error 非 null 确认 GameStateMachine.TransitionTo 成功时将 error 设为 null