Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
32 changes: 32 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,38 @@ var mockChild = new Mock<IUIElement>();
mockChild.Setup(c => c.IsVisible).Returns(true);
```

### Render tests

Property round-tripping is not enough on its own. Every alignment combination of `TextElement`,
every titled `BorderElement` with the default `TitleAlignment`, and every `BorderStyle.None`
element once threw `NotImplementedException` from the render path, and the suite stayed green
throughout because no test called `Render`.

`RecordingConsoleProvider` (in `TUI.Test`) is the test double for this. It implements
`IConsoleProvider` and records every `WriteAt` call rather than drawing, so a test can assert
*what* was drawn and *where*:

```csharp
RecordingConsoleProvider provider = new();
element.Render(provider);
Assert.AreEqual(0, provider.WritesOf("abc").Single().Position.X);
```

Rendering to the real `SpectreConsoleProvider` in a test writes to the runner's console and
leaves the output unobservable, so use the recorder instead.

Any new element needs render coverage across the full matrix of whatever enum drives its
layout — that is exactly where the bug above lived. `TextElementTests` and
`BorderElementRenderTests` use `[DynamicData]` over `Enum.GetValues<T>()` so a newly added
enum member is covered automatically rather than silently skipped.

Two sizing traps when writing these:

- `Dimensions.WithoutPadding` floors at zero, and an element with an empty content area returns
from `OnRender` before drawing. An element must be larger than its own padding or it draws
nothing and the assertion fails for a reason unrelated to what is under test.
- `BorderElement` only draws its title when `Width > 4`, and draws no border at all below 2x2.

## File Headers

All source files require this copyright header:
Expand Down
10 changes: 8 additions & 2 deletions TUI.Core/Elements/Primitives/BorderElement.cs
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,13 @@ protected override void OnRender(IConsoleProvider provider)
return;
}

// BorderStyle.None means draw no border and no title. Children are still rendered by
// the base class after this method returns, so the element remains a usable container.
if (BorderStyle == BorderStyle.None)
{
return;
}

BorderCharacters chars = GetBorderCharacters(BorderStyle);

// Draw corners
Expand Down Expand Up @@ -150,7 +157,7 @@ protected override void OnRender(IConsoleProvider provider)
{
HorizontalAlignment.Center => position.X + Math.Max(1, (dimensions.Width - titleWithPadding.Length) / 2),
HorizontalAlignment.Right => position.X + Math.Max(1, dimensions.Width - titleWithPadding.Length - 1),
HorizontalAlignment.Left => throw new NotImplementedException(),
HorizontalAlignment.Left => position.X + 1,
_ => position.X + 1
};

Expand Down Expand Up @@ -197,7 +204,6 @@ private static BorderCharacters GetBorderCharacters(BorderStyle style)
BorderStyle.Thick => new BorderCharacters('┏', '┓', '┗', '┛', '━', '┃'),
BorderStyle.Ascii => new BorderCharacters('+', '+', '+', '+', '-', '|'),
BorderStyle.SingleLine => new BorderCharacters('┌', '┐', '└', '┘', '─', '│'),
BorderStyle.None => throw new NotImplementedException(),
_ => new BorderCharacters('┌', '┐', '└', '┘', '─', '│') // SingleLine
};
}
Expand Down
4 changes: 2 additions & 2 deletions TUI.Core/Elements/Primitives/TextElement.cs
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,7 @@
{
HorizontalAlignment.Center => baseX + Math.Max(0, (availableWidth - line.Length) / 2),
HorizontalAlignment.Right => baseX + Math.Max(0, availableWidth - line.Length),
HorizontalAlignment.Left => throw new NotImplementedException(),
HorizontalAlignment.Left => baseX,
_ => baseX
};
}
Expand All @@ -153,12 +153,12 @@
{
VerticalAlignment.Center => baseY + Math.Max(0, (availableHeight - totalLines) / 2),
VerticalAlignment.Bottom => baseY + Math.Max(0, availableHeight - totalLines),
VerticalAlignment.Top => throw new NotImplementedException(),
VerticalAlignment.Top => baseY,
_ => baseY
};
}

private static string[] WrapText(string text, int maxWidth)

Check warning on line 161 in TUI.Core/Elements/Primitives/TextElement.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

Refactor this method to reduce its Cognitive Complexity from 16 to the 15 allowed.

Check warning on line 161 in TUI.Core/Elements/Primitives/TextElement.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

Refactor this method to reduce its Cognitive Complexity from 16 to the 15 allowed.

Check warning on line 161 in TUI.Core/Elements/Primitives/TextElement.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

Refactor this method to reduce its Cognitive Complexity from 16 to the 15 allowed.

Check warning on line 161 in TUI.Core/Elements/Primitives/TextElement.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

Refactor this method to reduce its Cognitive Complexity from 16 to the 15 allowed.

Check warning on line 161 in TUI.Core/Elements/Primitives/TextElement.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

Refactor this method to reduce its Cognitive Complexity from 16 to the 15 allowed.

Check warning on line 161 in TUI.Core/Elements/Primitives/TextElement.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

Refactor this method to reduce its Cognitive Complexity from 16 to the 15 allowed.

Check warning on line 161 in TUI.Core/Elements/Primitives/TextElement.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

Refactor this method to reduce its Cognitive Complexity from 16 to the 15 allowed.

Check warning on line 161 in TUI.Core/Elements/Primitives/TextElement.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

Refactor this method to reduce its Cognitive Complexity from 16 to the 15 allowed.

Check warning on line 161 in TUI.Core/Elements/Primitives/TextElement.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

Refactor this method to reduce its Cognitive Complexity from 16 to the 15 allowed.

Check warning on line 161 in TUI.Core/Elements/Primitives/TextElement.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

Refactor this method to reduce its Cognitive Complexity from 16 to the 15 allowed.
{
if (maxWidth <= 0)
{
Expand Down
209 changes: 209 additions & 0 deletions TUI.Test/BorderElementRenderTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,209 @@
// Copyright (c) 2023-2026 ktsu-dev contributors

namespace ktsu.TUI.Test;

using ktsu.TUI.Core.Elements.Primitives;
using ktsu.TUI.Core.Models;
using Microsoft.VisualStudio.TestTools.UnitTesting;

/// <summary>
/// Render-path tests for <see cref="BorderElement"/>.
/// </summary>
/// <remarks>
/// <see cref="BorderElementTests"/> covers property round-tripping and invalidation. This class
/// covers what the element actually draws, which is where ktsu-dev/TUI#102 lived: a titled
/// border with the default <see cref="BorderElement.TitleAlignment"/>, and every
/// <see cref="BorderStyle.None"/> element, threw <see cref="NotImplementedException"/>.
/// </remarks>
[TestClass]
public sealed class BorderElementRenderTests
{
private static BorderElement CreateElement(int width = 30, int height = 5)
{
BorderElement element = [];
element.Position = Position.Origin;
element.Dimensions = new Dimensions(width, height);
return element;
}

/// <summary>
/// A titled border with default alignment must render. This is the case that used to throw.
/// </summary>
[TestMethod]
public void RenderTitledBorderWithDefaultAlignmentDrawsTheTitle()
{
// Arrange
BorderElement element = CreateElement();
element.Title = "Title";
RecordingConsoleProvider provider = new();

// Act
element.Render(provider);

// Assert
Assert.AreEqual(HorizontalAlignment.Left, element.TitleAlignment, "Left must remain the default");
Assert.ContainsSingle(provider.WritesOf(" Title "));
}

/// <summary>
/// Every border style and title alignment pairing must render rather than throw.
/// </summary>
/// <param name="style">The border style under test.</param>
/// <param name="alignment">The title alignment under test.</param>
[TestMethod]
[DynamicData(nameof(StyleAlignmentCombinations))]
public void RenderSucceedsForEveryStyleAndTitleAlignment(BorderStyle style, HorizontalAlignment alignment)
{
// Arrange
BorderElement element = CreateElement();
element.BorderStyle = style;
element.Title = "Title";
element.TitleAlignment = alignment;
RecordingConsoleProvider provider = new();

// Act
element.Render(provider);

// Assert
int expectedTitles = style == BorderStyle.None ? 0 : 1;
Assert.HasCount(expectedTitles, provider.WritesOf(" Title ").ToList());
}

/// <summary>
/// Gets every border style and title alignment pairing.
/// </summary>
public static IEnumerable<object[]> StyleAlignmentCombinations =>
from style in Enum.GetValues<BorderStyle>()
from alignment in Enum.GetValues<HorizontalAlignment>()
select new object[] { style, alignment };

/// <summary>
/// <see cref="BorderStyle.None"/> must draw nothing rather than throwing or drawing a
/// single-line border, which is what the fallback arm would otherwise have produced.
/// </summary>
[TestMethod]
public void BorderStyleNoneDrawsNothing()
{
// Arrange
BorderElement element = CreateElement();
element.BorderStyle = BorderStyle.None;
element.Title = "Title";
RecordingConsoleProvider provider = new();

// Act
element.Render(provider);

// Assert
Assert.IsEmpty(provider.Writes);
}

/// <summary>
/// <see cref="BorderStyle.None"/> must still render children, so it remains a usable container.
/// </summary>
[TestMethod]
public void BorderStyleNoneStillRendersChildren()
{
// Arrange
BorderElement element = CreateElement();
element.BorderStyle = BorderStyle.None;
element.Child = new TextElement("child")
{
Position = Position.Origin,
Dimensions = new Dimensions(20, 1),
};
RecordingConsoleProvider provider = new();

// Act
element.Render(provider);

// Assert
Assert.ContainsSingle(provider.WritesOf("child"));
}

/// <summary>
/// Each visible border style must draw its own corner glyph.
/// </summary>
/// <param name="style">The border style under test.</param>
/// <param name="expectedTopLeft">The corner glyph that style is expected to draw.</param>
[TestMethod]
[DataRow(BorderStyle.SingleLine, "┌")]
[DataRow(BorderStyle.DoubleLine, "╔")]
[DataRow(BorderStyle.Rounded, "╭")]
[DataRow(BorderStyle.Thick, "┏")]
[DataRow(BorderStyle.Ascii, "+")]
public void EachStyleDrawsItsOwnCornerGlyph(BorderStyle style, string expectedTopLeft)
{
// Arrange
BorderElement element = CreateElement();
element.BorderStyle = style;
RecordingConsoleProvider provider = new();

// Act
element.Render(provider);

// Assert
Assert.AreEqual(expectedTopLeft, provider.Writes[0].Text);
Assert.AreEqual(Position.Origin, provider.Writes[0].Position);
}

/// <summary>
/// Left title alignment must sit left of centre alignment, which must sit left of right.
/// </summary>
[TestMethod]
public void TitleAlignmentOrdersLeftThenCenterThenRight()
{
// Arrange & Act
static int Render(HorizontalAlignment alignment)
{
BorderElement element = CreateElement(width: 40);
element.Title = "Title";
element.TitleAlignment = alignment;
RecordingConsoleProvider provider = new();
element.Render(provider);
return provider.WritesOf(" Title ").Single().Position.X;
}

int left = Render(HorizontalAlignment.Left);
int center = Render(HorizontalAlignment.Center);
int right = Render(HorizontalAlignment.Right);

// Assert
Assert.IsGreaterThan(left, center);
Assert.IsGreaterThan(center, right);
}

/// <summary>
/// A border smaller than 2x2 has no room to draw and must draw nothing.
/// </summary>
[TestMethod]
public void BorderSmallerThanTwoByTwoDrawsNothing()
{
// Arrange
BorderElement element = CreateElement(width: 1, height: 1);
RecordingConsoleProvider provider = new();

// Act
element.Render(provider);

// Assert
Assert.IsEmpty(provider.Writes);
}

/// <summary>
/// The title is only drawn once the border is wide enough to hold it.
/// </summary>
[TestMethod]
public void TitleIsSuppressedWhenTheBorderIsTooNarrow()
{
// Arrange
BorderElement element = CreateElement(width: 4, height: 3);
element.Title = "Title";
RecordingConsoleProvider provider = new();

// Act
element.Render(provider);

// Assert
Assert.IsEmpty(provider.WritesOf(" Title "));
}
}
80 changes: 80 additions & 0 deletions TUI.Test/RecordingConsoleProvider.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
// Copyright (c) 2023-2026 ktsu-dev contributors

namespace ktsu.TUI.Test;

using System.Collections.ObjectModel;

using ktsu.TUI.Core.Contracts;
using ktsu.TUI.Core.Models;

/// <summary>
/// An <see cref="IConsoleProvider"/> test double that records every <see cref="WriteAt"/> call
/// instead of drawing, so a test can assert what an element drew and where it drew it.
/// </summary>
/// <remarks>
/// Rendering to the real <c>SpectreConsoleProvider</c> would write to the test runner's console
/// and would make the drawn output unobservable. Recording the calls keeps render tests
/// headless and lets them assert on positions rather than only on "did not throw".
/// </remarks>
internal sealed class RecordingConsoleProvider : IConsoleProvider
{
/// <summary>
/// One recorded <see cref="WriteAt"/> call.
/// </summary>
/// <param name="Text">The text that was written.</param>
/// <param name="Position">The position it was written at.</param>
/// <param name="Style">The style it was written with, if any.</param>
internal sealed record Write(string Text, Position Position, TextStyle? Style);

private readonly Collection<Write> writes = [];

/// <summary>
/// Gets every <see cref="WriteAt"/> call recorded so far, in call order.
/// </summary>
internal IReadOnlyList<Write> Writes => writes;

/// <summary>
/// Gets the number of times <see cref="Clear"/> was called.
/// </summary>
internal int ClearCount { get; private set; }

/// <inheritdoc />
public Dimensions Dimensions { get; set; } = new(80, 24);

/// <inheritdoc />
public void Clear() => ClearCount++;

/// <inheritdoc />
public void Render(IUIElement element, Position position) => element?.Render(this);

/// <inheritdoc />
public void WriteAt(string text, Position position, TextStyle? style = null) =>
writes.Add(new Write(text, position, style));

/// <inheritdoc />
public Task<InputResult> ReadInputAsync() => Task.FromResult(new InputResult());

/// <inheritdoc />
public void SetCursorVisibility(bool visible) => CursorVisible = visible;

/// <inheritdoc />
public void SetCursorPosition(Position position) => CursorPosition = position;

/// <summary>
/// Gets the last cursor visibility set through <see cref="SetCursorVisibility"/>.
/// </summary>
internal bool CursorVisible { get; private set; } = true;

/// <summary>
/// Gets the last cursor position set through <see cref="SetCursorPosition"/>.
/// </summary>
internal Position CursorPosition { get; private set; }

/// <summary>
/// Returns every recorded write whose text is exactly <paramref name="text"/>.
/// </summary>
/// <param name="text">The text to match.</param>
/// <returns>The matching writes, in call order.</returns>
internal IEnumerable<Write> WritesOf(string text) =>
writes.Where(w => string.Equals(w.Text, text, StringComparison.Ordinal));
}
Loading