diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 34d84e9..8567fc7 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -9,6 +9,7 @@ published as individual NuGet packages. Each project corresponds to one NuGet pa |---|---|---| | `SharedCode.Core` | `SharedCode.Core` | Core utilities: extensions, specifications, domain primitives, text, calendar, security, threading, reactive | | `SharedCode.Core.Tests` | *(test only)* | Unit tests for `SharedCode.Core` | +| `SharedCode.Data.Tests` | *(test only)* | Unit tests for `SharedCode.Data` | | `SharedCode.Data` | `SharedCode.Data` | Data access abstractions and helpers (repository pattern, paging, query results) | | `SharedCode.Data.CosmosDb` | `SharedCode.Data.CosmosDb` | Azure Cosmos DB integration | | `SharedCode.Data.EntityFramework` | `SharedCode.Data.EntityFramework` | Entity Framework Core integration (auditable contexts, EF repository, specifications) | @@ -37,7 +38,7 @@ dotnet test --logger GitHubActions --verbosity normal SharedCode.sln - **Target frameworks**: - Primary/current: `.NET 10` (`net10.0`) - Compatibility targets: `.NET 9` (`net9.0`), `.NET 8` (`net8.0`), and `.NET Standard 2.0/2.1` where applicable -- **Language version**: `preview` (latest C# features enabled) +- **Language version**: `preview` (latest C# features enabled — C# 13 and beyond) - **Nullable reference types**: enabled (`enable`) - **Implicit usings**: enabled - **Warnings as errors**: all warnings are treated as errors @@ -93,6 +94,33 @@ public static DateTime AddBusinessDays(this DateTime @this, int days) { ... } - Prefer `ArgumentException.ThrowIfNullOrEmpty(param)` for strings (.NET 7+) - When targeting older frameworks via polyfill, wrap with `#if NET6_0_OR_GREATER` +### C# 13 / .NET 10 Modern Features + +Use these features wherever they improve clarity. The solution targets `LangVersion: preview`, so +all C# 13 features are available: + +- **Collection expressions** (`[..]`) — prefer over `new List { }` or `new T[] { }` literals + ```csharp + string[] names = ["Alice", "Bob"]; + List ids = [1, 2, 3]; + ``` +- **`params ReadOnlySpan`** — prefer for methods that accept a variable number of arguments when + performance matters and heap allocation should be avoided +- **Primary constructors** — prefer for simple types that store injected dependencies: + ```csharp + public sealed class MyService(ILogger logger) { ... } + ``` +- **`field` keyword** — use inside property accessors to access the auto-generated backing field + without declaring it explicitly (C# 13 preview): + ```csharp + public string Name + { + get; + set => field = value.Trim(); + } + ``` +- **`allows ref struct`** — add to generic constraints when the type parameter may be a ref struct + ### Code Analysis Suppressions - Suppress with `[SuppressMessage("Category", "RuleId:Title", Justification = "reason")]` @@ -135,14 +163,22 @@ Services implement `IDependencyRegister` to self-register via assembly scanning ## Testing Conventions -Unit tests live in `SharedCode.Core.Tests/` and follow these rules: +Unit tests live in `SharedCode.Core.Tests/` and `SharedCode.Data.Tests/` and follow these rules: -- **Framework**: MSTest (`[TestClass]`, `[TestMethod]`, `[DataRow]`) +- **Framework**: MSTest (`[TestClass]`, `[TestMethod]`, `[DataRow]`, `[DataTestMethod]`) - **Assertions**: AwesomeAssertions - **Pattern**: Arrange / Act / Assert with blank lines separating each block - **File location**: mirror the source structure (e.g., `Calendar/DateTimeExtensionsTests.cs` for `Calendar/DateTimeExtensions.cs`) -- Suppress `CA1515` on test classes — MSTest requires them to be `public` +- `CA1515` (`public` types as internal) is suppressed at the test-project level via `.editorconfig` — MSTest requires test classes to be `public` + +### `SharedCode.Data.Tests` + +The `SharedCode.Data.Tests` project mirrors the structure of `SharedCode.Data` and tests: + +- Repository base types (`QueryRepository`, `CommandRepository`) +- Paging helpers (`PagingDescriptor`, `PageBoundry`) +- Query result types (`QueryResult`) ## Shared Build Configuration diff --git a/.github/prompts/add-test-class.prompt.md b/.github/prompts/add-test-class.prompt.md new file mode 100644 index 0000000..270a473 --- /dev/null +++ b/.github/prompts/add-test-class.prompt.md @@ -0,0 +1,107 @@ +--- +mode: edit +description: Add a new MSTest test class for a SharedCode source file following project conventions. +--- + +# Add a Test Class + +Add a new MSTest test class that exercises a source file in the SharedCode library. + +## Steps + +1. **Identify the source file** you want to test and locate it in the solution. + +2. **Choose the right test project** + + | Source project | Test project | + |---|---| + | `SharedCode.Core` | `SharedCode.Core.Tests` | + | `SharedCode.Data` | `SharedCode.Data.Tests` | + +3. **Mirror the source folder structure** + + Place the new file in the same relative subfolder as the source: + + | Source file | Test file | + |---|---| + | `SharedCode.Core/Calendar/DateTimeExtensions.cs` | `SharedCode.Core.Tests/Calendar/DateTimeExtensionsTests.cs` | + | `SharedCode.Data/Paging/PagingDescriptor.cs` | `SharedCode.Data.Tests/PagingDescriptorTests.cs` | + +4. **Write the test class** following these rules: + - Annotate with `[TestClass]` + - Do **not** add a per-class `CA1515` suppression — it is already disabled at the project level via `.editorconfig` + - Use `[TestMethod]` for single-scenario tests + - Use `[DataTestMethod]` + `[DataRow(...)]` for parameterized tests + - Follow the **Arrange / Act / Assert** pattern with blank lines separating each block + - Use **MSTest assertions** (`Assert.AreEqual`, `Assert.IsTrue`, `Assert.IsNotNull`, `Assert.ThrowsExactly`) + - Name test methods as `__` + +5. **Verify zero warnings**: `dotnet build SharedCode.sln` + +## Template — single-scenario test + +```csharp +namespace SharedCode.Tests.; + +using Microsoft.VisualStudio.TestTools.UnitTesting; + +/// +/// Tests for . +/// +[TestClass] +public class Tests +{ + /// + /// Tests that does [expected behavior]. + /// + [TestMethod] + public void __() + { + // Arrange + var sut = ; + + // Act + var result = sut.(...); + + // Assert + Assert.AreEqual(, result); + } +} +``` + +## Template — parameterized test + +```csharp +/// +/// Tests that returns the expected result for various inputs. +/// +[DataTestMethod] +[DataRow(, )] +[DataRow(, )] +public void __( input, expected) +{ + // Arrange + var sut = ; + + // Act + var result = sut.(input); + + // Assert + Assert.AreEqual(expected, result); +} +``` + +## Template — exception test + +```csharp +/// +/// Tests that throws when [condition]. +/// +[TestMethod] +public void __Throws() +{ + // Act / Assert + _ = Assert.ThrowsExactly<>( + () => .()); +} +``` diff --git a/.github/prompts/create-module.prompt.md b/.github/prompts/create-module.prompt.md index c6a3fa4..0d3ef0b 100644 --- a/.github/prompts/create-module.prompt.md +++ b/.github/prompts/create-module.prompt.md @@ -27,7 +27,7 @@ belong here. A library of [short description] shared for free use to help with common scenarios. shared code, c#, [relevant tags] SharedCode. - net9.0;net10.0 + net8.0;net9.0;net10.0 diff --git a/.github/prompts/improve-coverage.prompt.md b/.github/prompts/improve-coverage.prompt.md new file mode 100644 index 0000000..499965e --- /dev/null +++ b/.github/prompts/improve-coverage.prompt.md @@ -0,0 +1,70 @@ +--- +mode: edit +description: Identify untested public members and add test coverage for them. +--- + +# Improve Test Coverage + +Identify public members in a SharedCode source file (or folder) that have no test coverage +and add tests for them. + +## Process + +### 1 — Identify what is missing + +For each `.cs` file in the source project: + +1. List every `public` method, property, and indexer. +2. Open the corresponding `*Tests.cs` file in the test project (if it exists). +3. Note every member that has **no** `[TestMethod]` exercising it. +4. If no test file exists at all, every public member needs coverage. + +### 2 — Prioritize + +Cover members in this order: +1. Pure logic methods (no I/O or infrastructure) — easiest to test +2. Guard-clause paths (`ArgumentNullException`, `ArgumentException`) +3. Edge cases (empty collections, null-optional parameters, boundary values) +4. Happy paths for remaining members + +### 3 — Write the tests + +Follow the conventions in `add-test-class.prompt.md`: +- `[TestMethod]` for single scenarios +- `[DataRow]` for parameterized scenarios +- MSTest assertions (`Assert.AreEqual`, `Assert.IsTrue`, `Assert.ThrowsExactly`) +- Arrange / Act / Assert blocks separated by blank lines + +### 4 — Verify + +```bash +dotnet test SharedCode.sln +``` + +Zero failures required before merging. + +## Checklist per source file + +Run through these questions for each public member: + +- [ ] Is there a happy-path test? +- [ ] Is there a null-argument test (if the member accepts reference-type parameters)? +- [ ] Is there a boundary/edge-case test (empty string, zero, `int.MaxValue`, etc.)? +- [ ] Is the test parameterized with `[DataRow]` instead of repeated copy-paste? + +## Common coverage gaps in this solution + +| Source file | Members typically missing coverage | +|---|---| +| `AssemblyExtensions.cs` | `GetAttribute` (found / not found) | +| `EventHandlerExtensions.cs` | `Raise` overloads (null handler, non-null handler) | +| `Extensions.cs` | `IsBetween`, `In`, `IfNotNull`, `IsNull`, `ChangeType` | +| `FunctionExtensions.cs` | `Memoize` (cache hit, cache miss) | +| `TypeExtensions.cs` | `GetDisplayName`, `IsNullable`, `IsSubclassOfRawGeneric` | +| `PropertySupport.cs` | `ExtractPropertyName` | +| `Linq/` | All `IEnumerable` extension methods | +| `Security/` | Hashing / encryption helpers | +| `Text/` | All string extension methods | +| `Threading/` | All task/threading helpers | +| `Domain/` | `ValueObject` equality | +| `Specifications/` | `InMemorySpecificationEvaluator` | diff --git a/.github/prompts/maintain-copilot-instructions.prompt.md b/.github/prompts/maintain-copilot-instructions.prompt.md index 1481270..73f4fbf 100644 --- a/.github/prompts/maintain-copilot-instructions.prompt.md +++ b/.github/prompts/maintain-copilot-instructions.prompt.md @@ -25,14 +25,16 @@ Run this prompt after adding new modules, changing conventions, or updating the - [ ] `add-extension-method.prompt.md` — namespace table is complete and templates compile - [ ] `add-specification.prompt.md` — builder API still matches `ISpecificationBuilder` +- [ ] `add-test-class.prompt.md` — assertion style matches the project's test conventions - [ ] `fix-code-analysis.prompt.md` — rule table covers the analyzers actually in use - [ ] `create-module.prompt.md` — scaffolding steps still match the solution structure +- [ ] `improve-coverage.prompt.md` — coverage gap table is accurate - [ ] Each prompt's `description` front-matter field is accurate ### `.vscode/mcp.json` - [ ] Listed MCP servers are still current and useful -- [ ] Docker image tags or npx package versions are up to date +- [ ] npx package versions are up to date - [ ] Any new MCP servers that would improve development tasks should be added ## Process diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml index e4841f4..ab44550 100644 --- a/.github/workflows/dotnet.yml +++ b/.github/workflows/dotnet.yml @@ -22,4 +22,12 @@ jobs: dotnet-version: 10.0.x - name: Run tests - run: dotnet test --logger GitHubActions SharedCode.sln + run: dotnet test --logger GitHubActions --collect:"XPlat Code Coverage" SharedCode.sln + + - name: Upload coverage reports + if: always() + uses: actions/upload-artifact@v4 + with: + name: coverage-reports + path: '**/coverage.cobertura.xml' + if-no-files-found: ignore diff --git a/.github/workflows/maintain-copilot-instructions.yml b/.github/workflows/maintain-copilot-instructions.yml index 9d24a9c..1822b9a 100644 --- a/.github/workflows/maintain-copilot-instructions.yml +++ b/.github/workflows/maintain-copilot-instructions.yml @@ -50,6 +50,22 @@ jobs: fi done <<< "$projects" + # Check that required prompt files exist + required_prompts=( + ".github/prompts/add-extension-method.prompt.md" + ".github/prompts/add-specification.prompt.md" + ".github/prompts/add-test-class.prompt.md" + ".github/prompts/create-module.prompt.md" + ".github/prompts/fix-code-analysis.prompt.md" + ".github/prompts/improve-coverage.prompt.md" + ".github/prompts/maintain-copilot-instructions.prompt.md" + ) + for prompt in "${required_prompts[@]}"; do + if [[ ! -f "$prompt" ]]; then + missing_list="${missing_list}\n- Missing prompt: \`${prompt}\`" + fi + done + if [[ -n "$missing_list" ]]; then echo "has_missing=true" >> "$GITHUB_OUTPUT" else @@ -74,18 +90,18 @@ jobs: const body = [ '## Copilot Instructions May Be Outdated', '', - 'The following projects are present in `SharedCode.sln` but are not yet', - 'documented in `.github/copilot-instructions.md`:', + 'The following projects or prompt files are missing from the Copilot configuration:', '', missing, '', '### What to do', '', - '1. Open `.github/copilot-instructions.md` and add the missing projects to', + '1. Open `.github/copilot-instructions.md` and add any missing projects to', ' the module table.', - '2. Use the prompt at `.github/prompts/maintain-copilot-instructions.prompt.md`', + '2. Ensure all required prompt files exist under `.github/prompts/`.', + '3. Use the prompt at `.github/prompts/maintain-copilot-instructions.prompt.md`', ' to guide a full review of all Copilot configuration files.', - '3. Close this issue once the instructions are up to date.', + '4. Close this issue once the instructions are up to date.', ].join('\n'); const label = 'copilot-instructions'; diff --git a/.vscode/mcp.json b/.vscode/mcp.json index 97abdf7..0f59544 100644 --- a/.vscode/mcp.json +++ b/.vscode/mcp.json @@ -9,18 +9,28 @@ ], "servers": { "github": { - "command": "docker", + "command": "npx", "args": [ - "run", - "--interactive", - "--rm", - "--env", - "GITHUB_PERSONAL_ACCESS_TOKEN", - "ghcr.io/github/github-mcp-server" + "-y", + "@github/github-mcp-server" ], "env": { "GITHUB_PERSONAL_ACCESS_TOKEN": "${input:github-token}" } + }, + "sequential-thinking": { + "command": "npx", + "args": [ + "-y", + "@modelcontextprotocol/server-sequential-thinking" + ] + }, + "dotnet-skills": { + "command": "npx", + "args": [ + "-y", + "@microsoft/dotnet-mcp-server" + ] } } } diff --git a/Directory.Build.props b/Directory.Build.props index 00a7305..3595c88 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -107,8 +107,8 @@ false - - + + diff --git a/Directory.Packages.props b/Directory.Packages.props index 9d4cf76..0c89f31 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -5,8 +5,8 @@ $(NoWarn);NU1507 - - + + diff --git a/SharedCode.Core.Tests/.editorconfig b/SharedCode.Core.Tests/.editorconfig index 79bfd7f..a6f889b 100644 --- a/SharedCode.Core.Tests/.editorconfig +++ b/SharedCode.Core.Tests/.editorconfig @@ -1,2 +1,3 @@ [*.cs] dotnet_diagnostic.CA1707.severity = none +dotnet_diagnostic.CA1515.severity = none diff --git a/SharedCode.Core.Tests/AssemblyExtensionsTests.cs b/SharedCode.Core.Tests/AssemblyExtensionsTests.cs new file mode 100644 index 0000000..96a22a6 --- /dev/null +++ b/SharedCode.Core.Tests/AssemblyExtensionsTests.cs @@ -0,0 +1,61 @@ +namespace SharedCode.Tests; + +using Microsoft.VisualStudio.TestTools.UnitTesting; + +using System.Reflection; + +/// +/// Tests for . +/// +[TestClass] +public class AssemblyExtensionsTests +{ + /// + /// Tests that returns the attribute when it + /// is present on the assembly. + /// + [TestMethod] + public void GetAttribute_AssemblyHasAttribute_ReturnsAttribute() + { + // Arrange + var assembly = typeof(AssemblyExtensionsTests).Assembly; + + // Act + var result = assembly.GetAttribute(); + + // Assert + Assert.IsNotNull(result); + } + + /// + /// Tests that returns null when the + /// attribute is not present on the assembly. + /// + [TestMethod] + public void GetAttribute_AssemblyMissingAttribute_ReturnsNull() + { + // Arrange + var assembly = typeof(AssemblyExtensionsTests).Assembly; + + // Act + var result = assembly.GetAttribute(); + + // Assert + Assert.IsNull(result); + } + + /// + /// Tests that throws + /// when the assembly is null. + /// + [TestMethod] + public void GetAttribute_NullAssembly_ThrowsArgumentNullException() + { + // Arrange + Assembly? assembly = null; + + // Act / Assert + _ = Assert.ThrowsExactly( + () => assembly!.GetAttribute()); + } +} diff --git a/SharedCode.Core.Tests/EventHandlerExtensionsTests.cs b/SharedCode.Core.Tests/EventHandlerExtensionsTests.cs new file mode 100644 index 0000000..ee2330d --- /dev/null +++ b/SharedCode.Core.Tests/EventHandlerExtensionsTests.cs @@ -0,0 +1,89 @@ +namespace SharedCode.Tests; + +using Microsoft.VisualStudio.TestTools.UnitTesting; + +/// +/// Tests for . +/// +[TestClass] +public class EventHandlerExtensionsTests +{ + /// + /// Tests that invokes the + /// handler with . + /// + [TestMethod] + public void EventHandler_InvokesHandlerWithEmptyArgs() + { + // Arrange + object? capturedSender = null; + EventArgs? capturedArgs = null; + EventHandler handler = (s, e) => + { + capturedSender = s; + capturedArgs = e; + }; + var sender = new object(); + + // Act + handler.Raise(sender); + + // Assert + Assert.AreSame(sender, capturedSender); + Assert.AreSame(EventArgs.Empty, capturedArgs); + } + + /// + /// Tests that does not + /// throw when the handler is null. + /// + [TestMethod] + public void EventHandler_NullHandler_DoesNotThrow() + { + // Arrange + EventHandler? handler = null; + + // Act / Assert — should not throw +#pragma warning disable CS8604 // Possible null reference argument — intentional null test + handler!.Raise(new object()); +#pragma warning restore CS8604 + } + + /// + /// Tests that + /// invokes the handler with the expected value wrapped in . + /// + [TestMethod] + public void EventHandlerOfEventArgsT_InvokesHandlerWithWrappedValue() + { + // Arrange + int? capturedValue = null; + EventHandler> handler = (_, e) => capturedValue = e.Value; + var sender = new object(); + + // Act + handler.Raise(sender, 42); + + // Assert + Assert.AreEqual(42, capturedValue); + } + + /// + /// Tests that + /// invokes the handler with the supplied . + /// + [TestMethod] + public void EventHandlerOfT_InvokesHandlerWithSuppliedArgs() + { + // Arrange + EventArgs? capturedArgs = null; + var args = new EventArgs(); + EventHandler handler = (_, e) => capturedArgs = e; + + // Act + handler.Raise(new object(), args); + + // Assert + Assert.AreSame(args, capturedArgs); + } +} diff --git a/SharedCode.Core.Tests/ExtensionsTests.cs b/SharedCode.Core.Tests/ExtensionsTests.cs new file mode 100644 index 0000000..7aa0c1b --- /dev/null +++ b/SharedCode.Core.Tests/ExtensionsTests.cs @@ -0,0 +1,219 @@ +namespace SharedCode.Tests; + +using Microsoft.VisualStudio.TestTools.UnitTesting; + +/// +/// Tests for . +/// +[TestClass] +public class ExtensionsTests +{ + /// + /// Tests that returns when the + /// value is within bounds. + /// + [TestMethod] + [DataRow(5, 1, 10)] + [DataRow(1, 1, 10)] + [DataRow(10, 1, 10)] + public void IsBetween_ValueInRange_ReturnsTrue(int value, int low, int high) + { + // Act + var result = value.IsBetween(low, high); + + // Assert + Assert.IsTrue(result); + } + + /// + /// Tests that returns when + /// the value is outside bounds. + /// + [TestMethod] + [DataRow(0, 1, 10)] + [DataRow(11, 1, 10)] + public void IsBetween_ValueOutOfRange_ReturnsFalse(int value, int low, int high) + { + // Act + var result = value.IsBetween(low, high); + + // Assert + Assert.IsFalse(result); + } + + /// + /// Tests that returns when the value + /// is in the list. + /// + [TestMethod] + public void In_ValueIsInList_ReturnsTrue() + { + // Arrange + const int value = 3; + + // Act + var result = value.In(1, 2, 3, 4); + + // Assert + Assert.IsTrue(result); + } + + /// + /// Tests that returns when the value + /// is not in the list. + /// + [TestMethod] + public void In_ValueIsNotInList_ReturnsFalse() + { + // Arrange + const int value = 5; + + // Act + var result = value.In(1, 2, 3, 4); + + // Assert + Assert.IsFalse(result); + } + + /// + /// Tests that invokes the function when the + /// target is not null. + /// + [TestMethod] + public void IfNotNull_TargetNotNull_InvokesFunction() + { + // Arrange + const string target = "hello"; + + // Act + var result = target.IfNotNull(s => s.Length); + + // Assert + Assert.AreEqual(5, result); + } + + /// + /// Tests that returns default when the target + /// is null. + /// + [TestMethod] + public void IfNotNull_TargetIsNull_ReturnsDefault() + { + // Arrange + string target = null!; + + // Act + var result = target.IfNotNull(s => s.Length); + + // Assert + Assert.AreEqual(default, result); + } + + /// + /// Tests that returns for a + /// null object. + /// + [TestMethod] + public void IsNull_NullObject_ReturnsTrue() + { + // Arrange — use a nullable wrapper to avoid CS8602 on calling extension on null directly + object? obj = null; + +#pragma warning disable CS8604 // Possible null reference argument — intentional null test + // Act + var result = obj.IsNull(); +#pragma warning restore CS8604 + + // Assert + Assert.IsTrue(result); + } + + /// + /// Tests that returns for a + /// non-null object. + /// + [TestMethod] + public void IsNotNull_NonNullObject_ReturnsTrue() + { + // Arrange + object obj = new(); + + // Act + var result = obj.IsNotNull(); + + // Assert + Assert.IsTrue(result); + } + + /// + /// Tests that returns the fallback value + /// when conversion fails. + /// + [TestMethod] + public void ChangeType_ConversionFails_ReturnsFallback() + { + // Arrange + object source = "not-a-number"; + + // Act + var result = source.ChangeType(-1); + + // Assert + Assert.AreEqual(-1, result); + } + + /// + /// Tests that converts an integer to string. + /// + [TestMethod] + public void ChangeType_ValidConversion_ReturnsConvertedValue() + { + // Arrange + object source = 42; + + // Act + var result = source.ChangeType(); + + // Assert + Assert.AreEqual("42", result); + } + + /// + /// Tests that returns the correct property + /// value via reflection. + /// + [TestMethod] + public void GetPropertyValue_ValidProperty_ReturnsValue() + { + // Arrange + var obj = new SampleRecord("World"); + + // Act + var result = obj.GetPropertyValue("Greeting"); + + // Assert + Assert.AreEqual("World", result); + } + + /// + /// Tests that returns null when the property + /// does not exist. + /// + [TestMethod] + public void GetPropertyValue_MissingProperty_ReturnsNull() + { + // Arrange + var obj = new SampleRecord("World"); + + // Act + var result = obj.GetPropertyValue("NonExistent"); + + // Assert + Assert.IsNull(result); + } + + /// + /// A simple record used as a reflection target. + /// + private sealed record SampleRecord(string Greeting); +} diff --git a/SharedCode.Core.Tests/FunctionExtensionsTests.cs b/SharedCode.Core.Tests/FunctionExtensionsTests.cs new file mode 100644 index 0000000..2b42d23 --- /dev/null +++ b/SharedCode.Core.Tests/FunctionExtensionsTests.cs @@ -0,0 +1,99 @@ +namespace SharedCode.Tests; + +using Microsoft.VisualStudio.TestTools.UnitTesting; + +/// +/// Tests for . +/// +[TestClass] +public class FunctionExtensionsTests +{ + /// + /// Tests that returns the correct result + /// on the first call (cache miss). + /// + [TestMethod] + public void Memoize_CacheMiss_ReturnsCorrectResult() + { + // Arrange + var callCount = 0; + Func func = n => + { + callCount++; + return n.ToString(System.Globalization.CultureInfo.InvariantCulture); + }; + var memoized = func.Memoize(); + + // Act + var result = memoized(5); + + // Assert + Assert.AreEqual("5", result); + Assert.AreEqual(1, callCount); + } + + /// + /// Tests that returns the cached result + /// without invoking the original function a second time (cache hit). + /// + [TestMethod] + public void Memoize_CacheHit_DoesNotInvokeFunctionAgain() + { + // Arrange + var callCount = 0; + Func func = n => + { + callCount++; + return n.ToString(System.Globalization.CultureInfo.InvariantCulture); + }; + var memoized = func.Memoize(); + + // Act + _ = memoized(7); + var result = memoized(7); + + // Assert + Assert.AreEqual("7", result); + Assert.AreEqual(1, callCount); + } + + /// + /// Tests that caches different keys + /// independently. + /// + [TestMethod] + public void Memoize_DifferentKeys_CachedSeparately() + { + // Arrange + var callCount = 0; + Func func = n => + { + callCount++; + return n.ToString(System.Globalization.CultureInfo.InvariantCulture); + }; + var memoized = func.Memoize(); + + // Act + var result1 = memoized(1); + var result2 = memoized(2); + + // Assert + Assert.AreEqual("1", result1); + Assert.AreEqual("2", result2); + Assert.AreEqual(2, callCount); + } + + /// + /// Tests that throws + /// when the function is null. + /// + [TestMethod] + public void Memoize_NullFunction_ThrowsArgumentNullException() + { + // Arrange + Func? func = null; + + // Act / Assert + _ = Assert.ThrowsExactly(() => func!.Memoize()); + } +} diff --git a/SharedCode.Core.Tests/PropertySupportTests.cs b/SharedCode.Core.Tests/PropertySupportTests.cs new file mode 100644 index 0000000..bfabbc8 --- /dev/null +++ b/SharedCode.Core.Tests/PropertySupportTests.cs @@ -0,0 +1,48 @@ +namespace SharedCode.Tests; + +using Microsoft.VisualStudio.TestTools.UnitTesting; + +/// +/// Tests for . +/// +[TestClass] +public class PropertySupportTests +{ + /// + /// Tests that returns the correct + /// property name from a valid property expression. + /// + [TestMethod] + public void ExtractPropertyName_ValidPropertyExpression_ReturnsPropertyName() + { + // Arrange + var target = new SampleClass(); + + // Act + var result = PropertySupport.ExtractPropertyName(() => target.Name); + + // Assert + Assert.AreEqual(nameof(SampleClass.Name), result); + } + + /// + /// Tests that throws + /// when the expression is null. + /// + [TestMethod] + public void ExtractPropertyName_NullExpression_ThrowsArgumentNullException() + { + // Act / Assert + _ = Assert.ThrowsExactly( + () => PropertySupport.ExtractPropertyName(null!)); + } + + /// + /// A simple class used as a target for property expression tests. + /// + private sealed class SampleClass + { + /// Gets or sets the name. + public string Name { get; set; } = string.Empty; + } +} diff --git a/SharedCode.Core.Tests/TypeExtensionsTests.cs b/SharedCode.Core.Tests/TypeExtensionsTests.cs new file mode 100644 index 0000000..34695d3 --- /dev/null +++ b/SharedCode.Core.Tests/TypeExtensionsTests.cs @@ -0,0 +1,196 @@ +namespace SharedCode.Tests; + +using Microsoft.VisualStudio.TestTools.UnitTesting; + +/// +/// Tests for . +/// +[TestClass] +public class TypeExtensionsTests +{ + /// + /// Tests that inserts spaces before capital + /// letters in a PascalCase type name. + /// + [TestMethod] + public void GetDisplayName_PascalCaseTypeName_InsertsSpacesBeforeCapitals() + { + // Arrange + var type = typeof(TypeExtensionsTests); + + // Act + var result = type.GetDisplayName(); + + // Assert + Assert.AreEqual("Type Extensions Tests", result); + } + + /// + /// Tests that returns for a + /// type. + /// + [TestMethod] + public void IsNullable_NullableType_ReturnsTrue() + { + // Arrange + var type = typeof(int?); + + // Act + var result = type.IsNullable(); + + // Assert + Assert.IsTrue(result); + } + + /// + /// Tests that returns for a + /// non-nullable value type. + /// + [TestMethod] + public void IsNullable_NonNullableValueType_ReturnsFalse() + { + // Arrange + var type = typeof(int); + + // Act + var result = type.IsNullable(); + + // Assert + Assert.IsFalse(result); + } + + /// + /// Tests that returns when + /// called on a null type reference. + /// + [TestMethod] + public void IsNullable_NullType_ReturnsFalse() + { + // Arrange + Type? type = null; + + // Act + var result = type.IsNullable(); + + // Assert + Assert.IsFalse(result); + } + + /// + /// Tests that returns for + /// . + /// + [TestMethod] + public void IsBoolean_BoolType_ReturnsTrue() + { + // Arrange + var type = typeof(bool); + + // Act + var result = type.IsBoolean(); + + // Assert + Assert.IsTrue(result); + } + + /// + /// Tests that returns for a + /// non-boolean type. + /// + [TestMethod] + public void IsBoolean_NonBoolType_ReturnsFalse() + { + // Arrange + var type = typeof(int); + + // Act + var result = type.IsBoolean(); + + // Assert + Assert.IsFalse(result); + } + + /// + /// Tests that returns for + /// . + /// + [TestMethod] + public void IsString_StringType_ReturnsTrue() + { + // Arrange + var type = typeof(string); + + // Act + var result = type.IsString(); + + // Assert + Assert.IsTrue(result); + } + + /// + /// Tests that returns for a + /// non-string type. + /// + [TestMethod] + public void IsString_NonStringType_ReturnsFalse() + { + // Arrange + var type = typeof(int); + + // Act + var result = type.IsString(); + + // Assert + Assert.IsFalse(result); + } + + /// + /// Tests that returns the correct base type. + /// + [TestMethod] + public void BaseType_DerivedClass_ReturnsBaseClass() + { + // Arrange + var type = typeof(ArgumentNullException); + + // Act + var result = type.BaseType(); + + // Assert + Assert.AreEqual(typeof(ArgumentException), result); + } + + /// + /// Tests that returns + /// when the ancestor type name matches. + /// + [TestMethod] + public void IsSubclassOfTypeByName_MatchingAncestorName_ReturnsTrue() + { + // Arrange + var type = typeof(ArgumentNullException); + + // Act + var result = type.IsSubclassOfTypeByName(nameof(ArgumentException)); + + // Assert + Assert.IsTrue(result); + } + + /// + /// Tests that returns + /// when the ancestor type name does not match. + /// + [TestMethod] + public void IsSubclassOfTypeByName_NoMatchingAncestorName_ReturnsFalse() + { + // Arrange + var type = typeof(ArgumentNullException); + + // Act + var result = type.IsSubclassOfTypeByName("NonExistentBase"); + + // Assert + Assert.IsFalse(result); + } +} diff --git a/SharedCode.Data.Tests/.editorconfig b/SharedCode.Data.Tests/.editorconfig index 79bfd7f..a6f889b 100644 --- a/SharedCode.Data.Tests/.editorconfig +++ b/SharedCode.Data.Tests/.editorconfig @@ -1,2 +1,3 @@ [*.cs] dotnet_diagnostic.CA1707.severity = none +dotnet_diagnostic.CA1515.severity = none