摄像机区域的架构改动
This commit is contained in:
@@ -5,6 +5,8 @@
|
||||
"BaseGames.Combat.StatusEffects",
|
||||
"BaseGames.Combat",
|
||||
"BaseGames.Core",
|
||||
"BaseGames.Core.Events",
|
||||
"BaseGames.Core.Save",
|
||||
"BaseGames.EventChain",
|
||||
"UnityEngine.TestRunner",
|
||||
"UnityEditor.TestRunner"
|
||||
@@ -16,7 +18,8 @@
|
||||
"allowUnsafeCode": false,
|
||||
"overrideReferences": true,
|
||||
"precompiledReferences": [
|
||||
"nunit.framework.dll"
|
||||
"nunit.framework.dll",
|
||||
"Newtonsoft.Json.dll"
|
||||
],
|
||||
"autoReferenced": false,
|
||||
"defineConstraints": [],
|
||||
|
||||
255
Assets/Tests/EditMode/EventSystemTests.cs
Normal file
255
Assets/Tests/EditMode/EventSystemTests.cs
Normal file
@@ -0,0 +1,255 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using NUnit.Framework;
|
||||
using BaseGames.Core.Events;
|
||||
using BaseGames.Core;
|
||||
using UObject = UnityEngine.Object;
|
||||
using UnityEngine;
|
||||
|
||||
namespace BaseGames.Tests.EditMode
|
||||
{
|
||||
/// <summary>
|
||||
/// SO 事件系统 + ServiceLocator 单元测试(EditMode,无需 Play Mode)。
|
||||
/// </summary>
|
||||
[TestFixture]
|
||||
public class EventSystemTests
|
||||
{
|
||||
// ── EventSubscription ────────────────────────────────────────────────
|
||||
|
||||
[Test]
|
||||
public void EventSubscription_Dispose_CallsUnsubscribeAction()
|
||||
{
|
||||
bool called = false;
|
||||
var sub = new EventSubscription(() => called = true);
|
||||
sub.Dispose();
|
||||
Assert.IsTrue(called, "Dispose 应调用 unsubscribe action");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void EventSubscription_Dispose_NullAction_DoesNotThrow()
|
||||
{
|
||||
var sub = new EventSubscription(null);
|
||||
Assert.DoesNotThrow(() => sub.Dispose());
|
||||
}
|
||||
|
||||
// ── CompositeDisposable ──────────────────────────────────────────────
|
||||
|
||||
[Test]
|
||||
public void CompositeDisposable_Clear_DisposesAllItems()
|
||||
{
|
||||
var composite = new CompositeDisposable();
|
||||
int count = 0;
|
||||
composite.Add(new EventSubscription(() => count++));
|
||||
composite.Add(new EventSubscription(() => count++));
|
||||
composite.Add(new EventSubscription(() => count++));
|
||||
|
||||
composite.Clear();
|
||||
Assert.AreEqual(3, count, "Clear 应 Dispose 所有已添加的订阅");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void CompositeDisposable_Clear_TwiceSafe()
|
||||
{
|
||||
var composite = new CompositeDisposable();
|
||||
int count = 0;
|
||||
composite.Add(new EventSubscription(() => count++));
|
||||
composite.Clear();
|
||||
composite.Clear(); // 第二次 Clear 不应重复 Dispose
|
||||
Assert.AreEqual(1, count, "Clear 两次不应重复 Dispose");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void CompositeDisposable_Dispose_SameAsClear()
|
||||
{
|
||||
var composite = new CompositeDisposable();
|
||||
bool disposed = false;
|
||||
composite.Add(new EventSubscription(() => disposed = true));
|
||||
((IDisposable)composite).Dispose();
|
||||
Assert.IsTrue(disposed);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AddTo_ReturnsOriginalSubscription()
|
||||
{
|
||||
var composite = new CompositeDisposable();
|
||||
bool called = false;
|
||||
var sub = new EventSubscription(() => called = true);
|
||||
var returned = sub.AddTo(composite);
|
||||
returned.Dispose();
|
||||
Assert.IsTrue(called, "AddTo 返回的订阅 Dispose 后应触发原 unsubscribe");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AddTo_Collection_AddsToList()
|
||||
{
|
||||
var list = new List<IDisposable>();
|
||||
bool called = false;
|
||||
var sub = new EventSubscription(() => called = true);
|
||||
sub.AddTo(list);
|
||||
Assert.AreEqual(1, list.Count, "AddTo(ICollection) 应将订阅添加到列表");
|
||||
list[0].Dispose();
|
||||
Assert.IsTrue(called);
|
||||
}
|
||||
|
||||
// ── IntEventChannelSO ────────────────────────────────────────────────
|
||||
|
||||
[Test]
|
||||
public void IntEventChannel_Raise_CallsAllSubscribers()
|
||||
{
|
||||
var channel = ScriptableObject.CreateInstance<IntEventChannelSO>();
|
||||
try
|
||||
{
|
||||
int sum = 0;
|
||||
channel.OnEventRaised += v => sum += v;
|
||||
channel.OnEventRaised += v => sum += v;
|
||||
channel.Raise(5);
|
||||
Assert.AreEqual(10, sum, "两个订阅者应各收到一次事件");
|
||||
}
|
||||
finally
|
||||
{
|
||||
UObject.DestroyImmediate(channel);
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void IntEventChannel_Raise_NoSubscribers_DoesNotThrow()
|
||||
{
|
||||
var channel = ScriptableObject.CreateInstance<IntEventChannelSO>();
|
||||
try
|
||||
{
|
||||
Assert.DoesNotThrow(() => channel.Raise(42));
|
||||
}
|
||||
finally
|
||||
{
|
||||
UObject.DestroyImmediate(channel);
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void IntEventChannel_UnsubscribeAfterDispose()
|
||||
{
|
||||
var channel = ScriptableObject.CreateInstance<IntEventChannelSO>();
|
||||
try
|
||||
{
|
||||
int callCount = 0;
|
||||
void Handler(int v) => callCount++;
|
||||
channel.OnEventRaised += Handler;
|
||||
channel.Raise(1);
|
||||
Assert.AreEqual(1, callCount);
|
||||
|
||||
channel.OnEventRaised -= Handler;
|
||||
channel.Raise(1);
|
||||
Assert.AreEqual(1, callCount, "取消订阅后不应再收到事件");
|
||||
}
|
||||
finally
|
||||
{
|
||||
UObject.DestroyImmediate(channel);
|
||||
}
|
||||
}
|
||||
|
||||
// ── VoidEventChannelSO ───────────────────────────────────────────────
|
||||
|
||||
[Test]
|
||||
public void VoidEventChannel_Raise_CallsSubscriber()
|
||||
{
|
||||
var channel = ScriptableObject.CreateInstance<VoidEventChannelSO>();
|
||||
try
|
||||
{
|
||||
bool received = false;
|
||||
channel.OnEventRaised += () => received = true;
|
||||
channel.Raise();
|
||||
Assert.IsTrue(received);
|
||||
}
|
||||
finally
|
||||
{
|
||||
UObject.DestroyImmediate(channel);
|
||||
}
|
||||
}
|
||||
|
||||
// ── ServiceLocator ───────────────────────────────────────────────────
|
||||
|
||||
[SetUp]
|
||||
public void SetUp() => ServiceLocator.Reset();
|
||||
|
||||
[TearDown]
|
||||
public void TearDown() => ServiceLocator.Reset();
|
||||
|
||||
[Test]
|
||||
public void ServiceLocator_RegisterAndGet_ReturnsInstance()
|
||||
{
|
||||
var mock = new MockService();
|
||||
ServiceLocator.Register<IMockService>(mock);
|
||||
var retrieved = ServiceLocator.Get<IMockService>();
|
||||
Assert.AreSame(mock, retrieved);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ServiceLocator_GetUnregistered_ThrowsInvalidOperation()
|
||||
{
|
||||
Assert.Throws<InvalidOperationException>(
|
||||
() => ServiceLocator.Get<IMockService>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ServiceLocator_GetOrDefault_Unregistered_ReturnsDefault()
|
||||
{
|
||||
var result = ServiceLocator.GetOrDefault<IMockService>();
|
||||
Assert.IsNull(result);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ServiceLocator_GetOrDefault_Registered_ReturnsInstance()
|
||||
{
|
||||
var mock = new MockService();
|
||||
ServiceLocator.Register<IMockService>(mock);
|
||||
var result = ServiceLocator.GetOrDefault<IMockService>();
|
||||
Assert.AreSame(mock, result);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ServiceLocator_RegisterIfAbsent_DoesNotOverwrite()
|
||||
{
|
||||
var first = new MockService();
|
||||
var second = new MockService();
|
||||
ServiceLocator.Register<IMockService>(first);
|
||||
ServiceLocator.RegisterIfAbsent<IMockService>(second);
|
||||
Assert.AreSame(first, ServiceLocator.Get<IMockService>(),
|
||||
"RegisterIfAbsent 不应覆盖已存在的注册");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ServiceLocator_Unregister_RemovesService()
|
||||
{
|
||||
ServiceLocator.Register<IMockService>(new MockService());
|
||||
ServiceLocator.Unregister<IMockService>();
|
||||
Assert.Throws<InvalidOperationException>(
|
||||
() => ServiceLocator.Get<IMockService>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ServiceLocator_Unregister_WithWrongInstance_DoesNotRemove()
|
||||
{
|
||||
var first = new MockService();
|
||||
var second = new MockService();
|
||||
ServiceLocator.Register<IMockService>(first);
|
||||
ServiceLocator.Unregister<IMockService>(second); // 不同实例,不应移除
|
||||
Assert.AreSame(first, ServiceLocator.Get<IMockService>(),
|
||||
"错误实例的 Unregister 不应移除已注册的服务");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ServiceLocator_OverrideForTest_ReplacesService()
|
||||
{
|
||||
var original = new MockService { Value = 1 };
|
||||
var replacement = new MockService { Value = 99 };
|
||||
ServiceLocator.Register<IMockService>(original);
|
||||
ServiceLocator.OverrideForTest<IMockService>(replacement);
|
||||
Assert.AreEqual(99, ServiceLocator.Get<IMockService>().Value);
|
||||
}
|
||||
|
||||
// ── Test Helpers ─────────────────────────────────────────────────────
|
||||
|
||||
private interface IMockService { int Value { get; set; } }
|
||||
private class MockService : IMockService { public int Value { get; set; } }
|
||||
}
|
||||
}
|
||||
11
Assets/Tests/EditMode/EventSystemTests.cs.meta
Normal file
11
Assets/Tests/EditMode/EventSystemTests.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: a7e7c694d5291464ea9c12df85b95bdb
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
270
Assets/Tests/EditMode/GameStateMachineTests.cs
Normal file
270
Assets/Tests/EditMode/GameStateMachineTests.cs
Normal file
@@ -0,0 +1,270 @@
|
||||
using System.Collections.Generic;
|
||||
using NUnit.Framework;
|
||||
using BaseGames.Core;
|
||||
using BaseGames.Core.Events;
|
||||
|
||||
namespace BaseGames.Tests.EditMode
|
||||
{
|
||||
/// <summary>
|
||||
/// GameStateMachine + GameStateId 单元测试(EditMode)。
|
||||
/// 覆盖:状态注册、合法/非法转换、OnEnter/OnExit 回调顺序、Tick 分发。
|
||||
/// </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); // 重新注册,含 B 作为有效下一状态
|
||||
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.Initializing,
|
||||
GameStates.MainMenu,
|
||||
GameStates.Gameplay,
|
||||
GameStates.Paused,
|
||||
GameStates.BossFight,
|
||||
GameStates.Cutscene,
|
||||
GameStates.LoadingScene,
|
||||
GameStates.Dead,
|
||||
GameStates.GameOver,
|
||||
};
|
||||
|
||||
var set = new HashSet<GameStateId>(ids);
|
||||
Assert.AreEqual(ids.Length, set.Count, "9 个内置游戏状态 ID 必须全部唯一");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void BuiltinStates_NoneIsDefault()
|
||||
{
|
||||
var ids = new[]
|
||||
{
|
||||
GameStates.Initializing,
|
||||
GameStates.MainMenu,
|
||||
GameStates.Gameplay,
|
||||
GameStates.Paused,
|
||||
GameStates.BossFight,
|
||||
GameStates.Cutscene,
|
||||
GameStates.LoadingScene,
|
||||
GameStates.Dead,
|
||||
GameStates.GameOver,
|
||||
};
|
||||
|
||||
foreach (var id in ids)
|
||||
{
|
||||
Assert.AreNotEqual(default(GameStateId), id,
|
||||
$"内置状态 {id} 不应等于 default(GameStateId)");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
11
Assets/Tests/EditMode/GameStateMachineTests.cs.meta
Normal file
11
Assets/Tests/EditMode/GameStateMachineTests.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 4ddb4fe2e45484443bf4cda5e3ac0fe8
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
223
Assets/Tests/EditMode/SaveSystemTests.cs
Normal file
223
Assets/Tests/EditMode/SaveSystemTests.cs
Normal file
@@ -0,0 +1,223 @@
|
||||
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.CurrentLingZhu = 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.CurrentLingZhu, restored.Player.CurrentLingZhu);
|
||||
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);
|
||||
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);
|
||||
}
|
||||
|
||||
// ── SaveMeta · IsSteelSoul ────────────────────────────────────────────
|
||||
|
||||
[Test]
|
||||
public void SaveMeta_IsSteelSoul_DefaultIsFalse()
|
||||
{
|
||||
var meta = new SaveMeta();
|
||||
Assert.IsFalse(meta.IsSteelSoul, "IsSteelSoul 默认应为 false");
|
||||
}
|
||||
}
|
||||
}
|
||||
11
Assets/Tests/EditMode/SaveSystemTests.cs.meta
Normal file
11
Assets/Tests/EditMode/SaveSystemTests.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 8d5a2681d950627479fc5246ed3ba04e
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -129,5 +129,83 @@ namespace BaseGames.Tests.EditMode
|
||||
{
|
||||
Assert.AreEqual(StatusEffectType.Stagger, new StaggerEffect().EffectType);
|
||||
}
|
||||
|
||||
// ── 补充测试 ──────────────────────────────────────────────────────────
|
||||
|
||||
[Test]
|
||||
public void StatusEffect_StackCount_InitialValueIsOne()
|
||||
{
|
||||
var effect = new FireEffect();
|
||||
Assert.AreEqual(1, effect.StackCount, "新建 Effect 的初始 StackCount 应为 1");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void StatusEffect_Update_RemainingTime_Decreases()
|
||||
{
|
||||
var effect = new StaggerEffect(2.0f);
|
||||
effect.OnApply(null);
|
||||
float initialDuration = effect.Duration;
|
||||
|
||||
effect.Update(0.5f);
|
||||
|
||||
Assert.Less(effect.Duration, initialDuration, "Update 后剩余时间应减少");
|
||||
Assert.IsFalse(effect.IsExpired, "0.5s Update 后 2.0s 效果不应过期");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void StatusEffect_Update_ExactExpiry()
|
||||
{
|
||||
var effect = new StaggerEffect(1.0f);
|
||||
effect.OnApply(null);
|
||||
|
||||
effect.Update(1.0f); // 恰好耗尽
|
||||
|
||||
Assert.IsTrue(effect.IsExpired, "恰好耗尽 duration 后应 IsExpired == true");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void MultipleEffects_IndependentTimers()
|
||||
{
|
||||
var fire = new FireEffect();
|
||||
var poison = new PoisonEffect();
|
||||
fire.OnApply(null);
|
||||
poison.OnApply(null);
|
||||
|
||||
float fireDuration = fire.Duration;
|
||||
float poisonDuration = poison.Duration;
|
||||
|
||||
fire.Update(0.5f);
|
||||
|
||||
// poison 未调用 Update,时间不应变化
|
||||
Assert.AreEqual(poisonDuration, poison.Duration, 0.0001f,
|
||||
"未 Update 的 Effect 持续时间不应减少");
|
||||
Assert.Less(fire.Duration, fireDuration, "已 Update 的 Effect 持续时间应减少");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void PoisonEffect_OnStack_DoesNotExceedMaxStacks_EdgeCase()
|
||||
{
|
||||
var effect = new PoisonEffect();
|
||||
// MaxStacks = 3,初始 StackCount = 1,再叠加 2 次达到上限
|
||||
effect.OnStack();
|
||||
effect.OnStack();
|
||||
Assert.AreEqual(3, effect.StackCount);
|
||||
|
||||
// 再叠加,不应超过 3
|
||||
effect.OnStack();
|
||||
effect.OnStack();
|
||||
Assert.AreEqual(3, effect.StackCount, "StackCount 不应超过 MaxStacks");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void FireEffect_OnApply_ThenExpire_DoesNotThrow()
|
||||
{
|
||||
var effect = new FireEffect();
|
||||
Assert.DoesNotThrow(() =>
|
||||
{
|
||||
effect.OnApply(null);
|
||||
effect.OnExpire(); // 注意:实际 API 为 OnExpire(),文档中误写为 OnRemove()
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user