Guidelines for writing unit tests in the Hex1b TUI library. Use when creating new tests for widgets, nodes, or terminal functionality.
This skill provides guidelines for AI agents writing unit tests for the Hex1b TUI library. It outlines the preferred testing approach, patterns, and anti-patterns to avoid. Tests use MSTest 4 with MSTest.Sdk/4.2.3, OutputType=Exe, and Microsoft.Testing.Platform (MTP). global.json configures dotnet test to use MTP; test projects can also run in executable mode with dotnet run --project tests/SomeProject/.
Hex1bTerminal.CreateBuilder() to create complete terminal environments.WithHex1bApp() for TUI functionality tests - This wires up the full app lifecycleCellPatternSearcher and color assertions for render verification| Test Type | Approach |
|-----------|----------|
| Widget behavior, layout, rendering | Full stack with Hex1bTerminal.CreateBuilder() |
| Input handling, focus navigation | Full stack with WithHex1bApp() |
| Low-level APIs (Surface, SurfaceCell) | Test in isolation (dependencies of Hex1bApp) |
| Color/theme verification | Full stack with snapshot color assertions |
Test files import Microsoft.VisualStudio.TestTools.UnitTesting. The Hex1b.Testing namespace is global-using'd via Directory.Build.props for helpers such as TestSeq. For test output, add public TestContext TestContext { get; set; } = null!; and call TestContext.WriteLine(...). Suppressed MSTest analyzers are MSTEST0014, MSTEST0030, MSTEST0032, and MSTEST0057.
This is the preferred pattern for most tests:
using Microsoft.VisualStudio.TestTools.UnitTesting;
[TestClass]
public class WidgetNameTests
{
[TestMethod]
public async Task WidgetName_Scenario_ExpectedBehavior()
{
// Arrange - Build the terminal with the app
await using var terminal = Hex1bTerminal.CreateBuilder()
.WithHex1bApp((app, options) => ctx => new VStackWidget([
new TextBlockWidget("Hello"),
new ButtonWidget("Click Me")
]))
.WithHeadless()
.WithDimensions(80, 24)
.Build();
// Act & Assert - Use input sequencer with WaitUntil
var snapshot = await new Hex1bTerminalInputSequenceBuilder()
.WaitUntil(s => s.ContainsText("Hello"), TimeSpan.FromSeconds(2), "initial render")
.Down() // Navigate to button
.WaitUntil(s => s.ContainsText("> Click Me"), TimeSpan.FromSeconds(2), "button focused")
.Capture("focused-button")
.Ctrl().Key(Hex1bKey.C)
.Build()
.ApplyWithCaptureAsync(terminal, TestContext.Current.CancellationToken);
// Assert (often redundant if WaitUntil already verified)
Assert.IsTrue(snapshot.ContainsText("> Click Me"));
}
}
await using var terminal - Ensures proper disposal.WithHeadless() - No actual terminal output (CI-safe).WithDimensions(80, 24) - Explicit terminal sizeWaitUntil before assertions - Prevents timing issues.Capture("name") - Saves SVG/HTML for debuggingCtrl().Key(Hex1bKey.C) - Clean exitawait new Hex1bTerminalInputSequenceBuilder()
.WaitUntil(s => s.ContainsText("Item 1"), TimeSpan.FromSeconds(2), "list rendered")
.Down()
.WaitUntil(s => s.ContainsText("> Item 2"), TimeSpan.FromSeconds(2), "moved to item 2")
.Down()
.WaitUntil(s => s.ContainsText("> Item 3"), TimeSpan.FromSeconds(2), "moved to item 3")
.Capture("navigation-result")
.Ctrl().Key(Hex1bKey.C)
.Build()
.ApplyWithCaptureAsync(terminal, TestContext.Current.CancellationToken);
await new Hex1bTerminalInputSequenceBuilder()
.WaitUntil(s => s.ContainsText("Name:"), TimeSpan.FromSeconds(2), "form rendered")
.Type("John Doe")
.WaitUntil(s => s.ContainsText("John Doe"), TimeSpan.FromSeconds(2), "text entered")
.Capture("text-input")
.Ctrl().Key(Hex1bKey.C)
.Build()
.ApplyWithCaptureAsync(terminal, TestContext.Current.CancellationToken);
await new Hex1bTerminalInputSequenceBuilder()
.WaitUntil(s => s.ContainsText("Ready"), TimeSpan.FromSeconds(2), "app ready")
.Ctrl().Key(Hex1bKey.S) // Ctrl+S
.WaitUntil(s => s.ContainsText("Saved"), TimeSpan.FromSeconds(2), "save completed")
.Capture("after-save")
.Ctrl().Key(Hex1bKey.C)
.Build()
.ApplyWithCaptureAsync(terminal, TestContext.Current.CancellationToken);
For precise cell-level assertions:
// Find a specific character
var pattern = new CellPatternSearcher().Find('█');
var result = pattern.Search(snapshot);
Assert.IsTrue(result.HasMatches);
Assert.AreEqual(expectedX, result.First!.Start.X);
// Find with regex pattern
var pattern = new CellPatternSearcher().FindPattern(@"Count:\s*\d+");
var result = pattern.Search(snapshot);
Assert.IsTrue(result.HasMatches);
// Find with predicate
var pattern = new CellPatternSearcher()
.Find(ctx => char.IsDigit(ctx.Cell.Character[0]));
var result = pattern.Search(snapshot);
Assert.AreEqual(3, result.Count);
For verifying themed/styled output:
// Check if any cell has a specific background color
Assert.IsTrue(snapshot.HasBackgroundColor(Hex1bColor.FromRgb(0, 100, 200)),
"Button should have blue background");
// Check if any cell has a specific foreground color
Assert.IsTrue(snapshot.HasForegroundColor(Hex1bColor.FromRgb(255, 255, 255)),
"Text should be white");
// Get color at specific position
var bgColor = snapshot.GetBackgroundColor(10, 5);
Assert.AreEqual(Hex1bColor.FromRgb(255, 0, 0), bgColor);
// Check uniform row background
Assert.IsTrue(snapshot.HasUniformBackgroundColor(0, Hex1bColor.FromRgb(50, 50, 50)),
"Header row should have dark background");
| Method | Purpose |
|--------|---------|
| HasBackgroundColor() | Any cell has a background color |
| HasBackgroundColor(Hex1bColor) | Any cell has specific background |
| HasForegroundColor() | Any cell has a foreground color |
| HasForegroundColor(Hex1bColor) | Any cell has specific foreground |
| GetBackgroundColor(x, y) | Get background at position |
| GetForegroundColor(x, y) | Get foreground at position |
| HasUniformBackgroundColor(y, color) | All cells in row have same background |
| VisualizeBackgroundColors() | Debug helper with visual representation |
📘 See the
test-fixerskill for detailed diagnosis and fixes when tests become flaky.
// BROKEN: Waits for partial content, but rest of screen may not be rendered
await new Hex1bTerminalInputSequenceBuilder()
.WaitUntil(s => s.ContainsText("Header"), TimeSpan.FromSeconds(2)) // ❌ Only checks header
.Capture("screen")
.Build()
.ApplyAsync(terminal, ct);
// Assertion on footer may fail - it wasn't part of the WaitUntil!
Assert.IsTrue(snapshot.ContainsText("Footer"));
Problem: Rendering is inherently async. Finding "Header" doesn't guarantee "Footer" has rendered yet. This is especially problematic when testing other terminal frameworks (like Spectre Console) which may drop input if they're not ready to receive it.
Fix: Over-specify the WaitUntil condition to ensure everything you need is present:
// ✅ Wait for ALL content you'll assert on
await new Hex1bTerminalInputSequenceBuilder()
.WaitUntil(s => s.ContainsText("Header") && s.ContainsText("Footer"),
TimeSpan.FromSeconds(2), "full screen rendered")
.Capture("screen")
.Build()
.ApplyAsync(terminal, ct);
Guideline: If you're going to assert on specific screen content, include it in the WaitUntil condition. Don't assume the rest of the screen is ready just because one part appeared.
// BROKEN: Snapshot taken AFTER Ctrl+C clears the buffer
var snapshot = await new Hex1bTerminalInputSequenceBuilder()
.WaitUntil(s => s.ContainsText("Hello"), TimeSpan.FromSeconds(2))
.Capture("final")
.Ctrl().Key(Hex1bKey.C) // Buffer may be cleared before snapshot!
.Build()
.ApplyWithCaptureAsync(terminal, ct);
Assert.IsTrue(snapshot.ContainsText("Hello")); // ❌ May fail on Linux CI
Fix: The WaitUntil already verified the content. If you need to assert, the WaitUntil serves as the assertion.
// BROKEN: No wait for render after Down()
await new Hex1bTerminalInputSequenceBuilder()
.WaitUntil(s => s.ContainsText("Item 1"), TimeSpan.FromSeconds(2))
.Down()
.Capture("after-down") // ❌ Render may not be complete!
.Ctrl().Key(Hex1bKey.C)
.Build()
.ApplyAsync(terminal, ct);
Fix: Always add WaitUntil after any action that changes state:
.Down()
.WaitUntil(s => s.ContainsText("> Item 2"), TimeSpan.FromSeconds(2), "moved down")
.Capture("after-down")
// BROKEN: Fixed delay may not be long enough on slow CI
await terminal.SendKeyAsync(Hex1bKey.Enter);
await Task.Delay(100); // ❌ Arbitrary delay
Assert.IsTrue(eventFired);
Fix: Use TaskCompletionSource to signal completion:
var eventSignal = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
// In event handler:
eventSignal.TrySetResult();
// In test:
await eventSignal.Task.WaitAsync(TimeSpan.FromSeconds(2), ct);
// AVOID: Too many layers of abstraction
var result = await TestHelpers.CreateTerminalAndRunScenario(
widgets: WidgetFactory.CreateStandardList(),
actions: ActionBuilder.NavigateAndSelect(3),
assertions: AssertionBuilder.SelectedItem("Item 3")
);
Prefer: Simple, linear, self-contained tests. Repetition is acceptable and helps AI agents understand patterns.
Let the server reserve its listening port atomically. Random port selection and
probing a free port before closing the probe both allow collisions under parallel
execution. For Kestrel fixtures, configure
options.Listen(IPAddress.Loopback, 0), await app.StartAsync(), then derive client
URIs from TestSeq.Single(app.Urls). For example:
var wsUri = new UriBuilder(TestSeq.Single(app.Urls))
{
Scheme = "ws",
Path = "/ws/attach"
}.Uri;
Keep the application owned by the fixture before awaiting startup so cleanup can
dispose it even if startup fails. Cover independently reachable concurrent servers
and listener release; see RemoteTerminalWorkloadAdapterTests. Do not mask port
collisions with sleeps, retries, or disabled parallelism.
For starvation regressions, keep the producer active while asserting input, timer,
or shutdown progress. A finite output burst followed by app.Invalidate() can
hide a lost wakeup. Use TestWidget.OnRender to place events at a known frame:
var observer = new TestWidget().OnRender(args =>
{
app.Invalidate(); // Renew on every frame, including while input is pending.
if (args.RenderCount == 3)
workload.SendKey(Hex1bKey.A);
});
This fragment assumes captured app and workload references. Pair it with
changing visible content so frames actually render, a bounded completion signal,
and cancellation in finally. See Hex1bAppSchedulingTests for full examples.
Check input ordering with coalescing both enabled and disabled. For exact cadence,
use the app's internal FrameTimeProvider with a fake clock, wait for timer
registration before advancing it, and assert both the requested delay and elapsed
virtual time. A fixed wall-clock tolerance around Task.Delay is not portable
across CI runners. Keep real-time full-stack tests alongside deterministic pacing
coverage rather than widening timing tolerances.
For nested output races, gate later child redraws: their extra notifications can
mask a lost first-frame notification. These controlled cases supplement, rather
than prove, responsiveness under arbitrary real-world load.
Process exit and terminal output consumption are separate events. For a controlled
regression, start a StandardProcessWorkloadAdapter and await its exit before
constructing a terminal with an already-completed run callback. Gate the first
presentation write with a TaskCompletionSource: RunAsync and lifecycle
completion must remain pending until the gate is released and both stdout and
stderr reach the snapshot. See StandardProcessOutputTests for raw and filtered
output, cancellation, and pump-failure cases. Keep ordinary WithProcess tests
alongside this ordering test; do not keep a one-shot child alive or wait for visible
output before awaiting RunAsync in a drain regression, since that hides the race.
For echo/transport tests, use an already-available executable (cmd /d /c echo on
Windows, /bin/echo on Unix). Runtime-compiling a temporary C# program with
dotnet run puts SDK startup and compilation inside the output deadline.
When writing tests for widgets, consider all the dimensions that affect behavior. Each widget should have tests covering these scenarios:
Widgets must work across different terminal sizes. Use MSTest DataRow for parameterized cases:
[TestMethod]
[DataRow(40, 10)] // Minimum realistic size
[DataRow(80, 24)] // Standard terminal
[DataRow(120, 40)] // Large terminal
[DataRow(200, 60)] // Very large terminal
public async Task ListWidget_VariousTerminalSizes_RendersCorrectly(int width, int height)
{
await using var terminal = Hex1bTerminal.CreateBuilder()
.WithHex1bApp((app, options) => ctx => new ListWidget(["Item 1", "Item 2", "Item 3"]))
.WithHeadless()
.WithDimensions(width, height)
.Build();
await new Hex1bTerminalInputSequenceBuilder()
.WaitUntil(s => s.ContainsText("Item 1"), TimeSpan.FromSeconds(2), "list rendered")
.Capture($"list-{width}x{height}")
.Ctrl().Key(Hex1bKey.C)
.Build()
.ApplyAsync(terminal, TestContext.Current.CancellationToken);
}
Key questions to answer:
Widgets behave differently depending on their parent container. Test inside various layouts:
[TestMethod]
public async Task ProgressWidget_InsideBorder_RendersWithCorrectWidth()
{
await using var terminal = Hex1bTerminal.CreateBuilder()
.WithHex1bApp((app, options) => ctx => new BorderWidget(
new ProgressWidget { Value = 50, Maximum = 100 },
title: "Loading"
))
.WithHeadless()
.WithDimensions(60, 10)
.Build();
await new Hex1bTerminalInputSequenceBuilder()
.WaitUntil(s => s.ContainsText("Loading"), TimeSpan.FromSeconds(2), "border rendered")
.Capture("progress-in-border")
.Ctrl().Key(Hex1bKey.C)
.Build()
.ApplyAsync(terminal, TestContext.Current.CancellationToken);
}
[TestMethod]
public async Task Button_InsideHStack_SharesSpaceCorrectly()
{
await using var terminal = Hex1bTerminal.CreateBuilder()
.WithHex1bApp((app, options) => ctx => new HStackWidget([
new ButtonWidget("Cancel"),
new ButtonWidget("OK")
]))
.WithHeadless()
.WithDimensions(40, 5)
.Build();
await new Hex1bTerminalInputSequenceBuilder()
.WaitUntil(s => s.ContainsText("Cancel") && s.ContainsText("OK"), TimeSpan.FromSeconds(2))
.Capture("buttons-in-hstack")
.Ctrl().Key(Hex1bKey.C)
.Build()
.ApplyAsync(terminal, TestContext.Current.CancellationToken);
}
Common container scenarios to test:
VStackWidget (vertical stacking)HStackWidget (horizontal stacking)BorderWidget (reduced available space)ScrollPanelWidget (scrollable content)SplitterWidget (resizable panes)Verify that widgets respect theme colors and can be customized:
[TestMethod]
public async Task Button_WithCustomTheme_UsesThemeColors()
{
var customTheme = new Hex1bTheme("TestTheme")
.Set(ButtonTheme.BackgroundColor, Hex1bColor.FromRgb(255, 0, 0))
.Set(ButtonTheme.ForegroundColor, Hex1bColor.FromRgb(255, 255, 255));
await using var terminal = Hex1bTerminal.CreateBuilder()
.WithHex1bApp((app, options) =>
{
options.Theme = customTheme;
return ctx => new ButtonWidget("Test Button");
})
.WithHeadless()
.WithDimensions(40, 5)
.Build();
var snapshot = await new Hex1bTerminalInputSequenceBuilder()
.WaitUntil(s => s.ContainsText("Test Button"), TimeSpan.FromSeconds(2))
.Capture("themed-button")
.Ctrl().Key(Hex1bKey.C)
.Build()
.ApplyWithCaptureAsync(terminal, TestContext.Current.CancellationToken);
Assert.IsTrue(snapshot.HasBackgroundColor(Hex1bColor.FromRgb(255, 0, 0)),
"Button should have red background from theme");
Assert.IsTrue(snapshot.HasForegroundColor(Hex1bColor.FromRgb(255, 255, 255)),
"Button should have white text from theme");
}
[TestMethod]
public async Task Button_FocusedState_UsesFocusedThemeColors()
{
await using var terminal = Hex1bTerminal.CreateBuilder()
.WithHex1bApp((app, options) => ctx => new VStackWidget([
new TextBlockWidget("Header"),
new ButtonWidget("Focusable Button")
]))
.WithHeadless()
.WithDimensions(40, 5)
.Build();
var snapshot = await new Hex1bTerminalInputSequenceBuilder()
.WaitUntil(s => s.ContainsText("Focusable Button"), TimeSpan.FromSeconds(2))
.Tab() // Focus the button
.WaitUntil(s => s.ContainsText(">"), TimeSpan.FromSeconds(2), "button focused")
.Capture("focused-button-theme")
.Ctrl().Key(Hex1bKey.C)
.Build()
.ApplyWithCaptureAsync(terminal, TestContext.Current.CancellationToken);
// Verify focused state uses different colors than unfocused
Assert.IsTrue(snapshot.HasBackgroundColor(), "Focused button should have background color");
}
Theming scenarios to test:
For comprehensive widget coverage, consider this matrix:
| Dimension | Variations to Test | |-----------|-------------------| | Terminal Size | Minimum (40×10), Standard (80×24), Large (120×40), Very Large (200×60) | | Container | Root, VStack, HStack, Border, Scroll, Splitter, Nested | | Theme | Default, Custom colors, Focused state, Disabled state | | Content | Empty, Minimal, Typical, Maximum/overflow | | State | Initial, After interaction, Edge cases |
Not every widget needs every combination, but consider which dimensions are relevant for the widget's behavior.
For APIs that are dependencies of Hex1bApp (like Surface), test in isolation:
[TestMethod]
public void Surface_WriteText_SetsCorrectCells()
{
// Arrange
var surface = new Surface(80, 24);
// Act
surface.WriteText(0, 0, "Hello");
// Assert
Assert.AreEqual('H', surface[0, 0].Character[0]);
Assert.AreEqual('e', surface[1, 0].Character[0]);
Assert.AreEqual('l', surface[2, 0].Character[0]);
Assert.AreEqual('l', surface[3, 0].Character[0]);
Assert.AreEqual('o', surface[4, 0].Character[0]);
}
[TestMethod]
public void SurfaceCell_WithColor_PreservesColor()
{
// Arrange
var cell = new SurfaceCell('X', Hex1bColor.Red, Hex1bColor.Blue);
// Assert
Assert.AreEqual('X', cell.Character[0]);
Assert.AreEqual(Hex1bColor.Red, cell.Foreground);
Assert.AreEqual(Hex1bColor.Blue, cell.Background);
}
Follow MethodName_Scenario_ExpectedBehavior:
[TestMethod]
public async Task ListWidget_DownArrow_SelectsNextItem() { }
[TestMethod]
public async Task TextBox_TypeText_DisplaysInput() { }
[TestMethod]
public async Task Button_EnterKey_TriggersClickHandler() { }
[TestMethod]
public void Surface_Fill_SetsAllCellsInRegion() { }
When you discover a new testing pattern while writing tests:
This builds the body of knowledge available to AI agents working on the codebase.
Hex1bTerminal.CreateBuilder() with .WithHeadless().WithHex1bApp() for TUI functionality (unless testing low-level APIs)WaitUntil after every action that changes stateWaitUntil immediately before .Capture()WaitUntil)Ctrl().Key(Hex1bKey.C)MethodName_Scenario_ExpectedBehavior namingCellPatternSearcher for precise cell assertions when needednpx skills add mitchdenny/writing-unit-tests下载完整 Skill 目录,包含 SKILL.md 及所有相关文件
Search for places (restaurants, cafes, etc.) via Google Places API proxy on localhost.
Interact with GitHub using the `gh` CLI. Use `gh issue`, `gh pr`, `gh run`, and `gh api` for issues, PRs, CI runs, and advanced queries.
Create or update AgentSkills. Use when designing, structuring, or packaging skills with scripts, references, and assets.
Start voice calls via the OpenClaw voice-call plugin.
Notion API for creating and managing pages, databases, and blocks.
Gemini CLI for one-shot Q&A, summaries, and generation.
Category:developer