diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 963c3e52..db07fe4f 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -159,6 +159,7 @@ jobs:
"GFramework.SourceGenerators.Tests/GFramework.SourceGenerators.Tests.csproj:sg"
"GFramework.Cqrs.Tests/GFramework.Cqrs.Tests.csproj:cqrs"
"GFramework.Ecs.Arch.Tests/GFramework.Ecs.Arch.Tests.csproj:ecs-arch"
+ "GFramework.Godot.Tests/GFramework.Godot.Tests.csproj:godot"
"GFramework.Godot.SourceGenerators.Tests/GFramework.Godot.SourceGenerators.Tests.csproj:godot-sg"
)
@@ -221,7 +222,7 @@ jobs:
done
- name: Run GFramework.Godot.Tests Diagnostics
- if: always()
+ if: always() && contains(steps.test_all_projects.outputs.failed_projects, 'GFramework.Godot.Tests/GFramework.Godot.Tests.csproj')
continue-on-error: true
run: |
mkdir -p TestResults
diff --git a/GFramework.Godot.Tests/Text/RichTextProfileTests.cs b/GFramework.Godot.Tests/Text/RichTextEffectPlanTests.cs
similarity index 56%
rename from GFramework.Godot.Tests/Text/RichTextProfileTests.cs
rename to GFramework.Godot.Tests/Text/RichTextEffectPlanTests.cs
index 666289b5..1bc3994b 100644
--- a/GFramework.Godot.Tests/Text/RichTextProfileTests.cs
+++ b/GFramework.Godot.Tests/Text/RichTextEffectPlanTests.cs
@@ -1,22 +1,20 @@
-using GFramework.Godot.Text;
-
namespace GFramework.Godot.Tests.Text;
///
-/// 的测试。
+/// 的纯托管测试。
///
[TestFixture]
-public sealed class RichTextProfileTests
+public sealed class RichTextEffectPlanTests
{
///
- /// 验证默认内置配置会暴露完整的第一阶段效果键集合。
+ /// 验证默认内置计划会暴露完整的第一阶段效果键集合。
///
[Test]
public void CreateBuiltInDefault_Should_Contain_The_First_Phase_Effect_Keys()
{
- var profile = RichTextProfile.CreateBuiltInDefault();
+ var plan = RichTextEffectPlan.CreateBuiltInDefault();
- Assert.That(profile.Effects.Select(static entry => entry.Key), Is.EqualTo(new[]
+ Assert.That(plan.Effects.Select(static entry => entry.Key), Is.EqualTo(new[]
{
"green",
"red",
diff --git a/GFramework.Godot.Tests/Text/RichTextEffectsControllerTests.cs b/GFramework.Godot.Tests/Text/RichTextEffectsControllerTests.cs
index 0280d8c9..93193ff2 100644
--- a/GFramework.Godot.Tests/Text/RichTextEffectsControllerTests.cs
+++ b/GFramework.Godot.Tests/Text/RichTextEffectsControllerTests.cs
@@ -1,7 +1,3 @@
-using Godot;
-using Godot.Collections;
-using Array = Godot.Collections.Array;
-
namespace GFramework.Godot.Tests.Text;
///
@@ -17,10 +13,8 @@ public sealed class RichTextEffectsControllerTests
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);
@@ -28,10 +22,10 @@ public sealed class RichTextEffectsControllerTests
controller.RefreshEffects();
Assert.That(host.BbcodeEnabled, Is.True);
- Assert.That(registry.CapturedAnimatedEffectsEnabled, Has.Count.EqualTo(1));
- Assert.That(registry.CapturedAnimatedEffectsEnabled[0], Is.False);
- Assert.That(registry.CapturedProfiles, Has.Count.EqualTo(1));
- Assert.That(registry.CapturedProfiles[0].Effects.Select(static entry => entry.Key), Is.EqualTo(new[]
+ Assert.That(host.CapturedAnimatedEffectsEnabled, Has.Count.EqualTo(1));
+ Assert.That(host.CapturedAnimatedEffectsEnabled[0], Is.False);
+ Assert.That(host.CapturedProfiles, Has.Count.EqualTo(1));
+ Assert.That(host.CapturedProfiles[0].Effects.Select(static entry => entry.Key), Is.EqualTo(new[]
{
"green",
"red",
@@ -45,83 +39,91 @@ public sealed class RichTextEffectsControllerTests
}
///
- /// 验证关闭框架效果时不会调用注册表,并会清空宿主的自定义效果集合。
+ /// 验证关闭框架效果时不会触发新的效果安装,并会清空宿主的自定义效果集合。
///
[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
+ BbcodeEnabled = true
};
- var registry = new RecordingRegistry();
+ host.SimulateInstalledEffects();
var controller = new RichTextEffectsController(
host,
- () => registry,
- () => RichTextProfile.CreateBuiltInDefault(),
+ () => RichTextEffectPlan.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);
+ Assert.That(host.CustomEffectsInstalled, Is.False);
+ Assert.That(host.ClearCustomEffectsCallCount, Is.EqualTo(1));
+ Assert.That(host.CapturedProfiles, Is.Empty);
}
///
- /// 验证控制器会在每次刷新时读取最新的注册表访问器结果,避免缓存旧注册表。
+ /// 验证控制器会在每次刷新时读取最新的配置访问器结果,避免缓存旧配置。
///
[Test]
- public void RefreshEffects_Should_Use_The_Current_Registry_From_Accessor()
+ public void RefreshEffects_Should_Use_The_Current_Profile_From_Accessor()
{
var host = new FakeRichTextEffectHost();
- var firstRegistry = new RecordingRegistry();
- var secondRegistry = new RecordingRegistry();
- IRichTextEffectRegistry currentRegistry = firstRegistry;
+ var firstProfile = new RichTextEffectPlan(
+ [
+ new RichTextEffectPlanEntry("green")
+ ]);
+ var secondProfile = new RichTextEffectPlan(
+ [
+ new RichTextEffectPlanEntry("gold")
+ ]);
+ RichTextEffectPlan? currentProfile = firstProfile;
var controller = new RichTextEffectsController(
host,
- () => currentRegistry,
- () => RichTextProfile.CreateBuiltInDefault(),
+ () => currentProfile,
() => true,
() => true);
controller.RefreshEffects();
- currentRegistry = secondRegistry;
+ currentProfile = secondProfile;
controller.RefreshEffects();
- Assert.That(firstRegistry.CapturedProfiles, Has.Count.EqualTo(1));
- Assert.That(secondRegistry.CapturedProfiles, Has.Count.EqualTo(1));
+ Assert.That(host.CapturedProfiles, Has.Count.EqualTo(2));
+ Assert.That(host.CapturedProfiles[0], Is.SameAs(firstProfile));
+ Assert.That(host.CapturedProfiles[1], Is.SameAs(secondProfile));
}
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 List CapturedProfiles { get; } = [];
public List CapturedAnimatedEffectsEnabled { get; } = [];
- public IReadOnlyList CreateEffects(RichTextProfile profile, bool animatedEffectsEnabled)
+ public bool CustomEffectsInstalled { get; private set; }
+
+ public int ClearCustomEffectsCallCount { get; private set; }
+ public bool BbcodeEnabled { get; set; }
+
+ public void ApplyEffects(RichTextEffectPlan profile, bool animatedEffectsEnabled)
{
+ ArgumentNullException.ThrowIfNull(profile);
+
CapturedProfiles.Add(profile);
CapturedAnimatedEffectsEnabled.Add(animatedEffectsEnabled);
- return new Array();
+ CustomEffectsInstalled = true;
}
- public RichTextEffect? CreateEffect(string key, bool animatedEffectsEnabled)
+ public void ClearCustomEffects()
{
- return null;
+ ClearCustomEffectsCallCount++;
+ CustomEffectsInstalled = false;
+ }
+
+ public void SimulateInstalledEffects()
+ {
+ CustomEffectsInstalled = true;
}
}
}
diff --git a/GFramework.Godot/Text/GfRichTextLabel.cs b/GFramework.Godot/Text/GfRichTextLabel.cs
index 78440196..3ee2d828 100644
--- a/GFramework.Godot/Text/GfRichTextLabel.cs
+++ b/GFramework.Godot/Text/GfRichTextLabel.cs
@@ -44,6 +44,36 @@ public partial class GfRichTextLabel : RichTextLabel, IRichTextEffectHost
set => _effectRegistry = value ?? throw new ArgumentNullException(nameof(value));
}
+ ///
+ /// 根据控制器提供的配置参数在适配层实例化 Godot 原生效果,并写回标签宿主。
+ /// 这样控制器与测试替身不需要直接触碰 或
+ /// ,而真正依赖 Godot runtime 的工作只发生在节点边界上。
+ ///
+ /// 需要安装的纯托管效果计划。
+ /// 当前是否允许字符级动态效果生效。
+ void IRichTextEffectHost.ApplyEffects(RichTextEffectPlan profile, bool animatedEffectsEnabled)
+ {
+ ArgumentNullException.ThrowIfNull(profile);
+
+ var registry = EffectRegistry;
+ var effects = registry.CreateEffects(RichTextProfile.FromPlan(profile), animatedEffectsEnabled);
+ var customEffects = new global::Godot.Collections.Array();
+ foreach (var effect in effects)
+ {
+ customEffects.Add(effect);
+ }
+
+ CustomEffects = customEffects;
+ }
+
+ ///
+ /// 清空标签当前持有的自定义效果集合。
+ ///
+ void IRichTextEffectHost.ClearCustomEffects()
+ {
+ CustomEffects = new global::Godot.Collections.Array();
+ }
+
///
/// 节点就绪时初始化控制器并安装效果集合。
///
@@ -69,8 +99,7 @@ public partial class GfRichTextLabel : RichTextLabel, IRichTextEffectHost
{
return _effectsController ??= new RichTextEffectsController(
this,
- () => EffectRegistry,
- () => Profile,
+ () => Profile is null ? null : RichTextEffectPlan.FromProfile(Profile),
() => EnableFrameworkEffects,
() => AnimatedEffectsEnabled);
}
diff --git a/GFramework.Godot/Text/IRichTextEffectHost.cs b/GFramework.Godot/Text/IRichTextEffectHost.cs
index b78efeb6..b01ebe3f 100644
--- a/GFramework.Godot/Text/IRichTextEffectHost.cs
+++ b/GFramework.Godot/Text/IRichTextEffectHost.cs
@@ -1,5 +1,3 @@
-using Array = Godot.Collections.Array;
-
namespace GFramework.Godot.Text;
///
@@ -15,7 +13,16 @@ internal interface IRichTextEffectHost
bool BbcodeEnabled { get; set; }
///
- /// 获取或设置当前安装到宿主上的自定义富文本效果集合。
+ /// 使用给定的配置和动画开关重建宿主上的自定义富文本效果。
+ /// 纯托管控制器只负责组合刷新参数,适配层负责在真正需要时解析注册表、实例化 Godot 效果对象并写回宿主。
///
- Array CustomEffects { get; set; }
+ /// 需要安装的纯托管效果计划。
+ /// 当前是否允许字符级动态效果生效。
+ void ApplyEffects(RichTextEffectPlan profile, bool animatedEffectsEnabled);
+
+ ///
+ /// 清空当前安装到宿主上的自定义富文本效果集合。
+ /// 关闭框架效果时,控制器会通过该方法显式撤销之前安装的效果。
+ ///
+ void ClearCustomEffects();
}
diff --git a/GFramework.Godot/Text/RichTextEffectPlan.cs b/GFramework.Godot/Text/RichTextEffectPlan.cs
new file mode 100644
index 00000000..cd6be232
--- /dev/null
+++ b/GFramework.Godot/Text/RichTextEffectPlan.cs
@@ -0,0 +1,70 @@
+namespace GFramework.Godot.Text;
+
+///
+/// 描述一次富文本效果安装所需的纯托管计划。
+/// 该类型用于把控制器与测试替身隔离在 Godot runtime 之外,使刷新决策可以在普通 .NET 测试进程中验证。
+///
+internal sealed class RichTextEffectPlan
+{
+ ///
+ /// 初始化一个富文本效果计划。
+ ///
+ /// 计划中声明的效果条目集合。
+ ///
+ /// 当 为 时抛出。
+ ///
+ public RichTextEffectPlan(IReadOnlyList effects)
+ {
+ ArgumentNullException.ThrowIfNull(effects);
+
+ Effects = effects.ToArray();
+ }
+
+ ///
+ /// 获取当前计划启用的效果条目集合。
+ ///
+ public IReadOnlyList Effects { get; }
+
+ ///
+ /// 从 Godot 资源配置转换为纯托管计划。
+ ///
+ /// 待转换的资源配置。
+ /// 与资源配置等价的纯托管计划。
+ ///
+ /// 当 为 时抛出。
+ ///
+ public static RichTextEffectPlan FromProfile(RichTextProfile profile)
+ {
+ ArgumentNullException.ThrowIfNull(profile);
+
+ var effects = new RichTextEffectPlanEntry[profile.Effects.Length];
+ for (var index = 0; index < profile.Effects.Length; index++)
+ {
+ var entry = profile.Effects[index];
+ effects[index] = entry is null
+ ? default
+ : new RichTextEffectPlanEntry(entry.Key, entry.Enabled);
+ }
+
+ return new RichTextEffectPlan(effects);
+ }
+
+ ///
+ /// 创建包含全部内置效果的默认计划。
+ ///
+ /// 包含第一阶段全部内置效果键的默认计划。
+ public static RichTextEffectPlan CreateBuiltInDefault()
+ {
+ return new RichTextEffectPlan(
+ [
+ new RichTextEffectPlanEntry("green"),
+ new RichTextEffectPlanEntry("red"),
+ new RichTextEffectPlanEntry("gold"),
+ new RichTextEffectPlanEntry("blue"),
+ new RichTextEffectPlanEntry("fade_in"),
+ new RichTextEffectPlanEntry("sine"),
+ new RichTextEffectPlanEntry("jitter"),
+ new RichTextEffectPlanEntry("fly_in")
+ ]);
+ }
+}
diff --git a/GFramework.Godot/Text/RichTextEffectPlanEntry.cs b/GFramework.Godot/Text/RichTextEffectPlanEntry.cs
new file mode 100644
index 00000000..f5b11b5d
--- /dev/null
+++ b/GFramework.Godot/Text/RichTextEffectPlanEntry.cs
@@ -0,0 +1,9 @@
+namespace GFramework.Godot.Text;
+
+///
+/// 描述一条纯托管的富文本效果计划项。
+/// 控制器与测试替身只关心效果键和启用状态,不需要依赖 Godot 资源对象本身。
+///
+/// 效果键。
+/// 该效果项是否启用。
+internal readonly record struct RichTextEffectPlanEntry(string Key, bool Enabled = true);
diff --git a/GFramework.Godot/Text/RichTextEffectsController.cs b/GFramework.Godot/Text/RichTextEffectsController.cs
index dabced40..29074953 100644
--- a/GFramework.Godot/Text/RichTextEffectsController.cs
+++ b/GFramework.Godot/Text/RichTextEffectsController.cs
@@ -1,9 +1,7 @@
-using Array = Godot.Collections.Array;
-
namespace GFramework.Godot.Text;
///
-/// 负责把配置、开关和注册表装配为宿主标签的实际效果集合。
+/// 负责把纯托管效果计划和开关装配为宿主标签的实际效果集合。
/// 该控制器是组合式扩展的装配中心,使 保持轻量。
///
internal sealed class RichTextEffectsController
@@ -11,31 +9,27 @@ internal sealed class RichTextEffectsController
private readonly Func _animatedEffectsEnabledAccessor;
private readonly Func _frameworkEffectsEnabledAccessor;
private readonly IRichTextEffectHost _host;
- private readonly Func _profileAccessor;
- private readonly Func _registryAccessor;
+ private readonly Func _profileAccessor;
///
/// 初始化控制器实例。
///
/// 目标富文本标签。
- /// 当前效果注册表访问器。
- /// 当前配置访问器。
+ /// 当前纯托管效果计划访问器。
/// 框架效果总开关访问器。
/// 字符动画开关访问器。
///
- /// 当 、、、
+ /// 当 、、
/// 或
/// 为 时抛出。
///
public RichTextEffectsController(
IRichTextEffectHost host,
- Func registryAccessor,
- Func profileAccessor,
+ Func profileAccessor,
Func frameworkEffectsEnabledAccessor,
Func animatedEffectsEnabledAccessor)
{
_host = host ?? throw new ArgumentNullException(nameof(host));
- _registryAccessor = registryAccessor ?? throw new ArgumentNullException(nameof(registryAccessor));
_profileAccessor = profileAccessor ?? throw new ArgumentNullException(nameof(profileAccessor));
_frameworkEffectsEnabledAccessor = frameworkEffectsEnabledAccessor
?? throw new ArgumentNullException(nameof(frameworkEffectsEnabledAccessor));
@@ -64,21 +58,11 @@ internal sealed class RichTextEffectsController
if (!frameworkEffectsEnabled)
{
- _host.CustomEffects = new Array();
+ _host.ClearCustomEffects();
return;
}
- var profile = _profileAccessor() ?? RichTextProfile.CreateBuiltInDefault();
- 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)
- {
- customEffects.Add(effect);
- }
-
- _host.CustomEffects = customEffects;
+ var profile = _profileAccessor() ?? RichTextEffectPlan.CreateBuiltInDefault();
+ _host.ApplyEffects(profile, _animatedEffectsEnabledAccessor());
}
}
diff --git a/GFramework.Godot/Text/RichTextProfile.cs b/GFramework.Godot/Text/RichTextProfile.cs
index d61d3f56..9759787e 100644
--- a/GFramework.Godot/Text/RichTextProfile.cs
+++ b/GFramework.Godot/Text/RichTextProfile.cs
@@ -2,7 +2,8 @@ namespace GFramework.Godot.Text;
///
/// 描述一个富文本效果组合配置。
-/// 该资源是组合式扩展的核心载体,用于声明宿主标签需要安装的效果集合。
+/// 该资源是 Godot 编辑器与场景系统使用的配置载体;运行时控制器会先把它转换为
+/// ,再在纯托管边界内完成刷新决策。
///
[GlobalClass]
public partial class RichTextProfile : Resource
@@ -20,18 +21,30 @@ public partial class RichTextProfile : Resource
/// 包含全部内置效果键的默认配置。
public static RichTextProfile CreateBuiltInDefault()
{
+ return FromPlan(RichTextEffectPlan.CreateBuiltInDefault());
+ }
+
+ ///
+ /// 从纯托管效果计划创建对应的 Godot 资源配置。
+ /// 该转换只应发生在真正需要与 Godot 宿主或公开注册表交互的适配层边界上。
+ ///
+ /// 待转换的纯托管效果计划。
+ /// 与计划等价的 Godot 资源配置。
+ ///
+ /// 当 为 时抛出。
+ ///
+ internal static RichTextProfile FromPlan(RichTextEffectPlan plan)
+ {
+ ArgumentNullException.ThrowIfNull(plan);
+
var profile = new RichTextProfile();
- profile.Effects =
- [
- new RichTextEffectEntry { Key = "green" },
- new RichTextEffectEntry { Key = "red" },
- new RichTextEffectEntry { Key = "gold" },
- new RichTextEffectEntry { Key = "blue" },
- new RichTextEffectEntry { Key = "fade_in" },
- new RichTextEffectEntry { Key = "sine" },
- new RichTextEffectEntry { Key = "jitter" },
- new RichTextEffectEntry { Key = "fly_in" }
- ];
+ profile.Effects = plan.Effects
+ .Select(static entry => new RichTextEffectEntry
+ {
+ Key = entry.Key,
+ Enabled = entry.Enabled
+ })
+ .ToArray();
return profile;
}
}