diff --git a/GFramework.Godot.Tests/Text/RichTextEffectsControllerTests.cs b/GFramework.Godot.Tests/Text/RichTextEffectsControllerTests.cs new file mode 100644 index 00000000..4d1a1c30 --- /dev/null +++ b/GFramework.Godot.Tests/Text/RichTextEffectsControllerTests.cs @@ -0,0 +1,126 @@ +using GFramework.Godot.Text; +using Godot; +using Array = Godot.Collections.Array; + +namespace GFramework.Godot.Tests.Text; + +/// +/// 的纯托管行为测试。 +/// +[TestFixture] +public sealed class RichTextEffectsControllerTests +{ + /// + /// 验证启用框架效果时会开启宿主 BBCode,并在 Profile 为空时回退到内置默认配置。 + /// + [Test] + public void RefreshEffects_Should_Enable_Bbcode_And_Use_BuiltIn_Default_Profile() + { + var host = new FakeRichTextEffectHost(); + var registry = new RecordingRegistry(); + var controller = new RichTextEffectsController( + host, + () => registry, + () => null, + () => true, + () => false); + + controller.RefreshEffects(); + + Assert.That(host.BbcodeEnabled, Is.True); + Assert.That(registry.CapturedAnimatedEffectsEnabled, Is.False); + Assert.That(registry.CapturedProfiles, Has.Count.EqualTo(1)); + Assert.That(registry.CapturedProfiles[0].Effects.Select(static entry => entry.Key), Is.EqualTo(new[] + { + "green", + "red", + "gold", + "blue", + "fade_in", + "sine", + "jitter", + "fly_in" + })); + } + + /// + /// 验证关闭框架效果时不会调用注册表,并会清空宿主的自定义效果集合。 + /// + [Test] + public void RefreshEffects_Should_Clear_CustomEffects_When_Framework_Effects_Are_Disabled() + { + var existingEffects = new Array(); + existingEffects.Add("placeholder"); + + var host = new FakeRichTextEffectHost + { + BbcodeEnabled = true, + CustomEffects = existingEffects + }; + var registry = new RecordingRegistry(); + var controller = new RichTextEffectsController( + host, + () => registry, + () => RichTextProfile.CreateBuiltInDefault(), + () => false, + () => true); + + controller.RefreshEffects(); + + Assert.That(host.BbcodeEnabled, Is.True); + Assert.That(host.CustomEffects.Count, Is.EqualTo(0)); + Assert.That(registry.CapturedProfiles, Is.Empty); + } + + /// + /// 验证控制器会在每次刷新时读取最新的注册表访问器结果,避免缓存旧注册表。 + /// + [Test] + public void RefreshEffects_Should_Use_The_Current_Registry_From_Accessor() + { + var host = new FakeRichTextEffectHost(); + var firstRegistry = new RecordingRegistry(); + var secondRegistry = new RecordingRegistry(); + IRichTextEffectRegistry currentRegistry = firstRegistry; + + var controller = new RichTextEffectsController( + host, + () => currentRegistry, + () => RichTextProfile.CreateBuiltInDefault(), + () => true, + () => true); + + controller.RefreshEffects(); + currentRegistry = secondRegistry; + controller.RefreshEffects(); + + Assert.That(firstRegistry.CapturedProfiles, Has.Count.EqualTo(1)); + Assert.That(secondRegistry.CapturedProfiles, Has.Count.EqualTo(1)); + } + + private sealed class FakeRichTextEffectHost : IRichTextEffectHost + { + public bool BbcodeEnabled { get; set; } + + public Array CustomEffects { get; set; } = new(); + } + + private sealed class RecordingRegistry : IRichTextEffectRegistry + { + public List CapturedProfiles { get; } = []; + + public bool CapturedAnimatedEffectsEnabled { get; private set; } + + public IReadOnlyList CreateEffects(RichTextProfile profile, bool animatedEffectsEnabled) + { + CapturedProfiles.Add(profile); + CapturedAnimatedEffectsEnabled = animatedEffectsEnabled; + return System.Array.Empty(); + } + + public RichTextEffect? CreateEffect(string key, bool animatedEffectsEnabled) + { + return null; + } + } +} diff --git a/GFramework.Godot.Tests/Text/RichTextMarkupTests.cs b/GFramework.Godot.Tests/Text/RichTextMarkupTests.cs index 72009309..127d783c 100644 --- a/GFramework.Godot.Tests/Text/RichTextMarkupTests.cs +++ b/GFramework.Godot.Tests/Text/RichTextMarkupTests.cs @@ -23,16 +23,43 @@ public sealed class RichTextMarkupTests /// 验证效果方法会按稳定顺序拼接环境参数。 /// [Test] - public void Effect_Should_Append_Environment_Parameters() + public void Effect_Should_Sort_Environment_Parameters_By_Key() { var env = new Dictionary { - ["speed"] = 4, - ["tick"] = 0.1f + ["tick"] = 0.1f, + ["speed"] = 4 }; var result = RichTextMarkup.Effect("Hello", "fade_in", env); Assert.That(result, Is.EqualTo("[fade_in speed=4 tick=0.1]Hello[/fade_in]")); } + + /// + /// 验证非法标签 token 会被拒绝,避免生成损坏的 BBCode。 + /// + [Test] + public void Effect_Should_Reject_Invalid_Tag_Tokens() + { + var exception = Assert.Throws(() => RichTextMarkup.Effect("Hello", "fade=in")); + + Assert.That(exception!.ParamName, Is.EqualTo("tag")); + } + + /// + /// 验证非法环境参数键会被拒绝,避免注入无效的 BBCode token。 + /// + [Test] + public void Effect_Should_Reject_Invalid_Environment_Key_Tokens() + { + var env = new Dictionary + { + ["bad key"] = 1 + }; + + var exception = Assert.Throws(() => RichTextMarkup.Effect("Hello", "fade_in", env)); + + Assert.That(exception!.ParamName, Is.EqualTo("env")); + } } diff --git a/GFramework.Godot/Text/DefaultRichTextEffectRegistry.cs b/GFramework.Godot/Text/DefaultRichTextEffectRegistry.cs index 590e36b3..e4fd4780 100644 --- a/GFramework.Godot/Text/DefaultRichTextEffectRegistry.cs +++ b/GFramework.Godot/Text/DefaultRichTextEffectRegistry.cs @@ -14,6 +14,9 @@ public sealed class DefaultRichTextEffectRegistry : IRichTextEffectRegistry /// 效果组合配置。 /// 当前是否允许字符级动态效果生效。 /// 内置效果实例集合。 + /// + /// 当 时抛出。 + /// public IReadOnlyList CreateEffects(RichTextProfile profile, bool animatedEffectsEnabled) { ArgumentNullException.ThrowIfNull(profile); diff --git a/GFramework.Godot/Text/Effects/RichTextEffectBase.cs b/GFramework.Godot/Text/Effects/RichTextEffectBase.cs index 41d7ca8e..55b89560 100644 --- a/GFramework.Godot/Text/Effects/RichTextEffectBase.cs +++ b/GFramework.Godot/Text/Effects/RichTextEffectBase.cs @@ -14,6 +14,7 @@ public abstract partial class RichTextEffectBase : RichTextEffect /// /// 获取 Godot 识别当前效果所需的 `bbcode` 属性。 + /// 属性名使用小写是 Godot `RichTextEffect` 的约定,不是框架对公共成员命名的放宽。 /// public string bbcode => TagName; @@ -23,10 +24,11 @@ public abstract partial class RichTextEffectBase : RichTextEffect /// 当前字符变换上下文。 /// 参数键。 /// 读取失败时使用的默认值。 - /// 最终布尔值。 + /// 最终布尔值;当环境参数不存在或类型不是 时返回默认值。 protected bool GetBool(CharFXTransform transform, string key, bool defaultValue = false) { - if (transform.Env.TryGetValue(Variant.From(key), out var value)) + if (transform.Env.TryGetValue(Variant.From(key), out var value) && + value.VariantType == Variant.Type.Bool) { return value.AsBool(); } @@ -40,10 +42,14 @@ public abstract partial class RichTextEffectBase : RichTextEffect /// 当前字符变换上下文。 /// 参数键。 /// 读取失败时使用的默认值。 - /// 最终浮点值。 + /// + /// 最终浮点值;当环境参数不存在,或类型既不是 也不是 + /// 时返回默认值。 + /// protected float GetFloat(CharFXTransform transform, string key, float defaultValue) { - if (transform.Env.TryGetValue(Variant.From(key), out var value)) + if (transform.Env.TryGetValue(Variant.From(key), out var value) && + (value.VariantType == Variant.Type.Float || value.VariantType == Variant.Type.Int)) { return (float)value.AsDouble(); } diff --git a/GFramework.Godot/Text/Effects/RichTextJitterEffect.cs b/GFramework.Godot/Text/Effects/RichTextJitterEffect.cs index b5b3fa16..1c8a8c76 100644 --- a/GFramework.Godot/Text/Effects/RichTextJitterEffect.cs +++ b/GFramework.Godot/Text/Effects/RichTextJitterEffect.cs @@ -3,6 +3,11 @@ namespace GFramework.Godot.Text.Effects; /// /// 为文本提供抖动效果。 /// +/// +/// 默认注册表会在每次宿主刷新时为该效果创建独立实例。 +/// 内部会复用并修改 的 Seed,因此该类型假定 Godot 在主线程顺序 +/// 执行字符效果,不支持跨多个 共享同一实例,也不保证并发调用下的线程安全。 +/// [GlobalClass] [Tool] public partial class RichTextJitterEffect : RichTextEffectBase diff --git a/GFramework.Godot/Text/GfRichTextLabel.cs b/GFramework.Godot/Text/GfRichTextLabel.cs index 6715ec45..78440196 100644 --- a/GFramework.Godot/Text/GfRichTextLabel.cs +++ b/GFramework.Godot/Text/GfRichTextLabel.cs @@ -6,7 +6,7 @@ namespace GFramework.Godot.Text; /// [GlobalClass] [Tool] -public partial class GfRichTextLabel : RichTextLabel +public partial class GfRichTextLabel : RichTextLabel, IRichTextEffectHost { private IRichTextEffectRegistry? _effectRegistry; private RichTextEffectsController? _effectsController; @@ -20,6 +20,7 @@ public partial class GfRichTextLabel : RichTextLabel /// /// 获取或设置是否启用框架管理的富文本效果装配。 + /// 关闭后只会停止框架效果安装,不会覆盖调用方手动维护的其他 BBCode 解析状态。 /// [Export] public bool EnableFrameworkEffects { get; set; } = true; @@ -34,6 +35,9 @@ public partial class GfRichTextLabel : RichTextLabel /// /// 获取当前使用的效果注册表。 /// + /// + /// 当设置值为 时抛出。 + /// internal IRichTextEffectRegistry EffectRegistry { get => _effectRegistry ??= new DefaultRichTextEffectRegistry(); @@ -45,11 +49,6 @@ public partial class GfRichTextLabel : RichTextLabel /// public override void _Ready() { - if (EnableFrameworkEffects && !BbcodeEnabled) - { - BbcodeEnabled = true; - } - EnsureController().Initialize(); } @@ -59,11 +58,6 @@ public partial class GfRichTextLabel : RichTextLabel /// public void RefreshFrameworkEffects() { - if (EnableFrameworkEffects && !BbcodeEnabled) - { - BbcodeEnabled = true; - } - EnsureController().RefreshEffects(); } @@ -75,7 +69,7 @@ public partial class GfRichTextLabel : RichTextLabel { return _effectsController ??= new RichTextEffectsController( this, - EffectRegistry, + () => EffectRegistry, () => Profile, () => EnableFrameworkEffects, () => AnimatedEffectsEnabled); diff --git a/GFramework.Godot/Text/IRichTextEffectHost.cs b/GFramework.Godot/Text/IRichTextEffectHost.cs new file mode 100644 index 00000000..b78efeb6 --- /dev/null +++ b/GFramework.Godot/Text/IRichTextEffectHost.cs @@ -0,0 +1,21 @@ +using Array = Godot.Collections.Array; + +namespace GFramework.Godot.Text; + +/// +/// 抽象可被富文本效果控制器驱动的宿主。 +/// 该接口把装配决策从 Godot 原生 生命周期中解耦出来,便于在纯托管测试中验证开关、 +/// 配置回退和注册表替换行为。 +/// +internal interface IRichTextEffectHost +{ + /// + /// 获取或设置宿主是否启用 BBCode 解析。 + /// + bool BbcodeEnabled { get; set; } + + /// + /// 获取或设置当前安装到宿主上的自定义富文本效果集合。 + /// + Array CustomEffects { get; set; } +} diff --git a/GFramework.Godot/Text/IRichTextEffectRegistry.cs b/GFramework.Godot/Text/IRichTextEffectRegistry.cs index 8d5a29c1..d43c0838 100644 --- a/GFramework.Godot/Text/IRichTextEffectRegistry.cs +++ b/GFramework.Godot/Text/IRichTextEffectRegistry.cs @@ -3,18 +3,31 @@ namespace GFramework.Godot.Text; /// /// 富文本效果注册表,负责把配置中的效果键解析为可安装的 实例。 /// +/// +/// 会在 就绪或显式刷新时调用该注册表,重建 +/// 。 +/// 该抽象存在的目的,是把“配置里声明了哪些效果”与“这些效果如何实例化”解耦,使宿主节点、配置资源和测试替身都不必 +/// 直接依赖具体内置效果类型。 +/// 当项目只需要组合现有标签时,应优先使用 ;当项目需要替换内置映射、注入自定义 +/// ,或按“动态效果是否启用”的边界切换效果实现时,应实现该接口。 +/// public interface IRichTextEffectRegistry { /// /// 根据指定配置创建完整的效果实例集合。 + /// 该方法会在每次宿主刷新时执行,因此实现应保持可重复、确定且与宿主生命周期兼容。 /// /// 效果组合配置。 /// 当前是否允许字符级动态效果生效。 /// 可直接写入 的效果实例集合。 + /// + /// 当 且实现不接受空配置时抛出。 + /// IReadOnlyList CreateEffects(RichTextProfile profile, bool animatedEffectsEnabled); /// /// 根据单个效果键创建对应效果实例。 + /// 该方法主要用于将配置声明的 key 映射为宿主可安装的单个效果对象。 /// /// 效果键。 /// 当前是否允许字符级动态效果生效。 diff --git a/GFramework.Godot/Text/RichTextEffectsController.cs b/GFramework.Godot/Text/RichTextEffectsController.cs index 2a9eeb78..dabced40 100644 --- a/GFramework.Godot/Text/RichTextEffectsController.cs +++ b/GFramework.Godot/Text/RichTextEffectsController.cs @@ -10,27 +10,32 @@ internal sealed class RichTextEffectsController { private readonly Func _animatedEffectsEnabledAccessor; private readonly Func _frameworkEffectsEnabledAccessor; - private readonly RichTextLabel _host; + private readonly IRichTextEffectHost _host; private readonly Func _profileAccessor; - private readonly IRichTextEffectRegistry _registry; + private readonly Func _registryAccessor; /// /// 初始化控制器实例。 /// /// 目标富文本标签。 - /// 效果注册表。 + /// 当前效果注册表访问器。 /// 当前配置访问器。 /// 框架效果总开关访问器。 /// 字符动画开关访问器。 + /// + /// 当 、 + /// + /// 为 时抛出。 + /// public RichTextEffectsController( - RichTextLabel host, - IRichTextEffectRegistry registry, + IRichTextEffectHost host, + Func registryAccessor, Func profileAccessor, Func frameworkEffectsEnabledAccessor, Func animatedEffectsEnabledAccessor) { _host = host ?? throw new ArgumentNullException(nameof(host)); - _registry = registry ?? throw new ArgumentNullException(nameof(registry)); + _registryAccessor = registryAccessor ?? throw new ArgumentNullException(nameof(registryAccessor)); _profileAccessor = profileAccessor ?? throw new ArgumentNullException(nameof(profileAccessor)); _frameworkEffectsEnabledAccessor = frameworkEffectsEnabledAccessor ?? throw new ArgumentNullException(nameof(frameworkEffectsEnabledAccessor)); @@ -51,14 +56,23 @@ internal sealed class RichTextEffectsController /// public void RefreshEffects() { - if (!_frameworkEffectsEnabledAccessor()) + var frameworkEffectsEnabled = _frameworkEffectsEnabledAccessor(); + if (frameworkEffectsEnabled && !_host.BbcodeEnabled) + { + _host.BbcodeEnabled = true; + } + + if (!frameworkEffectsEnabled) { _host.CustomEffects = new Array(); return; } var profile = _profileAccessor() ?? RichTextProfile.CreateBuiltInDefault(); - var effects = _registry.CreateEffects(profile, _animatedEffectsEnabledAccessor()); + var registry = _registryAccessor() + ?? throw new InvalidOperationException("The rich text effect registry accessor returned null."); + + var effects = registry.CreateEffects(profile, _animatedEffectsEnabledAccessor()); var customEffects = new Array(); foreach (var effect in effects) { diff --git a/GFramework.Godot/Text/RichTextMarkup.cs b/GFramework.Godot/Text/RichTextMarkup.cs index e08ec667..685e8408 100644 --- a/GFramework.Godot/Text/RichTextMarkup.cs +++ b/GFramework.Godot/Text/RichTextMarkup.cs @@ -15,6 +15,9 @@ public static class RichTextMarkup /// 原始文本。 /// 标签名。 /// 包裹后的 BBCode 文本。 + /// + /// 当 为空、仅包含空白字符,或包含 BBCode token 不允许的控制字符时抛出。 + /// public static string Color(string text, string tag) { return Wrap(text, tag); @@ -62,14 +65,19 @@ public static class RichTextMarkup /// /// 使用指定效果标签包裹文本,并可附带参数环境。 + /// 环境参数会按键名进行稳定排序,避免不同字典实现导致输出顺序漂移。 /// /// 原始文本。 /// 标签名。 /// 可选的标签参数集合。 /// 包裹后的 BBCode 文本。 + /// + /// 当 为空、仅包含空白字符,包含 BBCode token 不允许的控制字符, + /// 或 中存在包含非法控制字符的参数键时抛出。 + /// public static string Effect(string text, string tag, IReadOnlyDictionary? env = null) { - ArgumentException.ThrowIfNullOrWhiteSpace(tag); + ValidateToken(tag, nameof(tag)); var builder = new StringBuilder(); builder.Append('['); @@ -77,13 +85,8 @@ public static class RichTextMarkup if (env is not null) { - foreach (var pair in env) + foreach (var pair in CollectEnvironmentPairs(env)) { - if (string.IsNullOrWhiteSpace(pair.Key) || pair.Value is null) - { - continue; - } - builder.Append(' '); builder.Append(pair.Key); builder.Append('='); @@ -107,10 +110,61 @@ public static class RichTextMarkup /// 包裹后的 BBCode 文本。 private static string Wrap(string text, string tag) { - ArgumentException.ThrowIfNullOrWhiteSpace(tag); + ValidateToken(tag, nameof(tag)); return $"[{tag}]{text ?? string.Empty}[/{tag}]"; } + /// + /// 收集并排序可写入 BBCode 的环境参数。 + /// + /// 原始环境参数。 + /// 按键名稳定排序后的参数集合。 + /// + /// 当参数键包含 BBCode token 不允许的控制字符时抛出。 + /// + private static IReadOnlyList> CollectEnvironmentPairs( + IReadOnlyDictionary env) + { + var pairs = new List>(env.Count); + foreach (var pair in env) + { + if (pair.Value is null || string.IsNullOrWhiteSpace(pair.Key)) + { + continue; + } + + ValidateToken(pair.Key, nameof(env)); + pairs.Add(new KeyValuePair(pair.Key, pair.Value)); + } + + pairs.Sort(static (left, right) => StringComparer.Ordinal.Compare(left.Key, right.Key)); + return pairs; + } + + /// + /// 验证 BBCode 标签或参数键是否满足 token 约束。 + /// + /// 待验证的 token。 + /// 异常参数名。 + /// + /// 当 token 为空、仅包含空白字符,或包含 BBCode token 不允许的控制字符时抛出。 + /// + private static void ValidateToken(string token, string paramName) + { + if (string.IsNullOrWhiteSpace(token)) + { + throw new ArgumentException("BBCode token cannot be null, empty, or whitespace.", paramName); + } + + foreach (var character in token) + { + if (char.IsWhiteSpace(character) || character is '[' or ']' or '=') + { + throw new ArgumentException("BBCode token contains invalid control characters.", paramName); + } + } + } + /// /// 将标签参数值格式化为稳定的 BBCode 字符串表示。 ///