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