diff --git a/GFramework.Game.Abstractions/UI/IUiRouter.cs b/GFramework.Game.Abstractions/UI/IUiRouter.cs index 06510560..a84aff7b 100644 --- a/GFramework.Game.Abstractions/UI/IUiRouter.cs +++ b/GFramework.Game.Abstractions/UI/IUiRouter.cs @@ -194,10 +194,19 @@ public interface IUiRouter : ISystem IUiPageBehavior? GetUiActionOwner(UiInputAction action); /// - /// 尝试把语义动作分发给当前拥有该动作的页面。 + /// 尝试把语义动作分发给当前拥有该动作捕获权的页面。 /// /// 当前动作。 - /// 如果该动作已被某个页面捕获并消费,则返回 + /// 如果该动作已被某个页面捕获并完成分发,则返回 + bool TryDispatchUiAction(UiInputAction action); + + /// + /// 尝试把语义动作分发给当前拥有该动作捕获权的页面。 + /// + /// 当前动作。 + /// 如果该动作已被某个页面捕获并完成分发,则返回 + [Obsolete( + "Use TryDispatchUiAction(UiInputAction action) to emphasize dispatch semantics instead of handler success.")] bool TryHandleUiAction(UiInputAction action); /// diff --git a/GFramework.Game.Abstractions/UI/UiInteractionProfile.cs b/GFramework.Game.Abstractions/UI/UiInteractionProfile.cs index 32069d48..6f2cecb3 100644 --- a/GFramework.Game.Abstractions/UI/UiInteractionProfile.cs +++ b/GFramework.Game.Abstractions/UI/UiInteractionProfile.cs @@ -1,18 +1,16 @@ using GFramework.Core.Abstractions.Pause; -using GFramework.Game.Abstractions.Enums; namespace GFramework.Game.Abstractions.UI; /// -/// 描述一个 UI 页面在输入、World 阻断与暂停上的运行时语义。 +/// 描述一个 UI 页面在输入、World 阻断与暂停上的交互契约数据。 /// +/// +/// 该类型仅承载抽象层需要共享的页面交互配置,不包含默认值工厂或动作判定等运行时策略。 +/// 运行时层可在不反向依赖 Abstractions 的前提下,通过专门的 helper 为该 DTO 提供默认值和语义判定。 +/// public sealed class UiInteractionProfile { - /// - /// 获取默认值实例。 - /// - public static UiInteractionProfile Default { get; } = new(); - /// /// 声明当前页面要捕获的语义动作集合。 /// @@ -47,44 +45,4 @@ public sealed class UiInteractionProfile /// 页面向暂停栈登记时使用的原因文本。 /// public string PauseReason { get; init; } = string.Empty; - - /// - /// 判断当前配置是否捕获了指定动作。 - /// - /// 要查询的语义动作。 - /// 如果当前配置捕获该动作则返回 - public bool Captures(UiInputAction action) - { - return action switch - { - UiInputAction.Cancel => CapturedActions.HasFlag(UiInputActionMask.Cancel), - UiInputAction.Confirm => CapturedActions.HasFlag(UiInputActionMask.Confirm), - _ => false - }; - } - - /// - /// 为指定层级生成默认交互配置。 - /// - /// UI 层级。 - /// 该层级的默认交互语义。 - public static UiInteractionProfile CreateDefault(UiLayer layer) - { - return layer switch - { - UiLayer.Modal => new UiInteractionProfile - { - CapturedActions = UiInputActionMask.Cancel, - BlocksWorldPointerInput = true, - BlocksWorldActionInput = true - }, - UiLayer.Topmost => new UiInteractionProfile - { - CapturedActions = UiInputActionMask.Cancel, - BlocksWorldPointerInput = true, - BlocksWorldActionInput = true - }, - _ => Default - }; - } } diff --git a/GFramework.Game.Tests/UI/UiRouterInteractionTests.cs b/GFramework.Game.Tests/UI/UiRouterInteractionTests.cs new file mode 100644 index 00000000..205e1d6d --- /dev/null +++ b/GFramework.Game.Tests/UI/UiRouterInteractionTests.cs @@ -0,0 +1,339 @@ +// Copyright (c) 2026 GeWuYou +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System.Reflection; +using GFramework.Game.UI; + +namespace GFramework.Game.Tests.UI; + +/// +/// 验证 UI 路由输入语义、层级排序与显示恢复生命周期的回归测试。 +/// +[TestFixture] +public class UiRouterInteractionTests +{ + /// + /// 验证模态层和顶层共享同一套阻塞型默认交互配置。 + /// + [Test] + public void CreateDefault_ForModalAndTopmost_ReturnsBlockingCancelProfile() + { + // Arrange + var modal = UiInteractionProfiles.CreateDefault(UiLayer.Modal); + var topmost = UiInteractionProfiles.CreateDefault(UiLayer.Topmost); + + // Assert + Assert.Multiple(() => + { + Assert.That(modal.CapturedActions, Is.EqualTo(UiInputActionMask.Cancel)); + Assert.That(modal.BlocksWorldPointerInput, Is.True); + Assert.That(modal.BlocksWorldActionInput, Is.True); + Assert.That(topmost, Is.SameAs(modal)); + }); + } + + /// + /// 验证只要动作被页面捕获,路由分发就会返回成功,即使页面没有显式消费该动作。 + /// + [Test] + public void TryDispatchUiAction_WhenCapturedButUnhandled_ReturnsTrue() + { + // Arrange + var router = CreateRouter(); + var page = new TestUiPage("capturing-page", UiLayer.Topmost) + { + InteractionProfile = new UiInteractionProfile + { + CapturedActions = UiInputActionMask.Cancel + }, + TryHandleUiActionResult = false + }; + + router.Show(page, UiLayer.Topmost); + + // Act + var dispatched = router.TryDispatchUiAction(UiInputAction.Cancel); + + // Assert + Assert.Multiple(() => + { + Assert.That(dispatched, Is.True); + Assert.That(page.TryHandleUiActionCallCount, Is.EqualTo(1)); + }); + } + + /// + /// 验证层级页面排序使用实例自增序号,而不是依赖固定宽度的字符串顺序。 + /// + [Test] + public void GetUiActionOwner_WhenInstanceIdWidthOverflows_UsesNumericOrder() + { + // Arrange + var router = CreateRouter(); + SetInstanceCounter(router, 999998); + + var olderPage = new TestUiPage("older", UiLayer.Topmost) + { + InteractionProfile = new UiInteractionProfile + { + CapturedActions = UiInputActionMask.Cancel + } + }; + var newerPage = new TestUiPage("newer", UiLayer.Topmost) + { + InteractionProfile = new UiInteractionProfile + { + CapturedActions = UiInputActionMask.Cancel + } + }; + + router.Show(olderPage, UiLayer.Topmost); + router.Show(newerPage, UiLayer.Topmost); + + // Act + var owner = router.GetUiActionOwner(UiInputAction.Cancel); + + // Assert + Assert.That(owner, Is.SameAs(newerPage)); + } + + /// + /// 验证恢复挂起的层级页面时,不会再对依赖 OnShow 触发恢复的页面重复调用 OnResume。 + /// + [Test] + public void Resume_WhenPageResumesDuringShow_DoesNotCallResumeTwice() + { + // Arrange + var router = CreateRouter(); + var page = new TestUiPage("resumable-layer-page", UiLayer.Overlay) + { + ResumeFromShow = true + }; + + var handle = router.Show(page, UiLayer.Overlay); + router.Hide(handle, UiLayer.Overlay); + var resumeCountBeforeResume = page.OnResumeCallCount; + + // Act + router.Resume(handle, UiLayer.Overlay); + + // Assert + Assert.That(page.OnResumeCallCount, Is.EqualTo(resumeCountBeforeResume + 1)); + } + + /// + /// 验证弹出栈顶页面后,恢复下层页面时不会重复触发恢复逻辑。 + /// + [Test] + public async Task PopAsync_WhenPageResumesDuringShow_DoesNotCallResumeTwice() + { + // Arrange + var router = CreateRouter(); + var underlyingPage = new TestUiPage("underlying-page", UiLayer.Page) + { + ResumeFromShow = true + }; + var topPage = new TestUiPage("top-page", UiLayer.Page); + + await router.PushAsync(underlyingPage); + await router.PushAsync(topPage); + var resumeCountBeforePop = underlyingPage.OnResumeCallCount; + + // Act + await router.PopAsync(UiPopPolicy.Destroy); + + // Assert + Assert.That(underlyingPage.OnResumeCallCount, Is.EqualTo(resumeCountBeforePop + 1)); + } + + /// + /// 创建带有测试根节点的 UI 路由器。 + /// + /// 已绑定测试根节点的路由器实例。 + private static TestUiRouter CreateRouter() + { + var router = new TestUiRouter(); + router.BindRoot(new TestUiRoot()); + return router; + } + + /// + /// 把实例计数器调整到指定值,以便覆盖实例标识符宽度溢出的排序回归。 + /// + /// 目标路由器。 + /// 要写入的计数器值。 + private static void SetInstanceCounter(UiRouterBase router, int value) + { + var field = typeof(UiRouterBase).GetField("_instanceCounter", BindingFlags.Instance | BindingFlags.NonPublic); + Assert.That(field, Is.Not.Null); + + field!.SetValue(router, value); + } + + /// + /// 测试用 UI 路由器实现。 + /// + private sealed class TestUiRouter : UiRouterBase + { + /// + /// 注册处理器。 + /// + protected override void RegisterHandlers() + { + } + } + + /// + /// 测试用 UI 根节点,占位记录添加/移除操作即可。 + /// + private sealed class TestUiRoot : IUiRoot + { + /// + /// 记录当前挂载的页面集合。 + /// + private readonly List _children = new(); + + /// + public void AddUiPage(IUiPageBehavior child) + { + _children.Add(child); + } + + /// + public void AddUiPage(IUiPageBehavior child, UiLayer layer, int orderInLayer = 0) + { + _children.Add(child); + } + + /// + public void RemoveUiPage(IUiPageBehavior child) + { + _children.Remove(child); + } + } + + /// + /// 可配置的测试页面,用于模拟路由器在不同交互语义下的可观察行为。 + /// + private sealed class TestUiPage : IUiPageBehavior + { + /// + /// 初始化测试页面实例。 + /// + /// 页面键。 + /// 页面层级。 + public TestUiPage(string key, UiLayer layer) + { + Key = key; + Layer = layer; + InteractionProfile = UiInteractionProfiles.Default; + IsAlive = true; + } + + /// + /// 获取或设置一个值,指示 是否要模拟 `CanvasItemUiPageBehaviorBase` 那样触发恢复逻辑。 + /// + public bool ResumeFromShow { get; init; } + + /// + /// 获取或设置页面处理动作时返回的结果。 + /// + public bool TryHandleUiActionResult { get; init; } = true; + + /// + /// 记录恢复回调触发次数。 + /// + public int OnResumeCallCount { get; private set; } + + /// + /// 记录动作处理方法调用次数。 + /// + public int TryHandleUiActionCallCount { get; private set; } + + /// + public UiHandle? Handle { get; set; } + + /// + public UiLayer Layer { get; } + + /// + public bool IsReentrant { get; init; } = true; + + /// + public object View => this; + + /// + public bool IsAlive { get; private set; } + + /// + public bool IsVisible { get; private set; } + + /// + public bool IsModal => Layer == UiLayer.Modal; + + /// + public bool BlocksInput { get; init; } + + /// + public UiInteractionProfile InteractionProfile { get; init; } + + /// + public string Key { get; } + + /// + public void OnEnter(IUiPageEnterParam? param) + { + } + + /// + public void OnExit() + { + IsAlive = false; + IsVisible = false; + } + + /// + public void OnPause() + { + } + + /// + public void OnResume() + { + OnResumeCallCount++; + } + + /// + public void OnHide() + { + IsVisible = false; + } + + /// + public void OnShow() + { + IsVisible = true; + + // The Godot page behavior resumes from OnShow(), so the router must not call OnResume() again on top. + if (ResumeFromShow) + OnResume(); + } + + /// + public bool TryHandleUiAction(UiInputAction action) + { + TryHandleUiActionCallCount++; + return TryHandleUiActionResult; + } + } +} diff --git a/GFramework.Game/UI/UiInteractionProfiles.cs b/GFramework.Game/UI/UiInteractionProfiles.cs new file mode 100644 index 00000000..224de6b6 --- /dev/null +++ b/GFramework.Game/UI/UiInteractionProfiles.cs @@ -0,0 +1,59 @@ +using GFramework.Game.Abstractions.Enums; +using GFramework.Game.Abstractions.UI; + +namespace GFramework.Game.UI; + +/// +/// 为 提供运行时默认值与语义判定。 +/// +/// +/// 该 helper 保留在运行时程序集内,避免把默认策略和输入判定逻辑放回 Abstractions。 +/// UI 页面和路由器都应通过这里共享同一套默认语义,避免层级默认值漂移。 +/// +public static class UiInteractionProfiles +{ + /// + /// 获取不捕获动作、也不阻断 World 输入的默认配置。 + /// + public static UiInteractionProfile Default { get; } = new(); + + /// + /// 获取会捕获取消动作并阻断 World 输入的阻塞型默认配置。 + /// + public static UiInteractionProfile BlockingCancel { get; } = new() + { + CapturedActions = UiInputActionMask.Cancel, + BlocksWorldPointerInput = true, + BlocksWorldActionInput = true + }; + + /// + /// 为指定层级生成默认交互配置。 + /// + /// UI 层级。 + /// 该层级的默认交互语义。 + public static UiInteractionProfile CreateDefault(UiLayer layer) + { + return layer switch + { + UiLayer.Modal or UiLayer.Topmost => BlockingCancel, + _ => Default + }; + } + + /// + /// 判断指定配置是否捕获了目标 UI 语义动作。 + /// + /// 目标配置。 + /// 要查询的动作。 + /// 如果配置声明捕获了该动作则返回 + public static bool Captures(UiInteractionProfile profile, UiInputAction action) + { + return action switch + { + UiInputAction.Cancel => (profile.CapturedActions & UiInputActionMask.Cancel) != 0, + UiInputAction.Confirm => (profile.CapturedActions & UiInputActionMask.Confirm) != 0, + _ => false + }; + } +} diff --git a/GFramework.Game/UI/UiRouterBase.cs b/GFramework.Game/UI/UiRouterBase.cs index 8bb376e9..34b64132 100644 --- a/GFramework.Game/UI/UiRouterBase.cs +++ b/GFramework.Game/UI/UiRouterBase.cs @@ -1,5 +1,3 @@ -using GFramework.Core.Abstractions.Logging; -using GFramework.Core.Abstractions.Pause; using GFramework.Core.Extensions; using GFramework.Game.Abstractions.Enums; using GFramework.Game.Abstractions.UI; @@ -361,7 +359,6 @@ public abstract class UiRouterBase : RouterBase page.InteractionProfile.Captures(action)); + .FirstOrDefault(page => UiInteractionProfiles.Captures(page.InteractionProfile, action)); } /// @@ -475,7 +472,7 @@ public abstract class UiRouterBase : RouterBase /// 当前动作。 /// 如果已有页面捕获该动作则返回 - public bool TryHandleUiAction(UiInputAction action) + public bool TryDispatchUiAction(UiInputAction action) { var owner = GetUiActionOwner(action); if (owner is null) @@ -488,6 +485,18 @@ public abstract class UiRouterBase : RouterBase + /// 尝试将语义动作分发给当前拥有捕获权的页面。 + /// + /// 当前动作。 + /// 如果已有页面捕获该动作则返回 + [Obsolete( + "Use TryDispatchUiAction(UiInputAction action) to emphasize dispatch semantics instead of handler success.")] + public bool TryHandleUiAction(UiInputAction action) + { + return TryDispatchUiAction(action); + } + /// /// 判断当前可见 UI 是否阻断 World 指针输入。 /// @@ -735,7 +744,6 @@ public abstract class UiRouterBase : RouterBase 0) { var next = Stack.Peek(); - next.OnResume(); next.OnShow(); SyncPauseRequest(next, isVisible: true); } @@ -764,6 +772,7 @@ public abstract class UiRouterBase : RouterBase pair.Key, StringComparer.Ordinal) + // Use the numeric sequence encoded in the instance id so ordering stays correct after width overflow. + .OrderByDescending(static pair => ExtractInstanceSequence(pair.Key)) .Select(static pair => pair.Value) .Where(static page => page.IsAlive && page.IsVisible)) { @@ -849,5 +859,18 @@ public abstract class UiRouterBase : RouterBase + /// 从实例标识符中提取自增序号,供层内最近显示优先排序使用。 + /// + /// 实例标识符,预期格式为 ui_000001。 + /// 提取到的自增序号;若格式异常则返回 ,使异常值排在最后。 + private static int ExtractInstanceSequence(string instanceId) + { + return instanceId.Length > 3 && + int.TryParse(instanceId.AsSpan(3), NumberStyles.None, CultureInfo.InvariantCulture, out var sequence) + ? sequence + : int.MinValue; + } + #endregion } diff --git a/GFramework.Godot/UI/CanvasItemUiPageBehaviorBase.cs b/GFramework.Godot/UI/CanvasItemUiPageBehaviorBase.cs index b7bfafb4..565289e3 100644 --- a/GFramework.Godot/UI/CanvasItemUiPageBehaviorBase.cs +++ b/GFramework.Godot/UI/CanvasItemUiPageBehaviorBase.cs @@ -13,7 +13,7 @@ using GFramework.Game.Abstractions.Enums; using GFramework.Game.Abstractions.UI; -using GFramework.Godot.Extensions; +using GFramework.Game.UI; namespace GFramework.Godot.UI; @@ -131,7 +131,7 @@ public abstract class CanvasItemUiPageBehaviorBase : IUiPageBehavior /// 若页面未提供自定义配置,则回退到层级默认值。 /// public UiInteractionProfile InteractionProfile => _profileProvider?.GetUiInteractionProfile(Layer) - ?? UiInteractionProfile.CreateDefault(Layer); + ?? UiInteractionProfiles.CreateDefault(Layer); #endregion @@ -213,7 +213,6 @@ public abstract class CanvasItemUiPageBehaviorBase : IUiPageBehavior public virtual void OnShow() { _page?.OnShow(); - ApplyPauseAwareProcessingMode(); Owner.Show(); OnResume(); }