Skip to content

Commit 702344f

Browse files
authored
Print lists as AnsiConsole table (#69)
* Add `PrintTable` extension for `IAnsiConsole` with unit tests - Implemented `PrintTable` method for rendering tabular data in the console. - Added support for configurable columns, titles, and widths using `TableColumnDef`. - Included comprehensive unit tests for various scenarios, such as empty items, header visibility, and table configuration. * Add support for colored table column titles and values in `TableColumnDef` - Introduced `Color` property to `TableColumnDef`. - Enhanced title and value rendering with color formatting. - Refactored `GetValue` and `GetTitle` methods to apply color if specified. * Add unit tests for `PrintTable` with colored and non-colored columns - Introduce tests to verify table rendering with and without column colors. - Suppress nullable warnings in test class with `[SuppressMessage]`. - Refactor `TestItem` class to use immutable properties with `init`.
1 parent 89964ac commit 702344f

3 files changed

Lines changed: 329 additions & 2 deletions

File tree

source/SysConsole/CreativeCoders.SysConsole.Core/AnsiConsoleExtensions.cs

Lines changed: 45 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
1-
using System.Diagnostics.CodeAnalysis;
1+
using System;
2+
using System.Collections.Generic;
3+
using System.Diagnostics.CodeAnalysis;
4+
using System.Linq;
25
using CreativeCoders.Core;
36
using JetBrains.Annotations;
47
using Spectre.Console;
58

69
namespace CreativeCoders.SysConsole.Core;
710

8-
[ExcludeFromCodeCoverage]
911
[PublicAPI]
1012
public static class AnsiConsoleExtensions
1113
{
@@ -21,6 +23,7 @@ public static IAnsiConsolePrint PrintBlock(this IAnsiConsole ansiConsole, bool c
2123
return new AnsiConsolePrint(ansiConsole);
2224
}
2325

26+
[PublicAPI]
2427
public static IAnsiConsole Write<T>(this IAnsiConsole ansiConsole, T value, Color foregroundColor,
2528
Color? backgroundColor = null)
2629
{
@@ -31,6 +34,7 @@ public static IAnsiConsole Write<T>(this IAnsiConsole ansiConsole, T value, Colo
3134
return ansiConsole;
3235
}
3336

37+
[PublicAPI]
3438
public static IAnsiConsole WriteLine<T>(this IAnsiConsole ansiConsole, T value, Color foregroundColor,
3539
Color? backgroundColor = null)
3640
{
@@ -40,4 +44,43 @@ public static IAnsiConsole WriteLine<T>(this IAnsiConsole ansiConsole, T value,
4044

4145
return ansiConsole;
4246
}
47+
48+
[PublicAPI]
49+
public static void PrintTable<T>(this IAnsiConsole ansiConsole, IEnumerable<T> items,
50+
TableColumnDef<T>[] columns, Action<Table>? configureTable = null)
51+
{
52+
var table = new Table
53+
{
54+
ShowHeaders = false,
55+
Border = TableBorder.None
56+
};
57+
58+
foreach (var tableColumnDef in columns)
59+
{
60+
table.AddColumn(tableColumnDef.GetTitle(), x =>
61+
{
62+
x.Width = tableColumnDef.Width;
63+
tableColumnDef.ConfigureColumn(x);
64+
});
65+
66+
if (!string.IsNullOrWhiteSpace(tableColumnDef.Title))
67+
{
68+
table.ShowHeaders = true;
69+
}
70+
}
71+
72+
configureTable?.Invoke(table);
73+
74+
if (table.ShowHeaders)
75+
{
76+
table.AddRow(columns.Select(x => new string('=', x.Title?.Length ?? 0)).ToArray());
77+
}
78+
79+
foreach (var item in items)
80+
{
81+
table.AddRow(columns.Select(x => x.GetValue(item)).ToArray());
82+
}
83+
84+
ansiConsole.Write(table);
85+
}
4386
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
using System;
2+
using CreativeCoders.Core;
3+
using Spectre.Console;
4+
5+
namespace CreativeCoders.SysConsole.Core;
6+
7+
public class TableColumnDef<T>(
8+
Func<T, object?> valueSelector,
9+
string? title = null,
10+
int? width = null,
11+
Color? color = null,
12+
Action<TableColumn>? configureColumn = null)
13+
{
14+
private readonly Func<T, object?> _valueSelector = Ensure.NotNull(valueSelector);
15+
16+
public string GetValue(T item) =>
17+
GetStringWithColor(_valueSelector(item)?.ToString() ?? string.Empty);
18+
19+
public void ConfigureColumn(TableColumn column) => configureColumn?.Invoke(column);
20+
21+
private string GetStringWithColor(string text)
22+
{
23+
return color == null
24+
? text
25+
: $"[{color.Value.ToMarkup()}]{text}[/]";
26+
}
27+
28+
public string GetTitle() => GetStringWithColor(Title ?? string.Empty);
29+
30+
public string? Title { get; } = title;
31+
32+
public int? Width { get; } = width;
33+
}
Lines changed: 251 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,251 @@
1+
using System.Diagnostics.CodeAnalysis;
2+
using AwesomeAssertions;
3+
using CreativeCoders.SysConsole.Core;
4+
using FakeItEasy;
5+
using Spectre.Console;
6+
using Spectre.Console.Rendering;
7+
using Xunit;
8+
9+
namespace CreativeCoders.SysConsole.UnitTests;
10+
11+
[SuppressMessage("ReSharper", "NullableWarningSuppressionIsUsed")]
12+
public class AnsiConsoleExtensionsTests
13+
{
14+
[Fact]
15+
public void PrintTable_ItemsAndColumns_TableIsWrittenToConsole()
16+
{
17+
// Arrange
18+
var ansiConsole = A.Fake<IAnsiConsole>();
19+
20+
var items = new[]
21+
{
22+
new TestItem { Name = "Item1", Value = 10 },
23+
new TestItem { Name = "Item2", Value = 20 }
24+
};
25+
26+
var columns = new[]
27+
{
28+
new TableColumnDef<TestItem>(x => x.Name, "Name"),
29+
new TableColumnDef<TestItem>(x => x.Value, "Value")
30+
};
31+
32+
Table? capturedTable = null;
33+
A.CallTo(() => ansiConsole.Write(A<IRenderable>.Ignored))
34+
.Invokes(call => capturedTable = call.Arguments.Get<Table>(0));
35+
36+
// Act
37+
ansiConsole.PrintTable(items, columns);
38+
39+
// Assert
40+
capturedTable.Should().NotBeNull();
41+
capturedTable!.Rows.Count.Should().Be(3); // 1 header separator row + 2 data rows
42+
capturedTable.ShowHeaders.Should().BeTrue();
43+
}
44+
45+
[Fact]
46+
public void PrintTable_NoColumnTitles_HeadersAreNotShown()
47+
{
48+
// Arrange
49+
var ansiConsole = A.Fake<IAnsiConsole>();
50+
51+
var items = new[]
52+
{
53+
new TestItem { Name = "Item1", Value = 10 }
54+
};
55+
56+
var columns = new[]
57+
{
58+
new TableColumnDef<TestItem>(x => x.Name)
59+
};
60+
61+
Table? capturedTable = null;
62+
A.CallTo(() => ansiConsole.Write(A<IRenderable>.Ignored))
63+
.Invokes(call => capturedTable = call.Arguments.Get<Table>(0));
64+
65+
// Act
66+
ansiConsole.PrintTable(items, columns);
67+
68+
// Assert
69+
capturedTable.Should().NotBeNull();
70+
capturedTable!.ShowHeaders.Should().BeFalse();
71+
capturedTable.Rows.Count.Should().Be(1); // Only data row
72+
}
73+
74+
[Fact]
75+
public void PrintTable_WithConfigureTable_TableIsConfigured()
76+
{
77+
// Arrange
78+
var ansiConsole = A.Fake<IAnsiConsole>();
79+
80+
var items = new[]
81+
{
82+
new TestItem { Name = "Item1", Value = 10 }
83+
};
84+
85+
var columns = new[]
86+
{
87+
new TableColumnDef<TestItem>(x => x.Name, "Name")
88+
};
89+
90+
Table? capturedTable = null;
91+
A.CallTo(() => ansiConsole.Write(A<IRenderable>.Ignored))
92+
.Invokes(call => capturedTable = call.Arguments.Get<Table>(0));
93+
94+
// Act
95+
ansiConsole.PrintTable(items, columns, table => table.Title = new TableTitle("TestTitle"));
96+
97+
// Assert
98+
capturedTable.Should().NotBeNull();
99+
capturedTable!.Title!.Text.Should().Be("TestTitle");
100+
}
101+
102+
[Fact]
103+
public void PrintTable_WithColumnWidth_ColumnHasSpecifiedWidth()
104+
{
105+
// Arrange
106+
var ansiConsole = A.Fake<IAnsiConsole>();
107+
108+
var items = new[]
109+
{
110+
new TestItem { Name = "Item1", Value = 10 }
111+
};
112+
113+
var columns = new[]
114+
{
115+
new TableColumnDef<TestItem>(x => x.Name, "Name", width: 20)
116+
};
117+
118+
Table? capturedTable = null;
119+
A.CallTo(() => ansiConsole.Write(A<IRenderable>.Ignored))
120+
.Invokes(call => capturedTable = call.Arguments.Get<Table>(0));
121+
122+
// Act
123+
ansiConsole.PrintTable(items, columns);
124+
125+
// Assert
126+
capturedTable.Should().NotBeNull();
127+
capturedTable!.Columns[0].Width.Should().Be(20);
128+
}
129+
130+
[Fact]
131+
public void PrintTable_EmptyItems_OnlyHeaderRowsAreWritten()
132+
{
133+
// Arrange
134+
var ansiConsole = A.Fake<IAnsiConsole>();
135+
136+
var items = Enumerable.Empty<TestItem>();
137+
138+
var columns = new[]
139+
{
140+
new TableColumnDef<TestItem>(x => x.Name, "Name")
141+
};
142+
143+
Table? capturedTable = null;
144+
A.CallTo(() => ansiConsole.Write(A<IRenderable>.Ignored))
145+
.Invokes(call => capturedTable = call.Arguments.Get<Table>(0));
146+
147+
// Act
148+
ansiConsole.PrintTable(items, columns);
149+
150+
// Assert
151+
capturedTable.Should().NotBeNull();
152+
capturedTable!.Rows.Count.Should().Be(1); // Only header separator row
153+
}
154+
155+
[Fact]
156+
public void PrintTable_ColoredColumns_TableIsWrittenWithColorToConsole()
157+
{
158+
// Arrange
159+
var ansiConsole = A.Fake<IAnsiConsole>();
160+
161+
var items = new[]
162+
{
163+
new TestItem { Name = "Item1", Value = 10 }
164+
};
165+
166+
var columns = new[]
167+
{
168+
new TableColumnDef<TestItem>(x => x.Name, "Name", color: Color.Red),
169+
new TableColumnDef<TestItem>(x => x.Value, "Value", color: Color.Green)
170+
};
171+
172+
Table? capturedTable = null;
173+
A.CallTo(() => ansiConsole.Write(A<IRenderable>.Ignored))
174+
.Invokes(call => capturedTable = call.Arguments.Get<Table>(0));
175+
176+
// Act
177+
ansiConsole.PrintTable(items, columns);
178+
179+
// Assert
180+
capturedTable.Should().NotBeNull();
181+
capturedTable!.Rows.Count.Should().Be(2);
182+
183+
var row = (capturedTable.Rows as IReadOnlyList<TableRow>)[1];
184+
185+
var col0 = row[0].GetSegments(AnsiConsole.Create(new AnsiConsoleSettings())).First();
186+
col0.Text
187+
.Should().Be("Item1");
188+
189+
col0.Style.Foreground
190+
.Should().Be(Color.Red);
191+
192+
var col1 = row[1].GetSegments(AnsiConsole.Create(new AnsiConsoleSettings())).First();
193+
col1.Text
194+
.Should().Be("10");
195+
196+
col1.Style.Foreground
197+
.Should().Be(Color.Green);
198+
}
199+
200+
[Fact]
201+
public void PrintTable_NotColoredColumns_TableIsWrittenWithOutColorToConsole()
202+
{
203+
// Arrange
204+
var ansiConsole = A.Fake<IAnsiConsole>();
205+
206+
var items = new[]
207+
{
208+
new TestItem { Name = "Item1", Value = 10 }
209+
};
210+
211+
var columns = new[]
212+
{
213+
new TableColumnDef<TestItem>(x => x.Name, "Name"),
214+
new TableColumnDef<TestItem>(x => x.Value, "Value")
215+
};
216+
217+
Table? capturedTable = null;
218+
A.CallTo(() => ansiConsole.Write(A<IRenderable>.Ignored))
219+
.Invokes(call => capturedTable = call.Arguments.Get<Table>(0));
220+
221+
// Act
222+
ansiConsole.PrintTable(items, columns);
223+
224+
// Assert
225+
capturedTable.Should().NotBeNull();
226+
capturedTable!.Rows.Count.Should().Be(2);
227+
228+
var row = (capturedTable.Rows as IReadOnlyList<TableRow>)[1];
229+
230+
var col0 = row[0].GetSegments(AnsiConsole.Create(new AnsiConsoleSettings())).First();
231+
col0.Text
232+
.Should().Be("Item1");
233+
234+
col0.Style.Foreground
235+
.Should().Be(Color.Default);
236+
237+
var col1 = row[1].GetSegments(AnsiConsole.Create(new AnsiConsoleSettings())).First();
238+
col1.Text
239+
.Should().Be("10");
240+
241+
col1.Style.Foreground
242+
.Should().Be(Color.Default);
243+
}
244+
245+
private class TestItem
246+
{
247+
public string Name { get; init; } = string.Empty;
248+
249+
public int Value { get; init; }
250+
}
251+
}

0 commit comments

Comments
 (0)