Skip to content
126 changes: 126 additions & 0 deletions GFramework.Godot.Tests/Text/RichTextEffectsControllerTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
using GFramework.Godot.Text;
using Godot;
using Array = Godot.Collections.Array;

namespace GFramework.Godot.Tests.Text;

/// <summary>
/// <see cref="RichTextEffectsController" /> 的纯托管行为测试。
/// </summary>
[TestFixture]
public sealed class RichTextEffectsControllerTests
{
/// <summary>
/// 验证启用框架效果时会开启宿主 BBCode,并在 Profile 为空时回退到内置默认配置。
/// </summary>
[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"
}));
}

/// <summary>
/// 验证关闭框架效果时不会调用注册表,并会清空宿主的自定义效果集合。
/// </summary>
[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);
}

/// <summary>
/// 验证控制器会在每次刷新时读取最新的注册表访问器结果,避免缓存旧注册表。
/// </summary>
[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<RichTextProfile> CapturedProfiles { get; } = [];

public bool CapturedAnimatedEffectsEnabled { get; private set; }

public IReadOnlyList<RichTextEffect> CreateEffects(RichTextProfile profile, bool animatedEffectsEnabled)
{
CapturedProfiles.Add(profile);
CapturedAnimatedEffectsEnabled = animatedEffectsEnabled;
return System.Array.Empty<RichTextEffect>();
}

public RichTextEffect? CreateEffect(string key, bool animatedEffectsEnabled)
{
return null;
}
}
}
65 changes: 65 additions & 0 deletions GFramework.Godot.Tests/Text/RichTextMarkupTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
using GFramework.Godot.Text;

namespace GFramework.Godot.Tests.Text;

/// <summary>
/// <see cref="RichTextMarkup" /> 的测试。
/// </summary>
[TestFixture]
public sealed class RichTextMarkupTests
{
/// <summary>
/// 验证颜色快捷方法会输出预期标签。
/// </summary>
[Test]
public void Green_Should_Wrap_Text_With_Green_Tag()
{
var result = RichTextMarkup.Green("Ready");

Assert.That(result, Is.EqualTo("[green]Ready[/green]"));
}

/// <summary>
/// 验证效果方法会按稳定顺序拼接环境参数。
/// </summary>
[Test]
public void Effect_Should_Sort_Environment_Parameters_By_Key()
{
var env = new Dictionary<string, object?>
{
["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]"));
}

/// <summary>
/// 验证非法标签 token 会被拒绝,避免生成损坏的 BBCode。
/// </summary>
[Test]
public void Effect_Should_Reject_Invalid_Tag_Tokens()
{
var exception = Assert.Throws<ArgumentException>(() => RichTextMarkup.Effect("Hello", "fade=in"));

Assert.That(exception!.ParamName, Is.EqualTo("tag"));
}

/// <summary>
/// 验证非法环境参数键会被拒绝,避免注入无效的 BBCode token。
/// </summary>
[Test]
public void Effect_Should_Reject_Invalid_Environment_Key_Tokens()
{
var env = new Dictionary<string, object?>
{
["bad key"] = 1
};

var exception = Assert.Throws<ArgumentException>(() => RichTextMarkup.Effect("Hello", "fade_in", env));

Assert.That(exception!.ParamName, Is.EqualTo("env"));
}
}
31 changes: 31 additions & 0 deletions GFramework.Godot.Tests/Text/RichTextProfileTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
using GFramework.Godot.Text;

namespace GFramework.Godot.Tests.Text;

/// <summary>
/// <see cref="RichTextProfile" /> 的测试。
/// </summary>
[TestFixture]
public sealed class RichTextProfileTests
{
/// <summary>
/// 验证默认内置配置会暴露完整的第一阶段效果键集合。
/// </summary>
[Test]
public void CreateBuiltInDefault_Should_Contain_The_First_Phase_Effect_Keys()
{
var profile = RichTextProfile.CreateBuiltInDefault();

Assert.That(profile.Effects.Select(static entry => entry.Key), Is.EqualTo(new[]
{
"green",
"red",
"gold",
"blue",
"fade_in",
"sine",
"jitter",
"fly_in"
}));
}
}
68 changes: 68 additions & 0 deletions GFramework.Godot/Text/DefaultRichTextEffectRegistry.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
using GFramework.Godot.Text.Effects;

namespace GFramework.Godot.Text;

/// <summary>
/// 默认的富文本效果注册表。
/// 该实现仅负责内置效果键的解析,不处理业务层文本构建或配置持久化。
/// </summary>
public sealed class DefaultRichTextEffectRegistry : IRichTextEffectRegistry
{
/// <summary>
/// 创建当前配置对应的全部效果实例。
/// </summary>
/// <param name="profile">效果组合配置。</param>
/// <param name="animatedEffectsEnabled">当前是否允许字符级动态效果生效。</param>
/// <returns>内置效果实例集合。</returns>
/// <exception cref="ArgumentNullException">
/// 当 <paramref name="profile" /> 为 <see langword="null" /> 时抛出。
/// </exception>
public IReadOnlyList<RichTextEffect> CreateEffects(RichTextProfile profile, bool animatedEffectsEnabled)
{
ArgumentNullException.ThrowIfNull(profile);

var effects = new List<RichTextEffect>(profile.Effects.Length);
foreach (var entry in profile.Effects)
{
if (entry is null || !entry.Enabled || string.IsNullOrWhiteSpace(entry.Key))
{
continue;
}

var effect = CreateEffect(entry.Key, animatedEffectsEnabled);
if (effect is not null)
{
effects.Add(effect);
}
}

return effects;
}

/// <summary>
/// 根据效果键创建单个效果实例。
/// </summary>
/// <param name="key">效果键。</param>
/// <param name="animatedEffectsEnabled">当前是否允许字符级动态效果生效。</param>
/// <returns>解析成功时返回效果实例;否则返回 <see langword="null" />。</returns>
public RichTextEffect? CreateEffect(string key, bool animatedEffectsEnabled)
{
if (string.IsNullOrWhiteSpace(key))
{
return null;
}

return key.Trim().ToLowerInvariant() switch
{
"green" => new RichTextGreenEffect(),
"red" => new RichTextRedEffect(),
"gold" => new RichTextGoldEffect(),
"blue" => new RichTextBlueEffect(),
"fade_in" => new RichTextFadeInEffect(animatedEffectsEnabled),
"sine" => new RichTextSineEffect(animatedEffectsEnabled),
"jitter" => new RichTextJitterEffect(animatedEffectsEnabled),
"fly_in" => new RichTextFlyInEffect(animatedEffectsEnabled),
_ => null
};
}
}
27 changes: 27 additions & 0 deletions GFramework.Godot/Text/Effects/RichTextBlueEffect.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
namespace GFramework.Godot.Text.Effects;

/// <summary>
/// 为文本应用蓝色语义高亮。
/// </summary>
[GlobalClass]
[Tool]
public partial class RichTextBlueEffect : RichTextEffectBase
{
private static readonly Color BlueColor = new(0.44f, 0.72f, 0.98f, 1.0f);

/// <summary>
/// 获取标签名。
/// </summary>
protected override string TagName => "blue";

/// <summary>
/// 应用蓝色颜色效果。
/// </summary>
/// <param name="charFx">当前字符上下文。</param>
/// <returns>始终返回 <see langword="true" />。</returns>
public override bool _ProcessCustomFX(CharFXTransform charFx)
{
charFx.Color = BlueColor;
return true;
}
}
Loading
Loading