test(coroutine): 补齐 Godot 协程宿主回归测试

- 新增 Timing 纯托管测试宿主入口,支持在 dotnet test 下验证 Godot 协程阶段推进
- 补充 TimingTests,覆盖暂停、segment 路由和阶段等待回归
- 更新 coroutine ai-plan 跟踪与 trace,记录 RP-002 验证结果与后续缺口
This commit is contained in:
gewuyou 2026-04-20 09:40:46 +08:00
parent 374db438ea
commit 9576e0f8bd
5 changed files with 405 additions and 14 deletions

View File

@ -0,0 +1,196 @@
using GFramework.Core.Abstractions.Coroutine;
using GFramework.Core.Coroutine;
using GFramework.Core.Coroutine.Instructions;
using GFramework.Godot.Coroutine;
using System.Runtime.CompilerServices;
namespace GFramework.Godot.Tests.Coroutine;
/// <summary>
/// 验证 <see cref="Timing" /> 在纯托管测试宿主下仍保持与真实 Godot 生命周期一致的阶段语义。
/// </summary>
[TestFixture]
public sealed class TimingTests
{
private Timing _timing = null!;
/// <summary>
/// 为每个测试准备独立的 Timing 宿主,避免静态实例槽位相互污染。
/// </summary>
[SetUp]
public void SetUp()
{
// Timing 继承自 Godot.Node在纯 dotnet test 宿主中直接运行原生构造函数会触发测试进程崩溃。
// 这里仅为调度语义测试创建未初始化对象,再由 InitializeForTests 补齐纯托管字段与调度器状态。
_timing = (Timing)RuntimeHelpers.GetUninitializedObject(typeof(Timing));
_timing.InitializeForTests();
}
/// <summary>
/// 清理测试宿主注册的调度器与实例槽位。
/// </summary>
[TearDown]
public void TearDown()
{
_timing.DisposeForTests();
}
/// <summary>
/// 验证暂停场景时只会冻结普通 Process 协程,忽略暂停段仍会继续推进。
/// </summary>
[Test]
public void AdvanceProcessFrameForTests_Should_Freeze_Process_Segment_But_Keep_IgnorePause_Segment_Running()
{
var executedSegments = new List<string>();
var processHandle = _timing.RunCoroutineOnInstance(
CompleteAfterOneFrame(() => executedSegments.Add("process")),
Segment.Process);
var ignorePauseHandle = _timing.RunCoroutineOnInstance(
CompleteAfterOneFrame(() => executedSegments.Add("ignore-pause")),
Segment.ProcessIgnorePause);
_timing.AdvanceProcessFrameForTests(paused: true);
Assert.Multiple(() =>
{
Assert.That(executedSegments, Is.EqualTo(new[] { "ignore-pause" }));
Assert.That(_timing.ProcessCoroutines, Is.EqualTo(1));
Assert.That(_timing.ProcessIgnorePauseCoroutines, Is.EqualTo(0));
Assert.That(_timing.GetSchedulerForTests(Segment.Process).IsCoroutineAlive(processHandle), Is.True);
Assert.That(
_timing.GetSchedulerForTests(Segment.ProcessIgnorePause)
.TryGetCompletionStatus(ignorePauseHandle, out var status),
Is.True);
Assert.That(status, Is.EqualTo(CoroutineCompletionStatus.Completed));
});
}
/// <summary>
/// 验证 Physics 帧只会推进 Physics 段,不会提前消费普通 Process 段的等待。
/// </summary>
[Test]
public void AdvancePhysicsFrameForTests_Should_Only_Advance_Physics_Segment()
{
var executedSegments = new List<string>();
var processHandle = _timing.RunCoroutineOnInstance(
CompleteAfterOneFrame(() => executedSegments.Add("process")),
Segment.Process);
var physicsHandle = _timing.RunCoroutineOnInstance(
CompleteAfterOneFrame(() => executedSegments.Add("physics")),
Segment.PhysicsProcess);
_timing.AdvancePhysicsFrameForTests();
Assert.Multiple(() =>
{
Assert.That(executedSegments, Is.EqualTo(new[] { "physics" }));
Assert.That(_timing.GetSchedulerForTests(Segment.Process).IsCoroutineAlive(processHandle), Is.True);
Assert.That(_timing.GetSchedulerForTests(Segment.PhysicsProcess).IsCoroutineAlive(physicsHandle), Is.False);
});
}
/// <summary>
/// 验证帧尾段会在 Process 段之后执行,保持与生产宿主 `_Process -> CallDeferred` 的顺序一致。
/// </summary>
[Test]
public void AdvanceProcessFrameForTests_Should_Run_Deferred_Segment_After_Process_Segment()
{
var executionOrder = new List<string>();
_timing.RunCoroutineOnInstance(
CompleteAfterOneFrame(() => executionOrder.Add("process")),
Segment.Process);
_timing.RunCoroutineOnInstance(
CompleteAfterOneFrame(() => executionOrder.Add("deferred")),
Segment.DeferredProcess);
_timing.AdvanceProcessFrameForTests(paused: false);
Assert.That(executionOrder, Is.EqualTo(new[] { "process", "deferred" }));
}
/// <summary>
/// 验证 <see cref="WaitForFixedUpdate" /> 只会在 Physics 段完成,避免阶段型等待被错误地提前消费。
/// </summary>
[Test]
public void WaitForFixedUpdate_Should_Only_Complete_On_Physics_Segment()
{
var processCompletions = 0;
var physicsCompletions = 0;
var processHandle = _timing.RunCoroutineOnInstance(
CompleteAfterInstruction(new WaitForFixedUpdate(), () => processCompletions++),
Segment.Process);
var physicsHandle = _timing.RunCoroutineOnInstance(
CompleteAfterInstruction(new WaitForFixedUpdate(), () => physicsCompletions++),
Segment.PhysicsProcess);
_timing.AdvanceProcessFrameForTests(paused: false);
_timing.AdvancePhysicsFrameForTests();
Assert.Multiple(() =>
{
Assert.That(processCompletions, Is.EqualTo(0));
Assert.That(physicsCompletions, Is.EqualTo(1));
Assert.That(_timing.GetSchedulerForTests(Segment.Process).IsCoroutineAlive(processHandle), Is.True);
Assert.That(_timing.GetSchedulerForTests(Segment.PhysicsProcess).IsCoroutineAlive(physicsHandle), Is.False);
});
}
/// <summary>
/// 验证 <see cref="WaitForEndOfFrame" /> 只会在 Deferred 段完成,避免提前穿透到普通 Process 段。
/// </summary>
[Test]
public void WaitForEndOfFrame_Should_Only_Complete_On_Deferred_Segment()
{
var processCompletions = 0;
var deferredCompletions = 0;
var processHandle = _timing.RunCoroutineOnInstance(
CompleteAfterInstruction(new WaitForEndOfFrame(), () => processCompletions++),
Segment.Process);
var deferredHandle = _timing.RunCoroutineOnInstance(
CompleteAfterInstruction(new WaitForEndOfFrame(), () => deferredCompletions++),
Segment.DeferredProcess);
_timing.AdvanceProcessFrameForTests(paused: false);
Assert.Multiple(() =>
{
Assert.That(processCompletions, Is.EqualTo(0));
Assert.That(deferredCompletions, Is.EqualTo(1));
Assert.That(_timing.GetSchedulerForTests(Segment.Process).IsCoroutineAlive(processHandle), Is.True);
Assert.That(_timing.GetSchedulerForTests(Segment.DeferredProcess).IsCoroutineAlive(deferredHandle), Is.False);
});
}
/// <summary>
/// 构造一个在单帧等待后执行回调的测试协程。
/// </summary>
/// <param name="onCompleted">等待完成后执行的回调。</param>
/// <returns>供 Timing 运行的协程枚举器。</returns>
private static IEnumerator<IYieldInstruction> CompleteAfterOneFrame(Action onCompleted)
{
ArgumentNullException.ThrowIfNull(onCompleted);
yield return new WaitOneFrame();
onCompleted();
}
/// <summary>
/// 构造一个在指定等待指令完成后执行回调的测试协程。
/// </summary>
/// <param name="instruction">要验证的等待指令。</param>
/// <param name="onCompleted">等待完成后执行的回调。</param>
/// <returns>供 Timing 运行的协程枚举器。</returns>
private static IEnumerator<IYieldInstruction> CompleteAfterInstruction(
IYieldInstruction instruction,
Action onCompleted)
{
ArgumentNullException.ThrowIfNull(instruction);
ArgumentNullException.ThrowIfNull(onCompleted);
yield return instruction;
onCompleted();
}
}

