Skip to content

Commit db6d67e

Browse files
authored
Merge pull request #22 from NRG-Drink/perf/mem-focus
Perf/mem focus
2 parents 4daa86e + f54203f commit db6d67e

20 files changed

+845
-422
lines changed

.github/copilot-instructions.md

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,7 @@ Use these repository-wide instructions for any work in this repo.
1919

2020
If you’re unsure where a change belongs, start at the style (`Styles/`) and follow calls into models/display.
2121

22-
For C#-specific formatting and conventions, also follow the path-specific rules in `.github/instructions/csharp.instructions.md`.
23-
22+
For C#-specific formatting and conventions, also follow the path-specific rules in `.github/instructions/csharp.instructions.md`.For writing tests in the `NRG.Matrix.Tests` project, follow `.github/instructions/tunit-tests.instructions.md`.
2423
## Build / run / validate (Windows-friendly)
2524

2625
Prefer `dotnet` CLI commands from the repo root.
Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
---
2+
applyTo: "**/NRG.Matrix.Tests/**/*.cs"
3+
---
4+
5+
# TUnit Test Instructions
6+
7+
Write tests using [TUnit](https://tunit.dev/), a modern .NET testing framework with native async support.
8+
9+
## Categories
10+
11+
Add #Unit to all unit tests, #Internal to all tests that uses local sources and #External to all tests that uses sources on the internet.
12+
13+
#Unit - All sources in memory
14+
#Internal - When local resources are accessed (e.g. files, Docker container, etc.)
15+
#External - When external resources are used (e.g. http requests, etc.)
16+
17+
## Test Class Structure
18+
19+
```csharp
20+
[Category("#Unit")]
21+
[Category("FeatureName")]
22+
public class ClassNameTests
23+
{
24+
[Test]
25+
public async Task MethodName_Scenario_ExpectedBehavior()
26+
{
27+
// Arrange
28+
var input = CreateTestInput();
29+
30+
// Act
31+
var result = MethodUnderTest(input);
32+
33+
// Assert
34+
await Assert.That(result).IsEqualTo(expected);
35+
}
36+
}
37+
```
38+
39+
**Docs:** [Categories](https://tunit.dev/docs/tutorials/categories) | [Tests](https://tunit.dev/docs/tutorial-basics/writing-tests)
40+
41+
## Key Rules
42+
43+
- **All test methods MUST be async** returning `Task`
44+
- **All assertions MUST be awaited**
45+
- Use `MethodName_Scenario_ExpectedBehavior` naming
46+
- One class per production class being tested
47+
- Add `[Category]` attributes for test filtering
48+
49+
## Assertions (Fluent API)
50+
51+
```csharp
52+
// Equality
53+
await Assert.That(actual).IsEqualTo(expected);
54+
await Assert.That(actual).IsNotEqualTo(unwanted);
55+
56+
// Null checks
57+
await Assert.That(value).IsNull();
58+
await Assert.That(value).IsNotNull();
59+
60+
// Comparisons
61+
await Assert.That(number).IsGreaterThan(5);
62+
await Assert.That(number).IsLessThanOrEqualTo(10);
63+
64+
// Strings
65+
await Assert.That(text).Contains("substring");
66+
await Assert.That(text).StartsWith("prefix");
67+
68+
// Collections
69+
await Assert.That(collection).IsEmpty();
70+
await Assert.That(list).HasCount().EqualTo(5);
71+
```
72+
73+
**Docs:** [Assertions](https://tunit.dev/docs/tutorial-basics/assertions)
74+
75+
## Data-Driven Tests
76+
77+
Use `MethodDataSource` for parameterized tests:
78+
79+
```csharp
80+
public static IEnumerable<Func<(int Input, int Expected)>> GetTestCases()
81+
{
82+
yield return () => (1, 2);
83+
yield return () => (5, 10);
84+
yield return () => (0, 0);
85+
}
86+
87+
[Test]
88+
[MethodDataSource(nameof(GetTestCases))]
89+
public async Task Method_VariousInputs_ReturnsExpected(int input, int expected)
90+
{
91+
var result = Process(input);
92+
await Assert.That(result).IsEqualTo(expected);
93+
}
94+
```
95+
96+
**Docs:** [MethodDataSource](https://tunit.dev/docs/tutorial-parameterised-tests/method-data-source-generation)
97+
98+
## Test Coverage Priorities
99+
100+
1. Pure functions (extension methods, utilities, calculations)
101+
2. Models and data structures (validation, state management)
102+
3. Performance-critical code (verify no unexpected allocations)
103+
104+
## Testing Best Practices
105+
106+
- **Test edge cases:** null/empty, boundary values, invalid inputs
107+
- **Keep tests focused:** one behavior per test
108+
- **Use descriptive names:** test names document expected behavior
109+
- **Group related tests:** use data-driven tests for similar scenarios
110+
- **Performance tests:** use `[Category("Performance")]` for perf-sensitive code
111+
112+
## Running Tests
113+
114+
```bash
115+
# Run all tests
116+
dotnet test
117+
118+
# Run with filter (use --treenode-filter)
119+
dotnet run -- --treenode-filter "/*/*/*/*[Category=#Unit]"
120+
121+
# Filter by class
122+
dotnet run -- --treenode-filter "/*/*/ClassNameTests/*"
123+
124+
# Filter by method
125+
dotnet run -- --treenode-filter "/*/*/ClassName/MethodName"
126+
127+
# Multiple categories (OR)
128+
dotnet run -- --treenode-filter "/*/*/*/*[Category=#Unit]|/*/*/*/*[Category=Performance]"
129+
130+
# Exclude category
131+
dotnet run -- --treenode-filter "/*/*/*/*[Category!=#Unit]"
132+
```
133+
134+
**Note:** Use `--treenode-filter` with pattern `/<Assembly>/<Namespace>/<Class>/<Method>[Property=Value]`. The `--` separator passes arguments to the test app.
135+
136+
Use filters as often as you can to speed up test runs during development.

.vscode/settings.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
{
2+
"chat.tools.terminal.autoApprove": {
3+
"dotnet build": true,
4+
"dotnet test": true
5+
}
6+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
<Project Sdk="Microsoft.NET.Sdk">
2+
3+
<PropertyGroup>
4+
<OutputType>Exe</OutputType>
5+
<TargetFramework>net10.0</TargetFramework>
6+
<ImplicitUsings>enable</ImplicitUsings>
7+
<Nullable>enable</Nullable>
8+
<EnableMicrosoftTestingExtensions>true</EnableMicrosoftTestingExtensions>
9+
</PropertyGroup>
10+
11+
<ItemGroup>
12+
<PackageReference Include="TUnit" Version="*" />
13+
</ItemGroup>
14+
15+
<ItemGroup>
16+
<ProjectReference Include="..\NRG.Matrix\NRG.Matrix.csproj" />
17+
</ItemGroup>
18+
19+
</Project>
Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
using System.Text;
2+
3+
namespace NRG.Matrix.Tests;
4+
5+
[Category("#Unit")]
6+
[Category("Misc")]
7+
public class StringBuilderExtensionsTests
8+
{
9+
public static IEnumerable<Func<(Action<StringBuilder> Setup, string Expected)>> GetAppendPaddedTestCases()
10+
{
11+
// Basic zero cases
12+
yield return () => (sb => sb.AppendPadded(0, 0), "0");
13+
yield return () => (sb => sb.AppendPadded(0, 5), "00000");
14+
15+
// Positive numbers
16+
yield return () => (sb => sb.AppendPadded(123, 3), "123");
17+
yield return () => (sb => sb.AppendPadded(42, 5), "00042");
18+
yield return () => (sb => sb.AppendPadded(12345, 3), "12345");
19+
yield return () => (sb => sb.AppendPadded(7, 4), "0007");
20+
yield return () => (sb => sb.AppendPadded(9876543210, 5), "9876543210");
21+
22+
// Negative numbers
23+
yield return () => (sb => sb.AppendPadded(-42, 5), "-0042");
24+
yield return () => (sb => sb.AppendPadded(-0, 3), "000");
25+
yield return () => (sb => sb.AppendPadded(-123, 4), "-123");
26+
yield return () => (sb => sb.AppendPadded(-5, 3), "-05");
27+
yield return () => (sb => sb.AppendPadded(-99, 3), "-99");
28+
yield return () => (sb => sb.AppendPadded(-999, 2), "-999");
29+
yield return () => (sb => sb.AppendPadded(-1, 2), "-1");
30+
yield return () => (sb => sb.AppendPadded(-1, 5), "-0001");
31+
yield return () => (sb => sb.AppendPadded(-999, 5), "-0999");
32+
33+
// Extreme values
34+
yield return () => (sb => sb.AppendPadded(long.MaxValue, 20), "09223372036854775807");
35+
yield return () => (sb => sb.AppendPadded(long.MinValue + 1, 20), "-9223372036854775807");
36+
37+
// Width of one
38+
yield return () => (sb => sb.AppendPadded(5, 1), "5");
39+
yield return () => (sb => sb.AppendPadded(99, 1), "99");
40+
yield return () => (sb => sb.AppendPadded(-5, 1), "-5");
41+
42+
// Zero width
43+
yield return () => (sb => sb.AppendPadded(42, 0), "42");
44+
yield return () => (sb => sb.AppendPadded(-42, 0), "-42");
45+
46+
// With existing content
47+
yield return () => (sb => { sb.Append("prefix:"); sb.AppendPadded(42, 5); }, "prefix:00042");
48+
49+
// Multiple calls chained
50+
yield return () => (sb => sb.AppendPadded(1, 2).AppendPadded(23, 3).AppendPadded(456, 4), "010230456");
51+
52+
// Powers of ten
53+
yield return () => (sb =>
54+
{
55+
sb.AppendPadded(1, 0);
56+
sb.Append(',');
57+
sb.AppendPadded(10, 0);
58+
sb.Append(',');
59+
sb.AppendPadded(100, 0);
60+
sb.Append(',');
61+
sb.AppendPadded(1000, 0);
62+
}, "1,10,100,1000");
63+
64+
// Negative powers of ten
65+
yield return () => (sb =>
66+
{
67+
sb.AppendPadded(-1, 0);
68+
sb.Append(',');
69+
sb.AppendPadded(-10, 0);
70+
sb.Append(',');
71+
sb.AppendPadded(-100, 0);
72+
sb.Append(',');
73+
sb.AppendPadded(-1000, 0);
74+
}, "-1,-10,-100,-1000");
75+
76+
// Alternating zero and non-zero
77+
yield return () => (sb =>
78+
{
79+
sb.AppendPadded(0, 2);
80+
sb.Append(',');
81+
sb.AppendPadded(1, 2);
82+
sb.Append(',');
83+
sb.AppendPadded(0, 2);
84+
}, "00,01,00");
85+
86+
// Zero with different widths
87+
yield return () => (sb =>
88+
{
89+
sb.AppendPadded(0, 1);
90+
sb.Append('|');
91+
sb.AppendPadded(0, 2);
92+
sb.Append('|');
93+
sb.AppendPadded(0, 3);
94+
sb.Append('|');
95+
sb.AppendPadded(0, 4);
96+
}, "0|00|000|0000");
97+
98+
// Boundary values
99+
yield return () => (sb => sb.AppendPadded(999, 5), "00999");
100+
101+
// Long chain
102+
yield return () => (sb =>
103+
{
104+
for (var i = 0; i < 10; i++)
105+
{
106+
sb.AppendPadded(i, 2);
107+
if (i < 9) sb.Append(' ');
108+
}
109+
}, "00 01 02 03 04 05 06 07 08 09");
110+
111+
// Sequence with formatting
112+
yield return () => (sb =>
113+
{
114+
sb.Append("[");
115+
sb.AppendPadded(1, 3);
116+
sb.Append(", ");
117+
sb.AppendPadded(-2, 4);
118+
sb.Append(", ");
119+
sb.AppendPadded(999, 2);
120+
sb.Append("]");
121+
}, "[001, -002, 999]");
122+
123+
// All single digits with padding
124+
yield return () => (sb =>
125+
{
126+
for (var i = 0; i <= 9; i++)
127+
{
128+
if (i > 0) sb.Append(' ');
129+
sb.AppendPadded(i, 3);
130+
}
131+
}, "000 001 002 003 004 005 006 007 008 009");
132+
}
133+
134+
[Test]
135+
[MethodDataSource(nameof(GetAppendPaddedTestCases))]
136+
public async Task AppendPadded_VariousCases_ProducesExpectedOutput(Action<StringBuilder> setup, string expected)
137+
{
138+
var sb = new StringBuilder();
139+
setup(sb);
140+
await Assert.That(sb.ToString()).IsEqualTo(expected);
141+
}
142+
143+
[Test]
144+
public async Task AppendPadded_ReusedStringBuilder_NoSideEffects()
145+
{
146+
var sb = new StringBuilder();
147+
sb.AppendPadded(10, 3);
148+
var first = sb.ToString();
149+
150+
sb.Clear();
151+
sb.AppendPadded(20, 3);
152+
var second = sb.ToString();
153+
154+
await Assert.That(first).IsEqualTo("010");
155+
await Assert.That(second).IsEqualTo("020");
156+
}
157+
}

NRG.Matrix/NRG.Matrix.sln

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution
1515
EndProject
1616
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NRG.Matrix.Build", "NRG.Matrix.Build\NRG.Matrix.Build.csproj", "{80C49D57-835F-4E53-9F3F-DD6B99238D0F}"
1717
EndProject
18+
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NRG.Matrix.Tests", "NRG.Matrix.Tests\NRG.Matrix.Tests.csproj", "{3315831C-5BDF-499C-9151-385AAD37A711}"
19+
EndProject
1820
Global
1921
GlobalSection(SolutionConfigurationPlatforms) = preSolution
2022
Debug|Any CPU = Debug|Any CPU
@@ -29,6 +31,10 @@ Global
2931
{80C49D57-835F-4E53-9F3F-DD6B99238D0F}.Debug|Any CPU.Build.0 = Debug|Any CPU
3032
{80C49D57-835F-4E53-9F3F-DD6B99238D0F}.Release|Any CPU.ActiveCfg = Release|Any CPU
3133
{80C49D57-835F-4E53-9F3F-DD6B99238D0F}.Release|Any CPU.Build.0 = Release|Any CPU
34+
{3315831C-5BDF-499C-9151-385AAD37A711}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
35+
{3315831C-5BDF-499C-9151-385AAD37A711}.Debug|Any CPU.Build.0 = Debug|Any CPU
36+
{3315831C-5BDF-499C-9151-385AAD37A711}.Release|Any CPU.ActiveCfg = Release|Any CPU
37+
{3315831C-5BDF-499C-9151-385AAD37A711}.Release|Any CPU.Build.0 = Release|Any CPU
3238
EndGlobalSection
3339
GlobalSection(SolutionProperties) = preSolution
3440
HideSolutionNode = FALSE

0 commit comments

Comments
 (0)