Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions GFramework.Core.Abstractions/StateManagement/IStore.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,57 @@ namespace GFramework.Core.Abstractions.StateManagement;
/// <typeparam name="TState">状态树的根状态类型。</typeparam>
public interface IStore<out TState> : IReadonlyStore<TState>
{
/// <summary>
/// 获取当前是否可以撤销到更早的历史状态。
/// 当未启用历史缓冲区,或当前已经位于最早历史点时,返回 <see langword="false"/>。
/// </summary>
bool CanUndo { get; }

/// <summary>
/// 获取当前是否可以重做到更晚的历史状态。
/// 当未启用历史缓冲区,或当前已经位于最新历史点时,返回 <see langword="false"/>。
/// </summary>
bool CanRedo { get; }

/// <summary>
/// 分发一个 action 以触发状态演进。
/// Store 会按注册顺序执行与该 action 类型匹配的 reducer,并在状态变化后通知订阅者。
/// </summary>
/// <typeparam name="TAction">action 的具体类型。</typeparam>
/// <param name="action">要分发的 action 实例。</param>
void Dispatch<TAction>(TAction action);

/// <summary>
/// 将多个状态操作合并到一个批处理中执行。
/// 批处理内部的每次分发仍会立即更新 Store 状态和历史,但订阅通知会延迟到最外层批处理结束后再统一触发一次。
/// </summary>
/// <param name="batchAction">批处理主体;调用方应在其中执行若干次 <see cref="Dispatch{TAction}(TAction)"/>、<see cref="Undo"/> 或 <see cref="Redo"/>。</param>
void RunInBatch(Action batchAction);

/// <summary>
/// 将当前状态回退到上一个历史点。
/// </summary>
/// <exception cref="InvalidOperationException">当历史缓冲区未启用,或当前已经没有可撤销的历史点时抛出。</exception>
void Undo();

/// <summary>
/// 将当前状态前进到下一个历史点。
/// </summary>
/// <exception cref="InvalidOperationException">当历史缓冲区未启用,或当前已经没有可重做的历史点时抛出。</exception>
void Redo();

/// <summary>
/// 跳转到指定索引的历史点。
/// 该能力适合调试面板或开发工具实现时间旅行查看。
/// </summary>
/// <param name="historyIndex">目标历史索引,从 0 开始。</param>
/// <exception cref="InvalidOperationException">当历史缓冲区未启用时抛出。</exception>
/// <exception cref="ArgumentOutOfRangeException">当 <paramref name="historyIndex"/> 超出当前历史范围时抛出。</exception>
void TimeTravelTo(int historyIndex);

/// <summary>
/// 清空当前撤销/重做历史,并以当前状态作为新的历史锚点。
/// 该操作不会修改当前状态,也不会触发额外通知。
/// </summary>
void ClearHistory();
}
16 changes: 16 additions & 0 deletions GFramework.Core.Abstractions/StateManagement/IStoreBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,22 @@ public interface IStoreBuilder<TState>
/// <returns>当前构建器实例。</returns>
IStoreBuilder<TState> WithComparer(IEqualityComparer<TState> comparer);

/// <summary>
/// 配置历史缓冲区容量。
/// 传入 0 表示禁用历史记录;大于 0 时会保留最近若干个状态快照,用于撤销、重做和时间旅行调试。
/// </summary>
/// <param name="historyCapacity">历史缓冲区容量。</param>
/// <returns>当前构建器实例。</returns>
IStoreBuilder<TState> WithHistoryCapacity(int historyCapacity);

/// <summary>
/// 配置 reducer 的 action 匹配策略。
/// 默认使用 <see cref="StoreActionMatchingMode.ExactTypeOnly"/>,仅在需要复用基类或接口 action 层次时再启用多态匹配。
/// </summary>
/// <param name="actionMatchingMode">要使用的匹配策略。</param>
/// <returns>当前构建器实例。</returns>
IStoreBuilder<TState> WithActionMatching(StoreActionMatchingMode actionMatchingMode);