View File

@ -0,0 +1,159 @@
using System;
using System.Collections.Generic;
using GFramework.Core.Abstractions.Coroutine;
using GFramework.Core.Coroutine;
namespace GFramework.Godot.Coroutine;
public partial class Timing
{
/// <summary>
/// 使用可控时间源初始化当前 <see cref="Timing" /> 实例,供纯托管测试验证宿主阶段语义。
/// </summary>
/// <param name="processDeltaProvider">`Process` 段的增量提供器。</param>
/// <param name="physicsDeltaProvider">`PhysicsProcess` 段的增量提供器。</param>
/// <param name="deferredDeltaProvider">`DeferredProcess` 段的增量提供器。</param>
/// <remarks>
/// 该入口只用于测试宿主驱动顺序,不会挂接真实场景树,也不会暴露给运行时调用方。
/// 由于协程句柄包含实例槽位前缀,这里仍会注册实例槽位,便于沿用生产代码的查询与控制路径。
/// </remarks>
internal void InitializeForTests(
Func<double>? processDeltaProvider = null,
Func<double>? physicsDeltaProvider = null,
Func<double>? deferredDeltaProvider = null)
{
_instanceId = 1;
_ownedCoroutineRegistrations ??= new Dictionary<CoroutineHandle, OwnedCoroutineRegistration>();
_ownedCoroutinesByNode ??= new Dictionary<ulong, HashSet<CoroutineHandle>>();
RegisterInstance();
_processTimeSource = new GodotTimeSource(processDeltaProvider ?? DefaultDeltaProvider);
_processRealtimeTimeSource = new GodotTimeSource(processDeltaProvider ?? DefaultDeltaProvider);
_processIgnorePauseTimeSource = new GodotTimeSource(processDeltaProvider ?? DefaultDeltaProvider);
_processIgnorePauseRealtimeTimeSource = new GodotTimeSource(processDeltaProvider ?? DefaultDeltaProvider);
_physicsTimeSource = new GodotTimeSource(physicsDeltaProvider ?? DefaultDeltaProvider);
_physicsRealtimeTimeSource = new GodotTimeSource(physicsDeltaProvider ?? DefaultDeltaProvider);
_deferredTimeSource = new GodotTimeSource(deferredDeltaProvider ?? processDeltaProvider ?? DefaultDeltaProvider);
_deferredRealtimeTimeSource =
new GodotTimeSource(deferredDeltaProvider ?? processDeltaProvider ?? DefaultDeltaProvider);
_processScheduler = new CoroutineScheduler(
_processTimeSource,
_instanceId,
256,
false,
_processRealtimeTimeSource,
CoroutineExecutionStage.Update);
_processIgnorePauseScheduler = new CoroutineScheduler(
_processIgnorePauseTimeSource,
_instanceId,
256,
false,
_processIgnorePauseRealtimeTimeSource,
CoroutineExecutionStage.Update);
_physicsScheduler = new CoroutineScheduler(
_physicsTimeSource,
_instanceId,
128,
false,
_physicsRealtimeTimeSource,
CoroutineExecutionStage.FixedUpdate);
_deferredScheduler = new CoroutineScheduler(
_deferredTimeSource,
_instanceId,
64,
false,
_deferredRealtimeTimeSource,
CoroutineExecutionStage.EndOfFrame);
AttachSchedulerLifecycleHandlers(ProcessScheduler);
AttachSchedulerLifecycleHandlers(ProcessIgnorePauseScheduler);
AttachSchedulerLifecycleHandlers(PhysicsScheduler);
AttachSchedulerLifecycleHandlers(DeferredScheduler);
}
/// <summary>
/// 以测试宿主的方式推进一次 Process 帧。
/// </summary>
/// <param name="paused">
/// 指示当前帧是否视为场景暂停。
/// 暂停时仅推进 `ProcessIgnorePause` 段,并跳过 `DeferredProcess`,以匹配生产宿主逻辑。
/// </param>
internal void AdvanceProcessFrameForTests(bool paused)
{
if (!paused)
{
_processScheduler?.Update();
}
_processIgnorePauseScheduler?.Update();
_frameCounter++;
if (!paused)
{
_deferredScheduler?.Update();
}
}
/// <summary>
/// 以测试宿主的方式推进一次 Physics 帧。
/// </summary>
internal void AdvancePhysicsFrameForTests()
{
_physicsScheduler?.Update();
}
/// <summary>
/// 获取指定分段对应的调度器,供测试读取完成状态与快照。
/// </summary>
/// <param name="segment">目标分段。</param>
/// <returns>对应分段的调度器实例。</returns>
internal CoroutineScheduler GetSchedulerForTests(Segment segment)
{
return GetScheduler(segment);
}
/// <summary>
/// 清理测试初始化留下的实例槽位与调度器状态,避免跨测试污染静态单例表。
/// </summary>
internal void DisposeForTests()
{
DetachAllOwnedRegistrations();
ClearOnInstance();
if (_instanceId < ActiveInstances.Length)
{
ActiveInstances[_instanceId] = null;
}
CleanupInstanceIfNecessary();
_processScheduler = null;
_processIgnorePauseScheduler = null;
_physicsScheduler = null;
_deferredScheduler = null;
_processTimeSource = null;
_processRealtimeTimeSource = null;
_processIgnorePauseTimeSource = null;
_processIgnorePauseRealtimeTimeSource = null;
_physicsTimeSource = null;
_physicsRealtimeTimeSource = null;
_deferredTimeSource = null;
_deferredRealtimeTimeSource = null;
_frameCounter = 0;
_instanceId = 1;
}
/// <summary>
/// 提供测试默认使用的稳定帧增量。
/// </summary>
/// <returns>固定的 60 FPS 增量。</returns>
private static double DefaultDeltaProvider()
{
return 1.0 / 60.0;
}
}

