Files
zeling_v2/Docs/Verification/04_Unit_GameStateMachine.md

376 lines
14 KiB
Markdown
Raw 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.
# 单元测试 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` | 值相等性structToString 输出 |
| `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` |