Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 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
22 changes: 22 additions & 0 deletions .github/copilot-instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,28 @@ Internal classes and methods must be validated by exercising the public API that
- Public entry points provide sufficient coverage of internal code paths.
- The internal implementation exists solely as a helper or utility for public-facing functionality.

## 10. ExcludeFromCodeCoverage Prohibition

**Do not use `ExcludeFromCodeCoverage` attribute on any code.** This includes:

- Test classes or test methods
- Production code
- Configuration code
- Any other code path

### Rationale

- Excluding code from coverage hides gaps and creates false confidence in test completeness.
- If a code path cannot or should not be tested, refactor the code to eliminate that path rather than hiding it from metrics.
- Every executable line should be covered by tests or be genuinely unreachable (dead code to be removed).

### Alternative Approaches

- **Untestable code paths**: Refactor to separate concerns and eliminate the untestable path.
- **External dependencies**: Use test doubles (fakes, stubs, spies) instead of excluding from coverage.
- **Configuration-only code**: Move to configuration files or extract into testable methods.
- **Generated or third-party code**: These should not be in the primary codebase; use NuGet packages or dedicated vendor folders if necessary.

---
description: 'Writing Performance Tests in Cuemon'
applyTo: "tuning/**, **/*Benchmark*.cs"
Expand Down
12 changes: 10 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ For more details, please refer to `PackageReleaseNotes.txt` on a per assembly ba

## [10.5.3] - 2026-06-03

This is a patch release focused on fixing request service provider resolution for wrapped providers and improving test coverage for dependency injection scenarios.
This is a patch release focused on fixing request service provider resolution for wrapped providers and significantly expanding test coverage across the library. The release improves code coverage with 36 focused, System-Under-Test (SUT)-specific unit test classes covering core utilities, extension methods, serialization, resilience, threading, and infrastructure components.

### Fixed

Expand All @@ -18,7 +18,15 @@ This is a patch release focused on fixing request service provider resolution fo
### Added

- `ServiceProviderExtensionsTest` unit test class with comprehensive test coverage for `GetServiceDescriptors()` method, including scenarios for delegating providers, ambiguous multi-provider cases, and cyclic provider graph detection,
- Functional test in `ApplicationBuilderExtensionsTest` to verify fault descriptor exception handling works correctly with wrapped request services.
- Functional test in `ApplicationBuilderExtensionsTest` to verify fault descriptor exception handling works correctly with wrapped request services,
- Focused unit test classes for core utilities (replacing generic CoverageTest approach): `ActionFactoryTest`, `FuncFactoryTest`, `TesterFuncFactoryTest`, `WrapperTest`, and `VerticalDirectionTest`,
- Focused unit test classes for extension methods: `RegionInfoExtensionsTest`, `MethodDescriptorExtensionsTest`, `CacheValidatorTest`, `ChecksumBuilderTest`, `ChecksumBuilderExtensionsTest`, `FileInfoExtensionsTest`, `HostBuilderExtensionsTest`, and `UriExtensionsTest`,
- Focused unit test classes for Hierarchy-related functionality: `HierarchyTest`, `HierarchyOptionsTest`, `HierarchyDecoratorExtensionsTest`, and `HierarchySerializerTest`,
- Focused unit test classes for ASP.NET Core MVC formatters: `MvcBuilderExtensionsTest` and `MvcCoreBuilderExtensionsTest` for both Text.Json and Xml formatters,
- Focused unit test classes for XML serialization converters: `ExceptionConverterTest`, `DefaultXmlConverterTest`, `DynamicXmlConverterTest`, and related converter functionality across the Cuemon.Extensions.Xml namespace,
- Focused unit test classes for resilience and threading: `LatencyExceptionTest`, `TransientOperationOverloadTest`, `AdvancedParallelFactoryTest`, `AsyncPatternsTest`, and `ParallelFactoryOverloadTest`,
- Focused unit test classes for mail and data infrastructure: `MailDistributorTest` and focused coverage for `Cuemon.Data`, `Cuemon.Extensions.Net`, `Cuemon.Net`, and `Cuemon.Resilience` namespaces,
- Enhanced unit test guidelines in `.github/copilot-instructions.md` with clarifications on namespace conventions, base class usage, public facade testing patterns, and best practices for test organization.

