376 lines
14 KiB
Markdown
376 lines
14 KiB
Markdown
# 单元测试 04 · 游戏状态机(GameStateMachine)
|
||
|
||
> **测试类型**:EditMode 单元测试(NUnit)
|
||
> **测试文件**:`Assets/Tests/EditMode/GameStateMachineTests.cs`
|
||
> **被测程序集**:`BaseGames.Core`、`BaseGames.Core.Events`
|
||
> **asmdef 依赖**:`BaseGames.Tests.EditMode.asmdef` 需引用 `BaseGames.Core`、`BaseGames.Core.Events`
|
||
|
||
---
|
||
|
||
## 目录
|
||
|
||
1. [测试覆盖范围](#1-测试覆盖范围)
|
||
2. [GameStateMachine 测试](#2-gamestatemachine-测试)
|
||
3. [GameStateId 测试](#3-gamestateid-测试)
|
||
4. [BuiltinGameStates 测试](#4-builtingamestates-测试)
|
||
5. [完整测试代码](#5-完整测试代码)
|
||
|
||
---
|
||
|
||
## 1. 测试覆盖范围
|
||
|
||
| 类 | 测试点 |
|
||
|----|--------|
|
||
| `GameStateMachine` | 状态注册;合法转换成功;非法转换拒绝并返回 false;未知状态 ID 拒绝;OnEnter/OnExit 回调顺序正确;初始状态为 default |
|
||
| `GameStateId` | 值相等性(struct);ToString 输出 |
|
||
| `BuiltinGameStates` | 8 个内置状态 ID 唯一;`GameStates.Gameplay` 等工厂属性非 default |
|
||
|
||
---
|
||
|
||
## 2. GameStateMachine 测试
|
||
|
||
### 测试说明
|
||
|
||
`GameStateMachine` 是纯 C# 类(非 MonoBehaviour),完全可以在 EditMode 中实例化测试。
|
||
|
||
需要创建 `IGameState` 的 Mock 实现来注册状态:
|
||
|
||
```csharp
|
||
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`:
|
||
|
||
```csharp
|
||
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.Core` 和 `BaseGames.Core.Events` |
|
||
| TransitionTo 返回 true 但 error 非 null | 确认 `GameStateMachine.TransitionTo` 成功时将 `error` 设为 `null` |
|