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
18 changes: 18 additions & 0 deletions .config/dotnet-tools.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
{
"version": 1,
"isRoot": true,
"tools": {
"dotnet-coverage": {
"version": "17.14.1",
"commands": [
"dotnet-coverage"
]
},
"dotnet-reportgenerator-globaltool": {
"version": "5.4.5",
"commands": [
"reportgenerator"
]
}
}
}
18 changes: 18 additions & 0 deletions .github/scripts/run-tunit-tests.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
$ErrorActionPreference = 'Stop'

$testAssemblies = @(
'./SharedCode.Core.Tests/bin/Release/net9.0/SharedCode.Core.Tests.dll',
'./SharedCode.Core.Tests/bin/Release/net10.0/SharedCode.Core.Tests.dll',
'./SharedCode.Data.Tests/bin/Release/net9.0/SharedCode.Data.Tests.dll',
'./SharedCode.Data.Tests/bin/Release/net10.0/SharedCode.Data.Tests.dll'
)

foreach ($testAssembly in $testAssemblies)
{
dotnet $testAssembly --no-ansi --progress off

if ($LASTEXITCODE -ne 0)
{
exit $LASTEXITCODE
}
}
7 changes: 7 additions & 0 deletions .github/scripts/run-tunit-tests.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
#!/usr/bin/env bash
set -euo pipefail

dotnet ./SharedCode.Core.Tests/bin/Release/net9.0/SharedCode.Core.Tests.dll --no-ansi --progress off
dotnet ./SharedCode.Core.Tests/bin/Release/net10.0/SharedCode.Core.Tests.dll --no-ansi --progress off
dotnet ./SharedCode.Data.Tests/bin/Release/net9.0/SharedCode.Data.Tests.dll --no-ansi --progress off
dotnet ./SharedCode.Data.Tests/bin/Release/net10.0/SharedCode.Data.Tests.dll --no-ansi --progress off
49 changes: 49 additions & 0 deletions .github/skills/code-review/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
---
name: code-review
description: >
Context-aware code review guidance for the SharedCode .NET solution. Use this skill
when performing pull request reviews to apply project-specific conventions,
patterns, and quality checks.
---

## SharedCode Code Review Checklist

When reviewing pull requests in this repository, apply the following checks in addition
to general best practices.

### C# / .NET Conventions

- All public types and members have complete XML documentation (`<summary>`, `<param>`,
`<returns>`, `<exception>`, `<typeparam>`, `<remarks>` where applicable).
- Nullable reference types are respected — no suppression of nullable warnings without
a comment explaining why it is safe.
- `ArgumentNullException.ThrowIfNull` (or `ArgumentException.ThrowIfNullOrEmpty` for
strings) is used instead of manual null checks, on .NET 6+.
- `this.` prefix is used for all instance member accesses.
- Extension methods use `@this` as the first parameter name.
- Collection expressions (`[..]`) are preferred over `new List<T> { }` or `new T[] {}`.
- Primary constructors are preferred for types that only store injected dependencies.
- Code analysis suppressions always include a meaningful `Justification`.

### Project Structure

- New extension methods are placed in the same namespace as the extended type
and in a file named `<TypeName>Extensions.cs`.
- New projects are documented in `.github/copilot-instructions.md`.
- Package version changes are made only in `Directory.Packages.props`, never in
individual `.csproj` files.

### Testing

- Test framework is TUnit (`[Test]`, `[Arguments]`, `Assert.That(...)`).
- Tests follow the Arrange / Act / Assert pattern with blank lines separating each block.
- "Does not throw" tests are synchronous (no `async`/`await`) and do not include
a placeholder `await Assert.That(true).IsTrue()` assertion.
- Test files mirror the source structure
(e.g., `Calendar/DateTimeExtensionsTests.cs` for `Calendar/DateTimeExtensions.cs`).

### Build & Packaging

- All project files include `analyzers` in the `IncludeAssets` for `GCop.All.Common`
(consistent with `runtime; build; native; contentfiles; analyzers; buildtransitive`).
- No build warnings are introduced (warnings are treated as errors in this solution).
46 changes: 41 additions & 5 deletions .github/workflows/dotnet.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ name: .NET Continuous Integration

permissions:
contents: read
pull-requests: write

on:
pull_request:
Expand All @@ -21,13 +22,48 @@ jobs:
with:
dotnet-version: 10.0.x

- name: Run tests
run: dotnet test --logger GitHubActions --collect:"XPlat Code Coverage" SharedCode.sln
- name: Restore tools
run: dotnet tool restore

- name: Upload coverage reports
- name: Restore dependencies
run: dotnet restore SharedCode.sln

- name: Build
run: dotnet build --no-restore --configuration Release SharedCode.sln

- name: Run tests with coverage
shell: pwsh
run: >
dotnet tool run dotnet-coverage collect
--output coverage.xml
--output-format cobertura
--
pwsh -NoLogo -NoProfile -File ./.github/scripts/run-tunit-tests.ps1