View File

@ -20,8 +20,8 @@ public partial class Timing : Node
private static readonly Timing?[] ActiveInstances = new Timing?[16];
private static Timing? _instance;
private readonly Dictionary<CoroutineHandle, OwnedCoroutineRegistration> _ownedCoroutineRegistrations = new();
private readonly Dictionary<ulong, HashSet<CoroutineHandle>> _ownedCoroutinesByNode = new();
private Dictionary<CoroutineHandle, OwnedCoroutineRegistration> _ownedCoroutineRegistrations = new();
private Dictionary<ulong, HashSet<CoroutineHandle>> _ownedCoroutinesByNode = new();
private GodotTimeSource? _deferredRealtimeTimeSource;
private CoroutineScheduler? _deferredScheduler;
private GodotTimeSource? _deferredTimeSource;

View File

@ -7,32 +7,39 @@
## 当前恢复点
- 恢复点编号:`COROUTINE-OPTIMIZATION-RP-001`
- 当前阶段:`Phase 1`
- 恢复点编号:`COROUTINE-OPTIMIZATION-RP-002`
- 当前阶段:`Phase 4`
- 当前焦点:
- 已将 worktree-root 遗留的 `local-plan/` 迁入 `ai-plan/public/coroutine-optimization/`active 入口只保留当前恢复信息
- 基于早期计划中已经完成的第一轮实现,重新收敛后续切入点,避免把语义命名、宿主集成、测试扩面和文档清理混成一次大任务
- 明确记录“旧计划没有 durable trace只有 todo 基线”,后续恢复时先读 active 入口,再按需展开 archive
- 已`Timing` 补齐纯托管测试宿主入口,允许在 `dotnet test` 下验证 Godot 协程宿主阶段语义,而不依赖原生 `Node` 构造
- 已补充 `GFramework.Godot.Tests/Coroutine/TimingTests.cs`锁定暂停、segment 路由和阶段型等待指令的回归覆盖
- 下一轮优先补“仍需真实场景树参与”的归属协程 / 退树语义或转入文档迁移收口不再回到“Godot 宿主没有自动化回归”的旧状态
## 当前状态摘要
- Core 协程第一轮语义收拢已完成,包括真实时间源、执行阶段与阶段型等待的基础行为调整
- 调度器第一版控制与可观测能力已落地,包括完成状态、等待完成、快照查询和完成事件
- Godot 宿主第一版接入已落地,包括分段时间源、节点归属协程入口与退树终止语义
- Core 与 Godot 两侧已经具备一轮基础测试与文档更新,但更贴近运行时的集成验证、兼容性说明和迁移对照仍未收口
- Core 与 Godot 两侧已经具备一轮基础测试与文档更新;其中 Godot 侧现已补齐 `Timing` 的 pause / segment / stage-wait 自动化回归
- 更贴近真实场景树的节点归属、退树与 `queue_free` 集成验证,以及迁移对照文档仍未收口
## 当前活跃事实
- 本主题的详细历史不是从已有 trace 迁入,而是由旧 `local-plan/todos/coroutine/*.md` 整合出的计划基线
- `RP-001` 的详细工作流拆分、验收标准和缺失 trace 说明已归档到主题内 `archive/`
- 当前工作树分支 `feat/coroutine-optimization` 已在 `ai-plan/public/README.md` 建立 topic 映射
- `RP-002` 已在 `GFramework.Godot` 内新增仅供测试使用的 `Timing` 纯托管宿主入口,不改公开 API
- `RP-002` 已新增 `TimingTests`,覆盖:
- 暂停时 `Process` / `ProcessIgnorePause` 的差异
- `Process` / `PhysicsProcess` / `DeferredProcess` 的推进边界
- `WaitForFixedUpdate``WaitForEndOfFrame` 的阶段型等待语义
- `dotnet test GFramework.Godot.Tests/GFramework.Godot.Tests.csproj -c Release --no-restore` 当前通过,合计 `58` 个测试
## 当前风险
- 语义兼容性风险:`Delay``WaitForSecondsScaled``WaitForNextFrame``WaitOneFrame` 等命名与行为若继续调整,可能影响既有调用认知
- 缓解措施:下一轮只先挑一个语义面收敛,并同步补足迁移说明与宿主前提文档
- 宿主验证缺口风险Godot 节点归属、退树、暂停与各 segment 差异仍缺少更贴近运行时的自动化回归
- 缓解措施:优先规划 Godot 集成测试宿主,再决定是否扩展更多运行时诊断 API
- 宿主验证缺口风险Godot 节点归属、退树、`queue_free` 与真实场景树回调仍缺少更贴近运行时的自动化回归
- 缓解措施:下一轮仅补真实场景树相关宿主验证;已完成的 `Timing` 纯托管语义测试不再重复规划
- 历史信息稀疏风险:旧计划没有同步保留当时的执行 trace 与完整验证记录
- 缓解措施active 文档只保留当前结论;需要历史语义时回看 archive并明确哪些内容是从早期 todo 推导出的基线
@ -45,9 +52,15 @@
- 旧 `local-plan` 的五份 coroutine todo 已整合进主题内历史归档,不再作为 worktree-root durable recovery 入口保留
- active 跟踪文件只保留当前恢复点、活跃事实、风险与下一步,避免把更早期计划直接平移成新的追加式日志
- `dotnet test GFramework.Godot.Tests/GFramework.Godot.Tests.csproj -c Release --filter "FullyQualifiedName~TimingTests" --no-restore`
- 结果:通过
- 备注:新增 `TimingTests``5` 个测试全部通过
- `dotnet test GFramework.Godot.Tests/GFramework.Godot.Tests.csproj -c Release --no-restore`
- 结果:通过
- 备注Godot 测试项目共 `58` 个测试全部通过
## 下一步
1. 若继续该主题,先在 `Core Semantics``Control And Observability``Godot Runtime Integration``Tests And Regressions``Docs And Migration` 中只选一个切入点推进
2. 若优先补验证,先规划 Godot 集成测试宿主与节点归属/退树/暂停场景,再扩运行时诊断 API
3. 若优先补文档与迁移说明,先清理其余 `StartCoroutine()/StopCoroutine()` 残留,再为阶段等待和新入口补统一对照说明
1. 若继续补验证,优先只做真实场景树相关的节点归属 / 退树 / `queue_free` 回归,不再重新设计 `Timing` 纯托管宿主
2. 若转入文档收口,优先清理其余 `StartCoroutine()/StopCoroutine()` 残留,并补 Godot 新入口与阶段等待的迁移对照
3. 若下一轮涉及更大范围语义调整,再单独开新恢复点,避免把测试、文档和 API 改动重新混成一次任务

