Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 17 additions & 12 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -145,11 +145,12 @@ jobs:
run: dotnet build GFramework.sln -c Release --no-restore

# 运行单元测试,输出TRX格式结果到TestResults目录
# 在同一个 step 中并发执行所有测试以加快速度
# 顺序执行各测试项目,避免并发 dotnet test 进程导致“TRX 全绿但 step 仍返回失败”的假红状态
- name: Test All Projects
id: test_all_projects
run: |
set -euo pipefail
mkdir -p TestResults

test_projects=(
"GFramework.Core.Tests/GFramework.Core.Tests.csproj:core"
Expand All @@ -161,27 +162,31 @@ jobs:
"GFramework.Godot.SourceGenerators.Tests/GFramework.Godot.SourceGenerators.Tests.csproj:godot-sg"
)

pids=()
failed=0
failed_projects=()

for entry in "${test_projects[@]}"; do
project="${entry%%:*}"
name="${entry##*:}"

dotnet test "$project" \
echo "::group::dotnet test $project"
if ! dotnet test "$project" \
-c Release \
--no-build \
--logger "trx;LogFileName=${name}-$RANDOM.trx" \
--results-directory TestResults &

pids+=("$!")
done

failed=0
for pid in "${pids[@]}"; do
if ! wait "$pid"; then
--logger "trx;LogFileName=${name}.trx" \
--results-directory TestResults; then
failed=1
failed_projects+=("$project")
echo "::error title=Test project failed::$project returned a non-zero exit code."
fi
echo "::endgroup::"
done

if [ "$failed" -eq 1 ]; then
printf 'Failed test projects:\n'
printf ' %s\n' "${failed_projects[@]}"
fi

echo "failed=$failed" >> "$GITHUB_OUTPUT"

- name: Generate CTRF report
Expand Down
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 Array = Godot.Collections.Array;

Comment thread
GeWuYou marked this conversation as resolved.
Outdated
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, 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[]
{
"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

Check failure on line 108 in GFramework.Godot.Tests/Text/RichTextEffectsControllerTests.cs

View workflow job for this annotation

GitHub Actions / Build and Test

'RichTextEffectsControllerTests.RecordingRegistry' does not implement interface member 'IRichTextEffectRegistry.CreateEffect(string, bool)'. 'RichTextEffectsControllerTests.RecordingRegistry.CreateEffect(string, bool)' cannot implement 'IRichTextEffectRegistry.CreateEffect(string, bool)' because it does not have the matching return type of 'RichTextEffect'.

Check failure on line 108 in GFramework.Godot.Tests/Text/RichTextEffectsControllerTests.cs

View workflow job for this annotation

GitHub Actions / Build and Test

'RichTextEffectsControllerTests.RecordingRegistry' does not implement interface member 'IRichTextEffectRegistry.CreateEffects(RichTextProfile, bool)'. 'RichTextEffectsControllerTests.RecordingRegistry.CreateEffects(RichTextProfile, bool)' cannot implement 'IRichTextEffectRegistry.CreateEffects(RichTextProfile, bool)' because it does not have the matching return type of 'IReadOnlyList<RichTextEffect>'.

Check failure on line 108 in GFramework.Godot.Tests/Text/RichTextEffectsControllerTests.cs

View workflow job for this annotation

GitHub Actions / Build and Test

'RichTextEffectsControllerTests.RecordingRegistry' does not implement interface member 'IRichTextEffectRegistry.CreateEffect(string, bool)'. 'RichTextEffectsControllerTests.RecordingRegistry.CreateEffect(string, bool)' cannot implement 'IRichTextEffectRegistry.CreateEffect(string, bool)' because it does not have the matching return type of 'RichTextEffect'.

Check failure on line 108 in GFramework.Godot.Tests/Text/RichTextEffectsControllerTests.cs

View workflow job for this annotation

GitHub Actions / Build and Test

'RichTextEffectsControllerTests.RecordingRegistry' does not implement interface member 'IRichTextEffectRegistry.CreateEffects(RichTextProfile, bool)'. 'RichTextEffectsControllerTests.RecordingRegistry.CreateEffects(RichTextProfile, bool)' cannot implement 'IRichTextEffectRegistry.CreateEffects(RichTextProfile, bool)' because it does not have the matching return type of 'IReadOnlyList<RichTextEffect>'.
{
public List<RichTextProfile> CapturedProfiles { get; } = [];

public List<bool> CapturedAnimatedEffectsEnabled { get; } = [];

public IReadOnlyList<RichTextEffect> CreateEffects(RichTextProfile profile, bool animatedEffectsEnabled)

Check failure on line 114 in GFramework.Godot.Tests/Text/RichTextEffectsControllerTests.cs

View workflow job for this annotation

GitHub Actions / Build and Test

The type or namespace name 'RichTextEffect' could not be found (are you missing a using directive or an assembly reference?)

Check failure on line 114 in GFramework.Godot.Tests/Text/RichTextEffectsControllerTests.cs

View workflow job for this annotation

GitHub Actions / Build and Test

The type or namespace name 'RichTextEffect' could not be found (are you missing a using directive or an assembly reference?)
{
CapturedProfiles.Add(profile);
CapturedAnimatedEffectsEnabled.Add(animatedEffectsEnabled);
return Array.Empty<RichTextEffect>();
}

public RichTextEffect? CreateEffect(string key, bool animatedEffectsEnabled)

Check failure on line 121 in GFramework.Godot.Tests/Text/RichTextEffectsControllerTests.cs

View workflow job for this annotation

GitHub Actions / Build and Test

The type or namespace name 'RichTextEffect' could not be found (are you missing a using directive or an assembly reference?)

Check failure on line 121 in GFramework.Godot.Tests/Text/RichTextEffectsControllerTests.cs

View workflow job for this annotation

GitHub Actions / Build and Test

The type or namespace name 'RichTextEffect' could not be found (are you missing a using directive or an assembly reference?)
{
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