- name: Generate coverage report
if: always()
run: >
dotnet tool run reportgenerator
-reports:coverage.xml
-targetdir:coverage-report
"-reporttypes:Html;MarkdownSummaryGithub;Cobertura"

- name: Write coverage summary to workflow
if: always()
run: cat coverage-report/SummaryGithub.md >> $env:GITHUB_STEP_SUMMARY
shell: pwsh

- name: Post coverage comment on PR
if: always() && github.event_name == 'pull_request'
uses: marocchino/sticky-pull-request-comment@v2
with:
header: coverage
path: coverage-report/SummaryGithub.md

- name: Upload coverage report
if: always()
uses: actions/upload-artifact@v4
with:
name: coverage-reports
path: '**/coverage.cobertura.xml'
name: coverage-report
path: coverage-report/
if-no-files-found: ignore
16 changes: 5 additions & 11 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -9,27 +9,22 @@
<PackageVersion Include="AwesomeAssertions.Analyzers" Version="9.0.8" />
<PackageVersion Include="AwesomeAssertions.Json" Version="9.0.0" />
<PackageVersion Include="CommunityToolkit.Mvvm" Version="8.4.0" />
<PackageVersion Include="coverlet.collector" Version="6.0.4" />
<PackageVersion Include="GCop.All.Common" Version="2.8.1" />
<PackageVersion Include="GitHubActionsTestLogger" Version="2.4.1" />
<PackageVersion Include="MediatR" Version="13.0.0" />
<PackageVersion Include="Microsoft.Azure.Cosmos" Version="3.53.1" />
<PackageVersion Include="Microsoft.Bcl.AsyncInterfaces" Version="9.0.8" />
<PackageVersion Include="Microsoft.Bcl.AsyncInterfaces" Version="10.0.8" />
<PackageVersion Include="Microsoft.Bcl.HashCode" Version="6.0.0" />
<PackageVersion Include="Microsoft.Data.Services.Client" Version="5.8.5" />
<PackageVersion Include="Microsoft.EntityFrameworkCore" Version="9.0.8" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Relational" Version="9.0.8" />
<PackageVersion Include="Microsoft.Extensions.Caching.Memory" Version="9.0.8" />
<PackageVersion Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="9.0.8" />
<PackageVersion Include="Microsoft.Extensions.DependencyModel" Version="9.0.8" />
<PackageVersion Include="Microsoft.Extensions.DependencyModel" Version="10.0.8" />
<PackageVersion Include="Microsoft.Extensions.Diagnostics.HealthChecks" Version="9.0.8" />
<PackageVersion Include="Microsoft.Extensions.Http" Version="9.0.8" />
<PackageVersion Include="Microsoft.Extensions.Http.Polly" Version="9.0.8" />
<PackageVersion Include="Microsoft.Extensions.Logging.Abstractions" Version="9.0.8" />
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageVersion Include="Microsoft.SourceLink.GitHub" Version="8.0.0" />
<PackageVersion Include="MSTest.TestAdapter" Version="3.10.3" />
<PackageVersion Include="MSTest.TestFramework" Version="3.10.3" />
<PackageVersion Include="Newtonsoft.Json" Version="13.0.3" />
<PackageVersion Include="Polly" Version="8.6.3" />
<PackageVersion Include="Polly.Contrib.WaitAndRetry" Version="1.1.1" />
Expand All @@ -52,8 +47,7 @@
<PackageVersion Include="System.Net.Http" Version="4.3.4" />
<PackageVersion Include="System.Reactive" Version="6.0.2" />
<PackageVersion Include="System.Reflection.Metadata" Version="9.0.8" />
<PackageVersion Include="System.Text.Json" Version="9.0.8" />
<PackageVersion Include="xunit" Version="2.9.3" />
<PackageVersion Include="xunit.runner.visualstudio" Version="3.1.4" />
<PackageVersion Include="System.Text.Json" Version="10.0.8" />
<PackageVersion Include="TUnit" Version="1.64.13" />
</ItemGroup>
</Project>
</Project>
26 changes: 13 additions & 13 deletions SharedCode.Core.Tests/AssemblyExtensionsTests.cs
Original file line number Diff line number Diff line change
@@ -1,21 +1,22 @@
namespace SharedCode.Tests;
namespace SharedCode.Tests;

using Microsoft.VisualStudio.TestTools.UnitTesting;
using System.Threading.Tasks;
using TUnit.Assertions;
using TUnit.Core;

using System.Reflection;