View File

@ -32,3 +32,26 @@
1. 后续若继续 coroutine 主题,只从 `ai-plan/public/coroutine-optimization/` 进入,不再恢复 `local-plan/`
2. 下一轮只选择一个主切入点推进,避免语义、宿主、测试和文档扩面同时发生
3. 若 active 入口后续积累多轮已完成且已验证阶段,再按同一模式迁入该主题自己的 `archive/`
## 2026-04-20
### 阶段Godot 宿主回归覆盖补齐RP-002
- 选择只推进 `Tests And Regressions` 切面,不同时改动协程语义与迁移文档
- 新增 `GFramework.Godot/Coroutine/Timing.Testing.cs`,为 `Timing` 提供仅供测试使用的纯托管初始化与帧推进入口
- 新增 `GFramework.Godot.Tests/Coroutine/TimingTests.cs`,覆盖:
- 暂停时 `Process``ProcessIgnorePause` 的推进差异
- `Process` / `PhysicsProcess` / `DeferredProcess` 的执行边界与顺序
- `WaitForFixedUpdate``WaitForEndOfFrame` 的阶段型等待语义
- 关键决策:在 `dotnet test` 宿主中不直接运行 `Timing : Node` 的原生构造,而是使用未初始化对象配合测试入口补齐纯托管字段
- 原因:直接构造 `Godot.Node` 派生类型会导致 VSTest test host 崩溃,无法作为稳定回归路径
- 约束:当前测试覆盖的是宿主调度语义,不覆盖真实场景树信号、节点归属与退树回调
- 为支持上述测试入口,将 `Timing` 的节点归属字典从只读字段调整为可在测试初始化阶段重建的私有字段,未改动任何公共 API
- 完成验证:
- `dotnet test GFramework.Godot.Tests/GFramework.Godot.Tests.csproj -c Release --filter "FullyQualifiedName~TimingTests" --no-restore`
- `dotnet test GFramework.Godot.Tests/GFramework.Godot.Tests.csproj -c Release --no-restore`
### 下一步
1. 若继续补验证,优先规划真实场景树参与的节点归属 / 退树 / `queue_free` 测试宿主
2. 若转入文档收口,优先清理仍引用 `StartCoroutine()/StopCoroutine()` 的教程残留,并补迁移对照