diff --git a/.github/actions/push-package/action.yml b/.github/actions/push-package/action.yml
index cb0ab02..862ba5c 100644
--- a/.github/actions/push-package/action.yml
+++ b/.github/actions/push-package/action.yml
@@ -9,29 +9,22 @@ inputs:
default: 'false'
required: false
config:
- description: 'The build configuration (Build or Release)'
+ description: 'The build configuration (Debug or Release)'
default: Release
required: true
version:
description: 'The package version'
required: true
ghapikey:
- description: 'The Git Hub Api Key for package feed'
+ description: 'The GitHub API Key for package feed'
required: true
nugetapikey:
- description: 'The Nuget Api Key for package feed'
+ description: 'The NuGet API Key for package feed'
required: true
registry:
- description: 'The package registry (github or nuget)'
+ description: 'The package registry (GitHub or NuGet)'
default: github
required: true
-outputs:
- coverage-line:
- description: 'Line coverage percentage'
- value: ${{ steps.extract-coverage.outputs.line }}
- coverage-branch:
- description: 'Branch coverage percentage'
- value: ${{ steps.extract-coverage.outputs.branch }}
runs:
using: "composite"
steps:
@@ -41,29 +34,12 @@ runs:
run: dotnet build ./src/${{ inputs.project }}/${{ inputs.project }}.csproj -c ${{ inputs.config }} --verbosity minimal
shell: bash
- # Test
+ # Test and collect coverage
- name: Test
if: ${{ inputs.skiptest == 'false' }}
run: dotnet test ./test/${{ inputs.project }}.Tests/${{ inputs.project }}.Tests.csproj -c ${{ inputs.config }} --verbosity minimal --collect:"XPlat Code Coverage" --results-directory ./coverage
shell: bash
- # Generate coverage report
- - name: Coverage Report
- if: ${{ inputs.skiptest == 'false' }}
- run: reportgenerator -reports:./coverage/**/coverage.cobertura.xml -targetdir:./coverage/report -reporttypes:JsonSummary
- shell: bash
-
- # Extract line and branch coverage percentages
- - name: Extract Coverage
- id: extract-coverage
- if: ${{ inputs.skiptest == 'false' }}
- run: |
- LINE=$(cat ./coverage/report/Summary.json | python3 -c "import sys,json; d=json.load(sys.stdin); print(round(d['summary']['linecoverage']))")
- BRANCH=$(cat ./coverage/report/Summary.json | python3 -c "import sys,json; d=json.load(sys.stdin); print(round(d['summary']['branchcoverage']))")
- echo "line=$LINE" >> $GITHUB_OUTPUT
- echo "branch=$BRANCH" >> $GITHUB_OUTPUT
- shell: bash
-
# Pack
- name: Pack
run: |
@@ -71,21 +47,21 @@ runs:
dotnet pack ./src/${{ inputs.project }}/${{ inputs.project }}.csproj -p:PackageVersion=${{ inputs.version }} --no-build -c ${{ inputs.config }} -o _packages -v minimal
shell: bash
- # GITHUB FEED
- - name: Github Push
+ # GitHub Feed
+ - name: GitHub Push
if: ${{ inputs.registry == 'github' }}
run: dotnet nuget push _packages/${{ inputs.project }}.${{ inputs.version }}.nupkg --skip-duplicate --no-symbols --api-key ${{ inputs.ghapikey }} --source github
shell: bash
- # NUGET FEED
- # First push to github (for package cadence, Nuget requires package verification)
- - name: Github Push
+ # NuGet Feed
+ # First push to GitHub (for package cadence, NuGet requires package verification)
+ - name: GitHub Push
if: ${{ inputs.registry == 'nuget' }}
run: dotnet nuget push _packages/${{ inputs.project }}.${{ inputs.version }}.nupkg --skip-duplicate --no-symbols --api-key ${{ inputs.ghapikey }} --source github
shell: bash
- # Second push to Nuget (for package cadence, Github will be used and in production Nuget after package verification)
- - name: Nuget Push
+ # Second push to NuGet (for package cadence, GitHub will be used and in production NuGet after package verification)
+ - name: NuGet Push
if: ${{ inputs.registry == 'nuget' }}
run: dotnet nuget push _packages/${{ inputs.project }}.${{ inputs.version }}.nupkg --skip-duplicate --no-symbols --api-key ${{ inputs.nugetapikey }} --source nuget.org
shell: bash
diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md
new file mode 100644
index 0000000..62c46fb
--- /dev/null
+++ b/.github/copilot-instructions.md
@@ -0,0 +1,4 @@
+# Copilot Instructions
+
+## Project Guidelines
+- Never add section separator comments (e.g., // ── SectionName ──────) in code.
\ No newline at end of file
diff --git a/.github/workflows/build-test-cross.yml b/.github/workflows/build-test-cross.yml
index 5953972..14926fa 100644
--- a/.github/workflows/build-test-cross.yml
+++ b/.github/workflows/build-test-cross.yml
@@ -66,6 +66,11 @@ jobs:
- name: Checkout
uses: actions/checkout@v6
+ # Read env
+ - name: Read Env
+ run: cat .github/package_version.env >> $GITHUB_ENV
+ continue-on-error: false
+
# Setup .Net with global.json
- name: Setup .NET
uses: actions/setup-dotnet@v5
@@ -99,6 +104,11 @@ jobs:
- name: Checkout
uses: actions/checkout@v6
+ # Read env
+ - name: Read Env
+ run: cat .github/package_version.env >> $GITHUB_ENV
+ continue-on-error: false
+
# Setup .Net with global.json
- name: Setup .NET
uses: actions/setup-dotnet@v5
diff --git a/.github/workflows/build-test-push.yml b/.github/workflows/build-test-push.yml
index f3cc366..e9f2a9e 100644
--- a/.github/workflows/build-test-push.yml
+++ b/.github/workflows/build-test-push.yml
@@ -6,7 +6,7 @@
name: build-test-push
# 1 release at a time
-concurrency: shared
+concurrency: ${{ github.event.repository.name }}
# CICD. The rc and release are a manual trigger
on:
@@ -116,14 +116,13 @@ jobs:
run: dotnet tool install --global dotnet-reportgenerator-globaltool
continue-on-error: false
- # Setup local
- # OneImlx.Shared is the first package so we use the local project reference to avoid cyclic Nuget dependency resolution.
+ # OneImlx.Shared is the first package so we use the cross project reference to avoid cyclic Nuget dependency resolution.
# See OneImlx.Shared.Test.csproj for more info.
- - name: Force local
+ - name: Force cross
run: echo "PI_CI_REFERENCE=cross" >> $GITHUB_ENV
continue-on-error: false
- # Publish OneImlx.Shared
+ # Publish OneImlx.Shared (deposits coverage into ./coverage)
- name: OneImlx.Shared
id: oneimlx-shared
uses: ./.github/actions/push-package
@@ -135,7 +134,7 @@ jobs:
nugetapikey: ${{ env.PI_NUGET_PAT_ENV }}
registry: ${{ env.PI_PUBLISH_REGISTRY }}
- # Setup package
+ # Switch to package references for downstream packages
- name: Force package
run: echo "PI_CI_REFERENCE=package" >> $GITHUB_ENV
continue-on-error: false
@@ -146,13 +145,25 @@ jobs:
uses: ./.github/actions/push-package
with:
project: OneImlx.Test
- skiptest: 'true'
config: Release
version: ${{ env.PI_CI_PACKAGE_VERSION }}
ghapikey: ${{ env.PI_GITHUB_PAT_ENV }}
nugetapikey: ${{ env.PI_NUGET_PAT_ENV }}
registry: ${{ env.PI_PUBLISH_REGISTRY }}
+ # Aggregate coverage report across all packages deposited into ./coverage
+ - name: Coverage Report
+ run: reportgenerator -reports:./coverage/**/coverage.cobertura.xml -targetdir:./coverage/report -reporttypes:JsonSummary
+ shell: bash
+
+ # Extract aggregate line and branch coverage
+ - name: Extract Coverage
+ id: coverage
+ run: |
+ echo "line=$(jq '.summary.linecoverage' ./coverage/report/Summary.json | xargs printf '%.0f')" >> $GITHUB_OUTPUT
+ echo "branch=$(jq '.summary.branchcoverage' ./coverage/report/Summary.json | xargs printf '%.0f')" >> $GITHUB_OUTPUT
+ shell: bash
+
# Update line coverage badge in Gist
- name: Line Coverage Badge
uses: Schneegans/dynamic-badges-action@v1.8.0
@@ -161,8 +172,8 @@ jobs:
gistID: ${{ secrets.PI_GITHUB_GIST_ID }}
filename: coverage_${{ github.event.repository.name }}_line.json
label: coverage-line
- message: ${{ steps.oneimlx-shared.outputs.coverage-line }}%
- valColorRange: ${{ steps.oneimlx-shared.outputs.coverage-line }}
+ message: ${{ steps.coverage.outputs.line }}%
+ valColorRange: ${{ steps.coverage.outputs.line }}
maxColorRange: 100
minColorRange: 0
@@ -174,7 +185,7 @@ jobs:
gistID: ${{ secrets.PI_GITHUB_GIST_ID }}
filename: coverage_${{ github.event.repository.name }}_branch.json
label: coverage-branch
- message: ${{ steps.oneimlx-shared.outputs.coverage-branch }}%
- valColorRange: ${{ steps.oneimlx-shared.outputs.coverage-branch }}
+ message: ${{ steps.coverage.outputs.branch }}%
+ valColorRange: ${{ steps.coverage.outputs.branch }}
maxColorRange: 100
minColorRange: 0
diff --git a/README.md b/README.md
index 9c22ab4..804cbb1 100644
--- a/README.md
+++ b/README.md
@@ -5,8 +5,8 @@ This repository contains the shared components for our cross-platform frameworks
[](https://www.nuget.org/packages/OneImlx.Test)
## Status
-[](https://github.com/perpetualintelligence/shared/actions/workflows/build-test-cross-manual.yml)
-[](https://github.com/perpetualintelligence/shared/actions/workflows/build-test-push.yml)
+[](https://github.com/perpetualintelligence/shared/actions/workflows/build-test-cross.yml)
+[](https://github.com/perpetualintelligence/shared/actions/workflows/build-test-push.yml)


diff --git a/Shared.All.Solution.slnx b/Shared.All.Solution.slnx
index aa761fd..7571cba 100644
--- a/Shared.All.Solution.slnx
+++ b/Shared.All.Solution.slnx
@@ -13,5 +13,6 @@
+
diff --git a/build/README.md b/build/README.md
index f6d551a..0cf4ea1 100644
--- a/build/README.md
+++ b/build/README.md
@@ -1,26 +1,26 @@
# Build
## Local Machine
-Follow the steps to set up the `pi-cli` repository on your local development machine.
+Follow the steps to set up the `shared` repository on your local development machine.
-1. Download and install [Visual Studio 2022](https://visualstudio.microsoft.com/vs/)
-2. Clone the [protocols](https://github.com/perpetualintelligence/protocols) GitHub repo
+1. Download and install [Visual Studio 2026](https://visualstudio.microsoft.com/vs/)
+2. Clone the [shared](https://github.com/perpetualintelligence/shared) GitHub repo
3. Set PI_CI_REFERENCE environment variable to `cross`
## CICD
This workflow folder contains the build and deployment pipelines for generating and publishing [Nuget](https://www.nuget.org/profiles/perpetualintelligencellc) and [GitHub](https://github.com/orgs/perpetualintelligence/packages?repo_name=data) packages.
-- *build-test-cross-manual*: The manual action that builds and tests the code changes on Windows, Linux and macOS.
-- *build-test-publish*: The automated action that publishes the packages to [Nuget](https://www.nuget.org/profiles/perpetualintelligencellc) and [GitHub](https://github.com/orgs/perpetualintelligence/packages?repo_name=data), see [releases](https://github.com/perpetualintelligence/cli/releases)
+- *build-test-cross*: The manual action that builds and tests the code changes on Windows, Linux and macOS.
+- *build-test-push*: The automated action that publishes the packages to [Nuget](https://www.nuget.org/profiles/perpetualintelligencellc) and [GitHub](https://github.com/orgs/perpetualintelligence/packages?repo_name=data), see [releases](https://github.com/perpetualintelligence/shared/releases)
- *delete-packages*: The automated action cleans the packages every week and keeps the latest working version. For stable versions, refer to [Nuget](https://www.nuget.org/profiles/perpetualintelligencellc) packages.
-> ***Note: The `build-test-publish` release to Nuget pipeline triggers a deployment approval.***
+> ***Note: The `build-test-push` release to Nuget pipeline triggers a deployment approval.***
## Versioning
All packages follow [sematic](https://semver.org/) versioning schemes. The env file *package_version.env* defines the package versions.
## Project Dependencies
-The *PI_CI_REFERENCE* environment variable defines how *.csproj* references the dependencies for CI and local development. It supportes the following values:
+The *PI_CI_REFERENCE* environment variable defines how *.csproj* references the dependencies for CI and local development. It supports the following values:
- *local*: Project references for local development within the same repo
- *cross*: Project references for local development across repos
- *package*: Package references for CI/CD and deployment
diff --git a/src/OneImlx.Test/Extentions/ObjectExtensions.cs b/src/OneImlx.Test/Extentions/ObjectExtensions.cs
new file mode 100644
index 0000000..5b61f50
--- /dev/null
+++ b/src/OneImlx.Test/Extentions/ObjectExtensions.cs
@@ -0,0 +1,26 @@
+// Copyright © 2019-2026 Perpetual Intelligence L.L.C. All rights reserved.
+// For license, terms, and data policies, go to:
+// https://terms.perpetualintelligence.com/articles/intro.html
+
+using FluentAssertions;
+
+namespace OneImlx.Test.Extentions
+{
+ ///
+ /// extension methods for testing.
+ ///
+ public static class ObjectExtensions
+ {
+ ///
+ /// Asserts that the source object is not null and returns the source object for chaining.
+ ///
+ /// The type of the source object.
+ /// The source object to check for null.
+ /// The source object if it is not null.
+ public static T NotNull(this T? source)
+ {
+ source.Should().NotBeNull();
+ return source;
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/OneImlx.Test/FluentAssertions/AssemblyFluentAssertions.cs b/src/OneImlx.Test/FluentAssertions/AssemblyFluentAssertions.cs
index 44b9c2a..fb8bd7d 100644
--- a/src/OneImlx.Test/FluentAssertions/AssemblyFluentAssertions.cs
+++ b/src/OneImlx.Test/FluentAssertions/AssemblyFluentAssertions.cs
@@ -29,7 +29,10 @@ public static AndConstraint HaveTypesInRootNamespace(this As
{
var assembly = assertions.Subject;
var actualNamespace = assembly.GetName().Name ?? throw new InvalidOperationException("Assembly name null");
- actualNamespace.Should().Be(rootNamespace, $"Assembly '{assembly.GetName().Name}' should have root namespace '{rootNamespace}'");
+ if (actualNamespace != rootNamespace)
+ {
+ throw new AssertionFailedException($"Assembly '{assembly.GetName().Name}' should have root namespace '{rootNamespace}'.");
+ }
var types = assembly.GetTypes().Where(e => !IsCompilerGenerated(e));
var invalidTypes = types.Where(e =>
diff --git a/test/OneImlx.Shared.Tests/AssemblyTests.cs b/test/OneImlx.Shared.Tests/AssemblyTests.cs
index 5eadb4b..368ed3b 100644
--- a/test/OneImlx.Shared.Tests/AssemblyTests.cs
+++ b/test/OneImlx.Shared.Tests/AssemblyTests.cs
@@ -1,9 +1,6 @@
-/*
- Copyright (c) 2023 Perpetual Intelligence L.L.C. All Rights Reserved.
-
- For license, terms, and data policies, go to:
- https://terms.perpetualintelligence.com/articles/intro.html
-*/
+// Copyright © 2019-2026 Perpetual Intelligence L.L.C. All rights reserved.
+// For license, terms, and data policies, go to:
+// https://terms.perpetualintelligence.com/articles/intro.html
using FluentAssertions;
using OneImlx.Test.FluentAssertions;
diff --git a/test/OneImlx.Test.Tests/Extensions/ObjectExtensionsTests.cs b/test/OneImlx.Test.Tests/Extensions/ObjectExtensionsTests.cs
new file mode 100644
index 0000000..d309f4f
--- /dev/null
+++ b/test/OneImlx.Test.Tests/Extensions/ObjectExtensionsTests.cs
@@ -0,0 +1,42 @@
+// Copyright © 2019-2026 Perpetual Intelligence L.L.C. All rights reserved.
+// For license, terms, and data policies, go to:
+// https://terms.perpetualintelligence.com/articles/intro.html
+
+using FluentAssertions;
+using System;
+using Xunit;
+
+namespace OneImlx.Test.Extentions
+{
+ public class ObjectExtensionsTests
+ {
+ [Fact]
+ public void NotNullShouldReturnSourceWhenNotNull()
+ {
+ string result = "hello".NotNull();
+ result.Should().Be("hello");
+ }
+
+ [Fact]
+ public void NotNullShouldThrowWhenNull()
+ {
+ string? value = null;
+ FluentActions.Invoking(() => value.NotNull()).Should().Throw();
+ }
+
+ [Fact]
+ public void NotNullShouldReturnSameReferenceForObject()
+ {
+ var obj = new object();
+ var result = obj.NotNull();
+ result.Should().BeSameAs(obj);
+ }
+
+ [Fact]
+ public void NotNullShouldWorkWithValueType()
+ {
+ int result = 42.NotNull();
+ result.Should().Be(42);
+ }
+ }
+}
\ No newline at end of file
diff --git a/test/OneImlx.Test.Tests/FluentAssertions/AssemblyFluentAssertionsTests.cs b/test/OneImlx.Test.Tests/FluentAssertions/AssemblyFluentAssertionsTests.cs
new file mode 100644
index 0000000..94e59d8
--- /dev/null
+++ b/test/OneImlx.Test.Tests/FluentAssertions/AssemblyFluentAssertionsTests.cs
@@ -0,0 +1,84 @@
+// Copyright © 2019-2026 Perpetual Intelligence L.L.C. All rights reserved.
+// For license, terms, and data policies, go to:
+// https://terms.perpetualintelligence.com/articles/intro.html
+
+using System;
+using FluentAssertions;
+using FluentAssertions.Execution;
+using OneImlx.Shared.Infrastructure;
+using Xunit;
+
+namespace OneImlx.Test.FluentAssertions
+{
+ public class AssemblyFluentAssertionsTests
+ {
+ [Fact]
+ public void HaveTypesInRootNamespace_PassesWhenAssemblyNameMatchesRootNamespace()
+ {
+ var assembly = typeof(AssemblyFluentAssertions).Assembly;
+ Action act = () => assembly.Should().HaveTypesInRootNamespace("OneImlx.Test");
+ act.Should().NotThrow();
+ }
+
+ [Fact]
+ public void HaveTypesInRootNamespace_PassesForNestedNamespacesUnderRoot()
+ {
+ var assembly = typeof(AssemblyFluentAssertions).Assembly;
+ Action act = () => assembly.Should().HaveTypesInRootNamespace("OneImlx.Test");
+ act.Should().NotThrow();
+ }
+
+ [Fact]
+ public void HaveTypesInRootNamespace_ThrowsWhenAssemblyNameDoesNotMatchRootNamespace()
+ {
+ var assembly = typeof(AssemblyFluentAssertions).Assembly;
+ Action act = () => assembly.Should().HaveTypesInRootNamespace("WrongNamespace");
+ act.Should().Throw().WithMessage("*Assembly 'OneImlx.Test' should have root namespace 'WrongNamespace'*");
+ }
+
+ [Fact]
+ public void HaveTypesInRootNamespace_ThrowsWhenNamespaceIsConcatenationWithoutNesting()
+ {
+ // "OneImlx.TestXYZ" starts with "OneImlx.Test" but "XYZ" is not "." — treated as concatenation, not nesting
+ var assembly = typeof(AssemblyFluentAssertions).Assembly;
+ Action act = () => assembly.Should().HaveTypesInRootNamespace("OneImlx.TestXYZ");
+ act.Should().Throw().WithMessage("*Assembly 'OneImlx.Test' should have root namespace 'OneImlx.TestXYZ'*");
+ }
+
+ [Fact]
+ public void HaveTypesInRootNamespace_ReturnsAndConstraintForChaining()
+ {
+ var assembly = typeof(AssemblyFluentAssertions).Assembly;
+ var result = assembly.Should().HaveTypesInRootNamespace("OneImlx.Test");
+ result.Should().NotBeNull();
+ }
+
+ [Fact]
+ public void HaveTypesInValidLocations_PassesForOneImlxTestAssembly()
+ {
+ var assembly = typeof(AssemblyFluentAssertions).Assembly;
+ Action act = () => assembly.Should().HaveTypesInValidLocations();
+ act.Should().NotThrow();
+ }
+
+ [Fact]
+ public void HaveTypesInValidLocations_ThrowsWhenExcludedTypeCreatesFilesCountMismatch()
+ {
+ // Excluding ObjectExtensions removes the type but the .cs file still exists on disk,
+ // so type count (0) != file count (1) for namespace OneImlx.Test.Extentions
+ var excludeTypes = new[] { typeof(Extentions.ObjectExtensions) };
+ var assembly = typeof(AssemblyFluentAssertions).Assembly;
+ Action act = () => assembly.Should().HaveTypesInValidLocations(excludeTypes);
+ act.Should().Throw()
+ .WithMessage("Namespace 'OneImlx.Test.Extentions' has 0 types, but it has 1 source files.");
+ }
+
+ [Fact]
+ public void HaveTypesInValidLocations_ReturnsAndConstraintForChaining()
+ {
+ var assembly = typeof(AssemblyFluentAssertions).Assembly;
+ var result = assembly.Should().HaveTypesInValidLocations();
+ result.Should().NotBeNull();
+ }
+ }
+}
\ No newline at end of file
diff --git a/test/OneImlx.Test.Tests/FluentAssertions/ErrorExceptionFluentAssertionsTests.cs b/test/OneImlx.Test.Tests/FluentAssertions/ErrorExceptionFluentAssertionsTests.cs
new file mode 100644
index 0000000..b2bdc50
--- /dev/null
+++ b/test/OneImlx.Test.Tests/FluentAssertions/ErrorExceptionFluentAssertionsTests.cs
@@ -0,0 +1,157 @@
+// Copyright © 2019-2026 Perpetual Intelligence L.L.C. All rights reserved.
+// For license, terms, and data policies, go to:
+// https://terms.perpetualintelligence.com/articles/intro.html
+
+using FluentAssertions;
+using FluentAssertions.Execution;
+using OneImlx.Shared.Infrastructure;
+using System;
+using System.Threading.Tasks;
+using Xunit;
+
+namespace OneImlx.Test.FluentAssertions
+{
+ public class ErrorExceptionFluentAssertionsTests
+ {
+ [Fact]
+ public void WithErrorCodeShouldPassWhenErrorCodeMatches()
+ {
+ Action act = () => throw new ErrorException(new Error("err_test", "Something went wrong."));
+ act.Should().Throw().WithErrorCode("err_test");
+ }
+
+ [Fact]
+ public void WithErrorCodeShouldFailWhenErrorCodeDoesNotMatch()
+ {
+ Action act = () => throw new ErrorException(new Error("err_test", "Something went wrong."));
+ FluentActions.Invoking(() =>
+ act.Should().Throw().WithErrorCode("wrong_code"))
+ .Should().Throw();
+ }
+
+ [Fact]
+ public void WithErrorCodeShouldFailWhenExceptionIsNotErrorException()
+ {
+ Action act = () => throw new InvalidOperationException("not an ErrorException");
+ FluentActions.Invoking(() =>
+ act.Should().Throw().WithErrorCode("err_test"))
+ .Should().Throw();
+ }
+
+ [Fact]
+ public void WithErrorDescriptionShouldPassWhenDescriptionMatches()
+ {
+ Action act = () => throw new ErrorException(new Error("err_test", "Something went wrong."));
+ act.Should().Throw().WithErrorDescription("Something went wrong.");
+ }
+
+ [Fact]
+ public void WithErrorDescriptionShouldFailWhenDescriptionDoesNotMatch()
+ {
+ Action act = () => throw new ErrorException(new Error("err_test", "Something went wrong."));
+ FluentActions.Invoking(() =>
+ act.Should().Throw().WithErrorDescription("Wrong description."))
+ .Should().Throw();
+ }
+
+ [Fact]
+ public void WithErrorShouldPassWhenErrorMatches()
+ {
+ var error = new Error("err_test", "Something went wrong.");
+ Action act = () => throw new ErrorException(error);
+ act.Should().Throw().WithError(error);
+ }
+
+ [Fact]
+ public void WithErrorShouldFailWhenErrorDoesNotMatch()
+ {
+ var error = new Error("err_test", "Something went wrong.");
+ var differentError = new Error("err_other", "Other error.");
+ Action act = () => throw new ErrorException(error);
+ FluentActions.Invoking(() =>
+ act.Should().Throw().WithError(differentError))
+ .Should().Throw();
+ }
+
+ [Fact]
+ public async Task WithErrorCodeAsyncShouldPassWhenErrorCodeMatches()
+ {
+ Func act = async () =>
+ {
+ await Task.Yield();
+ throw new ErrorException(new Error("err_async", "Async error."));
+ };
+
+ await act.Should().ThrowAsync().WithErrorCode("err_async");
+ }
+
+ [Fact]
+ public async Task WithErrorCodeAsyncShouldFailWhenErrorCodeDoesNotMatch()
+ {
+ Func act = async () =>
+ {
+ await Task.Yield();
+ throw new ErrorException(new Error("err_async", "Async error."));
+ };
+
+ await FluentActions.Awaiting(async () =>
+ await act.Should().ThrowAsync().WithErrorCode("wrong_code"))
+ .Should().ThrowAsync();
+ }
+
+ [Fact]
+ public async Task WithErrorDescriptionAsyncShouldPassWhenDescriptionMatches()
+ {
+ Func act = async () =>
+ {
+ await Task.Yield();
+ throw new ErrorException(new Error("err_async", "Async error."));
+ };
+
+ await act.Should().ThrowAsync().WithErrorDescription("Async error.");
+ }
+
+ [Fact]
+ public async Task WithErrorDescriptionAsyncShouldFailWhenDescriptionDoesNotMatch()
+ {
+ Func act = async () =>
+ {
+ await Task.Yield();
+ throw new ErrorException(new Error("err_async", "Async error."));
+ };
+
+ await FluentActions.Awaiting(async () =>
+ await act.Should().ThrowAsync().WithErrorDescription("Wrong description."))
+ .Should().ThrowAsync();
+ }
+
+ [Fact]
+ public async Task WithErrorAsyncShouldPassWhenErrorMatches()
+ {
+ var error = new Error("err_async", "Async error.");
+ Func act = async () =>
+ {
+ await Task.Yield();
+ throw new ErrorException(error);
+ };
+
+ await act.Should().ThrowAsync().WithError(error);
+ }
+
+ [Fact]
+ public async Task WithErrorAsyncShouldFailWhenErrorDoesNotMatch()
+ {
+ var error = new Error("err_async", "Async error.");
+ var differentError = new Error("err_other", "Other.");
+ Func act = async () =>
+ {
+ await Task.Yield();
+ throw new ErrorException(error);
+ };
+
+ await FluentActions.Awaiting(async () =>
+ await act.Should().ThrowAsync().WithError(differentError))
+ .Should().ThrowAsync();
+ }
+ }
+}
\ No newline at end of file
diff --git a/test/OneImlx.Test.Tests/FluentAssertions/TypeFluentAssertionsTests.cs b/test/OneImlx.Test.Tests/FluentAssertions/TypeFluentAssertionsTests.cs
new file mode 100644
index 0000000..54c11d1
--- /dev/null
+++ b/test/OneImlx.Test.Tests/FluentAssertions/TypeFluentAssertionsTests.cs
@@ -0,0 +1,131 @@
+// Copyright © 2019-2026 Perpetual Intelligence L.L.C. All rights reserved.
+// For license, terms, and data policies, go to:
+// https://terms.perpetualintelligence.com/articles/intro.html
+
+using System;
+using System.Text.Json.Serialization;
+using FluentAssertions;
+using FluentAssertions.Execution;
+using Xunit;
+
+namespace OneImlx.Test.FluentAssertions
+{
+ public class TypeFluentAssertionsTests
+ {
+ [Fact]
+ public void HaveConstantCountShouldPassWhenCountMatches()
+ {
+ typeof(SampleWithConstants).Should().HaveConstantCount(2);
+ }
+
+ [Fact]
+ public void HaveConstantCountShouldFailWhenCountDoesNotMatch()
+ {
+ FluentActions.Invoking(() => typeof(SampleWithConstants).Should().HaveConstantCount(99))
+ .Should().Throw();
+ }
+
+ [Fact]
+ public void HavePropertyCountShouldPassWhenCountMatches()
+ {
+ typeof(SampleWithProperties).Should().HavePropertyCount(2);
+ }
+
+ [Fact]
+ public void HavePropertyCountShouldFailWhenCountDoesNotMatch()
+ {
+ FluentActions.Invoking(() => typeof(SampleWithProperties).Should().HavePropertyCount(99))
+ .Should().Throw();
+ }
+
+ [Fact]
+ public void HaveJsonPropertyShouldPassWhenAttributeMatches()
+ {
+ typeof(SampleWithJsonProps).Should().HaveJsonProperty(nameof(SampleWithJsonProps.FirstName), "first_name");
+ }
+
+ [Fact]
+ public void HaveJsonPropertyShouldFailWhenPropertyMissing()
+ {
+ FluentActions.Invoking(() => typeof(SampleWithJsonProps).Should().HaveJsonProperty("NonExistent", "non_existent"))
+ .Should().Throw();
+ }
+
+ [Fact]
+ public void HaveJsonPropertyShouldFailWhenJsonNameDoesNotMatch()
+ {
+ FluentActions.Invoking(() => typeof(SampleWithJsonProps).Should().HaveJsonProperty(nameof(SampleWithJsonProps.FirstName), "wrong_name"))
+ .Should().Throw();
+ }
+
+ [Fact]
+ public void HaveSnakeCaseJsonNamesShouldPassWhenAllPropertiesDecoratedCorrectly()
+ {
+ typeof(SampleWithValidSnakeCase).Should().HaveSnakeCaseJsonNames();
+ }
+
+ [Fact]
+ public void HaveSnakeCaseJsonNamesShouldFailWhenPropertyHasNoAttribute()
+ {
+ FluentActions.Invoking(() => typeof(SampleWithMissingAttribute).Should().HaveSnakeCaseJsonNames())
+ .Should().Throw();
+ }
+
+ [Fact]
+ public void HaveSnakeCaseJsonNamesShouldFailWhenJsonNameIsNotSnakeCase()
+ {
+ FluentActions.Invoking(() => typeof(SampleWithWrongJsonName).Should().HaveSnakeCaseJsonNames())
+ .Should().Throw();
+ }
+
+ [Fact]
+ public void HaveSnakeCaseJsonNamesShouldPassWhenPropertyIsJsonIgnored()
+ {
+ typeof(SampleWithJsonIgnore).Should().HaveSnakeCaseJsonNames();
+ }
+
+ private class SampleWithConstants
+ {
+ public const string ConstOne = "one";
+ public const string ConstTwo = "two";
+ }
+
+ private class SampleWithProperties
+ {
+ public string? Name { get; set; }
+ public int Age { get; set; }
+ }
+
+ private class SampleWithJsonProps
+ {
+ [JsonPropertyName("first_name")]
+ public string? FirstName { get; set; }
+ }
+
+ private class SampleWithValidSnakeCase
+ {
+ [JsonPropertyName("first_name")]
+ public string? FirstName { get; set; }
+
+ [JsonPropertyName("last_name")]
+ public string? LastName { get; set; }
+ }
+
+ private class SampleWithMissingAttribute
+ {
+ public string? FirstName { get; set; }
+ }
+
+ private class SampleWithWrongJsonName
+ {
+ [JsonPropertyName("firstName")]
+ public string? FirstName { get; set; }
+ }
+
+ private class SampleWithJsonIgnore
+ {
+ [JsonIgnore]
+ public string? InternalProp { get; set; }
+ }
+ }
+}
\ No newline at end of file
diff --git a/test/OneImlx.Test.Tests/OneImlx.Test.Tests.csproj b/test/OneImlx.Test.Tests/OneImlx.Test.Tests.csproj
new file mode 100644
index 0000000..dc77032
--- /dev/null
+++ b/test/OneImlx.Test.Tests/OneImlx.Test.Tests.csproj
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
\ No newline at end of file