diff --git a/TODO.md b/TODO.md index 7a45006..e717f34 100644 --- a/TODO.md +++ b/TODO.md @@ -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 从本地骨架变成可维护、可发布、可集成的类库 diff --git a/test/TinyTUI.Tests/Components/ComponentRegressionTests.cs b/test/TinyTUI.Tests/Components/ComponentRegressionTests.cs new file mode 100644 index 0000000..61bc2e0 --- /dev/null +++ b/test/TinyTUI.Tests/Components/ComponentRegressionTests.cs @@ -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); + } +} diff --git a/test/TinyTUI.Tests/GlobalUsings.cs b/test/TinyTUI.Tests/GlobalUsings.cs new file mode 100644 index 0000000..c802f44 --- /dev/null +++ b/test/TinyTUI.Tests/GlobalUsings.cs @@ -0,0 +1 @@ +global using Xunit; diff --git a/test/TinyTUI.Tests/Input/InputInfrastructureTests.cs b/test/TinyTUI.Tests/Input/InputInfrastructureTests.cs new file mode 100644 index 0000000..d537882 --- /dev/null +++ b/test/TinyTUI.Tests/Input/InputInfrastructureTests.cs @@ -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> + { + [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); + } +} diff --git a/test/TinyTUI.Tests/Overlay/OverlayManagerTests.cs b/test/TinyTUI.Tests/Overlay/OverlayManagerTests.cs new file mode 100644 index 0000000..28a90ea --- /dev/null +++ b/test/TinyTUI.Tests/Overlay/OverlayManagerTests.cs @@ -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 lines) : IComponent + { + /// + public IReadOnlyList Render(int width) => lines; + + /// + public void Invalidate() + { + } + } +} diff --git a/test/TinyTUI.Tests/Rendering/DifferentialRendererTests.cs b/test/TinyTUI.Tests/Rendering/DifferentialRendererTests.cs new file mode 100644 index 0000000..dde3477 --- /dev/null +++ b/test/TinyTUI.Tests/Rendering/DifferentialRendererTests.cs @@ -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); +} diff --git a/test/TinyTUI.Tests/TestInfrastructure/AnsiAssert.cs b/test/TinyTUI.Tests/TestInfrastructure/AnsiAssert.cs new file mode 100644 index 0000000..f86b43c --- /dev/null +++ b/test/TinyTUI.Tests/TestInfrastructure/AnsiAssert.cs @@ -0,0 +1,24 @@ +using System.Text.RegularExpressions; + +namespace TinyTUI.Tests.TestInfrastructure; + +/// +/// 提供 ANSI 感知的测试断言辅助 +/// +internal static partial class AnsiAssert +{ + /// + /// 移除常见 ANSI 控制序列后比较可见文本 + /// + public static void EqualVisible(string expected, string actual) + => Assert.Equal(expected, AnsiRegex().Replace(actual, string.Empty)); + + /// + /// 移除常见 ANSI 控制序列后判断可见文本是否包含指定片段 + /// + 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(); +} diff --git a/test/TinyTUI.Tests/TestInfrastructure/VirtualTerminalOutput.cs b/test/TinyTUI.Tests/TestInfrastructure/VirtualTerminalOutput.cs new file mode 100644 index 0000000..d378ed5 --- /dev/null +++ b/test/TinyTUI.Tests/TestInfrastructure/VirtualTerminalOutput.cs @@ -0,0 +1,70 @@ +using System.Text; +using TinyTUI.Stdout; + +namespace TinyTUI.Tests.TestInfrastructure; + +/// +/// 捕获渲染器写出的终端序列并提供可模拟的窗口尺寸 +/// +internal sealed class VirtualTerminalOutput(int columns = 80, int rows = 24) : ITerminalOutput +{ + private readonly List _writes = []; + + /// + /// 获取当前模拟终端尺寸 + /// + public TerminalSize Size { get; private set; } = new(columns, rows); + + /// + /// 获取完整输出缓冲 + /// + public StringBuilder Buffer { get; } = new(); + + /// + /// 获取每一次写入的原始片段 + /// + public IReadOnlyList Writes => _writes; + + /// + /// 调整模拟终端尺寸 + /// + public void Resize(int columns, int rows) => Size = new TerminalSize(columns, rows); + + /// + /// 清空已捕获的输出 + /// + public void ClearWrites() + { + _writes.Clear(); + Buffer.Clear(); + } + + /// + /// 判断完整输出缓冲是否包含指定序列 + /// + public bool Contains(string value) => Buffer.ToString().Contains(value, StringComparison.Ordinal); + + /// + public void Write(string value) + { + _writes.Add(value); + Buffer.Append(value); + } + + /// + public void Flush() + { + } + + /// + public void ClearScreen() => Write("\e[2J\e[H"); + + /// + public void HideCursor() => Write("\e[?25l"); + + /// + public void ShowCursor() => Write("\e[?25h"); + + /// + public void MoveCursorTo(int row, int column) => Write($"\e[{row};{column}H"); +} diff --git a/test/TinyTUI.Tests/Text/TerminalTextMeasurerTests.cs b/test/TinyTUI.Tests/Text/TerminalTextMeasurerTests.cs new file mode 100644 index 0000000..97efa48 --- /dev/null +++ b/test/TinyTUI.Tests/Text/TerminalTextMeasurerTests.cs @@ -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)); + } +} diff --git a/test/TinyTUI.Tests/TinyTUI.Tests.csproj b/test/TinyTUI.Tests/TinyTUI.Tests.csproj new file mode 100644 index 0000000..da28572 --- /dev/null +++ b/test/TinyTUI.Tests/TinyTUI.Tests.csproj @@ -0,0 +1,18 @@ + + + + + + + + + + + + net10.0 + enable + enable + false + + + diff --git a/ttui.slnx b/ttui.slnx index f504e04..85b48f9 100644 --- a/ttui.slnx +++ b/ttui.slnx @@ -10,5 +10,6 @@ +