/// <summary>
/// 添加一个强类型 reducer。
/// </summary>
Expand Down
36 changes: 36 additions & 0 deletions GFramework.Core.Abstractions/StateManagement/IStoreDiagnostics.cs
Original file line number Diff line number Diff line change
Expand Up @@ -28,4 +28,40 @@ public interface IStoreDiagnostics<TState>
/// 获取最近一次分发记录。
/// </summary>
StoreDispatchRecord<TState>? LastDispatchRecord { get; }

/// <summary>
/// 获取当前 Store 使用的 action 匹配策略。
/// </summary>
StoreActionMatchingMode ActionMatchingMode { get; }

/// <summary>
/// 获取历史缓冲区容量。
/// 返回 0 表示当前 Store 未启用历史记录能力。
/// </summary>
int HistoryCapacity { get; }

/// <summary>
/// 获取当前可见历史记录数量。
/// 当历史记录启用时,该值至少为 1,因为当前状态会作为历史锚点存在。
/// </summary>
int HistoryCount { get; }

/// <summary>
/// 获取当前状态在历史缓冲区中的索引。
/// 当未启用历史记录时返回 -1。
/// </summary>
int HistoryIndex { get; }

/// <summary>
/// 获取当前是否处于批处理阶段。
/// 该值为 <see langword="true"/> 时,状态变更通知会延迟到最外层批处理结束后再统一发送。
/// </summary>
bool IsBatching { get; }

/// <summary>
/// 获取当前历史快照列表的只读快照。
/// 该方法会返回一份独立快照,供调试工具渲染时间旅行面板,而不暴露 Store 的内部可变集合。
/// </summary>
/// <returns>当前历史快照列表;若未启用历史记录或当前没有历史,则返回空数组。</returns>
IReadOnlyList<StoreHistoryEntry<TState>> GetHistoryEntriesSnapshot();
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
namespace GFramework.Core.Abstractions.StateManagement;

/// <summary>
/// 定义 Store 在分发 action 时的 reducer 匹配策略。
/// 默认使用精确类型匹配,以保持执行结果和顺序的确定性;仅在确有需要时再启用多态匹配。
/// </summary>
public enum StoreActionMatchingMode
{
/// <summary>
/// 仅匹配与 action 运行时类型完全相同的 reducer。
/// 该模式不会命中基类或接口注册,适合作为默认的稳定行为。
/// </summary>
ExactTypeOnly = 0,

/// <summary>
/// 在精确类型匹配之外,额外匹配可赋值的基类和接口 reducer。
/// Store 会保持确定性的执行顺序:精确类型优先,其次是最近的基类,最后是接口注册。
/// </summary>
IncludeAssignableTypes = 1
}
44 changes: 44 additions & 0 deletions GFramework.Core.Abstractions/StateManagement/StoreHistoryEntry.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
namespace GFramework.Core.Abstractions.StateManagement;

