feat: add tui test infrastructure

- add xunit regression project with virtual terminal output

- cover text input overlay rendering and component checks
This commit is contained in:
chuan
2026-06-04 02:35:52 +08:00
Unverified
parent e2d534170f
commit b26104fe95
11 changed files with 521 additions and 0 deletions
+32
View File
@@ -355,6 +355,38 @@ TinyTUI 现在已经具备最小可运行的 C# TUI 框架骨架:终端输入
参考:`tmp/tui/test/virtual-terminal.ts``tmp/tui/test/*.test.ts`
本次推进:
- 新增 `test/TinyTUI.Tests` 正式 xUnit 测试项目并加入 `ttui.slnx`,让后续回归可以通过 `dotnet test ttui.slnx` 统一执行,不再只依赖手写 console check
- 新增 `VirtualTerminalOutput` 测试辅助,捕获渲染器写出的 ANSI 序列、记录每次 write、模拟终端窗口尺寸,并复用 `ITerminalOutput` 接口验证真实渲染输出路径
- 新增 `AnsiAssert`,提供 ANSI 感知的可见文本比较,便于 overlay、renderer 和文本工具测试在保留 escape sequence 的同时断言最终可见内容
- 迁移并规范化 TextChecks 的关键文本回归,覆盖 ANSI/tab/emoji 宽度、按列切片、ANSI 截断、省略符和 OSC 8 wrap
- 新增输入基础测试,覆盖 `StdinBuffer` 分块 escape、批量 Kitty CSI-u 片段、bracketed paste、flush 未完成序列、`DefaultInputParser` 常见按键和 keybinding 冲突归一化
- 新增 overlay 回归测试,覆盖短内容时 overlay 仍可见、短 overlay 覆盖长底层行时保留右侧内容、overlay 段 reset 隔离
- 新增差分渲染测试,覆盖 bottom viewport、行尾 reset、只重绘变化行、resize/shrink full redraw、硬件光标 marker 定位和 Kitty image 删除顺序
- 新增组件回归测试,覆盖 focusable 光标 marker、slash command autocomplete 确认/取消、Image 组件 Kitty/fallback 和 image line 任意位置识别
为什么先做:
- `tmp/tui/test/virtual-terminal.ts` 的核心价值是让终端行为可自动断言;C# 侧当前没有 xterm.js 等完整终端模拟依赖,本次先用 `ITerminalOutput` 建立轻量 fake output,优先覆盖 renderer 实际写出的 ANSI 序列和尺寸变化
- 前面 TODO 5/6/7/8 已经产生 TextChecks 和 ComponentChecks,继续追加 console check 会让失败定位和 CI 接入变差;正式测试项目能把这些回归按模块拆分,并为第 10 项 CI 打基础
- 本次只固化已实现能力和已修过的关键回归,不把 Kitty 完整协议、真实终端屏幕缓冲或更多组件行为混进同一个 commit,降低测试基础设施增量的风险
当前更好的点:
- C# 侧测试辅助直接实现 `ITerminalOutput`,不需要绕过 renderer 或 runtime 私有状态,能观察 `ClearScreen``MoveCursorTo``HideCursor`、Kitty cleanup 等真实输出
- `VirtualTerminalOutput` 同时保留完整 buffer 和 write 片段,既能断言最终序列,也能检查删除图像必须早于新内容写入这类顺序问题
- 正式测试项目已覆盖 text/input/overlay/rendering/components 五个基础面,后续新增回归时有明确目录,不需要继续把所有断言塞进单个 `Program.cs`
后续仍需补齐:
- 还没有像 `tmp/tui/test/virtual-terminal.ts` 那样基于真实终端 emulator 解析 ANSI 后得到 viewport、scrollback、cell style 和 cursor position;当前只能断言写出的序列,不能验证终端最终屏幕状态
- 现有 `TextChecks``ComponentChecks` 暂时保留,后续可以在所有断言迁移完成后删除或改成示例 smoke check,避免同一回归维护两套入口
- `DefaultInputParser` 当前对 `ESC[Z` Shift+Tab 仍会原样透传,本次测试只覆盖当前支持的修饰 CSI;后续输入协议增量应补正式回归
- `StdinBuffer` 还没有参考实现中的超时 flush、ESC+ESC+CSI 分裂、Kitty printable duplicate drop、鼠标协议和旧式 mouse sequence 测试
- overlay 测试目前通过 `OverlayManager.Compose` 断言合成结果,还没有跑完整 Runtime + Renderer + virtual terminal 的端到端路径,多层 overlay 焦点恢复和 viewport 对齐仍需继续补
- 渲染测试尚未覆盖 synchronized output、debug 日志、Termux resize 策略、硬件光标相对移动和完整 Kitty reserved row redraw 行为
### 10. 工程化和发布完整性
目标:让 TinyTUI 从本地骨架变成可维护、可发布、可集成的类库
@@ -0,0 +1,83 @@
using TinyTUI.Autocomplete;
using TinyTUI.Components;
using TinyTUI.Input;
using TinyTUI.Rendering;
using TinyTUI.Terminal.Images;
using TinyTUI.Text;
namespace TinyTUI.Tests.Components;
public sealed class ComponentRegressionTests
{
private readonly TerminalTextMeasurer _measurer = new();
[Fact]
public void FocusableComponentsOnlyRenderCursorMarkerWhenFocused()
{
var input = new TinyTUI.Components.Input(_measurer) { Prompt = "> " };
Assert.DoesNotContain(CursorMarker.Marker, input.Render(20)[0], StringComparison.Ordinal);
input.Focused = true;
Assert.Contains(CursorMarker.Marker, input.Render(20)[0], StringComparison.Ordinal);
var editor = new Editor(_measurer) { Placeholder = "type here" };
Assert.DoesNotContain(CursorMarker.Marker, editor.Render(20)[0], StringComparison.Ordinal);
editor.Focused = true;
Assert.Contains(CursorMarker.Marker, editor.Render(20)[0], StringComparison.Ordinal);
}
[Fact]
public void SlashCommandAutocompleteCanRenderConfirmAndCancel()
{
var editor = new Editor(_measurer)
{
AutocompleteProvider = new SlashCommandAutocompleteProvider(
[
new SlashCommand("help", "show help"),
new SlashCommand("history", "show history"),
]),
};
editor.HandleInput(new TuiInputEvent(TuiInputEventKind.Text, "/h"));
Assert.True(editor.IsAutocompleteActive);
Assert.Contains(editor.RenderAutocomplete(40), line => line.Contains("help", StringComparison.Ordinal));
editor.HandleInput(new TuiInputEvent(TuiInputEventKind.Key, KeyNames.Down));
editor.HandleInput(new TuiInputEvent(TuiInputEventKind.Key, KeyNames.Tab));
Assert.Equal("/history ", editor.Value);
Assert.False(editor.IsAutocompleteActive);
editor.Value = "/h";
editor.HandleInput(new TuiInputEvent(TuiInputEventKind.Key, KeyNames.Tab));
editor.HandleInput(new TuiInputEvent(TuiInputEventKind.Key, KeyNames.Escape));
Assert.False(editor.IsAutocompleteActive);
Assert.Equal("/h", editor.Value);
}
[Fact]
public void ImageComponentRendersKittySequenceAndFallback()
{
var imageService = new TerminalImageService();
imageService.SetCapabilities(new TerminalCapabilities(ImageProtocol.Kitty, true, true));
imageService.SetCellDimensions(new CellDimensions(10, 10));
Assert.Equal(new ImageCellSize(2, 2), imageService.CalculateCellSize(new ImageDimensions(20, 20), 2));
var image = new Image([1, 2, 3, 4], "image/png", imageService: imageService, dimensions: new ImageDimensions(20, 20))
{
MaxWidthCells = 2,
};
var imageLines = image.Render(4);
Assert.Equal(2, imageLines.Count);
Assert.True(image.ImageId is > 0);
Assert.StartsWith("\e_G", imageLines[0], StringComparison.Ordinal);
Assert.Contains(",C=1,", imageLines[0], StringComparison.Ordinal);
Assert.True(TerminalImageService.IsImageLine($"prefix {imageLines[0]} suffix"));
imageService.SetCapabilities(TerminalCapabilities.Conservative);
var fallback = new Image([1, 2, 3], "image/jpeg", imageService: imageService, dimensions: new ImageDimensions(8, 9))
{
FileName = "photo.jpg",
}.Render(80)[0];
Assert.Contains("[Image: photo.jpg [image/jpeg] 8x9]", fallback, StringComparison.Ordinal);
}
}
+1
View File
@@ -0,0 +1 @@
global using Xunit;
@@ -0,0 +1,61 @@
using TinyTUI.Input;
namespace TinyTUI.Tests.Input;
public sealed class InputInfrastructureTests
{
[Fact]
public void StdinBufferSplitsBatchedEscapeSequencesAndPaste()
{
var buffer = new StdinBuffer();
Assert.Empty(buffer.Process("\e["));
Assert.Equal([new StdinBufferEvent(StdinBufferEventKind.Data, "\e[A")], buffer.Process("A"));
Assert.Equal(
[
new StdinBufferEvent(StdinBufferEventKind.Data, "\e[97u"),
new StdinBufferEvent(StdinBufferEventKind.Data, "\e[97;1:3u"),
],
buffer.Process("\e[97u\e[97;1:3u"));
Assert.Empty(buffer.Process("\e[200~hello "));
Assert.Equal([new StdinBufferEvent(StdinBufferEventKind.Paste, "hello world")], buffer.Process("world\e[201~"));
}
[Fact]
public void StdinBufferFlushesIncompleteSequences()
{
var buffer = new StdinBuffer();
Assert.Empty(buffer.Process("\e[<35"));
Assert.Equal([new StdinBufferEvent(StdinBufferEventKind.Data, "\e[<35")], buffer.Flush());
}
[Fact]
public void DefaultInputParserParsesCommonKeysAndPaste()
{
var parser = new DefaultInputParser();
Assert.Equal([new TuiInputEvent(TuiInputEventKind.Key, KeyNames.Up)], parser.Parse("\e[A"));
Assert.Equal([new TuiInputEvent(TuiInputEventKind.Key, KeyNames.Shift(KeyNames.Up))], parser.Parse("\e[1;2A"));
Assert.Equal([new TuiInputEvent(TuiInputEventKind.Key, KeyNames.Ctrl("c"))], parser.Parse("\x03"));
Assert.Equal([new TuiInputEvent(TuiInputEventKind.Text, "abc")], parser.Parse("abc"));
Assert.Equal([new TuiInputEvent(TuiInputEventKind.Paste, "pasted")], parser.Parse("\e[200~pasted\e[201~"));
}
[Fact]
public void KeybindingRegistryReportsUserConflictsAndNormalizesAliases()
{
var registry = KeybindingRegistry.CreateDefault();
registry.SetUserBindings(new Dictionary<string, IEnumerable<KeyId>>
{
[TuiKeybindings.InputSubmit] = [KeyNames.PageUp],
[TuiKeybindings.InputCancel] = ["pageUp"],
});
var conflict = Assert.Single(registry.Conflicts);
Assert.Equal(KeyNames.PageUp, conflict.Key.Value);
Assert.Contains(TuiKeybindings.InputSubmit, conflict.Actions);
Assert.Contains(TuiKeybindings.InputCancel, conflict.Actions);
}
}
@@ -0,0 +1,68 @@
using TinyTUI.Components;
using TinyTUI.Overlay;
using TinyTUI.Tests.TestInfrastructure;
using TinyTUI.Text;
namespace TinyTUI.Tests.Overlay;
public sealed class OverlayManagerTests
{
[Fact]
public void ComposeKeepsOverlayVisibleWhenBaseContentIsShorterThanTerminal()
{
var manager = new OverlayManager(new TerminalTextMeasurer());
manager.Show(new StaticComponent(["OVERLAY_TOP", "OVERLAY_MID", "OVERLAY_BOT"]));
var lines = manager.Compose(["Line 1", "Line 2", "Line 3"], new TerminalSize(20, 10));
Assert.Contains(lines, line => line.Contains("OVERLAY", StringComparison.Ordinal));
}
[Fact]
public void ComposePreservesBaseContentToRightOfShortOverlay()
{
var manager = new OverlayManager(new TerminalTextMeasurer());
manager.Show(
new StaticComponent(["OVR"]),
new OverlayOptions
{
Row = 0,
Column = 3,
Width = 3,
});
var lines = manager.Compose(["0123456789"], new TerminalSize(10, 3));
AnsiAssert.EqualVisible("012OVR6789", lines[0]);
}
[Fact]
public void ComposeIsolatesOverlaySegmentWithResets()
{
var manager = new OverlayManager(new TerminalTextMeasurer());
manager.Show(
new StaticComponent(["OVR"]),
new OverlayOptions
{
Row = 0,
Column = 5,
Width = 3,
});
var line = manager.Compose(["\e[3mXXXXXXXXXXXXXXXXXXXX\e[23m"], new TerminalSize(20, 3))[0];
Assert.Contains("\e[0m\e]8;;\aOVR\e[0m\e]8;;\a", line, StringComparison.Ordinal);
AnsiAssert.ContainsVisible("OVR", line);
}
private sealed class StaticComponent(IReadOnlyList<string> lines) : IComponent
{
/// <inheritdoc />
public IReadOnlyList<string> Render(int width) => lines;
/// <inheritdoc />
public void Invalidate()
{
}
}
}
@@ -0,0 +1,105 @@
using TinyTUI.Rendering;
using TinyTUI.Terminal.Images;
using TinyTUI.Tests.TestInfrastructure;
using TinyTUI.Text;
namespace TinyTUI.Tests.Rendering;
public sealed class DifferentialRendererTests
{
private static readonly RenderPipelineOptions TestOptions = new()
{
ThrowOnWidthOverflow = true,
UseSynchronizedOutput = false,
};
[Fact]
public void FirstRenderCapturesVisibleViewportAndLineResets()
{
var output = new VirtualTerminalOutput(20, 3);
var renderer = CreateRenderer(output);
renderer.Render(["Line 0", "Line 1", "Line 2", "Line 3"], output.Size);
Assert.Equal(1, renderer.FullRedrawCount);
Assert.Equal(new RenderViewportState(1, 3, 4, 4), renderer.ViewportState);
Assert.Contains("Line 1\e[0m\e]8;;\a", output.Buffer.ToString(), StringComparison.Ordinal);
Assert.DoesNotContain("Line 0", output.Buffer.ToString(), StringComparison.Ordinal);
}
[Fact]
public void DiffRenderOnlyMovesAndClearsChangedRows()
{
var output = new VirtualTerminalOutput(20, 4);
var renderer = CreateRenderer(output);
renderer.Render(["Header", "Working...", "Footer"], output.Size);
output.ClearWrites();
renderer.Render(["Header", "Working /", "Footer"], output.Size);
Assert.Equal(1, renderer.DifferentialRedrawCount);
Assert.Contains("\e[2;1H\e[2KWorking /", output.Buffer.ToString(), StringComparison.Ordinal);
Assert.DoesNotContain("\e[1;1H\e[2KHeader", output.Buffer.ToString(), StringComparison.Ordinal);
}
[Fact]
public void ResizeAndShrinkForceFullRedrawsToClearStaleRows()
{
var output = new VirtualTerminalOutput(20, 5);
var renderer = CreateRenderer(output);
renderer.Render(["Line 0", "Line 1", "Line 2", "Line 3"], output.Size);
output.Resize(20, 6);
output.ClearWrites();
renderer.Render(["Line 0", "Line 1", "Line 2", "Line 3"], output.Size);
Assert.Equal(2, renderer.FullRedrawCount);
Assert.True(output.Contains("\e[2J\e[H"));
output.ClearWrites();
renderer.Render(["Line 0"], output.Size);
Assert.Equal(3, renderer.FullRedrawCount);
Assert.True(output.Contains("\e[2J\e[H"));
}
[Fact]
public void CursorMarkerMovesHardwareCursorAndCanShowCursor()
{
var output = new VirtualTerminalOutput(20, 4);
var renderer = new DifferentialRenderer(
output,
new TerminalTextMeasurer(),
new RenderPipelineOptions
{
ThrowOnWidthOverflow = true,
UseSynchronizedOutput = false,
ShowHardwareCursor = true,
});
renderer.Render([$"ab{CursorMarker.Marker}cd"], output.Size);
Assert.True(output.Contains("\e[1;3H"));
Assert.True(output.Contains("\e[?25h"));
}
[Fact]
public void ChangedKittyImageIsDeletedBeforeReplacementLine()
{
var output = new VirtualTerminalOutput(20, 4);
var renderer = CreateRenderer(output);
var kittyLine = "\e_Ga=T,f=100,q=2,C=1,c=1,r=1,i=42;AAAA\e\\";
renderer.Render([kittyLine], output.Size);
output.ClearWrites();
renderer.Render(["changed"], output.Size);
var writes = output.Buffer.ToString();
var deleteIndex = writes.IndexOf(TerminalImageService.DeleteKittyImage(42), StringComparison.Ordinal);
var changedIndex = writes.IndexOf("changed", StringComparison.Ordinal);
Assert.True(deleteIndex >= 0);
Assert.True(changedIndex >= 0);
Assert.True(deleteIndex < changedIndex);
}
private static DifferentialRenderer CreateRenderer(VirtualTerminalOutput output)
=> new(output, new TerminalTextMeasurer(), TestOptions);
}
@@ -0,0 +1,24 @@
using System.Text.RegularExpressions;
namespace TinyTUI.Tests.TestInfrastructure;
/// <summary>
/// 提供 ANSI 感知的测试断言辅助
/// </summary>
internal static partial class AnsiAssert
{
/// <summary>
/// 移除常见 ANSI 控制序列后比较可见文本
/// </summary>
public static void EqualVisible(string expected, string actual)
=> Assert.Equal(expected, AnsiRegex().Replace(actual, string.Empty));
/// <summary>
/// 移除常见 ANSI 控制序列后判断可见文本是否包含指定片段
/// </summary>
public static void ContainsVisible(string expected, string actual)
=> Assert.Contains(expected, AnsiRegex().Replace(actual, string.Empty), StringComparison.Ordinal);
[GeneratedRegex(@"\x1B(?:\[[0-?]*[ -/]*[@-~]|\][^\a]*(?:\a|\x1B\\)|_[^\a]*(?:\a|\x1B\\)|P[^\a]*(?:\a|\x1B\\))")]
private static partial Regex AnsiRegex();
}
@@ -0,0 +1,70 @@
using System.Text;
using TinyTUI.Stdout;
namespace TinyTUI.Tests.TestInfrastructure;
/// <summary>
/// 捕获渲染器写出的终端序列并提供可模拟的窗口尺寸
/// </summary>
internal sealed class VirtualTerminalOutput(int columns = 80, int rows = 24) : ITerminalOutput
{
private readonly List<string> _writes = [];
/// <summary>
/// 获取当前模拟终端尺寸
/// </summary>
public TerminalSize Size { get; private set; } = new(columns, rows);
/// <summary>
/// 获取完整输出缓冲
/// </summary>
public StringBuilder Buffer { get; } = new();
/// <summary>
/// 获取每一次写入的原始片段
/// </summary>
public IReadOnlyList<string> Writes => _writes;
/// <summary>
/// 调整模拟终端尺寸
/// </summary>
public void Resize(int columns, int rows) => Size = new TerminalSize(columns, rows);
/// <summary>
/// 清空已捕获的输出
/// </summary>
public void ClearWrites()
{
_writes.Clear();
Buffer.Clear();
}
/// <summary>
/// 判断完整输出缓冲是否包含指定序列
/// </summary>
public bool Contains(string value) => Buffer.ToString().Contains(value, StringComparison.Ordinal);
/// <inheritdoc />
public void Write(string value)
{
_writes.Add(value);
Buffer.Append(value);
}
/// <inheritdoc />
public void Flush()
{
}
/// <inheritdoc />
public void ClearScreen() => Write("\e[2J\e[H");
/// <inheritdoc />
public void HideCursor() => Write("\e[?25l");
/// <inheritdoc />
public void ShowCursor() => Write("\e[?25h");
/// <inheritdoc />
public void MoveCursorTo(int row, int column) => Write($"\e[{row};{column}H");
}
@@ -0,0 +1,58 @@
using TinyTUI.Text;
namespace TinyTUI.Tests.Text;
public sealed class TerminalTextMeasurerTests
{
private readonly TerminalTextMeasurer _measurer = new();
[Fact]
public void GetWidthHandlesAnsiTabsAndEmojiGraphemes()
{
Assert.Equal(5, _measurer.GetWidth("\t\e[31m界\e[0m"));
Assert.Equal(2, _measurer.GetWidth("🇨"));
Assert.Equal(2, _measurer.GetWidth("🇨🇳"));
Assert.Equal(2, _measurer.GetWidth("👨‍💻"));
Assert.Equal(2, _measurer.GetWidth("⚡️"));
}
[Fact]
public void SliceTracksTabColumnsAndMeasuredWidth()
{
var strictTabSlice = _measurer.Slice("out 192M\t.pi/skill-tests/results-ha", 0, 10, strict: true);
Assert.Equal("out 192M", strictTabSlice.Text);
Assert.Equal(8, strictTabSlice.Width);
Assert.Equal(_measurer.GetWidth(strictTabSlice.Text), strictTabSlice.Width);
var afterTabSlice = _measurer.Slice("out 192M\t.pi/skill-tests/results-ha", 13, 10, strict: true);
Assert.Equal("i/skill-te", afterTabSlice.Text);
Assert.Equal(_measurer.GetWidth(afterTabSlice.Text), afterTabSlice.Width);
}
[Fact]
public void TruncateKeepsAnsiPrefixAndBracketsEllipsis()
{
var ansiText = $"\e[31m{string.Concat(Enumerable.Repeat("hello ", 100))}\e[0m";
var truncated = _measurer.Truncate(ansiText, 20, "…");
Assert.True(_measurer.GetWidth(truncated) <= 20);
Assert.Contains("\e[31m", truncated, StringComparison.Ordinal);
Assert.EndsWith("\e[0m…\e[0m", truncated, StringComparison.Ordinal);
Assert.Equal("\e[0m🙂\e[0m", _measurer.Truncate("abcdef", 2, "🙂"));
Assert.Equal(string.Empty, _measurer.Truncate("abcdef", 1, "🙂"));
}
[Fact]
public void WrapRestoresAnsiAndOsc8AcrossLines()
{
var redWrapped = _measurer.Wrap($"\e[31mhello world this is red\e[0m", 10);
Assert.All(redWrapped.Skip(1), line => Assert.StartsWith("\e[31m", line, StringComparison.Ordinal));
Assert.All(redWrapped.Take(redWrapped.Count - 1), line => Assert.False(line.EndsWith("\e[0m", StringComparison.Ordinal)));
var hyperlink = "\e]8;;https://example.com\a0123456789\e]8;;\a";
var hyperlinkWrapped = _measurer.Wrap(hyperlink, 6);
Assert.True(hyperlinkWrapped.Count > 1);
Assert.All(hyperlinkWrapped, line => Assert.Contains("\e]8;;https://example.com\a", line, StringComparison.Ordinal));
Assert.All(hyperlinkWrapped.Take(hyperlinkWrapped.Count - 1), line => Assert.EndsWith("\e]8;;\a", line, StringComparison.Ordinal));
}
}
+18
View File
@@ -0,0 +1,18 @@
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<ProjectReference Include="..\..\src\TinyTUI\TinyTUI.csproj" />
<PackageReference Include="coverlet.collector" Version="6.0.4" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.0.1" />
<PackageReference Include="xunit.v3" Version="3.2.1" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.5" />
</ItemGroup>
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>
</Project>
+1
View File
@@ -10,5 +10,6 @@
<Project Path="src/Example/Example.csproj" Id="6656c1ec-b220-4e7a-8875-5a97a85afbc1" />
<Project Path="src/TinyTUI/TinyTUI.csproj" Id="f39199aa-6947-4951-b952-a1588045505e" />
<Project Path="test/TinyTUI.ComponentChecks/TinyTUI.ComponentChecks.csproj" Id="95c72f97-61f0-4806-8a90-28d70a89cd87" />
<Project Path="test/TinyTUI.Tests/TinyTUI.Tests.csproj" Id="9b90a587-33b5-4c51-8d18-b1c87ce239d0" />
<Project Path="test/TinyTUI.TextChecks/TinyTUI.TextChecks.csproj" Id="d827983c-78a1-4f04-85f3-874ccfe2e735" />
</Solution>