/// <summary>
/// Tests for <see cref="AssemblyExtensions" />.
/// </summary>
[TestClass]
public class AssemblyExtensionsTests
{
/// <summary>
/// Tests that <see cref="AssemblyExtensions.GetAttribute{T}" /> returns the attribute when it
/// is present on the assembly.
/// </summary>
[TestMethod]
public void GetAttribute_AssemblyHasAttribute_ReturnsAttribute()
[Test]
public async Task GetAttribute_AssemblyHasAttribute_ReturnsAttribute()
{
// Arrange
var assembly = typeof(AssemblyExtensionsTests).Assembly;
Expand All @@ -24,15 +25,15 @@ public void GetAttribute_AssemblyHasAttribute_ReturnsAttribute()
var result = assembly.GetAttribute<AssemblyTitleAttribute>();

// Assert
Assert.IsNotNull(result);
await Assert.That(result is not null).IsTrue();
}

/// <summary>
/// Tests that <see cref="AssemblyExtensions.GetAttribute{T}" /> returns null when the
/// attribute is not present on the assembly.
/// </summary>
[TestMethod]
public void GetAttribute_AssemblyMissingAttribute_ReturnsNull()
[Test]
public async Task GetAttribute_AssemblyMissingAttribute_ReturnsNull()
{
// Arrange
var assembly = typeof(AssemblyExtensionsTests).Assembly;
Expand All @@ -41,21 +42,20 @@ public void GetAttribute_AssemblyMissingAttribute_ReturnsNull()
var result = assembly.GetAttribute<ObsoleteAttribute>();

// Assert
Assert.IsNull(result);
await Assert.That(result is null).IsTrue();
}

/// <summary>
/// Tests that <see cref="AssemblyExtensions.GetAttribute{T}" /> throws
/// <see cref="ArgumentNullException" /> when the assembly is null.
/// </summary>
[TestMethod]
public void GetAttribute_NullAssembly_ThrowsArgumentNullException()
[Test]
public async Task GetAttribute_NullAssembly_ThrowsArgumentNullException()
{
// Arrange
Assembly? assembly = null;

// Act / Assert
_ = Assert.ThrowsExactly<ArgumentNullException>(
() => assembly!.GetAttribute<AssemblyTitleAttribute>());
await Assert.That(() => assembly!.GetAttribute<AssemblyTitleAttribute>()).ThrowsExactly<ArgumentNullException>();
}
}
55 changes: 30 additions & 25 deletions SharedCode.Core.Tests/AssertExtensions.cs
Original file line number Diff line number Diff line change
@@ -1,35 +1,40 @@

namespace SharedCode.Tests;

using Microsoft.VisualStudio.TestTools.UnitTesting;
namespace SharedCode.Tests;

using System.Collections;
using System.Threading.Tasks;

using TUnit.Assertions;

/// <summary>
/// The assert extensions class.
/// </summary>
[System.Diagnostics.CodeAnalysis.SuppressMessage("Maintainability", "CA1515:Consider making public types internal", Justification = "<Pending>")]
public static class AssertExtensions
{
/// <summary>
/// Asserts that the expected and actual values are equal using the specified comparer.
/// </summary>
/// <typeparam name="T">The type being compared.</typeparam>
/// <param name="_">The assert class.</param>
/// <param name="expected">The expected value.</param>
/// <param name="actual">The actual value.</param>
/// <param name="comparer">The comparer class.</param>
public static void AreEqual<T>(this Assert _, T expected, T actual, IComparer comparer) =>
CollectionAssert.AreEqual(new[] { expected }, new[] { actual }, comparer, $"\nExpected: <{expected}>.\nActual: <{actual}>.");
/// <summary>
/// Asserts that the expected and actual values are equal using the specified comparer.
/// </summary>
/// <typeparam name="T">The type being compared.</typeparam>
/// <param name="expected">The expected value.</param>
/// <param name="actual">The actual value.</param>
/// <param name="comparer">The comparer class.</param>
public static async Task AreEqual<T>(T expected, T actual, IComparer comparer)
{
_ = comparer ?? throw new ArgumentNullException(nameof(comparer));

await Assert.That(comparer.Compare(expected, actual)).IsEqualTo(0);
}

/// <summary>
/// Asserts that the expected and actual values are equal using the specified comparer.
/// </summary>
/// <typeparam name="T">The type being compared.</typeparam>
/// <param name="expected">The expected value.</param>
/// <param name="actual">The actual value.</param>
/// <param name="compareFunction">The compare function.</param>
public static async Task AreEqual<T>(T expected, T actual, CompareFunc<T> compareFunction)
{
_ = compareFunction ?? throw new ArgumentNullException(nameof(compareFunction));

/// <summary>
/// Asserts that the expected and actual values are equal using the specified comparer.
/// </summary>
/// <typeparam name="T">The type being compared.</typeparam>
/// <param name="_">The assert class.</param>
/// <param name="expected">The expected value.</param>
/// <param name="actual">The actual value.</param>
/// <param name="compareFunction">The compare function.</param>
public static void AreEqual<T>(this Assert _, T expected, T actual, CompareFunc<T> compareFunction) =>
CollectionAssert.AreEqual(new[] { expected }, new[] { actual }, new LambdaComparer<T>(compareFunction), $"\nExpected: <{expected}>.\nActual: <{actual}>.");
await Assert.That(compareFunction(expected, actual)).IsTrue();
}
}
Loading
Loading