test: converge smoke checks into xunit suite
ci / restore build format test pack (push) Failing after 22s
ci / restore build format test pack (push) Failing after 22s
- migrate remaining text and component smoke assertions into TinyTUI.Tests - remove legacy TextChecks and ComponentChecks projects from solution - update README test guidance to use the unified xUnit suite
This commit is contained in:
@@ -92,7 +92,7 @@ dotnet test ttui.slnx --configuration Release --no-build
|
||||
dotnet pack src/TinyTUI/TinyTUI.csproj --configuration Release --no-build
|
||||
```
|
||||
|
||||
`test/TinyTUI.Tests` 是正式 xUnit 回归测试项目。`test/TinyTUI.TextChecks` 和 `test/TinyTUI.ComponentChecks` 是早期最小 smoke check,后续会逐步收敛到正式测试项目。
|
||||
`test/TinyTUI.Tests` 是统一的 xUnit 回归测试项目,覆盖文本、输入、overlay、renderer、组件、自动补全和终端图像相关回归。
|
||||
|
||||
## 压力示例
|
||||
|
||||
|
||||
@@ -704,27 +704,34 @@ SettingsList 后续仍需补齐:
|
||||
- 新增 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 任意位置识别
|
||||
- 将 `TextChecks` 残余断言完全迁移到 `TerminalTextMeasurerTests`,补齐 plain wrap、underline URL wrap、ANSI continuation 和 OSC 8 BEL hyperlink wrap 覆盖
|
||||
- 将 `ComponentChecks` 残余断言完全迁移到 `ComponentRegressionTests` 和 `DifferentialRendererTests`,补齐 SelectList focused marker、Spacer 负数 clamp、TruncatedText padding/首行截断、CancellableLoader 取消、PNG dimensions、长 iTerm2 image line overflow bypass
|
||||
- 从 `ttui.slnx` 移除 `test/TinyTUI.TextChecks` 和 `test/TinyTUI.ComponentChecks`,删除两个早期 console smoke check 项目,README 测试说明改为统一 xUnit 入口
|
||||
|
||||
为什么先做:
|
||||
|
||||
- `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,降低测试基础设施增量的风险
|
||||
- 旧 smoke check 已经和 xUnit 用例重叠,继续保留会导致同一行为维护两套入口;本次先按旧 `Program.cs` 的有效断言逐项对齐,再删除旧项目,保证边界清晰
|
||||
|
||||
当前更好的点:
|
||||
|
||||
- C# 侧测试辅助直接实现 `ITerminalOutput`,不需要绕过 renderer 或 runtime 私有状态,能观察 `ClearScreen`、`MoveCursorTo`、`HideCursor`、Kitty cleanup 等真实输出
|
||||
- `VirtualTerminalOutput` 同时保留完整 buffer 和 write 片段,既能断言最终序列,也能检查删除图像必须早于新内容写入这类顺序问题
|
||||
- 正式测试项目已覆盖 text/input/overlay/rendering/components 五个基础面,后续新增回归时有明确目录,不需要继续把所有断言塞进单个 `Program.cs`
|
||||
- 相比 console check 手写 `AssertTrue`,xUnit 测试按模块命名,失败时能直接定位到文本、组件或 renderer 的具体行为
|
||||
- 现有 C# 写法用 collection expression、target-typed `new` 和 `using var` 保持测试输入短小,断言结构更接近被测行为本身
|
||||
|
||||
后续仍需补齐:
|
||||
|
||||
- 还没有像 `tmp/tui/test/virtual-terminal.ts` 那样基于真实终端 emulator 解析 ANSI 后得到 viewport、scrollback、cell style 和 cursor position;当前只能断言写出的序列,不能验证终端最终屏幕状态
|
||||
- 现有 `TextChecks` 和 `ComponentChecks` 暂时保留,后续可以在所有断言迁移完成后删除或改成示例 smoke check,避免同一回归维护两套入口
|
||||
- `TextChecks` 和 `ComponentChecks` 已删除,后续新增回归应直接进入 `test/TinyTUI.Tests`,不要再恢复独立 console 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 行为
|
||||
- 图像尺寸目前只把旧 smoke check 的 PNG 样本迁入正式测试;参考 `tmp/tui/test/terminal-image.test.ts` 的大量协议负例、环境能力检测和 WebP/JPEG/GIF 样本仍建议后续拆成专门测试类
|
||||
|
||||
### 10. 工程化和发布完整性
|
||||
|
||||
|
||||
@@ -1,160 +0,0 @@
|
||||
using TinyTUI;
|
||||
using TinyTUI.Components;
|
||||
using TinyTUI.Autocomplete;
|
||||
using TinyTUI.Input;
|
||||
using TinyTUI.Rendering;
|
||||
using TinyTUI.Stdout;
|
||||
using TinyTUI.Terminal.Images;
|
||||
using TinyTUI.Text;
|
||||
using System.Text;
|
||||
|
||||
var measurer = new TerminalTextMeasurer();
|
||||
|
||||
var input = new Input(measurer) { Prompt = "> " };
|
||||
AssertFalse(input.Render(20)[0].Contains(CursorMarker.Marker, StringComparison.Ordinal), "unfocused input marker");
|
||||
input.Focused = true;
|
||||
AssertTrue(input.Render(20)[0].Contains(CursorMarker.Marker, StringComparison.Ordinal), "focused input marker");
|
||||
|
||||
var editor = new Editor(measurer) { Placeholder = "type here" };
|
||||
AssertFalse(editor.Render(20)[0].Contains(CursorMarker.Marker, StringComparison.Ordinal), "unfocused editor marker");
|
||||
editor.Focused = true;
|
||||
AssertTrue(editor.Render(20)[0].Contains(CursorMarker.Marker, StringComparison.Ordinal), "focused editor marker");
|
||||
|
||||
var list = new SelectList(measurer);
|
||||
list.SetItems(["alpha", "beta"]);
|
||||
AssertFalse(list.Render(20)[0].Contains(CursorMarker.Marker, StringComparison.Ordinal), "unfocused select marker");
|
||||
list.Focused = true;
|
||||
AssertTrue(list.Render(20)[0].Contains(CursorMarker.Marker, StringComparison.Ordinal), "focused select marker");
|
||||
|
||||
AssertEqual(3, new Spacer(3).Render(10).Count, "spacer line count");
|
||||
AssertEqual(0, new Spacer(-1).Render(10).Count, "spacer clamps negative lines");
|
||||
|
||||
var truncated = new TruncatedText("abcdef\nignored", measurer)
|
||||
{
|
||||
PaddingX = 1,
|
||||
PaddingY = 1,
|
||||
};
|
||||
var truncatedLines = truncated.Render(6);
|
||||
AssertEqual(3, truncatedLines.Count, "truncated text vertical padding");
|
||||
AssertEqual(6, measurer.GetWidth(truncatedLines[1]), "truncated text padded width");
|
||||
AssertTrue(truncatedLines[1].Contains("abc", StringComparison.Ordinal), "truncated text first line only");
|
||||
|
||||
var loader = new CancellableLoader(keybindings: KeybindingRegistry.CreateDefault());
|
||||
var canceled = false;
|
||||
loader.OnCanceled = () => canceled = true;
|
||||
loader.HandleInput(new TuiInputEvent(TuiInputEventKind.Key, KeyNames.Escape));
|
||||
AssertTrue(loader.IsCanceled, "cancellable loader token");
|
||||
AssertTrue(canceled, "cancellable loader callback");
|
||||
|
||||
var autocompleteEditor = new Editor(measurer)
|
||||
{
|
||||
AutocompleteProvider = new SlashCommandAutocompleteProvider(
|
||||
[
|
||||
new SlashCommand("help", "show help"),
|
||||
new SlashCommand("history", "show history"),
|
||||
]),
|
||||
};
|
||||
autocompleteEditor.HandleInput(new TuiInputEvent(TuiInputEventKind.Text, "/h"));
|
||||
AssertTrue(autocompleteEditor.IsAutocompleteActive, "slash command autocomplete active");
|
||||
AssertTrue(autocompleteEditor.RenderAutocomplete(40).Any(line => line.Contains("help", StringComparison.Ordinal)), "slash command autocomplete render");
|
||||
autocompleteEditor.HandleInput(new TuiInputEvent(TuiInputEventKind.Key, KeyNames.Down));
|
||||
autocompleteEditor.HandleInput(new TuiInputEvent(TuiInputEventKind.Key, KeyNames.Tab));
|
||||
AssertEqual("/history ", autocompleteEditor.Value, "slash command autocomplete confirm");
|
||||
AssertFalse(autocompleteEditor.IsAutocompleteActive, "slash command autocomplete closes after confirm");
|
||||
|
||||
autocompleteEditor.Value = "/h";
|
||||
autocompleteEditor.HandleInput(new TuiInputEvent(TuiInputEventKind.Key, KeyNames.Tab));
|
||||
AssertTrue(autocompleteEditor.IsAutocompleteActive, "forced autocomplete active");
|
||||
autocompleteEditor.HandleInput(new TuiInputEvent(TuiInputEventKind.Key, KeyNames.Escape));
|
||||
AssertFalse(autocompleteEditor.IsAutocompleteActive, "autocomplete cancel");
|
||||
AssertEqual("/h", autocompleteEditor.Value, "autocomplete cancel keeps text");
|
||||
|
||||
var imageService = new TerminalImageService();
|
||||
imageService.SetCapabilities(new TerminalCapabilities(ImageProtocol.Kitty, true, true));
|
||||
imageService.SetCellDimensions(new CellDimensions(10, 10));
|
||||
var cellSize = imageService.CalculateCellSize(new ImageDimensions(20, 20), 2);
|
||||
AssertEqual(new ImageCellSize(2, 2), cellSize, "image cell size");
|
||||
|
||||
var pngData = Convert.FromBase64String("iVBORw0KGgoAAAANSUhEUgAAAAIAAAADCAIAAADZrBkAAAAACXBIWXMAAAsTAAALEwEAmpwYAAAAFElEQVR4nGNgYGBgYGBgYGBgAAAABQABJzQnCgAAAABJRU5ErkJggg==");
|
||||
AssertEqual(new ImageDimensions(2, 3), imageService.TryGetDimensions(pngData, "image/png"), "png dimensions");
|
||||
|
||||
var image = new Image([1, 2, 3, 4], "image/png", imageService: imageService, dimensions: new ImageDimensions(20, 20))
|
||||
{
|
||||
MaxWidthCells = 2,
|
||||
};
|
||||
var imageLines = image.Render(4);
|
||||
AssertEqual(2, imageLines.Count, "kitty image line count");
|
||||
AssertTrue(image.ImageId is > 0, "kitty image id allocated");
|
||||
AssertTrue(imageLines[0].StartsWith("\e_G", StringComparison.Ordinal), "kitty image sequence");
|
||||
AssertTrue(imageLines[0].Contains(",C=1,", StringComparison.Ordinal), "kitty image no cursor movement");
|
||||
AssertTrue(TerminalImageService.IsImageLine($"prefix {imageLines[0]} suffix"), "image line detection anywhere");
|
||||
|
||||
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];
|
||||
AssertTrue(fallback.Contains("[Image: photo.jpg [image/jpeg] 8x9]", StringComparison.Ordinal), "image fallback");
|
||||
|
||||
var fakeOutput = new FakeTerminalOutput();
|
||||
var renderer = new DifferentialRenderer(
|
||||
fakeOutput,
|
||||
measurer,
|
||||
new RenderPipelineOptions { ThrowOnWidthOverflow = true, UseSynchronizedOutput = false });
|
||||
var longImageLine = $"Read image file \e]1337;File=inline=1:{new string('A', 200)}\a";
|
||||
renderer.Render([longImageLine], new TerminalSize(20, 5));
|
||||
renderer.Render(["plain"], new TerminalSize(20, 5));
|
||||
|
||||
fakeOutput.Buffer.Clear();
|
||||
var kittyLine = "\e_Ga=T,f=100,q=2,C=1,c=1,r=1,i=42;AAAA\e\\";
|
||||
renderer.Render([kittyLine], new TerminalSize(20, 5));
|
||||
fakeOutput.Buffer.Clear();
|
||||
renderer.Render(["changed"], new TerminalSize(20, 5));
|
||||
AssertTrue(fakeOutput.Buffer.ToString().Contains(TerminalImageService.DeleteKittyImage(42), StringComparison.Ordinal), "kitty image cleanup on diff");
|
||||
|
||||
Console.WriteLine("TinyTUI component checks passed");
|
||||
|
||||
static void AssertEqual<T>(T expected, T actual, string name)
|
||||
{
|
||||
if (!EqualityComparer<T>.Default.Equals(expected, actual))
|
||||
throw new InvalidOperationException($"{name}: expected '{expected}', actual '{actual}'");
|
||||
}
|
||||
|
||||
static void AssertTrue(bool condition, string name)
|
||||
{
|
||||
if (!condition)
|
||||
throw new InvalidOperationException($"{name}: assertion failed");
|
||||
}
|
||||
|
||||
static void AssertFalse(bool condition, string name) => AssertTrue(!condition, name);
|
||||
|
||||
/// <summary>
|
||||
/// 捕获渲染器写出的终端序列
|
||||
/// </summary>
|
||||
internal sealed class FakeTerminalOutput : ITerminalOutput
|
||||
{
|
||||
/// <summary>
|
||||
/// 获取已写出的终端缓冲
|
||||
/// </summary>
|
||||
public StringBuilder Buffer { get; } = new();
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Write(string value) => Buffer.Append(value);
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Flush()
|
||||
{
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void ClearScreen() => Buffer.Append("\e[2J\e[H");
|
||||
|
||||
/// <inheritdoc />
|
||||
public void HideCursor() => Buffer.Append("\e[?25l");
|
||||
|
||||
/// <inheritdoc />
|
||||
public void ShowCursor() => Buffer.Append("\e[?25h");
|
||||
|
||||
/// <inheritdoc />
|
||||
public void MoveCursorTo(int row, int column) => Buffer.Append($"\e[{row};{column}H");
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\TinyTUI\TinyTUI.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
@@ -23,6 +23,46 @@ public sealed class ComponentRegressionTests
|
||||
Assert.DoesNotContain(CursorMarker.Marker, editor.Render(20)[0], StringComparison.Ordinal);
|
||||
editor.Focused = true;
|
||||
Assert.Contains(CursorMarker.Marker, editor.Render(20)[0], StringComparison.Ordinal);
|
||||
|
||||
var list = new SelectList(_measurer);
|
||||
list.SetItems(["alpha", "beta"]);
|
||||
Assert.DoesNotContain(CursorMarker.Marker, list.Render(20)[0], StringComparison.Ordinal);
|
||||
list.Focused = true;
|
||||
Assert.Contains(CursorMarker.Marker, list.Render(20)[0], StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BasicLayoutComponentsClampAndPadRenderedLines()
|
||||
{
|
||||
Assert.Equal(3, new Spacer(3).Render(10).Count);
|
||||
Assert.Empty(new Spacer(-1).Render(10));
|
||||
|
||||
var truncated = new TruncatedText("abcdef\nignored", _measurer)
|
||||
{
|
||||
PaddingX = 1,
|
||||
PaddingY = 1,
|
||||
};
|
||||
var lines = truncated.Render(6);
|
||||
|
||||
Assert.Equal(3, lines.Count);
|
||||
Assert.Equal(6, _measurer.GetWidth(lines[1]));
|
||||
// TruncatedText 是单行组件 这里确认换行后的内容不会泄漏到渲染结果
|
||||
Assert.Contains("abc", lines[1], StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("ignored", string.Concat(lines), StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CancellableLoaderCancelsTokenAndRunsCallback()
|
||||
{
|
||||
using var loader = new CancellableLoader(keybindings: KeybindingRegistry.CreateDefault());
|
||||
var canceled = false;
|
||||
loader.OnCanceled = () => canceled = true;
|
||||
|
||||
loader.HandleInput(new TuiInputEvent(TuiInputEventKind.Key, KeyNames.Escape));
|
||||
|
||||
Assert.True(loader.IsCanceled);
|
||||
Assert.True(loader.Token.IsCancellationRequested);
|
||||
Assert.True(canceled);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -60,6 +100,7 @@ public sealed class ComponentRegressionTests
|
||||
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));
|
||||
Assert.Equal(new ImageDimensions(2, 3), imageService.TryGetDimensions(Png2x3, "image/png"));
|
||||
|
||||
var image = new Image([1, 2, 3, 4], "image/png", imageService: imageService, dimensions: new ImageDimensions(20, 20))
|
||||
{
|
||||
@@ -80,4 +121,7 @@ public sealed class ComponentRegressionTests
|
||||
}.Render(80)[0];
|
||||
Assert.Contains("[Image: photo.jpg [image/jpeg] 8x9]", fallback, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private static readonly byte[] Png2x3 = Convert.FromBase64String(
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAIAAAADCAIAAADZrBkAAAAACXBIWXMAAAsTAAALEwEAmpwYAAAAFElEQVR4nGNgYGBgYGBgYGBgAAAABQABJzQnCgAAAABJRU5ErkJggg==");
|
||||
}
|
||||
|
||||
@@ -100,6 +100,20 @@ public sealed class DifferentialRendererTests
|
||||
Assert.True(deleteIndex < changedIndex);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LongITerm2ImageLineBypassesTextWidthOverflow()
|
||||
{
|
||||
var output = new VirtualTerminalOutput(20, 5);
|
||||
var renderer = CreateRenderer(output);
|
||||
var imageLine = $"Read image file \e]1337;File=inline=1:{new string('A', 200)}\a";
|
||||
|
||||
// 图像协议本身可能很长 但渲染器应按图像行处理而不是按可见文本宽度报错
|
||||
renderer.Render([imageLine], output.Size);
|
||||
renderer.Render(["plain"], output.Size);
|
||||
|
||||
Assert.Contains("plain", output.Buffer.ToString(), StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private static DifferentialRenderer CreateRenderer(VirtualTerminalOutput output)
|
||||
=> new(output, new TerminalTextMeasurer(), TestOptions);
|
||||
}
|
||||
|
||||
@@ -45,10 +45,19 @@ public sealed class TerminalTextMeasurerTests
|
||||
[Fact]
|
||||
public void WrapRestoresAnsiAndOsc8AcrossLines()
|
||||
{
|
||||
var plainWrapped = _measurer.Wrap("hello world this is a test", 10);
|
||||
Assert.True(plainWrapped.Count > 1);
|
||||
Assert.All(plainWrapped, line => Assert.True(_measurer.GetWidth(line) <= 10));
|
||||
|
||||
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 underlinedUrl = _measurer.Wrap($"read this thread \e[4mhttps://example.com/very/long/path/that/will/definitely/wrap\e[24m", 40);
|
||||
Assert.Equal("read this thread", underlinedUrl[0]);
|
||||
Assert.StartsWith("\e[4m", underlinedUrl[1], StringComparison.Ordinal);
|
||||
Assert.Contains(underlinedUrl.Take(underlinedUrl.Count - 1), line => line.EndsWith("\e[24m", StringComparison.Ordinal));
|
||||
|
||||
var hyperlink = "\e]8;;https://example.com\a0123456789\e]8;;\a";
|
||||
var hyperlinkWrapped = _measurer.Wrap(hyperlink, 6);
|
||||
Assert.True(hyperlinkWrapped.Count > 1);
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
using TinyTUI.Text;
|
||||
|
||||
var measurer = new TerminalTextMeasurer();
|
||||
|
||||
AssertEqual(5, measurer.GetWidth("\t\e[31m界\e[0m"), "tab and ansi width");
|
||||
AssertEqual(2, measurer.GetWidth("🇨"), "single regional indicator width");
|
||||
AssertEqual(2, measurer.GetWidth("🇨🇳"), "regional indicator pair width");
|
||||
AssertEqual(2, measurer.GetWidth("👨💻"), "zwj emoji width");
|
||||
AssertEqual(2, measurer.GetWidth("⚡️"), "variation selector emoji width");
|
||||
|
||||
var strictTabSlice = measurer.Slice("out 192M\t.pi/skill-tests/results-ha", 0, 10, strict: true);
|
||||
AssertEqual("out 192M", strictTabSlice.Text, "strict tab slice text");
|
||||
AssertEqual(8, strictTabSlice.Width, "strict tab slice width");
|
||||
AssertEqual(measurer.GetWidth(strictTabSlice.Text), strictTabSlice.Width, "strict tab slice measured width");
|
||||
|
||||
var afterTabSlice = measurer.Slice("out 192M\t.pi/skill-tests/results-ha", 13, 10, strict: true);
|
||||
AssertEqual("i/skill-te", afterTabSlice.Text, "slice after tab text");
|
||||
AssertEqual(measurer.GetWidth(afterTabSlice.Text), afterTabSlice.Width, "slice after tab measured width");
|
||||
|
||||
var ansiText = $"\e[31m{"hello ".Repeat(100)}\e[0m";
|
||||
var truncated = measurer.Truncate(ansiText, 20, "…");
|
||||
AssertTrue(measurer.GetWidth(truncated) <= 20, "ansi truncate width");
|
||||
AssertTrue(truncated.Contains("\e[31m", StringComparison.Ordinal), "ansi truncate keeps style prefix");
|
||||
AssertTrue(truncated.EndsWith("\e[0m…\e[0m", StringComparison.Ordinal), "ansi truncate brackets ellipsis");
|
||||
|
||||
var wideEllipsis = measurer.Truncate("abcdef", 2, "🙂");
|
||||
AssertEqual("\e[0m🙂\e[0m", wideEllipsis, "wide ellipsis clipping");
|
||||
AssertEqual(string.Empty, measurer.Truncate("abcdef", 1, "🙂"), "wide ellipsis too narrow");
|
||||
|
||||
var plainWrapped = measurer.Wrap("hello world this is a test", 10);
|
||||
AssertTrue(plainWrapped.Count > 1, "plain wrap line count");
|
||||
AssertTrue(plainWrapped.All(line => measurer.GetWidth(line) <= 10), "plain wrap width");
|
||||
|
||||
var redWrapped = measurer.Wrap($"\e[31mhello world this is red\e[0m", 10);
|
||||
AssertTrue(redWrapped.Skip(1).All(line => line.StartsWith("\e[31m", StringComparison.Ordinal)), "ansi wrap restores red");
|
||||
AssertTrue(redWrapped.Take(redWrapped.Count - 1).All(line => !line.EndsWith("\e[0m", StringComparison.Ordinal)), "ansi wrap avoids full reset before final");
|
||||
|
||||
var underlinedUrl = measurer.Wrap($"read this thread \e[4mhttps://example.com/very/long/path/that/will/definitely/wrap\e[24m", 40);
|
||||
AssertEqual("read this thread", underlinedUrl[0], "underline wrap keeps prefix unstyled");
|
||||
AssertTrue(underlinedUrl[1].StartsWith("\e[4m", StringComparison.Ordinal), "underline wrap starts style on url line");
|
||||
AssertTrue(underlinedUrl.Take(underlinedUrl.Count - 1).Any(line => line.EndsWith("\e[24m", StringComparison.Ordinal)), "underline wrap closes line");
|
||||
|
||||
var hyperlink = "\e]8;;https://example.com\a0123456789\e]8;;\a";
|
||||
var hyperlinkWrapped = measurer.Wrap(hyperlink, 6);
|
||||
AssertTrue(hyperlinkWrapped.Count > 1, "osc8 wrap line count");
|
||||
AssertTrue(hyperlinkWrapped.All(line => line.Contains("\e]8;;https://example.com\a", StringComparison.Ordinal)), "osc8 wrap reopens hyperlink with bel");
|
||||
AssertTrue(hyperlinkWrapped.Take(hyperlinkWrapped.Count - 1).All(line => line.EndsWith("\e]8;;\a", StringComparison.Ordinal)), "osc8 wrap closes hyperlink with bel");
|
||||
|
||||
Console.WriteLine("TinyTUI text checks passed");
|
||||
|
||||
static void AssertEqual<T>(T expected, T actual, string name)
|
||||
{
|
||||
if (!EqualityComparer<T>.Default.Equals(expected, actual))
|
||||
throw new InvalidOperationException($"{name}: expected '{expected}', actual '{actual}'");
|
||||
}
|
||||
|
||||
static void AssertTrue(bool condition, string name)
|
||||
{
|
||||
if (!condition)
|
||||
throw new InvalidOperationException($"{name}: assertion failed");
|
||||
}
|
||||
|
||||
internal static class StringExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// 重复字符串用于构造长文本测试输入
|
||||
/// </summary>
|
||||
public static string Repeat(this string value, int count) => string.Concat(Enumerable.Repeat(value, count));
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\TinyTUI\TinyTUI.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
@@ -10,7 +10,5 @@
|
||||
<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="benchmark/TinyTUI.Benchmarks/TinyTUI.Benchmarks.csproj" Id="f6b9b8d7-44aa-4786-a15d-5d341a4b77f5" />
|
||||
<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>
|
||||
|
||||
Reference in New Issue
Block a user