/// <summary>
/// 表示一条 Store 历史快照记录。
/// 该记录用于撤销/重做和调试面板查看历史状态,不会暴露 Store 的内部可变结构。
/// </summary>
/// <typeparam name="TState">状态树的根状态类型。</typeparam>
public sealed class StoreHistoryEntry<TState>
{
/// <summary>
/// 初始化一条历史记录。
/// </summary>
/// <param name="state">该历史点对应的状态快照。</param>
/// <param name="recordedAt">该历史点被记录的时间。</param>
/// <param name="action">触发该状态的 action;若为初始状态或已清空历史后的锚点,则为 <see langword="null"/>。</param>
public StoreHistoryEntry(TState state, DateTimeOffset recordedAt, object? action = null)
{
State = state;
RecordedAt = recordedAt;
Action = action;
}

/// <summary>
/// 获取该历史点对应的状态快照。
/// </summary>
public TState State { get; }

/// <summary>
/// 获取该历史点被记录的时间。
/// </summary>
public DateTimeOffset RecordedAt { get; }

/// <summary>
/// 获取触发该历史点的 action 实例。
/// 对于初始状态或调用 <c>ClearHistory()</c> 后的新锚点,该值为 <see langword="null"/>。
/// </summary>
public object? Action { get; }

/// <summary>
/// 获取触发该历史点的 action 运行时类型。
/// 若该历史点没有关联 action,则返回 <see langword="null"/>。
/// </summary>
public Type? ActionType => Action?.GetType();
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
using GFramework.Core.Events;

namespace GFramework.Core.Tests.StateManagement;

/// <summary>
/// Store 到 EventBus 桥接扩展的单元测试。
/// 这些测试验证旧模块兼容桥接能够正确转发 dispatch 和状态变化事件,并支持运行时拆除。
/// </summary>
[TestFixture]
public class StoreEventBusExtensionsTests
{
/// <summary>
/// 测试桥接会发布每次 dispatch 事件,并对批处理后的状态变化只发送一次最终状态事件。
/// </summary>
[Test]
public void BridgeToEventBus_Should_Publish_Dispatches_And_Collapsed_State_Changes()
{
var eventBus = new EventBus();
var store = CreateStore();
var dispatchedEvents = new List<StoreDispatchedEvent<CounterState>>();
var stateChangedEvents = new List<StoreStateChangedEvent<CounterState>>();

eventBus.Register<StoreDispatchedEvent<CounterState>>(dispatchedEvents.Add);
eventBus.Register<StoreStateChangedEvent<CounterState>>(stateChangedEvents.Add);

store.BridgeToEventBus(eventBus);

store.Dispatch(new IncrementAction(1));
store.RunInBatch(() =>
{
store.Dispatch(new IncrementAction(1));
store.Dispatch(new IncrementAction(1));
});

Assert.That(dispatchedEvents.Count, Is.EqualTo(3));
Assert.That(dispatchedEvents[0].DispatchRecord.NextState.Count, Is.EqualTo(1));
Assert.That(dispatchedEvents[2].DispatchRecord.NextState.Count, Is.EqualTo(3));

Assert.That(stateChangedEvents.Count, Is.EqualTo(2));
Assert.That(stateChangedEvents[0].State.Count, Is.EqualTo(1));
Assert.That(stateChangedEvents[1].State.Count, Is.EqualTo(3));
}

/// <summary>
/// 测试桥接句柄注销后不会再继续向 EventBus 发送事件。
/// </summary>
[Test]
public void BridgeToEventBus_UnRegister_Should_Stop_Future_Publications()
{
var eventBus = new EventBus();
var store = CreateStore();
var dispatchedEvents = new List<StoreDispatchedEvent<CounterState>>();
var stateChangedEvents = new List<StoreStateChangedEvent<CounterState>>();

eventBus.Register<StoreDispatchedEvent<CounterState>>(dispatchedEvents.Add);
eventBus.Register<StoreStateChangedEvent<CounterState>>(stateChangedEvents.Add);

var bridge = store.BridgeToEventBus(eventBus);

store.Dispatch(new IncrementAction(1));
bridge.UnRegister();
store.Dispatch(new IncrementAction(1));

Assert.That(dispatchedEvents.Count, Is.EqualTo(1));
Assert.That(stateChangedEvents.Count, Is.EqualTo(1));
Assert.That(stateChangedEvents[0].State.Count, Is.EqualTo(1));
}

/// <summary>
/// 创建一个带基础 reducer 的测试 Store。
/// </summary>
/// <returns>测试用 Store 实例。</returns>
private static Store<CounterState> CreateStore()
{
var store = new Store<CounterState>(new CounterState(0, "Player"));
store.RegisterReducer<IncrementAction>((state, action) => state with { Count = state.Count + action.Amount });
return store;
}

/// <summary>
/// 用于桥接测试的状态类型。
/// </summary>
/// <param name="Count">当前计数值。</param>
/// <param name="Name">当前名称。</param>
private sealed record CounterState(int Count, string Name);

/// <summary>
/// 用于桥接测试的计数 action。
/// </summary>
/// <param name="Amount">要增加的数量。</param>
private sealed record IncrementAction(int Amount);
}
Loading
Loading