## [10.5.2] - 2026-05-18

Expand Down
5 changes: 5 additions & 0 deletions src/Cuemon.Core/Reflection/AssemblyContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,11 @@ public static class AssemblyContext
/// <exception cref="ArgumentException">
/// <paramref name="setup"/> failed to configure an instance of <see cref="AssemblyContextOptions"/> in a valid state.
/// </exception>
/// <remarks>
/// When <see cref="AssemblyContextOptions.IncludeReferencedAssemblies"/> is <c>true</c>,
/// referenced assemblies may be loaded into the current application domain during traversal.
/// This can change subsequent results returned by <see cref="AppDomain.GetAssemblies"/>.
/// </remarks>
public static IReadOnlyList<Assembly> GetCurrentDomainAssemblies(Action<AssemblyContextOptions> setup = null)
{
Validator.ThrowIfInvalidConfigurator(setup, out var options);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ public static class MvcCoreBuilderExtensions
/// <paramref name="builder"/> cannot be null -or-
/// <paramref name="setup"/> cannot be null.
/// </exception>
public static IMvcCoreBuilder AddJsonFormatters(this IMvcCoreBuilder builder, Action<JsonFormatterOptions> setup)
public static IMvcCoreBuilder AddJsonFormatters(this IMvcCoreBuilder builder, Action<JsonFormatterOptions> setup = null)
{
Validator.ThrowIfNull(builder);
builder.Services.TryAddEnumerable(ServiceDescriptor.Transient<IConfigureOptions<MvcOptions>, JsonSerializationMvcOptionsSetup>());
Expand Down
1 change: 1 addition & 0 deletions src/Cuemon.Extensions.Core/Wrapper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@ protected Wrapper()
/// <param name="memberReference">The member from where <paramref name="instance"/> was referenced.</param>
public Wrapper(T instance, MemberInfo memberReference = null)
{
Validator.ThrowIfNull(instance);
_instance = instance;
_instanceType = instance.GetType();
_memberReference = memberReference;
Expand Down
2 changes: 1 addition & 1 deletion src/Cuemon.Net/Http/HttpMethodConverter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ public static class HttpMethodConverter

private static IDictionary<string, HttpMethods> InitStringToHttpMethodLookupTable()
{
var result = new Dictionary<string, HttpMethods>();
var result = new Dictionary<string, HttpMethods>(StringComparer.OrdinalIgnoreCase);
foreach (var pair in new EnumReadOnlyDictionary<HttpMethods>().Select(pair => pair.Value))
{
result.Add(pair, ParserFactory.FromEnum().Parse<HttpMethods>(pair));
Expand Down
1 change: 1 addition & 0 deletions src/Cuemon.Net/Http/HttpWatcher.cs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ public class HttpWatcher : Watcher
/// <param name="setup">The <see cref="HttpWatcherOptions" /> which may be configured.</param>
public HttpWatcher(Uri location, Action<HttpWatcherOptions> setup = null) : base(Patterns.ConfigureExchange<HttpWatcherOptions, WatcherOptions>(setup))
{
Validator.ThrowIfNull(location);
var options = Patterns.Configure(setup);
Location = location;
ClientFactory = options.ClientFactory;
Expand Down
5 changes: 3 additions & 2 deletions test/Cuemon.Core.Tests/Reflection/AssemblyContextTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -106,12 +106,13 @@ public void GetCurrentDomainAssemblies_ShouldRespectCustomAssemblyFilter_WhenPer
[Fact]
public void GetCurrentDomainAssemblies_ShouldReturnAtLeastAsManyAssemblies_WhenReferencedAssembliesIncluded()
{
var withRefs = AssemblyContext.GetCurrentDomainAssemblies(o => o.IncludeReferencedAssemblies = true);
var withoutRefs = AssemblyContext.GetCurrentDomainAssemblies(o => o.IncludeReferencedAssemblies = false);
var withRefs = AssemblyContext.GetCurrentDomainAssemblies(o => o.IncludeReferencedAssemblies = true);
var missing = withoutRefs.Except(withRefs).ToList();

TestOutput.WriteLine($"With referenced: {withRefs.Count}, without referenced: {withoutRefs.Count}");

Assert.True(withRefs.Count >= withoutRefs.Count);
Assert.Empty(missing);
}

[Fact]
Expand Down
122 changes: 122 additions & 0 deletions test/Cuemon.Data.Tests/DataManagerAndDependencyTest.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
using System;
using System.Data;
using System.Threading;
using System.Threading.Tasks;
using Codebelt.Extensions.Xunit;
using Cuemon.Runtime;
using Microsoft.Data.Sqlite;
using Xunit;

namespace Cuemon.Data
{
public class DataManagerAndDependencyTest : Test
{
public DataManagerAndDependencyTest(ITestOutputHelper output) : base(output)
{
}

[Fact]
public async Task AsyncAndWatcherDependency_ShouldReactToChanges()
{
var manager = CreateManager();
var affected = await manager.ExecuteAsync(new DataStatement("UPDATE Product SET DiscontinuedDate = @expired", o =>
{
o.Parameters = new IDataParameter[] { new SqliteParameter("@expired", DateTime.UtcNow) };
}));
var scalar = await manager.ExecuteScalarAsync(new DataStatement("SELECT Name FROM Product WHERE ProductID = 1"));

Assert.Equal(504, affected);
Assert.Equal("Adjustable Race", scalar);

var connectionString = $"Data Source=watcher-{Guid.NewGuid():N};Mode=Memory;Cache=Shared";
using var rootConnection = new SqliteConnection(connectionString);
rootConnection.Open();
using (var command = rootConnection.CreateCommand())
{
command.CommandText = "CREATE TABLE Item (Id INTEGER PRIMARY KEY, Name TEXT NOT NULL); INSERT INTO Item VALUES (1, 'Alpha');";
command.ExecuteNonQuery();
}

using var watcherConnection = new SqliteConnection(connectionString);
var watcher = new TestDatabaseWatcher(watcherConnection, CreateReader, o =>
{
o.DueTime = Timeout.InfiniteTimeSpan;
o.Period = Timeout.InfiniteTimeSpan;
});
var changedSignals = 0;
watcher.Changed += (_, _) => changedSignals++;

await watcher.SignalAsync();
Assert.NotNull(watcher.Checksum);
Assert.Equal(0, changedSignals);
Assert.Equal(ConnectionState.Closed, watcherConnection.State);

using (var command = rootConnection.CreateCommand())
{
command.CommandText = "UPDATE Item SET Name = 'Beta' WHERE Id = 1;";
command.ExecuteNonQuery();
}

await watcher.SignalAsync();
Assert.Equal(1, changedSignals);
Assert.Equal(ConnectionState.Closed, watcherConnection.State);

var dependencyChanged = new TaskCompletionSource<DateTime?>(TaskCreationOptions.RunContinuationsAsynchronously);
var dependency = new DatabaseDependency(new Lazy<DatabaseWatcher>(() => watcher));
dependency.DependencyChanged += (_, e) => dependencyChanged.TrySetResult(e.UtcLastModified);
await dependency.StartAsync();

using (var command = rootConnection.CreateCommand())
{
command.CommandText = "INSERT INTO Item VALUES (2, 'Gamma');";
command.ExecuteNonQuery();
}

watcher.ChangeSignaling(TimeSpan.Zero, Timeout.InfiniteTimeSpan);
var modified = await WaitOrThrowAsync(dependencyChanged.Task, TimeSpan.FromSeconds(5));
Assert.True(dependency.HasChanged);
Assert.Equal(modified, dependency.UtcLastModified);
Assert.Throws<ArgumentNullException>(() => new DatabaseWatcher(null, CreateReader));
Assert.Throws<ArgumentNullException>(() => new DatabaseWatcher(watcherConnection, null));
Assert.Throws<ArgumentNullException>(() => new DatabaseDependency((Lazy<DatabaseWatcher>)null));

static IDataReader CreateReader(IDbConnection connection)
{
var command = connection.CreateCommand();
command.CommandText = "SELECT Id, Name FROM Item ORDER BY Id";
return command.ExecuteReader();
}
}

private static async Task<T> WaitOrThrowAsync<T>(Task<T> task, TimeSpan timeout)
{
var timeoutTask = Task.Delay(timeout);
if (await Task.WhenAny(task, timeoutTask) != task) { throw new TimeoutException(); }
return await task;
}

private static Assets.FakeDataManager CreateManager()
{
var manager = new Assets.FakeDataManager(o =>
{
o.LeaveConnectionOpen = true;
o.LeaveCommandOpen = true;
o.ConnectionString = $"Data Source=coverage-{Guid.NewGuid():N};Mode=Memory;Cache=Shared";
});
Assets.SqliteDatabase.Create(manager, null);
return manager;
}

private sealed class TestDatabaseWatcher : DatabaseWatcher
{
public TestDatabaseWatcher(IDbConnection connection, Func<IDbConnection, IDataReader> readerFactory, Action<WatcherOptions> setup = null) : base(connection, readerFactory, setup)
{
}

public Task SignalAsync()
{
return HandleSignalingAsync();
}
}
}
}
116 changes: 116 additions & 0 deletions test/Cuemon.Data.Tests/DataReaderTest.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
using System;
using System.Collections.Specialized;
using System.Data;
using Codebelt.Extensions.Xunit;
using Xunit;

namespace Cuemon.Data
{
public class DataReaderTest : Test
{
public DataReaderTest(ITestOutputHelper output) : base(output)
{
}

[Fact]
public void ShouldExposeIDataReaderMembers()
{
var sut = new TestDataReader();

Assert.True(sut.Read());
Assert.Equal(1, sut.RowCount);
Assert.True(sut.Contains("Boolean"));
Assert.Equal(true, sut["Boolean"]);
Assert.Equal(true, sut[0]);
Assert.Equal(12, sut.FieldCount);
Assert.Equal("Boolean", sut.GetName(0));
Assert.Equal(string.Empty, sut.GetName(99));
Assert.Equal(0, sut.GetOrdinal("boolean"));
Assert.Throws<ArgumentNullException>(() => sut.GetOrdinal(null));
Assert.Throws<ArgumentOutOfRangeException>(() => sut.GetOrdinal("missing"));
Assert.True(sut.GetBoolean(0));
Assert.Equal((byte)8, sut.GetByte(1));
Assert.Equal('X', sut.GetChar(2));
Assert.Equal(new DateTime(2024, 5, 6, 7, 8, 9, DateTimeKind.Utc), sut.GetDateTime(3));
Assert.Equal(10.5m, sut.GetDecimal(4));
Assert.Equal(12.5d, sut.GetDouble(5));
Assert.Equal(typeof(Guid), sut.GetFieldType(6));
Assert.Equal(14.5f, sut.GetFloat(7));
Assert.Equal(Guid.Parse("11111111-1111-1111-1111-111111111111"), sut.GetGuid(6));
Assert.Equal((short)16, sut.GetInt16(8));
Assert.Equal(32, sut.GetInt32(9));
Assert.Equal(64L, sut.GetInt64(10));
Assert.Equal("alpha", sut.GetString(11));
Assert.Equal(true, sut.GetValue(0));
Assert.False(sut.IsDBNull(0));
Assert.Equal(0L, sut.GetBytes(0, 0, Array.Empty<byte>(), 0, 0));
Assert.Equal(0L, ((IDataRecord)sut).GetChars(0, 0, Array.Empty<char>(), 0, 0));
Assert.Throws<NotSupportedException>(() => ((IDataRecord)sut).GetData(0));
Assert.Equal(typeof(string).ToString(), ((IDataRecord)sut).GetDataTypeName(0));
Assert.Equal(0, sut.Depth);
Assert.Contains("Boolean=True", sut.ToString());
Assert.Throws<ArgumentNullException>(() => sut.GetValues(null));

var values = new object[sut.FieldCount];
Assert.Equal(sut.FieldCount, sut.GetValues(values));
Assert.Equal("alpha", values[11]);

var reader = (IDataReader)sut;
Assert.Equal(-1, reader.RecordsAffected);
Assert.Null(reader.GetSchemaTable());
Assert.False(reader.NextResult());
reader.Close();
Assert.True(reader.IsClosed);
}

private sealed class TestDataReader : DataReader<IOrderedDictionary>
{
private readonly IOrderedDictionary[] _rows;
private int _position = -1;

protected override void OnDisposeManagedResources()
{
}

public TestDataReader()
{
_rows = new IOrderedDictionary[]
{
new OrderedDictionary(StringComparer.OrdinalIgnoreCase)
{
{ "Boolean", true },
{ "Byte", (byte)8 },
{ "Char", 'X' },
{ "DateTime", new DateTime(2024, 5, 6, 7, 8, 9, DateTimeKind.Utc) },
{ "Decimal", 10.5m },
{ "Double", 12.5d },
{ "Guid", Guid.Parse("11111111-1111-1111-1111-111111111111") },
{ "Single", 14.5f },
{ "Int16", (short)16 },
{ "Int32", 32 },
{ "Int64", 64L },
{ "String", "alpha" }
}
};
}

public override int RowCount { get; protected set; }

protected override IOrderedDictionary NullRead => null;

protected override IOrderedDictionary ReadNext(IOrderedDictionary columns)
{
return columns;
}

public override bool Read()
{
_position++;
if (_position >= _rows.Length) { return false; }
SetFields(_rows[_position]);
RowCount++;
return true;
}
}
}
}
39 changes: 39 additions & 0 deletions test/Cuemon.Data.Tests/DataReaderVariantsAndExceptionsTest.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
using System;
using System.IO;
using Codebelt.Extensions.Xunit;
using Xunit;

namespace Cuemon.Data
{
public class DataReaderVariantsAndExceptionsTest : Test
{
public DataReaderVariantsAndExceptionsTest(ITestOutputHelper output) : base(output)
{
}

[Fact]
public void ShouldCoverPublicBehavior_DsvAndXmlReadersAndExceptions()
{
using (var dsv = new DsvDataReader(new StreamReader(new MemoryStream(System.Text.Encoding.UTF8.GetBytes("Id;Id\n1;2"))), setup: o => o.Delimiter = ";"))
{
Assert.True(dsv.Read());
Assert.Equal(1, dsv.FieldCount);
Assert.Equal(2, dsv.GetInt32(0));
}

using (var xml = new Xml.XmlDataReader(System.Xml.XmlReader.Create(new StringReader("<root><item>1</item><item>2</item></root>"))))
{
Assert.True(xml.Read());
Assert.Equal(1, xml.Depth);
Assert.Equal(1, xml.GetInt32(0));
Assert.True(xml.Read());
Assert.Equal(2, xml.GetInt32(0));
Assert.False(xml.Read());
}

var exception = new UniqueIndexViolationException("duplicate", new InvalidOperationException("inner"));
Assert.Equal("duplicate", exception.Message);
Assert.IsType<InvalidOperationException>(exception.InnerException);
}
}
}
Loading
Loading