diff --git a/.gitignore b/.gitignore index 2c9ba24d..a24efce5 100644 --- a/.gitignore +++ b/.gitignore @@ -351,3 +351,4 @@ MigrationBackup/ /src/Atc.Rest.ApiGenerator.CLI/Properties/launchSettings.json .idea .claude/settings.local.json +suggestions/ diff --git a/Directory.Build.props b/Directory.Build.props index f8999ea2..dabb3d74 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -42,9 +42,9 @@ - + - + \ No newline at end of file diff --git a/docs/CodeDoc/Atc.CodeAnalysis.CSharp/Atc.CodeAnalysis.CSharp.SyntaxFactories.md b/docs/CodeDoc/Atc.CodeAnalysis.CSharp/Atc.CodeAnalysis.CSharp.SyntaxFactories.md index cdf843bb..3f0fb224 100644 --- a/docs/CodeDoc/Atc.CodeAnalysis.CSharp/Atc.CodeAnalysis.CSharp.SyntaxFactories.md +++ b/docs/CodeDoc/Atc.CodeAnalysis.CSharp/Atc.CodeAnalysis.CSharp.SyntaxFactories.md @@ -736,6 +736,57 @@ Factory for creating `Microsoft.CodeAnalysis.CSharp.Syntax.LiteralExpressionSynt >     `syntaxKind`  -  The syntax kind for the literal (string or numeric).
> >Returns: A `Microsoft.CodeAnalysis.CSharp.Syntax.LiteralExpressionSyntax` node. +#### Create +>```csharp +>LiteralExpressionSyntax Create(long value) +>``` +>Summary: Creates a literal expression from a string value with the specified syntax kind. +> +>Parameters:
+>     `value`  -  The value for the literal expression.
+>     `syntaxKind`  -  The syntax kind for the literal (string or numeric).
+> +>Returns: A `Microsoft.CodeAnalysis.CSharp.Syntax.LiteralExpressionSyntax` node. +#### Create +>```csharp +>LiteralExpressionSyntax Create(double value) +>``` +>Summary: Creates a literal expression from a string value with the specified syntax kind. +> +>Parameters:
+>     `value`  -  The value for the literal expression.
+>     `syntaxKind`  -  The syntax kind for the literal (string or numeric).
+> +>Returns: A `Microsoft.CodeAnalysis.CSharp.Syntax.LiteralExpressionSyntax` node. +#### Create +>```csharp +>LiteralExpressionSyntax Create(bool value) +>``` +>Summary: Creates a literal expression from a string value with the specified syntax kind. +> +>Parameters:
+>     `value`  -  The value for the literal expression.
+>     `syntaxKind`  -  The syntax kind for the literal (string or numeric).
+> +>Returns: A `Microsoft.CodeAnalysis.CSharp.Syntax.LiteralExpressionSyntax` node. +#### Create +>```csharp +>LiteralExpressionSyntax Create(char value) +>``` +>Summary: Creates a literal expression from a string value with the specified syntax kind. +> +>Parameters:
+>     `value`  -  The value for the literal expression.
+>     `syntaxKind`  -  The syntax kind for the literal (string or numeric).
+> +>Returns: A `Microsoft.CodeAnalysis.CSharp.Syntax.LiteralExpressionSyntax` node. +#### CreateNull +>```csharp +>LiteralExpressionSyntax CreateNull() +>``` +>Summary: Creates a literal expression. +> +>Returns: A `Microsoft.CodeAnalysis.CSharp.Syntax.LiteralExpressionSyntax` node representing .
@@ -813,6 +864,70 @@ Factory for creating `Microsoft.CodeAnalysis.CSharp.Syntax.ObjectCreationExpress >     `identifierName`  -  The name of the type to instantiate.
> >Returns: An `Microsoft.CodeAnalysis.CSharp.Syntax.ObjectCreationExpressionSyntax` node. +#### Create +>```csharp +>ObjectCreationExpressionSyntax Create(string identifierName, ArgumentListSyntax argumentList) +>``` +>Summary: Creates an object creation expression for a type. +> +>Parameters:
+>     `identifierName`  -  The name of the type to instantiate.
+> +>Returns: An `Microsoft.CodeAnalysis.CSharp.Syntax.ObjectCreationExpressionSyntax` node. +#### Create +>```csharp +>ObjectCreationExpressionSyntax Create(string namespaceName, string identifierName, ArgumentListSyntax argumentList) +>``` +>Summary: Creates an object creation expression for a type. +> +>Parameters:
+>     `identifierName`  -  The name of the type to instantiate.
+> +>Returns: An `Microsoft.CodeAnalysis.CSharp.Syntax.ObjectCreationExpressionSyntax` node. +#### CreateGeneric +>```csharp +>ObjectCreationExpressionSyntax CreateGeneric(string identifierName, TypeArgumentListSyntax typeArgumentList) +>``` +>Summary: Creates a generic object creation expression (e.g. `new List<T>()`). +> +>Parameters:
+>     `identifierName`  -  The name of the generic type to instantiate.
+>     `typeArgumentList`  -  The type argument list (e.g. ).
+> +>Returns: An `Microsoft.CodeAnalysis.CSharp.Syntax.ObjectCreationExpressionSyntax` node for the generic type. +#### CreateGeneric +>```csharp +>ObjectCreationExpressionSyntax CreateGeneric(string identifierName, string typeArgumentName) +>``` +>Summary: Creates a generic object creation expression (e.g. `new List<T>()`). +> +>Parameters:
+>     `identifierName`  -  The name of the generic type to instantiate.
+>     `typeArgumentList`  -  The type argument list (e.g. ).
+> +>Returns: An `Microsoft.CodeAnalysis.CSharp.Syntax.ObjectCreationExpressionSyntax` node for the generic type. +#### CreateGeneric +>```csharp +>ObjectCreationExpressionSyntax CreateGeneric(string identifierName, TypeArgumentListSyntax typeArgumentList, ArgumentListSyntax argumentList) +>``` +>Summary: Creates a generic object creation expression (e.g. `new List<T>()`). +> +>Parameters:
+>     `identifierName`  -  The name of the generic type to instantiate.
+>     `typeArgumentList`  -  The type argument list (e.g. ).
+> +>Returns: An `Microsoft.CodeAnalysis.CSharp.Syntax.ObjectCreationExpressionSyntax` node for the generic type. +#### CreateGeneric +>```csharp +>ObjectCreationExpressionSyntax CreateGeneric(string identifierName, string typeArgumentName, ArgumentListSyntax argumentList) +>``` +>Summary: Creates a generic object creation expression (e.g. `new List<T>()`). +> +>Parameters:
+>     `identifierName`  -  The name of the generic type to instantiate.
+>     `typeArgumentList`  -  The type argument list (e.g. ).
+> +>Returns: An `Microsoft.CodeAnalysis.CSharp.Syntax.ObjectCreationExpressionSyntax` node for the generic type.
diff --git a/docs/CodeDoc/Atc.CodeAnalysis.CSharp/Index.md b/docs/CodeDoc/Atc.CodeAnalysis.CSharp/Index.md index 320371d7..c0ca27b3 100644 --- a/docs/CodeDoc/Atc.CodeAnalysis.CSharp/Index.md +++ b/docs/CodeDoc/Atc.CodeAnalysis.CSharp/Index.md @@ -47,6 +47,8 @@ - [EnumDeclarationSyntaxExtensions](Microsoft.CodeAnalysis.CSharp.Syntax.md#enumdeclarationsyntaxextensions) - [InterfaceDeclarationSyntaxExtensions](Microsoft.CodeAnalysis.CSharp.Syntax.md#interfacedeclarationsyntaxextensions) - [MethodDeclarationSyntaxExtensions](Microsoft.CodeAnalysis.CSharp.Syntax.md#methoddeclarationsyntaxextensions) +- [RecordDeclarationSyntaxExtensions](Microsoft.CodeAnalysis.CSharp.Syntax.md#recorddeclarationsyntaxextensions) +- [StructDeclarationSyntaxExtensions](Microsoft.CodeAnalysis.CSharp.Syntax.md#structdeclarationsyntaxextensions) - [SyntaxNodeExtensions](Microsoft.CodeAnalysis.CSharp.Syntax.md#syntaxnodeextensions) - [UsingDirectiveSyntaxExtensions](Microsoft.CodeAnalysis.CSharp.Syntax.md#usingdirectivesyntaxextensions) diff --git a/docs/CodeDoc/Atc.CodeAnalysis.CSharp/IndexExtended.md b/docs/CodeDoc/Atc.CodeAnalysis.CSharp/IndexExtended.md index a1b7ebaf..0a67e253 100644 --- a/docs/CodeDoc/Atc.CodeAnalysis.CSharp/IndexExtended.md +++ b/docs/CodeDoc/Atc.CodeAnalysis.CSharp/IndexExtended.md @@ -97,8 +97,13 @@ - StringTextParenthesesEnd() - [SyntaxLiteralExpressionFactory](Atc.CodeAnalysis.CSharp.SyntaxFactories.md#syntaxliteralexpressionfactory) - Static Methods + - Create(bool value) + - Create(char value) + - Create(double value) - Create(int value) + - Create(long value) - Create(string value, SyntaxKind syntaxKind = StringLiteralExpression) + - CreateNull() - [SyntaxMemberAccessExpressionFactory](Atc.CodeAnalysis.CSharp.SyntaxFactories.md#syntaxmemberaccessexpressionfactory) - Static Methods - Create(string memberTypeName, string memberName) @@ -108,7 +113,13 @@ - [SyntaxObjectCreationExpressionFactory](Atc.CodeAnalysis.CSharp.SyntaxFactories.md#syntaxobjectcreationexpressionfactory) - Static Methods - Create(string identifierName) + - Create(string identifierName, ArgumentListSyntax argumentList) - Create(string namespaceName, string identifierName) + - Create(string namespaceName, string identifierName, ArgumentListSyntax argumentList) + - CreateGeneric(string identifierName, TypeArgumentListSyntax typeArgumentList) + - CreateGeneric(string identifierName, TypeArgumentListSyntax typeArgumentList, ArgumentListSyntax argumentList) + - CreateGeneric(string identifierName, string typeArgumentName) + - CreateGeneric(string identifierName, string typeArgumentName, ArgumentListSyntax argumentList) - [SyntaxParameterFactory](Atc.CodeAnalysis.CSharp.SyntaxFactories.md#syntaxparameterfactory) - Static Methods - Create(string parameterTypeName, string parameterName, string genericListTypeName = null) @@ -194,9 +205,18 @@ - [InterfaceDeclarationSyntaxExtensions](Microsoft.CodeAnalysis.CSharp.Syntax.md#interfacedeclarationsyntaxextensions) - Static Methods - AddGeneratedCodeAttribute(this InterfaceDeclarationSyntax interfaceDeclaration, string toolName, string version) + - AddSuppressMessageAttribute(this InterfaceDeclarationSyntax interfaceDeclaration, SuppressMessageAttribute suppressMessage) - [MethodDeclarationSyntaxExtensions](Microsoft.CodeAnalysis.CSharp.Syntax.md#methoddeclarationsyntaxextensions) - Static Methods - AddSuppressMessageAttribute(this MethodDeclarationSyntax methodDeclaration, SuppressMessageAttribute suppressMessage) +- [RecordDeclarationSyntaxExtensions](Microsoft.CodeAnalysis.CSharp.Syntax.md#recorddeclarationsyntaxextensions) + - Static Methods + - AddGeneratedCodeAttribute(this RecordDeclarationSyntax recordDeclaration, string toolName, string version) + - AddSuppressMessageAttribute(this RecordDeclarationSyntax recordDeclaration, SuppressMessageAttribute suppressMessage) +- [StructDeclarationSyntaxExtensions](Microsoft.CodeAnalysis.CSharp.Syntax.md#structdeclarationsyntaxextensions) + - Static Methods + - AddGeneratedCodeAttribute(this StructDeclarationSyntax structDeclaration, string toolName, string version) + - AddSuppressMessageAttribute(this StructDeclarationSyntax structDeclaration, SuppressMessageAttribute suppressMessage) - [SyntaxNodeExtensions](Microsoft.CodeAnalysis.CSharp.Syntax.md#syntaxnodeextensions) - Static Methods - GetUsedUsingStatements(this SyntaxNode syntaxNode) diff --git a/docs/CodeDoc/Atc.CodeAnalysis.CSharp/Microsoft.CodeAnalysis.CSharp.Syntax.md b/docs/CodeDoc/Atc.CodeAnalysis.CSharp/Microsoft.CodeAnalysis.CSharp.Syntax.md index 0bd2f3c3..ce6aa0a1 100644 --- a/docs/CodeDoc/Atc.CodeAnalysis.CSharp/Microsoft.CodeAnalysis.CSharp.Syntax.md +++ b/docs/CodeDoc/Atc.CodeAnalysis.CSharp/Microsoft.CodeAnalysis.CSharp.Syntax.md @@ -130,6 +130,17 @@ Extension methods for `Microsoft.CodeAnalysis.CSharp.Syntax.InterfaceDeclaration >     `version`  -  The version of the code generation tool.
> >Returns: A new `Microsoft.CodeAnalysis.CSharp.Syntax.InterfaceDeclarationSyntax` with the attribute added. +#### AddSuppressMessageAttribute +>```csharp +>InterfaceDeclarationSyntax AddSuppressMessageAttribute(this InterfaceDeclarationSyntax interfaceDeclaration, SuppressMessageAttribute suppressMessage) +>``` +>Summary: Adds a `System.Diagnostics.CodeAnalysis.SuppressMessageAttribute` to the interface declaration. +> +>Parameters:
+>     `interfaceDeclaration`  -  The interface declaration to modify.
+>     `suppressMessage`  -  The suppress message attribute to add.
+> +>Returns: A new `Microsoft.CodeAnalysis.CSharp.Syntax.InterfaceDeclarationSyntax` with the attribute added.
@@ -156,6 +167,76 @@ Extension methods for `Microsoft.CodeAnalysis.CSharp.Syntax.MethodDeclarationSyn
+## RecordDeclarationSyntaxExtensions +Extension methods for `Microsoft.CodeAnalysis.CSharp.Syntax.RecordDeclarationSyntax`. + +>```csharp +>public static class RecordDeclarationSyntaxExtensions +>``` + +### Static Methods + +#### AddGeneratedCodeAttribute +>```csharp +>RecordDeclarationSyntax AddGeneratedCodeAttribute(this RecordDeclarationSyntax recordDeclaration, string toolName, string version) +>``` +>Summary: Adds a `System.CodeDom.Compiler.GeneratedCodeAttribute` to the record declaration. +> +>Parameters:
+>     `recordDeclaration`  -  The record declaration to modify.
+>     `toolName`  -  The name of the code generation tool.
+>     `version`  -  The version of the code generation tool.
+> +>Returns: A new `Microsoft.CodeAnalysis.CSharp.Syntax.RecordDeclarationSyntax` with the attribute added. +#### AddSuppressMessageAttribute +>```csharp +>RecordDeclarationSyntax AddSuppressMessageAttribute(this RecordDeclarationSyntax recordDeclaration, SuppressMessageAttribute suppressMessage) +>``` +>Summary: Adds a `System.Diagnostics.CodeAnalysis.SuppressMessageAttribute` to the record declaration. +> +>Parameters:
+>     `recordDeclaration`  -  The record declaration to modify.
+>     `suppressMessage`  -  The suppress message attribute to add.
+> +>Returns: A new `Microsoft.CodeAnalysis.CSharp.Syntax.RecordDeclarationSyntax` with the attribute added. + +
+ +## StructDeclarationSyntaxExtensions +Extension methods for `Microsoft.CodeAnalysis.CSharp.Syntax.StructDeclarationSyntax`. + +>```csharp +>public static class StructDeclarationSyntaxExtensions +>``` + +### Static Methods + +#### AddGeneratedCodeAttribute +>```csharp +>StructDeclarationSyntax AddGeneratedCodeAttribute(this StructDeclarationSyntax structDeclaration, string toolName, string version) +>``` +>Summary: Adds a `System.CodeDom.Compiler.GeneratedCodeAttribute` to the struct declaration. +> +>Parameters:
+>     `structDeclaration`  -  The struct declaration to modify.
+>     `toolName`  -  The name of the code generation tool.
+>     `version`  -  The version of the code generation tool.
+> +>Returns: A new `Microsoft.CodeAnalysis.CSharp.Syntax.StructDeclarationSyntax` with the attribute added. +#### AddSuppressMessageAttribute +>```csharp +>StructDeclarationSyntax AddSuppressMessageAttribute(this StructDeclarationSyntax structDeclaration, SuppressMessageAttribute suppressMessage) +>``` +>Summary: Adds a `System.Diagnostics.CodeAnalysis.SuppressMessageAttribute` to the struct declaration. +> +>Parameters:
+>     `structDeclaration`  -  The struct declaration to modify.
+>     `suppressMessage`  -  The suppress message attribute to add.
+> +>Returns: A new `Microsoft.CodeAnalysis.CSharp.Syntax.StructDeclarationSyntax` with the attribute added. + +
+ ## SyntaxNodeExtensions Extension methods for `Microsoft.CodeAnalysis.SyntaxNode`. diff --git a/docs/CodeDoc/Atc.CodeDocumentation/Atc.CodeDocumentation.md b/docs/CodeDoc/Atc.CodeDocumentation/Atc.CodeDocumentation.md index 9b01b233..e3423ae6 100644 --- a/docs/CodeDoc/Atc.CodeDocumentation/Atc.CodeDocumentation.md +++ b/docs/CodeDoc/Atc.CodeDocumentation/Atc.CodeDocumentation.md @@ -52,6 +52,16 @@ Provides public API methods for collecting and analyzing XML documentation comme >     `type`  -  The type to collect documentation for.
> >Returns: The type comments, or if the type was not found. +#### CollectExportedTypeWithCommentsFromType +>```csharp +>TypeComments CollectExportedTypeWithCommentsFromType(Type type, FileInfo xmlDocPath) +>``` +>Summary: Collects XML documentation comments for a specific type from its assembly. +> +>Parameters:
+>     `type`  -  The type to collect documentation for.
+> +>Returns: The type comments, or if the type was not found. #### CollectExportedTypesWithMissingCommentsFromAssembly >```csharp >TypeComments[] CollectExportedTypesWithMissingCommentsFromAssembly(Assembly assembly, List excludeTypes = null) @@ -63,6 +73,17 @@ Provides public API methods for collecting and analyzing XML documentation comme >     `excludeTypes`  -  Optional list of types to exclude from the results.
> >Returns: An array of type comments for types missing documentation. +#### CollectExportedTypesWithMissingCommentsFromAssembly +>```csharp +>TypeComments[] CollectExportedTypesWithMissingCommentsFromAssembly(Assembly assembly, FileInfo xmlDocPath, List excludeTypes = null) +>``` +>Summary: Collects all public types from an assembly that are missing XML documentation comments. +> +>Parameters:
+>     `assembly`  -  The assembly to scan for types.
+>     `excludeTypes`  -  Optional list of types to exclude from the results.
+> +>Returns: An array of type comments for types missing documentation. #### CollectExportedTypesWithMissingCommentsFromAssemblyAndGenerateText >```csharp >string CollectExportedTypesWithMissingCommentsFromAssemblyAndGenerateText(Assembly assembly, List excludeTypes = null, bool useFullName = False) diff --git a/docs/CodeDoc/Atc.CodeDocumentation/IndexExtended.md b/docs/CodeDoc/Atc.CodeDocumentation/IndexExtended.md index 5fa060dc..fe5d53ce 100644 --- a/docs/CodeDoc/Atc.CodeDocumentation/IndexExtended.md +++ b/docs/CodeDoc/Atc.CodeDocumentation/IndexExtended.md @@ -13,6 +13,8 @@ - [DocumentationHelper](Atc.CodeDocumentation.md#documentationhelper) - Static Methods - CollectExportedTypeWithCommentsFromType(Type type) + - CollectExportedTypeWithCommentsFromType(Type type, FileInfo xmlDocPath) + - CollectExportedTypesWithMissingCommentsFromAssembly(Assembly assembly, FileInfo xmlDocPath, List<Type> excludeTypes = null) - CollectExportedTypesWithMissingCommentsFromAssembly(Assembly assembly, List<Type> excludeTypes = null) - CollectExportedTypesWithMissingCommentsFromAssemblyAndGenerateText(Assembly assembly, List<Type> excludeTypes = null, bool useFullName = False) - CollectExportedTypesWithMissingCommentsFromAssemblyAndGenerateTextLines(Assembly assembly, List<Type> excludeTypes = null, bool useFullName = False) diff --git a/docs/CodeDoc/Atc.Console.Spectre/Atc.Console.Spectre.Logging.md b/docs/CodeDoc/Atc.Console.Spectre/Atc.Console.Spectre.Logging.md index 4202233a..72139c6a 100644 --- a/docs/CodeDoc/Atc.Console.Spectre/Atc.Console.Spectre.Logging.md +++ b/docs/CodeDoc/Atc.Console.Spectre/Atc.Console.Spectre.Logging.md @@ -65,6 +65,11 @@ Configuration options for the console logger used in Spectre.Console CLI applica >IncludeInnerMessageForException >``` >Summary: Gets or sets a value indicating whether the inner-exception-message should be rendered. +#### IncludeScopes +>```csharp +>IncludeScopes +>``` +>Summary: Gets or sets a value indicating whether log scope values are included in the output. When enabled, active scopes opened via `Microsoft.Extensions.Logging.ILogger.BeginScope``1(``0)` are rendered as a grey prefix before the log message. #### MinimumLogLevel >```csharp >MinimumLogLevel @@ -125,7 +130,7 @@ Provides logger instances configured for Spectre.Console rendering. >```csharp >ILogger CreateLogger(string categoryName) >``` ->Summary: Creates a new `Atc.Console.Spectre.Logging.ConsoleLogger` instance for the specified category. +>Summary: Creates a new `Atc.Console.Spectre.Logging.ConsoleLogger` instance for the specified category. All loggers share the provider's `Spectre.Console.IAnsiConsole` instance. > >Parameters:
>     `categoryName`  -  The category name for the logger.
diff --git a/docs/CodeDoc/Atc.Console.Spectre/IndexExtended.md b/docs/CodeDoc/Atc.Console.Spectre/IndexExtended.md index bb3b3a2d..ff679d8e 100644 --- a/docs/CodeDoc/Atc.Console.Spectre/IndexExtended.md +++ b/docs/CodeDoc/Atc.Console.Spectre/IndexExtended.md @@ -92,6 +92,7 @@ - ConsoleSettings - IncludeExceptionNameForException - IncludeInnerMessageForException + - IncludeScopes - MinimumLogLevel - RenderingMode - TimestampFormat diff --git a/docs/CodeDoc/Atc.DotNet/Atc.DotNet.md b/docs/CodeDoc/Atc.DotNet/Atc.DotNet.md index a1865e4e..fc9a2640 100644 --- a/docs/CodeDoc/Atc.DotNet/Atc.DotNet.md +++ b/docs/CodeDoc/Atc.DotNet/Atc.DotNet.md @@ -27,7 +27,7 @@ Provides helper methods for building .NET projects and solutions using the dotne #### BuildAndCollectErrors >```csharp ->Task> BuildAndCollectErrors(DirectoryInfo rootPath, int? runNumber = null, FileInfo buildFile = null, bool useNugetRestore = True, bool useConfigurationReleaseMode = True, int timeoutInSec = 1200, string logPrefix = , CancellationToken cancellationToken = null) +>Task> BuildAndCollectErrors(DirectoryInfo rootPath, int? runNumber = null, FileInfo buildFile = null, bool useNugetRestore = True, bool useConfigurationReleaseMode = True, int timeoutInSec = 1200, string logPrefix = , string additionalBuildArguments = , CancellationToken cancellationToken = null) >``` >Summary: Builds a .NET project or solution and collects compilation errors grouped by error code. > @@ -39,6 +39,7 @@ Provides helper methods for building .NET projects and solutions using the dotne >     `useConfigurationReleaseMode`  -  Whether to build in Release mode. If false, builds in Debug mode. Default is true.
>     `timeoutInSec`  -  Build timeout in seconds. Default is 1200 seconds (20 minutes).
>     `logPrefix`  -  Optional prefix for log messages.
+>     `additionalBuildArguments`  -  Additional arguments appended to the dotnet build command, such as -p:TreatWarningsAsErrors=false or -f net9.0.
>     `cancellationToken`  -  Token to cancel the build operation.
> >Returns: A dictionary mapping error codes to their occurrence counts. @@ -46,7 +47,7 @@ Provides helper methods for building .NET projects and solutions using the dotne >Remarks: This is a convenience overload that uses `Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance`; for build progress visibility prefer the overload accepting an `Microsoft.Extensions.Logging.ILogger`. #### BuildAndCollectErrors >```csharp ->Task> BuildAndCollectErrors(ILogger logger, DirectoryInfo rootPath, int? runNumber = null, FileInfo buildFile = null, bool useNugetRestore = True, bool useConfigurationReleaseMode = True, int timeoutInSec = 1200, string logPrefix = , CancellationToken cancellationToken = null) +>Task> BuildAndCollectErrors(ILogger logger, DirectoryInfo rootPath, int? runNumber = null, FileInfo buildFile = null, bool useNugetRestore = True, bool useConfigurationReleaseMode = True, int timeoutInSec = 1200, string logPrefix = , string additionalBuildArguments = , CancellationToken cancellationToken = null) >``` >Summary: Builds a .NET project or solution and collects compilation errors grouped by error code. > @@ -58,11 +59,72 @@ Provides helper methods for building .NET projects and solutions using the dotne >     `useConfigurationReleaseMode`  -  Whether to build in Release mode. If false, builds in Debug mode. Default is true.
>     `timeoutInSec`  -  Build timeout in seconds. Default is 1200 seconds (20 minutes).
>     `logPrefix`  -  Optional prefix for log messages.
+>     `additionalBuildArguments`  -  Additional arguments appended to the dotnet build command, such as -p:TreatWarningsAsErrors=false or -f net9.0.
>     `cancellationToken`  -  Token to cancel the build operation.
> >Returns: A dictionary mapping error codes to their occurrence counts. > >Remarks: This is a convenience overload that uses `Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance`; for build progress visibility prefer the overload accepting an `Microsoft.Extensions.Logging.ILogger`. +#### BuildAndCollectWarnings +>```csharp +>Task> BuildAndCollectWarnings(DirectoryInfo rootPath, int? runNumber = null, FileInfo buildFile = null, bool useNugetRestore = True, bool useConfigurationReleaseMode = True, int timeoutInSec = 1200, string logPrefix = , string additionalBuildArguments = , CancellationToken cancellationToken = null) +>``` +>Summary: Builds a .NET project or solution and collects compilation warnings grouped by warning code. +> +>Parameters:
+>     `rootPath`  -  The root directory containing the project or solution to build.
+>     `runNumber`  -  Optional run number for logging purposes.
+>     `buildFile`  -  Optional specific solution or project file to build. If not specified, discovers the build file automatically.
+>     `useNugetRestore`  -  Whether to perform NuGet restore before building. Default is true.
+>     `useConfigurationReleaseMode`  -  Whether to build in Release mode. If false, builds in Debug mode. Default is true.
+>     `timeoutInSec`  -  Build timeout in seconds. Default is 1200 seconds (20 minutes).
+>     `logPrefix`  -  Optional prefix for log messages.
+>     `additionalBuildArguments`  -  Additional arguments appended to the dotnet build command, such as -p:NoWarn=CS0168 or -f net9.0.
+>     `cancellationToken`  -  Token to cancel the build operation.
+> +>Returns: A dictionary mapping warning codes to their occurrence counts. +> +>Remarks: This is a convenience overload that uses `Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance`; for build progress visibility prefer the overload accepting an `Microsoft.Extensions.Logging.ILogger`. +#### BuildAndCollectWarnings +>```csharp +>Task> BuildAndCollectWarnings(ILogger logger, DirectoryInfo rootPath, int? runNumber = null, FileInfo buildFile = null, bool useNugetRestore = True, bool useConfigurationReleaseMode = True, int timeoutInSec = 1200, string logPrefix = , string additionalBuildArguments = , CancellationToken cancellationToken = null) +>``` +>Summary: Builds a .NET project or solution and collects compilation warnings grouped by warning code. +> +>Parameters:
+>     `rootPath`  -  The root directory containing the project or solution to build.
+>     `runNumber`  -  Optional run number for logging purposes.
+>     `buildFile`  -  Optional specific solution or project file to build. If not specified, discovers the build file automatically.
+>     `useNugetRestore`  -  Whether to perform NuGet restore before building. Default is true.
+>     `useConfigurationReleaseMode`  -  Whether to build in Release mode. If false, builds in Debug mode. Default is true.
+>     `timeoutInSec`  -  Build timeout in seconds. Default is 1200 seconds (20 minutes).
+>     `logPrefix`  -  Optional prefix for log messages.
+>     `additionalBuildArguments`  -  Additional arguments appended to the dotnet build command, such as -p:NoWarn=CS0168 or -f net9.0.
+>     `cancellationToken`  -  Token to cancel the build operation.
+> +>Returns: A dictionary mapping warning codes to their occurrence counts. +> +>Remarks: This is a convenience overload that uses `Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance`; for build progress visibility prefer the overload accepting an `Microsoft.Extensions.Logging.ILogger`. +#### ParseErrors +>```csharp +>Dictionary ParseErrors(string buildOutput) +>``` +>Summary: Parses raw dotnet build output and returns error codes grouped by their occurrence count. Recognises MSBuild errors (MSB prefix), NuGet errors (NU prefix), and general compiler errors (e.g. CS, CA). The project-file suffix that MSBuild appends — ` [project.csproj]` — is optional; errors emitted without it are still counted. +> +>Parameters:
+>     `buildOutput`  -  The raw text output from a dotnet build invocation.
+> +>Returns: A dictionary mapping each error code to the number of times it appeared. +#### ParseWarnings +>```csharp +>Dictionary ParseWarnings(string buildOutput) +>``` +>Summary: Parses raw dotnet build output and returns warning codes grouped by their occurrence count. Recognises MSBuild warnings (MSB prefix), NuGet warnings (NU prefix), and general compiler warnings (e.g. CS, CA). The project-file suffix that MSBuild appends — ` [project.csproj]` — is optional; warnings emitted without it are still counted. +> +>Parameters:
+>     `buildOutput`  -  The raw text output from a dotnet build invocation.
+> +>Returns: A dictionary mapping each warning code to the number of times it appeared.
diff --git a/docs/CodeDoc/Atc.DotNet/IndexExtended.md b/docs/CodeDoc/Atc.DotNet/IndexExtended.md index cf329cf9..99e604de 100644 --- a/docs/CodeDoc/Atc.DotNet/IndexExtended.md +++ b/docs/CodeDoc/Atc.DotNet/IndexExtended.md @@ -9,8 +9,12 @@ - [AtcDotnetAssemblyTypeInitializer](Atc.DotNet.md#atcdotnetassemblytypeinitializer) - [DotnetBuildHelper](Atc.DotNet.md#dotnetbuildhelper) - Static Methods - - BuildAndCollectErrors(DirectoryInfo rootPath, int? runNumber = null, FileInfo buildFile = null, bool useNugetRestore = True, bool useConfigurationReleaseMode = True, int timeoutInSec = 1200, string logPrefix = , CancellationToken cancellationToken = null) - - BuildAndCollectErrors(ILogger logger, DirectoryInfo rootPath, int? runNumber = null, FileInfo buildFile = null, bool useNugetRestore = True, bool useConfigurationReleaseMode = True, int timeoutInSec = 1200, string logPrefix = , CancellationToken cancellationToken = null) + - BuildAndCollectErrors(DirectoryInfo rootPath, int? runNumber = null, FileInfo buildFile = null, bool useNugetRestore = True, bool useConfigurationReleaseMode = True, int timeoutInSec = 1200, string logPrefix = , string additionalBuildArguments = , CancellationToken cancellationToken = null) + - BuildAndCollectErrors(ILogger logger, DirectoryInfo rootPath, int? runNumber = null, FileInfo buildFile = null, bool useNugetRestore = True, bool useConfigurationReleaseMode = True, int timeoutInSec = 1200, string logPrefix = , string additionalBuildArguments = , CancellationToken cancellationToken = null) + - BuildAndCollectWarnings(DirectoryInfo rootPath, int? runNumber = null, FileInfo buildFile = null, bool useNugetRestore = True, bool useConfigurationReleaseMode = True, int timeoutInSec = 1200, string logPrefix = , string additionalBuildArguments = , CancellationToken cancellationToken = null) + - BuildAndCollectWarnings(ILogger logger, DirectoryInfo rootPath, int? runNumber = null, FileInfo buildFile = null, bool useNugetRestore = True, bool useConfigurationReleaseMode = True, int timeoutInSec = 1200, string logPrefix = , string additionalBuildArguments = , CancellationToken cancellationToken = null) + - ParseErrors(string buildOutput) + - ParseWarnings(string buildOutput) - [DotnetCsProjFileHelper](Atc.DotNet.md#dotnetcsprojfilehelper) - Static Methods - FindAllInPath(DirectoryInfo directoryInfo, SearchOption searchOption = AllDirectories) diff --git a/docs/CodeDoc/Atc.Rest.Extended/Atc.Rest.Extended.Options.md b/docs/CodeDoc/Atc.Rest.Extended/Atc.Rest.Extended.Options.md index a708886d..ab3f8802 100644 --- a/docs/CodeDoc/Atc.Rest.Extended/Atc.Rest.Extended.Options.md +++ b/docs/CodeDoc/Atc.Rest.Extended/Atc.Rest.Extended.Options.md @@ -28,7 +28,7 @@ Configures API versioning options for ASP.NET Core API versioning. Sets up versi
## ConfigureAuthorizationOptions -Post-configures JWT Bearer authentication and authorization options based on `Atc.Rest.Extended.Options.RestApiExtendedOptions`. Handles issuer signing key retrieval from OpenID Connect configuration and token validation setup. +Post-configures JWT Bearer authentication and authorization options based on `Atc.Rest.Extended.Options.RestApiExtendedOptions`. Signing-key discovery is delegated to JwtBearer's built-in `Microsoft.IdentityModel.Protocols.ConfigurationManager`1`, which fetches and caches the OIDC discovery document on the first authentication request using the `Microsoft.AspNetCore.Authentication.JwtBearer.JwtBearerOptions.Authority` set here. >```csharp >public class ConfigureAuthorizationOptions : IPostConfigureOptions, IPostConfigureOptions @@ -40,7 +40,7 @@ Post-configures JWT Bearer authentication and authorization options based on `At >```csharp >void PostConfigure(string name, JwtBearerOptions options) >``` ->Summary: Post-configures JWT Bearer options with token validation parameters and issuer signing keys. +>Summary: Post-configures JWT Bearer options with token validation parameters. Signing keys are not pre-fetched; JwtBearer's built-in `Microsoft.IdentityModel.Protocols.ConfigurationManager`1` discovers and caches them from the OIDC discovery endpoint on the first authentication request. > >Parameters:
>     `name`  -  The name of the options instance being configured.
@@ -49,7 +49,7 @@ Post-configures JWT Bearer authentication and authorization options based on `At >```csharp >void PostConfigure(string name, AuthenticationOptions options) >``` ->Summary: Post-configures JWT Bearer options with token validation parameters and issuer signing keys. +>Summary: Post-configures JWT Bearer options with token validation parameters. Signing keys are not pre-fetched; JwtBearer's built-in `Microsoft.IdentityModel.Protocols.ConfigurationManager`1` discovers and caches them from the OIDC discovery endpoint on the first authentication request. > >Parameters:
>     `name`  -  The name of the options instance being configured.
diff --git a/docs/CodeDoc/Atc.Rest.FluentAssertions/Atc.Rest.FluentAssertions.md b/docs/CodeDoc/Atc.Rest.FluentAssertions/Atc.Rest.FluentAssertions.md index 80978303..159ec404 100644 --- a/docs/CodeDoc/Atc.Rest.FluentAssertions/Atc.Rest.FluentAssertions.md +++ b/docs/CodeDoc/Atc.Rest.FluentAssertions/Atc.Rest.FluentAssertions.md @@ -213,6 +213,17 @@ Provides FluentAssertions-style assertions for `Microsoft.AspNetCore.Mvc.OkObjec >     `becauseArgs`  -  Optional formatting arguments for the parameter.
> >Returns: An `FluentAssertions.AndWhichConstraint`2` for further assertions on the typed content. +#### WithEmptyContent +>```csharp +>AndConstraint WithEmptyContent(string because = , object[] becauseArgs) +>``` +>Summary: Asserts that the OK result has no body content (the result value is ). +> +>Parameters:
+>     `because`  -  Optional explanation of why the assertion is needed.
+>     `becauseArgs`  -  Optional formatting arguments for the parameter.
+> +>Returns: An `FluentAssertions.AndConstraint`1` for chaining further assertions.
@@ -324,6 +335,18 @@ Provides FluentAssertions-style assertions for `Microsoft.AspNetCore.Mvc.ActionR >     `becauseArgs`  -  Optional formatting arguments for the parameter.
> >Returns: An `Atc.Rest.FluentAssertions.OkResultAssertions` instance for further assertions. +#### BeOkResultWithContent +>```csharp +>AndWhichConstraint BeOkResultWithContent(T expectedContent, string because = , object[] becauseArgs) +>``` +>Summary: Asserts that the action result is a 200 OK result whose content is equivalent to `expectedContent`. This is a convenience shorthand for `BeOkResult().WithContent(expectedContent)`. +> +>Parameters:
+>     `expectedContent`  -  The expected content value to compare against.
+>     `because`  -  Optional explanation of why the assertion is needed.
+>     `becauseArgs`  -  Optional formatting arguments for the parameter.
+> +>Returns: An `FluentAssertions.AndWhichConstraint`2` for further assertions.
diff --git a/docs/CodeDoc/Atc.Rest.FluentAssertions/IndexExtended.md b/docs/CodeDoc/Atc.Rest.FluentAssertions/IndexExtended.md index 230a4bda..262a1370 100644 --- a/docs/CodeDoc/Atc.Rest.FluentAssertions/IndexExtended.md +++ b/docs/CodeDoc/Atc.Rest.FluentAssertions/IndexExtended.md @@ -29,6 +29,7 @@ - Methods - WithContent(T expectedContent, string because = , object[] becauseArgs) - WithContentOfType(string because = , object[] becauseArgs) + - WithEmptyContent(string because = , object[] becauseArgs) - [ResultAssertions](Atc.Rest.FluentAssertions.md#resultassertions) - Methods - BeAcceptedResult(string because = , object[] becauseArgs) @@ -40,6 +41,7 @@ - BeNoContentResult(string because = , object[] becauseArgs) - BeNotFoundResult(string because = , object[] becauseArgs) - BeOkResult(string because = , object[] becauseArgs) + - BeOkResultWithContent(T expectedContent, string because = , object[] becauseArgs) - [ResultBaseExtensions](Atc.Rest.FluentAssertions.md#resultbaseextensions) - Static Methods - Should(this ResultBase subject) diff --git a/docs/CodeDoc/Atc.Rest/Atc.Rest.Options.md b/docs/CodeDoc/Atc.Rest/Atc.Rest.Options.md index 4cbc59ff..4fa82669 100644 --- a/docs/CodeDoc/Atc.Rest/Atc.Rest.Options.md +++ b/docs/CodeDoc/Atc.Rest/Atc.Rest.Options.md @@ -100,6 +100,16 @@ Copy and fill out the AzureAd section into the project User Secrets. >Issuer >``` >Summary: Gets or sets the expected token issuer for validation. +#### NameClaimType +>```csharp +>NameClaimType +>``` +>Summary: Gets or sets the JWT claim type used to populate the user's identity name (`System.Security.Claims.ClaimsIdentity.Name`). For Azure AD access tokens the claim is typically `"name"` or `"preferred_username"`. When or empty, the framework default (`ClaimTypes.Name` = the long URI form) is used. +#### RoleClaimType +>```csharp +>RoleClaimType +>``` +>Summary: Gets or sets the JWT claim type used to populate ASP.NET Core roles for `[Authorize(Roles=…)]`. For Azure AD access tokens the claim is `"roles"`; for client-credentials tokens the scope claim is `"scp"`. When or empty, the framework default (`ClaimTypes.Role` = the long URI form) is used, which does not match the short-form claims issued by Azure AD. #### TenantId >```csharp >TenantId @@ -129,7 +139,7 @@ Copy and fill out the AzureAd section into the project User Secrets. ## ConfigureApiBehaviorOptions Configures ASP.NET Core API behavior options for model validation and error responses. ->Remarks: This class customizes the default API behavior to: Suppress automatic binding source inference for better controlReturn ValidationProblemDetails for invalid model stateInclude correlation ID in validation error responsesTrack validation errors in Application Insights telemetry +>Remarks: This class customizes the default API behavior to: Suppress automatic binding source inference for better controlReturn ValidationProblemDetails for invalid model stateInclude correlation ID in validation error responsesTrack validation errors in Application Insights telemetry when a `Microsoft.ApplicationInsights.TelemetryClient` is provided >```csharp >public class ConfigureApiBehaviorOptions : IConfigureOptions @@ -234,7 +244,7 @@ Configuration options for the REST API framework. >``` >Summary: Gets or sets the allowed CORS origins for the API. > ->Remarks: When null or empty, a permissive policy (AllowAnyOrigin, AllowAnyMethod, AllowAnyHeader) is used. When specified, only the listed origins are allowed. Set this in production to prevent CSRF attacks. +>Remarks: When null or empty and the environment is Development, a permissive policy (AllowAnyOrigin, AllowAnyMethod, AllowAnyHeader) is applied. When null or empty in non-Development environments, no CORS middleware is added and the browser's same-origin policy applies — no CORS headers are emitted. When specified, only the listed origins are allowed in all environments. #### AssemblyPairs >```csharp >AssemblyPairs diff --git a/docs/CodeDoc/Atc.Rest/Atc.Rest.Results.md b/docs/CodeDoc/Atc.Rest/Atc.Rest.Results.md index 90262410..36c09f26 100644 --- a/docs/CodeDoc/Atc.Rest/Atc.Rest.Results.md +++ b/docs/CodeDoc/Atc.Rest/Atc.Rest.Results.md @@ -159,6 +159,39 @@ Factory methods for creating standardized HTTP response results. >     `contentType`  -  The content type. Defaults to application/octet-stream.
> >Returns: A `Microsoft.AspNetCore.Mvc.FileResult` configured for file download. +#### CreateObjectResultWithProblemDetails +>```csharp +>ObjectResult CreateObjectResultWithProblemDetails(HttpStatusCode statusCode, string message) +>``` +>Summary: Creates an `Microsoft.AspNetCore.Mvc.ObjectResult` containing ProblemDetails, allowing ASP.NET Core's output formatters to serialize it using the app-configured `System.Text.Json.JsonSerializerOptions`. Prefer this over `Atc.Rest.Results.ResultFactory.CreateContentResultWithProblemDetails(System.Net.HttpStatusCode,System.String,System.String)` when consistent casing with the rest of the API is required. +> +>Parameters:
+>     `statusCode`  -  The HTTP status code.
+>     `message`  -  The detail message describing the problem.
+> +>Returns: An `Microsoft.AspNetCore.Mvc.ObjectResult` wrapping a `Microsoft.AspNetCore.Mvc.ProblemDetails` instance. +#### CreateObjectResultWithValidationProblemDetails +>```csharp +>ObjectResult CreateObjectResultWithValidationProblemDetails(HttpStatusCode statusCode, string message) +>``` +>Summary: Creates an `Microsoft.AspNetCore.Mvc.ObjectResult` containing ValidationProblemDetails without field-specific errors, allowing ASP.NET Core's output formatters to serialize it using the app-configured `System.Text.Json.JsonSerializerOptions`. Prefer this over `Atc.Rest.Results.ResultFactory.CreateContentResultWithValidationProblemDetails(System.Net.HttpStatusCode,System.String,System.String)` when consistent casing with the rest of the API is required. +> +>Parameters:
+>     `statusCode`  -  The HTTP status code.
+>     `message`  -  The detail message describing the validation failure.
+> +>Returns: An `Microsoft.AspNetCore.Mvc.ObjectResult` wrapping a `Microsoft.AspNetCore.Mvc.ValidationProblemDetails` instance. +#### CreateObjectResultWithValidationProblemDetails +>```csharp +>ObjectResult CreateObjectResultWithValidationProblemDetails(HttpStatusCode statusCode, Dictionary errors, string message) +>``` +>Summary: Creates an `Microsoft.AspNetCore.Mvc.ObjectResult` containing ValidationProblemDetails without field-specific errors, allowing ASP.NET Core's output formatters to serialize it using the app-configured `System.Text.Json.JsonSerializerOptions`. Prefer this over `Atc.Rest.Results.ResultFactory.CreateContentResultWithValidationProblemDetails(System.Net.HttpStatusCode,System.String,System.String)` when consistent casing with the rest of the API is required. +> +>Parameters:
+>     `statusCode`  -  The HTTP status code.
+>     `message`  -  The detail message describing the validation failure.
+> +>Returns: An `Microsoft.AspNetCore.Mvc.ObjectResult` wrapping a `Microsoft.AspNetCore.Mvc.ValidationProblemDetails` instance. #### CreateProblemDetails >```csharp >ProblemDetails CreateProblemDetails(HttpStatusCode statusCode, string message) diff --git a/docs/CodeDoc/Atc.Rest/IndexExtended.md b/docs/CodeDoc/Atc.Rest/IndexExtended.md index 9ef3499e..d6b87927 100644 --- a/docs/CodeDoc/Atc.Rest/IndexExtended.md +++ b/docs/CodeDoc/Atc.Rest/IndexExtended.md @@ -108,6 +108,8 @@ - ClientId - Instance - Issuer + - NameClaimType + - RoleClaimType - TenantId - ValidAudiences - ValidIssuers @@ -176,6 +178,9 @@ - CreateContentResultWithValidationProblemDetails(HttpStatusCode statusCode, Dictionary<string, string[]> errors, string message, string contentType = application/json) - CreateContentResultWithValidationProblemDetails(HttpStatusCode statusCode, string message, string contentType = application/json) - CreateFileContentResult(byte[] bytes, string fileName, string contentType = application/octet-stream) + - CreateObjectResultWithProblemDetails(HttpStatusCode statusCode, string message) + - CreateObjectResultWithValidationProblemDetails(HttpStatusCode statusCode, Dictionary<string, string[]> errors, string message) + - CreateObjectResultWithValidationProblemDetails(HttpStatusCode statusCode, string message) - CreateProblemDetails(HttpStatusCode statusCode, string message) - CreateValidationProblemDetails(HttpStatusCode statusCode, Dictionary<string, string[]> errors, string message) diff --git a/docs/CodeDoc/Atc.Rest/Microsoft.AspNetCore.Mvc.Filters.md b/docs/CodeDoc/Atc.Rest/Microsoft.AspNetCore.Mvc.Filters.md index db0e0be9..8504aa83 100644 --- a/docs/CodeDoc/Atc.Rest/Microsoft.AspNetCore.Mvc.Filters.md +++ b/docs/CodeDoc/Atc.Rest/Microsoft.AspNetCore.Mvc.Filters.md @@ -9,7 +9,7 @@ ## ErrorHandlingExceptionFilterAttribute Exception filter attribute that handles unhandled exceptions and converts them to standardized HTTP responses. ->Remarks: This filter intercepts exceptions thrown during action execution and: Maps exception types to appropriate HTTP status codesTracks exceptions in Application Insights telemetryReturns either ProblemDetails or plain text error messagesIncludes correlation ID for request tracing Supported exception mappings: `System.ComponentModel.DataAnnotations.ValidationException` → 400 Bad Request`System.UnauthorizedAccessException` → 401 Unauthorized`System.InvalidOperationException` → 409 Conflict`System.NotImplementedException` → 501 Not ImplementedAll other exceptions → 500 Internal Server Error +>Remarks: This filter intercepts exceptions thrown during action execution and: Maps exception types to appropriate HTTP status codesTracks exceptions in Application Insights telemetryReturns either ProblemDetails or plain text error messagesIncludes correlation ID for request tracing Supported exception mappings: `System.ComponentModel.DataAnnotations.ValidationException` → 400 Bad Request`System.UnauthorizedAccessException` → 401 Unauthorized`System.NotImplementedException` → 501 Not ImplementedAll other exceptions → 500 Internal Server Error >```csharp >public class ErrorHandlingExceptionFilterAttribute : ExceptionFilterAttribute, IAsyncExceptionFilter, IFilterMetadata, IExceptionFilter, IOrderedFilter diff --git a/docs/CodeDoc/Atc.XUnit/Atc.XUnit.md b/docs/CodeDoc/Atc.XUnit/Atc.XUnit.md index 56ed71d1..a7747054 100644 --- a/docs/CodeDoc/Atc.XUnit/Atc.XUnit.md +++ b/docs/CodeDoc/Atc.XUnit/Atc.XUnit.md @@ -82,12 +82,23 @@ Provides helper methods for asserting code compliance related to XML documentati >     `type`  -  The type to validate for XML documentation.
#### AssertExportedTypesWithMissingComments >```csharp +>void AssertExportedTypesWithMissingComments(Assembly assembly, FileInfo xmlDocPath, List excludeTypes = null) +>``` +>Summary: Asserts that all exported types in an assembly have XML documentation comments, using an explicit XML documentation file path instead of relying on automatic path resolution. Use this overload when the XML documentation file is not located next to the assembly or in `System.AppDomain.CurrentDomain` base directory. +> +>Parameters:
+>     `assembly`  -  The assembly to validate.
+>     `xmlDocPath`  -  The explicit path to the XML documentation file for .
+>     `excludeTypes`  -  Optional list of types to exclude from validation.
+#### AssertExportedTypesWithMissingComments +>```csharp >void AssertExportedTypesWithMissingComments(Assembly assembly, List excludeTypes = null) >``` ->Summary: Asserts that all exported types in an assembly have XML documentation comments. Fails the test if any types are missing documentation. +>Summary: Asserts that all exported types in an assembly have XML documentation comments, using an explicit XML documentation file path instead of relying on automatic path resolution. Use this overload when the XML documentation file is not located next to the assembly or in `System.AppDomain.CurrentDomain` base directory. > >Parameters:
>     `assembly`  -  The assembly to validate.
+>     `xmlDocPath`  -  The explicit path to the XML documentation file for .
>     `excludeTypes`  -  Optional list of types to exclude from validation.

@@ -162,7 +173,7 @@ Provides helper methods for asserting code compliance related to test coverage. #### AssertExportedMethodsWithMissingTests >```csharp ->void AssertExportedMethodsWithMissingTests(DecompilerType decompilerType, Type sourceType, Type testType, bool useFullName = False) +>void AssertExportedMethodsWithMissingTests(DecompilerType decompilerType, Type sourceType, Type testType, bool useFullName = False, CancellationToken cancellationToken = null) >``` >Summary: Asserts that all public methods in a source type have corresponding unit tests. Fails the test if any methods are missing test coverage. > @@ -171,9 +182,10 @@ Provides helper methods for asserting code compliance related to test coverage. >     `sourceType`  -  The source type to validate for test coverage.
>     `testType`  -  The test type containing unit tests for the source type.
>     `useFullName`  -  If set to true, use full type names in output.
+>     `cancellationToken`  -  A token to cancel the analysis operation.
#### AssertExportedMethodsWithMissingTests >```csharp ->void AssertExportedMethodsWithMissingTests(DecompilerType decompilerType, Type sourceType, Assembly testAssembly, bool useFullName = False) +>void AssertExportedMethodsWithMissingTests(DecompilerType decompilerType, Type sourceType, Assembly testAssembly, bool useFullName = False, CancellationToken cancellationToken = null) >``` >Summary: Asserts that all public methods in a source type have corresponding unit tests. Fails the test if any methods are missing test coverage. > @@ -182,9 +194,10 @@ Provides helper methods for asserting code compliance related to test coverage. >     `sourceType`  -  The source type to validate for test coverage.
>     `testType`  -  The test type containing unit tests for the source type.
>     `useFullName`  -  If set to true, use full type names in output.
+>     `cancellationToken`  -  A token to cancel the analysis operation.
#### AssertExportedMethodsWithMissingTests >```csharp ->void AssertExportedMethodsWithMissingTests(DecompilerType decompilerType, Assembly sourceAssembly, Assembly testAssembly, List excludeSourceTypes = null, bool useFullName = False) +>void AssertExportedMethodsWithMissingTests(DecompilerType decompilerType, Assembly sourceAssembly, Assembly testAssembly, List excludeSourceTypes = null, bool useFullName = False, CancellationToken cancellationToken = null) >``` >Summary: Asserts that all public methods in a source type have corresponding unit tests. Fails the test if any methods are missing test coverage. > @@ -193,9 +206,10 @@ Provides helper methods for asserting code compliance related to test coverage. >     `sourceType`  -  The source type to validate for test coverage.
>     `testType`  -  The test type containing unit tests for the source type.
>     `useFullName`  -  If set to true, use full type names in output.
+>     `cancellationToken`  -  A token to cancel the analysis operation.
#### CollectExportedMethodsWithMissingTestsAndGenerateText >```csharp ->string CollectExportedMethodsWithMissingTestsAndGenerateText(DecompilerType decompilerType, Assembly sourceAssembly, Assembly testAssembly, List excludeSourceTypes = null, bool useFullName = False) +>string CollectExportedMethodsWithMissingTestsAndGenerateText(DecompilerType decompilerType, Assembly sourceAssembly, Assembly testAssembly, List excludeSourceTypes = null, bool useFullName = False, CancellationToken cancellationToken = null) >``` >Summary: Collects exported methods with missing tests and generates a formatted text report. > @@ -205,11 +219,12 @@ Provides helper methods for asserting code compliance related to test coverage. >     `testAssembly`  -  The test assembly to search for unit tests.
>     `excludeSourceTypes`  -  Optional list of source types to exclude from analysis.
>     `useFullName`  -  If set to true, use full type names in output.
+>     `cancellationToken`  -  A token to cancel the analysis operation.
> >Returns: A multi-line string containing all method signatures missing tests. #### CollectExportedMethodsWithMissingTestsAndGenerateTextLines >```csharp ->string[] CollectExportedMethodsWithMissingTestsAndGenerateTextLines(DecompilerType decompilerType, Assembly sourceAssembly, Assembly testAssembly, List excludeSourceTypes = null, bool useFullName = False) +>string[] CollectExportedMethodsWithMissingTestsAndGenerateTextLines(DecompilerType decompilerType, Assembly sourceAssembly, Assembly testAssembly, List excludeSourceTypes = null, bool useFullName = False, CancellationToken cancellationToken = null) >``` >Summary: Collects exported methods with missing tests and generates an array of formatted method signatures. > @@ -219,11 +234,12 @@ Provides helper methods for asserting code compliance related to test coverage. >     `testAssembly`  -  The test assembly to search for unit tests.
>     `excludeSourceTypes`  -  Optional list of source types to exclude from analysis.
>     `useFullName`  -  If set to true, use full type names in output.
+>     `cancellationToken`  -  A token to cancel the analysis operation.
> >Returns: An array of strings containing beautified method signatures. #### CollectExportedMethodsWithMissingTestsFromAssembly >```csharp ->MethodInfo[] CollectExportedMethodsWithMissingTestsFromAssembly(DecompilerType decompilerType, Assembly sourceAssembly, Assembly testAssembly, List excludeSourceTypes = null) +>MethodInfo[] CollectExportedMethodsWithMissingTestsFromAssembly(DecompilerType decompilerType, Assembly sourceAssembly, Assembly testAssembly, List excludeSourceTypes = null, CancellationToken cancellationToken = null) >``` >Summary: Collects all exported methods from an assembly that are missing test coverage. > @@ -232,33 +248,36 @@ Provides helper methods for asserting code compliance related to test coverage. >     `sourceAssembly`  -  The source assembly to analyze.
>     `testAssembly`  -  The test assembly to search for unit tests.
>     `excludeSourceTypes`  -  Optional list of source types to exclude from analysis.
+>     `cancellationToken`  -  A token to cancel the analysis operation.
> >Returns: An array of `System.Reflection.MethodInfo` objects representing methods missing test coverage. #### CollectExportedMethodsWithMissingTestsToExcel >```csharp ->void CollectExportedMethodsWithMissingTestsToExcel(DecompilerType decompilerType, Assembly sourceAssembly, Assembly testAssembly, List excludeSourceTypes = null) +>void CollectExportedMethodsWithMissingTestsToExcel(DecompilerType decompilerType, Assembly sourceAssembly, Assembly testAssembly, List excludeSourceTypes = null, CancellationToken cancellationToken = null) >``` ->Summary: Collects exported methods with missing tests and exports them to an Excel file at C:\Temp. +>Summary: Collects exported methods with missing tests and exports them to an Excel file at the system temp directory. > >Parameters:
>     `decompilerType`  -  The to use for analyzing test method bodies.
>     `sourceAssembly`  -  The source assembly to analyze.
>     `testAssembly`  -  The test assembly to search for unit tests.
>     `excludeSourceTypes`  -  Optional list of source types to exclude from analysis.
+>     `cancellationToken`  -  A token to cancel the analysis operation.
#### CollectExportedMethodsWithMissingTestsToExcel >```csharp ->void CollectExportedMethodsWithMissingTestsToExcel(DecompilerType decompilerType, DirectoryInfo reportDirectory, Assembly sourceAssembly, Assembly testAssembly, List excludeSourceTypes = null) +>void CollectExportedMethodsWithMissingTestsToExcel(DecompilerType decompilerType, DirectoryInfo reportDirectory, Assembly sourceAssembly, Assembly testAssembly, List excludeSourceTypes = null, CancellationToken cancellationToken = null) >``` ->Summary: Collects exported methods with missing tests and exports them to an Excel file at C:\Temp. +>Summary: Collects exported methods with missing tests and exports them to an Excel file at the system temp directory. > >Parameters:
>     `decompilerType`  -  The to use for analyzing test method bodies.
>     `sourceAssembly`  -  The source assembly to analyze.
>     `testAssembly`  -  The test assembly to search for unit tests.
>     `excludeSourceTypes`  -  Optional list of source types to exclude from analysis.
+>     `cancellationToken`  -  A token to cancel the analysis operation.
#### CollectExportedTypesWithMissingTests >```csharp ->Type[] CollectExportedTypesWithMissingTests(DecompilerType decompilerType, Assembly sourceAssembly, Assembly testAssembly, List excludeSourceTypes = null) +>Type[] CollectExportedTypesWithMissingTests(DecompilerType decompilerType, Assembly sourceAssembly, Assembly testAssembly, List excludeSourceTypes = null, CancellationToken cancellationToken = null) >``` >Summary: Collects all exported types that have at least one method missing test coverage. > @@ -267,11 +286,12 @@ Provides helper methods for asserting code compliance related to test coverage. >     `sourceAssembly`  -  The source assembly to analyze.
>     `testAssembly`  -  The test assembly to search for unit tests.
>     `excludeSourceTypes`  -  Optional list of source types to exclude from analysis.
+>     `cancellationToken`  -  A token to cancel the analysis operation.
> >Returns: An array of types that have methods missing test coverage. #### CollectExportedTypesWithMissingTestsAndGenerateText >```csharp ->string CollectExportedTypesWithMissingTestsAndGenerateText(DecompilerType decompilerType, Assembly sourceAssembly, Assembly testAssembly, List excludeSourceTypes = null, bool useFullName = False) +>string CollectExportedTypesWithMissingTestsAndGenerateText(DecompilerType decompilerType, Assembly sourceAssembly, Assembly testAssembly, List excludeSourceTypes = null, bool useFullName = False, CancellationToken cancellationToken = null) >``` >Summary: Collects exported types with missing tests and generates a C# code snippet for an exclude list. Useful for generating initial exclude lists when adding test coverage validation. > @@ -281,6 +301,7 @@ Provides helper methods for asserting code compliance related to test coverage. >     `testAssembly`  -  The test assembly to search for unit tests.
>     `excludeSourceTypes`  -  Optional list of source types to exclude from analysis.
>     `useFullName`  -  If set to true, use full type names in output.
+>     `cancellationToken`  -  A token to cancel the analysis operation.
> >Returns: A formatted C# code snippet containing a list of typeof() expressions for types missing tests. diff --git a/docs/CodeDoc/Atc.XUnit/IndexExtended.md b/docs/CodeDoc/Atc.XUnit/IndexExtended.md index 2b69def9..0d83c1b9 100644 --- a/docs/CodeDoc/Atc.XUnit/IndexExtended.md +++ b/docs/CodeDoc/Atc.XUnit/IndexExtended.md @@ -15,6 +15,7 @@ - [CodeComplianceDocumentationHelper](Atc.XUnit.md#codecompliancedocumentationhelper) - Static Methods - AssertExportedTypeWithMissingComments(Type type) + - AssertExportedTypesWithMissingComments(Assembly assembly, FileInfo xmlDocPath, List<Type> excludeTypes = null) - AssertExportedTypesWithMissingComments(Assembly assembly, List<Type> excludeTypes = null) - [CodeComplianceHelper](Atc.XUnit.md#codecompliancehelper) - Static Methods @@ -25,16 +26,16 @@ - AssertLocalizationResourcesForMissingTranslations(Assembly assembly, IList<string> cultureNames) - [CodeComplianceTestHelper](Atc.XUnit.md#codecompliancetesthelper) - Static Methods - - AssertExportedMethodsWithMissingTests(DecompilerType decompilerType, Assembly sourceAssembly, Assembly testAssembly, List<Type> excludeSourceTypes = null, bool useFullName = False) - - AssertExportedMethodsWithMissingTests(DecompilerType decompilerType, Type sourceType, Assembly testAssembly, bool useFullName = False) - - AssertExportedMethodsWithMissingTests(DecompilerType decompilerType, Type sourceType, Type testType, bool useFullName = False) - - CollectExportedMethodsWithMissingTestsAndGenerateText(DecompilerType decompilerType, Assembly sourceAssembly, Assembly testAssembly, List<Type> excludeSourceTypes = null, bool useFullName = False) - - CollectExportedMethodsWithMissingTestsAndGenerateTextLines(DecompilerType decompilerType, Assembly sourceAssembly, Assembly testAssembly, List<Type> excludeSourceTypes = null, bool useFullName = False) - - CollectExportedMethodsWithMissingTestsFromAssembly(DecompilerType decompilerType, Assembly sourceAssembly, Assembly testAssembly, List<Type> excludeSourceTypes = null) - - CollectExportedMethodsWithMissingTestsToExcel(DecompilerType decompilerType, Assembly sourceAssembly, Assembly testAssembly, List<Type> excludeSourceTypes = null) - - CollectExportedMethodsWithMissingTestsToExcel(DecompilerType decompilerType, DirectoryInfo reportDirectory, Assembly sourceAssembly, Assembly testAssembly, List<Type> excludeSourceTypes = null) - - CollectExportedTypesWithMissingTests(DecompilerType decompilerType, Assembly sourceAssembly, Assembly testAssembly, List<Type> excludeSourceTypes = null) - - CollectExportedTypesWithMissingTestsAndGenerateText(DecompilerType decompilerType, Assembly sourceAssembly, Assembly testAssembly, List<Type> excludeSourceTypes = null, bool useFullName = False) + - AssertExportedMethodsWithMissingTests(DecompilerType decompilerType, Assembly sourceAssembly, Assembly testAssembly, List<Type> excludeSourceTypes = null, bool useFullName = False, CancellationToken cancellationToken = null) + - AssertExportedMethodsWithMissingTests(DecompilerType decompilerType, Type sourceType, Assembly testAssembly, bool useFullName = False, CancellationToken cancellationToken = null) + - AssertExportedMethodsWithMissingTests(DecompilerType decompilerType, Type sourceType, Type testType, bool useFullName = False, CancellationToken cancellationToken = null) + - CollectExportedMethodsWithMissingTestsAndGenerateText(DecompilerType decompilerType, Assembly sourceAssembly, Assembly testAssembly, List<Type> excludeSourceTypes = null, bool useFullName = False, CancellationToken cancellationToken = null) + - CollectExportedMethodsWithMissingTestsAndGenerateTextLines(DecompilerType decompilerType, Assembly sourceAssembly, Assembly testAssembly, List<Type> excludeSourceTypes = null, bool useFullName = False, CancellationToken cancellationToken = null) + - CollectExportedMethodsWithMissingTestsFromAssembly(DecompilerType decompilerType, Assembly sourceAssembly, Assembly testAssembly, List<Type> excludeSourceTypes = null, CancellationToken cancellationToken = null) + - CollectExportedMethodsWithMissingTestsToExcel(DecompilerType decompilerType, Assembly sourceAssembly, Assembly testAssembly, List<Type> excludeSourceTypes = null, CancellationToken cancellationToken = null) + - CollectExportedMethodsWithMissingTestsToExcel(DecompilerType decompilerType, DirectoryInfo reportDirectory, Assembly sourceAssembly, Assembly testAssembly, List<Type> excludeSourceTypes = null, CancellationToken cancellationToken = null) + - CollectExportedTypesWithMissingTests(DecompilerType decompilerType, Assembly sourceAssembly, Assembly testAssembly, List<Type> excludeSourceTypes = null, CancellationToken cancellationToken = null) + - CollectExportedTypesWithMissingTestsAndGenerateText(DecompilerType decompilerType, Assembly sourceAssembly, Assembly testAssembly, List<Type> excludeSourceTypes = null, bool useFullName = False, CancellationToken cancellationToken = null) - [DecompilerType](Atc.XUnit.md#decompilertype) - [IntegrationTestCliBase](Atc.XUnit.md#integrationtestclibase) - Static Methods diff --git a/docs/CodeDoc/Atc/Atc.Data.SemVer.md b/docs/CodeDoc/Atc/Atc.Data.SemVer.md index 872b5ec4..596ebcfd 100644 --- a/docs/CodeDoc/Atc/Atc.Data.SemVer.md +++ b/docs/CodeDoc/Atc/Atc.Data.SemVer.md @@ -11,7 +11,7 @@ Represents a version object, compliant with the Semantic Version standard 2.0 (http://semver.org). >```csharp ->public class SemanticVersion : IComparable, IComparable, IEquatable +>public class SemanticVersion : IComparable, IComparable, IEquatable, IFormattable, ISpanParsable, IParsable >``` ### Static Methods @@ -181,6 +181,10 @@ Represents a version object, compliant with the Semantic Version standard 2.0 (h >```csharp >string ToString() >``` +#### ToString +>```csharp +>string ToString(string format, IFormatProvider formatProvider) +>``` #### ToVersion >```csharp >Version ToVersion() diff --git a/docs/CodeDoc/Atc/Atc.Factories.md b/docs/CodeDoc/Atc/Atc.Factories.md index e7f937ac..ff421b2e 100644 --- a/docs/CodeDoc/Atc/Atc.Factories.md +++ b/docs/CodeDoc/Atc/Atc.Factories.md @@ -23,14 +23,48 @@ Provides factory methods for creating instances of `System.Collections.Generic.I >Summary: Returns an empty `System.Collections.Generic.IAsyncEnumerable`1`. > >Returns: An empty `System.Collections.Generic.IAsyncEnumerable`1`. +#### FromEnumerable +>```csharp +>IAsyncEnumerable FromEnumerable(IEnumerable source, CancellationToken cancellationToken = null) +>``` +>Summary: Wraps an `System.Collections.Generic.IEnumerable`1` as an `System.Collections.Generic.IAsyncEnumerable`1`, yielding each element in order. +> +>Parameters:
+>     `source`  -  The synchronous sequence to wrap.
+>     `cancellationToken`  -  A token to cancel the asynchronous iteration; checked before each element is yielded.
+> +>Returns: An `System.Collections.Generic.IAsyncEnumerable`1` that yields each element of `source`. +#### FromItems +>```csharp +>IAsyncEnumerable FromItems(T[] items, CancellationToken cancellationToken = null) +>``` +>Summary: Wraps an array of items as an `System.Collections.Generic.IAsyncEnumerable`1`, yielding each item in order. +> +>Parameters:
+>     `items`  -  The items to yield.
+>     `cancellationToken`  -  A token to cancel the asynchronous iteration; checked before each item is yielded.
+> +>Returns: An `System.Collections.Generic.IAsyncEnumerable`1` that yields each element of `items`. #### FromSingleItem >```csharp ->IAsyncEnumerable FromSingleItem(T item) +>IAsyncEnumerable FromSingleItem(T item, CancellationToken cancellationToken = null) >``` >Summary: Converts a single item into an `System.Collections.Generic.IAsyncEnumerable`1`. > >Parameters:
>     `item`  -  The item to convert.
+>     `cancellationToken`  -  A token to cancel the asynchronous iteration.
> >Returns: An `System.Collections.Generic.IAsyncEnumerable`1` containing the single item. +#### FromTask +>```csharp +>IAsyncEnumerable FromTask(Task task, CancellationToken cancellationToken = null) +>``` +>Summary: Creates an `System.Collections.Generic.IAsyncEnumerable`1` that awaits the specified task and yields its result as a single element. +> +>Parameters:
+>     `task`  -  The task whose result will be yielded.
+>     `cancellationToken`  -  A token to cancel the asynchronous operation before the task is awaited.
+> +>Returns: An `System.Collections.Generic.IAsyncEnumerable`1` that yields the single result of `task`.
Generated by MarkdownCodeDoc version 1.2
diff --git a/docs/CodeDoc/Atc/Atc.Helpers.md b/docs/CodeDoc/Atc/Atc.Helpers.md index e69c84f9..160a8382 100644 --- a/docs/CodeDoc/Atc/Atc.Helpers.md +++ b/docs/CodeDoc/Atc/Atc.Helpers.md @@ -796,11 +796,11 @@ DateTimeHelper. ### Static Methods -#### TryParseShortDateUsingCurrentUiCulture +#### TryParseShortDateUi >```csharp ->bool TryParseShortDateUsingCurrentUiCulture(string value, out DateTime result) +>bool TryParseShortDateUi(string value, out DateTime result) >``` ->Summary: Tries to parse a string representation of a short date using the current UI culture's date format. +>Summary: Tries to parse a string representation of a short date using the current UI culture's date format. Use this variant when parsing input from a user interface. > >Parameters:
>     `value`  -  The string to parse.
@@ -825,11 +825,11 @@ DateTimeHelper.
> >Returns: if the parsing was successful; otherwise, . -#### TryParseShortTimeUsingCurrentUiCulture +#### TryParseShortTimeUi >```csharp ->bool TryParseShortTimeUsingCurrentUiCulture(string value, out DateTime result) +>bool TryParseShortTimeUi(string value, out DateTime result) >``` ->Summary: Tries to parse a string representation of a short time using the current UI culture's time format (12-hour or 24-hour). +>Summary: Tries to parse a string representation of a short time using the current UI culture's time format (12-hour or 24-hour). Use this variant when parsing input from a user interface. > >Parameters:
>     `value`  -  The string to parse.
@@ -839,11 +839,11 @@ DateTimeHelper.
> >Returns: if the parsing was successful; otherwise, . -#### TryParseShortTimeUsingCurrentUiCultureUtc +#### TryParseShortTimeUiUtc >```csharp ->bool TryParseShortTimeUsingCurrentUiCultureUtc(string value, out DateTime result) +>bool TryParseShortTimeUiUtc(string value, out DateTime result) >``` ->Summary: Tries to parse a string representation of a short UTC time using the current UI culture's time format (12-hour or 24-hour). +>Summary: Tries to parse a string representation of a short UTC time using the current UI culture's time format (12-hour or 24-hour). Use this variant when parsing input from a user interface. > >Parameters:
>     `value`  -  The string to parse.
@@ -883,11 +883,11 @@ DateTimeHelper.
> >Returns: if the parsing was successful; otherwise, . -#### TryParseUsingCurrentUiCulture +#### TryParseUi >```csharp ->bool TryParseUsingCurrentUiCulture(string value, out DateTime result) +>bool TryParseUi(string value, out DateTime result) >``` ->Summary: Tries to parse a string representation of a `DateTime` using the current UI culture's date and time format. +>Summary: Tries to parse a string representation of a `DateTime` using the current UI culture's date and time format. Use this variant when parsing input from a user interface. > >Parameters:
>     `value`  -  The string to parse.
@@ -924,11 +924,11 @@ DateTimeOffsetHelper. ### Static Methods -#### TryParseShortDateUsingCurrentUiCulture +#### TryParseShortDateUi >```csharp ->bool TryParseShortDateUsingCurrentUiCulture(string value, out DateTime result) +>bool TryParseShortDateUi(string value, out DateTime result) >``` ->Summary: Tries to parse a string representation of a short date using the current UI culture's date format. +>Summary: Tries to parse a string representation of a short date using the current UI culture's date format. Use this variant when parsing input from a user interface. > >Parameters:
>     `value`  -  The string to parse.
@@ -938,25 +938,84 @@ DateTimeOffsetHelper.
> >Returns: if the parsing was successful; otherwise, . -#### TryParseShortTimeUsingCurrentUiCulture +#### TryParseShortDateUsingSpecificCulture +>```csharp +>bool TryParseShortDateUsingSpecificCulture(string value, CultureInfo cultureInfo, out DateTime result) +>``` +>Summary: Tries to parse a string representation of a short date using a specific culture's date format. +> +>Parameters:
+>     `value`  -  The string to parse.
+>     `cultureInfo`  -  The culture info to use for parsing.
+>     `result`  -   + When this method returns, contains the parsed DateTimeOffset, + if the parse operation was successful; otherwise, contains the default DateTimeOffset. +
+> +>Returns: if the parsing was successful; otherwise, . +#### TryParseShortTimeUi +>```csharp +>bool TryParseShortTimeUi(string value, out DateTime result) +>``` +>Summary: Tries to parse a string representation of a short time using the current UI culture's time format (12-hour or 24-hour). Use this variant when parsing input from a user interface. +> +>Parameters:
+>     `value`  -  The string to parse.
+>     `result`  -   + When this method returns, contains the parsed DateTimeOffset, + if the parse operation was successful; otherwise, contains the default DateTimeOffset. +
+> +>Returns: if the parsing was successful; otherwise, . +#### TryParseShortTimeUiUtc +>```csharp +>bool TryParseShortTimeUiUtc(string value, out DateTime result) +>``` +>Summary: Tries to parse a string representation of a short UTC time using the current UI culture's time format (12-hour or 24-hour). Use this variant when parsing input from a user interface. +> +>Parameters:
+>     `value`  -  The string to parse.
+>     `result`  -   + When this method returns, contains the parsed DateTimeOffset, + if the parse operation was successful; otherwise, contains the default DateTimeOffset. +
+> +>Returns: if the parsing was successful; otherwise, . +#### TryParseShortTimeUsingSpecificCulture >```csharp ->bool TryParseShortTimeUsingCurrentUiCulture(string value, out DateTime result) +>bool TryParseShortTimeUsingSpecificCulture(string value, CultureInfo cultureInfo, out DateTime result) >``` ->Summary: Tries to parse a string representation of a short time using the current UI culture's time format (12-hour or 24-hour). +>Summary: Tries to parse a string representation of a short time using a specific culture's time format (12-hour or 24-hour). > >Parameters:
>     `value`  -  The string to parse.
+>     `cultureInfo`  -  The culture info to use for parsing.
>     `result`  -   When this method returns, contains the parsed DateTimeOffset, if the parse operation was successful; otherwise, contains the default DateTimeOffset.
> >Returns: if the parsing was successful; otherwise, . -#### TryParseShortTimeUsingCurrentUiCultureUtc +#### TryParseShortTimeUsingSpecificCultureUtc +>```csharp +>bool TryParseShortTimeUsingSpecificCultureUtc(string value, CultureInfo cultureInfo, out DateTime result) +>``` +>Summary: Tries to parse a string representation of a short UTC time using a specific culture's time format (12-hour or 24-hour). +> +>Parameters:
+>     `value`  -  The string to parse.
+>     `cultureInfo`  -  The culture info to use for parsing.
+>     `result`  -   + When this method returns, contains the parsed DateTimeOffset in UTC, + if the parse operation was successful; otherwise, contains the default DateTimeOffset. +
+> +>Returns: if the parsing was successful; otherwise, . +#### TryParseUi >```csharp ->bool TryParseShortTimeUsingCurrentUiCultureUtc(string value, out DateTime result) +>bool TryParseUi(string value, out DateTime result) >``` ->Summary: Tries to parse a string representation of a short UTC time using the current UI culture's time format (12-hour or 24-hour). +>Summary: Tries to parse a string representation of a `DateTimeOffset` using the current UI culture's date and time format. Use this variant when parsing input from a user interface. > >Parameters:
>     `value`  -  The string to parse.
@@ -966,14 +1025,15 @@ DateTimeOffsetHelper.
> >Returns: if the parsing was successful; otherwise, . -#### TryParseUsingCurrentUiCulture +#### TryParseUsingSpecificCulture >```csharp ->bool TryParseUsingCurrentUiCulture(string value, out DateTime result) +>bool TryParseUsingSpecificCulture(string value, CultureInfo cultureInfo, out DateTime result) >``` ->Summary: Tries to parse a string representation of a `DateTimeOffset` using the current UI culture's date and time format. +>Summary: Tries to parse a string representation of a `DateTimeOffset` using a specific culture's date and time format. > >Parameters:
>     `value`  -  The string to parse.
+>     `cultureInfo`  -  The culture info to use for parsing.
>     `result`  -   When this method returns, contains the parsed DateTimeOffset, if the parse operation was successful; otherwise, contains the default DateTimeOffset. @@ -1145,6 +1205,18 @@ Enumeration Helper: EnumHelper. >     `sortDirectionType`  -  Type of the sort direction.
>     `byFlagIncludeBase`  -  if set to [by flag include base].
>     `byFlagIncludeCombined`  -  if set to [by flag include combined].
+#### ConvertEnumToReadOnlyDictionary +>```csharp +>IReadOnlyDictionary ConvertEnumToReadOnlyDictionary(bool includeDefault = True, bool byFlagIncludeBase = True, bool byFlagIncludeCombined = True) +>``` +>Summary: Builds a read-only map from each defined enum member to its underlying `System.Int32` value. +> +>Parameters:
+>     `includeDefault`  -  If set to the 0-valued member is included.
+>     `byFlagIncludeBase`  -  For enums, include the single-bit base values.
+>     `byFlagIncludeCombined`  -  For enums, include the multi-bit combined values.
+> +>Returns: An `System.Collections.Generic.IReadOnlyDictionary`2` keyed by the typed enum value with its underlying `System.Int32` as the value. Suitable for severity-rank or threshold lookup tables that previously required a hand-rolled `Dictionary<TEnum, int>` literal. #### GetDescription >```csharp >string GetDescription(Enum enumeration) @@ -2130,6 +2202,16 @@ Provides utility methods for checking network connectivity and retrieving networ >Summary: Retrieves the public IP address of the current machine by querying an external service (api.ipify.org). > >Returns: The public `System.Net.IPAddress` if retrieval succeeds; otherwise, . +#### GetPublicIpAddressAsync +>```csharp +>Task GetPublicIpAddressAsync(CancellationToken cancellationToken = null) +>``` +>Summary: Asynchronously retrieves the public IP address of the current machine by querying an external service (api.ipify.org). +> +>Parameters:
+>     `cancellationToken`  -  A token to cancel the asynchronous operation.
+> +>Returns: The public `System.Net.IPAddress` if retrieval succeeds; otherwise, . #### HasConnection >```csharp >bool HasConnection() @@ -2144,6 +2226,26 @@ Provides utility methods for checking network connectivity and retrieving networ >Summary: Determines whether there is network connectivity by pinging Google's DNS server (8.8.8.8). > >Returns: if a ping response is received; otherwise, . +#### HasConnectionAsync +>```csharp +>Task HasConnectionAsync(CancellationToken cancellationToken = null) +>``` +>Summary: Asynchronously determines whether there is network connectivity by pinging Google's DNS server (8.8.8.8). +> +>Parameters:
+>     `cancellationToken`  -  A token to cancel the asynchronous operation.
+> +>Returns: if a ping response is received; otherwise, . +#### HasConnectionAsync +>```csharp +>Task HasConnectionAsync(IPAddress ipAddress, CancellationToken cancellationToken = null) +>``` +>Summary: Asynchronously determines whether there is network connectivity by pinging Google's DNS server (8.8.8.8). +> +>Parameters:
+>     `cancellationToken`  -  A token to cancel the asynchronous operation.
+> +>Returns: if a ping response is received; otherwise, . #### HasHttpConnection >```csharp >bool HasHttpConnection() @@ -2158,6 +2260,26 @@ Provides utility methods for checking network connectivity and retrieving networ >Summary: Determines whether there is HTTP connectivity by making a request to Google's website. > >Returns: if the HTTP request succeeds; otherwise, . +#### HasHttpConnectionAsync +>```csharp +>Task HasHttpConnectionAsync(CancellationToken cancellationToken = null) +>``` +>Summary: Asynchronously determines whether there is HTTP connectivity by making a request to Google's website. +> +>Parameters:
+>     `cancellationToken`  -  A token to cancel the asynchronous operation.
+> +>Returns: if the HTTP request succeeds; otherwise, . +#### HasHttpConnectionAsync +>```csharp +>Task HasHttpConnectionAsync(Uri uri, CancellationToken cancellationToken = null) +>``` +>Summary: Asynchronously determines whether there is HTTP connectivity by making a request to Google's website. +> +>Parameters:
+>     `cancellationToken`  -  A token to cancel the asynchronous operation.
+> +>Returns: if the HTTP request succeeds; otherwise, . #### HasTcpConnection >```csharp >bool HasTcpConnection(IPAddress ipAddress, int port) @@ -2169,6 +2291,18 @@ Provides utility methods for checking network connectivity and retrieving networ >     `port`  -  The port number to connect to.
> >Returns: if the TCP connection succeeds; otherwise, . +#### HasTcpConnectionAsync +>```csharp +>Task HasTcpConnectionAsync(IPAddress ipAddress, int port, CancellationToken cancellationToken = null) +>``` +>Summary: Asynchronously determines whether a TCP connection can be established to the specified IP address and port. A connection timeout of 5 seconds is applied; pass a pre-cancelled token to impose a shorter deadline. +> +>Parameters:
+>     `ipAddress`  -  The IP address to connect to.
+>     `port`  -  The port number to connect to.
+>     `cancellationToken`  -  A token to cancel the asynchronous operation.
+> +>Returns: if the TCP connection succeeds within the timeout; otherwise, .
@@ -2712,6 +2846,34 @@ Provides utility methods for executing external processes, managing process life >     `cancellationToken`  -  A token to cancel the operation.
> >Returns: A task that returns a tuple containing success status and output/error messages. +#### ExecuteWithSeparateOutput +>```csharp +>Task> ExecuteWithSeparateOutput(FileInfo fileInfo, string arguments, bool runAsAdministrator = False, ushort timeoutInSec = 30, CancellationToken cancellationToken = null) +>``` +>Summary: Executes a process with the specified file and arguments, returning standard output and standard error separately. +> +>Parameters:
+>     `fileInfo`  -  The executable file to run.
+>     `arguments`  -  The command-line arguments to pass to the executable.
+>     `runAsAdministrator`  -  If , attempts to run the process with elevated privileges.
+>     `timeoutInSec`  -  The maximum time in seconds to wait for the process to complete. Default is 30 seconds.
+>     `cancellationToken`  -  A token to cancel the operation.
+> +>Returns: A task that returns a tuple containing success status, standard output, and standard error streams separately. +#### ExecuteWithSeparateOutput +>```csharp +>Task> ExecuteWithSeparateOutput(DirectoryInfo workingDirectory, FileInfo fileInfo, string arguments, bool runAsAdministrator = False, ushort timeoutInSec = 30, CancellationToken cancellationToken = null) +>``` +>Summary: Executes a process with the specified file and arguments, returning standard output and standard error separately. +> +>Parameters:
+>     `fileInfo`  -  The executable file to run.
+>     `arguments`  -  The command-line arguments to pass to the executable.
+>     `runAsAdministrator`  -  If , attempts to run the process with elevated privileges.
+>     `timeoutInSec`  -  The maximum time in seconds to wait for the process to complete. Default is 30 seconds.
+>     `cancellationToken`  -  A token to cancel the operation.
+> +>Returns: A task that returns a tuple containing success status, standard output, and standard error streams separately. #### KillById >```csharp >ValueTuple KillById(int processId, int timeoutInSec = 30) diff --git a/docs/CodeDoc/Atc/Atc.Math.GeoSpatial.md b/docs/CodeDoc/Atc/Atc.Math.GeoSpatial.md index 3a4ea2e5..59c364f0 100644 --- a/docs/CodeDoc/Atc/Atc.Math.GeoSpatial.md +++ b/docs/CodeDoc/Atc/Atc.Math.GeoSpatial.md @@ -36,6 +36,28 @@ Provides utility methods for geospatial calculations including distance measurem ### Static Methods +#### Bearing +>```csharp +>double Bearing(CartesianCoordinate coordinate1, CartesianCoordinate coordinate2) +>``` +>Summary: Calculates the initial bearing (forward azimuth) from one geographic coordinate to another. The bearing is the angle measured clockwise from true north (0°) to the direction of travel. +> +>Parameters:
+>     `coordinate1`  -  The starting coordinate.
+>     `coordinate2`  -  The destination coordinate.
+> +>Returns: The initial bearing in degrees (0–360), where 0° is north, 90° east, 180° south, 270° west. +#### Bearing +>```csharp +>double Bearing(double longitude1, double latitude1, double longitude2, double latitude2) +>``` +>Summary: Calculates the initial bearing (forward azimuth) from one geographic coordinate to another. The bearing is the angle measured clockwise from true north (0°) to the direction of travel. +> +>Parameters:
+>     `coordinate1`  -  The starting coordinate.
+>     `coordinate2`  -  The destination coordinate.
+> +>Returns: The initial bearing in degrees (0–360), where 0° is north, 90° east, 180° south, 270° west. #### Distance >```csharp >double Distance(CartesianCoordinate coordinate1, CartesianCoordinate coordinate2, DistanceMeasurementType measurement) @@ -50,7 +72,7 @@ Provides utility methods for geospatial calculations including distance measurem >Returns: The distance between the two coordinates in the specified measurement unit. #### Distance >```csharp ->double Distance(double longitude1, double latitude1, double longitude2, double latitude2, DistanceMeasurementType measurement = Kilometers) +>double Distance(double longitude1, double latitude1, double longitude2, double latitude2, DistanceMeasurementType measurement = Kilometers, double earthRadiusKm = 6371) >``` >Summary: Calculates the great-circle distance between two geographic coordinates. > @@ -131,16 +153,26 @@ UniversalTransverseMercatorConverter >     `coordinate`  -  The coordinate.
#### ToWgs84 >```csharp +>CartesianCoordinate ToWgs84(UniversalTransverseMercatorResult utmResult, int maxDecimalPrecision = 8) +>``` +>Summary: Converts a `Atc.Math.GeoSpatial.UniversalTransverseMercatorResult` back to a WGS84 geographic coordinate. This is a convenience overload that unpacks the fields from the result returned by `Atc.Math.GeoSpatial.UniversalTransverseMercatorConverter.ToUtm(Atc.Structs.CartesianCoordinate)` or `Atc.Math.GeoSpatial.UniversalTransverseMercatorConverter.ToUtm(System.Double,System.Double)`. +> +>Parameters:
+>     `utmResult`  -  The UTM result to convert.
+>     `maxDecimalPrecision`  -  The maximum number of decimal places in the returned latitude/longitude values.
+> +>Returns: A `Atc.Structs.CartesianCoordinate` containing the WGS84 latitude and longitude. +#### ToWgs84 +>```csharp >CartesianCoordinate ToWgs84(int utmZoneNumber, string utmZoneLetter, double utmEasting, double utmNorthing, int maxDecimalPrecision = 8) >``` ->Summary: To WGS84. +>Summary: Converts a `Atc.Math.GeoSpatial.UniversalTransverseMercatorResult` back to a WGS84 geographic coordinate. This is a convenience overload that unpacks the fields from the result returned by `Atc.Math.GeoSpatial.UniversalTransverseMercatorConverter.ToUtm(Atc.Structs.CartesianCoordinate)` or `Atc.Math.GeoSpatial.UniversalTransverseMercatorConverter.ToUtm(System.Double,System.Double)`. > >Parameters:
->     `utmZoneNumber`  -  The utm zone number.
->     `utmZoneLetter`  -  The utm zone letter.
->     `utmEasting`  -  The utm easting.
->     `utmNorthing`  -  The utm northing.
->     `maxDecimalPrecision`  -  The maximum decimal precision.
+>     `utmResult`  -  The UTM result to convert.
+>     `maxDecimalPrecision`  -  The maximum number of decimal places in the returned latitude/longitude values.
+> +>Returns: A `Atc.Structs.CartesianCoordinate` containing the WGS84 latitude and longitude.
diff --git a/docs/CodeDoc/Atc/Atc.Serialization.JsonConverters.md b/docs/CodeDoc/Atc/Atc.Serialization.JsonConverters.md index a64a1773..bd90233b 100644 --- a/docs/CodeDoc/Atc/Atc.Serialization.JsonConverters.md +++ b/docs/CodeDoc/Atc/Atc.Serialization.JsonConverters.md @@ -174,7 +174,7 @@ JSON converter that enables deserialization of interface types by using the runt ## NumberToStringJsonConverter JSON converter that converts numeric values to string representation and vice versa. ->Remarks: This converter handles conversion between JSON numbers and strings, allowing numeric values in JSON to be read as strings. During deserialization, JSON numbers are converted to their string representation using the current thread's culture. During serialization, any object is converted to its string representation. +>Remarks: This converter handles conversion between JSON numbers and strings, allowing numeric values in JSON to be read as strings. During deserialization, JSON numbers are converted to their string representation using the invariant culture so the result is stable across machines and locales. During serialization, any object is converted to its string representation. >```csharp >public class NumberToStringJsonConverter : JsonConverter @@ -304,7 +304,7 @@ JSON converter that serializes `System.Uri` objects to and from their absolute U ## VersionJsonConverter JSON converter that serializes `System.Version` objects to and from their string representation. ->Remarks: This converter supports reading `System.Version` from both string format (e.g., "1.2.3.4") and object format with internal fields (_Major, _Minor, _Build, _Revision). During writing, `System.Version` is always serialized as a string. If parsing fails, a default empty `System.Version` is returned. +>Remarks: This converter supports reading `System.Version` from both string format (e.g., "1.2.3.4") and object format with public properties (Major, Minor, Build, Revision) as produced by `System.Text.Json.JsonSerializer`. During writing, `System.Version` is always serialized as a string. If parsing fails, a default empty `System.Version` is returned. >```csharp >public class VersionJsonConverter : JsonConverter diff --git a/docs/CodeDoc/Atc/Atc.Serialization.md b/docs/CodeDoc/Atc/Atc.Serialization.md index 0344bb65..6ffa82ac 100644 --- a/docs/CodeDoc/Atc/Atc.Serialization.md +++ b/docs/CodeDoc/Atc/Atc.Serialization.md @@ -152,6 +152,65 @@ Configuration settings for creating `System.Text.Json.JsonSerializerOptions` ins
+## JsonSerializerHelper +Provides async stream-based serialization and deserialization helpers using `System.Text.Json.JsonSerializer`. +>Remarks: All overloads that omit `System.Text.Json.JsonSerializerOptions` use `Atc.Serialization.JsonSerializerOptionsFactory` default options. + +>```csharp +>public static class JsonSerializerHelper +>``` + +### Static Methods + +#### DeserializeFromStreamAsync +>```csharp +>Task DeserializeFromStreamAsync(Stream stream, CancellationToken cancellationToken = null) +>``` +>Summary: Asynchronously deserializes a value of type `T` from the specified UTF-8 JSON stream using the default serializer options. +> +>Parameters:
+>     `stream`  -  The UTF-8 encoded JSON stream to read from.
+>     `cancellationToken`  -  A token to cancel the asynchronous operation.
+> +>Returns: A `System.Threading.Tasks.Task`1` that represents the asynchronous operation, containing the deserialized value, or if the stream contains a JSON null literal. +#### DeserializeFromStreamAsync +>```csharp +>Task DeserializeFromStreamAsync(Stream stream, JsonSerializerOptions options, CancellationToken cancellationToken = null) +>``` +>Summary: Asynchronously deserializes a value of type `T` from the specified UTF-8 JSON stream using the default serializer options. +> +>Parameters:
+>     `stream`  -  The UTF-8 encoded JSON stream to read from.
+>     `cancellationToken`  -  A token to cancel the asynchronous operation.
+> +>Returns: A `System.Threading.Tasks.Task`1` that represents the asynchronous operation, containing the deserialized value, or if the stream contains a JSON null literal. +#### SerializeToStreamAsync +>```csharp +>Task SerializeToStreamAsync(T value, Stream stream, CancellationToken cancellationToken = null) +>``` +>Summary: Asynchronously serializes `value` as UTF-8 JSON into the specified stream using the default serializer options. +> +>Parameters:
+>     `value`  -  The value to serialize.
+>     `stream`  -  The stream to write JSON into.
+>     `cancellationToken`  -  A token to cancel the asynchronous operation.
+> +>Returns: A `System.Threading.Tasks.Task` that represents the asynchronous write operation. +#### SerializeToStreamAsync +>```csharp +>Task SerializeToStreamAsync(T value, Stream stream, JsonSerializerOptions options, CancellationToken cancellationToken = null) +>``` +>Summary: Asynchronously serializes `value` as UTF-8 JSON into the specified stream using the default serializer options. +> +>Parameters:
+>     `value`  -  The value to serialize.
+>     `stream`  -  The stream to write JSON into.
+>     `cancellationToken`  -  A token to cancel the asynchronous operation.
+> +>Returns: A `System.Threading.Tasks.Task` that represents the asynchronous write operation. + +
+ ## JsonSerializerOptionsFactory Factory class for creating preconfigured `System.Text.Json.JsonSerializerOptions` instances. >Remarks: This factory provides convenient methods to create `System.Text.Json.JsonSerializerOptions` with common settings and custom converters. It supports both parameter-based and settings-based configuration. @@ -160,6 +219,13 @@ Factory class for creating preconfigured `System.Text.Json.JsonSerializerOptions >public static class JsonSerializerOptionsFactory >``` +### Static Properties + +#### Default +>```csharp +>Default +>``` +>Summary: Gets a cached, shared `System.Text.Json.JsonSerializerOptions` instance using the default settings (camelCase, null-values ignored, case-insensitive names, indented output). The instance is created once and reused; after the first serialization call it becomes read-only. Use `Atc.Serialization.JsonSerializerOptionsFactory.Create(System.Boolean,System.Boolean,System.Boolean,System.Boolean)` when you need a mutable copy. ### Static Methods #### Create diff --git a/docs/CodeDoc/Atc/Atc.Units.DigitalInformation.md b/docs/CodeDoc/Atc/Atc.Units.DigitalInformation.md index 9f199bbe..3880a1e3 100644 --- a/docs/CodeDoc/Atc/Atc.Units.DigitalInformation.md +++ b/docs/CodeDoc/Atc/Atc.Units.DigitalInformation.md @@ -11,9 +11,35 @@ Represents a size in bytes. >```csharp ->public struct ByteSize : IEquatable +>public struct ByteSize : IEquatable, IComparable, IComparable >``` +### Static Methods + +#### Parse +>```csharp +>ByteSize Parse(string value) +>``` +>Summary: Parses a string of digits into a `Atc.Units.DigitalInformation.ByteSize`. +> +>Parameters:
+>     `value`  -  The string to parse. Must represent a valid value.
+> +>Returns: A `Atc.Units.DigitalInformation.ByteSize` with the parsed byte count. +#### TryParse +>```csharp +>bool TryParse(string value, out byte result) +>``` +>Summary: Tries to parse a string into a `Atc.Units.DigitalInformation.ByteSize`. +> +>Parameters:
+>     `value`  -  The string to parse, or .
+>     `result`  -   + When this method returns, contains the parsed + if parsing succeeded; otherwise, . +
+> +>Returns: if parsing succeeded; otherwise, . ### Properties #### Value @@ -23,6 +49,26 @@ Represents a size in bytes. >Summary: Gets the size in bytes. ### Methods +#### CompareTo +>```csharp +>int CompareTo(ByteSize other) +>``` +>Summary: Compares this instance to another `Atc.Units.DigitalInformation.ByteSize` value. +> +>Parameters:
+>     `other`  -  The other value to compare to.
+> +>Returns: A negative number if this instance is less than `other`; zero if they are equal; a positive number if this instance is greater. +#### CompareTo +>```csharp +>int CompareTo(object obj) +>``` +>Summary: Compares this instance to another `Atc.Units.DigitalInformation.ByteSize` value. +> +>Parameters:
+>     `other`  -  The other value to compare to.
+> +>Returns: A negative number if this instance is less than `other`; zero if they are equal; a positive number if this instance is greater. #### Equals >```csharp >bool Equals(ByteSize other) @@ -174,6 +220,7 @@ Defines the suffix format for displaying byte sizes. | 0 | None | None | No suffix is appended to the numeric value. | | 1 | Short | Short | Short suffix format (e.g., "B", "KB", "MB", "GB"). | | 2 | Full | Full | Full suffix format (e.g., "byte", "Kilobyte", "Megabyte", "Gigabyte"). | +| 3 | ShortBinary | Short Binary | Short IEC binary suffix format (e.g., "B", "KiB", "MiB", "GiB"). Uses IEC 80000-13 notation to distinguish 1024-based units from SI decimal units. | diff --git a/docs/CodeDoc/Atc/Atc.Units.InternationalSystemOfUnits.md b/docs/CodeDoc/Atc/Atc.Units.InternationalSystemOfUnits.md index 8a3c668c..038d6a54 100644 --- a/docs/CodeDoc/Atc/Atc.Units.InternationalSystemOfUnits.md +++ b/docs/CodeDoc/Atc/Atc.Units.InternationalSystemOfUnits.md @@ -33,7 +33,7 @@ Enumeration: BaseUnitType ## InternationalSystemOfUnitsHelper Provides utility methods for converting between International System of Units (SI) prefixes. ->Remarks: This helper class supports conversions between various SI unit prefixes such as kilo, mega, giga, milli, centi, etc. Note that not all prefix combinations are currently supported. +>Remarks: This helper class supports conversions between all standard SI unit prefixes (Yotta through Yocto) using a table-driven exponent approach. >```csharp >public static class InternationalSystemOfUnitsHelper @@ -50,10 +50,10 @@ Provides utility methods for converting between International System of Units (S >Parameters:
>     `prefixTypeFrom`  -  The source SI prefix type.
>     `prefixTypeTo`  -  The target SI prefix type.
->     `numberOfDecimals`  -  The number of decimal places to round to (0 for no rounding).
+>     `numberOfDecimals`  -  The number of decimal places to round to. Pass 0 for no rounding.
>     `value`  -  The value to convert.
> ->Returns: The converted value in the target prefix type. +>Returns: The converted value in the target prefix type, optionally rounded.
diff --git a/docs/CodeDoc/Atc/Atc.md b/docs/CodeDoc/Atc/Atc.md index 80f9e88c..a7a6b770 100644 --- a/docs/CodeDoc/Atc/Atc.md +++ b/docs/CodeDoc/Atc/Atc.md @@ -165,12 +165,12 @@ Represents compass directions including the four cardinal points and intermediat | 1024 | SouthWest | South West | SouthWest. | | 2048 | WestSouthWest | West South West | WestSouthWest. | | 4096 | West | West | West. | -| 4625 | Simple | Simple | Simple = North | East | South | West. | +| 4625 | Simple | Simple | Simple = North \| East \| South \| West. | | 8192 | WestNorthWest | West North West | WestNorthWest. | | 16384 | NorthWest | North West | NorthWest. | -| 22101 | Medium | Medium | Medium = North | NorthEast | East | SouthEast | South | SouthWest | West | NorthWest. | +| 22101 | Medium | Medium | Medium = North \| NorthEast \| East \| SouthEast \| South \| SouthWest \| West \| NorthWest. | | 32768 | NorthNorthWest | North North West | NorthNorthWest. | -| 65535 | Advanced | Advanced | Advanced = North | NorthNorthEast | NorthEast | EastNorthEast | East | EastSouthEast | SouthEast | SouthSouthEast | South | SouthSouthWest | SouthWest | WestSouthWest | West | WestNorthWest | NorthWest | NorthNorthWest. | +| 65535 | Advanced | Advanced | Advanced = North \| NorthNorthEast \| NorthEast \| EastNorthEast \| East \| EastSouthEast \| SouthEast \| SouthSouthEast \| South \| SouthSouthWest \| SouthWest \| WestSouthWest \| West \| WestNorthWest \| NorthWest \| NorthNorthWest. | @@ -536,6 +536,29 @@ Extension methods for enums. >List> list = Enum.ToKeyValuePairsWithStringKey(); >Assert.Equal(7, list.Count); >``` +#### ToReadOnlyDictionary +>```csharp +>IReadOnlyDictionary ToReadOnlyDictionary(bool includeDefault = True, bool byFlagIncludeBase = True, bool byFlagIncludeCombined = True) +>``` +>Summary: Builds a read-only map from each defined enum member to its underlying `System.Int32` value. Useful for severity-rank or threshold lookup tables that previously required a hand-rolled `Dictionary<TEnum, int>` literal. +> +>Parameters:
+>     `includeDefault`  -  If set to the 0-valued member is included.
+>     `byFlagIncludeBase`  -  For enums, include the single-bit base values.
+>     `byFlagIncludeCombined`  -  For enums, include the multi-bit combined values.
+> +>Returns: An `System.Collections.Generic.IReadOnlyDictionary`2` keyed by the typed enum value with its underlying `System.Int32` as the value. +> +>Code usage: +>```csharp +>IReadOnlyDictionary map = Enum.ToReadOnlyDictionary(); +>``` +> +>Code example: +>```csharp +>IReadOnlyDictionary map = Enum.ToReadOnlyDictionary(); +>Assert.Equal(0, map[DayOfWeek.Sunday]); +>``` #### TryGetEnumValue >```csharp >bool TryGetEnumValue(string value, bool ignoreCase, out T returnedValue) @@ -1193,7 +1216,7 @@ Provides custom string formatting based on specified case formatting options.
```csharp >StringCaseFormatter Default >``` ->Summary: Static `Atc.StringCaseFormatter` using `System.Globalization.CultureInfo.CurrentCulture`. +>Summary: Static `Atc.StringCaseFormatter` using `System.Globalization.CultureInfo.InvariantCulture`. ### Static Methods #### Format diff --git a/docs/CodeDoc/Atc/Index.md b/docs/CodeDoc/Atc/Index.md index e0faf4d8..15160ab4 100644 --- a/docs/CodeDoc/Atc/Index.md +++ b/docs/CodeDoc/Atc/Index.md @@ -145,6 +145,7 @@ - [DynamicJson](Atc.Serialization.md#dynamicjson) - [JsonSerializerFactorySettings](Atc.Serialization.md#jsonserializerfactorysettings) +- [JsonSerializerHelper](Atc.Serialization.md#jsonserializerhelper) - [JsonSerializerOptionsFactory](Atc.Serialization.md#jsonserializeroptionsfactory) ## [Atc.Serialization.JsonConverters](Atc.Serialization.JsonConverters.md) @@ -266,6 +267,8 @@ ## [System.Reflection](System.Reflection.md) - [AssemblyExtensions](System.Reflection.md#assemblyextensions) +- [ConstructorInfoExtensions](System.Reflection.md#constructorinfoextensions) +- [EventInfoExtensions](System.Reflection.md#eventinfoextensions) - [FieldInfoExtensions](System.Reflection.md#fieldinfoextensions) - [MemberInfoExtensions](System.Reflection.md#memberinfoextensions) - [MethodInfoExtensions](System.Reflection.md#methodinfoextensions) diff --git a/docs/CodeDoc/Atc/IndexExtended.md b/docs/CodeDoc/Atc/IndexExtended.md index a0198384..6ec4ace8 100644 --- a/docs/CodeDoc/Atc/IndexExtended.md +++ b/docs/CodeDoc/Atc/IndexExtended.md @@ -43,6 +43,7 @@ - ToDictionaryWithStringKey(DropDownFirstItemType dropDownFirstItemType = None, bool useDescriptionAttribute = True, bool includeDefault = True, SortDirectionType sortDirectionType = None, bool byFlagIncludeBase = True, bool byFlagIncludeCombined = True) - ToKeyValuePairs(DropDownFirstItemType dropDownFirstItemType = None, bool useDescriptionAttribute = True, bool includeDefault = True, SortDirectionType sortDirectionType = None, bool byFlagIncludeBase = True, bool byFlagIncludeCombined = True) - ToKeyValuePairsWithStringKey(DropDownFirstItemType dropDownFirstItemType = None, bool useDescriptionAttribute = True, bool includeDefault = True, SortDirectionType sortDirectionType = None, bool byFlagIncludeBase = True, bool byFlagIncludeCombined = True) + - ToReadOnlyDictionary(bool includeDefault = True, bool byFlagIncludeBase = True, bool byFlagIncludeCombined = True) - TryGetEnumValue(Enum value, out T returnedValue) - TryGetEnumValue(string value, bool ignoreCase, out T returnedValue) - TryParse(string value, bool ignoreCase, out T returnedValue) @@ -4393,6 +4394,7 @@ - GreaterThanOrEqualTo(SemanticVersion otherVersion, int significantParts = 4, int startingPart = 1) - IsNewerThan(SemanticVersion otherVersion, bool withinMinorReleaseOnly = False, bool looseMode = False) - ToString() + - ToString(string format, IFormatProvider formatProvider) - ToVersion() ## [Atc.Factories](Atc.Factories.md) @@ -4400,7 +4402,10 @@ - [AsyncEnumerableFactory](Atc.Factories.md#asyncenumerablefactory) - Static Methods - Empty() - - FromSingleItem(T item) + - FromEnumerable(IEnumerable<T> source, CancellationToken cancellationToken = null) + - FromItems(T[] items, CancellationToken cancellationToken = null) + - FromSingleItem(T item, CancellationToken cancellationToken = null) + - FromTask(Task<T> task, CancellationToken cancellationToken = null) ## [Atc.Helpers](Atc.Helpers.md) @@ -4503,20 +4508,24 @@ - TryValidateOutToValidationException(T data, out ValidationException validationException, bool validateAllProperties = True) - [DateTimeHelper](Atc.Helpers.md#datetimehelper) - Static Methods - - TryParseShortDateUsingCurrentUiCulture(string value, out DateTime result) + - TryParseShortDateUi(string value, out DateTime result) - TryParseShortDateUsingSpecificCulture(string value, CultureInfo cultureInfo, out DateTime result) - - TryParseShortTimeUsingCurrentUiCulture(string value, out DateTime result) - - TryParseShortTimeUsingCurrentUiCultureUtc(string value, out DateTime result) + - TryParseShortTimeUi(string value, out DateTime result) + - TryParseShortTimeUiUtc(string value, out DateTime result) - TryParseShortTimeUsingSpecificCulture(string value, CultureInfo cultureInfo, out DateTime result) - TryParseShortTimeUsingSpecificCultureUtc(string value, CultureInfo cultureInfo, out DateTime result) - - TryParseUsingCurrentUiCulture(string value, out DateTime result) + - TryParseUi(string value, out DateTime result) - TryParseUsingSpecificCulture(string value, CultureInfo cultureInfo, out DateTime result) - [DateTimeOffsetHelper](Atc.Helpers.md#datetimeoffsethelper) - Static Methods - - TryParseShortDateUsingCurrentUiCulture(string value, out DateTime result) - - TryParseShortTimeUsingCurrentUiCulture(string value, out DateTime result) - - TryParseShortTimeUsingCurrentUiCultureUtc(string value, out DateTime result) - - TryParseUsingCurrentUiCulture(string value, out DateTime result) + - TryParseShortDateUi(string value, out DateTime result) + - TryParseShortDateUsingSpecificCulture(string value, CultureInfo cultureInfo, out DateTime result) + - TryParseShortTimeUi(string value, out DateTime result) + - TryParseShortTimeUiUtc(string value, out DateTime result) + - TryParseShortTimeUsingSpecificCulture(string value, CultureInfo cultureInfo, out DateTime result) + - TryParseShortTimeUsingSpecificCultureUtc(string value, CultureInfo cultureInfo, out DateTime result) + - TryParseUi(string value, out DateTime result) + - TryParseUsingSpecificCulture(string value, CultureInfo cultureInfo, out DateTime result) - [DayOfWeekHelper](Atc.Helpers.md#dayofweekhelper) - Static Methods - GetDescription(DayOfWeek dayOfWeek, CultureInfo culture = null) @@ -4536,6 +4545,7 @@ - ConvertEnumToArray(Type enumType, DropDownFirstItemType dropDownFirstItemType = None, bool useDescriptionAttribute = False, bool includeDefault = True, SortDirectionType sortDirectionType = None, bool byFlagIncludeBase = True, bool byFlagIncludeCombined = True) - ConvertEnumToDictionary(Type enumType, DropDownFirstItemType dropDownFirstItemType = None, bool useDescriptionAttribute = False, bool includeDefault = True, SortDirectionType sortDirectionType = None, bool byFlagIncludeBase = True, bool byFlagIncludeCombined = True) - ConvertEnumToDictionaryWithStringKey(Type enumType, DropDownFirstItemType dropDownFirstItemType = None, bool useDescriptionAttribute = False, bool includeDefault = True, SortDirectionType sortDirectionType = None, bool byFlagIncludeBase = True, bool byFlagIncludeCombined = True) + - ConvertEnumToReadOnlyDictionary(bool includeDefault = True, bool byFlagIncludeBase = True, bool byFlagIncludeCombined = True) - GetDescription(Enum enumeration) - GetEnumValue(string value, bool ignoreCase = True) - GetIndividualValues(bool includeDefault = True) @@ -4648,11 +4658,17 @@ - [NetworkInformationHelper](Atc.Helpers.md#networkinformationhelper) - Static Methods - GetPublicIpAddress() + - GetPublicIpAddressAsync(CancellationToken cancellationToken = null) - HasConnection() - HasConnection(IPAddress ipAddress) + - HasConnectionAsync(CancellationToken cancellationToken = null) + - HasConnectionAsync(IPAddress ipAddress, CancellationToken cancellationToken = null) - HasHttpConnection() - HasHttpConnection(Uri uri) + - HasHttpConnectionAsync(CancellationToken cancellationToken = null) + - HasHttpConnectionAsync(Uri uri, CancellationToken cancellationToken = null) - HasTcpConnection(IPAddress ipAddress, int port) + - HasTcpConnectionAsync(IPAddress ipAddress, int port, CancellationToken cancellationToken = null) - [NumberHelper](Atc.Helpers.md#numberhelper) - Static Methods - IsDecimal(string value) @@ -4712,6 +4728,8 @@ - ExecuteAsync(FileInfo fileInfo, string arguments, bool runAsAdministrator = False, ushort timeoutInSec = 30, CancellationToken cancellationToken = null) - ExecuteAsync(ProcessStartInfo startInfo, ushort timeoutInSec = 30, CancellationToken cancellationToken = null) - ExecutePrompt(DirectoryInfo workingDirectory, FileInfo fileInfo, string arguments, string[] inputLines, bool runAsAdministrator = False, ushort timeoutInSec = 1, CancellationToken cancellationToken = null) + - ExecuteWithSeparateOutput(DirectoryInfo workingDirectory, FileInfo fileInfo, string arguments, bool runAsAdministrator = False, ushort timeoutInSec = 30, CancellationToken cancellationToken = null) + - ExecuteWithSeparateOutput(FileInfo fileInfo, string arguments, bool runAsAdministrator = False, ushort timeoutInSec = 30, CancellationToken cancellationToken = null) - KillById(int processId, int timeoutInSec = 30) - KillByName(string processName, bool allowMultiKill = True, int timeoutInSec = 30) - KillEntryCaller(int timeoutInSec = 30) @@ -4796,13 +4814,16 @@ - [DistanceMeasurementType](Atc.Math.GeoSpatial.md#distancemeasurementtype) - [GeoSpatialHelper](Atc.Math.GeoSpatial.md#geospatialhelper) - Static Methods + - Bearing(CartesianCoordinate coordinate1, CartesianCoordinate coordinate2) + - Bearing(double longitude1, double latitude1, double longitude2, double latitude2) - Distance(CartesianCoordinate coordinate1, CartesianCoordinate coordinate2, DistanceMeasurementType measurement) - - Distance(double longitude1, double latitude1, double longitude2, double latitude2, DistanceMeasurementType measurement = Kilometers) + - Distance(double longitude1, double latitude1, double longitude2, double latitude2, DistanceMeasurementType measurement = Kilometers, double earthRadiusKm = 6371) - [ReferenceEllipsoidType](Atc.Math.GeoSpatial.md#referenceellipsoidtype) - [UniversalTransverseMercatorConverter](Atc.Math.GeoSpatial.md#universaltransversemercatorconverter) - Methods - ToUtm(CartesianCoordinate coordinate) - ToUtm(double latitude, double longitude) + - ToWgs84(UniversalTransverseMercatorResult utmResult, int maxDecimalPrecision = 8) - ToWgs84(int utmZoneNumber, string utmZoneLetter, double utmEasting, double utmNorthing, int maxDecimalPrecision = 8) - [UniversalTransverseMercatorResult](Atc.Math.GeoSpatial.md#universaltransversemercatorresult) - Properties @@ -4880,7 +4901,15 @@ - UseConverterUnixDatetimeOffset - UseConverterVersion - WriteIndented +- [JsonSerializerHelper](Atc.Serialization.md#jsonserializerhelper) + - Static Methods + - DeserializeFromStreamAsync(Stream stream, CancellationToken cancellationToken = null) + - DeserializeFromStreamAsync(Stream stream, JsonSerializerOptions options, CancellationToken cancellationToken = null) + - SerializeToStreamAsync(T value, Stream stream, CancellationToken cancellationToken = null) + - SerializeToStreamAsync(T value, Stream stream, JsonSerializerOptions options, CancellationToken cancellationToken = null) - [JsonSerializerOptionsFactory](Atc.Serialization.md#jsonserializeroptionsfactory) + - Static Properties + - Default - Static Methods - Create(JsonSerializerFactorySettings settings) - Create(bool useCamelCase = True, bool ignoreNullValues = True, bool propertyNameCaseInsensitive = True, bool writeIndented = True) @@ -4966,9 +4995,14 @@ ## [Atc.Units.DigitalInformation](Atc.Units.DigitalInformation.md) - [ByteSize](Atc.Units.DigitalInformation.md#bytesize) + - Static Methods + - Parse(string value) + - TryParse(string value, out byte result) - Properties - Value - Methods + - CompareTo(ByteSize other) + - CompareTo(object obj) - Equals(ByteSize other) - Equals(object obj) - Format() @@ -5062,53 +5096,74 @@ - [CharExtensions](System.md#charextensions) - Static Methods - IsAscii(this char value) + - IsAsciiDigit(this char value) + - IsAsciiLetter(this char value) + - IsHexDigit(this char value) + - IsVowel(this char value) - [ConfigurationException](System.md#configurationexception) + - Static Methods + - ThrowIfInvalid(bool condition, string section, string key) + - ThrowIfMissing(string value, string section, string key) - [DateTimeExtensions](System.md#datetimeextensions) - Static Methods - DateTimeDiff(this DateTime startDate, DateTime endDate, DateTimeDiffCompareType howToCompare) + - EndOfDay(this DateTime dateTime) + - EndOfMonth(this DateTime dateTime) - GetPrettyTimeDiff(this DateTime startDate, DateTime endDate, int decimalPrecision = 3) - GetPrettyTimeDiff(this DateTime startDate, int decimalPrecision = 3) - GetWeekNumber(this DateTime date) + - GetWeekNumberUi(this DateTime date) - IsBetween(this DateTime date, DateTime startDate, DateTime endDate) + - IsWeekend(this DateTime dateTime) + - StartOfDay(this DateTime dateTime) + - StartOfMonth(this DateTime dateTime) - ToIso8601Date(this DateTime dateTime) - ToIso8601UtcDate(this DateTime dateTime) - ToLongDateString(this DateTime dateTime, DateTimeFormatInfo dateTimeFormatInfo) - - ToLongDateStringUsingCurrentUiCulture(this DateTime dateTime) + - ToLongDateStringUi(this DateTime dateTime) - ToLongDateStringUsingSpecificCulture(this DateTime dateTime, CultureInfo cultureInfo) - ToLongTimeString(this DateTime dateTime, DateTimeFormatInfo dateTimeFormatInfo) - - ToLongTimeStringUsingCurrentUiCulture(this DateTime dateTime) + - ToLongTimeStringUi(this DateTime dateTime) - ToLongTimeStringUsingSpecificCulture(this DateTime dateTime, CultureInfo cultureInfo) - ToShortDateString(this DateTime dateTime, DateTimeFormatInfo dateTimeFormatInfo) - - ToShortDateStringUsingCurrentUiCulture(this DateTime dateTime) + - ToShortDateStringUi(this DateTime dateTime) - ToShortDateStringUsingSpecificCulture(this DateTime dateTime, CultureInfo cultureInfo) - ToShortTimeString(this DateTime dateTime, DateTimeFormatInfo dateTimeFormatInfo) - - ToShortTimeStringUsingCurrentUiCulture(this DateTime dateTime) + - ToShortTimeStringUi(this DateTime dateTime) - ToShortTimeStringUsingSpecificCulture(this DateTime dateTime, CultureInfo cultureInfo) - [DateTimeOffsetExtensions](System.md#datetimeoffsetextensions) - Static Methods - DateTimeDiff(this DateTimeOffset startDate, DateTimeOffset endDate, DateTimeDiffCompareType howToCompare) + - EndOfDay(this DateTimeOffset dateTimeOffset) + - EndOfMonth(this DateTimeOffset dateTimeOffset) - GetPrettyTimeDiff(this DateTimeOffset startDate, DateTimeOffset endDate, int decimalPrecision = 3) - GetPrettyTimeDiff(this DateTimeOffset startDate, int decimalPrecision = 3) - GetWeekNumber(this DateTimeOffset date) + - GetWeekNumberUi(this DateTimeOffset date) - IsBetween(this DateTimeOffset dateTimeOffset, DateTimeOffset startDate, DateTimeOffset endDate) + - IsWeekend(this DateTimeOffset dateTimeOffset) - ResetToStartOfCurrentHour(this DateTimeOffset dateTimeOffset) - SetHourAndMinutes(this DateTimeOffset dateTimeOffset, int hour, int minutes) + - StartOfDay(this DateTimeOffset dateTimeOffset) + - StartOfMonth(this DateTimeOffset dateTimeOffset) - ToIso8601Date(this DateTimeOffset dateTimeOffset) - ToIso8601UtcDate(this DateTimeOffset dateTimeOffset) - ToLongDateString(this DateTimeOffset dateTimeOffset, DateTimeFormatInfo dateTimeFormatInfo) - - ToLongDateStringUsingCurrentUiCulture(this DateTimeOffset dateTimeOffset) + - ToLongDateStringUi(this DateTimeOffset dateTimeOffset) - ToLongTimeString(this DateTimeOffset dateTimeOffset, DateTimeFormatInfo dateTimeFormatInfo) - - ToLongTimeStringUsingCurrentUiCulture(this DateTimeOffset dateTimeOffset) + - ToLongTimeStringUi(this DateTimeOffset dateTimeOffset) - ToShortDateString(this DateTimeOffset dateTimeOffset, DateTimeFormatInfo dateTimeFormatInfo) - - ToShortDateStringUsingCurrentUiCulture(this DateTimeOffset dateTimeOffset) + - ToShortDateStringUi(this DateTimeOffset dateTimeOffset) + - ToShortDateStringUsingSpecificCulture(this DateTimeOffset dateTimeOffset, CultureInfo cultureInfo) - ToShortTimeString(this DateTimeOffset dateTimeOffset, DateTimeFormatInfo dateTimeFormatInfo) - - ToShortTimeStringUsingCurrentUiCulture(this DateTimeOffset dateTimeOffset) + - ToShortTimeStringUi(this DateTimeOffset dateTimeOffset) - ToUnixTime(this DateTimeOffset dateTimeOffset) - [DecimalExtensions](System.md#decimalextensions) - Static Methods - CurrencyRounding(this decimal value) - CurrencyRounding(this decimal value, int digits) - CurrencyRoundingAsInteger(this decimal value) + - CurrencyRoundingUi(this decimal value) - IsEqual(this decimal a, decimal b) - IsEqual(this decimal a, decimal b, int decimalPrecision) - IsEqual(this decimal? a, decimal? b) @@ -5127,6 +5182,7 @@ - CurrencyRounding(this double value) - CurrencyRounding(this double value, int digits) - CurrencyRoundingAsInteger(this double value) + - CurrencyRoundingUi(this double value) - GreaterThanOrClose(this double value1, double value2) - IsEqual(this double a, double b) - IsEqual(this double a, double b, int decimalPrecision) @@ -5179,6 +5235,7 @@ - MapTo(this Enum source, TTarget? defaultValue = null) - ToStringLowerCase(this Enum enumeration) - ToStringUpperCase(this Enum enumeration) + - TryMapTo(this Enum source, out TTarget result) - [ExceptionExtensions](System.md#exceptionextensions) - Static Methods - Flatten(this Exception exception, string message = , bool includeStackTrace = False) @@ -5192,9 +5249,12 @@ - [IntegerExtensions](System.md#integerextensions) - Static Methods - GetFirstDayOfWeekNumberByYear(this int year, int weekNumber) + - GetFirstDayOfWeekNumberByYearUi(this int year, int weekNumber) - GetLastDayOfWeekNumberByYear(this int year, int weekNumber) - - GetMonthNameByMonthNumber(this int month, bool pascalCased = False) + - GetLastDayOfWeekNumberByYearUi(this int year, int weekNumber) + - GetMonthNameByMonthNumberUi(this int month, bool pascalCased = False) - GetNumberOfWeeksByYear(this int year) + - GetNumberOfWeeksByYearUi(this int year) - IsBinarySequence(this int number) - IsEqual(this int? a, int? b) - IsEven(this int number) @@ -5205,6 +5265,7 @@ - Static Methods - FromUnixTime(this long valueInSeconds) - FromUnixTimeMs(this long valueInMs) + - IsBinarySequence(this long number) - [NullException](System.md#nullexception) - [ObjectExtensions](System.md#objectextensions) - Static Methods @@ -5306,10 +5367,16 @@ - IsFormatXml(this string value) - IsGuid(this string value) - IsGuid(this string value, out Guid output) + - IsHostName(this string value) + - IsIPAddress(this string value) + - IsIPv4Address(this string value) + - IsIPv6Address(this string value) - IsKey(this string value) - IsLengthEven(this string value) + - IsMacAddress(this string value) - IsNumericOnly(this string value) - IsPersonCprNumber(this string cprNumber) + - IsPort(this string value) - IsSentence(this string value) - IsStringFormatParametersBalanced(this string value, bool isNumeric = True) - IsTrue(this string value) @@ -5319,6 +5386,9 @@ - IsWord(this string value) - [StringNullOrEmptyException](System.md#stringnulloremptyexception) - [SwitchCaseDefaultException](System.md#switchcasedefaultexception) + - Static Methods + - Throw(Enum value) + - Throw(object value) - [TaskExtensions](System.md#taskextensions) - Static Methods - Forget(this Task task) @@ -5328,6 +5398,7 @@ - [TimeSpanExtensions](System.md#timespanextensions) - Static Methods - GetPrettyTime(this TimeSpan timeSpan, int decimalPrecision = 3) + - GetPrettyTimeUi(this TimeSpan timeSpan, int decimalPrecision = 3) - Max(this TimeSpan t1, TimeSpan t2) - Min(this TimeSpan t1, TimeSpan t2) - RemoveMilliseconds(this TimeSpan timeSpan) @@ -5495,8 +5566,11 @@ - [StreamExtensions](System.IO.md#streamextensions) - Static Methods - CopyToStream(this Stream stream, int bufferSize = 4096) + - CopyToStreamAsync(this Stream stream, int bufferSize = 4096, CancellationToken cancellationToken = null) - ToBytes(this Stream stream) + - ToBytesAsync(this Stream stream, CancellationToken cancellationToken = null) - ToStringData(this Stream stream) + - ToStringDataAsync(this Stream stream, CancellationToken cancellationToken = null) ## [System.Net](System.Net.md) @@ -5521,6 +5595,12 @@ - GetResourceManagers(this Assembly assembly) - GetTypesInheritingFromType(this Assembly assembly, Type type) - IsDebugBuild(this Assembly assembly) +- [ConstructorInfoExtensions](System.Reflection.md#constructorinfoextensions) + - Static Methods + - BeautifyName(this ConstructorInfo constructorInfo, bool useFullName = False, bool useHtmlFormat = False) +- [EventInfoExtensions](System.Reflection.md#eventinfoextensions) + - Static Methods + - BeautifyName(this EventInfo eventInfo, bool useFullName = False, bool useHtmlFormat = False, bool includeEventHandlerType = False) - [FieldInfoExtensions](System.Reflection.md#fieldinfoextensions) - Static Methods - BeautifyName(this FieldInfo fieldInfo, bool useFullName = False, bool useHtmlFormat = False, bool includeReturnType = False) diff --git a/docs/CodeDoc/Atc/System.IO.md b/docs/CodeDoc/Atc/System.IO.md index 4dd1788e..3336b81d 100644 --- a/docs/CodeDoc/Atc/System.IO.md +++ b/docs/CodeDoc/Atc/System.IO.md @@ -180,7 +180,7 @@ Extensions for the `System.IO.MemoryStream` class. > >Parameters:
>     `stream`  -  The memory stream to convert.
->     `encoding`  -  The encoding to use for the conversion. If , Unicode encoding is used.
+>     `encoding`  -  The encoding to use for the conversion. Defaults to when .
> >Returns: A string representation of the memory stream content. @@ -206,6 +206,18 @@ Extensions for the `System.IO.Stream` class. >     `bufferSize`  -  The size of the buffer used for copying. Defaults to 4096 bytes.
> >Returns: A new `System.IO.MemoryStream` containing the copied data with position set to 0. +#### CopyToStreamAsync +>```csharp +>Task CopyToStreamAsync(this Stream stream, int bufferSize = 4096, CancellationToken cancellationToken = null) +>``` +>Summary: Asynchronously copies the contents of the stream to a new `System.IO.MemoryStream`. +> +>Parameters:
+>     `stream`  -  The source stream to copy from. The stream position will be reset to 0 before copying if the stream supports seeking.
+>     `bufferSize`  -  The size of the buffer used for copying. Defaults to 4096 bytes.
+>     `cancellationToken`  -  A token to cancel the asynchronous operation.
+> +>Returns: A `System.Threading.Tasks.Task`1` that represents the asynchronous operation, containing a new `System.IO.MemoryStream` with position set to 0. #### ToBytes >```csharp >byte[] ToBytes(this Stream stream) @@ -216,6 +228,17 @@ Extensions for the `System.IO.Stream` class. >     `stream`  -  The stream to read from. The stream position will be reset to 0 before reading.
> >Returns: A byte array containing all data from the stream. +#### ToBytesAsync +>```csharp +>Task ToBytesAsync(this Stream stream, CancellationToken cancellationToken = null) +>``` +>Summary: Asynchronously reads all bytes from the stream and returns them as a byte array. +> +>Parameters:
+>     `stream`  -  The stream to read from. The stream position will be reset to 0 before reading if the stream supports seeking.
+>     `cancellationToken`  -  A token to cancel the asynchronous operation.
+> +>Returns: A `System.Threading.Tasks.Task`1` that represents the asynchronous operation, containing a byte array with all data from the stream. #### ToStringData >```csharp >string ToStringData(this Stream stream) @@ -226,4 +249,15 @@ Extensions for the `System.IO.Stream` class. >     `stream`  -  The stream to read from. The stream position will be reset to 0 before reading.
> >Returns: A string containing all text content from the stream. +#### ToStringDataAsync +>```csharp +>Task ToStringDataAsync(this Stream stream, CancellationToken cancellationToken = null) +>``` +>Summary: Asynchronously reads all content from the stream and converts it to a string using UTF-8 encoding. +> +>Parameters:
+>     `stream`  -  The stream to read from. The stream position will be reset to 0 before reading if the stream supports seeking.
+>     `cancellationToken`  -  A token to cancel the asynchronous operation.
+> +>Returns: A `System.Threading.Tasks.Task`1` that represents the asynchronous operation, containing a string with all text content from the stream.
Generated by MarkdownCodeDoc version 1.2
diff --git a/docs/CodeDoc/Atc/System.Reflection.md b/docs/CodeDoc/Atc/System.Reflection.md index 7d673ccd..b46595e2 100644 --- a/docs/CodeDoc/Atc/System.Reflection.md +++ b/docs/CodeDoc/Atc/System.Reflection.md @@ -41,7 +41,7 @@ Extensions for the `System.Reflection.Assembly` class. >```csharp >Version GetFileVersion(this Assembly assembly) >``` ->Summary: Gets the file version of the assembly. +>Summary: Gets the file version of the assembly. Falls back to `System.Reflection.AssemblyFileVersionAttribute` when the assembly location is unavailable (e.g., single-file published apps where `System.Reflection.Assembly.Location` is empty). > >Parameters:
>     `assembly`  -  The assembly to query.
@@ -81,6 +81,55 @@ Extensions for the `System.Reflection.Assembly` class.
+## ConstructorInfoExtensions +Extensions for the `System.Reflection.ConstructorInfo` class. + +>```csharp +>public static class ConstructorInfoExtensions +>``` + +### Static Methods + +#### BeautifyName +>```csharp +>string BeautifyName(this ConstructorInfo constructorInfo, bool useFullName = False, bool useHtmlFormat = False) +>``` +>Summary: Returns a human-readable representation of the constructor signature, optionally using full type names and HTML formatting for the parameter types. +> +>Parameters:
+>     `constructorInfo`  -  The constructor information.
+>     `useFullName`  -  If , parameter types are rendered with their fully-qualified names.
+>     `useHtmlFormat`  -  If , parameter type names are wrapped in HTML tags.
+> +>Returns: A string of the form `.ctor(TypeA paramA, TypeB paramB)`. + +
+ +## EventInfoExtensions +Extensions for the `System.Reflection.EventInfo` class. + +>```csharp +>public static class EventInfoExtensions +>``` + +### Static Methods + +#### BeautifyName +>```csharp +>string BeautifyName(this EventInfo eventInfo, bool useFullName = False, bool useHtmlFormat = False, bool includeEventHandlerType = False) +>``` +>Summary: Returns a human-readable representation of the event, optionally including the event handler type and using full type names or HTML formatting. +> +>Parameters:
+>     `eventInfo`  -  The event information.
+>     `useFullName`  -  If , the event-handler type is rendered with its fully-qualified name.
+>     `useHtmlFormat`  -  If , the event-handler type name is wrapped in HTML tags.
+>     `includeEventHandlerType`  -  If , the event-handler type is prepended to the name.
+> +>Returns: A string such as `MyEvent` or `EventHandler MyEvent` when `includeEventHandlerType` is . + +
+ ## FieldInfoExtensions Extensions for the `System.Reflection.FieldInfo` class. diff --git a/docs/CodeDoc/Atc/System.md b/docs/CodeDoc/Atc/System.md index c60f9a68..a90f9390 100644 --- a/docs/CodeDoc/Atc/System.md +++ b/docs/CodeDoc/Atc/System.md @@ -558,6 +558,46 @@ Extensions for the `System.Char` type. >     `value`  -  The character to check.
> >Returns: if the character value is less than or equal to 127 (ASCII range); otherwise, . +#### IsAsciiDigit +>```csharp +>bool IsAsciiDigit(this char value) +>``` +>Summary: Determines whether the specified character is an ASCII decimal digit (0–9). +> +>Parameters:
+>     `value`  -  The character to check.
+> +>Returns: if the character is a digit 0 through 9; otherwise, . +#### IsAsciiLetter +>```csharp +>bool IsAsciiLetter(this char value) +>``` +>Summary: Determines whether the specified character is an ASCII letter (A–Z or a–z). +> +>Parameters:
+>     `value`  -  The character to check.
+> +>Returns: if the character is an ASCII letter; otherwise, . +#### IsHexDigit +>```csharp +>bool IsHexDigit(this char value) +>``` +>Summary: Determines whether the specified character is a hexadecimal digit (0–9, A–F, a–f). +> +>Parameters:
+>     `value`  -  The character to check.
+> +>Returns: if the character is a valid hexadecimal digit; otherwise, . +#### IsVowel +>```csharp +>bool IsVowel(this char value) +>``` +>Summary: Determines whether the specified character is an ASCII vowel (A, E, I, O, U — case-insensitive). +> +>Parameters:
+>     `value`  -  The character to check.
+> +>Returns: if the character is one of A, E, I, O, U (upper or lower case); otherwise, .
@@ -568,6 +608,28 @@ The exception that is thrown when a configuration error occurs, such as missing >public class ConfigurationException : Exception, ISerializable >``` +### Static Methods + +#### ThrowIfInvalid +>```csharp +>void ThrowIfInvalid(bool condition, string section, string key) +>``` +>Summary: Throws a `System.ConfigurationException` if `condition` is . +> +>Parameters:
+>     `condition`  -  The condition that indicates the configuration value is invalid.
+>     `section`  -  The configuration section name.
+>     `key`  -  The configuration key name.
+#### ThrowIfMissing +>```csharp +>void ThrowIfMissing(string value, string section, string key) +>``` +>Summary: Throws a `System.ConfigurationException` if `value` is or empty. +> +>Parameters:
+>     `value`  -  The configuration value to validate.
+>     `section`  -  The configuration section name.
+>     `key`  -  The configuration key name.

@@ -592,6 +654,26 @@ Extensions for the `System.DateTime` class. >     `howToCompare`  -  The how to compare.
> >Returns: The number between start date and end date, depend on the DiffCompareType. +#### EndOfDay +>```csharp +>DateTime EndOfDay(this DateTime dateTime) +>``` +>Summary: Returns a new `System.DateTime` set to the very end of the same day (23:59:59.9999999). The `System.DateTime.Kind` of the result matches the input. +> +>Parameters:
+>     `dateTime`  -  The date value.
+> +>Returns: The last representable tick of `dateTime`'s date. +#### EndOfMonth +>```csharp +>DateTime EndOfMonth(this DateTime dateTime) +>``` +>Summary: Returns a new `System.DateTime` set to the last tick of the last day of the same month. +> +>Parameters:
+>     `dateTime`  -  The date value.
+> +>Returns: The last representable tick of the last day of the month containing `dateTime`. #### GetPrettyTimeDiff >```csharp >string GetPrettyTimeDiff(this DateTime startDate, int decimalPrecision = 3) @@ -620,6 +702,16 @@ Extensions for the `System.DateTime` class. >     `date`  -  The date.
> >Returns: The week number from the given date. +#### GetWeekNumberUi +>```csharp +>int GetWeekNumberUi(this DateTime date) +>``` +>Summary: Gets the week number from a given date using the current UI culture's calendar. Use this variant when rendering the week number for display in a user interface. +> +>Parameters:
+>     `date`  -  The date.
+> +>Returns: The week number from the given date. #### IsBetween >```csharp >bool IsBetween(this DateTime date, DateTime startDate, DateTime endDate) @@ -632,6 +724,36 @@ Extensions for the `System.DateTime` class. >     `endDate`  -  End date to check for.
> >Returns: boolean value indicating if the date is between or equal to one of the two values. +#### IsWeekend +>```csharp +>bool IsWeekend(this DateTime dateTime) +>``` +>Summary: Determines whether the specified date falls on a Saturday or Sunday. +> +>Parameters:
+>     `dateTime`  -  The date to test.
+> +>Returns: if the day of the week is `System.DayOfWeek.Saturday` or `System.DayOfWeek.Sunday`; otherwise, . +#### StartOfDay +>```csharp +>DateTime StartOfDay(this DateTime dateTime) +>``` +>Summary: Returns a new `System.DateTime` set to the very start of the same day (00:00:00.000). The `System.DateTime.Kind` of the result matches the input. +> +>Parameters:
+>     `dateTime`  -  The date value.
+> +>Returns: Midnight at the start of `dateTime`'s date. +#### StartOfMonth +>```csharp +>DateTime StartOfMonth(this DateTime dateTime) +>``` +>Summary: Returns a new `System.DateTime` set to the first day of the same month at midnight (00:00:00.000). +> +>Parameters:
+>     `dateTime`  -  The date value.
+> +>Returns: The first day of the month containing `dateTime`. #### ToIso8601Date >```csharp >string ToIso8601Date(this DateTime dateTime) @@ -664,11 +786,11 @@ Extensions for the `System.DateTime` class. >     `dateTimeFormatInfo`  -  The DateTimeFormatInfo specifying the format to use.
> >Returns: A string representation of the DateTime using the long date pattern of the provided DateTimeFormatInfo. -#### ToLongDateStringUsingCurrentUiCulture +#### ToLongDateStringUi >```csharp ->string ToLongDateStringUsingCurrentUiCulture(this DateTime dateTime) +>string ToLongDateStringUi(this DateTime dateTime) >``` ->Summary: Converts a DateTime to a string using the long date pattern of the current UI culture. +>Summary: Converts a DateTime to a string using the long date pattern of the current UI culture. Use this variant when rendering output for display in a user interface. > >Parameters:
>     `dateTime`  -  The DateTime to format.
@@ -696,11 +818,11 @@ Extensions for the `System.DateTime` class. >     `dateTimeFormatInfo`  -  The DateTimeFormatInfo specifying the format to use.
> >Returns: A string representation of the DateTime using the long time pattern of the provided DateTimeFormatInfo. -#### ToLongTimeStringUsingCurrentUiCulture +#### ToLongTimeStringUi >```csharp ->string ToLongTimeStringUsingCurrentUiCulture(this DateTime dateTime) +>string ToLongTimeStringUi(this DateTime dateTime) >``` ->Summary: Converts a DateTime to a string using the long time pattern of the current UI culture. +>Summary: Converts a DateTime to a string using the long time pattern of the current UI culture. Use this variant when rendering output for display in a user interface. > >Parameters:
>     `dateTime`  -  The DateTime to format.
@@ -728,11 +850,11 @@ Extensions for the `System.DateTime` class. >     `dateTimeFormatInfo`  -  The DateTimeFormatInfo specifying the format to use.
> >Returns: A string representation of the DateTime using the short date pattern of the provided DateTimeFormatInfo. -#### ToShortDateStringUsingCurrentUiCulture +#### ToShortDateStringUi >```csharp ->string ToShortDateStringUsingCurrentUiCulture(this DateTime dateTime) +>string ToShortDateStringUi(this DateTime dateTime) >``` ->Summary: Converts a DateTime to a string using the short date pattern of the current UI culture. +>Summary: Converts a DateTime to a string using the short date pattern of the current UI culture. Use this variant when rendering output for display in a user interface. > >Parameters:
>     `dateTime`  -  The DateTime to format.
@@ -760,11 +882,11 @@ Extensions for the `System.DateTime` class. >     `dateTimeFormatInfo`  -  The DateTimeFormatInfo specifying the format to use.
> >Returns: A string representation of the DateTime using the short time pattern of the provided DateTimeFormatInfo. -#### ToShortTimeStringUsingCurrentUiCulture +#### ToShortTimeStringUi >```csharp ->string ToShortTimeStringUsingCurrentUiCulture(this DateTime dateTime) +>string ToShortTimeStringUi(this DateTime dateTime) >``` ->Summary: Converts a DateTime to a string using the short time pattern of the current UI culture. +>Summary: Converts a DateTime to a string using the short time pattern of the current UI culture. Use this variant when rendering output for display in a user interface. > >Parameters:
>     `dateTime`  -  The DateTime to format.
@@ -805,6 +927,26 @@ Extensions for the `System.DateTimeOffset` class. >     `howToCompare`  -  The how to compare.
> >Returns: The number between start date and end date, depend on the DiffCompareType. +#### EndOfDay +>```csharp +>DateTimeOffset EndOfDay(this DateTimeOffset dateTimeOffset) +>``` +>Summary: Returns a new `System.DateTimeOffset` set to the very end of the same day (23:59:59.9999999), preserving the original `System.DateTimeOffset.Offset`. +> +>Parameters:
+>     `dateTimeOffset`  -  The date-time value.
+> +>Returns: The last representable tick of the date, with the same UTC offset. +#### EndOfMonth +>```csharp +>DateTimeOffset EndOfMonth(this DateTimeOffset dateTimeOffset) +>``` +>Summary: Returns a new `System.DateTimeOffset` set to the last tick of the last day of the same month, preserving the original `System.DateTimeOffset.Offset`. +> +>Parameters:
+>     `dateTimeOffset`  -  The date-time value.
+> +>Returns: The last representable tick of the month, with the same UTC offset. #### GetPrettyTimeDiff >```csharp >string GetPrettyTimeDiff(this DateTimeOffset startDate, int decimalPrecision = 3) @@ -833,6 +975,16 @@ Extensions for the `System.DateTimeOffset` class. >     `date`  -  The date.
> >Returns: The week number from the given date. +#### GetWeekNumberUi +>```csharp +>int GetWeekNumberUi(this DateTimeOffset date) +>``` +>Summary: Gets the week number from a given date using the current UI culture's calendar. Use this variant when rendering the week number for display in a user interface. +> +>Parameters:
+>     `date`  -  The date.
+> +>Returns: The week number from the given date. #### IsBetween >```csharp >bool IsBetween(this DateTimeOffset dateTimeOffset, DateTimeOffset startDate, DateTimeOffset endDate) @@ -845,6 +997,16 @@ Extensions for the `System.DateTimeOffset` class. >     `endDate`  -  End date to check for.
> >Returns: boolean value indicating if the date is between or equal to one of the two values. +#### IsWeekend +>```csharp +>bool IsWeekend(this DateTimeOffset dateTimeOffset) +>``` +>Summary: Determines whether the date falls on a Saturday or Sunday. +> +>Parameters:
+>     `dateTimeOffset`  -  The date to test.
+> +>Returns: if the day of week is `System.DayOfWeek.Saturday` or `System.DayOfWeek.Sunday`; otherwise, . #### ResetToStartOfCurrentHour >```csharp >DateTimeOffset ResetToStartOfCurrentHour(this DateTimeOffset dateTimeOffset) @@ -867,6 +1029,26 @@ Extensions for the `System.DateTimeOffset` class. >     `minutes`  -  The minutes.
> >Returns: The dateTimeOffset with the specified hour and minutes. +#### StartOfDay +>```csharp +>DateTimeOffset StartOfDay(this DateTimeOffset dateTimeOffset) +>``` +>Summary: Returns a new `System.DateTimeOffset` set to the very start of the same day (00:00:00.000), preserving the original `System.DateTimeOffset.Offset`. +> +>Parameters:
+>     `dateTimeOffset`  -  The date-time value.
+> +>Returns: Midnight at the start of the date, with the same UTC offset. +#### StartOfMonth +>```csharp +>DateTimeOffset StartOfMonth(this DateTimeOffset dateTimeOffset) +>``` +>Summary: Returns a new `System.DateTimeOffset` set to the first day of the same month at midnight, preserving the original `System.DateTimeOffset.Offset`. +> +>Parameters:
+>     `dateTimeOffset`  -  The date-time value.
+> +>Returns: The first day of the month, with the same UTC offset. #### ToIso8601Date >```csharp >string ToIso8601Date(this DateTimeOffset dateTimeOffset) @@ -894,16 +1076,16 @@ Extensions for the `System.DateTimeOffset` class. >     `dateTimeFormatInfo`  -  The DateTimeFormatInfo specifying the format to use.
> >Returns: A string representation of the DateTime using the long date pattern of the provided DateTimeFormatInfo. -#### ToLongDateStringUsingCurrentUiCulture +#### ToLongDateStringUi >```csharp ->string ToLongDateStringUsingCurrentUiCulture(this DateTimeOffset dateTimeOffset) +>string ToLongDateStringUi(this DateTimeOffset dateTimeOffset) >``` ->Summary: Converts a DateTime to a string using the long date pattern of the current UI culture. +>Summary: Converts a DateTimeOffset to a string using the long date pattern of the current UI culture. Use this variant when rendering output for display in a user interface. > >Parameters:
>     `dateTimeOffset`  -  The DateTimeOffset to format.
> ->Returns: A string representation of the DateTime using the long date pattern of the current UI culture. +>Returns: A string representation of the DateTimeOffset using the long date pattern of the current UI culture. #### ToLongTimeString >```csharp >string ToLongTimeString(this DateTimeOffset dateTimeOffset, DateTimeFormatInfo dateTimeFormatInfo) @@ -915,16 +1097,16 @@ Extensions for the `System.DateTimeOffset` class. >     `dateTimeFormatInfo`  -  The DateTimeFormatInfo specifying the format to use.
> >Returns: A string representation of the DateTime using the long time pattern of the provided DateTimeFormatInfo. -#### ToLongTimeStringUsingCurrentUiCulture +#### ToLongTimeStringUi >```csharp ->string ToLongTimeStringUsingCurrentUiCulture(this DateTimeOffset dateTimeOffset) +>string ToLongTimeStringUi(this DateTimeOffset dateTimeOffset) >``` ->Summary: Converts a DateTime to a string using the long time pattern of the current UI culture. +>Summary: Converts a DateTimeOffset to a string using the long time pattern of the current UI culture. Use this variant when rendering output for display in a user interface. > >Parameters:
>     `dateTimeOffset`  -  The DateTimeOffset to format.
> ->Returns: A string representation of the DateTime using the long time pattern of the current UI culture. +>Returns: A string representation of the DateTimeOffset using the long time pattern of the current UI culture. #### ToShortDateString >```csharp >string ToShortDateString(this DateTimeOffset dateTimeOffset, DateTimeFormatInfo dateTimeFormatInfo) @@ -936,16 +1118,27 @@ Extensions for the `System.DateTimeOffset` class. >     `dateTimeFormatInfo`  -  The DateTimeFormatInfo specifying the format to use.
> >Returns: A string representation of the DateTime using the short date pattern of the provided DateTimeFormatInfo. -#### ToShortDateStringUsingCurrentUiCulture +#### ToShortDateStringUi >```csharp ->string ToShortDateStringUsingCurrentUiCulture(this DateTimeOffset dateTimeOffset) +>string ToShortDateStringUi(this DateTimeOffset dateTimeOffset) >``` ->Summary: Converts a DateTime to a string using the short date pattern of the current UI culture. +>Summary: Converts a DateTimeOffset to a string using the short date pattern of the current UI culture. Use this variant when rendering output for display in a user interface. > >Parameters:
>     `dateTimeOffset`  -  The DateTimeOffset to format.
> ->Returns: A string representation of the DateTime using the short date pattern of the current UI culture. +>Returns: A string representation of the DateTimeOffset using the short date pattern of the current UI culture. +#### ToShortDateStringUsingSpecificCulture +>```csharp +>string ToShortDateStringUsingSpecificCulture(this DateTimeOffset dateTimeOffset, CultureInfo cultureInfo) +>``` +>Summary: Converts a `System.DateTimeOffset` to a string using the short date pattern of the specified culture. +> +>Parameters:
+>     `dateTimeOffset`  -  The DateTimeOffset to format.
+>     `cultureInfo`  -  The culture whose short date pattern is used.
+> +>Returns: A string representation of the date using the short date pattern of `cultureInfo`. #### ToShortTimeString >```csharp >string ToShortTimeString(this DateTimeOffset dateTimeOffset, DateTimeFormatInfo dateTimeFormatInfo) @@ -957,16 +1150,16 @@ Extensions for the `System.DateTimeOffset` class. >     `dateTimeFormatInfo`  -  The DateTimeFormatInfo specifying the format to use.
> >Returns: A string representation of the DateTime using the short time pattern of the provided DateTimeFormatInfo. -#### ToShortTimeStringUsingCurrentUiCulture +#### ToShortTimeStringUi >```csharp ->string ToShortTimeStringUsingCurrentUiCulture(this DateTimeOffset dateTimeOffset) +>string ToShortTimeStringUi(this DateTimeOffset dateTimeOffset) >``` ->Summary: Converts a DateTime to a string using the short time pattern of the current UI culture. +>Summary: Converts a DateTimeOffset to a string using the short time pattern of the current UI culture. Use this variant when rendering output for display in a user interface. > >Parameters:
>     `dateTimeOffset`  -  The DateTimeOffset to format.
> ->Returns: A string representation of the DateTime using the short time pattern of the current UI culture. +>Returns: A string representation of the DateTimeOffset using the short time pattern of the current UI culture. #### ToUnixTime >```csharp >long ToUnixTime(this DateTimeOffset dateTimeOffset) @@ -1004,7 +1197,7 @@ Extensions for the `System.Decimal` class. >```csharp >decimal CurrencyRounding(this decimal value) >``` ->Summary: Rounds a decimal value using the currency decimal digits of the current UI culture. +>Summary: Rounds a decimal value using the currency decimal digits of the current culture. > >Parameters:
>     `value`  -  The decimal value to round.
@@ -1014,7 +1207,7 @@ Extensions for the `System.Decimal` class. >```csharp >decimal CurrencyRounding(this decimal value, int digits) >``` ->Summary: Rounds a decimal value using the currency decimal digits of the current UI culture. +>Summary: Rounds a decimal value using the currency decimal digits of the current culture. > >Parameters:
>     `value`  -  The decimal value to round.
@@ -1030,6 +1223,16 @@ Extensions for the `System.Decimal` class. >     `value`  -  The decimal value to round.
> >Returns: The rounded value as an integer. +#### CurrencyRoundingUi +>```csharp +>decimal CurrencyRoundingUi(this decimal value) +>``` +>Summary: Rounds a decimal value using the currency decimal digits of the current UI culture. Use this variant when rounding for display in a user interface. +> +>Parameters:
+>     `value`  -  The decimal value to round.
+> +>Returns: The rounded decimal value. #### IsEqual >```csharp >bool IsEqual(this decimal a, decimal b) @@ -1142,7 +1345,7 @@ Extensions for the `System.Double` class. >```csharp >double DoubleEpsilon >``` ->Summary: The double epsilon. +>Summary: The tolerance used by `System.DoubleExtensions.IsEqual(System.Double,System.Double)` and `System.DoubleExtensions.IsZero(System.Double)`. Values whose absolute difference is at or below this threshold are considered equal. ### Static Methods #### AreClose @@ -1160,17 +1363,17 @@ Extensions for the `System.Double` class. >```csharp >int CountDecimalPoints(this double value) >``` ->Summary: Counts the number of decimal places in the double value. +>Summary: Counts the number of decimal places in the double value, up to a maximum of 15 (the limit of meaningful precision for a 64-bit floating-point number). > >Parameters:
>     `value`  -  The double value to analyze.
> ->Returns: The number of decimal places in the value. +>Returns: The number of decimal places, capped at 15. #### CurrencyRounding >```csharp >double CurrencyRounding(this double value) >``` ->Summary: Rounds a double value using the currency decimal digits of the current UI culture. +>Summary: Rounds a double value using the currency decimal digits of the current culture. > >Parameters:
>     `value`  -  The double value to round.
@@ -1180,7 +1383,7 @@ Extensions for the `System.Double` class. >```csharp >double CurrencyRounding(this double value, int digits) >``` ->Summary: Rounds a double value using the currency decimal digits of the current UI culture. +>Summary: Rounds a double value using the currency decimal digits of the current culture. > >Parameters:
>     `value`  -  The double value to round.
@@ -1196,6 +1399,16 @@ Extensions for the `System.Double` class. >     `value`  -  The double value to round.
> >Returns: The rounded value as an integer. +#### CurrencyRoundingUi +>```csharp +>double CurrencyRoundingUi(this double value) +>``` +>Summary: Rounds a double value using the currency decimal digits of the current UI culture. Use this variant when rounding for display in a user interface. +> +>Parameters:
+>     `value`  -  The double value to round.
+> +>Returns: The rounded double value. #### GreaterThanOrClose >```csharp >bool GreaterThanOrClose(this double value1, double value2) @@ -1211,56 +1424,56 @@ Extensions for the `System.Double` class. >```csharp >bool IsEqual(this double a, double b) >``` ->Summary: Compare two values. Return if they are equals. +>Summary: Determines whether two double values are approximately equal within a tolerance of `System.DoubleExtensions.DoubleEpsilon` (1e-9). This handles common floating-point arithmetic rounding, for example `0.1 + 0.2 == 0.3`. Use `System.DoubleExtensions.IsEqual(System.Double,System.Double,System.Int32)` when an exact decimal-precision comparison is needed. > >Parameters:
>     `a`  -  The first value.
>     `b`  -  The second value.
> ->Returns: if the two values are equals, otherwise. +>Returns: if the absolute difference between `a` and `b` is at most `System.DoubleExtensions.DoubleEpsilon`; otherwise, . #### IsEqual >```csharp >bool IsEqual(this double? a, double? b) >``` ->Summary: Compare two values. Return if they are equals. +>Summary: Determines whether two double values are approximately equal within a tolerance of `System.DoubleExtensions.DoubleEpsilon` (1e-9). This handles common floating-point arithmetic rounding, for example `0.1 + 0.2 == 0.3`. Use `System.DoubleExtensions.IsEqual(System.Double,System.Double,System.Int32)` when an exact decimal-precision comparison is needed. > >Parameters:
>     `a`  -  The first value.
>     `b`  -  The second value.
> ->Returns: if the two values are equals, otherwise. +>Returns: if the absolute difference between `a` and `b` is at most `System.DoubleExtensions.DoubleEpsilon`; otherwise, . #### IsEqual >```csharp >bool IsEqual(this double a, double b, int decimalPrecision) >``` ->Summary: Compare two values. Return if they are equals. +>Summary: Determines whether two double values are approximately equal within a tolerance of `System.DoubleExtensions.DoubleEpsilon` (1e-9). This handles common floating-point arithmetic rounding, for example `0.1 + 0.2 == 0.3`. Use `System.DoubleExtensions.IsEqual(System.Double,System.Double,System.Int32)` when an exact decimal-precision comparison is needed. > >Parameters:
>     `a`  -  The first value.
>     `b`  -  The second value.
> ->Returns: if the two values are equals, otherwise. +>Returns: if the absolute difference between `a` and `b` is at most `System.DoubleExtensions.DoubleEpsilon`; otherwise, . #### IsEqual >```csharp >bool IsEqual(this double? a, double? b, int decimalPrecision) >``` ->Summary: Compare two values. Return if they are equals. +>Summary: Determines whether two double values are approximately equal within a tolerance of `System.DoubleExtensions.DoubleEpsilon` (1e-9). This handles common floating-point arithmetic rounding, for example `0.1 + 0.2 == 0.3`. Use `System.DoubleExtensions.IsEqual(System.Double,System.Double,System.Int32)` when an exact decimal-precision comparison is needed. > >Parameters:
>     `a`  -  The first value.
>     `b`  -  The second value.
> ->Returns: if the two values are equals, otherwise. +>Returns: if the absolute difference between `a` and `b` is at most `System.DoubleExtensions.DoubleEpsilon`; otherwise, . #### IsZero >```csharp >bool IsZero(this double value) >``` ->Summary: Determines whether the specified double value is approximately zero. +>Summary: Determines whether the specified double value is approximately zero within a tolerance of `System.DoubleExtensions.DoubleEpsilon` (1e-9). > >Parameters:
>     `value`  -  The value to check.
> ->Returns: if the absolute value is less than epsilon; otherwise, . +>Returns: if the absolute value is at most `System.DoubleExtensions.DoubleEpsilon`; otherwise, . #### RoundOff >```csharp >double RoundOff(this double value, int numberOfDecimals) @@ -1688,13 +1901,13 @@ Extension methods for enumerations. >```csharp >bool IsSet(this Enum enumeration, Enum matchTo) >``` ->Summary: Determines whether the specified enumeration match another enumeration. +>Summary: Determines whether all bits of `matchTo` are set in `enumeration`, equivalent to `enumeration.HasFlag(matchTo)`. Works for all underlying numeric types including -backed enumerations. > >Parameters:
->     `enumeration`  -  The enumeration.
->     `matchTo`  -  The enumeration to match.
+>     `enumeration`  -  The enumeration to check.
+>     `matchTo`  -  The flags that must all be present in .
> ->Returns: true on match; otherwise false. +>Returns: if every bit in `matchTo` is also set in `enumeration`; otherwise, . > >Code usage: >```csharp @@ -1709,13 +1922,13 @@ Extension methods for enumerations. >```csharp >bool IsSet(this T enumeration, T flags) >``` ->Summary: Determines whether the specified enumeration match another enumeration. +>Summary: Determines whether all bits of `matchTo` are set in `enumeration`, equivalent to `enumeration.HasFlag(matchTo)`. Works for all underlying numeric types including -backed enumerations. > >Parameters:
->     `enumeration`  -  The enumeration.
->     `matchTo`  -  The enumeration to match.
+>     `enumeration`  -  The enumeration to check.
+>     `matchTo`  -  The flags that must all be present in .
> ->Returns: true on match; otherwise false. +>Returns: if every bit in `matchTo` is also set in `enumeration`; otherwise, . > >Code usage: >```csharp @@ -1753,6 +1966,19 @@ Extension methods for enumerations. > >Parameters:
>     `enumeration`  -  The enum.
+#### TryMapTo +>```csharp +>bool TryMapTo(this Enum source, out TTarget result) +>``` +>Summary: Tries to map the current enum value to a target enum type by matching the name (case-insensitive). Unlike `System.EnumExtensions.MapTo``1(System.Enum,System.Nullable{``0})`, this method never throws — it returns when no match is found. +> +>Parameters:
+>     `source`  -  The source enum value.
+>     `result`  -   + When this method returns , the matched target value; otherwise, the default value of . +
+> +>Returns: if a matching named value was found in `TTarget`; otherwise, .
@@ -1865,6 +2091,17 @@ Extensions for the `System.Int32` class. >     `weekNumber`  -  The week number.
> >Returns: The date of the first day in the given year and week number. +#### GetFirstDayOfWeekNumberByYearUi +>```csharp +>DateTime GetFirstDayOfWeekNumberByYearUi(this int year, int weekNumber) +>``` +>Summary: Gets the date of the first day of a given week in a given year using the current UI culture's calendar. Use this variant when rendering dates for display in a user interface. +> +>Parameters:
+>     `year`  -  The four-digit year.
+>     `weekNumber`  -  The ISO week number (1–53).
+> +>Returns: The `System.DateTime` of the Monday that starts the requested week. #### GetLastDayOfWeekNumberByYear >```csharp >DateTime GetLastDayOfWeekNumberByYear(this int year, int weekNumber) @@ -1876,11 +2113,22 @@ Extensions for the `System.Int32` class. >     `weekNumber`  -  The week number.
> >Returns: The date of the last day in the given year and week number. -#### GetMonthNameByMonthNumber +#### GetLastDayOfWeekNumberByYearUi >```csharp ->string GetMonthNameByMonthNumber(this int month, bool pascalCased = False) +>DateTime GetLastDayOfWeekNumberByYearUi(this int year, int weekNumber) >``` ->Summary: Gets the month name by month number. +>Summary: Gets the date of the last day of a given week in a given year using the current UI culture's calendar. Use this variant when rendering dates for display in a user interface. +> +>Parameters:
+>     `year`  -  The four-digit year.
+>     `weekNumber`  -  The ISO week number (1–53).
+> +>Returns: The `System.DateTime` of the Sunday that ends the requested week. +#### GetMonthNameByMonthNumberUi +>```csharp +>string GetMonthNameByMonthNumberUi(this int month, bool pascalCased = False) +>``` +>Summary: Gets the month name by month number using the current UI culture. Use this variant when rendering output for display in a user interface. > >Parameters:
>     `month`  -  The month.
@@ -1897,6 +2145,16 @@ Extensions for the `System.Int32` class. >     `year`  -  The year.
> >Returns: The get number of weeks. +#### GetNumberOfWeeksByYearUi +>```csharp +>int GetNumberOfWeeksByYearUi(this int year) +>``` +>Summary: Returns the number of ISO weeks in the given year using the current UI culture's calendar. Use this variant when rendering the value for display in a user interface. +> +>Parameters:
+>     `year`  -  The four-digit year.
+> +>Returns: 52 or 53 depending on the year and calendar. #### IsBinarySequence >```csharp >bool IsBinarySequence(this int number) @@ -2023,6 +2281,16 @@ Extensions for the `System.Int64` class. >long unixTime = 0; // Equivalent to 1-1-1970 >DateTimeOffset dateTimeOffset = unixTime.FromUnixTimeMs(); >``` +#### IsBinarySequence +>```csharp +>bool IsBinarySequence(this long number) +>``` +>Summary: Determines whether the value is a binary sequence (a power of two), meaning exactly one bit is set. +> +>Parameters:
+>     `number`  -  The number to evaluate.
+> +>Returns: if `number` is a positive power of two; otherwise, .
@@ -2813,12 +3081,14 @@ Extensions for the string class. >```csharp >string XmlDecode(this string xml) >``` ->Summary: Decodes an XML string by unescaping special character entities (&amp, &#39;, &lt;, &gt;, &quot). +>Summary: Decodes an XML string by unescaping special character entities (&amp;, &#39;, &lt;, &gt;, &quot;). > >Parameters:
>     `xml`  -  The XML string to decode.
> >Returns: The decoded XML string with special character entities replaced. +> +>Remarks: The ampersand entity (`&amp;`) is decoded last so that already-decoded content is not re-interpreted, keeping `System.StringExtensions.XmlEncode(System.String)`/`System.StringExtensions.XmlDecode(System.String)` a faithful round-trip. #### XmlEncode >```csharp >string XmlEncode(this string xml) @@ -3026,6 +3296,48 @@ StringHasIsExtensions. >     `value`  -  The string to work on.
> >Returns: if the specified string is a System.Guid; otherwise, . +#### IsHostName +>```csharp +>bool IsHostName(this string value) +>``` +>Summary: Determines whether the specified value is a syntactically valid DNS host name (RFC 1123). +> +>Parameters:
+>     `value`  -  The string to validate.
+> +>Returns: if the value is a valid host name; otherwise, . +> +>Remarks: Accepts single-label names (e.g. `localhost`) and an optional trailing dot (e.g. `example.com.`). Each label is 1-63 ASCII alphanumeric/hyphen characters and may not start or end with a hyphen; the total length is limited to 253 characters. Underscores and raw Unicode (non-punycode IDN) are not allowed. This is a purely syntactic check and does not perform any DNS resolution. +#### IsIPAddress +>```csharp +>bool IsIPAddress(this string value) +>``` +>Summary: Determines whether the specified value is a valid IPv4 or IPv6 address. +> +>Parameters:
+>     `value`  -  The string to validate.
+> +>Returns: if the value is a valid IP address; otherwise, . +#### IsIPv4Address +>```csharp +>bool IsIPv4Address(this string value) +>``` +>Summary: Determines whether the specified value is a valid IPv4 address. +> +>Parameters:
+>     `value`  -  The string to validate.
+> +>Returns: if the value is a valid IPv4 address; otherwise, . +#### IsIPv6Address +>```csharp +>bool IsIPv6Address(this string value) +>``` +>Summary: Determines whether the specified value is a valid IPv6 address. +> +>Parameters:
+>     `value`  -  The string to validate.
+> +>Returns: if the value is a valid IPv6 address; otherwise, . #### IsKey >```csharp >bool IsKey(this string value) @@ -3046,6 +3358,18 @@ StringHasIsExtensions. >     `value`  -  The string to work on.
> >Returns: if the specified string length is even; otherwise, . +#### IsMacAddress +>```csharp +>bool IsMacAddress(this string value) +>``` +>Summary: Determines whether the specified value is a valid MAC address. +> +>Parameters:
+>     `value`  -  The string to validate.
+> +>Returns: if the value is a valid 48-bit MAC address; otherwise, . +> +>Remarks: Accepts the three most common notations: colon-separated (`AA:BB:CC:DD:EE:FF`), hyphen-separated (`AA-BB-CC-DD-EE-FF`), and dot-separated Cisco style (`AABB.CCDD.EEFF`), as well as the compact twelve-hex-digit form (`AABBCCDDEEFF`). The check is case-insensitive. #### IsNumericOnly >```csharp >bool IsNumericOnly(this string value) @@ -3066,6 +3390,16 @@ StringHasIsExtensions. >     `cprNumber`  -  The CPR number.
> >Returns: if the specified person CPR number is a valid number; otherwise, . +#### IsPort +>```csharp +>bool IsPort(this string value) +>``` +>Summary: Determines whether the specified value is a valid TCP/UDP port number. +> +>Parameters:
+>     `value`  -  The string to validate.
+> +>Returns: if the value parses to an integer in the range 1–65535; otherwise, . #### IsSentence >```csharp >bool IsSentence(this string value) @@ -3156,6 +3490,24 @@ The exception that is thrown when an unexpected value is encountered in a switch >public class SwitchCaseDefaultException : Exception, ISerializable >``` +### Static Methods + +#### Throw +>```csharp +>void Throw(Enum value) +>``` +>Summary: Throws a `System.SwitchCaseDefaultException` for the given enum value. +> +>Parameters:
+>     `value`  -  The unexpected enum value encountered in the switch default case.
+#### Throw +>```csharp +>void Throw(object value) +>``` +>Summary: Throws a `System.SwitchCaseDefaultException` for the given enum value. +> +>Parameters:
+>     `value`  -  The unexpected enum value encountered in the switch default case.

@@ -3229,6 +3581,17 @@ Extensions for the `System.TimeSpan` class. >     `decimalPrecision`  -  The number of decimal places to display (default is 3).
> >Returns: A formatted string representing the time in the most appropriate unit (days, hours, minutes, seconds, or milliseconds). +#### GetPrettyTimeUi +>```csharp +>string GetPrettyTimeUi(this TimeSpan timeSpan, int decimalPrecision = 3) +>``` +>Summary: Converts a TimeSpan to a human-readable string representation with appropriate time units, using the current UI culture for number formatting and unit label casing. Use this variant when rendering output for display in a user interface. +> +>Parameters:
+>     `timeSpan`  -  The TimeSpan to format.
+>     `decimalPrecision`  -  The number of decimal places to display (default is 3).
+> +>Returns: A formatted string representing the time in the most appropriate unit (days, hours, minutes, seconds, or milliseconds). #### Max >```csharp >TimeSpan Max(this TimeSpan t1, TimeSpan t2) diff --git a/sample/Demo.Atc.Console.Spectre.Cli/Demo.Atc.Console.Spectre.Cli.csproj b/sample/Demo.Atc.Console.Spectre.Cli/Demo.Atc.Console.Spectre.Cli.csproj index c1fac715..334a4dfd 100644 --- a/sample/Demo.Atc.Console.Spectre.Cli/Demo.Atc.Console.Spectre.Cli.csproj +++ b/sample/Demo.Atc.Console.Spectre.Cli/Demo.Atc.Console.Spectre.Cli.csproj @@ -8,10 +8,10 @@ - - - - + + + + diff --git a/sample/Demo.Atc.Dotnet.Cli/Demo.Atc.Dotnet.Cli.csproj b/sample/Demo.Atc.Dotnet.Cli/Demo.Atc.Dotnet.Cli.csproj index d79c00bc..062ff94a 100644 --- a/sample/Demo.Atc.Dotnet.Cli/Demo.Atc.Dotnet.Cli.csproj +++ b/sample/Demo.Atc.Dotnet.Cli/Demo.Atc.Dotnet.Cli.csproj @@ -8,10 +8,10 @@ - - - - + + + + diff --git a/src/Atc.CodeAnalysis.CSharp/Extensions/EnumDeclarationSyntaxExtensions.cs b/src/Atc.CodeAnalysis.CSharp/Extensions/EnumDeclarationSyntaxExtensions.cs index c55c6978..57aaa7bb 100644 --- a/src/Atc.CodeAnalysis.CSharp/Extensions/EnumDeclarationSyntaxExtensions.cs +++ b/src/Atc.CodeAnalysis.CSharp/Extensions/EnumDeclarationSyntaxExtensions.cs @@ -85,9 +85,16 @@ public static bool HasAttributeOfAttributeType( throw new ArgumentNullException(nameof(attributeType)); } - var attributeName = attributeType.Name.Replace("Attribute", string.Empty, StringComparison.Ordinal); + // Strip the conventional "Attribute" suffix so both [Flags] and [FlagsAttribute] match FlagsAttribute. + var shortName = attributeType.Name.Replace("Attribute", string.Empty, StringComparison.Ordinal); + var fullName = attributeType.Name; // "FlagsAttribute" return enumDeclaration .Select() - .Any(x => attributeName.Equals(x.Name.ToString(), StringComparison.Ordinal)); + .Any(x => + { + var attrName = x.Name.ToString(); + return attrName.Equals(shortName, StringComparison.Ordinal) || + attrName.Equals(fullName, StringComparison.Ordinal); + }); } } \ No newline at end of file diff --git a/src/Atc.CodeAnalysis.CSharp/Extensions/InterfaceDeclarationSyntaxExtensions.cs b/src/Atc.CodeAnalysis.CSharp/Extensions/InterfaceDeclarationSyntaxExtensions.cs index a8c1ea51..8ad033c6 100644 --- a/src/Atc.CodeAnalysis.CSharp/Extensions/InterfaceDeclarationSyntaxExtensions.cs +++ b/src/Atc.CodeAnalysis.CSharp/Extensions/InterfaceDeclarationSyntaxExtensions.cs @@ -6,6 +6,51 @@ namespace Microsoft.CodeAnalysis.CSharp.Syntax; /// public static class InterfaceDeclarationSyntaxExtensions { + /// + /// Adds a to the interface declaration. + /// + /// The interface declaration to modify. + /// The suppress message attribute to add. + /// A new with the attribute added. + /// Thrown when or is null. + /// Thrown when the justification in is invalid. + public static InterfaceDeclarationSyntax AddSuppressMessageAttribute( + this InterfaceDeclarationSyntax interfaceDeclaration, + SuppressMessageAttribute suppressMessage) + { + if (interfaceDeclaration is null) + { + throw new ArgumentNullException(nameof(interfaceDeclaration)); + } + + if (suppressMessage is null) + { + throw new ArgumentNullException(nameof(suppressMessage)); + } + + if (string.IsNullOrEmpty(suppressMessage.Justification)) + { + throw new ArgumentException("Justification is invalid.", nameof(suppressMessage)); + } + + var attributeArgumentList = SyntaxFactory.AttributeArgumentList( + SyntaxFactory.SeparatedList( + SyntaxFactory.NodeOrTokenList( + SyntaxFactory.AttributeArgument(SyntaxLiteralExpressionFactory.Create(suppressMessage.Category)), + SyntaxTokenFactory.Comma(), + SyntaxFactory.AttributeArgument(SyntaxLiteralExpressionFactory.Create(suppressMessage.CheckId)), + SyntaxTokenFactory.Comma(), + SyntaxFactory + .AttributeArgument(SyntaxLiteralExpressionFactory.Create(suppressMessage.Justification!)) + .WithNameEquals( + SyntaxNameEqualsFactory + .Create(nameof(SuppressMessageAttribute.Justification)) + .WithEqualsToken(SyntaxTokenFactory.Equals()))))); + + return interfaceDeclaration + .AddAttributeLists(SyntaxAttributeListFactory.Create(nameof(SuppressMessageAttribute), attributeArgumentList)); + } + /// /// Adds a to the interface declaration. /// diff --git a/src/Atc.CodeAnalysis.CSharp/Extensions/RecordDeclarationSyntaxExtensions.cs b/src/Atc.CodeAnalysis.CSharp/Extensions/RecordDeclarationSyntaxExtensions.cs new file mode 100644 index 00000000..caeec03e --- /dev/null +++ b/src/Atc.CodeAnalysis.CSharp/Extensions/RecordDeclarationSyntaxExtensions.cs @@ -0,0 +1,91 @@ +// ReSharper disable once CheckNamespace +namespace Microsoft.CodeAnalysis.CSharp.Syntax; + +/// +/// Extension methods for . +/// +public static class RecordDeclarationSyntaxExtensions +{ + /// + /// Adds a to the record declaration. + /// + /// The record declaration to modify. + /// The suppress message attribute to add. + /// A new with the attribute added. + /// Thrown when or is null. + /// Thrown when the justification in is invalid. + public static RecordDeclarationSyntax AddSuppressMessageAttribute( + this RecordDeclarationSyntax recordDeclaration, + SuppressMessageAttribute suppressMessage) + { + if (recordDeclaration is null) + { + throw new ArgumentNullException(nameof(recordDeclaration)); + } + + if (suppressMessage is null) + { + throw new ArgumentNullException(nameof(suppressMessage)); + } + + if (string.IsNullOrEmpty(suppressMessage.Justification)) + { + throw new ArgumentException("Justification is invalid.", nameof(suppressMessage)); + } + + var attributeArgumentList = SyntaxFactory.AttributeArgumentList( + SyntaxFactory.SeparatedList( + SyntaxFactory.NodeOrTokenList( + SyntaxFactory.AttributeArgument(SyntaxLiteralExpressionFactory.Create(suppressMessage.Category)), + SyntaxTokenFactory.Comma(), + SyntaxFactory.AttributeArgument(SyntaxLiteralExpressionFactory.Create(suppressMessage.CheckId)), + SyntaxTokenFactory.Comma(), + SyntaxFactory + .AttributeArgument(SyntaxLiteralExpressionFactory.Create(suppressMessage.Justification!)) + .WithNameEquals( + SyntaxNameEqualsFactory + .Create(nameof(SuppressMessageAttribute.Justification)) + .WithEqualsToken(SyntaxTokenFactory.Equals()))))); + + return recordDeclaration + .AddAttributeLists(SyntaxAttributeListFactory.Create(nameof(SuppressMessageAttribute), attributeArgumentList)); + } + + /// + /// Adds a to the record declaration. + /// + /// The record declaration to modify. + /// The name of the code generation tool. + /// The version of the code generation tool. + /// A new with the attribute added. + /// Thrown when , , or is null. + public static RecordDeclarationSyntax AddGeneratedCodeAttribute( + this RecordDeclarationSyntax recordDeclaration, + string toolName, + string version) + { + if (recordDeclaration is null) + { + throw new ArgumentNullException(nameof(recordDeclaration)); + } + + if (toolName is null) + { + throw new ArgumentNullException(nameof(toolName)); + } + + if (version is null) + { + throw new ArgumentNullException(nameof(version)); + } + + var attributeArgumentList = SyntaxFactory.AttributeArgumentList( + SyntaxFactory.SeparatedList(SyntaxFactory.NodeOrTokenList( + SyntaxFactory.AttributeArgument(SyntaxLiteralExpressionFactory.Create(toolName)), + SyntaxTokenFactory.Comma(), + SyntaxFactory.AttributeArgument(SyntaxLiteralExpressionFactory.Create(version))))); + + return recordDeclaration + .AddAttributeLists(SyntaxAttributeListFactory.Create(nameof(GeneratedCodeAttribute), attributeArgumentList)); + } +} \ No newline at end of file diff --git a/src/Atc.CodeAnalysis.CSharp/Extensions/StructDeclarationSyntaxExtensions.cs b/src/Atc.CodeAnalysis.CSharp/Extensions/StructDeclarationSyntaxExtensions.cs new file mode 100644 index 00000000..82c186b4 --- /dev/null +++ b/src/Atc.CodeAnalysis.CSharp/Extensions/StructDeclarationSyntaxExtensions.cs @@ -0,0 +1,91 @@ +// ReSharper disable once CheckNamespace +namespace Microsoft.CodeAnalysis.CSharp.Syntax; + +/// +/// Extension methods for . +/// +public static class StructDeclarationSyntaxExtensions +{ + /// + /// Adds a to the struct declaration. + /// + /// The struct declaration to modify. + /// The suppress message attribute to add. + /// A new with the attribute added. + /// Thrown when or is null. + /// Thrown when the justification in is invalid. + public static StructDeclarationSyntax AddSuppressMessageAttribute( + this StructDeclarationSyntax structDeclaration, + SuppressMessageAttribute suppressMessage) + { + if (structDeclaration is null) + { + throw new ArgumentNullException(nameof(structDeclaration)); + } + + if (suppressMessage is null) + { + throw new ArgumentNullException(nameof(suppressMessage)); + } + + if (string.IsNullOrEmpty(suppressMessage.Justification)) + { + throw new ArgumentException("Justification is invalid.", nameof(suppressMessage)); + } + + var attributeArgumentList = SyntaxFactory.AttributeArgumentList( + SyntaxFactory.SeparatedList( + SyntaxFactory.NodeOrTokenList( + SyntaxFactory.AttributeArgument(SyntaxLiteralExpressionFactory.Create(suppressMessage.Category)), + SyntaxTokenFactory.Comma(), + SyntaxFactory.AttributeArgument(SyntaxLiteralExpressionFactory.Create(suppressMessage.CheckId)), + SyntaxTokenFactory.Comma(), + SyntaxFactory + .AttributeArgument(SyntaxLiteralExpressionFactory.Create(suppressMessage.Justification!)) + .WithNameEquals( + SyntaxNameEqualsFactory + .Create(nameof(SuppressMessageAttribute.Justification)) + .WithEqualsToken(SyntaxTokenFactory.Equals()))))); + + return structDeclaration + .AddAttributeLists(SyntaxAttributeListFactory.Create(nameof(SuppressMessageAttribute), attributeArgumentList)); + } + + /// + /// Adds a to the struct declaration. + /// + /// The struct declaration to modify. + /// The name of the code generation tool. + /// The version of the code generation tool. + /// A new with the attribute added. + /// Thrown when , , or is null. + public static StructDeclarationSyntax AddGeneratedCodeAttribute( + this StructDeclarationSyntax structDeclaration, + string toolName, + string version) + { + if (structDeclaration is null) + { + throw new ArgumentNullException(nameof(structDeclaration)); + } + + if (toolName is null) + { + throw new ArgumentNullException(nameof(toolName)); + } + + if (version is null) + { + throw new ArgumentNullException(nameof(version)); + } + + var attributeArgumentList = SyntaxFactory.AttributeArgumentList( + SyntaxFactory.SeparatedList(SyntaxFactory.NodeOrTokenList( + SyntaxFactory.AttributeArgument(SyntaxLiteralExpressionFactory.Create(toolName)), + SyntaxTokenFactory.Comma(), + SyntaxFactory.AttributeArgument(SyntaxLiteralExpressionFactory.Create(version))))); + + return structDeclaration + .AddAttributeLists(SyntaxAttributeListFactory.Create(nameof(GeneratedCodeAttribute), attributeArgumentList)); + } +} \ No newline at end of file diff --git a/src/Atc.CodeAnalysis.CSharp/Extensions/SyntaxNodeExtensions.cs b/src/Atc.CodeAnalysis.CSharp/Extensions/SyntaxNodeExtensions.cs index 40fbcec2..d56a2531 100644 --- a/src/Atc.CodeAnalysis.CSharp/Extensions/SyntaxNodeExtensions.cs +++ b/src/Atc.CodeAnalysis.CSharp/Extensions/SyntaxNodeExtensions.cs @@ -60,7 +60,8 @@ public static string[] GetUsedUsingStatements(this SyntaxNode syntaxNode) return syntaxNode .Select() - .Select(x => x.Name!.ToFullString()) + .Where(x => x.Name is not null) + .Select(x => x.Name!.ToString()) .ToArray(); } @@ -80,8 +81,8 @@ public static string[] GetUsedUsingStatementsWithoutAlias( return syntaxNode .Select() - .Where(x => x.Alias is null) - .Select(x => x.Name!.ToFullString()) + .Where(x => x.Alias is null && x.Name is not null) + .Select(x => x.Name!.ToString()) .ToArray(); } } \ No newline at end of file diff --git a/src/Atc.CodeAnalysis.CSharp/Extensions/UsingDirectiveSyntaxExtensions.cs b/src/Atc.CodeAnalysis.CSharp/Extensions/UsingDirectiveSyntaxExtensions.cs index 8c53b046..ecf4f7fd 100644 --- a/src/Atc.CodeAnalysis.CSharp/Extensions/UsingDirectiveSyntaxExtensions.cs +++ b/src/Atc.CodeAnalysis.CSharp/Extensions/UsingDirectiveSyntaxExtensions.cs @@ -20,9 +20,9 @@ internal static SyntaxList Sort( .OrderBy(Compare) .ThenBy(x => x.Alias?.ToString(), StringComparer.Ordinal) .ThenByDescending(x => placeSystemNamespaceFirst && - x.Name! - .ToString() - .StartsWith(nameof(System), StringComparison.Ordinal)) + (x.Name?.ToString() is { } n && + (n == nameof(System) || + n.StartsWith(nameof(System) + ".", StringComparison.Ordinal)))) .ThenBy(x => x.Name!.ToString(), StringComparer.Ordinal)); private static int Compare(UsingDirectiveSyntax directive) diff --git a/src/Atc.CodeAnalysis.CSharp/SyntaxFactories/SyntaxInterpolatedFactory.cs b/src/Atc.CodeAnalysis.CSharp/SyntaxFactories/SyntaxInterpolatedFactory.cs index 17ffcdd6..91bf601a 100644 --- a/src/Atc.CodeAnalysis.CSharp/SyntaxFactories/SyntaxInterpolatedFactory.cs +++ b/src/Atc.CodeAnalysis.CSharp/SyntaxFactories/SyntaxInterpolatedFactory.cs @@ -11,14 +11,28 @@ public static class SyntaxInterpolatedFactory /// The text value to include in the interpolated string. /// An representing the text. public static InterpolatedStringContentSyntax StringText(string value) - => SyntaxFactory.InterpolatedStringText() + { + if (value is null) + { + throw new ArgumentNullException(nameof(value)); + } + + // In the raw source text of an interpolated string, '{' and '}' must be doubled to + // avoid being interpreted as interpolation holes. The valueText (semantic value) keeps + // the original characters because that is what the runtime sees at execution time. + var rawText = value + .Replace("{", "{{", StringComparison.Ordinal) + .Replace("}", "}}", StringComparison.Ordinal); + + return SyntaxFactory.InterpolatedStringText() .WithTextToken( SyntaxFactory.Token( SyntaxFactory.TriviaList(), SyntaxKind.InterpolatedStringTextToken, - value, + rawText, value, SyntaxFactory.TriviaList())); + } /// /// Creates interpolated string text for a colon and space (": "). diff --git a/src/Atc.CodeAnalysis.CSharp/SyntaxFactories/SyntaxLiteralExpressionFactory.cs b/src/Atc.CodeAnalysis.CSharp/SyntaxFactories/SyntaxLiteralExpressionFactory.cs index ca1f3a93..f893e276 100644 --- a/src/Atc.CodeAnalysis.CSharp/SyntaxFactories/SyntaxLiteralExpressionFactory.cs +++ b/src/Atc.CodeAnalysis.CSharp/SyntaxFactories/SyntaxLiteralExpressionFactory.cs @@ -30,8 +30,11 @@ public static LiteralExpressionSyntax Create( return SyntaxFactory.LiteralExpression(syntaxKind, SyntaxFactory.Literal(parsedInt)); } - value = value.Replace(',', '.'); - if (double.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out var parsedDouble)) + // Support European decimal notation (comma as decimal separator): "12,345" → 12.345. + // Thousands-separator values (e.g. "1,000") are not supported — they would silently + // become 1.0, so callers must normalise such values before passing them in. + var normalised = value.Replace(',', '.'); + if (double.TryParse(normalised, NumberStyles.Float, CultureInfo.InvariantCulture, out var parsedDouble)) { return SyntaxFactory.LiteralExpression(syntaxKind, SyntaxFactory.Literal(parsedDouble)); } @@ -50,4 +53,44 @@ public static LiteralExpressionSyntax Create( /// A node representing the integer. public static LiteralExpressionSyntax Create(int value) => SyntaxFactory.LiteralExpression(SyntaxKind.NumericLiteralExpression, SyntaxFactory.Literal(value)); + + /// + /// Creates a numeric literal expression from a long value. + /// + /// The long value for the literal expression. + /// A node representing the long. + public static LiteralExpressionSyntax Create(long value) + => SyntaxFactory.LiteralExpression(SyntaxKind.NumericLiteralExpression, SyntaxFactory.Literal(value)); + + /// + /// Creates a numeric literal expression from a double value. + /// + /// The double value for the literal expression. + /// A node representing the double. + public static LiteralExpressionSyntax Create(double value) + => SyntaxFactory.LiteralExpression(SyntaxKind.NumericLiteralExpression, SyntaxFactory.Literal(value)); + + /// + /// Creates a boolean literal expression. + /// + /// The boolean value. + /// A node representing or . + public static LiteralExpressionSyntax Create(bool value) + => SyntaxFactory.LiteralExpression( + value ? SyntaxKind.TrueLiteralExpression : SyntaxKind.FalseLiteralExpression); + + /// + /// Creates a character literal expression. + /// + /// The character value. + /// A node representing the character literal. + public static LiteralExpressionSyntax Create(char value) + => SyntaxFactory.LiteralExpression(SyntaxKind.CharacterLiteralExpression, SyntaxFactory.Literal(value)); + + /// + /// Creates a literal expression. + /// + /// A node representing . + public static LiteralExpressionSyntax CreateNull() + => SyntaxFactory.LiteralExpression(SyntaxKind.NullLiteralExpression); } \ No newline at end of file diff --git a/src/Atc.CodeAnalysis.CSharp/SyntaxFactories/SyntaxObjectCreationExpressionFactory.cs b/src/Atc.CodeAnalysis.CSharp/SyntaxFactories/SyntaxObjectCreationExpressionFactory.cs index 40c7e3d2..95f7b487 100644 --- a/src/Atc.CodeAnalysis.CSharp/SyntaxFactories/SyntaxObjectCreationExpressionFactory.cs +++ b/src/Atc.CodeAnalysis.CSharp/SyntaxFactories/SyntaxObjectCreationExpressionFactory.cs @@ -47,4 +47,181 @@ public static ObjectCreationExpressionSyntax Create( SyntaxFactory.IdentifierName(namespaceName), SyntaxFactory.IdentifierName(identifierName))); } + + /// + /// Creates an object creation expression for a type with an explicit argument list. + /// + /// The name of the type to instantiate. + /// The argument list to pass to the constructor. + /// An node with the given arguments. + /// Thrown when or is null. + public static ObjectCreationExpressionSyntax Create( + string identifierName, + ArgumentListSyntax argumentList) + { + if (identifierName is null) + { + throw new ArgumentNullException(nameof(identifierName)); + } + + if (argumentList is null) + { + throw new ArgumentNullException(nameof(argumentList)); + } + + return SyntaxFactory.ObjectCreationExpression(SyntaxFactory.IdentifierName(identifierName)) + .WithArgumentList(argumentList); + } + + /// + /// Creates an object creation expression for a qualified type name with an explicit argument list. + /// + /// The namespace containing the type. + /// The name of the type to instantiate. + /// The argument list to pass to the constructor. + /// An node with the given arguments. + /// Thrown when any parameter is null. + public static ObjectCreationExpressionSyntax Create( + string namespaceName, + string identifierName, + ArgumentListSyntax argumentList) + { + if (namespaceName is null) + { + throw new ArgumentNullException(nameof(namespaceName)); + } + + if (identifierName is null) + { + throw new ArgumentNullException(nameof(identifierName)); + } + + if (argumentList is null) + { + throw new ArgumentNullException(nameof(argumentList)); + } + + return SyntaxFactory.ObjectCreationExpression( + SyntaxFactory.QualifiedName( + SyntaxFactory.IdentifierName(namespaceName), + SyntaxFactory.IdentifierName(identifierName))) + .WithArgumentList(argumentList); + } + + /// + /// Creates a generic object creation expression (e.g. new List<T>()). + /// + /// The name of the generic type to instantiate. + /// The type argument list (e.g. <T>). + /// An node for the generic type. + /// Thrown when or is null. + public static ObjectCreationExpressionSyntax CreateGeneric( + string identifierName, + TypeArgumentListSyntax typeArgumentList) + { + if (identifierName is null) + { + throw new ArgumentNullException(nameof(identifierName)); + } + + if (typeArgumentList is null) + { + throw new ArgumentNullException(nameof(typeArgumentList)); + } + + return SyntaxFactory.ObjectCreationExpression( + SyntaxFactory.GenericName( + SyntaxFactory.Identifier(identifierName)) + .WithTypeArgumentList(typeArgumentList)); + } + + /// + /// Creates a generic object creation expression with a single named type argument (e.g. new List<MyType>()). + /// + /// The name of the generic type to instantiate. + /// The name of the single type argument. + /// An node for the generic type. + /// Thrown when any parameter is null. + public static ObjectCreationExpressionSyntax CreateGeneric( + string identifierName, + string typeArgumentName) + { + if (identifierName is null) + { + throw new ArgumentNullException(nameof(identifierName)); + } + + if (typeArgumentName is null) + { + throw new ArgumentNullException(nameof(typeArgumentName)); + } + + return CreateGeneric(identifierName, SyntaxTypeArgumentListFactory.CreateWithOneItem(typeArgumentName)); + } + + /// + /// Creates a generic object creation expression with an explicit argument list (e.g. new Dictionary<K,V>(capacity)). + /// + /// The name of the generic type to instantiate. + /// The type argument list. + /// The argument list to pass to the constructor. + /// An node for the generic type with arguments. + /// Thrown when any parameter is null. + public static ObjectCreationExpressionSyntax CreateGeneric( + string identifierName, + TypeArgumentListSyntax typeArgumentList, + ArgumentListSyntax argumentList) + { + if (identifierName is null) + { + throw new ArgumentNullException(nameof(identifierName)); + } + + if (typeArgumentList is null) + { + throw new ArgumentNullException(nameof(typeArgumentList)); + } + + if (argumentList is null) + { + throw new ArgumentNullException(nameof(argumentList)); + } + + return SyntaxFactory.ObjectCreationExpression( + SyntaxFactory.GenericName( + SyntaxFactory.Identifier(identifierName)) + .WithTypeArgumentList(typeArgumentList)) + .WithArgumentList(argumentList); + } + + /// + /// Creates a generic object creation expression with a single named type argument and an argument list. + /// + /// The name of the generic type to instantiate. + /// The name of the single type argument. + /// The argument list to pass to the constructor. + /// An node for the generic type with arguments. + /// Thrown when any parameter is null. + public static ObjectCreationExpressionSyntax CreateGeneric( + string identifierName, + string typeArgumentName, + ArgumentListSyntax argumentList) + { + if (identifierName is null) + { + throw new ArgumentNullException(nameof(identifierName)); + } + + if (typeArgumentName is null) + { + throw new ArgumentNullException(nameof(typeArgumentName)); + } + + if (argumentList is null) + { + throw new ArgumentNullException(nameof(argumentList)); + } + + return CreateGeneric(identifierName, SyntaxTypeArgumentListFactory.CreateWithOneItem(typeArgumentName), argumentList); + } } \ No newline at end of file diff --git a/src/Atc.CodeDocumentation/AssemblyCommentHelper.cs b/src/Atc.CodeDocumentation/AssemblyCommentHelper.cs index 7b4a85ed..48026743 100644 --- a/src/Atc.CodeDocumentation/AssemblyCommentHelper.cs +++ b/src/Atc.CodeDocumentation/AssemblyCommentHelper.cs @@ -18,7 +18,32 @@ internal static class AssemblyCommentHelper } var xmlFile = GetXmlFileForAssembly(type.Assembly); - return CollectExportedTypesWithComments(type.Assembly, xmlFile, namespaceMatch: null, excludeSourceTypes: null) + return CollectExportedTypesWithCommentsCore(type.Assembly, xmlFile, namespaceMatch: null, excludeSourceTypes: null) + .FirstOrDefault(x => string.Equals(x.FullName, type.FullName, StringComparison.Ordinal)); + } + + /// + /// Collects XML documentation comments for a specific type using an explicit XML documentation file. + /// + /// The type to collect documentation for. + /// The explicit path to the XML documentation file. + /// The type comments, or if the type was not found. + /// Thrown when or is null. + public static TypeComments? CollectExportedTypeWithComments( + Type type, + FileInfo xmlDocPath) + { + if (type is null) + { + throw new ArgumentNullException(nameof(type)); + } + + if (xmlDocPath is null) + { + throw new ArgumentNullException(nameof(xmlDocPath)); + } + + return CollectExportedTypesWithCommentsCore(type.Assembly, xmlDocPath, namespaceMatch: null, excludeSourceTypes: null) .FirstOrDefault(x => string.Equals(x.FullName, type.FullName, StringComparison.Ordinal)); } @@ -40,7 +65,36 @@ public static TypeComments[] CollectExportedTypesWithMissingComments( } var xmlFile = GetXmlFileForAssembly(assembly); - return CollectExportedTypesWithMissingComments(assembly, xmlFile, namespaceMatch, excludeSourceTypes); + return CollectExportedTypesWithMissingCommentsCore(assembly, xmlFile, namespaceMatch, excludeSourceTypes); + } + + /// + /// Collects all public types from an assembly that are missing XML documentation comments, + /// using an explicit XML documentation file path. + /// + /// The assembly to scan for types. + /// The explicit path to the XML documentation file. + /// Optional regex pattern to filter types by namespace. + /// Optional list of types to exclude from the results. + /// An array of type comments for types missing documentation. + /// Thrown when or is null. + public static TypeComments[] CollectExportedTypesWithMissingComments( + Assembly assembly, + FileInfo xmlDocPath, + string? namespaceMatch = null, + List? excludeSourceTypes = null) + { + if (assembly is null) + { + throw new ArgumentNullException(nameof(assembly)); + } + + if (xmlDocPath is null) + { + throw new ArgumentNullException(nameof(xmlDocPath)); + } + + return CollectExportedTypesWithMissingCommentsCore(assembly, xmlDocPath, namespaceMatch, excludeSourceTypes); } /// @@ -61,7 +115,36 @@ public static TypeComments[] CollectExportedTypesWithComments( } var xmlFile = GetXmlFileForAssembly(assembly); - return CollectExportedTypesWithComments(assembly, xmlFile, namespaceMatch, excludeSourceTypes); + return CollectExportedTypesWithCommentsCore(assembly, xmlFile, namespaceMatch, excludeSourceTypes); + } + + /// + /// Collects all public types from an assembly along with their XML documentation comments, + /// using an explicit XML documentation file path. + /// + /// The assembly to scan for types. + /// The explicit path to the XML documentation file. + /// Optional regex pattern to filter types by namespace. + /// Optional list of types to exclude from the results. + /// An array of type comments for all matching types. + /// Thrown when or is null. + public static TypeComments[] CollectExportedTypesWithComments( + Assembly assembly, + FileInfo xmlDocPath, + string? namespaceMatch = null, + List? excludeSourceTypes = null) + { + if (assembly is null) + { + throw new ArgumentNullException(nameof(assembly)); + } + + if (xmlDocPath is null) + { + throw new ArgumentNullException(nameof(xmlDocPath)); + } + + return CollectExportedTypesWithCommentsCore(assembly, xmlDocPath, namespaceMatch, excludeSourceTypes); } /// @@ -85,12 +168,22 @@ public static string GetTypesAsRenderText( private static FileInfo GetXmlFileForAssembly(Assembly assembly) { - var xmlFile = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, assembly.GetName().Name + ".xml"); + var assemblyDir = string.IsNullOrEmpty(assembly.Location) + ? AppDomain.CurrentDomain.BaseDirectory + : Path.GetDirectoryName(assembly.Location) ?? AppDomain.CurrentDomain.BaseDirectory; + + var xmlFile = Path.Combine(assemblyDir, assembly.GetName().Name + ".xml"); if (File.Exists(xmlFile)) { return new FileInfo(xmlFile); } + var fallback = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, assembly.GetName().Name + ".xml"); + if (File.Exists(fallback)) + { + return new FileInfo(fallback); + } + throw new IOException($"No xml document found for the assembly: {assembly.FullName}, expected file: {xmlFile}"); } @@ -100,7 +193,7 @@ private static bool IsRequiredNamespace( => regex is null || regex.IsMatch(type.Namespace ?? string.Empty); - private static TypeComments[] CollectExportedTypesWithMissingComments( + private static TypeComments[] CollectExportedTypesWithMissingCommentsCore( Assembly assembly, FileSystemInfo xmlPath, string? namespaceMatch, @@ -121,7 +214,7 @@ private static TypeComments[] CollectExportedTypesWithMissingComments( throw new IOException($"File don't exist: {xmlPath.FullName}"); } - var collectExportedTypesWithComments = CollectExportedTypesWithComments(assembly, xmlPath, namespaceMatch, excludeSourceTypes); + var collectExportedTypesWithComments = CollectExportedTypesWithCommentsCore(assembly, xmlPath, namespaceMatch, excludeSourceTypes); var collectExportedTypesWithMissingComments = collectExportedTypesWithComments .Where(x => !x.HasComments) @@ -131,7 +224,7 @@ private static TypeComments[] CollectExportedTypesWithMissingComments( return collectExportedTypesWithMissingComments; } - private static TypeComments[] CollectExportedTypesWithComments( + private static TypeComments[] CollectExportedTypesWithCommentsCore( Assembly assembly, FileSystemInfo xmlPath, string? namespaceMatch, diff --git a/src/Atc.CodeDocumentation/DocumentationHelper.cs b/src/Atc.CodeDocumentation/DocumentationHelper.cs index e24ea397..059a5abe 100644 --- a/src/Atc.CodeDocumentation/DocumentationHelper.cs +++ b/src/Atc.CodeDocumentation/DocumentationHelper.cs @@ -14,6 +14,17 @@ public static class DocumentationHelper Type type) => AssemblyCommentHelper.CollectExportedTypeWithComments(type); + /// + /// Collects XML documentation comments for a specific type using an explicit XML documentation file. + /// + /// The type to collect documentation for. + /// The explicit path to the XML documentation file. + /// The type comments, or if the type was not found. + public static TypeComments? CollectExportedTypeWithCommentsFromType( + Type type, + FileInfo xmlDocPath) + => AssemblyCommentHelper.CollectExportedTypeWithComments(type, xmlDocPath); + /// /// Collects all public types from an assembly that are missing XML documentation comments. /// @@ -28,6 +39,24 @@ public static TypeComments[] CollectExportedTypesWithMissingCommentsFromAssembly namespaceMatch: null, excludeTypes); + /// + /// Collects all public types from an assembly that are missing XML documentation comments, + /// using an explicit XML documentation file path. + /// + /// The assembly to scan for types. + /// The explicit path to the XML documentation file. + /// Optional list of types to exclude from the results. + /// An array of type comments for types missing documentation. + public static TypeComments[] CollectExportedTypesWithMissingCommentsFromAssembly( + Assembly assembly, + FileInfo xmlDocPath, + List? excludeTypes = null) + => AssemblyCommentHelper.CollectExportedTypesWithMissingComments( + assembly, + xmlDocPath, + namespaceMatch: null, + excludeTypes); + /// /// Collects all public types from an assembly that are missing XML documentation and generates a formatted text report. /// diff --git a/src/Atc.CodeDocumentation/Markdown/MarkdownBuilder.cs b/src/Atc.CodeDocumentation/Markdown/MarkdownBuilder.cs index 0c43139f..d46a3298 100644 --- a/src/Atc.CodeDocumentation/Markdown/MarkdownBuilder.cs +++ b/src/Atc.CodeDocumentation/Markdown/MarkdownBuilder.cs @@ -37,13 +37,12 @@ public void AppendLine( int indentSpaces, string text) { - var sbLocal = new StringBuilder(); for (var i = 0; i < indentSpaces; i++) { - sbLocal.Append(" "); + sb.Append(" "); } - sb.AppendLine(sbLocal + text); + sb.AppendLine(text); } /// @@ -192,7 +191,7 @@ public void Table( sb.Append("| "); foreach (var item in headers) { - sb.Append(item); + sb.Append(EscapeTableCell(item)); sb.Append(" | "); } @@ -212,7 +211,7 @@ public void Table( sb.Append("| "); foreach (var item2 in item) { - sb.Append(item2); + sb.Append(EscapeTableCell(item2)); sb.Append(" | "); } @@ -222,6 +221,26 @@ public void Table( sb.AppendLine(); } + /// + /// Escapes a value for safe use inside a markdown table cell by neutralizing the + /// column delimiter and collapsing line breaks, which would otherwise break the table layout. + /// + /// The raw cell value. + /// The escaped cell value. + private static string EscapeTableCell(string value) + { + if (string.IsNullOrEmpty(value)) + { + return value; + } + + return value + .Replace("\r\n", "
", StringComparison.Ordinal) + .Replace("\r", "
", StringComparison.Ordinal) + .Replace("\n", "
", StringComparison.Ordinal) + .Replace("|", "\\|", StringComparison.Ordinal); + } + /// /// Appends an unordered list item (no nesting). /// diff --git a/src/Atc.CodeDocumentation/Markdown/MarkdownCodeDocGenerator.cs b/src/Atc.CodeDocumentation/Markdown/MarkdownCodeDocGenerator.cs index 83435579..38e60cb2 100644 --- a/src/Atc.CodeDocumentation/Markdown/MarkdownCodeDocGenerator.cs +++ b/src/Atc.CodeDocumentation/Markdown/MarkdownCodeDocGenerator.cs @@ -55,6 +55,23 @@ public static void Run( { var assemblyName = assembly.GetName().Name!; var baseDirectory = AppDomain.CurrentDomain.BaseDirectory; + + // Walk up the directory tree until we find a segment whose name matches the assembly name. + // This avoids a fragile string-index search that breaks when the assembly name appears + // multiple times in the path. + var dir = new DirectoryInfo(baseDirectory); + while (dir is not null) + { + if (dir.Name.Equals(assemblyName, StringComparison.Ordinal)) + { + return new DirectoryInfo(Path.Combine(dir.FullName, "CodeDoc")); + } + + dir = dir.Parent; + } + + // Fall back to the original index-based search for cases where the assembly name + // does not appear as a standalone directory segment (e.g. single-file publish). var index = baseDirectory.IndexOf(assemblyName, StringComparison.Ordinal); if (index == -1) { @@ -73,7 +90,7 @@ private static void PrepareOutputPath(DirectoryInfo outputPath) } else { - foreach (var file in Directory.GetFiles(outputPath.FullName, "*.md", SearchOption.AllDirectories)) + foreach (var file in Directory.GetFiles(outputPath.FullName, "*.md", SearchOption.TopDirectoryOnly)) { File.Delete(file); } diff --git a/src/Atc.CodeDocumentation/Markdown/MarkdownHelper.cs b/src/Atc.CodeDocumentation/Markdown/MarkdownHelper.cs index a3926e82..e30fedd3 100644 --- a/src/Atc.CodeDocumentation/Markdown/MarkdownHelper.cs +++ b/src/Atc.CodeDocumentation/Markdown/MarkdownHelper.cs @@ -350,7 +350,7 @@ private static void AppendBodyForEnum( .GetNames(typeComments.Type) .Select(x => new { - Value = (int)Enum.Parse(typeComments.Type, x), + Value = Convert.ToInt64(Enum.Parse(typeComments.Type, x), GlobalizationConstants.EnglishCultureInfo), Name = x, Description = (Enum.Parse(typeComments.Type, x) as Enum)!.GetDescription(), }) @@ -410,7 +410,7 @@ private static void BuildTable( var data = seq .Select(item => { - var summary = docs.FirstOrDefault(x => string.Equals(x.MemberName, name(item), StringComparison.Ordinal) || x.MemberName!.StartsWith(name(item) + "`", StringComparison.Ordinal))?.Summary ?? string.Empty; + var summary = docs.FirstOrDefault(x => string.Equals(x.MemberName, name(item), StringComparison.Ordinal) || x.MemberName?.StartsWith(name(item) + "`", StringComparison.Ordinal) == true)?.Summary ?? string.Empty; return new[] { xType(item), @@ -432,7 +432,7 @@ private static void BuildTable( var data = seq .Select(item => { - var summary = docs.FirstOrDefault(x => string.Equals(x.MemberName, name(item), StringComparison.Ordinal) || x.MemberName!.StartsWith(name(item) + "`", StringComparison.Ordinal))?.Summary ?? string.Empty; + var summary = docs.FirstOrDefault(x => string.Equals(x.MemberName, name(item), StringComparison.Ordinal) || x.MemberName?.StartsWith(name(item) + "`", StringComparison.Ordinal) == true)?.Summary ?? string.Empty; return new[] { xType(item), @@ -485,7 +485,7 @@ private static void Build( // ReSharper disable once PossibleMultipleEnumeration var commentForMember = docs.FirstOrDefault(x => string.Equals(x.MemberName, name(item), StringComparison.Ordinal) || - x.MemberName!.StartsWith(name(item) + "`", StringComparison.Ordinal)); + x.MemberName?.StartsWith(name(item) + "`", StringComparison.Ordinal) == true); if (commentForMember is null || string.IsNullOrEmpty(commentForMember.Summary)) { diff --git a/src/Atc.CodeDocumentation/XmlDocument/XmlDocumentCommentParser.cs b/src/Atc.CodeDocumentation/XmlDocument/XmlDocumentCommentParser.cs index f4746f10..a6365319 100644 --- a/src/Atc.CodeDocumentation/XmlDocument/XmlDocumentCommentParser.cs +++ b/src/Atc.CodeDocumentation/XmlDocument/XmlDocumentCommentParser.cs @@ -2,6 +2,36 @@ namespace Atc.CodeDocumentation.XmlDocument; internal static class XmlDocumentCommentParser { + private static readonly Regex MemberAttributeRegex = new( + @"(.):(.+)\.([^.()]+)?(\(.+\)|$)", + RegexOptions.Compiled, + TimeSpan.FromSeconds(5)); + + private static readonly Regex ParaTagRegex = new( + @"|<\/para>", + RegexOptions.Compiled, + TimeSpan.FromSeconds(1)); + + private static readonly Regex SeeCrefRegex = new( + @"", + RegexOptions.Compiled, + TimeSpan.FromSeconds(1)); + + private static readonly Regex ParamRefRegex = new( + @"<(type)*paramref name=""([^\""]*)""\s*\/>", + RegexOptions.Compiled, + TimeSpan.FromSeconds(1)); + + private static readonly Regex CodeTagRegex = new( + @"]*>(.*?)<\/c>", + RegexOptions.Compiled, + TimeSpan.FromSeconds(1)); + + private static readonly Regex TypeNameSuffixRegex = new( + @"\.(?:.(?!\.))+$", + RegexOptions.Compiled, + TimeSpan.FromSeconds(1)); + [SuppressMessage("Design", "MA0051:Method is too long", Justification = "OK.")] internal static XmlDocumentComment?[] ParseXmlComment( XDocument xDocument, @@ -16,7 +46,7 @@ internal static class XmlDocumentCommentParser return null; } - var match = Regex.Match(attributeValue, @"(.):(.+)\.([^.()]+)?(\(.+\)|$)", RegexOptions.None, TimeSpan.FromSeconds(5)); + var match = MemberAttributeRegex.Match(attributeValue); if (!match.Groups[1].Success) { return null; @@ -132,7 +162,7 @@ private static string ResolveSeeElement( } return typeName.StartsWith(ns, StringComparison.Ordinal) - ? $"[{typeName}]({Regex.Replace(typeName, "\\.(?:.(?!\\.))+$", me => me.Groups[0].Value.Replace('.', '#').ToLower(GlobalizationConstants.EnglishCultureInfo), RegexOptions.None, TimeSpan.FromSeconds(1))})" + ? $"[{typeName}]({TypeNameSuffixRegex.Replace(typeName, me => me.Groups[0].Value.Replace('.', '#').ToLower(GlobalizationConstants.EnglishCultureInfo))})" : $"`{typeName}`"; } @@ -148,12 +178,14 @@ private static string ParseElementText( } innerXml = innerXml.Replace("", string.Empty, StringComparison.Ordinal); - innerXml = innerXml.Replace(Environment.NewLine, " ", StringComparison.Ordinal); + innerXml = innerXml.Replace("\r\n", " ", StringComparison.Ordinal); + innerXml = innerXml.Replace("\n", " ", StringComparison.Ordinal); + innerXml = innerXml.Replace("\r", " ", StringComparison.Ordinal); innerXml = Regex.Replace(innerXml, @$"<\/?{name}>", string.Empty, RegexOptions.None, TimeSpan.FromSeconds(1)).Trim(); - innerXml = Regex.Replace(innerXml, @"|<\/para>", Environment.NewLine, RegexOptions.None, TimeSpan.FromSeconds(1)); - innerXml = Regex.Replace(innerXml, @"", m => ResolveSeeElement(m, @namespace), RegexOptions.None, TimeSpan.FromSeconds(1)); - innerXml = Regex.Replace(innerXml, @"<(type)*paramref name=""([^\""]*)""\s*\/>", e => $"`{e.Groups[2].Value}`", RegexOptions.None, TimeSpan.FromSeconds(1)); - innerXml = Regex.Replace(innerXml, @"]*>(.*?)<\/c>", e => $"`{e.Groups[1].Value}`", RegexOptions.None, TimeSpan.FromSeconds(1)); + innerXml = ParaTagRegex.Replace(innerXml, Environment.NewLine); + innerXml = SeeCrefRegex.Replace(innerXml, m => ResolveSeeElement(m, @namespace)); + innerXml = ParamRefRegex.Replace(innerXml, e => $"`{e.Groups[2].Value}`"); + innerXml = CodeTagRegex.Replace(innerXml, e => $"`{e.Groups[1].Value}`"); innerXml = innerXml.TrimExtended(); var lines = innerXml diff --git a/src/Atc.Console.Spectre/Atc.Console.Spectre.csproj b/src/Atc.Console.Spectre/Atc.Console.Spectre.csproj index e50e6c61..4e88b6e0 100644 --- a/src/Atc.Console.Spectre/Atc.Console.Spectre.csproj +++ b/src/Atc.Console.Spectre/Atc.Console.Spectre.csproj @@ -8,8 +8,8 @@ - - + + diff --git a/src/Atc.Console.Spectre/Logging/ConsoleLogger.cs b/src/Atc.Console.Spectre/Logging/ConsoleLogger.cs index 986870ef..a856ed5b 100644 --- a/src/Atc.Console.Spectre/Logging/ConsoleLogger.cs +++ b/src/Atc.Console.Spectre/Logging/ConsoleLogger.cs @@ -12,21 +12,49 @@ namespace Atc.Console.Spectre.Logging; public class ConsoleLogger : ILogger { private readonly string categoryName; + private readonly string escapedCategoryName; private readonly ConsoleLoggerConfiguration config; private readonly IAnsiConsole console; /// - /// Initializes a new instance of the class. + /// Initializes a new instance of the class with a shared console. + /// Prefer this constructor when the logger is created by a so + /// that all loggers in the same provider share one instance. /// /// The category name for the logger. /// The console logger configuration. - /// Thrown when is null. + /// A shared instance to write to. + /// Thrown when or is null. public ConsoleLogger( string categoryName, - ConsoleLoggerConfiguration config) + ConsoleLoggerConfiguration config, + IAnsiConsole console) { this.categoryName = categoryName; + this.escapedCategoryName = Markup.Escape(categoryName ?? string.Empty); this.config = config ?? throw new ArgumentNullException(nameof(config)); + this.console = console ?? throw new ArgumentNullException(nameof(console)); + } + + /// + /// Initializes a new instance of the class, creating its own + /// from the configuration. Use this only when creating a logger + /// outside of a ; for provider-managed loggers prefer the + /// overload that accepts a shared . + /// + /// The category name for the logger. + /// The console logger configuration. + /// Thrown when is null. + public ConsoleLogger( + string categoryName, + ConsoleLoggerConfiguration config) + : this(categoryName, config, CreateConsole(config)) + { + } + + private static IAnsiConsole CreateConsole(ConsoleLoggerConfiguration config) + { + ArgumentNullException.ThrowIfNull(config); var settings = config.ConsoleSettings ?? new AnsiConsoleSettings { @@ -34,12 +62,14 @@ public ConsoleLogger( ColorSystem = ColorSystemSupport.Detect, }; - console = AnsiConsole.Create(settings); - config.ConsoleConfiguration?.Invoke(console); + var c = AnsiConsole.Create(settings); + config.ConsoleConfiguration?.Invoke(c); + return c; } /// - public IDisposable BeginScope(TState state) => default!; + public IDisposable BeginScope(TState state) + => new LogScope(state); /// public bool IsEnabled(LogLevel logLevel) @@ -51,7 +81,7 @@ public void Log( EventId eventId, TState state, Exception? exception, - Func formatter) + Func formatter) { ArgumentNullException.ThrowIfNull(formatter); @@ -60,11 +90,20 @@ public void Log( return; } - var stateStr = formatter(state, exception!); + var stateStr = formatter(state, exception); var message = config.AllowMarkup ? stateStr : Markup.Escape(stateStr); + if (config.IncludeScopes) + { + var scopeText = LogScope.BuildScopeText(); + if (scopeText.Length > 0) + { + message = $"[grey]{Markup.Escape(scopeText)}[/] {message}"; + } + } + var exceptionMessage = exception?.GetMessage( includeInnerMessage: config.IncludeInnerMessageForException, includeExceptionName: config.IncludeInnerMessageForException); @@ -297,7 +336,7 @@ private string GetTimeStampWithMarkup() => $"[white]{GetTimeStamp()}[/]"; private string GetCategoryNameWithMarkup() - => $"[grey]{categoryName}[/]"; + => $"[grey]{escapedCategoryName}[/]"; private string GetTimeStampAndCategoryNameWithMarkup() => $"{GetTimeStampWithMarkup()} {GetCategoryNameWithMarkup()}"; @@ -306,4 +345,63 @@ private string GetMessageWithMarkup( LogLevel logLevel, string message) => $"{GetLogLevelMarkupStartTag(logLevel)}{message}[/]"; + + /// + /// Tracks a single log scope entry in a per-async-context linked list. + /// Disposing removes this scope from the ambient context. + /// + private sealed class LogScope : IDisposable + { + private static readonly AsyncLocal Current = new(); + + private readonly LogScope? parent; + private bool disposed; + + internal LogScope(object? state) + { + State = state; + parent = Current.Value; + Current.Value = this; + } + + internal object? State { get; } + + /// + /// Builds a formatted string from all active scopes in the current async context, + /// from outermost to innermost, separated by " => ". + /// Returns when no scopes are active. + /// + internal static string BuildScopeText() + { + var scope = Current.Value; + if (scope is null) + { + return string.Empty; + } + + var parts = new List(); + while (scope is not null) + { + var text = scope.State?.ToString(); + if (!string.IsNullOrEmpty(text)) + { + parts.Add(text); + } + + scope = scope.parent; + } + + parts.Reverse(); + return string.Join(" => ", parts); + } + + public void Dispose() + { + if (!disposed) + { + Current.Value = parent; + disposed = true; + } + } + } } \ No newline at end of file diff --git a/src/Atc.Console.Spectre/Logging/ConsoleLoggerConfiguration.cs b/src/Atc.Console.Spectre/Logging/ConsoleLoggerConfiguration.cs index 16c747c7..7c8742e8 100644 --- a/src/Atc.Console.Spectre/Logging/ConsoleLoggerConfiguration.cs +++ b/src/Atc.Console.Spectre/Logging/ConsoleLoggerConfiguration.cs @@ -80,6 +80,13 @@ public ConsoleLoggerConfiguration() ///
public bool AllowMarkup { get; set; } + /// + /// Gets or sets a value indicating whether log scope values are included in the output. + /// When enabled, active scopes opened via + /// are rendered as a grey prefix before the log message. + /// + public bool IncludeScopes { get; set; } + /// /// Gets or sets a value indicating whether the Timestamp should be rendered as UTC. /// @@ -100,5 +107,5 @@ public ConsoleLoggerConfiguration() /// public override string ToString() - => $"{nameof(MinimumLogLevel)}: {MinimumLogLevel}, {nameof(RenderingMode)}: {RenderingMode}, {nameof(UseTimestamp)}: {UseTimestamp}, {nameof(UseShortNameForLogLevel)}: {UseShortNameForLogLevel}, {nameof(IncludeInnerMessageForException)}: {IncludeInnerMessageForException}, {nameof(IncludeExceptionNameForException)}: {IncludeExceptionNameForException}, {nameof(AllowMarkup)}: {AllowMarkup}, {nameof(UseTimestampUtc)}: {UseTimestampUtc}, {nameof(TimestampFormat)}: {TimestampFormat}"; + => $"{nameof(MinimumLogLevel)}: {MinimumLogLevel}, {nameof(RenderingMode)}: {RenderingMode}, {nameof(UseTimestamp)}: {UseTimestamp}, {nameof(UseShortNameForLogLevel)}: {UseShortNameForLogLevel}, {nameof(IncludeInnerMessageForException)}: {IncludeInnerMessageForException}, {nameof(IncludeExceptionNameForException)}: {IncludeExceptionNameForException}, {nameof(AllowMarkup)}: {AllowMarkup}, {nameof(IncludeScopes)}: {IncludeScopes}, {nameof(UseTimestampUtc)}: {UseTimestampUtc}, {nameof(TimestampFormat)}: {TimestampFormat}"; } \ No newline at end of file diff --git a/src/Atc.Console.Spectre/Logging/ConsoleLoggerProvider.cs b/src/Atc.Console.Spectre/Logging/ConsoleLoggerProvider.cs index 032fef98..4210f957 100644 --- a/src/Atc.Console.Spectre/Logging/ConsoleLoggerProvider.cs +++ b/src/Atc.Console.Spectre/Logging/ConsoleLoggerProvider.cs @@ -6,24 +6,40 @@ namespace Atc.Console.Spectre.Logging; public class ConsoleLoggerProvider : ILoggerProvider { private readonly ConsoleLoggerConfiguration config; + private readonly IAnsiConsole console; private readonly ConcurrentDictionary loggers = new(StringComparer.Ordinal); /// /// Initializes a new instance of the class. + /// A single is created here and shared across all loggers produced + /// by this provider, preventing torn interleaved output under concurrent log calls. /// /// The console logger configuration to use. + /// Thrown when is null. public ConsoleLoggerProvider(ConsoleLoggerConfiguration config) { + ArgumentNullException.ThrowIfNull(config); + this.config = config; + + var settings = config.ConsoleSettings ?? new AnsiConsoleSettings + { + Ansi = AnsiSupport.Detect, + ColorSystem = ColorSystemSupport.Detect, + }; + + console = AnsiConsole.Create(settings); + config.ConsoleConfiguration?.Invoke(console); } /// /// Creates a new instance for the specified category. + /// All loggers share the provider's instance. /// /// The category name for the logger. /// A instance. public ILogger CreateLogger(string categoryName) - => loggers.GetOrAdd(categoryName, name => new ConsoleLogger(name, config)); + => loggers.GetOrAdd(categoryName, name => new ConsoleLogger(name, config, console)); /// public void Dispose() diff --git a/src/Atc.DotNet/DotnetBuildHelper.cs b/src/Atc.DotNet/DotnetBuildHelper.cs index cfc8721a..3cb53ca9 100644 --- a/src/Atc.DotNet/DotnetBuildHelper.cs +++ b/src/Atc.DotNet/DotnetBuildHelper.cs @@ -7,6 +7,11 @@ namespace Atc.DotNet; public static class DotnetBuildHelper { private const int DefaultTimeoutInSec = 1200; + + // Sentinel prefix used internally when there are multiple candidate build files so the + // caller can detect the ambiguity without relying on a localised dotnet CLI message. + private const string MultipleFilesOutputPrefix = "Please specify which"; + private static readonly ConcurrentDictionary RegexCache = new(StringComparer.Ordinal); /// @@ -19,6 +24,7 @@ public static class DotnetBuildHelper /// Whether to build in Release mode. If false, builds in Debug mode. Default is true. /// Build timeout in seconds. Default is 1200 seconds (20 minutes). /// Optional prefix for log messages. + /// Additional arguments appended to the dotnet build command, such as -p:TreatWarningsAsErrors=false or -f net9.0. /// Token to cancel the build operation. /// A dictionary mapping error codes to their occurrence counts. /// Thrown when is null. @@ -26,6 +32,7 @@ public static class DotnetBuildHelper /// This is a convenience overload that uses ; for build /// progress visibility prefer the overload accepting an . /// + [SuppressMessage("Major Code Smell", "S107:Methods should not have too many parameters", Justification = "OK.")] public static Task> BuildAndCollectErrors( DirectoryInfo rootPath, int? runNumber = null, @@ -34,6 +41,7 @@ public static Task> BuildAndCollectErrors( bool useConfigurationReleaseMode = true, int timeoutInSec = DefaultTimeoutInSec, string logPrefix = "", + string additionalBuildArguments = "", CancellationToken cancellationToken = default) => BuildAndCollectErrors( NullLogger.Instance, @@ -44,6 +52,7 @@ public static Task> BuildAndCollectErrors( useConfigurationReleaseMode, timeoutInSec, logPrefix, + additionalBuildArguments, cancellationToken); /// @@ -57,6 +66,7 @@ public static Task> BuildAndCollectErrors( /// Whether to build in Release mode. If false, builds in Debug mode. Default is true. /// Build timeout in seconds. Default is 1200 seconds (20 minutes). /// Optional prefix for log messages. + /// Additional arguments appended to the dotnet build command, such as -p:TreatWarningsAsErrors=false or -f net9.0. /// Token to cancel the build operation. /// A dictionary mapping error codes to their occurrence counts. /// Thrown when or is null. @@ -70,19 +80,99 @@ public static Task> BuildAndCollectErrors( bool useConfigurationReleaseMode = true, int timeoutInSec = DefaultTimeoutInSec, string logPrefix = "", + string additionalBuildArguments = "", CancellationToken cancellationToken = default) { - if (logger is null) - { - throw new ArgumentNullException(nameof(logger)); - } + ArgumentNullException.ThrowIfNull(logger); + ArgumentNullException.ThrowIfNull(rootPath); - if (rootPath is null) - { - throw new ArgumentNullException(nameof(rootPath)); - } + return InvokeBuildAndCollect( + logger, + rootPath, + runNumber, + buildFile, + useNugetRestore, + useConfigurationReleaseMode, + timeoutInSec, + logPrefix, + additionalBuildArguments, + collectWarnings: false, + cancellationToken); + } - return InvokeBuildAndCollectErrors( + /// + /// Builds a .NET project or solution and collects compilation warnings grouped by warning code. + /// + /// The root directory containing the project or solution to build. + /// Optional run number for logging purposes. + /// Optional specific solution or project file to build. If not specified, discovers the build file automatically. + /// Whether to perform NuGet restore before building. Default is true. + /// Whether to build in Release mode. If false, builds in Debug mode. Default is true. + /// Build timeout in seconds. Default is 1200 seconds (20 minutes). + /// Optional prefix for log messages. + /// Additional arguments appended to the dotnet build command, such as -p:NoWarn=CS0168 or -f net9.0. + /// Token to cancel the build operation. + /// A dictionary mapping warning codes to their occurrence counts. + /// Thrown when is null. + /// + /// This is a convenience overload that uses ; for build + /// progress visibility prefer the overload accepting an . + /// + [SuppressMessage("Major Code Smell", "S107:Methods should not have too many parameters", Justification = "OK.")] + public static Task> BuildAndCollectWarnings( + DirectoryInfo rootPath, + int? runNumber = null, + FileInfo? buildFile = null, + bool useNugetRestore = true, + bool useConfigurationReleaseMode = true, + int timeoutInSec = DefaultTimeoutInSec, + string logPrefix = "", + string additionalBuildArguments = "", + CancellationToken cancellationToken = default) + => BuildAndCollectWarnings( + NullLogger.Instance, + rootPath, + runNumber, + buildFile, + useNugetRestore, + useConfigurationReleaseMode, + timeoutInSec, + logPrefix, + additionalBuildArguments, + cancellationToken); + + /// + /// Builds a .NET project or solution with logging support and collects compilation warnings grouped by warning code. + /// + /// The logger to use for build progress and results. + /// The root directory containing the project or solution to build. + /// Optional run number for logging purposes. + /// Optional specific solution or project file to build. If not specified, discovers the build file automatically. + /// Whether to perform NuGet restore before building. Default is true. + /// Whether to build in Release mode. If false, builds in Debug mode. Default is true. + /// Build timeout in seconds. Default is 1200 seconds (20 minutes). + /// Optional prefix for log messages. + /// Additional arguments appended to the dotnet build command, such as -p:NoWarn=CS0168 or -f net9.0. + /// Token to cancel the build operation. + /// A dictionary mapping warning codes to their occurrence counts. + /// Thrown when or is null. + [SuppressMessage("Major Code Smell", "S107:Methods should not have too many parameters", Justification = "OK.")] + public static Task> BuildAndCollectWarnings( + ILogger logger, + DirectoryInfo rootPath, + int? runNumber = null, + FileInfo? buildFile = null, + bool useNugetRestore = true, + bool useConfigurationReleaseMode = true, + int timeoutInSec = DefaultTimeoutInSec, + string logPrefix = "", + string additionalBuildArguments = "", + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(logger); + ArgumentNullException.ThrowIfNull(rootPath); + + return InvokeBuildAndCollect( logger, rootPath, runNumber, @@ -91,11 +181,13 @@ public static Task> BuildAndCollectErrors( useConfigurationReleaseMode, timeoutInSec, logPrefix, + additionalBuildArguments, + collectWarnings: true, cancellationToken); } [SuppressMessage("Major Code Smell", "S107:Methods should not have too many parameters", Justification = "OK.")] - private static async Task> InvokeBuildAndCollectErrors( + private static async Task> InvokeBuildAndCollect( ILogger logger, DirectoryInfo rootPath, int? runNumber, @@ -104,6 +196,8 @@ private static async Task> InvokeBuildAndCollectErrors( bool useConfigurationReleaseMode, int timeoutInSec, string logPrefix, + string additionalBuildArguments, + bool collectWarnings, CancellationToken cancellationToken) { logger.LogInformation(runNumber is > 0 @@ -118,33 +212,38 @@ private static async Task> InvokeBuildAndCollectErrors( useNugetRestore, useConfigurationReleaseMode, timeoutInSec, + additionalBuildArguments, cancellationToken) .ConfigureAwait(false); - if (output.StartsWith("Please specify which", StringComparison.Ordinal) && + if (output.StartsWith(MultipleFilesOutputPrefix, StringComparison.Ordinal) && output.Contains("option: --buildFile", StringComparison.Ordinal)) { stopwatch.Stop(); throw new IOException(output); } - var parsedErrors = ParseBuildOutput(output); - int totalErrors = parsedErrors.Sum(parsedError => parsedError.Value); + var parsed = collectWarnings + ? ParseBuildOutput(output, diagnostic: "warning") + : ParseBuildOutput(output, diagnostic: "error"); + + int total = parsed.Sum(x => x.Value); stopwatch.Stop(); - if (totalErrors > 0) + if (total > 0) { + var kind = collectWarnings ? "warnings" : "errors"; logger.LogError(runNumber is > 0 - ? $"{logPrefix}Found {totalErrors} errors divided into {parsedErrors.Count} rules in Build ({runNumber})" - : $"{logPrefix}Found {totalErrors} errors divided into {parsedErrors.Count} rules"); + ? $"{logPrefix}Found {total} {kind} divided into {parsed.Count} rules in Build ({runNumber})" + : $"{logPrefix}Found {total} {kind} divided into {parsed.Count} rules"); } logger.LogInformation(runNumber is > 0 ? $"{logPrefix}Build ({runNumber}) time: {stopwatch.Elapsed.GetPrettyTime()}" : $"{logPrefix}Build time: {stopwatch.Elapsed.GetPrettyTime()}"); - return parsedErrors; + return parsed; } private static async Task<( @@ -155,6 +254,7 @@ private static async Task> InvokeBuildAndCollectErrors( bool useNugetRestore, bool useConfigurationReleaseMode, int timeoutInSec, + string additionalBuildArguments, CancellationToken cancellationToken) { var argumentNugetRestore = useNugetRestore @@ -165,38 +265,44 @@ private static async Task> InvokeBuildAndCollectErrors( ? " -c Release" : " -c Debug"; + var argumentAdditional = string.IsNullOrWhiteSpace(additionalBuildArguments) + ? string.Empty + : $" {additionalBuildArguments.Trim()}"; + string arguments; if (buildFile is not null && buildFile.Exists) { - arguments = $"build {buildFile.FullName}{argumentNugetRestore}{argumentConfigurationReleaseMode} -v q -clp:NoSummary"; + arguments = $"build {buildFile.FullName}{argumentNugetRestore}{argumentConfigurationReleaseMode}{argumentAdditional} -v q -clp:NoSummary"; } else { - arguments = $"build{argumentNugetRestore}{argumentConfigurationReleaseMode} -v q -clp:NoSummary"; + arguments = $"build{argumentNugetRestore}{argumentConfigurationReleaseMode}{argumentAdditional} -v q -clp:NoSummary"; var slnFiles = Directory.GetFiles(rootPath.FullName, "*.sln"); if (slnFiles.Length > 1) { - var files = await slnFiles +#pragma warning disable AsyncFixer02 + var files = slnFiles .Select(x => new FileInfo(x).Name) - .ToListAsync(cancellationToken) - .ConfigureAwait(false); + .ToList(); +#pragma warning restore AsyncFixer02 return ( IsSuccessful: false, - Output: $"Please specify which solution file to use:{Environment.NewLine} - {string.Join($"{Environment.NewLine} - ", files)}{Environment.NewLine} Specify the solution file using this option: --buildFile"); + Output: $"{MultipleFilesOutputPrefix} solution file to use:{Environment.NewLine} - {string.Join($"{Environment.NewLine} - ", files)}{Environment.NewLine} Specify the solution file using this option: --buildFile"); } var csprojFiles = Directory.GetFiles(rootPath.FullName, "*.csproj"); if (csprojFiles.Length > 1) { - var files = await csprojFiles +#pragma warning disable AsyncFixer02 + var files = csprojFiles .Select(x => new FileInfo(x).Name) - .ToListAsync(cancellationToken) - .ConfigureAwait(false); + .ToList(); +#pragma warning restore AsyncFixer02 return ( IsSuccessful: false, - Output: $"Please specify which C# project file to use:{Environment.NewLine} - {string.Join($"{Environment.NewLine} - ", files)}{Environment.NewLine} Specify the C# project file using this option: --buildFile"); + Output: $"{MultipleFilesOutputPrefix} C# project file to use:{Environment.NewLine} - {string.Join($"{Environment.NewLine} - ", files)}{Environment.NewLine} Specify the C# project file using this option: --buildFile"); } } @@ -207,25 +313,49 @@ private static async Task> InvokeBuildAndCollectErrors( .ConfigureAwait(false); } - private static Dictionary ParseBuildOutput(string buildResult) + /// + /// Parses raw dotnet build output and returns error codes grouped by their occurrence count. + /// Recognises MSBuild errors (MSB prefix), NuGet errors (NU prefix), and general compiler errors + /// (e.g. CS, CA). The project-file suffix that MSBuild appends — [project.csproj] — is + /// optional; errors emitted without it are still counted. + /// + /// The raw text output from a dotnet build invocation. + /// A dictionary mapping each error code to the number of times it appeared. + public static Dictionary ParseErrors(string buildOutput) + => ParseBuildOutput(buildOutput, diagnostic: "error"); + + /// + /// Parses raw dotnet build output and returns warning codes grouped by their occurrence count. + /// Recognises MSBuild warnings (MSB prefix), NuGet warnings (NU prefix), and general compiler warnings + /// (e.g. CS, CA). The project-file suffix that MSBuild appends — [project.csproj] — is + /// optional; warnings emitted without it are still counted. + /// + /// The raw text output from a dotnet build invocation. + /// A dictionary mapping each warning code to the number of times it appeared. + public static Dictionary ParseWarnings(string buildOutput) + => ParseBuildOutput(buildOutput, diagnostic: "warning"); + + private static Dictionary ParseBuildOutput( + string buildResult, + string diagnostic) { - const string? regexPatternMsBuild = @": error MSB(\S+?): (.*)"; - const string? regexPatternNuget = @": error NU(\S+?): (.*)"; - const string? regexPatternGeneral = @": error ([A-Z]\S+?): (.*) \["; + var patternMsBuild = $@": {diagnostic} MSB(\S+?): (.*)"; + var patternNuget = $@": {diagnostic} NU(\S+?): (.*)"; + var patternGeneral = $@": {diagnostic} ([A-Z]\S+?): (.+)"; - var errors = ParseBuildOutputHelper(buildResult, regexPatternMsBuild, "MSB"); - if (errors.Any()) + var results = ParseBuildOutputHelper(buildResult, patternMsBuild, "MSB"); + if (results.Any()) { - return errors; + return results; } - errors = ParseBuildOutputHelper(buildResult, regexPatternNuget, "NU"); - if (errors.Any()) + results = ParseBuildOutputHelper(buildResult, patternNuget, "NU"); + if (results.Any()) { - return errors; + return results; } - return ParseBuildOutputHelper(buildResult, regexPatternGeneral); + return ParseBuildOutputHelper(buildResult, patternGeneral); } private static Dictionary ParseBuildOutputHelper( diff --git a/src/Atc.DotNet/DotnetHelper.cs b/src/Atc.DotNet/DotnetHelper.cs index 5d31995b..15dd35bd 100644 --- a/src/Atc.DotNet/DotnetHelper.cs +++ b/src/Atc.DotNet/DotnetHelper.cs @@ -17,16 +17,21 @@ public static class DotnetHelper /// public static DirectoryInfo GetDotnetDirectory() { - var pathEnvironmentVariable = Environment.GetEnvironmentVariable("path"); - if (pathEnvironmentVariable is not null && - pathEnvironmentVariable.Contains("dotnet", StringComparison.Ordinal)) + var dotnetFilename = OperatingSystem.IsWindows() ? "dotnet.exe" : "dotnet"; + var pathEnvironmentVariable = Environment.GetEnvironmentVariable("PATH"); + if (pathEnvironmentVariable is not null) { - var sa = pathEnvironmentVariable.Split(Path.PathSeparator); - foreach (var s in sa) + foreach (var segment in pathEnvironmentVariable.Split(Path.PathSeparator)) { - if (s.Contains("dotnet", StringComparison.Ordinal)) + if (string.IsNullOrWhiteSpace(segment)) { - return new DirectoryInfo(s); + continue; + } + + var candidate = Path.Combine(segment, dotnetFilename); + if (File.Exists(candidate)) + { + return new DirectoryInfo(segment); } } } diff --git a/src/Atc.DotNet/DotnetNugetHelper.cs b/src/Atc.DotNet/DotnetNugetHelper.cs index 2d313242..05523f7e 100644 --- a/src/Atc.DotNet/DotnetNugetHelper.cs +++ b/src/Atc.DotNet/DotnetNugetHelper.cs @@ -49,51 +49,20 @@ public static List GetAllPackageReferences( throw new DataException("Expect xml content"); } - var data = new List(); - foreach (var line in fileContent.EnsureEnvironmentNewLinesAndSplit()) - { - if (!line.Contains("", string.Empty, StringComparison.Ordinal) - .Replace(">", string.Empty, StringComparison.Ordinal) - .Trim() - .Split(' '); - - var packageId = string.Empty; - var version = string.Empty; - - foreach (var attribute in attributes) + var xDoc = XDocument.Parse(fileContent); + var data = xDoc + .Descendants("PackageReference") + .Select(e => new { - if (attribute.StartsWith("Include=", StringComparison.Ordinal)) - { - packageId = attribute - .Replace("Include=", string.Empty, StringComparison.Ordinal) - .Replace("\"", string.Empty, StringComparison.Ordinal); - } - else if (attribute.StartsWith("Version=", StringComparison.Ordinal)) - { - version = attribute - .Replace("Version=", string.Empty, StringComparison.Ordinal) - .Replace("\"", string.Empty, StringComparison.Ordinal); - } - } - - if (!string.IsNullOrEmpty(packageId) && - !string.IsNullOrEmpty(version)) - { - data.Add(new DotnetNugetPackageMetadataBase(packageId, version)); - } - } - - return data + PackageId = e.Attribute("Include")?.Value, + Version = e.Attribute("Version")?.Value + ?? e.Element("Version")?.Value, + }) + .Where(x => !string.IsNullOrEmpty(x.PackageId) && !string.IsNullOrEmpty(x.Version)) + .Select(x => new DotnetNugetPackageMetadataBase(x.PackageId!, x.Version!)) .OrderBy(x => x.PackageId, StringComparer.Ordinal) .ToList(); + + return data; } } \ No newline at end of file diff --git a/src/Atc.OpenApi/Extensions/OpenApiResponsesExtensions.cs b/src/Atc.OpenApi/Extensions/OpenApiResponsesExtensions.cs index 53e9b30b..73c97271 100644 --- a/src/Atc.OpenApi/Extensions/OpenApiResponsesExtensions.cs +++ b/src/Atc.OpenApi/Extensions/OpenApiResponsesExtensions.cs @@ -242,7 +242,7 @@ public static bool IsSchemaUsingBinaryFormatForOkResponse( { foreach (var (key, value) in responses.OrderBy(x => x.Key, StringComparer.Ordinal)) { - if (!key.Equals(((int)HttpStatusCode.OK).ToString(CultureInfo.CurrentCulture), StringComparison.OrdinalIgnoreCase)) + if (!key.Equals(((int)HttpStatusCode.OK).ToString(CultureInfo.InvariantCulture), StringComparison.Ordinal)) { continue; } diff --git a/src/Atc.OpenApi/Extensions/OpenApiSchemaExtensions.cs b/src/Atc.OpenApi/Extensions/OpenApiSchemaExtensions.cs index f26289e7..78fcd32e 100644 --- a/src/Atc.OpenApi/Extensions/OpenApiSchemaExtensions.cs +++ b/src/Atc.OpenApi/Extensions/OpenApiSchemaExtensions.cs @@ -604,6 +604,11 @@ public static bool HasAnyPropertiesFormatTypeFromSystemNamespace( this OpenApiSchema schema, IDictionary componentSchemas) { + if (componentSchemas is null) + { + throw new ArgumentNullException(nameof(componentSchemas)); + } + if (!schema.HasAnyProperties()) { return false; @@ -642,6 +647,11 @@ public static bool HasAnyPropertiesFormatTypeFromSystemCollectionGenericNamespac this OpenApiSchema schema, IDictionary componentSchemas) { + if (componentSchemas is null) + { + throw new ArgumentNullException(nameof(componentSchemas)); + } + if (!schema.HasAnyProperties()) { return false; @@ -1459,7 +1469,12 @@ public static Tuple GetEnumSchema( continue; } - var enumName = schemaProperty.Value.Reference?.Id ?? schema.Reference.Id; + var enumName = schemaProperty.Value.Reference?.Id ?? schema.Reference?.Id; + if (enumName is null) + { + throw new ItemNotFoundException("Enum schema is missing a Reference.Id."); + } + return Tuple.Create(enumName, schemaProperty.Value); } @@ -1519,9 +1534,8 @@ private static bool HasAnyPropertiesFormatTypeFromSystemNamespaceHelper( return false; } - var componentSchema = componentSchemas.FirstOrDefault(x => x.Key == modelName); - return !string.IsNullOrEmpty(componentSchema.Key) && - componentSchema.Value.HasAnyPropertiesFormatTypeFromSystemNamespace(componentSchemas); + return componentSchemas.TryGetValue(modelName!, out var componentSchemaValue) && + componentSchemaValue.HasAnyPropertiesFormatTypeFromSystemNamespace(componentSchemas); } private static bool HasAnyPropertiesFormatTypeFromSystemCollectionGenericNamespaceHelper( @@ -1539,8 +1553,7 @@ private static bool HasAnyPropertiesFormatTypeFromSystemCollectionGenericNamespa return false; } - var componentSchema = componentSchemas.FirstOrDefault(x => x.Key == modelName); - return !string.IsNullOrEmpty(componentSchema.Key) && - componentSchema.Value.HasAnyPropertiesFormatTypeFromSystemCollectionGenericNamespace(componentSchemas); + return componentSchemas.TryGetValue(modelName!, out var componentSchemaValue) && + componentSchemaValue.HasAnyPropertiesFormatTypeFromSystemCollectionGenericNamespace(componentSchemas); } } \ No newline at end of file diff --git a/src/Atc.Rest.Extended/Atc.Rest.Extended.csproj b/src/Atc.Rest.Extended/Atc.Rest.Extended.csproj index 5dc5ca94..894d3b5a 100644 --- a/src/Atc.Rest.Extended/Atc.Rest.Extended.csproj +++ b/src/Atc.Rest.Extended/Atc.Rest.Extended.csproj @@ -12,7 +12,7 @@ - + diff --git a/src/Atc.Rest.Extended/Filters/SwaggerEnumDescriptionsDocumentFilter.cs b/src/Atc.Rest.Extended/Filters/SwaggerEnumDescriptionsDocumentFilter.cs index 9c5bcd21..5abf97f9 100644 --- a/src/Atc.Rest.Extended/Filters/SwaggerEnumDescriptionsDocumentFilter.cs +++ b/src/Atc.Rest.Extended/Filters/SwaggerEnumDescriptionsDocumentFilter.cs @@ -115,9 +115,7 @@ private static string DescribeEnum( private static Type? GetEnumTypeByName(string enumTypeName) => AppDomain.CurrentDomain .GetAssemblies() - .SelectMany(x => x - .GetTypes() - .Where(t => t.IsEnum)) + .SelectMany(GetEnumTypesFromAssembly) .Where(x => string.Equals(x.Name, enumTypeName, StringComparison.Ordinal)) .ToArray() switch @@ -125,4 +123,21 @@ private static string DescribeEnum( { Length: 1 } a => a[0], _ => null, }; + + [SuppressMessage("Design", "CA1031:Do not catch general exception types", Justification = "Assembly.GetExportedTypes can fail for many reasons; fall back to empty on any error.")] + private static IEnumerable GetEnumTypesFromAssembly(Assembly assembly) + { + try + { + return assembly.GetExportedTypes().Where(t => t.IsEnum); + } + catch (ReflectionTypeLoadException ex) + { + return ex.Types.Where(t => t?.IsEnum == true).Select(t => t!); + } + catch + { + return []; + } + } } \ No newline at end of file diff --git a/src/Atc.Rest.Extended/GlobalUsings.cs b/src/Atc.Rest.Extended/GlobalUsings.cs index 9bac7546..f2a28811 100644 --- a/src/Atc.Rest.Extended/GlobalUsings.cs +++ b/src/Atc.Rest.Extended/GlobalUsings.cs @@ -16,7 +16,6 @@ global using FluentValidation; global using FluentValidation.AspNetCore; -global using Microsoft.ApplicationInsights; global using Microsoft.AspNetCore.Authentication; global using Microsoft.AspNetCore.Authentication.JwtBearer; global using Microsoft.AspNetCore.Authorization; @@ -32,8 +31,6 @@ global using Microsoft.Extensions.Logging; global using Microsoft.Extensions.Options; global using Microsoft.IdentityModel.Logging; -global using Microsoft.IdentityModel.Protocols; -global using Microsoft.IdentityModel.Protocols.OpenIdConnect; global using Microsoft.IdentityModel.Tokens; global using Microsoft.OpenApi; diff --git a/src/Atc.Rest.Extended/Options/ConfigureApiVersioningOptions.cs b/src/Atc.Rest.Extended/Options/ConfigureApiVersioningOptions.cs index c5355941..17b150f1 100644 --- a/src/Atc.Rest.Extended/Options/ConfigureApiVersioningOptions.cs +++ b/src/Atc.Rest.Extended/Options/ConfigureApiVersioningOptions.cs @@ -6,15 +6,11 @@ namespace Atc.Rest.Extended.Options; /// public class ConfigureApiVersioningOptions : IConfigureOptions { - private readonly TelemetryClient telemetry; - /// /// Initializes a new instance of the class. /// - /// The Application Insights telemetry client. - public ConfigureApiVersioningOptions(TelemetryClient telemetry) + public ConfigureApiVersioningOptions() { - this.telemetry = telemetry; } /// diff --git a/src/Atc.Rest.Extended/Options/ConfigureAuthorizationOptions.cs b/src/Atc.Rest.Extended/Options/ConfigureAuthorizationOptions.cs index df02c693..7d51ac0d 100644 --- a/src/Atc.Rest.Extended/Options/ConfigureAuthorizationOptions.cs +++ b/src/Atc.Rest.Extended/Options/ConfigureAuthorizationOptions.cs @@ -5,14 +5,13 @@ namespace Atc.Rest.Extended.Options; /// /// Post-configures JWT Bearer authentication and authorization options based on . -/// Handles issuer signing key retrieval from OpenID Connect configuration and token validation setup. +/// Signing-key discovery is delegated to JwtBearer's built-in , +/// which fetches and caches the OIDC discovery document on the first authentication request using the set here. /// public class ConfigureAuthorizationOptions : IPostConfigureOptions, IPostConfigureOptions { - private const string WellKnownOpenidConfiguration = ".well-known/openid-configuration"; - private static readonly TimeSpan SigningKeyFetchTimeout = TimeSpan.FromSeconds(30); private readonly IWebHostEnvironment? environment; private readonly RestApiExtendedOptions apiOptions; private readonly ILogger? logger; @@ -34,7 +33,9 @@ public ConfigureAuthorizationOptions( } /// - /// Post-configures JWT Bearer options with token validation parameters and issuer signing keys. + /// Post-configures JWT Bearer options with token validation parameters. + /// Signing keys are not pre-fetched; JwtBearer's built-in + /// discovers and caches them from the OIDC discovery endpoint on the first authentication request. /// /// The name of the options instance being configured. /// The to configure. @@ -92,39 +93,44 @@ public void PostConfigure( options.Authority = $"{apiOptions.Authorization.Instance}/{apiOptions.Authorization.TenantId}/"; } - options.TokenValidationParameters = new TokenValidationParameters + var tvp = new TokenValidationParameters { ValidateAudience = true, ValidAudience = apiOptions.Authorization.Audience, ValidAudiences = apiOptions.Authorization.ValidAudiences, ValidateIssuer = !string.IsNullOrWhiteSpace(apiOptions.Authorization.Issuer) || apiOptions.Authorization.ValidIssuers?.Any() == true, + + // Always validate the token signature (fail-closed). This is never relaxed below, + // so a transient key-fetch failure can never cause unverified tokens to be accepted. + ValidateIssuerSigningKey = true, }; - if (!options.TokenValidationParameters.ValidateIssuer) + if (!string.IsNullOrEmpty(apiOptions.Authorization.RoleClaimType)) { - return; + tvp.RoleClaimType = apiOptions.Authorization.RoleClaimType; } - options.TokenValidationParameters.ValidIssuer = apiOptions.Authorization.Issuer; - options.TokenValidationParameters.ValidIssuers = apiOptions.Authorization.ValidIssuers ?? new List(); + if (!string.IsNullOrEmpty(apiOptions.Authorization.NameClaimType)) + { + tvp.NameClaimType = apiOptions.Authorization.NameClaimType; + } + + options.TokenValidationParameters = tvp; - // Run the async fetch on a thread-pool thread with a hard timeout so we cannot deadlock - // on a captured sync-context and cannot hang application startup indefinitely if an - // identity provider is unreachable. - var fetchTask = Task.Run(() => GetIssuerSigningKeysAsync(options)); - if (!fetchTask.Wait(SigningKeyFetchTimeout)) + if (!options.TokenValidationParameters.ValidateIssuer) { - logger?.LogWarning( - "Timed out fetching issuer signing keys after {TimeoutSeconds}s. Token validation will fall back to empty key set; signing-key validation disabled.", - SigningKeyFetchTimeout.TotalSeconds); - options.TokenValidationParameters.IssuerSigningKeys = Array.Empty(); - options.TokenValidationParameters.ValidateIssuerSigningKey = false; return; } - options.TokenValidationParameters.IssuerSigningKeys = fetchTask.Result; - options.TokenValidationParameters.ValidateIssuerSigningKey = options.TokenValidationParameters.IssuerSigningKeys.Any(); + options.TokenValidationParameters.ValidIssuer = apiOptions.Authorization.Issuer; + options.TokenValidationParameters.ValidIssuers = apiOptions.Authorization.ValidIssuers ?? new List(); + + // Signing keys are discovered lazily by JwtBearer's built-in ConfigurationManager via options.Authority. + // Pre-fetching here blocked application startup for up to 30 s and duplicated JwtBearer's own mechanism. + logger?.LogInformation( + "JWT signing-key discovery deferred: keys will be fetched from {Authority} on the first authentication request.", + options.Authority); } /// @@ -141,68 +147,6 @@ public void PostConfigure( options.DefaultScheme = JwtBearerDefaults.AuthenticationScheme; } - /// - /// Retrieves issuer signing keys from the OpenID Connect configuration endpoint. - /// - /// The issuer URL. - /// - /// A collection of security keys for token validation, or an empty array if retrieval failed - /// (the failure is logged via the configured ; callers should treat an - /// empty result as "signing keys unavailable"). - /// - [SuppressMessage("Microsoft.Design", "CA1031:Do not catch general exception types", Justification = "Failure to fetch keys must not abort startup; we log and return empty so the caller decides.")] - private async Task> GetIssuerSigningKeysAsync( - string issuer) - { - try - { - var configurationManager = new ConfigurationManager( - $"{issuer}/{WellKnownOpenidConfiguration}", - new OpenIdConnectConfigurationRetriever()); - - var configuration = await configurationManager.GetConfigurationAsync(); - return configuration.SigningKeys; - } - catch (Exception e) - { - logger?.LogWarning( - e, - "Failed to retrieve OpenID Connect signing keys from {Issuer}. Token validation will fall back to an empty key set for this issuer.", - issuer); - return Array.Empty(); - } - } - - private async Task> GetIssuerSigningKeysAsync( - JwtBearerOptions options) - { - var issuerSigningKeys = new List(); - - if (!string.IsNullOrEmpty(options.Authority)) - { - issuerSigningKeys.AddRange( - await GetIssuerSigningKeysAsync( - options.Authority)); - } - - if (apiOptions.Authorization is not null && - !string.IsNullOrWhiteSpace(apiOptions.Authorization.Issuer)) - { - issuerSigningKeys.AddRange( - await GetIssuerSigningKeysAsync( - apiOptions.Authorization.Issuer)); - } - - foreach (var issuer in options.TokenValidationParameters.ValidIssuers) - { - issuerSigningKeys.AddRange( - await GetIssuerSigningKeysAsync( - issuer)); - } - - return issuerSigningKeys; - } - private void SanityCheck(JwtBearerOptions options) { ArgumentNullException.ThrowIfNull(options); diff --git a/src/Atc.Rest.Extended/Options/ConfigureSwaggerOptions.cs b/src/Atc.Rest.Extended/Options/ConfigureSwaggerOptions.cs index 84decb60..83f315dd 100644 --- a/src/Atc.Rest.Extended/Options/ConfigureSwaggerOptions.cs +++ b/src/Atc.Rest.Extended/Options/ConfigureSwaggerOptions.cs @@ -47,6 +47,7 @@ public void Configure(SwaggerUIOptions options) /// Configures Swagger generation options including API versioning, XML documentation, and security filters. /// /// The to configure. + [SuppressMessage("Design", "MA0051:Method is too long", Justification = "OK.")] public void Configure(SwaggerGenOptions options) { options.TagActionsBy(api => @@ -84,7 +85,7 @@ public void Configure(SwaggerGenOptions options) description.GroupName, new OpenApiInfo { - Title = Assembly.GetEntryAssembly()!.GetApiName(), + Title = (Assembly.GetEntryAssembly() ?? Assembly.GetCallingAssembly()).GetApiName(), Version = description.ApiVersion.ToString(), }); } @@ -106,6 +107,33 @@ public void Configure(SwaggerGenOptions options) { options.OperationFilter(); options.OperationFilter(); + + var authOptions = restApiOptions.Authorization; + if (authOptions?.IsSecurityEnabled() == true && + !string.IsNullOrEmpty(authOptions.Instance) && + !string.IsNullOrEmpty(authOptions.TenantId)) + { + var baseUrl = authOptions.Instance.TrimEnd('/'); + var tenant = authOptions.TenantId; + options.AddSecurityDefinition( + "OAuth2", + new OpenApiSecurityScheme + { + Type = SecuritySchemeType.OAuth2, + Flows = new OpenApiOAuthFlows + { + AuthorizationCode = new OpenApiOAuthFlow + { + AuthorizationUrl = new Uri($"{baseUrl}/{tenant}/oauth2/v2.0/authorize"), + TokenUrl = new Uri($"{baseUrl}/{tenant}/oauth2/v2.0/token"), + Scopes = new Dictionary(StringComparer.Ordinal) + { + [$"api://{authOptions.ClientId}/.default"] = "Default scope", + }, + }, + }, + }); + } } } diff --git a/src/Atc.Rest.FluentAssertions/Assertions/ContentResultAssertionsBase.cs b/src/Atc.Rest.FluentAssertions/Assertions/ContentResultAssertionsBase.cs index 0de80b50..0ca642b8 100644 --- a/src/Atc.Rest.FluentAssertions/Assertions/ContentResultAssertionsBase.cs +++ b/src/Atc.Rest.FluentAssertions/Assertions/ContentResultAssertionsBase.cs @@ -78,7 +78,7 @@ public AndWhichConstraint WithContentOfType( .BecauseOf(because, becauseArgs) .WithDefaultIdentifier($"content type of {Identifier}") .Given(() => Subject.ContentType) - .ForCondition(contentType => contentType is not null && contentType.Equals(MediaTypeNames.Application.Json, StringComparison.Ordinal)) + .ForCondition(IsJsonContentType) .FailWith("Expected {context} to be {0}{reason}, but found {1}.", _ => MediaTypeNames.Application.Json, x => x); var parseSuccess = TryContentValueAs(out var result); @@ -119,6 +119,29 @@ protected bool TryContentValueAs([NotNullWhen(true)] out T content) return false; } + /// + /// Determines whether the supplied content type denotes JSON, ignoring any + /// parameters such as ; charset=utf-8 and comparing case-insensitively. + /// + /// The raw value. + /// if the media type is application/json; otherwise, . + private static bool IsJsonContentType(string? contentType) + { + if (string.IsNullOrEmpty(contentType)) + { + return false; + } + + var separatorIndex = contentType.IndexOf(';', StringComparison.Ordinal); + var mediaType = separatorIndex >= 0 + ? contentType[..separatorIndex] + : contentType; + + return mediaType + .Trim() + .Equals(MediaTypeNames.Application.Json, StringComparison.OrdinalIgnoreCase); + } + private bool TryContentValueAs( Type type, out object content) diff --git a/src/Atc.Rest.FluentAssertions/Assertions/ErrorContentResultAssertions.cs b/src/Atc.Rest.FluentAssertions/Assertions/ErrorContentResultAssertions.cs index ed06a41e..ddbc145c 100644 --- a/src/Atc.Rest.FluentAssertions/Assertions/ErrorContentResultAssertions.cs +++ b/src/Atc.Rest.FluentAssertions/Assertions/ErrorContentResultAssertions.cs @@ -34,7 +34,7 @@ public AndWhichConstraint WithErrorMessage( if (TryContentValueAs(out var pd)) { - actualErrorMessage = pd.Detail; + actualErrorMessage = pd.Detail ?? string.Empty; } else if (TryContentValueAs(out var details)) { diff --git a/src/Atc.Rest.FluentAssertions/Assertions/OkResultAssertions.cs b/src/Atc.Rest.FluentAssertions/Assertions/OkResultAssertions.cs index 0d372682..44789350 100644 --- a/src/Atc.Rest.FluentAssertions/Assertions/OkResultAssertions.cs +++ b/src/Atc.Rest.FluentAssertions/Assertions/OkResultAssertions.cs @@ -35,6 +35,23 @@ public AndWhichConstraint WithContentOfType( } } + /// + /// Asserts that the OK result has no body content (the result value is ). + /// + /// Optional explanation of why the assertion is needed. + /// Optional formatting arguments for the parameter. + /// An for chaining further assertions. + public AndConstraint WithEmptyContent( + string because = "", + params object[] becauseArgs) + { + Execute.Assertion + .BecauseOf(because, becauseArgs) + .ForCondition(Subject.Value is null) + .FailWith("Expected content of {0} to be empty{{reason}}, but found {1}.", Identifier, Subject.Value); + return new AndConstraint(this); + } + /// /// Asserts that the OK result contains content equivalent to the specified expected content. /// diff --git a/src/Atc.Rest.FluentAssertions/Assertions/ResultAssertions.cs b/src/Atc.Rest.FluentAssertions/Assertions/ResultAssertions.cs index 8c0dfa01..44f680cb 100644 --- a/src/Atc.Rest.FluentAssertions/Assertions/ResultAssertions.cs +++ b/src/Atc.Rest.FluentAssertions/Assertions/ResultAssertions.cs @@ -47,6 +47,24 @@ public OkResultAssertions BeOkResult( return new OkResultAssertions(okSubject); } + /// + /// Asserts that the action result is a 200 OK result whose content is equivalent to . + /// This is a convenience shorthand for BeOkResult().WithContent(expectedContent). + /// + /// The type of the expected content. + /// The expected content value to compare against. + /// Optional explanation of why the assertion is needed. + /// Optional formatting arguments for the parameter. + /// An for further assertions. + public AndWhichConstraint BeOkResultWithContent( + T expectedContent, + string because = "", + params object[] becauseArgs) + { + var okAssertions = BeOkResult(because, becauseArgs); + return okAssertions.WithContent(expectedContent, because, becauseArgs); + } + /// /// Asserts that the action result is a with HTTP status code 201 (Created). /// diff --git a/src/Atc.Rest.HealthChecks/Atc.Rest.HealthChecks.csproj b/src/Atc.Rest.HealthChecks/Atc.Rest.HealthChecks.csproj index 1725035a..a883e009 100644 --- a/src/Atc.Rest.HealthChecks/Atc.Rest.HealthChecks.csproj +++ b/src/Atc.Rest.HealthChecks/Atc.Rest.HealthChecks.csproj @@ -9,7 +9,7 @@ - + diff --git a/src/Atc.Rest.HealthChecks/Extensions/HealthReportEntryExtensions.cs b/src/Atc.Rest.HealthChecks/Extensions/HealthReportEntryExtensions.cs index 681d933a..2ae15308 100644 --- a/src/Atc.Rest.HealthChecks/Extensions/HealthReportEntryExtensions.cs +++ b/src/Atc.Rest.HealthChecks/Extensions/HealthReportEntryExtensions.cs @@ -60,8 +60,12 @@ private static IReadOnlyDictionary SanitizeData( IReadOnlyDictionary data) => data.ToDictionary( kvp => kvp.Key, - kvp => kvp.Value is Exception ex - ? ex.Message - : kvp.Value, + kvp => kvp.Value switch + { + null => (object)string.Empty, + Exception ex => ex.Message, + string s => s, + _ => kvp.Value.ToString() ?? string.Empty, + }, StringComparer.Ordinal); } \ No newline at end of file diff --git a/src/Atc.Rest.HealthChecks/Extensions/ResourceHealthCheckExtensions.cs b/src/Atc.Rest.HealthChecks/Extensions/ResourceHealthCheckExtensions.cs index e6d17396..757db6a8 100644 --- a/src/Atc.Rest.HealthChecks/Extensions/ResourceHealthCheckExtensions.cs +++ b/src/Atc.Rest.HealthChecks/Extensions/ResourceHealthCheckExtensions.cs @@ -12,13 +12,19 @@ public static class ResourceHealthCheckExtensions /// A read-only dictionary where keys are resource names and values are anonymous objects containing status, duration, and description. public static IReadOnlyDictionary ToIReadOnlyDictionary( this IEnumerable resourceHealthCheck) - => resourceHealthCheck.ToDictionary( - keySelector: key => key.Name, - elementSelector: e => (object)new - { - e.Status, - e.Duration, - e.Description, - }, - StringComparer.Ordinal); + => resourceHealthCheck + .GroupBy(e => e.Name, StringComparer.Ordinal) + .ToDictionary( + g => g.Key, + g => + { + var e = g.First(); + return (object)new + { + e.Status, + e.Duration, + e.Description, + }; + }, + StringComparer.Ordinal); } \ No newline at end of file diff --git a/src/Atc.Rest.HealthChecks/Factories/HealthCheckOptionsFactory.cs b/src/Atc.Rest.HealthChecks/Factories/HealthCheckOptionsFactory.cs index f2f1a498..be8ef80f 100644 --- a/src/Atc.Rest.HealthChecks/Factories/HealthCheckOptionsFactory.cs +++ b/src/Atc.Rest.HealthChecks/Factories/HealthCheckOptionsFactory.cs @@ -33,10 +33,11 @@ public static HealthCheckOptions CreateJson( r.Status, r.TotalDuration); - await c.Response.WriteAsync( - JsonSerializer.Serialize( - response, - jsonSerializerOptions ?? JsonSerializerOptionsFactory.Create())); + await JsonSerializer.SerializeAsync( + c.Response.Body, + response, + jsonSerializerOptions ?? JsonSerializerOptionsFactory.Default, + c.RequestAborted); }, }; } \ No newline at end of file diff --git a/src/Atc.Rest.HealthChecks/GlobalUsings.cs b/src/Atc.Rest.HealthChecks/GlobalUsings.cs index f42fecc3..4affb6a3 100644 --- a/src/Atc.Rest.HealthChecks/GlobalUsings.cs +++ b/src/Atc.Rest.HealthChecks/GlobalUsings.cs @@ -4,5 +4,4 @@ global using Atc.Rest.HealthChecks.Models; global using Atc.Serialization; global using Microsoft.AspNetCore.Diagnostics.HealthChecks; -global using Microsoft.AspNetCore.Http; global using Microsoft.Extensions.Diagnostics.HealthChecks; \ No newline at end of file diff --git a/src/Atc.Rest/Extensions/ApplicationBuilder/RestApiBuilderExtensions.cs b/src/Atc.Rest/Extensions/ApplicationBuilder/RestApiBuilderExtensions.cs index 1613dc4e..33ce9bc9 100644 --- a/src/Atc.Rest/Extensions/ApplicationBuilder/RestApiBuilderExtensions.cs +++ b/src/Atc.Rest/Extensions/ApplicationBuilder/RestApiBuilderExtensions.cs @@ -110,8 +110,11 @@ public static IApplicationBuilder ConfigureRestApi( options.AllowCredentials(); }); } - else + else if (env.IsDevelopment()) { + // Only allow all origins in Development; in other environments with no configured + // origins the CORS middleware is simply not added, which means the browser's + // same-origin policy applies and no CORS headers are emitted. app.UseCors(options => { options.AllowAnyHeader(); diff --git a/src/Atc.Rest/Extensions/HeaderDictionaryExtensions.cs b/src/Atc.Rest/Extensions/HeaderDictionaryExtensions.cs index 53b439eb..41ec7652 100644 --- a/src/Atc.Rest/Extensions/HeaderDictionaryExtensions.cs +++ b/src/Atc.Rest/Extensions/HeaderDictionaryExtensions.cs @@ -58,7 +58,11 @@ public static string AddCorrelationId( if (headers.TryGetValue(WellKnownHttpHeaders.RequestId, out var header)) { - return header.FirstOrDefault(); + var value = header.FirstOrDefault(); + if (!string.IsNullOrEmpty(value) && IsSafeRequestId(value!)) + { + return value; + } } var requestId = Guid @@ -93,4 +97,13 @@ private static bool IsValidCorrelationId(string value) => value.Length <= 68 && !value.AsSpan().ContainsAny('\r', '\n') && Guid.TryParse(value, out _); + + /// + /// Validates that a request ID value is safe to echo and log: bounded length and free of + /// CR/LF control characters, guarding against header-injection and log-forging. Unlike the + /// correlation ID, a non-GUID format is permitted so legitimate upstream request IDs pass through. + /// + private static bool IsSafeRequestId(string value) + => value.Length <= 128 && + !value.AsSpan().ContainsAny('\r', '\n'); } \ No newline at end of file diff --git a/src/Atc.Rest/Extensions/HttpRequestExExtensions.cs b/src/Atc.Rest/Extensions/HttpRequestExExtensions.cs index 7f3f5926..4c05add9 100644 --- a/src/Atc.Rest/Extensions/HttpRequestExExtensions.cs +++ b/src/Atc.Rest/Extensions/HttpRequestExExtensions.cs @@ -20,8 +20,19 @@ public static async Task GetRawBodyStringAsync( encoding ??= Encoding.UTF8; - using var reader = new StreamReader(request.Body, encoding); - return await reader.ReadToEndAsync(); + // Allow the body to be read more than once so downstream model binding still works. + request.EnableBuffering(); + + string body; + using (var reader = new StreamReader(request.Body, encoding, leaveOpen: true)) + { + body = await reader.ReadToEndAsync(); + } + + // Rewind so subsequent reads (e.g. model binding) start from the beginning. + request.Body.Position = 0; + + return body; } /// diff --git a/src/Atc.Rest/Extensions/ServiceCollection/ServiceCollectionExtensions.cs b/src/Atc.Rest/Extensions/ServiceCollection/ServiceCollectionExtensions.cs index 53d73439..999f4a6e 100644 --- a/src/Atc.Rest/Extensions/ServiceCollection/ServiceCollectionExtensions.cs +++ b/src/Atc.Rest/Extensions/ServiceCollection/ServiceCollectionExtensions.cs @@ -34,23 +34,23 @@ public static void AutoRegistrateServices( .DefinedTypes .ToArray(); + // Build a lookup keyed by interface FullName to avoid an O(interfaces × types) double loop. + var implementationLookup = implementationTypes + .SelectMany(t => t.GetInterfaces(), (t, i) => (InterfaceName: i.FullName, Type: t)) + .Where(x => x.InterfaceName is not null) + .ToLookup(x => x.InterfaceName!, x => x.Type, StringComparer.Ordinal); + foreach (var implementationInterface in implementationInterfaces) { - foreach (var implementationType in implementationTypes) + var matchingType = implementationLookup[implementationInterface.FullName ?? string.Empty].FirstOrDefault(); + if (matchingType is null) { - if (implementationType - .GetInterfaces() - .FirstOrDefault(x => string.Equals(x.FullName, implementationInterface.FullName, StringComparison.Ordinal)) is null) - { - continue; - } - - if (!IsImplementationTypeRegistered(services, implementationType)) - { - services.AddTransient(implementationInterface, implementationType); - } + continue; + } - break; + if (!IsImplementationTypeRegistered(services, matchingType)) + { + services.AddTransient(implementationInterface, matchingType); } } } @@ -68,11 +68,12 @@ public static void ValidateServiceRegistrations( ArgumentNullException.ThrowIfNull(services); ArgumentNullException.ThrowIfNull(apiAssembly); + var registeredServiceTypes = new HashSet(services.Select(x => x.ServiceType)); + var notRegistered = apiAssembly .DefinedTypes .Where(x => x.IsInterface) - .Where(typeInfo => services - .All(x => x.ServiceType != typeInfo)) + .Where(typeInfo => !registeredServiceTypes.Contains(typeInfo)) .ToList(); if (notRegistered.Count <= 0) diff --git a/src/Atc.Rest/Filters/ErrorHandlingExceptionFilterAttribute.cs b/src/Atc.Rest/Filters/ErrorHandlingExceptionFilterAttribute.cs index 1a7966e0..120e37d8 100644 --- a/src/Atc.Rest/Filters/ErrorHandlingExceptionFilterAttribute.cs +++ b/src/Atc.Rest/Filters/ErrorHandlingExceptionFilterAttribute.cs @@ -17,7 +17,6 @@ namespace Microsoft.AspNetCore.Mvc.Filters; /// /// → 400 Bad Request /// → 401 Unauthorized -/// → 409 Conflict /// → 501 Not Implemented /// All other exceptions → 500 Internal Server Error /// @@ -92,10 +91,6 @@ private static HttpStatusCode GetHttpStatusCodeByExceptionType( { statusCode = HttpStatusCode.Unauthorized; } - else if (exceptionType == typeof(InvalidOperationException)) - { - statusCode = HttpStatusCode.Conflict; - } else if (exceptionType == typeof(NotImplementedException)) { statusCode = HttpStatusCode.NotImplemented; @@ -106,14 +101,24 @@ private static HttpStatusCode GetHttpStatusCodeByExceptionType( private void HandleException(ExceptionContext context) { - context.Result = new ContentResult + var statusCode = (int)GetHttpStatusCodeByExceptionType(context); + + if (useProblemDetailsAsResponseBody) { - ContentType = MediaTypeNames.Application.Json, - StatusCode = (int)GetHttpStatusCodeByExceptionType(context), - Content = useProblemDetailsAsResponseBody - ? JsonSerializer.Serialize(CreateProblemDetails(context)) - : CreateMessage(context), - }; + context.Result = new ObjectResult(CreateProblemDetails(context)) + { + StatusCode = statusCode, + }; + } + else + { + context.Result = new ContentResult + { + ContentType = MediaTypeNames.Application.Json, + StatusCode = statusCode, + Content = CreateMessage(context), + }; + } } [SuppressMessage("Globalization", "CA1305:Specify IFormatProvider", Justification = "OK.")] diff --git a/src/Atc.Rest/Middleware/ExceptionTelemetryMiddleware.cs b/src/Atc.Rest/Middleware/ExceptionTelemetryMiddleware.cs index 849d385e..1bf24e2d 100644 --- a/src/Atc.Rest/Middleware/ExceptionTelemetryMiddleware.cs +++ b/src/Atc.Rest/Middleware/ExceptionTelemetryMiddleware.cs @@ -10,19 +10,14 @@ namespace Atc.Rest.Middleware; public class ExceptionTelemetryMiddleware { private readonly RequestDelegate next; - private readonly TelemetryClient client; /// /// Initializes a new instance of the class. /// /// The next middleware delegate in the pipeline. - /// The Application Insights telemetry client. - public ExceptionTelemetryMiddleware( - RequestDelegate next, - TelemetryClient client) + public ExceptionTelemetryMiddleware(RequestDelegate next) { this.next = next; - this.client = client; } /// @@ -47,7 +42,8 @@ private async Task InternalInvokeAsync(HttpContext context) } catch (Exception ex) { - client.TrackException(ex); + var telemetryClient = context.RequestServices.GetService(); + telemetryClient?.TrackException(ex); if (context.Response.HasStarted) { @@ -57,12 +53,16 @@ private async Task InternalInvokeAsync(HttpContext context) requestFailed = true; } - if (requestFailed) + if (requestFailed && !context.RequestAborted.IsCancellationRequested) { context.Response.StatusCode = (int)HttpStatusCode.InternalServerError; - await context - .Response - .WriteAsync($"Something is broken. Please contact the development team with the value of the returned header named '{WellKnownHttpHeaders.CorrelationId}'"); + var problem = new ProblemDetails + { + Status = (int)HttpStatusCode.InternalServerError, + Title = "An unexpected error occurred.", + Detail = $"Contact the development team with the value of the '{WellKnownHttpHeaders.CorrelationId}' response header.", + }; + await context.Response.WriteAsJsonAsync(problem, context.RequestAborted); } } } \ No newline at end of file diff --git a/src/Atc.Rest/Middleware/RequestResponseLoggerMiddleware.cs b/src/Atc.Rest/Middleware/RequestResponseLoggerMiddleware.cs index 9841ab7e..403a30bc 100644 --- a/src/Atc.Rest/Middleware/RequestResponseLoggerMiddleware.cs +++ b/src/Atc.Rest/Middleware/RequestResponseLoggerMiddleware.cs @@ -167,8 +167,19 @@ private static async Task ReadBodyFromRequest( request.EnableBuffering(); } - using var streamReader = new StreamReader(request.Body, leaveOpen: true); - var requestBody = await streamReader.ReadToEndAsync(); + string requestBody; + if (maxBufferSize > 0) + { + // Read at most maxBufferSize bytes so the documented cap is enforced. + var buffer = new byte[maxBufferSize]; + var bytesRead = await request.Body.ReadAsync(buffer.AsMemory(0, buffer.Length)); + requestBody = Encoding.UTF8.GetString(buffer, 0, bytesRead); + } + else + { + using var streamReader = new StreamReader(request.Body, leaveOpen: true); + requestBody = await streamReader.ReadToEndAsync(); + } // Reset the request's body stream position for next middleware in the pipeline. request.Body.Position = 0; diff --git a/src/Atc.Rest/Options/AuthorizationOptions.cs b/src/Atc.Rest/Options/AuthorizationOptions.cs index 349853b0..3b5490dd 100644 --- a/src/Atc.Rest/Options/AuthorizationOptions.cs +++ b/src/Atc.Rest/Options/AuthorizationOptions.cs @@ -72,6 +72,24 @@ public class AuthorizationOptions /// public List ValidIssuers { get; set; } = new(); + /// + /// Gets or sets the JWT claim type used to populate ASP.NET Core roles for + /// [Authorize(Roles=…)]. For Azure AD access tokens the claim is "roles"; + /// for client-credentials tokens the scope claim is "scp". + /// When or empty, the framework default + /// (ClaimTypes.Role = the long URI form) is used, which does not match + /// the short-form claims issued by Azure AD. + /// + public string? RoleClaimType { get; set; } + + /// + /// Gets or sets the JWT claim type used to populate the user's identity name (). + /// For Azure AD access tokens the claim is typically "name" or "preferred_username". + /// When or empty, the framework default + /// (ClaimTypes.Name = the long URI form) is used. + /// + public string? NameClaimType { get; set; } + /// /// Determines whether any security settings are configured. /// diff --git a/src/Atc.Rest/Options/ConfigureApiBehaviorOptions.cs b/src/Atc.Rest/Options/ConfigureApiBehaviorOptions.cs index 027b77ce..63dd9e99 100644 --- a/src/Atc.Rest/Options/ConfigureApiBehaviorOptions.cs +++ b/src/Atc.Rest/Options/ConfigureApiBehaviorOptions.cs @@ -9,17 +9,24 @@ namespace Atc.Rest.Options; /// Suppress automatic binding source inference for better control /// Return ValidationProblemDetails for invalid model state /// Include correlation ID in validation error responses -/// Track validation errors in Application Insights telemetry +/// Track validation errors in Application Insights telemetry when a is provided /// /// public class ConfigureApiBehaviorOptions : IConfigureOptions { - private readonly TelemetryClient telemetry; + private readonly TelemetryClient? telemetry; + + /// + /// Initializes a new instance of the class without telemetry. + /// + public ConfigureApiBehaviorOptions() + { + } /// /// Initializes a new instance of the class. /// - /// The Application Insights telemetry client. + /// The Application Insights telemetry client used to track validation errors. public ConfigureApiBehaviorOptions(TelemetryClient telemetry) { this.telemetry = telemetry; @@ -44,11 +51,14 @@ public void Configure(ApiBehaviorOptions options) }, }; - telemetry.TrackTrace( + // Log only the field names (not values) to avoid capturing PII or user-submitted data in telemetry. + var invalidFields = string.Join(", ", error.Errors.Keys); + telemetry?.TrackTrace( "BadRequest", new Dictionary(StringComparer.Ordinal) { - { "Response.Body", JsonSerializer.Serialize(error) }, + { "InvalidFields", invalidFields }, + { "TraceId", error.Extensions.TryGetValue("traceId", out var traceId) ? traceId?.ToString() ?? string.Empty : string.Empty }, }); return new BadRequestObjectResult(error); diff --git a/src/Atc.Rest/Options/RestApiOptions.cs b/src/Atc.Rest/Options/RestApiOptions.cs index 97a4fe96..3989cf80 100644 --- a/src/Atc.Rest/Options/RestApiOptions.cs +++ b/src/Atc.Rest/Options/RestApiOptions.cs @@ -76,8 +76,11 @@ public class RestApiOptions /// Gets or sets the allowed CORS origins for the API. /// /// - /// When null or empty, a permissive policy (AllowAnyOrigin, AllowAnyMethod, AllowAnyHeader) is used. - /// When specified, only the listed origins are allowed. Set this in production to prevent CSRF attacks. + /// When null or empty and the environment is Development, a permissive policy + /// (AllowAnyOrigin, AllowAnyMethod, AllowAnyHeader) is applied. + /// When null or empty in non-Development environments, no CORS middleware is added and the + /// browser's same-origin policy applies — no CORS headers are emitted. + /// When specified, only the listed origins are allowed in all environments. /// [SuppressMessage("Usage", "CA2227:Collection properties should be read only", Justification = "OK.")] public List? AllowedCorsOrigins { get; set; } diff --git a/src/Atc.Rest/Results/Pagination.cs b/src/Atc.Rest/Results/Pagination.cs index 0de1bbef..367fad00 100644 --- a/src/Atc.Rest/Results/Pagination.cs +++ b/src/Atc.Rest/Results/Pagination.cs @@ -102,7 +102,7 @@ public Pagination( /// /// Gets the total number of pages based on TotalCount and PageSize. /// - public int? TotalPages => TotalCount is null + public int? TotalPages => TotalCount is null || PageSize <= 0 ? default(int?) : (int)System.Math.Ceiling((double)TotalCount / PageSize); diff --git a/src/Atc.Rest/Results/ResultFactory.cs b/src/Atc.Rest/Results/ResultFactory.cs index 0838ded9..d5c1568a 100644 --- a/src/Atc.Rest/Results/ResultFactory.cs +++ b/src/Atc.Rest/Results/ResultFactory.cs @@ -61,7 +61,7 @@ public static ContentResult CreateContentResultWithProblemDetails( { ContentType = contentType, StatusCode = (int)statusCode, - Content = JsonSerializer.Serialize(CreateProblemDetails(statusCode, message)), + Content = JsonSerializer.Serialize(CreateProblemDetails(statusCode, message), JsonSerializerOptionsFactory.Create(writeIndented: false)), }; /// @@ -93,11 +93,11 @@ public static ContentResult CreateContentResultWithProblemDetails( var message = SimpleTypeHelper.IsSimpleType(beautifyTypeName) ? value.ToString() - : JsonSerializer.Serialize(value); + : JsonSerializer.Serialize(value, JsonSerializerOptionsFactory.Create(writeIndented: false)); var problemDetails = CreateProblemDetails(statusCode, message); - result.Content = JsonSerializer.Serialize(problemDetails); + result.Content = JsonSerializer.Serialize(problemDetails, JsonSerializerOptionsFactory.Create(writeIndented: false)); return result; } @@ -117,7 +117,7 @@ public static ContentResult CreateContentResultWithValidationProblemDetails( { ContentType = contentType, StatusCode = (int)statusCode, - Content = JsonSerializer.Serialize(CreateValidationProblemDetails(statusCode, new Dictionary(StringComparer.Ordinal), message)), + Content = JsonSerializer.Serialize(CreateValidationProblemDetails(statusCode, new Dictionary(StringComparer.Ordinal), message), JsonSerializerOptionsFactory.Create(writeIndented: false)), }; /// @@ -137,7 +137,60 @@ public static ContentResult CreateContentResultWithValidationProblemDetails( { ContentType = contentType, StatusCode = (int)statusCode, - Content = JsonSerializer.Serialize(CreateValidationProblemDetails(statusCode, errors, message)), + Content = JsonSerializer.Serialize(CreateValidationProblemDetails(statusCode, errors, message), JsonSerializerOptionsFactory.Create(writeIndented: false)), + }; + + /// + /// Creates an containing ProblemDetails, allowing ASP.NET Core's + /// output formatters to serialize it using the app-configured . + /// Prefer this over + /// when consistent casing with the rest of the API is required. + /// + /// The HTTP status code. + /// The detail message describing the problem. + /// An wrapping a instance. + public static ObjectResult CreateObjectResultWithProblemDetails( + HttpStatusCode statusCode, + string? message) + => new(CreateProblemDetails(statusCode, message)) + { + StatusCode = (int)statusCode, + }; + + /// + /// Creates an containing ValidationProblemDetails without field-specific errors, + /// allowing ASP.NET Core's output formatters to serialize it using the app-configured . + /// Prefer this over + /// when consistent casing with the rest of the API is required. + /// + /// The HTTP status code. + /// The detail message describing the validation failure. + /// An wrapping a instance. + public static ObjectResult CreateObjectResultWithValidationProblemDetails( + HttpStatusCode statusCode, + string? message) + => new(CreateValidationProblemDetails(statusCode, new Dictionary(StringComparer.Ordinal), message)) + { + StatusCode = (int)statusCode, + }; + + /// + /// Creates an containing ValidationProblemDetails with field-specific errors, + /// allowing ASP.NET Core's output formatters to serialize it using the app-configured . + /// Prefer this over + /// when consistent casing with the rest of the API is required. + /// + /// The HTTP status code. + /// A dictionary of field names and their associated validation errors. + /// The detail message describing the validation failure. + /// An wrapping a instance. + public static ObjectResult CreateObjectResultWithValidationProblemDetails( + HttpStatusCode statusCode, + Dictionary errors, + string? message) + => new(CreateValidationProblemDetails(statusCode, errors, message)) + { + StatusCode = (int)statusCode, }; /// diff --git a/src/Atc.XUnit/Atc.XUnit.csproj b/src/Atc.XUnit/Atc.XUnit.csproj index 7f508788..208b7945 100644 --- a/src/Atc.XUnit/Atc.XUnit.csproj +++ b/src/Atc.XUnit/Atc.XUnit.csproj @@ -13,7 +13,7 @@ - + NU1701 diff --git a/src/Atc.XUnit/CodeComplianceDocumentationHelper.cs b/src/Atc.XUnit/CodeComplianceDocumentationHelper.cs index ac731a30..ff38a1ba 100644 --- a/src/Atc.XUnit/CodeComplianceDocumentationHelper.cs +++ b/src/Atc.XUnit/CodeComplianceDocumentationHelper.cs @@ -23,6 +23,53 @@ public static void AssertExportedTypeWithMissingComments(Type type) TestResultHelper.AssertOnTestResults(testResults); } + /// + /// Asserts that all exported types in an assembly have XML documentation comments, + /// using an explicit XML documentation file path instead of relying on automatic path resolution. + /// Use this overload when the XML documentation file is not located next to the assembly or in + /// base directory. + /// + /// The assembly to validate. + /// The explicit path to the XML documentation file for . + /// Optional list of types to exclude from validation. + /// Thrown when or is null. + public static void AssertExportedTypesWithMissingComments( + Assembly assembly, + FileInfo xmlDocPath, + List? excludeTypes = null) + { + if (assembly is null) + { + throw new ArgumentNullException(nameof(assembly)); + } + + if (xmlDocPath is null) + { + throw new ArgumentNullException(nameof(xmlDocPath)); + } + + // Due to some build issue with GenerateDocumentationFile=true and xml-file location, this hack is made for now. + if (!OperatingSystem.IsWindows()) + { + return; + } + + var typesWithMissingCommentsGroups = DocumentationHelper + .CollectExportedTypesWithMissingCommentsFromAssembly(assembly, xmlDocPath, excludeTypes) + .OrderBy(x => x.Type.FullName, StringComparer.Ordinal) + .GroupBy(x => x.Type.BeautifyName(useFullName: true), StringComparer.Ordinal) + .ToArray(); + + var testResults = new List + { + new($"Assembly: {assembly.GetName()}"), + }; + + testResults.AddRange(typesWithMissingCommentsGroups.Select(item => new TestResult(isError: false, 1, $"Type: {item.Key}"))); + + TestResultHelper.AssertOnTestResults(testResults); + } + /// /// Asserts that all exported types in an assembly have XML documentation comments. /// Fails the test if any types are missing documentation. diff --git a/src/Atc.XUnit/CodeComplianceTestHelper.cs b/src/Atc.XUnit/CodeComplianceTestHelper.cs index 1b2f85f0..701596b0 100644 --- a/src/Atc.XUnit/CodeComplianceTestHelper.cs +++ b/src/Atc.XUnit/CodeComplianceTestHelper.cs @@ -13,11 +13,13 @@ public static class CodeComplianceTestHelper /// The source type to validate for test coverage. /// The test type containing unit tests for the source type. /// If set to true, use full type names in output. + /// A token to cancel the analysis operation. public static void AssertExportedMethodsWithMissingTests( DecompilerType decompilerType, Type sourceType, Type testType, - bool useFullName = false) + bool useFullName = false, + CancellationToken cancellationToken = default) { if (sourceType is null) { @@ -32,7 +34,8 @@ public static void AssertExportedMethodsWithMissingTests( var methodsWithMissingTests = AssemblyTestHelper.CollectExportedMethodsWithMissingTests( decompilerType, sourceType, - testType); + testType, + cancellationToken); TestResultHelper.AssertOnTestResultsFromMethodsWithMissingTests( sourceType.Assembly.GetName().Name!, methodsWithMissingTests, @@ -47,11 +50,13 @@ public static void AssertExportedMethodsWithMissingTests( /// The source type to validate for test coverage. /// The test assembly to search for unit tests. /// If set to true, use full type names in output. + /// A token to cancel the analysis operation. public static void AssertExportedMethodsWithMissingTests( DecompilerType decompilerType, Type sourceType, Assembly testAssembly, - bool useFullName = false) + bool useFullName = false, + CancellationToken cancellationToken = default) { if (sourceType is null) { @@ -61,7 +66,8 @@ public static void AssertExportedMethodsWithMissingTests( var methodsWithMissingTests = AssemblyTestHelper.CollectExportedMethodsWithMissingTests( decompilerType, sourceType, - testAssembly); + testAssembly, + cancellationToken); TestResultHelper.AssertOnTestResultsFromMethodsWithMissingTests( sourceType.Assembly.GetName().Name!, methodsWithMissingTests, @@ -77,12 +83,14 @@ public static void AssertExportedMethodsWithMissingTests( /// The test assembly containing unit tests. /// Optional list of source types to exclude from validation. /// If set to true, use full type names in output. + /// A token to cancel the analysis operation. public static void AssertExportedMethodsWithMissingTests( DecompilerType decompilerType, Assembly sourceAssembly, Assembly testAssembly, List? excludeSourceTypes = null, - bool useFullName = false) + bool useFullName = false, + CancellationToken cancellationToken = default) { if (sourceAssembly is null) { @@ -98,7 +106,8 @@ public static void AssertExportedMethodsWithMissingTests( decompilerType, sourceAssembly, testAssembly, - excludeSourceTypes); + excludeSourceTypes, + cancellationToken); TestResultHelper.AssertOnTestResultsFromMethodsWithMissingTests( sourceAssembly.GetName().Name!, methodsWithMissingTests, @@ -112,12 +121,14 @@ public static void AssertExportedMethodsWithMissingTests( /// The source assembly to analyze. /// The test assembly to search for unit tests. /// Optional list of source types to exclude from analysis. + /// A token to cancel the analysis operation. /// An array of types that have methods missing test coverage. public static Type[] CollectExportedTypesWithMissingTests( DecompilerType decompilerType, Assembly sourceAssembly, Assembly testAssembly, - List? excludeSourceTypes = null) + List? excludeSourceTypes = null, + CancellationToken cancellationToken = default) { if (sourceAssembly is null) { @@ -133,7 +144,8 @@ public static Type[] CollectExportedTypesWithMissingTests( decompilerType, sourceAssembly, testAssembly, - excludeSourceTypes); + excludeSourceTypes, + cancellationToken); } /// @@ -145,13 +157,15 @@ public static Type[] CollectExportedTypesWithMissingTests( /// The test assembly to search for unit tests. /// Optional list of source types to exclude from analysis. /// If set to true, use full type names in output. + /// A token to cancel the analysis operation. /// A formatted C# code snippet containing a list of typeof() expressions for types missing tests. public static string CollectExportedTypesWithMissingTestsAndGenerateText( DecompilerType decompilerType, Assembly sourceAssembly, Assembly testAssembly, List? excludeSourceTypes = null, - bool useFullName = false) + bool useFullName = false, + CancellationToken cancellationToken = default) { if (sourceAssembly is null) { @@ -167,7 +181,8 @@ public static string CollectExportedTypesWithMissingTestsAndGenerateText( decompilerType, sourceAssembly, testAssembly, - excludeSourceTypes); + excludeSourceTypes, + cancellationToken); var sb = new StringBuilder(); sb.AppendLine(12, "var excludeTypes = new List"); @@ -203,12 +218,14 @@ public static string CollectExportedTypesWithMissingTestsAndGenerateText( /// The source assembly to analyze. /// The test assembly to search for unit tests. /// Optional list of source types to exclude from analysis. + /// A token to cancel the analysis operation. /// An array of objects representing methods missing test coverage. public static MethodInfo[] CollectExportedMethodsWithMissingTestsFromAssembly( DecompilerType decompilerType, Assembly sourceAssembly, Assembly testAssembly, - List? excludeSourceTypes = null) + List? excludeSourceTypes = null, + CancellationToken cancellationToken = default) { if (sourceAssembly is null) { @@ -220,7 +237,7 @@ public static MethodInfo[] CollectExportedMethodsWithMissingTestsFromAssembly( throw new ArgumentNullException(nameof(testAssembly)); } - return AssemblyTestHelper.CollectExportedMethodsWithMissingTests(decompilerType, sourceAssembly, testAssembly, excludeSourceTypes); + return AssemblyTestHelper.CollectExportedMethodsWithMissingTests(decompilerType, sourceAssembly, testAssembly, excludeSourceTypes, cancellationToken); } /// @@ -231,13 +248,15 @@ public static MethodInfo[] CollectExportedMethodsWithMissingTestsFromAssembly( /// The test assembly to search for unit tests. /// Optional list of source types to exclude from analysis. /// If set to true, use full type names in output. + /// A token to cancel the analysis operation. /// An array of strings containing beautified method signatures. public static string[] CollectExportedMethodsWithMissingTestsAndGenerateTextLines( DecompilerType decompilerType, Assembly sourceAssembly, Assembly testAssembly, List? excludeSourceTypes = null, - bool useFullName = false) + bool useFullName = false, + CancellationToken cancellationToken = default) { if (sourceAssembly is null) { @@ -249,7 +268,7 @@ public static string[] CollectExportedMethodsWithMissingTestsAndGenerateTextLine throw new ArgumentNullException(nameof(testAssembly)); } - var methodsWithMissingTests = AssemblyTestHelper.CollectExportedMethodsWithMissingTests(decompilerType, sourceAssembly, testAssembly, excludeSourceTypes); + var methodsWithMissingTests = AssemblyTestHelper.CollectExportedMethodsWithMissingTests(decompilerType, sourceAssembly, testAssembly, excludeSourceTypes, cancellationToken); return AssemblyTestHelper.GetMethodsAsRenderTextLines(methodsWithMissingTests, useFullName); } @@ -261,13 +280,15 @@ public static string[] CollectExportedMethodsWithMissingTestsAndGenerateTextLine /// The test assembly to search for unit tests. /// Optional list of source types to exclude from analysis. /// If set to true, use full type names in output. + /// A token to cancel the analysis operation. /// A multi-line string containing all method signatures missing tests. public static string CollectExportedMethodsWithMissingTestsAndGenerateText( DecompilerType decompilerType, Assembly sourceAssembly, Assembly testAssembly, List? excludeSourceTypes = null, - bool useFullName = false) + bool useFullName = false, + CancellationToken cancellationToken = default) { if (sourceAssembly is null) { @@ -279,22 +300,24 @@ public static string CollectExportedMethodsWithMissingTestsAndGenerateText( throw new ArgumentNullException(nameof(testAssembly)); } - var methodsWithMissingTests = AssemblyTestHelper.CollectExportedMethodsWithMissingTests(decompilerType, sourceAssembly, testAssembly, excludeSourceTypes); + var methodsWithMissingTests = AssemblyTestHelper.CollectExportedMethodsWithMissingTests(decompilerType, sourceAssembly, testAssembly, excludeSourceTypes, cancellationToken); return AssemblyTestHelper.GetMethodsAsRenderText(methodsWithMissingTests, useFullName); } /// - /// Collects exported methods with missing tests and exports them to an Excel file at C:\Temp. + /// Collects exported methods with missing tests and exports them to an Excel file at the system temp directory. /// /// The to use for analyzing test method bodies. /// The source assembly to analyze. /// The test assembly to search for unit tests. /// Optional list of source types to exclude from analysis. + /// A token to cancel the analysis operation. public static void CollectExportedMethodsWithMissingTestsToExcel( DecompilerType decompilerType, Assembly sourceAssembly, Assembly testAssembly, - List? excludeSourceTypes = null) + List? excludeSourceTypes = null, + CancellationToken cancellationToken = default) { if (sourceAssembly is null) { @@ -308,10 +331,11 @@ public static void CollectExportedMethodsWithMissingTestsToExcel( CollectExportedMethodsWithMissingTestsToExcel( decompilerType, - new DirectoryInfo(@"C:\Temp"), + new DirectoryInfo(Path.GetTempPath()), sourceAssembly, testAssembly, - excludeSourceTypes); + excludeSourceTypes, + cancellationToken); } /// @@ -322,12 +346,14 @@ public static void CollectExportedMethodsWithMissingTestsToExcel( /// The source assembly to analyze. /// The test assembly to search for unit tests. /// Optional list of source types to exclude from analysis. + /// A token to cancel the analysis operation. public static void CollectExportedMethodsWithMissingTestsToExcel( DecompilerType decompilerType, DirectoryInfo reportDirectory, Assembly sourceAssembly, Assembly testAssembly, - List? excludeSourceTypes = null) + List? excludeSourceTypes = null, + CancellationToken cancellationToken = default) { if (reportDirectory is null) { @@ -348,7 +374,8 @@ public static void CollectExportedMethodsWithMissingTestsToExcel( decompilerType, sourceAssembly, testAssembly, - excludeSourceTypes); + excludeSourceTypes, + cancellationToken); TestResultHelper.ToExcelTestResultsFromMethodsWithMissingTests( reportDirectory, sourceAssembly.GetName().Name!, diff --git a/src/Atc.XUnit/GlobalUsings.cs b/src/Atc.XUnit/GlobalUsings.cs index 6f2ece8a..8438af06 100644 --- a/src/Atc.XUnit/GlobalUsings.cs +++ b/src/Atc.XUnit/GlobalUsings.cs @@ -1,4 +1,5 @@ global using System.Collections; +global using System.Collections.Concurrent; global using System.Diagnostics; global using System.Diagnostics.CodeAnalysis; global using System.Globalization; diff --git a/src/Atc.XUnit/IntegrationTestCliBase.cs b/src/Atc.XUnit/IntegrationTestCliBase.cs index b71037e4..e7015daa 100644 --- a/src/Atc.XUnit/IntegrationTestCliBase.cs +++ b/src/Atc.XUnit/IntegrationTestCliBase.cs @@ -261,6 +261,10 @@ private static (string CliFileNameExe, DirectoryInfo SearchFromPath) GetCliFileE .BaseDirectory; var testAssemblyName = GetTestAssemblyName(); + if (string.IsNullOrEmpty(testAssemblyName)) + { + return (GetCliFileName(programTypeForCliExe), new DirectoryInfo(currentDomainBaseDirectory)); + } var searchFromPath = new DirectoryInfo(currentDomainBaseDirectory.Split(testAssemblyName, StringSplitOptions.RemoveEmptyEntries)[0]); if (searchFromPath.Parent is not null) diff --git a/src/Atc.XUnit/Internal/AbstractSyntaxTree/AnalyzerHelper.cs b/src/Atc.XUnit/Internal/AbstractSyntaxTree/AnalyzerHelper.cs index ddd8e06d..46616e3d 100644 --- a/src/Atc.XUnit/Internal/AbstractSyntaxTree/AnalyzerHelper.cs +++ b/src/Atc.XUnit/Internal/AbstractSyntaxTree/AnalyzerHelper.cs @@ -42,11 +42,6 @@ internal static MethodInfo[] GetSourceMethodsWithMissingTest( { methodsWithTest.Add(method); } - else if (debugLimitData is not null) - { - // Dummy for breakpoint - throw new Exception("Whoops.."); - } } } } @@ -148,7 +143,7 @@ private static bool IsMethodUsedByTestMethod( { if (method.DeclaringType is null) { - throw new Exception("method.DeclaringType is null..."); + return false; } var parameters = method.GetParameters(); diff --git a/src/Atc.XUnit/Internal/AbstractSyntaxTree/DecompilerHelper.cs b/src/Atc.XUnit/Internal/AbstractSyntaxTree/DecompilerHelper.cs index 473ed41b..d3fa4ecf 100644 --- a/src/Atc.XUnit/Internal/AbstractSyntaxTree/DecompilerHelper.cs +++ b/src/Atc.XUnit/Internal/AbstractSyntaxTree/DecompilerHelper.cs @@ -3,9 +3,19 @@ namespace Atc.XUnit.Internal.AbstractSyntaxTree; internal static class DecompilerHelper { + private static readonly ConcurrentDictionary> Cache = new(StringComparer.OrdinalIgnoreCase); + internal static CSharpDecompiler GetDecompiler(Assembly assembly) { var assemblyFileName = assembly.Location; + return Cache.GetOrAdd(assemblyFileName, static path => + new Lazy(() => CreateDecompiler(path), LazyThreadSafetyMode.ExecutionAndPublication)).Value; + } + + private static CSharpDecompiler CreateDecompiler(string assemblyFileName) + { + // PEFile is used only to validate the assembly path; the resolver is the long-lived handle. + // Close the validation PEFile immediately to avoid keeping the native handle open. using var module = new PEFile(assemblyFileName); var resolver = new UniversalAssemblyResolver(assemblyFileName, false, targetFramework: null); return new CSharpDecompiler(assemblyFileName, resolver, GetSettings()); @@ -24,6 +34,11 @@ internal static Tuple[] GetTestMethodsWithDecompi var testMethods = new List>(); foreach ((Type testType, MethodInfo[] testMethodInfos) in testTypeMethods) { + if (testType.FullName is null) + { + continue; + } + var fullTypeName = new FullTypeName(testType.FullName); var syntaxTree = decompiler.DecompileType(fullTypeName); var astNodes = syntaxTree diff --git a/src/Atc.XUnit/Internal/AbstractSyntaxTree/ParametersNamingMatchHelper.cs b/src/Atc.XUnit/Internal/AbstractSyntaxTree/ParametersNamingMatchHelper.cs index 0ed0b6f6..3b70053a 100644 --- a/src/Atc.XUnit/Internal/AbstractSyntaxTree/ParametersNamingMatchHelper.cs +++ b/src/Atc.XUnit/Internal/AbstractSyntaxTree/ParametersNamingMatchHelper.cs @@ -252,7 +252,12 @@ private static bool ParameterCheckForMemberReferenceExpression( return true; } - if (astNodeTypeName.Contains("Constants", StringComparison.Ordinal)) + // Only auto-pass a TypeReferenceExpression for a constants type when the parameter + // itself is of type System.Type (i.e., the test passes typeof(SomeConstants)). + // The previous broad Contains("Constants") check caused any test referencing a + // *Constants* type to be silently marked as covered, causing false negatives. + if (astNodeTypeName.EndsWith("Constants", StringComparison.Ordinal) && + parameter.ParameterType == typeof(Type)) { return true; } diff --git a/src/Atc.XUnit/Internal/AssemblyAnalyzerHelper.cs b/src/Atc.XUnit/Internal/AssemblyAnalyzerHelper.cs index 60ff4a5f..0904b98f 100644 --- a/src/Atc.XUnit/Internal/AssemblyAnalyzerHelper.cs +++ b/src/Atc.XUnit/Internal/AssemblyAnalyzerHelper.cs @@ -164,7 +164,13 @@ private static Type[] CollectFilteredAssemblyTypes( var className = classType.Name.Replace("Extensions", string.Empty, StringComparison.Ordinal); var classNamePrefixSimplified = GetSimpleTypeName(className); - var firstParameterType = method.GetParameters()[0].ParameterType; + var parameters = method.GetParameters(); + if (parameters.Length == 0) + { + return "Extension method has no parameters."; + } + + var firstParameterType = parameters[0].ParameterType; var firstParameterNameSimplified = GetSimpleTypeName(firstParameterType); if (classNamePrefixSimplified.Equals(firstParameterNameSimplified, StringComparison.Ordinal) || ("I" + classNamePrefixSimplified).Equals(firstParameterNameSimplified, StringComparison.Ordinal) || diff --git a/src/Atc.XUnit/Internal/AssemblyTestHelper.cs b/src/Atc.XUnit/Internal/AssemblyTestHelper.cs index f379ba74..34e9f27b 100644 --- a/src/Atc.XUnit/Internal/AssemblyTestHelper.cs +++ b/src/Atc.XUnit/Internal/AssemblyTestHelper.cs @@ -25,9 +25,10 @@ internal static Type[] CollectExportedTypesWithMissingTests( DecompilerType decompilerType, Assembly sourceAssembly, Assembly testAssembly, - List? excludeSourceTypes) + List? excludeSourceTypes, + CancellationToken cancellationToken = default) { - var methodsWithMissingTests = CollectExportedMethodsWithMissingTests(decompilerType, sourceAssembly, testAssembly, excludeSourceTypes); + var methodsWithMissingTests = CollectExportedMethodsWithMissingTests(decompilerType, sourceAssembly, testAssembly, excludeSourceTypes, cancellationToken); var methodsWithMissingTestsGroups = methodsWithMissingTests .OrderBy(x => x.DeclaringType?.FullName, StringComparer.Ordinal) @@ -43,7 +44,8 @@ internal static Type[] CollectExportedTypesWithMissingTests( internal static MethodInfo[] CollectExportedMethodsWithMissingTests( DecompilerType decompilerType, Type sourceType, - Type testType) + Type testType, + CancellationToken cancellationToken = default) { if (sourceType is null) { @@ -55,6 +57,8 @@ internal static MethodInfo[] CollectExportedMethodsWithMissingTests( throw new ArgumentNullException(nameof(testType)); } + cancellationToken.ThrowIfCancellationRequested(); + var sourceTypes = new[] { sourceType }; var testTypes = new[] { testType }; @@ -76,7 +80,8 @@ internal static MethodInfo[] CollectExportedMethodsWithMissingTests( internal static MethodInfo[] CollectExportedMethodsWithMissingTests( DecompilerType decompilerType, Type sourceType, - Assembly testAssembly) + Assembly testAssembly, + CancellationToken cancellationToken = default) { if (sourceType is null) { @@ -88,6 +93,8 @@ internal static MethodInfo[] CollectExportedMethodsWithMissingTests( throw new ArgumentNullException(nameof(testAssembly)); } + cancellationToken.ThrowIfCancellationRequested(); + var sourceTypes = new[] { sourceType }; var testTypes = testAssembly.ExportedTypes.ToArray(); @@ -111,7 +118,8 @@ internal static MethodInfo[] CollectExportedMethodsWithMissingTests( DecompilerType decompilerType, Assembly sourceAssembly, Assembly testAssembly, - List? excludeSourceTypes) + List? excludeSourceTypes, + CancellationToken cancellationToken = default) { if (sourceAssembly is null) { @@ -123,6 +131,8 @@ internal static MethodInfo[] CollectExportedMethodsWithMissingTests( throw new ArgumentNullException(nameof(testAssembly)); } + cancellationToken.ThrowIfCancellationRequested(); + var sourceTypes = sourceAssembly.ExportedTypes .Where(x => !x.IsInterface && !x.IsNested && @@ -142,6 +152,8 @@ internal static MethodInfo[] CollectExportedMethodsWithMissingTests( .ToArray(); } + cancellationToken.ThrowIfCancellationRequested(); + var testTypes = testAssembly.ExportedTypes.ToArray(); var testTypeMethods = TypeAndMethodAndParameterHelper.GetTypeMethodsWithTestAttributes(testTypes); switch (decompilerType) diff --git a/src/Atc.XUnit/Internal/MonoReflection/AnalyzerHelper.cs b/src/Atc.XUnit/Internal/MonoReflection/AnalyzerHelper.cs index e5d93143..17de035f 100644 --- a/src/Atc.XUnit/Internal/MonoReflection/AnalyzerHelper.cs +++ b/src/Atc.XUnit/Internal/MonoReflection/AnalyzerHelper.cs @@ -40,11 +40,6 @@ public static MethodInfo[] GetSourceMethodsWithMissingTest( { methodsWithTest.Add(method); } - else if (debugLimitData is not null) - { - // Dummy for breakpoint - throw new Exception("Ups.."); - } } } } @@ -70,7 +65,7 @@ internal static MethodInfo[] GetUsedSourceMethods( Type[] sourceTypes, Tuple[] testTypeMethods) { - var list = new List(); + var set = new HashSet(); foreach (var tuple in testTypeMethods) { foreach (var method in tuple.Item2) @@ -78,12 +73,12 @@ internal static MethodInfo[] GetUsedSourceMethods( var instructions = method.GetInstructions(); foreach (var instruction in instructions) { - ProcessInstruction(instruction, sourceTypes, list); + ProcessInstruction(instruction, sourceTypes, set); } } } - return list + return set .OrderBy(x => x.DeclaringType?.Name, StringComparer.Ordinal) .ThenBy(x => x.Name, StringComparer.Ordinal) .ToArray(); @@ -92,7 +87,7 @@ internal static MethodInfo[] GetUsedSourceMethods( private static void ProcessInstruction( Instruction instruction, Type[] sourceTypes, - List list) + HashSet set) { if (instruction.Operand is not MethodInfo usedMethodInTest) { @@ -111,9 +106,9 @@ private static void ProcessInstruction( { // Try to find the original method from the state machine var originalMethod = TryGetOriginalMethodFromStateMachine(type, sourceTypes); - if (originalMethod is not null && !list.Contains(originalMethod)) + if (originalMethod is not null) { - list.Add(originalMethod); + set.Add(originalMethod); return; } } @@ -135,10 +130,7 @@ private static void ProcessInstruction( ? usedMethodInTest.GetGenericMethodDefinition() : usedMethodInTest; - if (!list.Contains(methodToAdd)) - { - list.Add(methodToAdd); - } + set.Add(methodToAdd); } [SuppressMessage("Performance", "MA0009:Regular expressions should not be vulnerable to Denial of Service attacks", Justification = "OK - Simple pattern for compiler-generated names")] diff --git a/src/Atc/Atc.csproj b/src/Atc/Atc.csproj index 441491fa..f599855c 100644 --- a/src/Atc/Atc.csproj +++ b/src/Atc/Atc.csproj @@ -9,13 +9,13 @@ - + all runtime; build; native; contentfiles; analyzers; buildtransitive - + - + @@ -76,7 +76,7 @@ - + \ No newline at end of file diff --git a/src/Atc/Collections/ConcurrentHashSet.cs b/src/Atc/Collections/ConcurrentHashSet.cs index 67ffc979..8af6eb0e 100644 --- a/src/Atc/Collections/ConcurrentHashSet.cs +++ b/src/Atc/Collections/ConcurrentHashSet.cs @@ -35,19 +35,23 @@ public int Count } /// + /// + /// Returns an enumerator over a point-in-time snapshot taken under a read lock, + /// so iteration is safe even if the set is mutated concurrently afterwards. + /// public IEnumerator GetEnumerator() { - readerWriterLock.EnterWriteLock(); + readerWriterLock.EnterReadLock(); try { - return hashSet.GetEnumerator(); + return hashSet.ToList().GetEnumerator(); } finally { - if (readerWriterLock.IsWriteLockHeld) + if (readerWriterLock.IsReadLockHeld) { - readerWriterLock.ExitWriteLock(); + readerWriterLock.ExitReadLock(); } } } diff --git a/src/Atc/Comparers/ByteArrayEqualityComparer.cs b/src/Atc/Comparers/ByteArrayEqualityComparer.cs index 7de8c49a..1329beca 100644 --- a/src/Atc/Comparers/ByteArrayEqualityComparer.cs +++ b/src/Atc/Comparers/ByteArrayEqualityComparer.cs @@ -35,6 +35,7 @@ public bool Equals( return false; } +#if NETSTANDARD2_0 for (var i = 0; i < x.Length; i++) { if (x[i] != y[i]) @@ -44,6 +45,9 @@ public bool Equals( } return true; +#else + return x.AsSpan().SequenceEqual(y); +#endif } /// diff --git a/src/Atc/Comparers/NumericAlphaComparer.cs b/src/Atc/Comparers/NumericAlphaComparer.cs index 1e9939b3..bacdb496 100644 --- a/src/Atc/Comparers/NumericAlphaComparer.cs +++ b/src/Atc/Comparers/NumericAlphaComparer.cs @@ -111,7 +111,7 @@ private static string ExtractLetters(string value) .Replace(".", string.Empty, StringComparison.Ordinal) .Replace(",", string.Empty, StringComparison.Ordinal); return value - .Replace(ExtractNumber(value).ToString(Thread.CurrentThread.CurrentCulture), string.Empty, StringComparison.Ordinal) + .Replace(ExtractNumber(value).ToString(GlobalizationConstants.EnglishCultureInfo), string.Empty, StringComparison.Ordinal) .Trim(); } } \ No newline at end of file diff --git a/src/Atc/Data/AssemblyInformationFactory.cs b/src/Atc/Data/AssemblyInformationFactory.cs index b3406ae7..ea3d3f66 100644 --- a/src/Atc/Data/AssemblyInformationFactory.cs +++ b/src/Atc/Data/AssemblyInformationFactory.cs @@ -65,7 +65,7 @@ private static bool IsAssemblyCompliedToDebug( return false; } - return attributes[0] is DebuggableAttribute { IsJITTrackingEnabled: true }; + return attributes[0] is DebuggableAttribute { IsJITOptimizerDisabled: true }; } catch (IOException) { diff --git a/src/Atc/Data/Models/LogItem.cs b/src/Atc/Data/Models/LogItem.cs index 15949dc4..8db987d2 100644 --- a/src/Atc/Data/Models/LogItem.cs +++ b/src/Atc/Data/Models/LogItem.cs @@ -11,7 +11,7 @@ public class LogItem /// public LogItem() { - TimeStamp = DateTime.Now; + TimeStamp = DateTime.UtcNow; Severity = LogCategoryType.Information; Message = string.Empty; } diff --git a/src/Atc/Data/SemVer/Identifier.cs b/src/Atc/Data/SemVer/Identifier.cs index 0ce1135b..92edc9aa 100644 --- a/src/Atc/Data/SemVer/Identifier.cs +++ b/src/Atc/Data/SemVer/Identifier.cs @@ -43,7 +43,7 @@ private void SetNumeric() { if (!int.TryParse( Value, - NumberStyles.Any, + NumberStyles.None, GlobalizationConstants.EnglishCultureInfo, out var x)) { diff --git a/src/Atc/Data/SemVer/SemanticVersion.cs b/src/Atc/Data/SemVer/SemanticVersion.cs index 32464123..1dcfda48 100644 --- a/src/Atc/Data/SemVer/SemanticVersion.cs +++ b/src/Atc/Data/SemVer/SemanticVersion.cs @@ -9,7 +9,11 @@ namespace Atc.Data.SemVer; /// Represents a version object, compliant with the Semantic Version standard 2.0 (http://semver.org). /// [Serializable] -public sealed class SemanticVersion : IComparable, IComparable, IEquatable +#if NET7_0_OR_GREATER +public sealed class SemanticVersion : IComparable, IComparable, IEquatable, IFormattable, ISpanParsable +#else +public sealed class SemanticVersion : IComparable, IComparable, IEquatable, IFormattable +#endif { private static readonly Regex StrictModeRegex = new( @"^ @@ -107,7 +111,7 @@ public SemanticVersion( if (looseMode && !string.IsNullOrEmpty(PreRelease) && string.IsNullOrEmpty(Build) && - PreRelease.StartsWith('.') && + PreRelease.StartsWith(".", StringComparison.Ordinal) && int.TryParse( PreRelease.Replace(".", string.Empty, StringComparison.Ordinal), NumberStyles.Any, @@ -457,6 +461,18 @@ public override string ToString() return $"{Major}.{Minor}.{Patch}{preReleaseString}{buildString}"; } + /// + /// Formats the value of the current instance using the specified format. + /// Both parameters are ignored; uses a fixed, culture-invariant format. + /// + /// Format specifier — not used by this type. + /// Culture provider — not used by this type. + /// The string representation of the current instance. + public string ToString( + string? format, + IFormatProvider? formatProvider) + => ToString(); + /// /// Converts to based on Major, Minor and Patch. /// @@ -475,6 +491,72 @@ public Version ToVersion() return new(Major, Minor, Patch); } +#if NET7_0_OR_GREATER + /// + /// Parses a string into a . + /// + /// The string to parse. + /// Ignored; parsing is culture-invariant. + /// The parsed . + /// Thrown when is not a valid semantic version string. + static SemanticVersion IParsable.Parse( + string s, + IFormatProvider? provider) + => Parse(s); + + /// + /// Tries to parse a string into a . + /// + /// The string to parse, or . + /// Ignored; parsing is culture-invariant. + /// + /// When this method returns, contains the parsed , + /// or if parsing fails. + /// + /// if parsing succeeded; otherwise, . + static bool IParsable.TryParse( + string? s, + IFormatProvider? provider, + [NotNullWhen(true)] out SemanticVersion? result) + { + if (s is null) + { + result = null; + return false; + } + + return TryParse(s, out result); + } + + /// + /// Parses a character span into a . + /// + /// The character span to parse. + /// Ignored; parsing is culture-invariant. + /// The parsed . + /// Thrown when is not a valid semantic version string. + static SemanticVersion ISpanParsable.Parse( + ReadOnlySpan s, + IFormatProvider? provider) + => Parse(s.ToString()); + + /// + /// Tries to parse a character span into a . + /// + /// The character span to parse. + /// Ignored; parsing is culture-invariant. + /// + /// When this method returns, contains the parsed , + /// or if parsing fails. + /// + /// if parsing succeeded; otherwise, . + static bool ISpanParsable.TryParse( + ReadOnlySpan s, + IFormatProvider? provider, + [NotNullWhen(true)] out SemanticVersion? result) + => TryParse(s.ToString(), out result); +#endif + public static bool operator ==( SemanticVersion? a, SemanticVersion? b) diff --git a/src/Atc/Exceptions/ArgumentNullOrDefaultException.cs b/src/Atc/Exceptions/ArgumentNullOrDefaultException.cs index c335b3d0..a3429ac2 100644 --- a/src/Atc/Exceptions/ArgumentNullOrDefaultException.cs +++ b/src/Atc/Exceptions/ArgumentNullOrDefaultException.cs @@ -64,7 +64,11 @@ public ArgumentNullOrDefaultException( private ArgumentNullOrDefaultException( SerializationInfo serializationInfo, StreamingContext streamingContext) +#if NETSTANDARD2_0 + : base(serializationInfo, streamingContext) +#else : base(ExceptionMessage) +#endif { } } \ No newline at end of file diff --git a/src/Atc/Exceptions/ArgumentNullOrDefaultPropertyException.cs b/src/Atc/Exceptions/ArgumentNullOrDefaultPropertyException.cs index 26e88e75..fb6b8100 100644 --- a/src/Atc/Exceptions/ArgumentNullOrDefaultPropertyException.cs +++ b/src/Atc/Exceptions/ArgumentNullOrDefaultPropertyException.cs @@ -64,7 +64,11 @@ public ArgumentNullOrDefaultPropertyException( private ArgumentNullOrDefaultPropertyException( SerializationInfo serializationInfo, StreamingContext streamingContext) +#if NETSTANDARD2_0 + : base(serializationInfo, streamingContext) +#else : base(ExceptionMessage) +#endif { } } \ No newline at end of file diff --git a/src/Atc/Exceptions/ArgumentNullPropertyException.cs b/src/Atc/Exceptions/ArgumentNullPropertyException.cs index 97081e9e..e6174ea9 100644 --- a/src/Atc/Exceptions/ArgumentNullPropertyException.cs +++ b/src/Atc/Exceptions/ArgumentNullPropertyException.cs @@ -64,7 +64,11 @@ public ArgumentNullPropertyException( private ArgumentNullPropertyException( SerializationInfo serializationInfo, StreamingContext streamingContext) +#if NETSTANDARD2_0 + : base(serializationInfo, streamingContext) +#else : base(ExceptionMessage) +#endif { } } \ No newline at end of file diff --git a/src/Atc/Exceptions/ArgumentPropertyException.cs b/src/Atc/Exceptions/ArgumentPropertyException.cs index 0e0a7171..f3398d84 100644 --- a/src/Atc/Exceptions/ArgumentPropertyException.cs +++ b/src/Atc/Exceptions/ArgumentPropertyException.cs @@ -64,7 +64,11 @@ public ArgumentPropertyException( private ArgumentPropertyException( SerializationInfo serializationInfo, StreamingContext streamingContext) +#if NETSTANDARD2_0 + : base(serializationInfo, streamingContext) +#else : base(ExceptionMessage) +#endif { } } \ No newline at end of file diff --git a/src/Atc/Exceptions/ArgumentPropertyNullException.cs b/src/Atc/Exceptions/ArgumentPropertyNullException.cs index 1dd4e35e..e2ec0670 100644 --- a/src/Atc/Exceptions/ArgumentPropertyNullException.cs +++ b/src/Atc/Exceptions/ArgumentPropertyNullException.cs @@ -64,7 +64,11 @@ public ArgumentPropertyNullException( private ArgumentPropertyNullException( SerializationInfo serializationInfo, StreamingContext streamingContext) +#if NETSTANDARD2_0 + : base(serializationInfo, streamingContext) +#else : base(ExceptionMessage) +#endif { } } \ No newline at end of file diff --git a/src/Atc/Exceptions/CertificateValidationException.cs b/src/Atc/Exceptions/CertificateValidationException.cs index 9c934a7e..a3ea499e 100644 --- a/src/Atc/Exceptions/CertificateValidationException.cs +++ b/src/Atc/Exceptions/CertificateValidationException.cs @@ -47,7 +47,11 @@ public CertificateValidationException( protected CertificateValidationException( SerializationInfo serializationInfo, StreamingContext streamingContext) +#if NETSTANDARD2_0 + : base(serializationInfo, streamingContext) +#else : base(ExceptionMessage) +#endif { } } \ No newline at end of file diff --git a/src/Atc/Exceptions/ConfigurationException.cs b/src/Atc/Exceptions/ConfigurationException.cs index bc57f98c..9b4c1974 100644 --- a/src/Atc/Exceptions/ConfigurationException.cs +++ b/src/Atc/Exceptions/ConfigurationException.cs @@ -32,7 +32,7 @@ public ConfigurationException(string message) /// /// The configuration section name. /// The configuration key name. - /// A value indicating whether the configuration is missing (true) or invalid (false). + /// A value indicating whether the configuration is missing () or invalid (). public ConfigurationException( string section, string key, @@ -41,6 +41,24 @@ public ConfigurationException( { } + /// + /// Initializes a new instance of the class with a configuration section, key, status, and inner exception. + /// + /// The configuration section name. + /// The configuration key name. + /// A value indicating whether the configuration is missing () or invalid (). + /// The exception that is the cause of the current exception. + public ConfigurationException( + string section, + string key, + bool isMissing, + Exception innerException) + : base( + $"Configuration with section '{section}' and key '{key}' is {GetTermForIsMissing(isMissing)}.", + innerException) + { + } + /// /// Initializes a new instance of the class. /// @@ -53,6 +71,40 @@ public ConfigurationException( { } + /// + /// Throws a if is or empty. + /// + /// The configuration value to validate. + /// The configuration section name. + /// The configuration key name. + public static void ThrowIfMissing( + [NotNull] string? value, + string section, + string key) + { + if (value is null || value.Length == 0) + { + throw new ConfigurationException(section, key, isMissing: true); + } + } + + /// + /// Throws a if is . + /// + /// The condition that indicates the configuration value is invalid. + /// The configuration section name. + /// The configuration key name. + public static void ThrowIfInvalid( + bool condition, + string section, + string key) + { + if (condition) + { + throw new ConfigurationException(section, key, isMissing: false); + } + } + /// /// Initializes a new instance of the class with serialized data. /// @@ -61,7 +113,11 @@ public ConfigurationException( protected ConfigurationException( SerializationInfo serializationInfo, StreamingContext streamingContext) +#if NETSTANDARD2_0 + : base(serializationInfo, streamingContext) +#else : base(ExceptionMessage) +#endif { } diff --git a/src/Atc/Exceptions/DesignTimeUseOnlyException.cs b/src/Atc/Exceptions/DesignTimeUseOnlyException.cs index 3ca55c71..d06e3b58 100644 --- a/src/Atc/Exceptions/DesignTimeUseOnlyException.cs +++ b/src/Atc/Exceptions/DesignTimeUseOnlyException.cs @@ -51,7 +51,11 @@ public DesignTimeUseOnlyException( protected DesignTimeUseOnlyException( SerializationInfo serializationInfo, StreamingContext streamingContext) +#if NETSTANDARD2_0 + : base(serializationInfo, streamingContext) +#else : base(ExceptionMessage) +#endif { } } \ No newline at end of file diff --git a/src/Atc/Exceptions/EntityStoreException.cs b/src/Atc/Exceptions/EntityStoreException.cs index 4e8327d8..ad5cd7b8 100644 --- a/src/Atc/Exceptions/EntityStoreException.cs +++ b/src/Atc/Exceptions/EntityStoreException.cs @@ -47,7 +47,11 @@ public EntityStoreException( protected EntityStoreException( SerializationInfo serializationInfo, StreamingContext streamingContext) +#if NETSTANDARD2_0 + : base(serializationInfo, streamingContext) +#else : base(ExceptionMessage) +#endif { } } \ No newline at end of file diff --git a/src/Atc/Exceptions/ItemNotFoundException.cs b/src/Atc/Exceptions/ItemNotFoundException.cs index 64882f46..3b28f1fd 100644 --- a/src/Atc/Exceptions/ItemNotFoundException.cs +++ b/src/Atc/Exceptions/ItemNotFoundException.cs @@ -47,7 +47,11 @@ public ItemNotFoundException( protected ItemNotFoundException( SerializationInfo serializationInfo, StreamingContext streamingContext) +#if NETSTANDARD2_0 + : base(serializationInfo, streamingContext) +#else : base(ExceptionMessage) +#endif { } } \ No newline at end of file diff --git a/src/Atc/Exceptions/NullException.cs b/src/Atc/Exceptions/NullException.cs index a105e597..300d2f77 100644 --- a/src/Atc/Exceptions/NullException.cs +++ b/src/Atc/Exceptions/NullException.cs @@ -47,7 +47,11 @@ public NullException( protected NullException( SerializationInfo serializationInfo, StreamingContext streamingContext) +#if NETSTANDARD2_0 + : base(serializationInfo, streamingContext) +#else : base(ExceptionMessage) +#endif { } } \ No newline at end of file diff --git a/src/Atc/Exceptions/PermissionException.cs b/src/Atc/Exceptions/PermissionException.cs index 6074820d..b38d34dd 100644 --- a/src/Atc/Exceptions/PermissionException.cs +++ b/src/Atc/Exceptions/PermissionException.cs @@ -47,7 +47,11 @@ public PermissionException( protected PermissionException( SerializationInfo serializationInfo, StreamingContext streamingContext) +#if NETSTANDARD2_0 + : base(serializationInfo, streamingContext) +#else : base(ExceptionMessage) +#endif { } } \ No newline at end of file diff --git a/src/Atc/Exceptions/StringNullOrEmptyException.cs b/src/Atc/Exceptions/StringNullOrEmptyException.cs index 7db94ffe..160dbdc1 100644 --- a/src/Atc/Exceptions/StringNullOrEmptyException.cs +++ b/src/Atc/Exceptions/StringNullOrEmptyException.cs @@ -47,7 +47,11 @@ public StringNullOrEmptyException( protected StringNullOrEmptyException( SerializationInfo serializationInfo, StreamingContext streamingContext) +#if NETSTANDARD2_0 + : base(serializationInfo, streamingContext) +#else : base(ExceptionMessage) +#endif { } } \ No newline at end of file diff --git a/src/Atc/Exceptions/SwitchCaseDefaultException.cs b/src/Atc/Exceptions/SwitchCaseDefaultException.cs index cde401e1..3bd630e6 100644 --- a/src/Atc/Exceptions/SwitchCaseDefaultException.cs +++ b/src/Atc/Exceptions/SwitchCaseDefaultException.cs @@ -36,15 +36,9 @@ public SwitchCaseDefaultException(string message) /// /// The unexpected enum value that was encountered. /// Thrown when is null. - [SuppressMessage("Major Code Smell", "S5766:Deserializing objects without performing data validation is security-sensitive", Justification = "OK.")] public SwitchCaseDefaultException(Enum value) + : base(BuildMessage(value)) { - if (value is null) - { - throw new ArgumentNullException(nameof(value)); - } - - ReflectionHelper.SetPrivateField(this, "_message", $"Unexpected value.{Environment.NewLine}Enum name: {value.GetType().FullName}{Environment.NewLine}Enum value: {value}"); } /// @@ -53,22 +47,22 @@ public SwitchCaseDefaultException(Enum value) /// The unexpected enum value that was encountered. /// The custom error message that describes the error. /// Thrown when or is null. - [SuppressMessage("Major Code Smell", "S5766:Deserializing objects without performing data validation is security-sensitive", Justification = "OK.")] public SwitchCaseDefaultException( Enum value, string message) + : base(BuildMessage(value, message)) { - if (value is null) - { - throw new ArgumentNullException(nameof(value)); - } - - if (message is null) - { - throw new ArgumentNullException(nameof(message)); - } + } - ReflectionHelper.SetPrivateField(this, "_message", $"{message}{Environment.NewLine}Enum name: {value.GetType().FullName}{Environment.NewLine}Enum value: {value}"); + /// + /// Initializes a new instance of the class with any non-enum value. + /// + /// + /// The unexpected value that was encountered. Accepts any type, including . + /// + public SwitchCaseDefaultException(object? value) + : base(BuildMessageForObject(value)) + { } /// @@ -83,6 +77,22 @@ public SwitchCaseDefaultException( { } + /// + /// Throws a for the given enum value. + /// + /// The unexpected enum value encountered in the switch default case. + [DoesNotReturn] + public static void Throw(Enum value) + => throw new SwitchCaseDefaultException(value); + + /// + /// Throws a for the given value. + /// + /// The unexpected value encountered in the switch default case. + [DoesNotReturn] + public static void Throw(object? value) + => throw new SwitchCaseDefaultException(value); + /// /// Initializes a new instance of the class with serialized data. /// @@ -91,7 +101,43 @@ public SwitchCaseDefaultException( protected SwitchCaseDefaultException( SerializationInfo serializationInfo, StreamingContext streamingContext) +#if NETSTANDARD2_0 + : base(serializationInfo, streamingContext) +#else : base(ExceptionMessage) +#endif { } + + private static string BuildMessage(Enum value) + { + if (value is null) + { + throw new ArgumentNullException(nameof(value)); + } + + return $"Unexpected value.{Environment.NewLine}Enum name: {value.GetType().FullName}{Environment.NewLine}Enum value: {value}"; + } + + private static string BuildMessage( + Enum value, + string message) + { + if (value is null) + { + throw new ArgumentNullException(nameof(value)); + } + + if (message is null) + { + throw new ArgumentNullException(nameof(message)); + } + + return $"{message}{Environment.NewLine}Enum name: {value.GetType().FullName}{Environment.NewLine}Enum value: {value}"; + } + + private static string BuildMessageForObject(object? value) + => value is null + ? $"Unexpected value.{Environment.NewLine}Value: " + : $"Unexpected value.{Environment.NewLine}Type: {value.GetType().FullName}{Environment.NewLine}Value: {value}"; } \ No newline at end of file diff --git a/src/Atc/Exceptions/TcpException.cs b/src/Atc/Exceptions/TcpException.cs index c04ee54d..2317857b 100644 --- a/src/Atc/Exceptions/TcpException.cs +++ b/src/Atc/Exceptions/TcpException.cs @@ -47,7 +47,11 @@ public TcpException( protected TcpException( SerializationInfo serializationInfo, StreamingContext streamingContext) +#if NETSTANDARD2_0 + : base(serializationInfo, streamingContext) +#else : base(ExceptionMessage) +#endif { } } \ No newline at end of file diff --git a/src/Atc/Exceptions/UnexpectedTypeException.cs b/src/Atc/Exceptions/UnexpectedTypeException.cs index 8cb94de9..739c461b 100644 --- a/src/Atc/Exceptions/UnexpectedTypeException.cs +++ b/src/Atc/Exceptions/UnexpectedTypeException.cs @@ -33,34 +33,11 @@ public UnexpectedTypeException(string message) /// The actual type that was encountered. /// The type that was expected. /// Thrown when or is null. - [SuppressMessage("Major Code Smell", "S5766:Deserializing objects without performing data validation is security-sensitive", Justification = "OK.")] public UnexpectedTypeException( Type actualType, Type expectedType) + : base(BuildMessage(actualType, expectedType)) { - if (actualType is null) - { - throw new ArgumentNullException(nameof(actualType)); - } - - if (expectedType is null) - { - throw new ArgumentNullException(nameof(expectedType)); - } - - var actualTypeName = actualType.FullName!; - if (actualType.IsSimple()) - { - actualTypeName = actualType.BeautifyTypeName(); - } - - var expectedTypeName = expectedType.FullName!; - if (expectedType.IsSimple()) - { - expectedTypeName = expectedType.BeautifyTypeName(); - } - - ReflectionHelper.SetPrivateField(this, "_message", $"Unexpected type.{Environment.NewLine}ActualType name: {actualTypeName}{Environment.NewLine}ExpectedType name: {expectedTypeName}"); } /// @@ -70,40 +47,12 @@ public UnexpectedTypeException( /// The type that was expected. /// The custom error message that describes the error. /// Thrown when , , or is null. - [SuppressMessage("Major Code Smell", "S5766:Deserializing objects without performing data validation is security-sensitive", Justification = "OK.")] public UnexpectedTypeException( Type actualType, Type expectedType, string message) + : base(BuildMessage(actualType, expectedType, message)) { - if (actualType is null) - { - throw new ArgumentNullException(nameof(actualType)); - } - - if (expectedType is null) - { - throw new ArgumentNullException(nameof(expectedType)); - } - - if (message is null) - { - throw new ArgumentNullException(nameof(message)); - } - - var actualTypeName = actualType.FullName!; - if (actualType.IsSimple()) - { - actualTypeName = actualType.BeautifyTypeName(); - } - - var expectedTypeName = expectedType.FullName!; - if (expectedType.IsSimple()) - { - expectedTypeName = expectedType.BeautifyTypeName(); - } - - ReflectionHelper.SetPrivateField(this, "_message", $"{message}{Environment.NewLine}ActualType name: {actualTypeName}{Environment.NewLine}ExpectedType name: {expectedTypeName}"); } /// @@ -126,7 +75,54 @@ public UnexpectedTypeException( protected UnexpectedTypeException( SerializationInfo serializationInfo, StreamingContext streamingContext) +#if NETSTANDARD2_0 + : base(serializationInfo, streamingContext) +#else : base(ExceptionMessage) +#endif { } + + private static string GetTypeName(Type type) + => type.IsSimple() ? type.BeautifyTypeName() : type.FullName!; + + private static string BuildMessage( + Type actualType, + Type expectedType) + { + if (actualType is null) + { + throw new ArgumentNullException(nameof(actualType)); + } + + if (expectedType is null) + { + throw new ArgumentNullException(nameof(expectedType)); + } + + return $"Unexpected type.{Environment.NewLine}ActualType name: {GetTypeName(actualType)}{Environment.NewLine}ExpectedType name: {GetTypeName(expectedType)}"; + } + + private static string BuildMessage( + Type actualType, + Type expectedType, + string message) + { + if (actualType is null) + { + throw new ArgumentNullException(nameof(actualType)); + } + + if (expectedType is null) + { + throw new ArgumentNullException(nameof(expectedType)); + } + + if (message is null) + { + throw new ArgumentNullException(nameof(message)); + } + + return $"{message}{Environment.NewLine}ActualType name: {GetTypeName(actualType)}{Environment.NewLine}ExpectedType name: {GetTypeName(expectedType)}"; + } } \ No newline at end of file diff --git a/src/Atc/Exceptions/UserNotFoundException.cs b/src/Atc/Exceptions/UserNotFoundException.cs index c97b40ac..58ca05cc 100644 --- a/src/Atc/Exceptions/UserNotFoundException.cs +++ b/src/Atc/Exceptions/UserNotFoundException.cs @@ -47,7 +47,11 @@ public UserNotFoundException( protected UserNotFoundException( SerializationInfo serializationInfo, StreamingContext streamingContext) +#if NETSTANDARD2_0 + : base(serializationInfo, streamingContext) +#else : base(ExceptionMessage) +#endif { } } \ No newline at end of file diff --git a/src/Atc/Exceptions/ViewModelException.cs b/src/Atc/Exceptions/ViewModelException.cs index 0ed9590e..0c1a3ba9 100644 --- a/src/Atc/Exceptions/ViewModelException.cs +++ b/src/Atc/Exceptions/ViewModelException.cs @@ -47,7 +47,11 @@ public ViewModelException( protected ViewModelException( SerializationInfo serializationInfo, StreamingContext streamingContext) +#if NETSTANDARD2_0 + : base(serializationInfo, streamingContext) +#else : base(ExceptionMessage) +#endif { } } \ No newline at end of file diff --git a/src/Atc/Extensions/BaseTypes/ByteExtensions.cs b/src/Atc/Extensions/BaseTypes/ByteExtensions.cs index aa34ca53..840f9dee 100644 --- a/src/Atc/Extensions/BaseTypes/ByteExtensions.cs +++ b/src/Atc/Extensions/BaseTypes/ByteExtensions.cs @@ -29,10 +29,7 @@ public static byte[] TakeBytes( return Array.Empty(); } - return value - .Skip(startPosition) - .Take(length) - .ToArray(); + return new ReadOnlySpan(value, startPosition, length).ToArray(); } /// @@ -58,10 +55,10 @@ public static int TakeBytesAndConvertToInt( return -1; } - if (length < sizeof(int)) + if (bytes.Length < sizeof(int)) { bytes = bytes - .Concat(ByteHelper.CreateZeroArray(length)) + .Concat(ByteHelper.CreateZeroArray(sizeof(int) - bytes.Length)) .ToArray(); } @@ -95,10 +92,10 @@ public static long TakeBytesAndConvertToLong( return -1; } - if (length < sizeof(long)) + if (bytes.Length < sizeof(long)) { bytes = bytes - .Concat(ByteHelper.CreateZeroArray(length)) + .Concat(ByteHelper.CreateZeroArray(sizeof(long) - bytes.Length)) .ToArray(); } diff --git a/src/Atc/Extensions/BaseTypes/CharExtensions.cs b/src/Atc/Extensions/BaseTypes/CharExtensions.cs index b047f0e0..925acfbf 100644 --- a/src/Atc/Extensions/BaseTypes/CharExtensions.cs +++ b/src/Atc/Extensions/BaseTypes/CharExtensions.cs @@ -13,4 +13,39 @@ public static class CharExtensions /// if the character value is less than or equal to 127 (ASCII range); otherwise, . public static bool IsAscii(this char value) => value <= sbyte.MaxValue; + + /// + /// Determines whether the specified character is an ASCII letter (A–Z or a–z). + /// + /// The character to check. + /// if the character is an ASCII letter; otherwise, . + public static bool IsAsciiLetter(this char value) + => (value >= 'A' && value <= 'Z') || (value >= 'a' && value <= 'z'); + + /// + /// Determines whether the specified character is an ASCII decimal digit (0–9). + /// + /// The character to check. + /// if the character is a digit 0 through 9; otherwise, . + public static bool IsAsciiDigit(this char value) + => value >= '0' && value <= '9'; + + /// + /// Determines whether the specified character is a hexadecimal digit (0–9, A–F, a–f). + /// + /// The character to check. + /// if the character is a valid hexadecimal digit; otherwise, . + public static bool IsHexDigit(this char value) + => (value >= '0' && value <= '9') + || (value >= 'A' && value <= 'F') + || (value >= 'a' && value <= 'f'); + + /// + /// Determines whether the specified character is an ASCII vowel (A, E, I, O, U — case-insensitive). + /// + /// The character to check. + /// if the character is one of A, E, I, O, U (upper or lower case); otherwise, . + public static bool IsVowel(this char value) + => value is 'A' or 'E' or 'I' or 'O' or 'U' + or 'a' or 'e' or 'i' or 'o' or 'u'; } \ No newline at end of file diff --git a/src/Atc/Extensions/BaseTypes/DateTimeExtensions.cs b/src/Atc/Extensions/BaseTypes/DateTimeExtensions.cs index feaae93c..15e0489d 100644 --- a/src/Atc/Extensions/BaseTypes/DateTimeExtensions.cs +++ b/src/Atc/Extensions/BaseTypes/DateTimeExtensions.cs @@ -53,6 +53,15 @@ public static string GetPrettyTimeDiff( /// The date. /// The week number from the given date. public static int GetWeekNumber(this DateTime date) + => CultureInfo.CurrentCulture.Calendar.GetWeekOfYear(date, CalendarWeekRule.FirstFourDayWeek, DayOfWeek.Monday); + + /// + /// Gets the week number from a given date using the current UI culture's calendar. + /// Use this variant when rendering the week number for display in a user interface. + /// + /// The date. + /// The week number from the given date. + public static int GetWeekNumberUi(this DateTime date) => CultureInfo.CurrentUICulture.Calendar.GetWeekOfYear(date, CalendarWeekRule.FirstFourDayWeek, DayOfWeek.Monday); /// @@ -113,15 +122,13 @@ public static string ToIso8601UtcDate(this DateTime dateTime) .ToString(GlobalizationConstants.DateTimeIso8601, GlobalizationConstants.EnglishCultureInfo); /// - /// Converts a DateTime to a string using the long date pattern - /// of the current UI culture. + /// Converts a DateTime to a string using the long date pattern of the current UI culture. + /// Use this variant when rendering output for display in a user interface. /// /// The DateTime to format. - /// A string representation of the DateTime using the - /// long date pattern of the current UI culture. - public static string ToLongDateStringUsingCurrentUiCulture( - this DateTime dateTime) - => dateTime.ToLongDateString(Thread.CurrentThread.CurrentUICulture.DateTimeFormat); + /// A string representation of the DateTime using the long date pattern of the current UI culture. + public static string ToLongDateStringUi(this DateTime dateTime) + => dateTime.ToLongDateString(CultureInfo.CurrentUICulture.DateTimeFormat); /// /// Converts a DateTime to a string using the long date pattern of a specific culture. @@ -166,15 +173,13 @@ public static string ToLongDateString( } /// - /// Converts a DateTime to a string using the long time pattern - /// of the current UI culture. + /// Converts a DateTime to a string using the long time pattern of the current UI culture. + /// Use this variant when rendering output for display in a user interface. /// /// The DateTime to format. - /// A string representation of the DateTime using the - /// long time pattern of the current UI culture. - public static string ToLongTimeStringUsingCurrentUiCulture( - this DateTime dateTime) - => dateTime.ToLongTimeString(Thread.CurrentThread.CurrentUICulture.DateTimeFormat); + /// A string representation of the DateTime using the long time pattern of the current UI culture. + public static string ToLongTimeStringUi(this DateTime dateTime) + => dateTime.ToLongTimeString(CultureInfo.CurrentUICulture.DateTimeFormat); /// /// Converts a DateTime to a string using the long time pattern of a specific culture. @@ -218,15 +223,13 @@ public static string ToLongTimeString( } /// - /// Converts a DateTime to a string using the short date pattern - /// of the current UI culture. + /// Converts a DateTime to a string using the short date pattern of the current UI culture. + /// Use this variant when rendering output for display in a user interface. /// /// The DateTime to format. - /// A string representation of the DateTime using the - /// short date pattern of the current UI culture. - public static string ToShortDateStringUsingCurrentUiCulture( - this DateTime dateTime) - => dateTime.ToShortDateString(Thread.CurrentThread.CurrentUICulture.DateTimeFormat); + /// A string representation of the DateTime using the short date pattern of the current UI culture. + public static string ToShortDateStringUi(this DateTime dateTime) + => dateTime.ToShortDateString(CultureInfo.CurrentUICulture.DateTimeFormat); /// /// Converts a DateTime to a string using the short date pattern of a specific culture. @@ -270,15 +273,13 @@ public static string ToShortDateString( } /// - /// Converts a DateTime to a string using the short time pattern - /// of the current UI culture. + /// Converts a DateTime to a string using the short time pattern of the current UI culture. + /// Use this variant when rendering output for display in a user interface. /// /// The DateTime to format. - /// A string representation of the DateTime using the - /// short time pattern of the current UI culture. - public static string ToShortTimeStringUsingCurrentUiCulture( - this DateTime dateTime) - => dateTime.ToShortTimeString(Thread.CurrentThread.CurrentUICulture.DateTimeFormat); + /// A string representation of the DateTime using the short time pattern of the current UI culture. + public static string ToShortTimeStringUi(this DateTime dateTime) + => dateTime.ToShortTimeString(CultureInfo.CurrentUICulture.DateTimeFormat); /// /// Converts a DateTime to a string using the short time pattern of a specific culture. @@ -320,4 +321,48 @@ public static string ToShortTimeString( dateTimeFormatInfo.ShortTimePattern, dateTimeFormatInfo); } + + /// + /// Returns a new set to the very start of the same day (00:00:00.000). + /// The of the result matches the input. + /// + /// The date value. + /// Midnight at the start of 's date. + public static DateTime StartOfDay(this DateTime dateTime) + => dateTime.Date; + + /// + /// Returns a new set to the very end of the same day (23:59:59.9999999). + /// The of the result matches the input. + /// + /// The date value. + /// The last representable tick of 's date. + public static DateTime EndOfDay(this DateTime dateTime) + => dateTime.Date.AddDays(1).AddTicks(-1); + + /// + /// Returns a new set to the first day of the same month at midnight (00:00:00.000). + /// + /// The date value. + /// The first day of the month containing . + public static DateTime StartOfMonth(this DateTime dateTime) + => new(dateTime.Year, dateTime.Month, 1, 0, 0, 0, dateTime.Kind); + + /// + /// Returns a new set to the last tick of the last day of the same month. + /// + /// The date value. + /// The last representable tick of the last day of the month containing . + public static DateTime EndOfMonth(this DateTime dateTime) + => new DateTime(dateTime.Year, dateTime.Month, DateTime.DaysInMonth(dateTime.Year, dateTime.Month), 0, 0, 0, dateTime.Kind) + .AddDays(1) + .AddTicks(-1); + + /// + /// Determines whether the specified date falls on a Saturday or Sunday. + /// + /// The date to test. + /// if the day of the week is or ; otherwise, . + public static bool IsWeekend(this DateTime dateTime) + => dateTime.DayOfWeek is DayOfWeek.Saturday or DayOfWeek.Sunday; } \ No newline at end of file diff --git a/src/Atc/Extensions/BaseTypes/DateTimeOffsetExtensions.cs b/src/Atc/Extensions/BaseTypes/DateTimeOffsetExtensions.cs index d4355416..828e09e8 100644 --- a/src/Atc/Extensions/BaseTypes/DateTimeOffsetExtensions.cs +++ b/src/Atc/Extensions/BaseTypes/DateTimeOffsetExtensions.cs @@ -53,6 +53,15 @@ public static string GetPrettyTimeDiff( /// The date. /// The week number from the given date. public static int GetWeekNumber(this DateTimeOffset date) + => CultureInfo.CurrentCulture.Calendar.GetWeekOfYear(date.DateTime, CalendarWeekRule.FirstFourDayWeek, DayOfWeek.Monday); + + /// + /// Gets the week number from a given date using the current UI culture's calendar. + /// Use this variant when rendering the week number for display in a user interface. + /// + /// The date. + /// The week number from the given date. + public static int GetWeekNumberUi(this DateTimeOffset date) => CultureInfo.CurrentUICulture.Calendar.GetWeekOfYear(date.DateTime, CalendarWeekRule.FirstFourDayWeek, DayOfWeek.Monday); /// @@ -127,7 +136,7 @@ public static DateTimeOffset SetHourAndMinutes( hour, minutes, 0, - TimeSpan.Zero); + dateTimeOffset.Offset); /// Converts the DateTimeOffset to a unix time - seconds starting from 1-1-1970. /// The date time offset. @@ -157,15 +166,13 @@ public static string ToIso8601UtcDate(this DateTimeOffset dateTimeOffset) .ToString(GlobalizationConstants.DateTimeIso8601, GlobalizationConstants.EnglishCultureInfo); /// - /// Converts a DateTime to a string using the long date pattern - /// of the current UI culture. + /// Converts a DateTimeOffset to a string using the long date pattern of the current UI culture. + /// Use this variant when rendering output for display in a user interface. /// /// The DateTimeOffset to format. - /// A string representation of the DateTime using the - /// long date pattern of the current UI culture. - public static string ToLongDateStringUsingCurrentUiCulture( - this DateTimeOffset dateTimeOffset) - => dateTimeOffset.ToLongDateString(Thread.CurrentThread.CurrentUICulture.DateTimeFormat); + /// A string representation of the DateTimeOffset using the long date pattern of the current UI culture. + public static string ToLongDateStringUi(this DateTimeOffset dateTimeOffset) + => dateTimeOffset.ToLongDateString(CultureInfo.CurrentUICulture.DateTimeFormat); /// /// Converts a DateTime to a string using the long date pattern of the provided DateTimeFormatInfo. @@ -191,15 +198,13 @@ public static string ToLongDateString( } /// - /// Converts a DateTime to a string using the long time pattern - /// of the current UI culture. + /// Converts a DateTimeOffset to a string using the long time pattern of the current UI culture. + /// Use this variant when rendering output for display in a user interface. /// /// The DateTimeOffset to format. - /// A string representation of the DateTime using the - /// long time pattern of the current UI culture. - public static string ToLongTimeStringUsingCurrentUiCulture( - this DateTimeOffset dateTimeOffset) - => dateTimeOffset.ToLongTimeString(Thread.CurrentThread.CurrentUICulture.DateTimeFormat); + /// A string representation of the DateTimeOffset using the long time pattern of the current UI culture. + public static string ToLongTimeStringUi(this DateTimeOffset dateTimeOffset) + => dateTimeOffset.ToLongTimeString(CultureInfo.CurrentUICulture.DateTimeFormat); /// /// Converts a DateTime to a string using the long time pattern of the provided DateTimeFormatInfo. @@ -224,15 +229,28 @@ public static string ToLongTimeString( } /// - /// Converts a DateTime to a string using the short date pattern - /// of the current UI culture. + /// Converts a DateTimeOffset to a string using the short date pattern of the current UI culture. + /// Use this variant when rendering output for display in a user interface. /// /// The DateTimeOffset to format. - /// A string representation of the DateTime using the - /// short date pattern of the current UI culture. - public static string ToShortDateStringUsingCurrentUiCulture( - this DateTimeOffset dateTimeOffset) - => dateTimeOffset.ToShortDateString(Thread.CurrentThread.CurrentUICulture.DateTimeFormat); + /// A string representation of the DateTimeOffset using the short date pattern of the current UI culture. + public static string ToShortDateStringUi(this DateTimeOffset dateTimeOffset) + => dateTimeOffset.ToShortDateString(CultureInfo.CurrentUICulture.DateTimeFormat); + + /// + /// Converts a to a string using the short date pattern of the specified culture. + /// + /// The DateTimeOffset to format. + /// The culture whose short date pattern is used. + /// A string representation of the date using the short date pattern of . + /// Thrown when is null. + public static string ToShortDateStringUsingSpecificCulture( + this DateTimeOffset dateTimeOffset, + CultureInfo cultureInfo) + { + ArgumentNullException.ThrowIfNull(cultureInfo); + return dateTimeOffset.ToShortDateString(cultureInfo.DateTimeFormat); + } /// /// Converts a DateTime to a string using the short date pattern of the provided DateTimeFormatInfo. @@ -257,15 +275,13 @@ public static string ToShortDateString( } /// - /// Converts a DateTime to a string using the short time pattern - /// of the current UI culture. + /// Converts a DateTimeOffset to a string using the short time pattern of the current UI culture. + /// Use this variant when rendering output for display in a user interface. /// /// The DateTimeOffset to format. - /// A string representation of the DateTime using the - /// short time pattern of the current UI culture. - public static string ToShortTimeStringUsingCurrentUiCulture( - this DateTimeOffset dateTimeOffset) - => dateTimeOffset.ToShortTimeString(Thread.CurrentThread.CurrentUICulture.DateTimeFormat); + /// A string representation of the DateTimeOffset using the short time pattern of the current UI culture. + public static string ToShortTimeStringUi(this DateTimeOffset dateTimeOffset) + => dateTimeOffset.ToShortTimeString(CultureInfo.CurrentUICulture.DateTimeFormat); /// /// Converts a DateTime to a string using the short time pattern of the provided DateTimeFormatInfo. @@ -288,4 +304,53 @@ public static string ToShortTimeString( dateTimeFormatInfo.ShortTimePattern, dateTimeFormatInfo); } + + /// + /// Returns a new set to the very start of the same day (00:00:00.000), + /// preserving the original . + /// + /// The date-time value. + /// Midnight at the start of the date, with the same UTC offset. + public static DateTimeOffset StartOfDay(this DateTimeOffset dateTimeOffset) + => new(dateTimeOffset.Year, dateTimeOffset.Month, dateTimeOffset.Day, 0, 0, 0, dateTimeOffset.Offset); + + /// + /// Returns a new set to the very end of the same day (23:59:59.9999999), + /// preserving the original . + /// + /// The date-time value. + /// The last representable tick of the date, with the same UTC offset. + public static DateTimeOffset EndOfDay(this DateTimeOffset dateTimeOffset) + => new DateTimeOffset(dateTimeOffset.Year, dateTimeOffset.Month, dateTimeOffset.Day, 0, 0, 0, dateTimeOffset.Offset) + .AddDays(1) + .AddTicks(-1); + + /// + /// Returns a new set to the first day of the same month at midnight, + /// preserving the original . + /// + /// The date-time value. + /// The first day of the month, with the same UTC offset. + public static DateTimeOffset StartOfMonth( + this DateTimeOffset dateTimeOffset) + => new(dateTimeOffset.Year, dateTimeOffset.Month, 1, 0, 0, 0, dateTimeOffset.Offset); + + /// + /// Returns a new set to the last tick of the last day of the same month, + /// preserving the original . + /// + /// The date-time value. + /// The last representable tick of the month, with the same UTC offset. + public static DateTimeOffset EndOfMonth(this DateTimeOffset dateTimeOffset) + => new DateTimeOffset(dateTimeOffset.Year, dateTimeOffset.Month, DateTime.DaysInMonth(dateTimeOffset.Year, dateTimeOffset.Month), 0, 0, 0, dateTimeOffset.Offset) + .AddDays(1) + .AddTicks(-1); + + /// + /// Determines whether the date falls on a Saturday or Sunday. + /// + /// The date to test. + /// if the day of week is or ; otherwise, . + public static bool IsWeekend(this DateTimeOffset dateTimeOffset) + => dateTimeOffset.DayOfWeek is DayOfWeek.Saturday or DayOfWeek.Sunday; } \ No newline at end of file diff --git a/src/Atc/Extensions/BaseTypes/DecimalExtensions.cs b/src/Atc/Extensions/BaseTypes/DecimalExtensions.cs index c92bae6f..43482bf3 100644 --- a/src/Atc/Extensions/BaseTypes/DecimalExtensions.cs +++ b/src/Atc/Extensions/BaseTypes/DecimalExtensions.cs @@ -51,13 +51,9 @@ public static bool IsEqual( decimal b, int decimalPrecision) { - var sa = a - .ToString(CultureInfo.InvariantCulture) - .Replace(',', '.'); - - var sb = b - .ToString(CultureInfo.InvariantCulture) - .Replace(',', '.'); + // InvariantCulture always uses '.' as decimal separator — no Replace needed. + var sa = a.ToString(CultureInfo.InvariantCulture); + var sb = b.ToString(CultureInfo.InvariantCulture); var saa = sa.Split('.'); var sab = sb.Split('.'); @@ -126,12 +122,12 @@ public static int CurrencyRoundingAsInteger(this decimal value) => (int)CurrencyRounding(value, 0); /// - /// Rounds a decimal value using the currency decimal digits of the current UI culture. + /// Rounds a decimal value using the currency decimal digits of the current culture. /// /// The decimal value to round. /// The rounded decimal value. public static decimal CurrencyRounding(this decimal value) - => CurrencyRounding(value, Thread.CurrentThread.CurrentUICulture.NumberFormat.CurrencyDecimalDigits); + => CurrencyRounding(value, CultureInfo.CurrentCulture.NumberFormat.CurrencyDecimalDigits); /// /// Rounds a decimal value to a specified number of decimal digits using midpoint rounding away from zero. @@ -151,6 +147,15 @@ public static decimal CurrencyRounding( return Math.Round(value, digits, MidpointRounding.AwayFromZero); } + /// + /// Rounds a decimal value using the currency decimal digits of the current UI culture. + /// Use this variant when rounding for display in a user interface. + /// + /// The decimal value to round. + /// The rounded decimal value. + public static decimal CurrencyRoundingUi(this decimal value) + => CurrencyRounding(value, CultureInfo.CurrentUICulture.NumberFormat.CurrencyDecimalDigits); + /// /// Rounds a decimal value to 2 decimal places. /// diff --git a/src/Atc/Extensions/BaseTypes/DoubleExtensions.cs b/src/Atc/Extensions/BaseTypes/DoubleExtensions.cs index 29ead502..33ac8a8b 100644 --- a/src/Atc/Extensions/BaseTypes/DoubleExtensions.cs +++ b/src/Atc/Extensions/BaseTypes/DoubleExtensions.cs @@ -7,22 +7,26 @@ namespace System; public static class DoubleExtensions { /// - /// The double epsilon. + /// The tolerance used by and . + /// Values whose absolute difference is at or below this threshold are considered equal. /// - public const double DoubleEpsilon = double.Epsilon; + public const double DoubleEpsilon = 1e-9; /// - /// Compare two values. Return if they are equals. + /// Determines whether two double values are approximately equal within a tolerance of (1e-9). + /// This handles common floating-point arithmetic rounding, for example 0.1 + 0.2 == 0.3. + /// Use when an exact decimal-precision comparison is needed. /// /// The first value. /// The second value. /// - /// if the two values are equals, otherwise. + /// if the absolute difference between and + /// is at most ; otherwise, . /// public static bool IsEqual( this double a, double b) - => Math.Abs(a - b) < double.Epsilon; + => Math.Abs(a - b) <= DoubleEpsilon; /// /// Compare two values. Return if they are equals. @@ -109,12 +113,12 @@ public static bool GreaterThanOrClose( => value1 > value2 || AreClose(value1, value2); /// - /// Determines whether the specified double value is approximately zero. + /// Determines whether the specified double value is approximately zero within a tolerance of (1e-9). /// /// The value to check. - /// if the absolute value is less than epsilon; otherwise, . + /// if the absolute value is at most ; otherwise, . public static bool IsZero(this double value) - => Math.Abs(value) < DoubleEpsilon; + => Math.Abs(value) <= DoubleEpsilon; /// /// Rounds a double value using currency rounding rules and returns it as an integer. @@ -125,12 +129,12 @@ public static int CurrencyRoundingAsInteger(this double value) => (int)CurrencyRounding(value, 0); /// - /// Rounds a double value using the currency decimal digits of the current UI culture. + /// Rounds a double value using the currency decimal digits of the current culture. /// /// The double value to round. /// The rounded double value. public static double CurrencyRounding(this double value) - => CurrencyRounding(value, Thread.CurrentThread.CurrentUICulture.NumberFormat.CurrencyDecimalDigits); + => CurrencyRounding(value, CultureInfo.CurrentCulture.NumberFormat.CurrencyDecimalDigits); /// /// Rounds a double value to a specified number of decimal digits using midpoint rounding away from zero. @@ -150,6 +154,15 @@ public static double CurrencyRounding( return Math.Round(value, digits, MidpointRounding.AwayFromZero); } + /// + /// Rounds a double value using the currency decimal digits of the current UI culture. + /// Use this variant when rounding for display in a user interface. + /// + /// The double value to round. + /// The rounded double value. + public static double CurrencyRoundingUi(this double value) + => CurrencyRounding(value, CultureInfo.CurrentUICulture.NumberFormat.CurrencyDecimalDigits); + /// /// Rounds a double value to 2 decimal places. /// @@ -186,16 +199,25 @@ public static double RoundOffPercent(this double percent) => RoundOff(percent, 2); /// - /// Counts the number of decimal places in the double value. + /// Counts the number of decimal places in the double value, up to a maximum of 15 + /// (the limit of meaningful precision for a 64-bit floating-point number). /// /// The double value to analyze. - /// The number of decimal places in the value. + /// The number of decimal places, capped at 15. public static int CountDecimalPoints(this double value) { + const int maxPrecision = 15; + const double tolerance = 1e-9; var precision = 0; - while (Math.Abs((value * Math.Pow(10, precision)) - Math.Round(value * Math.Pow(10, precision))) > double.Epsilon) + while (precision < maxPrecision) { + var scaled = value * Math.Pow(10, precision); + if (Math.Abs(scaled - Math.Round(scaled)) <= tolerance) + { + break; + } + precision++; } diff --git a/src/Atc/Extensions/BaseTypes/IntegerExtensions.cs b/src/Atc/Extensions/BaseTypes/IntegerExtensions.cs index 44b2f0d8..ab613f85 100644 --- a/src/Atc/Extensions/BaseTypes/IntegerExtensions.cs +++ b/src/Atc/Extensions/BaseTypes/IntegerExtensions.cs @@ -92,13 +92,14 @@ public static bool IsBinarySequence(this int number) => number > 0 && (number & (number - 1)) == 0; /// - /// Gets the month name by month number. + /// Gets the month name by month number using the current UI culture. + /// Use this variant when rendering output for display in a user interface. /// /// The month. /// if set to [pascal cased]. /// The name of the month. [SuppressMessage("Microsoft.Globalization", "CA1305:SpecifyIFormatProvider", Justification = "OK.")] - public static string GetMonthNameByMonthNumber( + public static string GetMonthNameByMonthNumberUi( this int month, bool pascalCased = false) { @@ -111,7 +112,7 @@ public static string GetMonthNameByMonthNumber( var time = timeBegin.AddMonths(month - 1); // ReSharper disable once StringLiteralTypo - var str = time.ToString("MMMM", Thread.CurrentThread.CurrentUICulture); + var str = time.ToString("MMMM", CultureInfo.CurrentUICulture); if (str.Length <= 0) { return string.Empty; @@ -128,6 +129,15 @@ public static string GetMonthNameByMonthNumber( /// The year. /// The get number of weeks. public static int GetNumberOfWeeksByYear(this int year) + => CultureInfo.CurrentCulture.Calendar.GetWeekOfYear(new DateTime(year, 12, 28), CalendarWeekRule.FirstFourDayWeek, DayOfWeek.Monday); + + /// + /// Returns the number of ISO weeks in the given year using the current UI culture's calendar. + /// Use this variant when rendering the value for display in a user interface. + /// + /// The four-digit year. + /// 52 or 53 depending on the year and calendar. + public static int GetNumberOfWeeksByYearUi(this int year) => CultureInfo.CurrentUICulture.Calendar.GetWeekOfYear(new DateTime(year, 12, 28), CalendarWeekRule.FirstFourDayWeek, DayOfWeek.Monday); /// @@ -139,6 +149,34 @@ public static int GetNumberOfWeeksByYear(this int year) public static DateTime GetFirstDayOfWeekNumberByYear( this int year, int weekNumber) + { + var calendar = CultureInfo.CurrentCulture.Calendar; + var firstOfYear = new DateTime(year, 1, 1, calendar); + var daysOffset = DayOfWeek.Thursday - firstOfYear.DayOfWeek; + + var firstThursday = firstOfYear.AddDays(daysOffset); + var firstWeek = CultureInfo.CurrentCulture.Calendar.GetWeekOfYear(firstThursday, CalendarWeekRule.FirstFourDayWeek, DayOfWeek.Monday); + + var weekNum = weekNumber; + if (firstWeek <= 1) + { + weekNum -= 1; + } + + var result = firstThursday.AddDays(weekNum * 7); + return result.AddDays(-3); + } + + /// + /// Gets the date of the first day of a given week in a given year using the current UI culture's calendar. + /// Use this variant when rendering dates for display in a user interface. + /// + /// The four-digit year. + /// The ISO week number (1–53). + /// The of the Monday that starts the requested week. + public static DateTime GetFirstDayOfWeekNumberByYearUi( + this int year, + int weekNumber) { var calendar = CultureInfo.CurrentUICulture.Calendar; var firstOfYear = new DateTime(year, 1, 1, calendar); @@ -167,4 +205,16 @@ public static DateTime GetLastDayOfWeekNumberByYear( this int year, int weekNumber) => GetFirstDayOfWeekNumberByYear(year, weekNumber).AddDays(6); + + /// + /// Gets the date of the last day of a given week in a given year using the current UI culture's calendar. + /// Use this variant when rendering dates for display in a user interface. + /// + /// The four-digit year. + /// The ISO week number (1–53). + /// The of the Sunday that ends the requested week. + public static DateTime GetLastDayOfWeekNumberByYearUi( + this int year, + int weekNumber) + => GetFirstDayOfWeekNumberByYearUi(year, weekNumber).AddDays(6); } \ No newline at end of file diff --git a/src/Atc/Extensions/BaseTypes/LongExtensions.cs b/src/Atc/Extensions/BaseTypes/LongExtensions.cs index 23395360..3e6eac95 100644 --- a/src/Atc/Extensions/BaseTypes/LongExtensions.cs +++ b/src/Atc/Extensions/BaseTypes/LongExtensions.cs @@ -26,7 +26,13 @@ public static DateTimeOffset FromUnixTime(this long valueInSeconds) /// DateTimeOffset dateTimeOffset = unixTime.FromUnixTimeMs(); /// ]]> public static DateTimeOffset FromUnixTimeMs(this long valueInMs) - => valueInMs >= 1000 - ? DateTimeOffset.FromUnixTimeSeconds(valueInMs / 1000) - : DateTimeOffset.FromUnixTimeSeconds(valueInMs); + => DateTimeOffset.FromUnixTimeMilliseconds(valueInMs); + + /// + /// Determines whether the value is a binary sequence (a power of two), meaning exactly one bit is set. + /// + /// The number to evaluate. + /// if is a positive power of two; otherwise, . + public static bool IsBinarySequence(this long number) + => number > 0 && (number & (number - 1)) == 0; } \ No newline at end of file diff --git a/src/Atc/Extensions/BaseTypes/TimeSpanExtensions.cs b/src/Atc/Extensions/BaseTypes/TimeSpanExtensions.cs index 33f58fbf..a12689ce 100644 --- a/src/Atc/Extensions/BaseTypes/TimeSpanExtensions.cs +++ b/src/Atc/Extensions/BaseTypes/TimeSpanExtensions.cs @@ -61,30 +61,72 @@ public static string GetPrettyTime( { if ((int)timeSpan.TotalDays > 0) { - return $"{timeSpan.TotalDays.ToString("N" + decimalPrecision, Thread.CurrentThread.CurrentUICulture)} " + - $"{DateAndTime.Days.ToLower(Thread.CurrentThread.CurrentUICulture)}"; + return $"{timeSpan.TotalDays.ToString("N" + decimalPrecision, CultureInfo.CurrentCulture)} " + + $"{DateAndTime.Days.ToLower(CultureInfo.CurrentCulture)}"; } if ((int)timeSpan.TotalHours > 0) { - return $"{timeSpan.TotalHours.ToString("N" + decimalPrecision, Thread.CurrentThread.CurrentUICulture)} " + - $"{DateAndTime.Hours.ToLower(Thread.CurrentThread.CurrentUICulture)}"; + return $"{timeSpan.TotalHours.ToString("N" + decimalPrecision, CultureInfo.CurrentCulture)} " + + $"{DateAndTime.Hours.ToLower(CultureInfo.CurrentCulture)}"; } if ((int)timeSpan.TotalMinutes > 0) { - return $"{timeSpan.TotalMinutes.ToString("N" + decimalPrecision, Thread.CurrentThread.CurrentUICulture)} " + - $"{DateAndTime.MinuteAsAbbreviation.ToLower(Thread.CurrentThread.CurrentUICulture)}"; + return $"{timeSpan.TotalMinutes.ToString("N" + decimalPrecision, CultureInfo.CurrentCulture)} " + + $"{DateAndTime.MinuteAsAbbreviation.ToLower(CultureInfo.CurrentCulture)}"; } // ReSharper disable once ConvertIfStatementToReturnStatement if ((int)timeSpan.TotalSeconds > 0) { - return $"{timeSpan.TotalSeconds.ToString("N" + decimalPrecision, Thread.CurrentThread.CurrentUICulture)} " + - $"{DateAndTime.SecondAsAbbreviation.ToLower(Thread.CurrentThread.CurrentUICulture)}"; + return $"{timeSpan.TotalSeconds.ToString("N" + decimalPrecision, CultureInfo.CurrentCulture)} " + + $"{DateAndTime.SecondAsAbbreviation.ToLower(CultureInfo.CurrentCulture)}"; } - return $"{timeSpan.TotalMilliseconds.ToString("N" + decimalPrecision, Thread.CurrentThread.CurrentUICulture)} " + - $"{DateAndTime.MillisecondAsAbbreviation1.ToLower(Thread.CurrentThread.CurrentUICulture)}"; + return $"{timeSpan.TotalMilliseconds.ToString("N" + decimalPrecision, CultureInfo.CurrentCulture)} " + + $"{DateAndTime.MillisecondAsAbbreviation1.ToLower(CultureInfo.CurrentCulture)}"; + } + + /// + /// Converts a TimeSpan to a human-readable string representation with appropriate time units, + /// using the current UI culture for number formatting and unit label casing. + /// Use this variant when rendering output for display in a user interface. + /// + /// The TimeSpan to format. + /// The number of decimal places to display (default is 3). + /// A formatted string representing the time in the most appropriate unit (days, hours, minutes, seconds, or milliseconds). + [SuppressMessage("Globalization", "CA1305:Specify IFormatProvider", Justification = "OK.")] + public static string GetPrettyTimeUi( + this TimeSpan timeSpan, + int decimalPrecision = 3) + { + if ((int)timeSpan.TotalDays > 0) + { + return $"{timeSpan.TotalDays.ToString("N" + decimalPrecision, CultureInfo.CurrentUICulture)} " + + $"{DateAndTime.Days.ToLower(CultureInfo.CurrentUICulture)}"; + } + + if ((int)timeSpan.TotalHours > 0) + { + return $"{timeSpan.TotalHours.ToString("N" + decimalPrecision, CultureInfo.CurrentUICulture)} " + + $"{DateAndTime.Hours.ToLower(CultureInfo.CurrentUICulture)}"; + } + + if ((int)timeSpan.TotalMinutes > 0) + { + return $"{timeSpan.TotalMinutes.ToString("N" + decimalPrecision, CultureInfo.CurrentUICulture)} " + + $"{DateAndTime.MinuteAsAbbreviation.ToLower(CultureInfo.CurrentUICulture)}"; + } + + // ReSharper disable once ConvertIfStatementToReturnStatement + if ((int)timeSpan.TotalSeconds > 0) + { + return $"{timeSpan.TotalSeconds.ToString("N" + decimalPrecision, CultureInfo.CurrentUICulture)} " + + $"{DateAndTime.SecondAsAbbreviation.ToLower(CultureInfo.CurrentUICulture)}"; + } + + return $"{timeSpan.TotalMilliseconds.ToString("N" + decimalPrecision, CultureInfo.CurrentUICulture)} " + + $"{DateAndTime.MillisecondAsAbbreviation1.ToLower(CultureInfo.CurrentUICulture)}"; } } \ No newline at end of file diff --git a/src/Atc/Extensions/DataTableExtensions.cs b/src/Atc/Extensions/DataTableExtensions.cs index c2f9473f..1447f783 100644 --- a/src/Atc/Extensions/DataTableExtensions.cs +++ b/src/Atc/Extensions/DataTableExtensions.cs @@ -166,7 +166,7 @@ where d is not null using (var ds = new DataSet("DataSet")) { ds.Locale = GlobalizationConstants.EnglishCultureInfo; - ds.Tables.Add(dataTable); + ds.Tables.Add(dataTable.Copy()); xmlDocument.LoadXml(ds.GetXml()); } diff --git a/src/Atc/Extensions/DictionaryExtensions.cs b/src/Atc/Extensions/DictionaryExtensions.cs index 2f414813..4e1e5ede 100644 --- a/src/Atc/Extensions/DictionaryExtensions.cs +++ b/src/Atc/Extensions/DictionaryExtensions.cs @@ -112,5 +112,132 @@ public static bool TryUpdate( dictionaryValue = valueFactory(key, dictionaryValue); return true; } +#else + /// + /// Retrieves the value associated with the specified key or adds a new value if the key does not exist. + /// + /// The type of the dictionary keys. + /// The type of the dictionary values. + /// The dictionary instance. + /// The key whose value to get or add. + /// The value to add if the key does not exist. + /// The existing or newly added value. + public static TValue GetOrAdd( + this Dictionary dict, + TKey key, + TValue value) + where TKey : notnull + { + if (dict is null) + { + throw new ArgumentNullException(nameof(dict)); + } + + if (dict.TryGetValue(key, out var existing)) + { + return existing; + } + + dict.Add(key, value); + return value; + } + + /// + /// Retrieves the value associated with the specified key or adds a new value generated by the provided factory function if the key does not exist. + /// + /// The type of the dictionary keys. + /// The type of the dictionary values. + /// The dictionary instance. + /// The key whose value to get or add. + /// A function to generate the value if the key does not exist. + /// The existing or newly added value. + public static TValue GetOrAdd( + this Dictionary dict, + TKey key, + Func valueFactory) + where TKey : notnull + { + if (dict is null) + { + throw new ArgumentNullException(nameof(dict)); + } + + if (valueFactory is null) + { + throw new ArgumentNullException(nameof(valueFactory)); + } + + if (dict.TryGetValue(key, out var existing)) + { + return existing; + } + + var newValue = valueFactory(key); + dict.Add(key, newValue); + return newValue; + } + + /// + /// Attempts to update the value of an existing key in the dictionary. + /// + /// The type of the dictionary keys. + /// The type of the dictionary values. + /// The dictionary instance. + /// The key whose value should be updated. + /// The new value to assign. + /// true if the key exists and the value was updated; otherwise, false. + public static bool TryUpdate( + this Dictionary dict, + TKey key, + TValue value) + where TKey : notnull + { + if (dict is null) + { + throw new ArgumentNullException(nameof(dict)); + } + + if (!dict.ContainsKey(key)) + { + return false; + } + + dict[key] = value; + return true; + } + + /// + /// Attempts to update the value of an existing key in the dictionary using a factory function. + /// + /// The type of the dictionary keys. + /// The type of the dictionary values. + /// The dictionary instance. + /// The key whose value should be updated. + /// A function to generate the new value based on the existing value. + /// true if the key exists and the value was updated; otherwise, false. + public static bool TryUpdate( + this Dictionary dict, + TKey key, + Func valueFactory) + where TKey : notnull + { + if (dict is null) + { + throw new ArgumentNullException(nameof(dict)); + } + + if (valueFactory is null) + { + throw new ArgumentNullException(nameof(valueFactory)); + } + + if (!dict.TryGetValue(key, out var existing)) + { + return false; + } + + dict[key] = valueFactory(key, existing); + return true; + } #endif } \ No newline at end of file diff --git a/src/Atc/Extensions/EnumExtensions.cs b/src/Atc/Extensions/EnumExtensions.cs index 8b2a0310..8ee3ca10 100644 --- a/src/Atc/Extensions/EnumExtensions.cs +++ b/src/Atc/Extensions/EnumExtensions.cs @@ -25,10 +25,14 @@ public static bool AreFlagsSet( Enum flags) => IsSet(enumeration, flags); - /// Determines whether the specified enumeration match another enumeration. - /// The enumeration. - /// The enumeration to match. - /// true on match; otherwise false. + /// + /// Determines whether all bits of are set in , + /// equivalent to enumeration.HasFlag(matchTo). Works for all underlying numeric types including + /// -backed enumerations. + /// + /// The enumeration to check. + /// The flags that must all be present in . + /// if every bit in is also set in ; otherwise, . /// /// @@ -195,6 +199,33 @@ public static TTarget MapTo( return defaultValue ?? throw new InvalidOperationException($"Cannot map '{source}' from {source.GetType().Name} to {typeof(TTarget).Name}."); } + /// + /// Tries to map the current enum value to a target enum type by matching the name (case-insensitive). + /// Unlike , this method never throws — it returns when no match is found. + /// + /// The target enum type. + /// The source enum value. + /// + /// When this method returns , the matched target value; otherwise, the default value of . + /// + /// if a matching named value was found in ; otherwise, . + public static bool TryMapTo( + this Enum source, + out TTarget result) + where TTarget : struct, Enum + { + ArgumentNullException.ThrowIfNull(source); + + if (Enum.TryParse(source.ToString(), ignoreCase: true, out result) && + Enum.IsDefined(typeof(TTarget), result)) + { + return true; + } + + result = default; + return false; + } + /// Gets the attribute value. /// The type. /// The type of the expected. diff --git a/src/Atc/Extensions/ExceptionExtensions.cs b/src/Atc/Extensions/ExceptionExtensions.cs index b336b84f..76df4065 100644 --- a/src/Atc/Extensions/ExceptionExtensions.cs +++ b/src/Atc/Extensions/ExceptionExtensions.cs @@ -86,16 +86,12 @@ public static string Flatten( while (currentException is not null) { sb.AppendLine(currentException.Message); - if (includeStackTrace && exception.StackTrace is not null) + if (includeStackTrace && currentException.StackTrace is not null) { - sb.Append(exception.StackTrace); + sb.AppendLine(currentException.StackTrace); } currentException = currentException.InnerException; - if (includeStackTrace && exception.StackTrace is not null) - { - sb.AppendLine(); - } } return sb.ToString(); @@ -128,7 +124,11 @@ public static XDocument ToXml(this Exception exception) { var xElements = from frame in exception.StackTrace.Split('\n') - let prettierFrame = frame[6..].Trim() + let trimmedFrame = frame.Trim() + where trimmedFrame.Length > 0 + let prettierFrame = trimmedFrame.StartsWith("at ", StringComparison.Ordinal) + ? trimmedFrame[3..].Trim() + : trimmedFrame select new XElement("Frame", prettierFrame); root.Add(new XElement("StackTrace", xElements)); } diff --git a/src/Atc/Extensions/LoggerExtensions.cs b/src/Atc/Extensions/LoggerExtensions.cs index ffe9e9e4..4b709e32 100644 --- a/src/Atc/Extensions/LoggerExtensions.cs +++ b/src/Atc/Extensions/LoggerExtensions.cs @@ -29,26 +29,26 @@ public static void LogKeyValueItem( switch (logKeyValueItem.LogCategory) { case LogCategoryType.Critical: - logger.LogCritical(message); + logger.LogCritical("{Message}", message); break; case LogCategoryType.Error: - logger.LogError(message); + logger.LogError("{Message}", message); break; case LogCategoryType.Warning: - logger.LogWarning(message); + logger.LogWarning("{Message}", message); break; case LogCategoryType.Security: case LogCategoryType.Audit: case LogCategoryType.Service: case LogCategoryType.UI: case LogCategoryType.Information: - logger.LogInformation(message); + logger.LogInformation("{Message}", message); break; case LogCategoryType.Debug: - logger.LogDebug(message); + logger.LogDebug("{Message}", message); break; case LogCategoryType.Trace: - logger.LogTrace(message); + logger.LogTrace("{Message}", message); break; default: throw new SwitchCaseDefaultException(logKeyValueItem.LogCategory); diff --git a/src/Atc/Extensions/MemoryStreamExtensions.cs b/src/Atc/Extensions/MemoryStreamExtensions.cs index 65122d69..1d8bb55c 100644 --- a/src/Atc/Extensions/MemoryStreamExtensions.cs +++ b/src/Atc/Extensions/MemoryStreamExtensions.cs @@ -10,7 +10,7 @@ public static class MemoryStreamExtensions /// Converts the memory stream content to a string using the specified encoding. /// /// The memory stream to convert. - /// The encoding to use for the conversion. If , Unicode encoding is used. + /// The encoding to use for the conversion. Defaults to when . /// A string representation of the memory stream content. /// Thrown when is . public static string ToString( @@ -22,7 +22,7 @@ public static string ToString( throw new ArgumentNullException(nameof(stream)); } - encoding ??= Encoding.Unicode; + encoding ??= Encoding.UTF8; return encoding.GetString(stream.ToArray()); } diff --git a/src/Atc/Extensions/ProcessExtensions.cs b/src/Atc/Extensions/ProcessExtensions.cs index a2beb924..499fb8c4 100644 --- a/src/Atc/Extensions/ProcessExtensions.cs +++ b/src/Atc/Extensions/ProcessExtensions.cs @@ -11,8 +11,7 @@ public static class ProcessExtensions { private static readonly TimeSpan DefaultKillTimeout = TimeSpan.FromSeconds(30); - [SuppressMessage("Major Code Smell", "S4457:Parameter validation in \"async\"/\"await\" methods should be wrapped", Justification = "OK.")] - public static async Task WaitForExitAsync( + public static Task WaitForExitAsync( this Process process, CancellationToken cancellationToken = default) { @@ -21,6 +20,18 @@ public static async Task WaitForExitAsync( throw new ArgumentNullException(nameof(process)); } +#if NET5_0_OR_GREATER + return process.WaitForExitAsync(cancellationToken); +#else + return WaitForExitAsyncCore(process, cancellationToken); +#endif + } + +#if !NET5_0_OR_GREATER + private static async Task WaitForExitAsyncCore( + Process process, + CancellationToken cancellationToken) + { var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); void ProcessExited( @@ -54,6 +65,7 @@ void ProcessExited( process.Exited -= ProcessExited; } } +#endif /// /// Synchronously terminates the process and any child processes it started, using a default @@ -204,18 +216,15 @@ private static (int ExitCode, string Output) RunProcessAndReadOutput( return (-1, string.Empty); } + var outputTask = Task.Run(() => process.StandardOutput.ReadToEnd()); if (process.WaitForExit((int)timeout.TotalMilliseconds)) { - return ( - process.ExitCode, - process.StandardOutput.ReadToEnd()); + return (process.ExitCode, outputTask.GetAwaiter().GetResult()); } process.Kill(); - return ( - process.ExitCode, - string.Empty); + return (process.ExitCode, string.Empty); } private static void RunProcessAndIgnoreOutput( diff --git a/src/Atc/Extensions/ReadOnlyListExtensions.cs b/src/Atc/Extensions/ReadOnlyListExtensions.cs index 3b663254..8f07e176 100644 --- a/src/Atc/Extensions/ReadOnlyListExtensions.cs +++ b/src/Atc/Extensions/ReadOnlyListExtensions.cs @@ -12,16 +12,7 @@ public static IEnumerable> GetUniqueCombinations( throw new ArgumentNullException(nameof(list)); } - var result = new List>(); - var uniqueCombinations = GetUniqueCombinationsAsCommaSeparated(list); - foreach (var uniqueCombination in uniqueCombinations) - { - var combinations = new List(); - combinations.AddRange(uniqueCombination.Split(',')); - result.Add(combinations); - } - - return result; + return GetPowerSet(list).Where(subset => subset.Any()); } public static IEnumerable GetUniqueCombinationsAsCommaSeparated( @@ -46,6 +37,11 @@ public static IEnumerable> GetPowerSet( throw new ArgumentNullException(nameof(list)); } + if (list.Count >= 31) + { + throw new ArgumentOutOfRangeException(nameof(list), $"Cannot compute a power set for a list with {list.Count} elements; the bitmask overflows int at 31."); + } + return from m in Enumerable.Range(0, 1 << list.Count) select from i in Enumerable.Range(0, list.Count) diff --git a/src/Atc/Extensions/Reflection/AssemblyExtensions.cs b/src/Atc/Extensions/Reflection/AssemblyExtensions.cs index b6c50c46..6a858135 100644 --- a/src/Atc/Extensions/Reflection/AssemblyExtensions.cs +++ b/src/Atc/Extensions/Reflection/AssemblyExtensions.cs @@ -9,6 +9,8 @@ public static class AssemblyExtensions { /// /// Gets the file version of the assembly. + /// Falls back to when the assembly location is + /// unavailable (e.g., single-file published apps where is empty). /// /// The assembly to query. /// The file version, or 1.0.0.0 if the version cannot be determined. @@ -20,15 +22,23 @@ public static Version GetFileVersion(this Assembly assembly) throw new ArgumentNullException(nameof(assembly)); } - var fileVersion = FileVersionInfo.GetVersionInfo(assembly.Location).FileVersion; - if (fileVersion is null) + var location = assembly.Location; + if (!string.IsNullOrEmpty(location)) { - return new Version(1, 0, 0, 0); + var fileVersion = FileVersionInfo.GetVersionInfo(location).FileVersion; + if (fileVersion is not null && Version.TryParse(fileVersion, out var fvVersion)) + { + return fvVersion; + } + } + + var attr = assembly.GetCustomAttribute(); + if (attr is not null && Version.TryParse(attr.Version, out var attrVersion)) + { + return attrVersion; } - return Version.TryParse(fileVersion, out var version) - ? version - : new Version(1, 0, 0, 0); + return new Version(1, 0, 0, 0); } /// @@ -46,7 +56,7 @@ public static bool IsDebugBuild(this Assembly assembly) return assembly .GetCustomAttributes(false) .OfType() - .Select(att => att.IsJITTrackingEnabled) + .Select(att => att.IsJITOptimizerDisabled) .FirstOrDefault(); } diff --git a/src/Atc/Extensions/Reflection/ConstructorInfoExtensions.cs b/src/Atc/Extensions/Reflection/ConstructorInfoExtensions.cs new file mode 100644 index 00000000..10f0c1b3 --- /dev/null +++ b/src/Atc/Extensions/Reflection/ConstructorInfoExtensions.cs @@ -0,0 +1,53 @@ +// ReSharper disable once CheckNamespace +namespace System.Reflection; + +/// +/// Extensions for the class. +/// +public static class ConstructorInfoExtensions +{ + /// + /// Returns a human-readable representation of the constructor signature, optionally using full type names + /// and HTML formatting for the parameter types. + /// + /// The constructor information. + /// If , parameter types are rendered with their fully-qualified names. + /// If , parameter type names are wrapped in HTML tags. + /// A string of the form .ctor(TypeA paramA, TypeB paramB). + /// Thrown when is null. + public static string BeautifyName( + this ConstructorInfo constructorInfo, + bool useFullName = false, + bool useHtmlFormat = false) + { + ArgumentNullException.ThrowIfNull(constructorInfo); + + var seq = constructorInfo + .GetParameters() + .Select(x => + { + var typeName = x.ParameterType.BeautifyName(useFullName, useHtmlFormat); + + // ReSharper disable once InvertIf + if (x.ParameterType.Name.EndsWith('&')) + { + if (x.IsIn) + { + typeName = "in " + typeName.Replace("&", string.Empty, StringComparison.Ordinal); + } + else if (x.IsOut) + { + typeName = "out " + typeName.Replace("&", string.Empty, StringComparison.Ordinal); + } + else + { + typeName = "ref " + typeName.Replace("&", string.Empty, StringComparison.Ordinal); + } + } + + return typeName + " " + x.Name; + }); + + return $".ctor({string.Join(", ", seq)})"; + } +} \ No newline at end of file diff --git a/src/Atc/Extensions/Reflection/EventInfoExtensions.cs b/src/Atc/Extensions/Reflection/EventInfoExtensions.cs new file mode 100644 index 00000000..93655ad5 --- /dev/null +++ b/src/Atc/Extensions/Reflection/EventInfoExtensions.cs @@ -0,0 +1,31 @@ +// ReSharper disable once CheckNamespace +namespace System.Reflection; + +/// +/// Extensions for the class. +/// +public static class EventInfoExtensions +{ + /// + /// Returns a human-readable representation of the event, optionally including the event handler type + /// and using full type names or HTML formatting. + /// + /// The event information. + /// If , the event-handler type is rendered with its fully-qualified name. + /// If , the event-handler type name is wrapped in HTML tags. + /// If , the event-handler type is prepended to the name. + /// A string such as MyEvent or EventHandler MyEvent when is . + /// Thrown when is null. + public static string BeautifyName( + this EventInfo eventInfo, + bool useFullName = false, + bool useHtmlFormat = false, + bool includeEventHandlerType = false) + { + ArgumentNullException.ThrowIfNull(eventInfo); + + return includeEventHandlerType && eventInfo.EventHandlerType is not null + ? $"{eventInfo.EventHandlerType.BeautifyName(useFullName, useHtmlFormat)} {eventInfo.Name}" + : eventInfo.Name; + } +} \ No newline at end of file diff --git a/src/Atc/Extensions/Reflection/MemberInfoExtensions.cs b/src/Atc/Extensions/Reflection/MemberInfoExtensions.cs index cc0e7c9b..47c9b501 100644 --- a/src/Atc/Extensions/Reflection/MemberInfoExtensions.cs +++ b/src/Atc/Extensions/Reflection/MemberInfoExtensions.cs @@ -15,9 +15,10 @@ public static class MemberInfoExtensions [SuppressMessage("", "CA2263:Prefer the generic overload", Justification = "OK")] public static bool AnyCustomAttributes(this MemberInfo element) where T : Attribute - => element - .GetCustomAttributes(typeof(T)) - .Any(); + { + ArgumentNullException.ThrowIfNull(element); + return element.IsDefined(typeof(T), inherit: false); + } /// /// Determines whether [has exclude from code coverage attribute]. @@ -35,8 +36,7 @@ public static bool HasExcludeFromCodeCoverageAttribute( throw new ArgumentNullException(nameof(memberInfo)); } - var attributeData = memberInfo.CustomAttributes.FirstOrDefault(x => x.AttributeType == typeof(ExcludeFromCodeCoverageAttribute)); - return attributeData is not null; + return memberInfo.IsDefined(typeof(ExcludeFromCodeCoverageAttribute), inherit: false); } /// @@ -52,8 +52,7 @@ public static bool HasCompilerGeneratedAttribute(this MemberInfo memberInfo) throw new ArgumentNullException(nameof(memberInfo)); } - var attributeData = memberInfo.CustomAttributes.FirstOrDefault(x => x.AttributeType == typeof(CompilerGeneratedAttribute)); - return attributeData is not null; + return memberInfo.IsDefined(typeof(CompilerGeneratedAttribute), inherit: false); } /// @@ -71,8 +70,7 @@ public static bool HasIgnoreDisplayAttribute(this MemberInfo memberInfo) throw new ArgumentNullException(nameof(memberInfo)); } - var attributeData = memberInfo.CustomAttributes.FirstOrDefault(x => x.AttributeType == typeof(IgnoreDisplayAttribute)); - return attributeData is not null; + return memberInfo.IsDefined(typeof(IgnoreDisplayAttribute), inherit: false); } /// @@ -90,8 +88,7 @@ public static bool HasRequiredAttribute(this MemberInfo memberInfo) throw new ArgumentNullException(nameof(memberInfo)); } - var attributeData = memberInfo.CustomAttributes.FirstOrDefault(x => x.AttributeType == typeof(RequiredAttribute)); - return attributeData is not null; + return memberInfo.IsDefined(typeof(RequiredAttribute), inherit: false); } /// diff --git a/src/Atc/Extensions/StreamExtensions.cs b/src/Atc/Extensions/StreamExtensions.cs index a3909d84..f9bacb02 100644 --- a/src/Atc/Extensions/StreamExtensions.cs +++ b/src/Atc/Extensions/StreamExtensions.cs @@ -22,15 +22,13 @@ public static Stream CopyToStream( throw new ArgumentNullException(nameof(stream)); } - stream.Position = 0; - var buffer = new byte[bufferSize]; - int nRead; - var destination = new MemoryStream(); - while ((nRead = stream.Read(buffer, 0, bufferSize)) > 0) + if (stream.CanSeek) { - destination.Write(buffer, 0, nRead); + stream.Position = 0; } + var destination = new MemoryStream(); + stream.CopyTo(destination, bufferSize); destination.Position = 0; return destination; } @@ -48,19 +46,14 @@ public static byte[] ToBytes(this Stream stream) throw new ArgumentNullException(nameof(stream)); } - stream.Position = 0; - var buffer = new byte[32768]; - using var ms = new MemoryStream(); - while (true) + if (stream.CanSeek) { - var read = stream.Read(buffer, 0, buffer.Length); - if (read <= 0) - { - return ms.ToArray(); - } - - ms.Write(buffer, 0, read); + stream.Position = 0; } + + using var ms = new MemoryStream(); + stream.CopyTo(ms); + return ms.ToArray(); } /// @@ -76,9 +69,97 @@ public static string ToStringData(this Stream stream) throw new ArgumentNullException(nameof(stream)); } - stream.Position = 0; - using var reader = new StreamReader(stream); - var val = reader.ReadToEnd(); - return val; + if (stream.CanSeek) + { + stream.Position = 0; + } + + using var reader = new StreamReader(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true, bufferSize: -1, leaveOpen: true); + return reader.ReadToEnd(); + } + + /// + /// Asynchronously copies the contents of the stream to a new . + /// + /// The source stream to copy from. The stream position will be reset to 0 before copying if the stream supports seeking. + /// The size of the buffer used for copying. Defaults to 4096 bytes. + /// A token to cancel the asynchronous operation. + /// A that represents the asynchronous operation, containing a new with position set to 0. + /// Thrown when is . + public static async Task CopyToStreamAsync( + this Stream stream, + int bufferSize = 4096, + CancellationToken cancellationToken = default) + { + if (stream is null) + { + throw new ArgumentNullException(nameof(stream)); + } + + if (stream.CanSeek) + { + stream.Position = 0; + } + + var destination = new MemoryStream(); + await stream.CopyToAsync(destination, bufferSize, cancellationToken).ConfigureAwait(false); + destination.Position = 0; + return destination; + } + + /// + /// Asynchronously reads all bytes from the stream and returns them as a byte array. + /// + /// The stream to read from. The stream position will be reset to 0 before reading if the stream supports seeking. + /// A token to cancel the asynchronous operation. + /// A that represents the asynchronous operation, containing a byte array with all data from the stream. + /// Thrown when is . + public static async Task ToBytesAsync( + this Stream stream, + CancellationToken cancellationToken = default) + { + if (stream is null) + { + throw new ArgumentNullException(nameof(stream)); + } + + if (stream.CanSeek) + { + stream.Position = 0; + } + + using var ms = new MemoryStream(); + await stream.CopyToAsync(ms, 81920, cancellationToken).ConfigureAwait(false); + return ms.ToArray(); + } + + /// + /// Asynchronously reads all content from the stream and converts it to a string using UTF-8 encoding. + /// + /// The stream to read from. The stream position will be reset to 0 before reading if the stream supports seeking. + /// A token to cancel the asynchronous operation. + /// A that represents the asynchronous operation, containing a string with all text content from the stream. + /// Thrown when is . + public static async Task ToStringDataAsync( + this Stream stream, + CancellationToken cancellationToken = default) + { + if (stream is null) + { + throw new ArgumentNullException(nameof(stream)); + } + + if (stream.CanSeek) + { + stream.Position = 0; + } + + using var reader = new StreamReader(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true, bufferSize: -1, leaveOpen: true); +#if NET7_0_OR_GREATER + return await reader.ReadToEndAsync(cancellationToken).ConfigureAwait(false); +#else + cancellationToken.ThrowIfCancellationRequested(); + return await reader.ReadToEndAsync().ConfigureAwait(false); +#endif } } \ No newline at end of file diff --git a/src/Atc/Extensions/StringBuilderExtensions.cs b/src/Atc/Extensions/StringBuilderExtensions.cs index 510f7bac..e8c11313 100644 --- a/src/Atc/Extensions/StringBuilderExtensions.cs +++ b/src/Atc/Extensions/StringBuilderExtensions.cs @@ -53,7 +53,7 @@ public static void Append( if (indentSpaces < 0) { - throw new ArgumentOutOfRangeException(nameof(value)); + throw new ArgumentOutOfRangeException(nameof(indentSpaces)); } if (value is null) @@ -61,7 +61,8 @@ public static void Append( throw new ArgumentNullException(nameof(value)); } - sb.Append(value.PadLeft(value.Length + indentSpaces)); + sb.Append(' ', indentSpaces); + sb.Append(value); } /// @@ -111,7 +112,7 @@ public static void AppendLine( if (indentSpaces < 0) { - throw new ArgumentOutOfRangeException(nameof(value)); + throw new ArgumentOutOfRangeException(nameof(indentSpaces)); } if (value is null) @@ -119,7 +120,8 @@ public static void AppendLine( throw new ArgumentNullException(nameof(value)); } - sb.AppendLine(value.PadLeft(value.Length + indentSpaces)); + sb.Append(' ', indentSpaces); + sb.AppendLine(value); } /// diff --git a/src/Atc/Extensions/StringExtensions.cs b/src/Atc/Extensions/StringExtensions.cs index 7e045a15..edb2a711 100644 --- a/src/Atc/Extensions/StringExtensions.cs +++ b/src/Atc/Extensions/StringExtensions.cs @@ -938,27 +938,31 @@ public static string XmlEncode(this string xml) } return xml - .Replace("&", "&", StringComparison.Ordinal) + .Replace("&", "&", StringComparison.Ordinal) .Replace("'", "'", StringComparison.Ordinal) .Replace("<", "<", StringComparison.Ordinal) .Replace(">", ">", StringComparison.Ordinal) - .Replace("\"", """, StringComparison.Ordinal); + .Replace("\"", """, StringComparison.Ordinal); } /// - /// Decodes an XML string by unescaping special character entities (&amp, &#39;, &lt;, &gt;, &quot). + /// Decodes an XML string by unescaping special character entities (&amp;, &#39;, &lt;, &gt;, &quot;). /// /// The XML string to decode. /// The decoded XML string with special character entities replaced. + /// + /// The ampersand entity (&amp;) is decoded last so that already-decoded content + /// is not re-interpreted, keeping / a faithful round-trip. + /// public static string XmlDecode(this string xml) => string.IsNullOrEmpty(xml) ? xml : xml - .Replace("&", "&", StringComparison.Ordinal) .Replace("'", "'", StringComparison.Ordinal) .Replace("<", "<", StringComparison.Ordinal) .Replace(">", ">", StringComparison.Ordinal) - .Replace(""", "\"", StringComparison.Ordinal); + .Replace(""", "\"", StringComparison.Ordinal) + .Replace("&", "&", StringComparison.Ordinal); /// /// Sorts letters in the string alphabetically. @@ -2276,13 +2280,13 @@ private static string NormalizeAccentsHelper( case LetterAccentType.Grave: value = value .Replace("à", "a", StringComparison.Ordinal) - .Replace("è ", "e", StringComparison.Ordinal) - .Replace("ì ", "i", StringComparison.Ordinal) + .Replace("è", "e", StringComparison.Ordinal) + .Replace("ì", "i", StringComparison.Ordinal) .Replace("ò", "o", StringComparison.Ordinal) .Replace("ù", "u", StringComparison.Ordinal) .Replace("à", "a", StringComparison.Ordinal) - .Replace("è ", "e", StringComparison.Ordinal) - .Replace("ì ", "i", StringComparison.Ordinal) + .Replace("è", "e", StringComparison.Ordinal) + .Replace("ì", "i", StringComparison.Ordinal) .Replace("ò", "o", StringComparison.Ordinal) .Replace("ù", "u", StringComparison.Ordinal); break; @@ -2402,7 +2406,7 @@ private static string NormalizeAccentsHelper( .Replace("Ä", "A", StringComparison.Ordinal) .Replace("Ë", "E", StringComparison.Ordinal) .Replace("Ï", "I", StringComparison.Ordinal) - .Replace("Ö,", "O", StringComparison.Ordinal) + .Replace("Ö", "O", StringComparison.Ordinal) .Replace("Ü", "U", StringComparison.Ordinal) .Replace("Ÿ", "Y", StringComparison.Ordinal); break; diff --git a/src/Atc/Extensions/StringHasIsExtensions.cs b/src/Atc/Extensions/StringHasIsExtensions.cs index 4fc1cf2d..ffb86e51 100644 --- a/src/Atc/Extensions/StringHasIsExtensions.cs +++ b/src/Atc/Extensions/StringHasIsExtensions.cs @@ -17,11 +17,13 @@ public static class StringHasIsExtensions private static readonly Lazy RxNumeric = new(() => new Regex("[^0-9]", RegexOptions.Singleline | RegexOptions.Compiled, TimeSpan.FromSeconds(1))); #endif - private static readonly Lazy RxEmailAddress = new(() => new Regex(@"^(([A-Za-z0-9]+_+)|([A-Za-z0-9]+\-+)|([A-Za-z0-9]+\.+)|([A-Za-z0-9]+\++))*[A-Za-z0-9]+@((\w+\-+)|(\w+\.))*\w{1,63}\.[a-zA-Z]{2,6}$", RegexOptions.Singleline | RegexOptions.Compiled, TimeSpan.FromSeconds(1))); + private static readonly Lazy RxEmailAddress = new(() => new Regex(@"^(([A-Za-z0-9]+_+)|([A-Za-z0-9]+\-+)|([A-Za-z0-9]+\.+)|([A-Za-z0-9]+\++))*[A-Za-z0-9]+@((\w+\-+)|(\w+\.))*\w{1,63}\.[a-zA-Z]{2,63}$", RegexOptions.Singleline | RegexOptions.Compiled, TimeSpan.FromSeconds(1))); + private static readonly Lazy RxMacAddress = new(() => new Regex(@"^(?:(?:[0-9A-Fa-f]{2}[:\-]){5}[0-9A-Fa-f]{2}|[0-9A-Fa-f]{12}|[0-9A-Fa-f]{4}\.[0-9A-Fa-f]{4}\.[0-9A-Fa-f]{4})$", RegexOptions.Singleline | RegexOptions.Compiled, TimeSpan.FromSeconds(1))); private static readonly Lazy RxGuid = new(() => new Regex(@"^(\{){0,1}[0-9a-fA-F]{8}\-[0-9a-fA-F]{4}\-[0-9a-fA-F]{4}\-[0-9a-fA-F]{4}\-[0-9a-fA-F]{12}(\}){0,1}$", RegexOptions.Singleline | RegexOptions.Compiled, TimeSpan.FromSeconds(1))); private static readonly Lazy RxKey = new(() => new Regex(@"^([a-zA-Z]+[a-zA-Z0-9_]+$)", RegexOptions.Singleline | RegexOptions.Compiled, TimeSpan.FromSeconds(1))); private static readonly Lazy RxHtmlTags = new(() => new Regex(@"<[^>]+>", RegexOptions.Multiline | RegexOptions.Compiled, TimeSpan.FromSeconds(5))); private static readonly Lazy RxSingleWord = new(() => new Regex(@"^((?!-)+)([a-zA-Z_-]+$).*((? RxHostName = new(() => new Regex(@"^(?=.{1,253}\.?$)(?!-)[A-Za-z0-9-]{1,63}(? /// Determines whether [has HTML tags] [the specified value]. @@ -586,4 +588,78 @@ public static bool IsUriHttpOrHttps(this string value) /// if the value is a valid opc.tcp:// URI; otherwise, . public static bool IsUriOpcTcp(this string value) => UriAttribute.IsValidOpcTcp(value); + + /// + /// Determines whether the specified value is a syntactically valid DNS host name (RFC 1123). + /// + /// The string to validate. + /// if the value is a valid host name; otherwise, . + /// + /// Accepts single-label names (e.g. localhost) and an optional trailing dot (e.g. example.com.). + /// Each label is 1-63 ASCII alphanumeric/hyphen characters and may not start or end with a hyphen; + /// the total length is limited to 253 characters. Underscores and raw Unicode (non-punycode IDN) are not allowed. + /// This is a purely syntactic check and does not perform any DNS resolution. + /// + public static bool IsHostName(this string value) + => !string.IsNullOrEmpty(value) && + RxHostName.Value.IsMatch(value); + + /// + /// Determines whether the specified value is a valid IPv4 address. + /// + /// The string to validate. + /// if the value is a valid IPv4 address; otherwise, . + public static bool IsIPv4Address(this string value) + => !string.IsNullOrEmpty(value) && + IPAddress.TryParse(value, out var address) && + address.AddressFamily == AddressFamily.InterNetwork && + value.Split('.').Length == 4; + + /// + /// Determines whether the specified value is a valid IPv6 address. + /// + /// The string to validate. + /// if the value is a valid IPv6 address; otherwise, . + public static bool IsIPv6Address(this string value) + => !string.IsNullOrEmpty(value) && + IPAddress.TryParse(value, out var address) && + address.AddressFamily == AddressFamily.InterNetworkV6; + + /// + /// Determines whether the specified value is a valid IPv4 or IPv6 address. + /// + /// The string to validate. + /// if the value is a valid IP address; otherwise, . + public static bool IsIPAddress(this string value) + => value.IsIPv4Address() || + value.IsIPv6Address(); + + /// + /// Determines whether the specified value is a valid TCP/UDP port number. + /// + /// The string to validate. + /// + /// if the value parses to an integer in the range 1–65535; otherwise, . + /// + public static bool IsPort(this string value) + => !string.IsNullOrEmpty(value) && + int.TryParse(value, NumberStyles.None, GlobalizationConstants.EnglishCultureInfo, out var port) && + port is >= 1 and <= 65535; + + /// + /// Determines whether the specified value is a valid MAC address. + /// + /// The string to validate. + /// + /// if the value is a valid 48-bit MAC address; otherwise, . + /// + /// + /// Accepts the three most common notations: colon-separated (AA:BB:CC:DD:EE:FF), + /// hyphen-separated (AA-BB-CC-DD-EE-FF), and dot-separated Cisco style (AABB.CCDD.EEFF), + /// as well as the compact twelve-hex-digit form (AABBCCDDEEFF). + /// The check is case-insensitive. + /// + public static bool IsMacAddress(this string value) + => !string.IsNullOrEmpty(value) && + RxMacAddress.Value.IsMatch(value); } \ No newline at end of file diff --git a/src/Atc/Extensions/TaskExtensions.cs b/src/Atc/Extensions/TaskExtensions.cs index c4cd2d86..0b5e0ec8 100644 --- a/src/Atc/Extensions/TaskExtensions.cs +++ b/src/Atc/Extensions/TaskExtensions.cs @@ -65,7 +65,10 @@ public static void StartAndWaitAllThrottled( foreach (var task in tasks) { // Increment the number of tasks currently running and wait if too many are running. - throttler.Wait(timeoutInMilliseconds, cancellationToken); + if (!throttler.Wait(timeoutInMilliseconds, cancellationToken)) + { + throw new TimeoutException($"Timed out after {timeoutInMilliseconds} ms waiting for a task slot to become available."); + } cancellationToken.ThrowIfCancellationRequested(); task.Start(); diff --git a/src/Atc/Extensions/TypeExtensions.cs b/src/Atc/Extensions/TypeExtensions.cs index d3fea70a..ea6e0513 100644 --- a/src/Atc/Extensions/TypeExtensions.cs +++ b/src/Atc/Extensions/TypeExtensions.cs @@ -156,7 +156,7 @@ public static bool IsInheritedFrom( inheritTypeFullName = inheritTypeFullName.Substring(0, inheritTypeFullName.IndexOf(GenericSign, StringComparison.Ordinal)); } - return string.Equals(baseTypeFullName, inheritTypeFullName, StringComparison.OrdinalIgnoreCase) || type.BaseType.IsInheritedFrom(inheritType); + return string.Equals(baseTypeFullName, inheritTypeFullName, StringComparison.Ordinal) || type.BaseType.IsInheritedFrom(inheritType); } /// @@ -572,7 +572,7 @@ public static string BeautifyName( .ToArray(); for (var i = 0; i < sa.Length; i++) { - sa[0] = "T"; + sa[i] = "T"; } genericArguments = string.Join(", ", sa); diff --git a/src/Atc/Factories/AsyncEnumerableFactory.cs b/src/Atc/Factories/AsyncEnumerableFactory.cs index 9b11e788..456b08d1 100644 --- a/src/Atc/Factories/AsyncEnumerableFactory.cs +++ b/src/Atc/Factories/AsyncEnumerableFactory.cs @@ -10,10 +10,18 @@ public static class AsyncEnumerableFactory /// /// The type of the elements in the sequence. /// An empty . - public static async IAsyncEnumerable Empty() + public static IAsyncEnumerable Empty() + => EmptyAsyncEnumerable.Instance; + + private static class EmptyAsyncEnumerable { - await Task.CompletedTask; - yield break; + internal static readonly IAsyncEnumerable Instance = CreateEmpty(); + + private static async IAsyncEnumerable CreateEmpty() + { + await Task.CompletedTask.ConfigureAwait(false); + yield break; + } } /// @@ -21,10 +29,108 @@ public static async IAsyncEnumerable Empty() /// /// The type of the item. /// The item to convert. + /// A token to cancel the asynchronous iteration. /// An containing the single item. - public static async IAsyncEnumerable FromSingleItem(T item) + public static async IAsyncEnumerable FromSingleItem( + T item, + [EnumeratorCancellation] CancellationToken cancellationToken = default) { + cancellationToken.ThrowIfCancellationRequested(); yield return item; await Task.CompletedTask; } + + /// + /// Wraps an array of items as an , yielding each item in order. + /// + /// The type of the elements. + /// The items to yield. + /// A token to cancel the asynchronous iteration; checked before each item is yielded. + /// An that yields each element of . + /// Thrown when is . + public static IAsyncEnumerable FromItems( + T[] items, + CancellationToken cancellationToken = default) + { + if (items is null) + { + throw new ArgumentNullException(nameof(items)); + } + + return FromItemsCore(items, cancellationToken); + } + + /// + /// Wraps an as an , yielding each element in order. + /// + /// The type of the elements. + /// The synchronous sequence to wrap. + /// A token to cancel the asynchronous iteration; checked before each element is yielded. + /// An that yields each element of . + /// Thrown when is . + public static IAsyncEnumerable FromEnumerable( + IEnumerable source, + CancellationToken cancellationToken = default) + { + if (source is null) + { + throw new ArgumentNullException(nameof(source)); + } + + return FromEnumerableCore(source, cancellationToken); + } + + /// + /// Creates an that awaits the specified task and yields its result as a single element. + /// + /// The type of the task result. + /// The task whose result will be yielded. + /// A token to cancel the asynchronous operation before the task is awaited. + /// An that yields the single result of . + /// Thrown when is . + public static IAsyncEnumerable FromTask( + Task task, + CancellationToken cancellationToken = default) + { + if (task is null) + { + throw new ArgumentNullException(nameof(task)); + } + + return FromTaskCore(task, cancellationToken); + } + + private static async IAsyncEnumerable FromItemsCore( + T[] items, + [EnumeratorCancellation] CancellationToken cancellationToken) + { + foreach (var item in items) + { + cancellationToken.ThrowIfCancellationRequested(); + yield return item; + } + + await Task.CompletedTask; + } + + private static async IAsyncEnumerable FromEnumerableCore( + IEnumerable source, + [EnumeratorCancellation] CancellationToken cancellationToken) + { + foreach (var item in source) + { + cancellationToken.ThrowIfCancellationRequested(); + yield return item; + } + + await Task.CompletedTask; + } + + private static async IAsyncEnumerable FromTaskCore( + Task task, + [EnumeratorCancellation] CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + yield return await task.ConfigureAwait(false); + } } \ No newline at end of file diff --git a/src/Atc/Formatters/StringCaseFormatter.cs b/src/Atc/Formatters/StringCaseFormatter.cs index a57f77e7..f4f8ae18 100644 --- a/src/Atc/Formatters/StringCaseFormatter.cs +++ b/src/Atc/Formatters/StringCaseFormatter.cs @@ -48,7 +48,7 @@ namespace Atc; public sealed class StringCaseFormatter : IFormatProvider, ICustomFormatter { /// - /// Static using . + /// Static using . /// public static readonly StringCaseFormatter Default = new(); @@ -87,25 +87,25 @@ public string Format( return format switch { - "U" => str.ToUpper(CultureInfo.CurrentCulture), - "Ul" => str.ToUpper(CultureInfo.CurrentCulture).EnsureFirstCharacterToLower(), + "U" => str.ToUpper(CultureInfo.InvariantCulture), + "Ul" => str.ToUpper(CultureInfo.InvariantCulture).EnsureFirstCharacterToLower(), "u" => str.EnsureFirstCharacterToUpper(), - "L" => str.ToLower(CultureInfo.CurrentCulture), - "Lu" => str.ToLower(CultureInfo.CurrentCulture).EnsureFirstCharacterToUpper(), + "L" => str.ToLower(CultureInfo.InvariantCulture), + "Lu" => str.ToLower(CultureInfo.InvariantCulture).EnsureFirstCharacterToUpper(), "l" => str.EnsureFirstCharacterToLower(), - "U." => str.ToUpper(CultureInfo.CurrentCulture).EnsureEndsWithDot(), - "Ul." => str.ToUpper(CultureInfo.CurrentCulture).EnsureFirstCharacterToLower().EnsureEndsWithDot(), + "U." => str.ToUpper(CultureInfo.InvariantCulture).EnsureEndsWithDot(), + "Ul." => str.ToUpper(CultureInfo.InvariantCulture).EnsureFirstCharacterToLower().EnsureEndsWithDot(), "u." => str.EnsureFirstCharacterToUpper().EnsureEndsWithDot(), - "L." => str.ToLower(CultureInfo.CurrentCulture).EnsureEndsWithDot(), - "Lu." => str.ToLower(CultureInfo.CurrentCulture).EnsureFirstCharacterToUpper().EnsureEndsWithDot(), + "L." => str.ToLower(CultureInfo.InvariantCulture).EnsureEndsWithDot(), + "Lu." => str.ToLower(CultureInfo.InvariantCulture).EnsureFirstCharacterToUpper().EnsureEndsWithDot(), "l." => str.EnsureFirstCharacterToLower().EnsureEndsWithDot(), - "U:" => str.ToUpper(CultureInfo.CurrentCulture).EnsureEndsWithColon(), - "Ul:" => str.ToUpper(CultureInfo.CurrentCulture).EnsureFirstCharacterToLower().EnsureEndsWithColon(), + "U:" => str.ToUpper(CultureInfo.InvariantCulture).EnsureEndsWithColon(), + "Ul:" => str.ToUpper(CultureInfo.InvariantCulture).EnsureFirstCharacterToLower().EnsureEndsWithColon(), "u:" => str.EnsureFirstCharacterToUpper().EnsureEndsWithColon(), - "L:" => str.ToLower(CultureInfo.CurrentCulture).EnsureEndsWithColon(), - "Lu:" => str.ToLower(CultureInfo.CurrentCulture).EnsureFirstCharacterToUpper().EnsureEndsWithColon(), + "L:" => str.ToLower(CultureInfo.InvariantCulture).EnsureEndsWithColon(), + "Lu:" => str.ToLower(CultureInfo.InvariantCulture).EnsureFirstCharacterToUpper().EnsureEndsWithColon(), "l:" => str.EnsureFirstCharacterToLower().EnsureEndsWithColon(), _ => str, diff --git a/src/Atc/GlobalizationConstants.cs b/src/Atc/GlobalizationConstants.cs index 5fd98600..55c8d330 100644 --- a/src/Atc/GlobalizationConstants.cs +++ b/src/Atc/GlobalizationConstants.cs @@ -13,10 +13,10 @@ public static class GlobalizationConstants /// /// EnglishCultureInfo. /// - public static readonly CultureInfo EnglishCultureInfo = new("en-US"); + public static readonly CultureInfo EnglishCultureInfo = CultureInfo.ReadOnly(new CultureInfo("en-US")); /// /// DanishCultureInfo. /// - public static readonly CultureInfo DanishCultureInfo = new("da-DK"); + public static readonly CultureInfo DanishCultureInfo = CultureInfo.ReadOnly(new CultureInfo("da-DK")); } \ No newline at end of file diff --git a/src/Atc/Helpers/ArticleNumberHelper.cs b/src/Atc/Helpers/ArticleNumberHelper.cs index ac8945b0..bc018c75 100644 --- a/src/Atc/Helpers/ArticleNumberHelper.cs +++ b/src/Atc/Helpers/ArticleNumberHelper.cs @@ -6,16 +6,28 @@ namespace Atc.Helpers; /// /// https://en.wikipedia.org/wiki/International_Article_Number. /// -public static class ArticleNumberHelper +public static partial class ArticleNumberHelper { - private static readonly Lazy AsinRegex = new( +#if NET7_0_OR_GREATER + [GeneratedRegex(@"^B\d{2}\w{7}|\d{9}(X|\d)$", RegexOptions.None, matchTimeoutMilliseconds: 250)] + private static partial Regex GetAsinRegex(); + + [GeneratedRegex(@"^\d{4}-\d{3}[\dxX]{1}$", RegexOptions.None, matchTimeoutMilliseconds: 250)] + private static partial Regex GetIssnRegex(); +#else + private static readonly Lazy AsinRegexLazy = new( () => new Regex(@"^B\d{2}\w{7}|\d{9}(X|\d)$", RegexOptions.Compiled, TimeSpan.FromMilliseconds(250)), LazyThreadSafetyMode.ExecutionAndPublication); - private static readonly Lazy IssnRegex = new( + private static readonly Lazy IssnRegexLazy = new( () => new Regex(@"^\d{4}-\d{3}[\dxX]{1}$", RegexOptions.Compiled, TimeSpan.FromMilliseconds(250)), LazyThreadSafetyMode.ExecutionAndPublication); + private static Regex GetAsinRegex() => AsinRegexLazy.Value; + + private static Regex GetIssnRegex() => IssnRegexLazy.Value; +#endif + /// /// Get ArticleNumberType. /// @@ -70,7 +82,7 @@ public static bool IsValidAsin(string asin) return false; } - return AsinRegex.Value.IsMatch(asin); + return GetAsinRegex().IsMatch(asin); } /// @@ -190,7 +202,7 @@ public static bool IsValidIssn(string code) return false; } - return IssnRegex.Value.IsMatch(code); + return GetIssnRegex().IsMatch(code); } /// diff --git a/src/Atc/Helpers/AssemblyHelper.cs b/src/Atc/Helpers/AssemblyHelper.cs index 69ebb561..f3f4557b 100644 --- a/src/Atc/Helpers/AssemblyHelper.cs +++ b/src/Atc/Helpers/AssemblyHelper.cs @@ -105,7 +105,7 @@ public static byte[] ReadAsBytes(FileInfo assemblyFile) if (!assemblyFile.Extension.Equals(".dll", StringComparison.OrdinalIgnoreCase) && !assemblyFile.Extension.Equals(".exe", StringComparison.OrdinalIgnoreCase) && - !(assemblyFile.Name.StartsWith('~') && assemblyFile.Name.EndsWith(".tmp", StringComparison.Ordinal))) + !(assemblyFile.Name.StartsWith("~", StringComparison.Ordinal) && assemblyFile.Name.EndsWith(".tmp", StringComparison.Ordinal))) { throw new IOException("File is not a dll or a executable file"); } diff --git a/src/Atc/Helpers/CSharpTypeHelper.cs b/src/Atc/Helpers/CSharpTypeHelper.cs index 482ea7e3..65fdb00c 100644 --- a/src/Atc/Helpers/CSharpTypeHelper.cs +++ b/src/Atc/Helpers/CSharpTypeHelper.cs @@ -80,7 +80,7 @@ public static bool IsExtendedValueType(string typeName) /// True if the type is nullable. public static bool IsNullable(string typeName) => !string.IsNullOrEmpty(typeName) && - typeName.EndsWith('?'); + typeName.EndsWith("?", StringComparison.Ordinal); /// /// Gets the base type by removing the nullable marker (?). diff --git a/src/Atc/Helpers/CultureHelper.cs b/src/Atc/Helpers/CultureHelper.cs index dbc0542f..9d47a464 100644 --- a/src/Atc/Helpers/CultureHelper.cs +++ b/src/Atc/Helpers/CultureHelper.cs @@ -32,7 +32,8 @@ public static List GetCultures() allCultures ??= new Dictionary>(); - allCultures[Thread.CurrentThread.CurrentUICulture.LCID] = new List(); + var lcid = Thread.CurrentThread.CurrentUICulture.LCID; + var result = new List(); var culturesFromPlatform = GetCultureInfoFromPlatform(); foreach (var cultureInfo in culturesFromPlatform) { @@ -57,13 +58,14 @@ public static List GetCultures() culture.CountryDisplayName = TryTranslateCountryEnglishName(culture.CountryEnglishName); culture.LanguageDisplayName = TryTranslateLanguageEnglishName(culture.LanguageEnglishName); - if (!allCultures[Thread.CurrentThread.CurrentUICulture.LCID].Contains(culture)) + if (!result.Contains(culture)) { - allCultures[Thread.CurrentThread.CurrentUICulture.LCID].Add(culture); + result.Add(culture); } } - return allCultures[Thread.CurrentThread.CurrentUICulture.LCID]; + allCultures[lcid] = result; + return result; } } @@ -131,10 +133,17 @@ public static List GetCultures( Thread.CurrentThread.CurrentUICulture = new CultureInfo(displayLanguageLcid); } - var cultures = GetCultures(); - if (backupCultureInfo is not null) + List cultures; + try { - Thread.CurrentThread.CurrentUICulture = backupCultureInfo; + cultures = GetCultures(); + } + finally + { + if (backupCultureInfo is not null) + { + Thread.CurrentThread.CurrentUICulture = backupCultureInfo; + } } var data = new List(); @@ -615,11 +624,19 @@ public static Dictionary GetCountryNames( Thread.CurrentThread.CurrentUICulture = new CultureInfo(displayLanguageLcid); } - var cultures = GetCultures(); - var data = DataFactory.CreateKeyValueDictionaryOfIntString(dropDownFirstItemType); - if (backupCultureInfo is not null) + List cultures; + Dictionary data; + try { - Thread.CurrentThread.CurrentUICulture = backupCultureInfo; + cultures = GetCultures(); + data = DataFactory.CreateKeyValueDictionaryOfIntString(dropDownFirstItemType); + } + finally + { + if (backupCultureInfo is not null) + { + Thread.CurrentThread.CurrentUICulture = backupCultureInfo; + } } var countryDisplayNameCount = new Dictionary(StringComparer.Ordinal); @@ -750,11 +767,19 @@ public static Dictionary GetLanguageNames( Thread.CurrentThread.CurrentUICulture = new CultureInfo(displayLanguageLcid); } - var cultures = GetCultures(); - var data = DataFactory.CreateKeyValueDictionaryOfIntString(dropDownFirstItemType); - if (backupCultureInfo is not null) + List cultures; + Dictionary data; + try { - Thread.CurrentThread.CurrentUICulture = backupCultureInfo; + cultures = GetCultures(); + data = DataFactory.CreateKeyValueDictionaryOfIntString(dropDownFirstItemType); + } + finally + { + if (backupCultureInfo is not null) + { + Thread.CurrentThread.CurrentUICulture = backupCultureInfo; + } } var languageDisplayNameCount = new Dictionary(StringComparer.Ordinal); @@ -810,19 +835,25 @@ public static List GetCultureLcidsWhereCountryIsNotTranslated( Thread.CurrentThread.CurrentUICulture = new CultureInfo(displayLanguageLcid); } - var culturesFromPlatform = GetCultureInfoFromPlatform(); - var data = ( - from cultureInfo - in culturesFromPlatform - let countryEnglishName = ExtractCountryEnglishName(cultureInfo) - let countryDisplayName = TryTranslateCountryEnglishName(countryEnglishName, useValueAsDefault: false) - where countryDisplayName is null - select cultureInfo.LCID) - .ToList(); - - if (backupCultureInfo is not null) + List data; + try { - Thread.CurrentThread.CurrentUICulture = backupCultureInfo; + var culturesFromPlatform = GetCultureInfoFromPlatform(); + data = ( + from cultureInfo + in culturesFromPlatform + let countryEnglishName = ExtractCountryEnglishName(cultureInfo) + let countryDisplayName = TryTranslateCountryEnglishName(countryEnglishName, useValueAsDefault: false) + where countryDisplayName is null + select cultureInfo.LCID) + .ToList(); + } + finally + { + if (backupCultureInfo is not null) + { + Thread.CurrentThread.CurrentUICulture = backupCultureInfo; + } } if (includeOnlyLcids is null || includeOnlyLcids.Count <= 0) @@ -997,9 +1028,7 @@ private static string TryTranslateLanguageEnglishName( private static ResourceManager GetResourceManagerForResource( string resource) { - var assembly = AppDomain.CurrentDomain - .GetAssemblies() - .Single(x => x.FullName!.StartsWith(ResourceBaseName + ", Version", StringComparison.Ordinal)); + var assembly = typeof(CultureHelper).Assembly; return new ResourceManager($"{ResourceBaseName}.Resources.{resource}", assembly); } } \ No newline at end of file diff --git a/src/Atc/Helpers/DateTimeHelper.cs b/src/Atc/Helpers/DateTimeHelper.cs index c5c03278..fd76f94d 100644 --- a/src/Atc/Helpers/DateTimeHelper.cs +++ b/src/Atc/Helpers/DateTimeHelper.cs @@ -10,8 +10,8 @@ public static class DateTimeHelper private const int MaxTimeLengthFor12Hours = 8; /// - /// Tries to parse a string representation of a DateTime using - /// the current UI culture's date and time format. + /// Tries to parse a string representation of a DateTime using the current UI culture's date and time format. + /// Use this variant when parsing input from a user interface. /// /// The string to parse. /// @@ -21,12 +21,12 @@ public static class DateTimeHelper /// /// if the parsing was successful; otherwise, . /// - public static bool TryParseUsingCurrentUiCulture( + public static bool TryParseUi( string value, out DateTime result) { result = default; - if (!TryParseUsingSpecificCulture(value, Thread.CurrentThread.CurrentUICulture, out var res)) + if (!TryParseUsingSpecificCulture(value, CultureInfo.CurrentUICulture, out var res)) { return false; } @@ -78,8 +78,8 @@ public static bool TryParseUsingSpecificCulture( } /// - /// Tries to parse a string representation of a short date using - /// the current UI culture's date format. + /// Tries to parse a string representation of a short date using the current UI culture's date format. + /// Use this variant when parsing input from a user interface. /// /// The string to parse. /// @@ -89,12 +89,12 @@ public static bool TryParseUsingSpecificCulture( /// /// if the parsing was successful; otherwise, . /// - public static bool TryParseShortDateUsingCurrentUiCulture( + public static bool TryParseShortDateUi( string value, out DateTime result) { result = default; - if (!TryParseShortDateUsingSpecificCulture(value, Thread.CurrentThread.CurrentUICulture, out var res)) + if (!TryParseShortDateUsingSpecificCulture(value, CultureInfo.CurrentUICulture, out var res)) { return false; } @@ -146,8 +146,8 @@ public static bool TryParseShortDateUsingSpecificCulture( } /// - /// Tries to parse a string representation of a short time using the - /// current UI culture's time format (12-hour or 24-hour). + /// Tries to parse a string representation of a short time using the current UI culture's time format (12-hour or 24-hour). + /// Use this variant when parsing input from a user interface. /// /// The string to parse. /// @@ -157,12 +157,12 @@ public static bool TryParseShortDateUsingSpecificCulture( /// /// if the parsing was successful; otherwise, . /// - public static bool TryParseShortTimeUsingCurrentUiCulture( + public static bool TryParseShortTimeUi( string value, out DateTime result) { result = default; - if (!TryParseShortTimeUsingSpecificCulture(value, Thread.CurrentThread.CurrentUICulture, out var res)) + if (!TryParseShortTimeUsingSpecificCulture(value, CultureInfo.CurrentUICulture, out var res)) { return false; } @@ -221,8 +221,8 @@ public static bool TryParseShortTimeUsingSpecificCulture( } /// - /// Tries to parse a string representation of a short UTC time using the - /// current UI culture's time format (12-hour or 24-hour). + /// Tries to parse a string representation of a short UTC time using the current UI culture's time format (12-hour or 24-hour). + /// Use this variant when parsing input from a user interface. /// /// The string to parse. /// @@ -232,12 +232,12 @@ public static bool TryParseShortTimeUsingSpecificCulture( /// /// if the parsing was successful; otherwise, . /// - public static bool TryParseShortTimeUsingCurrentUiCultureUtc( + public static bool TryParseShortTimeUiUtc( string value, out DateTime result) { result = default; - if (!TryParseShortTimeUsingSpecificCultureUtc(value, Thread.CurrentThread.CurrentUICulture, out var res)) + if (!TryParseShortTimeUsingSpecificCultureUtc(value, CultureInfo.CurrentUICulture, out var res)) { return false; } diff --git a/src/Atc/Helpers/DateTimeOffsetHelper.cs b/src/Atc/Helpers/DateTimeOffsetHelper.cs index 90a6fc31..d4d15fb8 100644 --- a/src/Atc/Helpers/DateTimeOffsetHelper.cs +++ b/src/Atc/Helpers/DateTimeOffsetHelper.cs @@ -10,8 +10,8 @@ public static class DateTimeOffsetHelper private const int MaxTimeLengthFor12Hours = 8; /// - /// Tries to parse a string representation of a DateTimeOffset using - /// the current UI culture's date and time format. + /// Tries to parse a string representation of a DateTimeOffset using the current UI culture's date and time format. + /// Use this variant when parsing input from a user interface. /// /// The string to parse. /// @@ -21,10 +21,40 @@ public static class DateTimeOffsetHelper /// /// if the parsing was successful; otherwise, . /// - public static bool TryParseUsingCurrentUiCulture( + public static bool TryParseUi( string value, out DateTimeOffset result) { + result = default; + if (!TryParseUsingSpecificCulture(value, CultureInfo.CurrentUICulture, out var res)) + { + return false; + } + + result = res; + return true; + } + + /// + /// Tries to parse a string representation of a DateTimeOffset using a specific culture's date and time format. + /// + /// The string to parse. + /// The culture info to use for parsing. + /// + /// When this method returns, contains the parsed DateTimeOffset, + /// if the parse operation was successful; otherwise, contains the default DateTimeOffset. + /// + /// + /// if the parsing was successful; otherwise, . + /// + /// Thrown when is null. + public static bool TryParseUsingSpecificCulture( + string value, + CultureInfo cultureInfo, + out DateTimeOffset result) + { + ArgumentNullException.ThrowIfNull(cultureInfo); + result = default; if (string.IsNullOrWhiteSpace(value) || value.Length < DateLength) @@ -34,7 +64,7 @@ public static bool TryParseUsingCurrentUiCulture( if (!DateTimeOffset.TryParse( value, - Thread.CurrentThread.CurrentUICulture.DateTimeFormat, + cultureInfo.DateTimeFormat, DateTimeStyles.None, out var res)) { @@ -46,8 +76,8 @@ public static bool TryParseUsingCurrentUiCulture( } /// - /// Tries to parse a string representation of a short date using - /// the current UI culture's date format. + /// Tries to parse a string representation of a short date using the current UI culture's date format. + /// Use this variant when parsing input from a user interface. /// /// The string to parse. /// @@ -57,10 +87,40 @@ public static bool TryParseUsingCurrentUiCulture( /// /// if the parsing was successful; otherwise, . /// - public static bool TryParseShortDateUsingCurrentUiCulture( + public static bool TryParseShortDateUi( string value, out DateTimeOffset result) { + result = default; + if (!TryParseShortDateUsingSpecificCulture(value, CultureInfo.CurrentUICulture, out var res)) + { + return false; + } + + result = res; + return true; + } + + /// + /// Tries to parse a string representation of a short date using a specific culture's date format. + /// + /// The string to parse. + /// The culture info to use for parsing. + /// + /// When this method returns, contains the parsed DateTimeOffset, + /// if the parse operation was successful; otherwise, contains the default DateTimeOffset. + /// + /// + /// if the parsing was successful; otherwise, . + /// + /// Thrown when is null. + public static bool TryParseShortDateUsingSpecificCulture( + string value, + CultureInfo cultureInfo, + out DateTimeOffset result) + { + ArgumentNullException.ThrowIfNull(cultureInfo); + result = default; if (string.IsNullOrWhiteSpace(value) || value.Length > DateLength) @@ -70,7 +130,7 @@ public static bool TryParseShortDateUsingCurrentUiCulture( if (!DateTimeOffset.TryParse( value, - Thread.CurrentThread.CurrentUICulture.DateTimeFormat, + cultureInfo.DateTimeFormat, DateTimeStyles.None, out var res)) { @@ -82,8 +142,8 @@ public static bool TryParseShortDateUsingCurrentUiCulture( } /// - /// Tries to parse a string representation of a short time using the - /// current UI culture's time format (12-hour or 24-hour). + /// Tries to parse a string representation of a short time using the current UI culture's time format (12-hour or 24-hour). + /// Use this variant when parsing input from a user interface. /// /// The string to parse. /// @@ -93,14 +153,44 @@ public static bool TryParseShortDateUsingCurrentUiCulture( /// /// if the parsing was successful; otherwise, . /// - public static bool TryParseShortTimeUsingCurrentUiCulture( + public static bool TryParseShortTimeUi( string value, out DateTimeOffset result) { + result = default; + if (!TryParseShortTimeUsingSpecificCulture(value, CultureInfo.CurrentUICulture, out var res)) + { + return false; + } + + result = res; + return true; + } + + /// + /// Tries to parse a string representation of a short time using a specific culture's time format (12-hour or 24-hour). + /// + /// The string to parse. + /// The culture info to use for parsing. + /// + /// When this method returns, contains the parsed DateTimeOffset, + /// if the parse operation was successful; otherwise, contains the default DateTimeOffset. + /// + /// + /// if the parsing was successful; otherwise, . + /// + /// Thrown when is null. + public static bool TryParseShortTimeUsingSpecificCulture( + string value, + CultureInfo cultureInfo, + out DateTimeOffset result) + { + ArgumentNullException.ThrowIfNull(cultureInfo); + result = default; - var use24Hours = !(Thread.CurrentThread.CurrentUICulture.DateTimeFormat.ShortTimePattern.StartsWith("h:", StringComparison.Ordinal) || - Thread.CurrentThread.CurrentUICulture.DateTimeFormat.ShortTimePattern.StartsWith("h.", StringComparison.Ordinal)); + var use24Hours = !(cultureInfo.DateTimeFormat.ShortTimePattern.StartsWith("h:", StringComparison.Ordinal) || + cultureInfo.DateTimeFormat.ShortTimePattern.StartsWith("h.", StringComparison.Ordinal)); var maxLength = use24Hours ? MaxTimeLengthFor24Hours : MaxTimeLengthFor12Hours; @@ -110,10 +200,10 @@ public static bool TryParseShortTimeUsingCurrentUiCulture( return false; } - var dateTimeOffsetValue = $"{DateTimeOffset.Now.ToShortDateStringUsingCurrentUiCulture()} {value}"; + var dateTimeOffsetValue = $"{DateTimeOffset.Now.ToShortDateStringUsingSpecificCulture(cultureInfo)} {value}"; if (!DateTimeOffset.TryParse( dateTimeOffsetValue, - Thread.CurrentThread.CurrentUICulture.DateTimeFormat, + cultureInfo.DateTimeFormat, DateTimeStyles.None, out var res)) { @@ -125,8 +215,8 @@ public static bool TryParseShortTimeUsingCurrentUiCulture( } /// - /// Tries to parse a string representation of a short UTC time using the - /// current UI culture's time format (12-hour or 24-hour). + /// Tries to parse a string representation of a short UTC time using the current UI culture's time format (12-hour or 24-hour). + /// Use this variant when parsing input from a user interface. /// /// The string to parse. /// @@ -136,14 +226,44 @@ public static bool TryParseShortTimeUsingCurrentUiCulture( /// /// if the parsing was successful; otherwise, . /// - public static bool TryParseShortTimeUsingCurrentUiCultureUtc( + public static bool TryParseShortTimeUiUtc( string value, out DateTimeOffset result) { + result = default; + if (!TryParseShortTimeUsingSpecificCultureUtc(value, CultureInfo.CurrentUICulture, out var res)) + { + return false; + } + + result = res; + return true; + } + + /// + /// Tries to parse a string representation of a short UTC time using a specific culture's time format (12-hour or 24-hour). + /// + /// The string to parse. + /// The culture info to use for parsing. + /// + /// When this method returns, contains the parsed DateTimeOffset in UTC, + /// if the parse operation was successful; otherwise, contains the default DateTimeOffset. + /// + /// + /// if the parsing was successful; otherwise, . + /// + /// Thrown when is null. + public static bool TryParseShortTimeUsingSpecificCultureUtc( + string value, + CultureInfo cultureInfo, + out DateTimeOffset result) + { + ArgumentNullException.ThrowIfNull(cultureInfo); + result = default; - var use24Hours = !(Thread.CurrentThread.CurrentUICulture.DateTimeFormat.ShortTimePattern.StartsWith("h:", StringComparison.Ordinal) || - Thread.CurrentThread.CurrentUICulture.DateTimeFormat.ShortTimePattern.StartsWith("h.", StringComparison.Ordinal)); + var use24Hours = !(cultureInfo.DateTimeFormat.ShortTimePattern.StartsWith("h:", StringComparison.Ordinal) || + cultureInfo.DateTimeFormat.ShortTimePattern.StartsWith("h.", StringComparison.Ordinal)); var maxLength = use24Hours ? MaxTimeLengthFor24Hours : MaxTimeLengthFor12Hours; @@ -153,10 +273,10 @@ public static bool TryParseShortTimeUsingCurrentUiCultureUtc( return false; } - var dateTimeOffsetValue = $"{DateTimeOffset.UtcNow.ToShortDateStringUsingCurrentUiCulture()} {value}"; + var dateTimeOffsetValue = $"{DateTimeOffset.UtcNow.ToShortDateStringUsingSpecificCulture(cultureInfo)} {value}"; if (!DateTimeOffset.TryParse( dateTimeOffsetValue, - Thread.CurrentThread.CurrentUICulture.DateTimeFormat, + cultureInfo.DateTimeFormat, DateTimeStyles.None, out var res)) { diff --git a/src/Atc/Helpers/Enums/EnumHelper.cs b/src/Atc/Helpers/Enums/EnumHelper.cs index 1d4125fe..dc727ae7 100644 --- a/src/Atc/Helpers/Enums/EnumHelper.cs +++ b/src/Atc/Helpers/Enums/EnumHelper.cs @@ -212,14 +212,14 @@ public static Dictionary ConvertEnumToDictionary( if (dropDownFirstItemType == DropDownFirstItemType.None) { - if (!list.ContainsKey((int)objEnumValue)) + if (!list.ContainsKey(Convert.ToInt32(objEnumValue, CultureInfo.InvariantCulture))) { - list.Add((int)objEnumValue, value); + list.Add(Convert.ToInt32(objEnumValue, CultureInfo.InvariantCulture), value); } } - else if (!list.ContainsKey((int)objEnumValue)) + else if (!list.ContainsKey(Convert.ToInt32(objEnumValue, CultureInfo.InvariantCulture))) { - list.Add((int)objEnumValue, value); + list.Add(Convert.ToInt32(objEnumValue, CultureInfo.InvariantCulture), value); } } @@ -636,7 +636,7 @@ private static bool ShouldEnumValueBeSkipped( bool byFlagIncludeBase, bool byFlagIncludeCombined) { - if (!includeDefault && (int)objEnumValue == 0) + if (!includeDefault && Convert.ToInt64(objEnumValue, CultureInfo.InvariantCulture) == 0L) { return true; } @@ -646,7 +646,7 @@ private static bool ShouldEnumValueBeSkipped( return false; } - var n = (int)objEnumValue; + var n = Convert.ToInt64(objEnumValue, CultureInfo.InvariantCulture); if (!byFlagIncludeBase && n.IsBinarySequence()) { return true; @@ -662,6 +662,6 @@ private static bool ShouldEnumValueBeSkipped( return false; } - return !includeDefault || (int)objEnumValue != 0; + return !includeDefault || Convert.ToInt64(objEnumValue, CultureInfo.InvariantCulture) != 0L; } } \ No newline at end of file diff --git a/src/Atc/Helpers/MathHelper.cs b/src/Atc/Helpers/MathHelper.cs index 833ef9c4..fc09cbeb 100644 --- a/src/Atc/Helpers/MathHelper.cs +++ b/src/Atc/Helpers/MathHelper.cs @@ -207,9 +207,7 @@ public static int Min(int[] values) throw new ArgumentNullException(nameof(values)); } - return values - .Concat(new[] { int.MaxValue }) - .Min(); + return values.DefaultIfEmpty(int.MaxValue).Min(); } /// @@ -223,9 +221,7 @@ public static int Min(List values) throw new ArgumentNullException(nameof(values)); } - return values - .Concat(new[] { int.MaxValue }) - .Min(); + return values.DefaultIfEmpty(int.MaxValue).Min(); } /// @@ -239,9 +235,7 @@ public static double Min(double[] values) throw new ArgumentNullException(nameof(values)); } - return values - .Concat(new double[] { int.MaxValue }) - .Min(); + return values.DefaultIfEmpty(double.MaxValue).Min(); } /// @@ -255,9 +249,7 @@ public static double Min(List values) throw new ArgumentNullException(nameof(values)); } - return values - .Concat(new double[] { int.MaxValue }) - .Min(); + return values.DefaultIfEmpty(double.MaxValue).Min(); } /// @@ -271,9 +263,7 @@ public static int Max(int[] values) throw new ArgumentNullException(nameof(values)); } - return values - .Concat(new[] { int.MinValue }) - .Max(); + return values.DefaultIfEmpty(int.MinValue).Max(); } /// @@ -287,9 +277,7 @@ public static int Max(List values) throw new ArgumentNullException(nameof(values)); } - return values - .Concat(new[] { int.MinValue }) - .Max(); + return values.DefaultIfEmpty(int.MinValue).Max(); } /// @@ -303,9 +291,7 @@ public static double Max(double[] values) throw new ArgumentNullException(nameof(values)); } - return values - .Concat(new double[] { int.MinValue }) - .Max(); + return values.DefaultIfEmpty(double.MinValue).Max(); } /// @@ -319,9 +305,7 @@ public static double Max(List values) throw new ArgumentNullException(nameof(values)); } - return values - .Concat(new double[] { int.MinValue }) - .Max(); + return values.DefaultIfEmpty(double.MinValue).Max(); } /// @@ -332,7 +316,7 @@ public static double Max(List values) /// if [is equal to zero] [the specified value]; otherwise, . /// public static bool IsEqualToZero(double value) - => System.Math.Abs(value) <= 0.0000001; + => System.Math.Abs(value) <= DoubleExtensions.DoubleEpsilon; /// /// Determines whether the specified value1 is equals. @@ -368,7 +352,7 @@ public static double TruncateToMaxPrecision( return value; } - var decimals = sa[1].Substring(0, decimalPrecision); + var decimals = sa[1].Substring(0, System.Math.Min(decimalPrecision, sa[1].Length)); return double.Parse($"{sa[0]}.{decimals}", GlobalizationConstants.EnglishCultureInfo); } } \ No newline at end of file diff --git a/src/Atc/Helpers/NetworkInformationHelper.cs b/src/Atc/Helpers/NetworkInformationHelper.cs index bb60c1a6..f27045c2 100644 --- a/src/Atc/Helpers/NetworkInformationHelper.cs +++ b/src/Atc/Helpers/NetworkInformationHelper.cs @@ -7,6 +7,8 @@ namespace Atc.Helpers; [SuppressMessage("Minor Code Smell", "S1075:URIs should not be hardcoded", Justification = "OK.")] public static class NetworkInformationHelper { + private static readonly HttpClient SharedHttpClient = new(); + /// /// Determines whether there is network connectivity by pinging Google's DNS server (8.8.8.8). /// @@ -68,8 +70,7 @@ public static bool HasHttpConnection(Uri uri) { try { - using HttpClient client = new HttpClient(); - await client + await SharedHttpClient .GetStringAsync(uri) .ConfigureAwait(false); @@ -100,15 +101,19 @@ public static bool HasTcpConnection( throw new ArgumentNullException(nameof(ipAddress)); } + var client = new TcpClient(); try { - using var client = new TcpClient(ipAddress.ToString(), port); - return client.Connected; + return client.ConnectAsync(ipAddress, port).Wait(5_000) && client.Connected; } catch { return false; } + finally + { + client.Dispose(); + } } /// @@ -123,8 +128,7 @@ public static bool HasTcpConnection( { try { - using var client = new HttpClient(); - response = await client + response = await SharedHttpClient .GetStringAsync(new Uri("https://api.ipify.org")) .ConfigureAwait(false); } @@ -143,4 +147,200 @@ public static bool HasTcpConnection( ? ipAddress : null; } + + /// + /// Asynchronously determines whether there is network connectivity by pinging Google's DNS server (8.8.8.8). + /// + /// A token to cancel the asynchronous operation. + /// if a ping response is received; otherwise, . + public static Task HasConnectionAsync( + CancellationToken cancellationToken = default) + { + const string googleDns = "8.8.8.8"; + return HasConnectionAsync(IPAddress.Parse(googleDns), cancellationToken); + } + + /// + /// Asynchronously determines whether there is network connectivity to a specified IP address. + /// + /// The IP address to ping. + /// A token to cancel the asynchronous operation. + /// if a ping response is received; otherwise, . + /// Thrown if is . + public static async Task HasConnectionAsync( + IPAddress ipAddress, + CancellationToken cancellationToken = default) + { + if (ipAddress is null) + { + throw new ArgumentNullException(nameof(ipAddress)); + } + + try + { + using var ping = new Ping(); + var buffer = new byte[32]; + + const int timeout = 1000; + var pingOptions = new PingOptions(); + +#if NET7_0_OR_GREATER + var pingReply = await ping.SendPingAsync( + ipAddress, + TimeSpan.FromMilliseconds(timeout), + buffer, + pingOptions, + cancellationToken).ConfigureAwait(false); +#else + cancellationToken.ThrowIfCancellationRequested(); + var pingReply = await ping.SendPingAsync(ipAddress, timeout, buffer, pingOptions).ConfigureAwait(false); +#endif + + return pingReply is not null && + pingReply.Status == IPStatus.Success; + } + catch (OperationCanceledException) + { + throw; + } + catch + { + return false; + } + } + + /// + /// Asynchronously determines whether there is HTTP connectivity by making a request to Google's website. + /// + /// A token to cancel the asynchronous operation. + /// if the HTTP request succeeds; otherwise, . + public static Task HasHttpConnectionAsync( + CancellationToken cancellationToken = default) + => HasHttpConnectionAsync(new Uri("https://www.google.com/"), cancellationToken); + + /// + /// Asynchronously determines whether there is HTTP connectivity to a specified URI. + /// + /// The URI to request. + /// A token to cancel the asynchronous operation. + /// if the HTTP request succeeds; otherwise, . + /// Thrown if is . + public static async Task HasHttpConnectionAsync( + Uri uri, + CancellationToken cancellationToken = default) + { + if (uri is null) + { + throw new ArgumentNullException(nameof(uri)); + } + + try + { + using var response = await SharedHttpClient + .GetAsync(uri, HttpCompletionOption.ResponseHeadersRead, cancellationToken) + .ConfigureAwait(false); + + response.EnsureSuccessStatusCode(); + return true; + } + catch (OperationCanceledException) + { + throw; + } + catch + { + return false; + } + } + + /// + /// Asynchronously determines whether a TCP connection can be established to the specified IP address and port. + /// A connection timeout of 5 seconds is applied; pass a pre-cancelled token to impose a shorter deadline. + /// + /// The IP address to connect to. + /// The port number to connect to. + /// A token to cancel the asynchronous operation. + /// if the TCP connection succeeds within the timeout; otherwise, . + /// Thrown if is . + public static async Task HasTcpConnectionAsync( + IPAddress ipAddress, + int port, + CancellationToken cancellationToken = default) + { + if (ipAddress is null) + { + throw new ArgumentNullException(nameof(ipAddress)); + } + + var client = new TcpClient(); + try + { +#if NET5_0_OR_GREATER + using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + cts.CancelAfter(5_000); + await client.ConnectAsync(ipAddress, port, cts.Token).ConfigureAwait(false); +#else + var connectTask = client.ConnectAsync(ipAddress, port); + await Task.WhenAny(connectTask, Task.Delay(5_000, CancellationToken.None)).ConfigureAwait(false); + cancellationToken.ThrowIfCancellationRequested(); +#endif + return client.Connected; + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch + { + return false; + } + finally + { + client.Dispose(); + } + } + + /// + /// Asynchronously retrieves the public IP address of the current machine by querying an external service (api.ipify.org). + /// + /// A token to cancel the asynchronous operation. + /// The public if retrieval succeeds; otherwise, . + public static async Task GetPublicIpAddressAsync( + CancellationToken cancellationToken = default) + { + try + { + using var response = await SharedHttpClient + .GetAsync(new Uri("https://api.ipify.org"), cancellationToken) + .ConfigureAwait(false); + + response.EnsureSuccessStatusCode(); +#if NET5_0_OR_GREATER + var responseBody = await response.Content + .ReadAsStringAsync(cancellationToken) + .ConfigureAwait(false); +#else + var responseBody = await response.Content + .ReadAsStringAsync() + .ConfigureAwait(false); +#endif + + if (string.IsNullOrEmpty(responseBody)) + { + return null; + } + + return IPAddress.TryParse(responseBody, out var ipAddress) + ? ipAddress + : null; + } + catch (OperationCanceledException) + { + throw; + } + catch + { + return null; + } + } } \ No newline at end of file diff --git a/src/Atc/Helpers/ProcessHelper.cs b/src/Atc/Helpers/ProcessHelper.cs index 74beb641..6a694969 100644 --- a/src/Atc/Helpers/ProcessHelper.cs +++ b/src/Atc/Helpers/ProcessHelper.cs @@ -111,6 +111,103 @@ public static class ProcessHelper cancellationToken); } + /// + /// Executes a process with the specified file and arguments, returning standard output and standard error separately. + /// + /// The executable file to run. + /// The command-line arguments to pass to the executable. + /// If , attempts to run the process with elevated privileges. + /// The maximum time in seconds to wait for the process to complete. Default is 30 seconds. + /// A token to cancel the operation. + /// A task that returns a tuple containing success status, standard output, and standard error streams separately. + /// Thrown if or is . + /// Thrown if the specified file does not exist. + public static Task<( + bool IsSuccessful, + string StdOut, + string StdErr)> ExecuteWithSeparateOutput( + FileInfo fileInfo, + string arguments, + bool runAsAdministrator = false, + ushort timeoutInSec = DefaultTimeoutInSec, + CancellationToken cancellationToken = default) + { + if (fileInfo is null) + { + throw new ArgumentNullException(nameof(fileInfo)); + } + + if (arguments is null) + { + throw new ArgumentNullException(nameof(arguments)); + } + + if (!File.Exists(fileInfo.FullName)) + { + throw new FileNotFoundException(nameof(fileInfo)); + } + + return InvokeExecuteWithTimeoutSeparate( + workingDirectory: null, + fileInfo, + arguments, + runAsAdministrator, + timeoutInSec, + cancellationToken); + } + + /// + /// Executes a process with the specified working directory, file, and arguments, returning standard output and standard error separately. + /// + /// The working directory for the process. + /// The executable file to run. + /// The command-line arguments to pass to the executable. + /// If , attempts to run the process with elevated privileges. + /// The maximum time in seconds to wait for the process to complete. Default is 30 seconds. + /// A token to cancel the operation. + /// A task that returns a tuple containing success status, standard output, and standard error streams separately. + /// Thrown if , , or is . + /// Thrown if the specified file does not exist. + public static Task<( + bool IsSuccessful, + string StdOut, + string StdErr)> ExecuteWithSeparateOutput( + DirectoryInfo workingDirectory, + FileInfo fileInfo, + string arguments, + bool runAsAdministrator = false, + ushort timeoutInSec = DefaultTimeoutInSec, + CancellationToken cancellationToken = default) + { + if (workingDirectory is null) + { + throw new ArgumentNullException(nameof(workingDirectory)); + } + + if (fileInfo is null) + { + throw new ArgumentNullException(nameof(fileInfo)); + } + + if (arguments is null) + { + throw new ArgumentNullException(nameof(arguments)); + } + + if (!File.Exists(fileInfo.FullName)) + { + throw new FileNotFoundException(nameof(fileInfo)); + } + + return InvokeExecuteWithTimeoutSeparate( + workingDirectory, + fileInfo, + arguments, + runAsAdministrator, + timeoutInSec, + cancellationToken); + } + /// /// Executes a process without capturing its output, returning only success status. /// @@ -645,25 +742,39 @@ public static (bool IsSuccessful, string Output) KillByName( ushort timeoutInSec, CancellationToken cancellationToken) { - var processId = -1; + var processIdHolder = new[] { -1 }; var resultOutput = string.Empty; try { - var (isSuccessful, output, assignedProcessId) = await TaskHelper + var (isSuccessful, stdOut, stdErr, _) = await TaskHelper .Execute( - _ => InvokeExecuteWithProcessId(workingDirectory, fileInfo, arguments, runAsAdministrator), + _ => InvokeExecuteWithProcessId(workingDirectory, fileInfo, arguments, runAsAdministrator, id => Volatile.Write(ref processIdHolder[0], id)), TimeSpan.FromSeconds(timeoutInSec), cancellationToken) .ConfigureAwait(false); - processId = assignedProcessId; + string output; + if (string.IsNullOrEmpty(stdErr)) + { + output = stdOut; + } + else if (string.IsNullOrEmpty(stdOut)) + { + output = stdErr; + } + else + { + output = $"{stdOut}{Environment.NewLine}{stdErr}"; + } + resultOutput = output; return (IsSuccessful: isSuccessful, Output: output); } catch (TimeoutException) { + var processId = Volatile.Read(ref processIdHolder[0]); if (processId > 0) { var (killIsSuccessful, _) = KillById(processId); @@ -691,6 +802,53 @@ public static (bool IsSuccessful, string Output) KillByName( } } + private static async Task<( + bool IsSuccessful, + string StdOut, + string StdErr)> InvokeExecuteWithTimeoutSeparate( + DirectoryInfo? workingDirectory, + FileInfo fileInfo, + string arguments, + bool runAsAdministrator, + ushort timeoutInSec, + CancellationToken cancellationToken) + { + var processIdHolder = new[] { -1 }; + + try + { + var (isSuccessful, stdOut, stdErr, _) = await TaskHelper + .Execute( + _ => InvokeExecuteWithProcessId(workingDirectory, fileInfo, arguments, runAsAdministrator, id => Volatile.Write(ref processIdHolder[0], id)), + TimeSpan.FromSeconds(timeoutInSec), + cancellationToken) + .ConfigureAwait(false); + + return (IsSuccessful: isSuccessful, StdOut: stdOut, StdErr: stdErr); + } + catch (TimeoutException) + { + var processId = Volatile.Read(ref processIdHolder[0]); + string stdErr; + if (processId > 0) + { + var (killIsSuccessful, _) = KillById(processId); + stdErr = killIsSuccessful + ? $"Process has been running for {timeoutInSec} seconds. before terminated." + : $"Process has been running for {timeoutInSec} seconds."; + } + else + { + stdErr = $"Process has been running for {timeoutInSec} seconds."; + } + + return ( + IsSuccessful: false, + StdOut: string.Empty, + StdErr: stdErr); + } + } + private static async Task InvokeExecuteWithTimeoutAndIgnoreOutput( DirectoryInfo? workingDirectory, FileInfo fileInfo, @@ -763,25 +921,25 @@ await process int timeoutInSec, CancellationToken cancellationToken) { - var processId = -1; + var processIdHolder = new[] { -1 }; var resultOutput = string.Empty; try { - var (isSuccessful, output, assignedProcessId) = await TaskHelper + var (isSuccessful, output, _) = await TaskHelper .Execute( - _ => InvokeExecutePromptWithProcessId(workingDirectory, fileInfo, arguments, inputLines, runAsAdministrator), + _ => InvokeExecutePromptWithProcessId(workingDirectory, fileInfo, arguments, inputLines, runAsAdministrator, id => Volatile.Write(ref processIdHolder[0], id)), TimeSpan.FromSeconds(timeoutInSec), cancellationToken) .ConfigureAwait(false); - processId = assignedProcessId; resultOutput = output; return (IsSuccessful: isSuccessful, Output: output); } catch (TimeoutException) { + var processId = Volatile.Read(ref processIdHolder[0]); if (processId > 0) { var (killIsSuccessful, _) = KillById(processId); @@ -809,16 +967,17 @@ await process } } - [SuppressMessage("Major Code Smell", "S3358:Ternary operators should not be nested", Justification = "OK.")] [SuppressMessage("Microsoft.Design", "CA1031:Do not catch general exception types", Justification = "OK.")] private static async Task<( bool IsSuccessful, - string Output, + string StdOut, + string StdErr, int ProcessId)> InvokeExecuteWithProcessId( DirectoryInfo? workingDirectory, FileInfo fileInfo, string arguments, - bool runAsAdministrator) + bool runAsAdministrator, + Action? onProcessStarted = null) { using var process = CreateProcess( redirectStandard: true, @@ -833,39 +992,34 @@ await process { process.Start(); processId = process.Id; + onProcessStarted?.Invoke(processId); - var standardOutput = await process - .StandardOutput - .ReadToEndAsync() - .ConfigureAwait(false); - - var standardError = await process - .StandardError - .ReadToEndAsync() - .ConfigureAwait(false); + // Drain stdout and stderr concurrently to avoid a pipe-buffer deadlock + // when the child process fills one buffer while we wait on the other. + var standardOutputTask = process.StandardOutput.ReadToEndAsync(); + var standardErrorTask = process.StandardError.ReadToEndAsync(); await process .WaitForExitAsync() .ConfigureAwait(false); - var message = string.IsNullOrEmpty(standardError) - ? standardOutput - : string.IsNullOrEmpty(standardOutput) - ? standardError - : $"{standardOutput}{Environment.NewLine}{standardError}"; + var standardOutput = await standardOutputTask.ConfigureAwait(false); + var standardError = await standardErrorTask.ConfigureAwait(false); return ( - IsSuccessful: process.ExitCode == ConsoleExitStatusCodes.Success && string.IsNullOrEmpty(standardError), - Output: message, + IsSuccessful: process.ExitCode == ConsoleExitStatusCodes.Success, + StdOut: standardOutput, + StdErr: standardError, ProcessId: processId); } catch (Exception ex) { return ( IsSuccessful: false, - Output: ex.GetMessage( + StdOut: ex.GetMessage( includeInnerMessage: true, includeExceptionName: true), + StdErr: string.Empty, ProcessId: processId); } } @@ -880,7 +1034,8 @@ await process FileInfo fileInfo, string arguments, IEnumerable inputLines, - bool runAsAdministrator) + bool runAsAdministrator, + Action? onProcessStarted = null) { using var process = CreateProcess( redirectStandard: true, @@ -895,6 +1050,12 @@ await process { process.Start(); processId = process.Id; + onProcessStarted?.Invoke(processId); + + // Start draining stdout and stderr concurrently before blocking on input/exit + // to avoid a pipe-buffer deadlock when the child writes a lot to either stream. + var standardOutputTask = process.StandardOutput.ReadToEndAsync(); + var standardErrorTask = process.StandardError.ReadToEndAsync(); foreach (var line in inputLines) { @@ -904,20 +1065,13 @@ await process .ConfigureAwait(false); } - var standardOutput = await process - .StandardOutput - .ReadToEndAsync() - .ConfigureAwait(false); - - var standardError = await process - .StandardError - .ReadToEndAsync() - .ConfigureAwait(false); - await process .WaitForExitAsync() .ConfigureAwait(false); + var standardOutput = await standardOutputTask.ConfigureAwait(false); + var standardError = await standardErrorTask.ConfigureAwait(false); + var message = string.IsNullOrEmpty(standardError) ? standardOutput : string.IsNullOrEmpty(standardOutput) @@ -925,7 +1079,7 @@ await process : $"{standardOutput}{Environment.NewLine}{standardError}"; return ( - IsSuccessful: process.ExitCode == ConsoleExitStatusCodes.Success && string.IsNullOrEmpty(standardError), + IsSuccessful: process.ExitCode == ConsoleExitStatusCodes.Success, Output: message, ProcessId: processId); } @@ -1034,26 +1188,23 @@ private static async Task InvokeExecuteAsync( { process.Start(); + // Drain stdout and stderr concurrently to avoid a pipe-buffer deadlock + // when the child process fills one buffer while we wait on the other. #if NET9_0_OR_GREATER - var standardOutput = await process.StandardOutput - .ReadToEndAsync(linkedCts.Token) - .ConfigureAwait(false); - var standardError = await process.StandardError - .ReadToEndAsync(linkedCts.Token) - .ConfigureAwait(false); + var standardOutputTask = process.StandardOutput.ReadToEndAsync(linkedCts.Token); + var standardErrorTask = process.StandardError.ReadToEndAsync(linkedCts.Token); #else - var standardOutput = await process.StandardOutput - .ReadToEndAsync() - .ConfigureAwait(false); - var standardError = await process.StandardError - .ReadToEndAsync() - .ConfigureAwait(false); + var standardOutputTask = process.StandardOutput.ReadToEndAsync(); + var standardErrorTask = process.StandardError.ReadToEndAsync(); #endif await process .WaitForExitAsync(linkedCts.Token) .ConfigureAwait(false); + var standardOutput = await standardOutputTask.ConfigureAwait(false); + var standardError = await standardErrorTask.ConfigureAwait(false); + var message = string.IsNullOrEmpty(standardError) ? standardOutput : string.IsNullOrEmpty(standardOutput) @@ -1061,7 +1212,7 @@ await process : $"{standardOutput}{Environment.NewLine}{standardError}"; return new ProcessExecutionResult( - IsSuccessful: process.ExitCode == ConsoleExitStatusCodes.Success && string.IsNullOrEmpty(standardError), + IsSuccessful: process.ExitCode == ConsoleExitStatusCodes.Success, Output: message, ExitCode: process.ExitCode); } @@ -1109,26 +1260,23 @@ private static async Task InvokeExecuteAsyncFromStartInf { process.Start(); + // Drain stdout and stderr concurrently to avoid a pipe-buffer deadlock + // when the child process fills one buffer while we wait on the other. #if NET9_0_OR_GREATER - var standardOutput = await process.StandardOutput - .ReadToEndAsync(linkedCts.Token) - .ConfigureAwait(false); - var standardError = await process.StandardError - .ReadToEndAsync(linkedCts.Token) - .ConfigureAwait(false); + var standardOutputTask = process.StandardOutput.ReadToEndAsync(linkedCts.Token); + var standardErrorTask = process.StandardError.ReadToEndAsync(linkedCts.Token); #else - var standardOutput = await process.StandardOutput - .ReadToEndAsync() - .ConfigureAwait(false); - var standardError = await process.StandardError - .ReadToEndAsync() - .ConfigureAwait(false); + var standardOutputTask = process.StandardOutput.ReadToEndAsync(); + var standardErrorTask = process.StandardError.ReadToEndAsync(); #endif await process .WaitForExitAsync(linkedCts.Token) .ConfigureAwait(false); + var standardOutput = await standardOutputTask.ConfigureAwait(false); + var standardError = await standardErrorTask.ConfigureAwait(false); + var message = string.IsNullOrEmpty(standardError) ? standardOutput : string.IsNullOrEmpty(standardOutput) @@ -1136,7 +1284,7 @@ await process : $"{standardOutput}{Environment.NewLine}{standardError}"; return new ProcessExecutionResult( - IsSuccessful: process.ExitCode == ConsoleExitStatusCodes.Success && string.IsNullOrEmpty(standardError), + IsSuccessful: process.ExitCode == ConsoleExitStatusCodes.Success, Output: message, ExitCode: process.ExitCode); } diff --git a/src/Atc/Math/GeoSpatial/GeoSpatialHelper.cs b/src/Atc/Math/GeoSpatial/GeoSpatialHelper.cs index 68a99981..1bb85f8a 100644 --- a/src/Atc/Math/GeoSpatial/GeoSpatialHelper.cs +++ b/src/Atc/Math/GeoSpatial/GeoSpatialHelper.cs @@ -26,6 +26,7 @@ public static double Distance( /// The longitude of the second point in degrees. /// The latitude of the second point in degrees. /// The unit of measurement for the result. Default is kilometers. + /// The Earth radius in kilometers used for the calculation. Defaults to the mean Earth radius of 6371 km. /// The great-circle distance between the two points in the specified measurement unit. /// /// This method assumes a spherical Earth and uses the Haversine formula for calculation. @@ -36,35 +37,69 @@ public static double Distance( double latitude1, double longitude2, double latitude2, - DistanceMeasurementType measurement = DistanceMeasurementType.Kilometers) + DistanceMeasurementType measurement = DistanceMeasurementType.Kilometers, + double earthRadiusKm = 6371.0) { - var diff = longitude1 - longitude2; - var distance = (System.Math.Sin(MathHelper.DegreesToRadians(latitude1)) * System.Math.Sin(MathHelper.DegreesToRadians(latitude2))) + - (System.Math.Cos(MathHelper.DegreesToRadians(latitude1)) * System.Math.Cos(MathHelper.DegreesToRadians(latitude2)) * System.Math.Cos(MathHelper.DegreesToRadians(diff))); - distance = System.Math.Acos(distance); - distance = MathHelper.RadiansToDegrees(distance); - distance = distance * 60 * 1.1515; - switch (measurement) + var lat1Rad = MathHelper.DegreesToRadians(latitude1); + var lat2Rad = MathHelper.DegreesToRadians(latitude2); + var dLat = MathHelper.DegreesToRadians(latitude2 - latitude1); + var dLon = MathHelper.DegreesToRadians(longitude2 - longitude1); + + var a = (System.Math.Sin(dLat / 2) * System.Math.Sin(dLat / 2)) + + (System.Math.Cos(lat1Rad) * System.Math.Cos(lat2Rad) * + System.Math.Sin(dLon / 2) * System.Math.Sin(dLon / 2)); + + var c = 2 * System.Math.Atan2(System.Math.Sqrt(a), System.Math.Sqrt(1 - a)); + var distanceKm = earthRadiusKm * c; + + return measurement switch { - case DistanceMeasurementType.Meters: - distance = distance * 1.609344 * 1000; - break; - case DistanceMeasurementType.Feet: - distance = distance * 1.609344 * 1000 * 3.2808399; - break; - case DistanceMeasurementType.Kilometers: - distance *= 1.609344; - break; - case DistanceMeasurementType.StatuteMiles: - // default - break; - case DistanceMeasurementType.NauticalMiles: - distance *= 0.8684; - break; - default: - throw new SwitchCaseDefaultException(measurement); - } + DistanceMeasurementType.Meters => distanceKm * 1_000, + DistanceMeasurementType.Feet => distanceKm * 1_000 * 3.2808399, + DistanceMeasurementType.Kilometers => distanceKm, + DistanceMeasurementType.StatuteMiles => distanceKm / 1.609344, + DistanceMeasurementType.NauticalMiles => distanceKm / 1.852, + _ => throw new SwitchCaseDefaultException(measurement), + }; + } + + /// + /// Calculates the initial bearing (forward azimuth) from one geographic coordinate to another. + /// The bearing is the angle measured clockwise from true north (0°) to the direction of travel. + /// + /// The starting coordinate. + /// The destination coordinate. + /// The initial bearing in degrees (0–360), where 0° is north, 90° east, 180° south, 270° west. + public static double Bearing( + CartesianCoordinate coordinate1, + CartesianCoordinate coordinate2) + => Bearing(coordinate1.Longitude, coordinate1.Latitude, coordinate2.Longitude, coordinate2.Latitude); + + /// + /// Calculates the initial bearing (forward azimuth) from one geographic point to another. + /// The bearing is the angle measured clockwise from true north (0°) to the direction of travel. + /// + /// The longitude of the starting point in degrees. + /// The latitude of the starting point in degrees. + /// The longitude of the destination point in degrees. + /// The latitude of the destination point in degrees. + /// The initial bearing in degrees (0–360), where 0° is north, 90° east, 180° south, 270° west. + public static double Bearing( + double longitude1, + double latitude1, + double longitude2, + double latitude2) + { + var lat1Rad = MathHelper.DegreesToRadians(latitude1); + var lat2Rad = MathHelper.DegreesToRadians(latitude2); + var dLonRad = MathHelper.DegreesToRadians(longitude2 - longitude1); + + var y = System.Math.Sin(dLonRad) * System.Math.Cos(lat2Rad); + var x = (System.Math.Cos(lat1Rad) * System.Math.Sin(lat2Rad)) - + (System.Math.Sin(lat1Rad) * System.Math.Cos(lat2Rad) * System.Math.Cos(dLonRad)); - return distance; + var bearingRad = System.Math.Atan2(y, x); + var bearingDeg = MathHelper.RadiansToDegrees(bearingRad) + 360.0; + return bearingDeg % 360.0; } } \ No newline at end of file diff --git a/src/Atc/Math/GeoSpatial/UniversalTransverseMercatorConverter.cs b/src/Atc/Math/GeoSpatial/UniversalTransverseMercatorConverter.cs index d60c61b5..c09a74b4 100644 --- a/src/Atc/Math/GeoSpatial/UniversalTransverseMercatorConverter.cs +++ b/src/Atc/Math/GeoSpatial/UniversalTransverseMercatorConverter.cs @@ -125,11 +125,11 @@ public UniversalTransverseMercatorResult ToUtm( + (15 * eccSquared * eccSquared / 256 + 45 * eccSquared * eccSquared * eccSquared / 1024) * System.Math.Sin(4 * latitudeRadian) - 35 * eccSquared * eccSquared * eccSquared / 3072 * System.Math.Sin(6 * latitudeRadian)); - var utmEasting = 0.9996 * N * (A + (1 - T + C) * A * A * A / 6 + var utmEasting = UTM_FAKTOR * N * (A + (1 - T + C) * A * A * A / 6 + (5 - 18 * T + T * T + 72 * C - 58 * eccPrimeSquared) * A * A * A * A * A / 120) - + 500000.0; + + UTM_FALSE_EASTING; - var utmNorthing = 0.9996 * (M + N * System.Math.Tan(latitudeRadian) * (A * A / 2 + (5 - T + 9 * C + 4 * C * C) * A * A * A * A / 24 + var utmNorthing = UTM_FAKTOR * (M + N * System.Math.Tan(latitudeRadian) * (A * A / 2 + (5 - T + 9 * C + 4 * C * C) * A * A * A * A / 24 + (61 - 58 * T + T * T + 600 * C - 330 * eccPrimeSquared) * A * A * A * A * A * A / 720)); if (latitude < 0) @@ -140,6 +140,27 @@ public UniversalTransverseMercatorResult ToUtm( return new UniversalTransverseMercatorResult(zoneNumber, utmZone, utmEasting, utmNorthing); } + /// + /// Converts a back to a WGS84 geographic coordinate. + /// This is a convenience overload that unpacks the fields from the result returned by or . + /// + /// The UTM result to convert. + /// The maximum number of decimal places in the returned latitude/longitude values. + /// A containing the WGS84 latitude and longitude. + /// Thrown when is null. + [SuppressMessage("Performance", "CA1822:Mark members as static", Justification = "OK.")] + public CartesianCoordinate ToWgs84( + UniversalTransverseMercatorResult utmResult, + int maxDecimalPrecision = 8) + { + if (utmResult is null) + { + throw new ArgumentNullException(nameof(utmResult)); + } + + return ToWgs84(utmResult.ZoneNumber, utmResult.ZoneLetter, utmResult.UtmEasting, utmResult.UtmNorthing, maxDecimalPrecision); + } + /// /// To WGS84. /// @@ -178,8 +199,7 @@ public CartesianCoordinate ToWgs84( MathHelper.RadiansToDegrees(151 * WGS84_EXZENT6 / 6144 - 453 * WGS84_EXZENT8 / 12288); // Northern / Southern Hemisphere - var b = utmZoneLetter[0]; - if (b < 'N' && !string.IsNullOrEmpty(utmZoneLetter)) + if (!string.IsNullOrEmpty(utmZoneLetter) && utmZoneLetter[0] < 'N') { utmNorthing -= 10E+06; } @@ -201,20 +221,20 @@ public CartesianCoordinate ToWgs84( // Transverse curvature var qkhm1 = WGS84_POL / System.Math.Sqrt(1 + eta); - var qkhm2 = System.Math.Pow(qkhm1, 2); - var qkhm3 = System.Math.Pow(qkhm1, 3); - var qkhm4 = System.Math.Pow(qkhm1, 4); - var qkhm5 = System.Math.Pow(qkhm1, 5); - var qkhm6 = System.Math.Pow(qkhm1, 6); + var qkhm2 = qkhm1 * qkhm1; + var qkhm3 = qkhm2 * qkhm1; + var qkhm4 = qkhm2 * qkhm2; + var qkhm5 = qkhm4 * qkhm1; + var qkhm6 = qkhm3 * qkhm3; // Difference to the reference meridian var merid = (utmZoneNumber - 30) * 6 - 3; var dlongitude1 = (utmEasting - UTM_FALSE_EASTING) / UTM_FAKTOR; - var dlongitude2 = System.Math.Pow(dlongitude1, 2); - var dlongitude3 = System.Math.Pow(dlongitude1, 3); - var dlongitude4 = System.Math.Pow(dlongitude1, 4); - var dlongitude5 = System.Math.Pow(dlongitude1, 5); - var dlongitude6 = System.Math.Pow(dlongitude1, 6); + var dlongitude2 = dlongitude1 * dlongitude1; + var dlongitude3 = dlongitude2 * dlongitude1; + var dlongitude4 = dlongitude2 * dlongitude2; + var dlongitude5 = dlongitude4 * dlongitude1; + var dlongitude6 = dlongitude3 * dlongitude3; // Factors for latitude calculation var bfakt2 = -tangens1 * (1 + eta) / (2 * qkhm2); diff --git a/src/Atc/Math/Geometry/CircleHelper.cs b/src/Atc/Math/Geometry/CircleHelper.cs index 80564b96..7a567ace 100644 --- a/src/Atc/Math/Geometry/CircleHelper.cs +++ b/src/Atc/Math/Geometry/CircleHelper.cs @@ -11,7 +11,7 @@ public static class CircleHelper /// The radius of the circle. /// The area of the circle (π * r²). public static double Area(double radius) - => System.Math.PI * System.Math.Pow(radius, 2); + => System.Math.PI * radius * radius; /// /// Calculates the circumference of a circle given its radius. diff --git a/src/Atc/Math/Geometry/CoordinateSystem/CartesianHelper.cs b/src/Atc/Math/Geometry/CoordinateSystem/CartesianHelper.cs index a9863e6a..affe255b 100644 --- a/src/Atc/Math/Geometry/CoordinateSystem/CartesianHelper.cs +++ b/src/Atc/Math/Geometry/CoordinateSystem/CartesianHelper.cs @@ -74,17 +74,9 @@ public static double DistanceBetweenTwoPoints( double x2, double y2) { - // Take x2-x1, then square it - var part1 = System.Math.Pow(x2 - x1, 2); - - // Take y2-y1, then square it - var part2 = System.Math.Pow(y2 - y1, 2); - - // Add both of the parts together - var underRadical = part1 + part2; - - // Get the square root of the parts - return System.Math.Sqrt(underRadical); + var dx = x2 - x1; + var dy = y2 - y1; + return System.Math.Sqrt((dx * dx) + (dy * dy)); } /// @@ -106,14 +98,12 @@ public static double DistanceBetweenTwoPoints( double y2, double z2) { - // Take x2-x1, then square it - var part1 = System.Math.Pow(x2 - x1, 2); - - // Take y2-y1, then square it - var part2 = System.Math.Pow(y2 - y1, 2); - - // Take z2-z1, then square it - var part3 = System.Math.Pow(z2 - z1, 2); + var dx = x2 - x1; + var dy = y2 - y1; + var dz = z2 - z1; + var part1 = dx * dx; + var part2 = dy * dy; + var part3 = dz * dz; // Add both of the parts together var underRadical = part1 + part2 + part3; diff --git a/src/Atc/Math/Geometry/TriangleHelper.cs b/src/Atc/Math/Geometry/TriangleHelper.cs index d20c9e71..424fbc5d 100644 --- a/src/Atc/Math/Geometry/TriangleHelper.cs +++ b/src/Atc/Math/Geometry/TriangleHelper.cs @@ -50,7 +50,7 @@ public static bool IsSumOfTheAnglesATriangle( double angleA, double angleB, double angleC) - => (angleA + angleB + angleC).IsEqual(180); + => System.Math.Abs(angleA + angleB + angleC - 180.0) < 1e-9; /// /// Calculate the unspecified side (unspecified with NULL). @@ -73,19 +73,37 @@ public static double Pythagorean( if (sideA is null && sideB is not null && sideC is not null) { // Calc sideA - return System.Math.Sqrt(System.Math.Pow((double)sideC, 2) - System.Math.Pow((double)sideB, 2)); + var c = (double)sideC; + var b = (double)sideB; + var radicand = (c * c) - (b * b); + if (radicand < 0) + { + throw new ArithmeticException("The given side lengths do not form a valid right triangle."); + } + + return System.Math.Sqrt(radicand); } if (sideA is not null && sideB is null && sideC is not null) { // Calc sideB - return System.Math.Sqrt(System.Math.Pow((double)sideC, 2) - System.Math.Pow((double)sideA, 2)); + var c = (double)sideC; + var a = (double)sideA; + var radicand = (c * c) - (a * a); + if (radicand < 0) + { + throw new ArithmeticException("The given side lengths do not form a valid right triangle."); + } + + return System.Math.Sqrt(radicand); } if (sideA is not null && sideB is not null && sideC is null) { // Calc sideC - return System.Math.Sqrt(System.Math.Pow((double)sideA, 2) + System.Math.Pow((double)sideB, 2)); + var a = (double)sideA; + var b = (double)sideB; + return System.Math.Sqrt((a * a) + (b * b)); } throw new ArithmeticException("Expected early return - Bad implementation."); diff --git a/src/Atc/Math/Trigonometry/TriangleHelper.cs b/src/Atc/Math/Trigonometry/TriangleHelper.cs index ad6db76f..3da3dc91 100644 --- a/src/Atc/Math/Trigonometry/TriangleHelper.cs +++ b/src/Atc/Math/Trigonometry/TriangleHelper.cs @@ -74,6 +74,13 @@ public static TriangleData SinesAndCosines( return result; } + /// + /// The maximum number of refinement passes performed by . + /// A solvable triangle converges within one or two passes; this cap guards against + /// under-determined or degenerate inputs that would otherwise recurse indefinitely. + /// + private const int MaxCalculationPasses = 8; + private static bool IsAngleAndSidesCalculated(TriangleData result) => !MathHelper.IsEqualToZero(result.A) && !MathHelper.IsEqualToZero(result.B) @@ -91,7 +98,9 @@ private static bool IsAngleAndSidesCalculated(TriangleData result) /// [SuppressMessage("Design", "MA0051:Method is too long", Justification = "OK.")] [SuppressMessage("Critical Code Smell", "S3776:Cognitive Complexity of methods should not be too high", Justification = "OK.")] - private static TriangleData CalculateAnglesAndSides(TriangleData data) + private static TriangleData CalculateAnglesAndSides( + TriangleData data, + int remainingPasses = MaxCalculationPasses) { // A if (MathHelper.IsEqualToZero(data.A)) @@ -255,8 +264,16 @@ private static TriangleData CalculateAnglesAndSides(TriangleData data) } } - return IsAngleAndSidesCalculated(data) - ? data - : CalculateAnglesAndSides(data); + if (IsAngleAndSidesCalculated(data)) + { + return data; + } + + if (remainingPasses <= 0) + { + throw new ArithmeticException("Unable to calculate the triangle from the supplied values; the input may be under-determined or degenerate."); + } + + return CalculateAnglesAndSides(data, remainingPasses - 1); } } \ No newline at end of file diff --git a/src/Atc/Polyfills/NullabilityAttributes.cs b/src/Atc/Polyfills/NullabilityAttributes.cs new file mode 100644 index 00000000..ce998dbc --- /dev/null +++ b/src/Atc/Polyfills/NullabilityAttributes.cs @@ -0,0 +1,79 @@ +#if NETSTANDARD2_0 +#pragma warning disable MA0048 // File name must match type name +#pragma warning disable SA1649 // File name should match first type name +#pragma warning disable SA1402 // File may only contain a single type +#pragma warning disable ATC202 // Multi parameters should be broken down to separate lines +#pragma warning disable CA1019 // Add a public read-only property accessor for positional argument member of Attribute + +namespace System.Diagnostics.CodeAnalysis; + +/// +/// Specifies that the method or property will ensure that the listed field and property members have not-null values when returning with the specified return value condition. +/// +[AttributeUsage(AttributeTargets.Method | AttributeTargets.Property, Inherited = false, AllowMultiple = true)] +internal sealed class MemberNotNullWhenAttribute : Attribute +{ + /// + /// Initializes a new instance of the class with the specified return value condition and a field or property member. + /// + /// + /// The return value condition. If the method returns this value, the associated parameter will not be null. + /// + /// + /// The field or property member that is promised to be not-null. + /// + public MemberNotNullWhenAttribute(bool returnValue, string member) + { + ReturnValue = returnValue; + Members = new[] { member }; + } + + /// + /// Initializes a new instance of the class with the specified return value condition and list of field and property members. + /// + /// + /// The return value condition. If the method returns this value, the associated parameter will not be null. + /// + /// + /// The list of field and property members that are promised to be not-null. + /// + public MemberNotNullWhenAttribute(bool returnValue, params string[] members) + { + ReturnValue = returnValue; + Members = members; + } + + /// + /// Gets the return value condition. + /// + public bool ReturnValue { get; } + + /// + /// Gets the field or property member names. + /// + public string[] Members { get; } +} + +/// +/// Specifies that the output will be non-null if the named parameter is non-null. +/// +[AttributeUsage(AttributeTargets.Parameter, Inherited = false)] +internal sealed class NotNullWhenAttribute : Attribute +{ + /// + /// Initializes a new instance of the class with the specified return value condition. + /// + /// + /// The return value condition. If the method returns this value, the associated parameter will not be null. + /// + public NotNullWhenAttribute(bool returnValue) + { + ReturnValue = returnValue; + } + + /// + /// Gets the return value condition. + /// + public bool ReturnValue { get; } +} +#endif \ No newline at end of file diff --git a/src/Atc/Polyfills/StringPolyfillExtensions.cs b/src/Atc/Polyfills/StringPolyfillExtensions.cs new file mode 100644 index 00000000..b7fee899 --- /dev/null +++ b/src/Atc/Polyfills/StringPolyfillExtensions.cs @@ -0,0 +1,146 @@ +#if NETSTANDARD2_0 +#pragma warning disable SA1611 // The documentation for parameter is missing +#pragma warning disable ATC202 // Multi parameters should be broken down to separate lines + +namespace System; + +/// +/// Polyfill extension methods for String that are not available in netstandard2.0. +/// +internal static class StringPolyfillExtensions +{ + /// + /// Returns a value indicating whether a specified character occurs within this string, using the specified comparison rules. + /// + public static bool Contains(this string str, char value, StringComparison comparisonType) + { + if (str == null) + { + throw new System.ArgumentNullException(nameof(str)); + } + + return str.IndexOf(value.ToString(), comparisonType) >= 0; + } + + /// + /// Returns a value indicating whether a specified string occurs within this string, using the specified comparison rules. + /// + public static bool Contains(this string str, string value, StringComparison comparisonType) + { + if (str == null) + { + throw new System.ArgumentNullException(nameof(str)); + } + + if (value == null) + { + throw new System.ArgumentNullException(nameof(value)); + } + + return str.IndexOf(value, comparisonType) >= 0; + } + + /// + /// Returns a new string in which all occurrences of a specified string are replaced with another specified string, using the provided comparison type. + /// + public static string Replace(this string str, string oldValue, string newValue, StringComparison comparisonType) + { + if (str == null) + { + throw new System.ArgumentNullException(nameof(str)); + } + + if (oldValue == null) + { + throw new System.ArgumentNullException(nameof(oldValue)); + } + + if (newValue == null) + { + throw new System.ArgumentNullException(nameof(newValue)); + } + + if (oldValue.Length == 0) + { + throw new ArgumentException("String cannot be of zero length.", nameof(oldValue)); + } + + if (comparisonType == StringComparison.Ordinal) + { + return str.Replace(oldValue, newValue); + } + + var sb = new System.Text.StringBuilder(); + var previousIndex = 0; + var index = str.IndexOf(oldValue, comparisonType); + + while (index != -1) + { + sb.Append(str.Substring(previousIndex, index - previousIndex)); + sb.Append(newValue); + previousIndex = index + oldValue.Length; + index = str.IndexOf(oldValue, previousIndex, comparisonType); + } + + sb.Append(str.Substring(previousIndex)); + return sb.ToString(); + } + + /// + /// Splits a string into substrings based on specified delimiting characters and options. + /// + public static string[] Split(this string str, char separator, StringSplitOptions options) + { + if (str == null) + { + throw new System.ArgumentNullException(nameof(str)); + } + + return str.Split(new[] { separator }, options); + } + + /// + /// Splits a string into substrings based on specified delimiting strings and options. + /// + public static string[] Split(this string str, string separator, StringSplitOptions options) + { + if (str == null) + { + throw new System.ArgumentNullException(nameof(str)); + } + + if (separator == null) + { + throw new System.ArgumentNullException(nameof(separator)); + } + + return str.Split(new[] { separator }, options); + } + + /// + /// Determines whether the end of this string instance matches the specified character. + /// + public static bool EndsWith(this string str, char value) + { + if (str == null) + { + throw new System.ArgumentNullException(nameof(str)); + } + + return str.Length > 0 && str[str.Length - 1] == value; + } + + /// + /// Determines whether the beginning of this string instance matches the specified character. + /// + public static bool StartsWith(this string str, char value) + { + if (str == null) + { + throw new System.ArgumentNullException(nameof(str)); + } + + return str.Length > 0 && str[0] == value; + } +} +#endif \ No newline at end of file diff --git a/src/Atc/Serialization/DynamicJson.cs b/src/Atc/Serialization/DynamicJson.cs index 1193a66b..740799f8 100644 --- a/src/Atc/Serialization/DynamicJson.cs +++ b/src/Atc/Serialization/DynamicJson.cs @@ -212,7 +212,8 @@ private static IReadOnlyList GetSegmentsFromPath(string path) : null; } - if (currentDict[key] is Dictionary nestedDict) + if (currentDict.TryGetValue(key, out var nestedValue) && + nestedValue is Dictionary nestedDict) { return GetValueRecursive( nestedDict, @@ -258,7 +259,8 @@ private static (bool IsSucceeded, string? ErrorMessage) SetValueRecursive( currentDict.Add(key, new Dictionary(StringComparer.Ordinal)); } - if (currentDict[key] is Dictionary nestedDict) + if (currentDict.TryGetValue(key, out var nestedValue) && + nestedValue is Dictionary nestedDict) { return SetValueRecursive( nestedDict, @@ -417,7 +419,8 @@ private static (bool IsSucceeded, string? ErrorMessage) RemovePathRecursive( ErrorMessage: null); } - if (currentDict[key] is Dictionary nestedDict) + if (currentDict.TryGetValue(key, out var nestedValue) && + nestedValue is Dictionary nestedDict) { return RemovePathRecursive( nestedDict, diff --git a/src/Atc/Serialization/JsonConverters/CultureInfoToNameJsonConverter.cs b/src/Atc/Serialization/JsonConverters/CultureInfoToNameJsonConverter.cs index 31d7c4b9..df1c9650 100644 --- a/src/Atc/Serialization/JsonConverters/CultureInfoToNameJsonConverter.cs +++ b/src/Atc/Serialization/JsonConverters/CultureInfoToNameJsonConverter.cs @@ -15,6 +15,11 @@ public sealed class CultureInfoToNameJsonConverter : JsonConverter Type typeToConvert, JsonSerializerOptions options) { + if (reader.TokenType == JsonTokenType.Null) + { + return null; + } + var name = reader.GetString(); return string.IsNullOrEmpty(name) ? null diff --git a/src/Atc/Serialization/JsonConverters/DirectoryInfoToFullNameJsonConverter.cs b/src/Atc/Serialization/JsonConverters/DirectoryInfoToFullNameJsonConverter.cs index 87582320..3f3212c2 100644 --- a/src/Atc/Serialization/JsonConverters/DirectoryInfoToFullNameJsonConverter.cs +++ b/src/Atc/Serialization/JsonConverters/DirectoryInfoToFullNameJsonConverter.cs @@ -15,6 +15,11 @@ public sealed class DirectoryInfoToFullNameJsonConverter : JsonConverter Type typeToConvert, JsonSerializerOptions options) { + if (reader.TokenType == JsonTokenType.Null) + { + return null; + } + var fillName = reader.GetString(); return string.IsNullOrEmpty(fillName) ? null diff --git a/src/Atc/Serialization/JsonConverters/InterfaceJsonConverter.cs b/src/Atc/Serialization/JsonConverters/InterfaceJsonConverter.cs index 175ff1ab..53ac8811 100644 --- a/src/Atc/Serialization/JsonConverters/InterfaceJsonConverter.cs +++ b/src/Atc/Serialization/JsonConverters/InterfaceJsonConverter.cs @@ -46,16 +46,18 @@ public override TInterface Read( using var document = JsonDocument.ParseValue(ref reader); var jsonObject = document.RootElement; - // Create a new JsonSerializerOptions without this converter + // Deserialize the already-parsed JsonElement directly, avoiding an unnecessary re-serialise + // to string. We still need options without this converter to prevent infinite recursion, but + // we create a minimal copy (clone of converters minus ourselves) rather than a full options + // clone to avoid per-call allocations. var modifiedOptions = new JsonSerializerOptions(options); var converterToRemove = modifiedOptions.Converters.FirstOrDefault(c => c is InterfaceJsonConverter); - if (converterToRemove != null) + if (converterToRemove is not null) { modifiedOptions.Converters.Remove(converterToRemove); } - // Deserialize using the provided concrete type - return (TInterface)JsonSerializer.Deserialize(jsonObject.GetRawText(), typeToConvert, modifiedOptions)!; + return (TInterface)JsonSerializer.Deserialize(jsonObject, typeToConvert, modifiedOptions)!; } /// diff --git a/src/Atc/Serialization/JsonConverters/NumberToStringJsonConverter.cs b/src/Atc/Serialization/JsonConverters/NumberToStringJsonConverter.cs index 557a779d..2cab7e33 100644 --- a/src/Atc/Serialization/JsonConverters/NumberToStringJsonConverter.cs +++ b/src/Atc/Serialization/JsonConverters/NumberToStringJsonConverter.cs @@ -7,7 +7,8 @@ namespace Atc.Serialization.JsonConverters; /// /// This converter handles conversion between JSON numbers and strings, allowing numeric values in JSON /// to be read as strings. During deserialization, JSON numbers are converted to their string representation -/// using the current thread's culture. During serialization, any object is converted to its string representation. +/// using the invariant culture so the result is stable across machines and locales. During serialization, +/// any object is converted to its string representation. /// public sealed class NumberToStringJsonConverter : JsonConverter { @@ -25,10 +26,10 @@ public override object Read( { case JsonTokenType.Number: return reader.TryGetInt64(out var l) - ? l.ToString(Thread.CurrentThread.CurrentCulture) + ? l.ToString(CultureInfo.InvariantCulture) : reader .GetDouble() - .ToString(Thread.CurrentThread.CurrentCulture); + .ToString(CultureInfo.InvariantCulture); case JsonTokenType.String: return reader.GetString() ?? string.Empty; default: diff --git a/src/Atc/Serialization/JsonConverters/StringEnumMemberJsonConverter.cs b/src/Atc/Serialization/JsonConverters/StringEnumMemberJsonConverter.cs index 0181f456..227da3aa 100644 --- a/src/Atc/Serialization/JsonConverters/StringEnumMemberJsonConverter.cs +++ b/src/Atc/Serialization/JsonConverters/StringEnumMemberJsonConverter.cs @@ -12,38 +12,53 @@ namespace Atc.Serialization.JsonConverters; public sealed class StringEnumMemberJsonConverter : JsonConverter where TEnum : Enum { - /// - public override TEnum Read( - ref Utf8JsonReader reader, - Type typeToConvert, - JsonSerializerOptions options) + private static readonly Dictionary NameToValue = BuildNameToValue(); + private static readonly Dictionary ValueToName = BuildValueToName(); + + private static Dictionary BuildNameToValue() { - if (typeToConvert is null) + var map = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var field in typeof(TEnum).GetFields(BindingFlags.Public | BindingFlags.Static)) { - throw new ArgumentNullException(nameof(typeToConvert)); + var member = field.GetCustomAttribute()?.Value ?? field.Name; + map[member] = (TEnum)field.GetValue(null)!; + if (!map.ContainsKey(field.Name)) + { + map[field.Name] = (TEnum)field.GetValue(null)!; + } } - var enumValue = reader.GetString(); - foreach (var field in typeToConvert.GetFields()) - { - var enumMemberAttribute = field.GetCustomAttribute(); + return map; + } - switch (enumMemberAttribute) + private static Dictionary BuildValueToName() + { + var map = new Dictionary(); + foreach (var field in typeof(TEnum).GetFields(BindingFlags.Public | BindingFlags.Static)) + { + var key = (TEnum)field.GetValue(null)!; + if (!map.ContainsKey(key)) { - case null when - field.Name.Equals(enumValue, StringComparison.OrdinalIgnoreCase): - return (TEnum)field.GetValue(null)!; - case null: - continue; + map[key] = field.GetCustomAttribute()?.Value ?? field.Name; } + } - if (enumMemberAttribute.Value!.Equals(enumValue, StringComparison.OrdinalIgnoreCase)) - { - return (TEnum)field.GetValue(null)!; - } + return map; + } + + /// + public override TEnum Read( + ref Utf8JsonReader reader, + Type typeToConvert, + JsonSerializerOptions options) + { + var enumValue = reader.GetString(); + if (enumValue is not null && NameToValue.TryGetValue(enumValue, out var result)) + { + return result; } - throw new JsonException($"Unable to convert \"{enumValue}\" to Enum \"{typeToConvert}\"."); + throw new JsonException($"Unable to convert \"{enumValue}\" to Enum \"{typeof(TEnum)}\"."); } /// @@ -57,22 +72,12 @@ public override void Write( throw new ArgumentNullException(nameof(writer)); } - if (value is null) - { - throw new ArgumentNullException(nameof(value)); - } - if (options is null) { throw new ArgumentNullException(nameof(options)); } - var enumMemberAttribute = value - .GetType() - .GetField(value.ToString())! - .GetCustomAttribute(); - - var enumValue = enumMemberAttribute?.Value ?? value.ToString(); + var enumValue = ValueToName.TryGetValue(value, out var name) ? name : value.ToString(); writer.WriteStringValue(options.PropertyNamingPolicy == JsonNamingPolicy.CamelCase ? enumValue.EnsureFirstCharacterToLower() diff --git a/src/Atc/Serialization/JsonConverters/TypeDiscriminatorJsonConverter.cs b/src/Atc/Serialization/JsonConverters/TypeDiscriminatorJsonConverter.cs index 796e31ba..db3904ba 100644 --- a/src/Atc/Serialization/JsonConverters/TypeDiscriminatorJsonConverter.cs +++ b/src/Atc/Serialization/JsonConverters/TypeDiscriminatorJsonConverter.cs @@ -13,24 +13,47 @@ namespace Atc.Serialization.JsonConverters; public sealed class TypeDiscriminatorJsonConverter : JsonConverter where T : ITypeDiscriminator { - private readonly IEnumerable types; + // Cached per base-type so that multiple converter instances share the same scan result. + private static readonly ConcurrentDictionary> TypeCache = new(); + + private readonly IReadOnlyList types; /// /// Initializes a new instance of the class. /// /// /// This constructor scans all loaded assemblies in the current to find all - /// concrete (non-abstract) class types that implement . + /// concrete (non-abstract) class types that implement . The result is cached + /// per base type so subsequent instances do not re-scan. /// public TypeDiscriminatorJsonConverter() { - var type = typeof(T); - types = AppDomain - .CurrentDomain - .GetAssemblies() - .SelectMany(s => s.GetTypes()) - .Where(p => type.IsAssignableFrom(p) && p.IsClass && !p.IsAbstract) - .ToList(); + types = TypeCache.GetOrAdd(typeof(T), static baseType => + { + var found = new List(); + foreach (var assembly in AppDomain.CurrentDomain.GetAssemblies()) + { + IEnumerable assemblyTypes; + try + { + assemblyTypes = assembly.GetTypes(); + } + catch (ReflectionTypeLoadException ex) + { + assemblyTypes = ex.Types.Where(t => t is not null)!; + } + + foreach (var t in assemblyTypes) + { + if (baseType.IsAssignableFrom(t) && t.IsClass && !t.IsAbstract) + { + found.Add(t); + } + } + } + + return found.AsReadOnly(); + }); } /// diff --git a/src/Atc/Serialization/JsonConverters/UnixDateTimeOffsetJsonConverter.cs b/src/Atc/Serialization/JsonConverters/UnixDateTimeOffsetJsonConverter.cs index 4ebd5640..7d63c820 100644 --- a/src/Atc/Serialization/JsonConverters/UnixDateTimeOffsetJsonConverter.cs +++ b/src/Atc/Serialization/JsonConverters/UnixDateTimeOffsetJsonConverter.cs @@ -14,9 +14,16 @@ public sealed class UnixDateTimeOffsetJsonConverter : JsonConverter reader.TryGetInt64(out var value) + { + if (reader.TokenType == JsonTokenType.Null) + { + return default; + } + + return reader.TryGetInt64(out var value) ? DateTimeOffset.FromUnixTimeSeconds(value) : default; + } /// public override void Write( diff --git a/src/Atc/Serialization/JsonConverters/UriToAbsoluteUriJsonConverter.cs b/src/Atc/Serialization/JsonConverters/UriToAbsoluteUriJsonConverter.cs index 732cf278..d17747ac 100644 --- a/src/Atc/Serialization/JsonConverters/UriToAbsoluteUriJsonConverter.cs +++ b/src/Atc/Serialization/JsonConverters/UriToAbsoluteUriJsonConverter.cs @@ -15,6 +15,11 @@ public sealed class UriToAbsoluteUriJsonConverter : JsonConverter Type typeToConvert, JsonSerializerOptions options) { + if (reader.TokenType == JsonTokenType.Null) + { + return null; + } + var absoluteUri = reader.GetString(); return string.IsNullOrEmpty(absoluteUri) ? null diff --git a/src/Atc/Serialization/JsonConverters/VersionJsonConverter.cs b/src/Atc/Serialization/JsonConverters/VersionJsonConverter.cs index cc26798c..13301a3e 100644 --- a/src/Atc/Serialization/JsonConverters/VersionJsonConverter.cs +++ b/src/Atc/Serialization/JsonConverters/VersionJsonConverter.cs @@ -6,8 +6,9 @@ namespace Atc.Serialization.JsonConverters; /// /// /// This converter supports reading from both string format (e.g., "1.2.3.4") and object format -/// with internal fields (_Major, _Minor, _Build, _Revision). During writing, is always serialized -/// as a string. If parsing fails, a default empty is returned. +/// with public properties (Major, Minor, Build, Revision) as produced by . +/// During writing, is always serialized as a string. If parsing fails, a default empty +/// is returned. /// public sealed class VersionJsonConverter : JsonConverter { @@ -27,19 +28,19 @@ public override Version Read( { var major = jsonDocument .RootElement - .GetProperty("_Major") + .GetProperty("Major") .GetInt32(); var minor = jsonDocument .RootElement - .GetProperty("_Minor") + .GetProperty("Minor") .GetInt32(); var build = jsonDocument .RootElement - .GetProperty("_Build") + .GetProperty("Build") .GetInt32(); var revision = jsonDocument .RootElement - .GetProperty("_Revision") + .GetProperty("Revision") .GetInt32(); return new Version(major, minor, build, revision); } diff --git a/src/Atc/Serialization/JsonSerializerHelper.cs b/src/Atc/Serialization/JsonSerializerHelper.cs new file mode 100644 index 00000000..2b3c533e --- /dev/null +++ b/src/Atc/Serialization/JsonSerializerHelper.cs @@ -0,0 +1,119 @@ +namespace Atc.Serialization; + +/// +/// Provides async stream-based serialization and deserialization helpers using . +/// +/// +/// All overloads that omit use +/// default options. +/// +public static class JsonSerializerHelper +{ + /// + /// Asynchronously deserializes a value of type from the specified UTF-8 JSON stream + /// using the default serializer options. + /// + /// The type to deserialize. + /// The UTF-8 encoded JSON stream to read from. + /// A token to cancel the asynchronous operation. + /// A that represents the asynchronous operation, containing the deserialized value, + /// or if the stream contains a JSON null literal. + /// Thrown when is . + public static async Task DeserializeFromStreamAsync( + Stream stream, + CancellationToken cancellationToken = default) + { + if (stream is null) + { + throw new ArgumentNullException(nameof(stream)); + } + + return await JsonSerializer + .DeserializeAsync(stream, JsonSerializerOptionsFactory.Create(), cancellationToken) + .ConfigureAwait(false); + } + + /// + /// Asynchronously deserializes a value of type from the specified UTF-8 JSON stream + /// using the provided serializer options. + /// + /// The type to deserialize. + /// The UTF-8 encoded JSON stream to read from. + /// The to use during deserialization. + /// A token to cancel the asynchronous operation. + /// A that represents the asynchronous operation, containing the deserialized value, + /// or if the stream contains a JSON null literal. + /// Thrown when or is . + public static async Task DeserializeFromStreamAsync( + Stream stream, + JsonSerializerOptions options, + CancellationToken cancellationToken = default) + { + if (stream is null) + { + throw new ArgumentNullException(nameof(stream)); + } + + if (options is null) + { + throw new ArgumentNullException(nameof(options)); + } + + return await JsonSerializer + .DeserializeAsync(stream, options, cancellationToken) + .ConfigureAwait(false); + } + + /// + /// Asynchronously serializes as UTF-8 JSON into the specified stream + /// using the default serializer options. + /// + /// The type of the value to serialize. + /// The value to serialize. + /// The stream to write JSON into. + /// A token to cancel the asynchronous operation. + /// A that represents the asynchronous write operation. + /// Thrown when is . + public static Task SerializeToStreamAsync( + T value, + Stream stream, + CancellationToken cancellationToken = default) + { + if (stream is null) + { + throw new ArgumentNullException(nameof(stream)); + } + + return JsonSerializer.SerializeAsync(stream, value, JsonSerializerOptionsFactory.Create(), cancellationToken); + } + + /// + /// Asynchronously serializes as UTF-8 JSON into the specified stream + /// using the provided serializer options. + /// + /// The type of the value to serialize. + /// The value to serialize. + /// The stream to write JSON into. + /// The to use during serialization. + /// A token to cancel the asynchronous operation. + /// A that represents the asynchronous write operation. + /// Thrown when or is . + public static Task SerializeToStreamAsync( + T value, + Stream stream, + JsonSerializerOptions options, + CancellationToken cancellationToken = default) + { + if (stream is null) + { + throw new ArgumentNullException(nameof(stream)); + } + + if (options is null) + { + throw new ArgumentNullException(nameof(options)); + } + + return JsonSerializer.SerializeAsync(stream, value, options, cancellationToken); + } +} \ No newline at end of file diff --git a/src/Atc/Serialization/JsonSerializerOptionsFactory.cs b/src/Atc/Serialization/JsonSerializerOptionsFactory.cs index 8a658b99..132646d8 100644 --- a/src/Atc/Serialization/JsonSerializerOptionsFactory.cs +++ b/src/Atc/Serialization/JsonSerializerOptionsFactory.cs @@ -10,6 +10,17 @@ namespace Atc.Serialization; /// public static class JsonSerializerOptionsFactory { + private static readonly Lazy LazyDefault = + new(() => Create(), LazyThreadSafetyMode.ExecutionAndPublication); + + /// + /// Gets a cached, shared instance using the default settings + /// (camelCase, null-values ignored, case-insensitive names, indented output). + /// The instance is created once and reused; after the first serialization call it becomes read-only. + /// Use when you need a mutable copy. + /// + public static JsonSerializerOptions Default => LazyDefault.Value; + /// /// Creates a new instance with the specified parameters. /// diff --git a/src/Atc/Structs/Point2D.cs b/src/Atc/Structs/Point2D.cs index 8da9c1cf..f6107f0a 100644 --- a/src/Atc/Structs/Point2D.cs +++ b/src/Atc/Structs/Point2D.cs @@ -14,9 +14,10 @@ public record struct Point2D( /// Gets a value indicating whether this instance represents the default (origin) position at coordinates (0, 0). /// /// - /// if both X and Y are approximately zero; otherwise, . + /// if both X and Y are exactly zero; otherwise, . /// - public readonly bool IsDefault => X.IsEqual(0) && Y.IsEqual(0); + [SuppressMessage("SonarAnalyzer.CSharp", "S1244:Do not check floating point equality with exact values, use a range instead", Justification = "Intentional: IsDefault checks for exact binary zero, not approximate equality.")] + public readonly bool IsDefault => X == 0.0 && Y == 0.0; /// public override readonly string ToString() diff --git a/src/Atc/Structs/Point3D.cs b/src/Atc/Structs/Point3D.cs index 74ed9271..3843fca8 100644 --- a/src/Atc/Structs/Point3D.cs +++ b/src/Atc/Structs/Point3D.cs @@ -15,10 +15,11 @@ public record struct Point3D( /// Gets a value indicating whether this instance represents the default (origin) position at coordinates (0, 0, 0). /// /// - /// if X, Y, and Z are all approximately zero; otherwise, . + /// if X, Y, and Z are all exactly zero; otherwise, . /// + [SuppressMessage("SonarAnalyzer.CSharp", "S1244:Do not check floating point equality with exact values, use a range instead", Justification = "Intentional: IsDefault checks for exact binary zero, not approximate equality.")] public readonly bool IsDefault - => X.IsEqual(0) && Y.IsEqual(0) && Z.IsEqual(0); + => X == 0.0 && Y == 0.0 && Z == 0.0; /// public override readonly string ToString() diff --git a/src/Atc/Units/DigitalInformation/ByteSize.cs b/src/Atc/Units/DigitalInformation/ByteSize.cs index efd0f156..e098d187 100644 --- a/src/Atc/Units/DigitalInformation/ByteSize.cs +++ b/src/Atc/Units/DigitalInformation/ByteSize.cs @@ -5,7 +5,7 @@ namespace Atc.Units.DigitalInformation; /// [Serializable] [SuppressMessage("Usage", "CA2225:Operator overloads have named alternates", Justification = "OK.")] -public struct ByteSize : IEquatable +public struct ByteSize : IEquatable, IComparable, IComparable { /// /// Initializes a new instance of the struct. @@ -153,6 +153,127 @@ public ByteSize(long value) /// public static implicit operator ByteSize(ushort value) => new(value); + /// + /// Implements the less-than operator. + /// + /// Left operand. + /// Right operand. + /// if is less than . + public static bool operator <( + ByteSize a, + ByteSize b) + => a.Value < b.Value; + + /// + /// Implements the less-than-or-equal operator. + /// + /// Left operand. + /// Right operand. + /// if is less than or equal to . + public static bool operator <=( + ByteSize a, + ByteSize b) + => a.Value <= b.Value; + + /// + /// Implements the greater-than operator. + /// + /// Left operand. + /// Right operand. + /// if is greater than . + public static bool operator >( + ByteSize a, + ByteSize b) + => a.Value > b.Value; + + /// + /// Implements the greater-than-or-equal operator. + /// + /// Left operand. + /// Right operand. + /// if is greater than or equal to . + public static bool operator >=( + ByteSize a, + ByteSize b) + => a.Value >= b.Value; + + /// + /// Adds two values. + /// + /// Left operand. + /// Right operand. + /// The sum of and . + public static ByteSize operator +( + ByteSize a, + ByteSize b) + => new(a.Value + b.Value); + + /// + /// Subtracts one value from another. + /// + /// Left operand. + /// Right operand. + /// The difference between and . + public static ByteSize operator -( + ByteSize a, + ByteSize b) + => new(a.Value - b.Value); + + /// + /// Parses a string of digits into a . + /// + /// The string to parse. Must represent a valid value. + /// A with the parsed byte count. + /// Thrown when is null. + /// Thrown when is not a valid integer. + public static ByteSize Parse(string value) + { + ArgumentNullException.ThrowIfNull(value); + if (long.TryParse( + value.Trim(), + NumberStyles.Integer, + CultureInfo.InvariantCulture, + out var longValue)) + { + return new ByteSize(longValue); + } + + throw new FormatException( + $"The value '{value}' is not a valid byte size."); + } + + /// + /// Tries to parse a string into a . + /// + /// The string to parse, or . + /// + /// When this method returns, contains the parsed + /// if parsing succeeded; otherwise, . + /// + /// if parsing succeeded; otherwise, . + public static bool TryParse( + string? value, + out ByteSize result) + { + result = default; + if (value is null) + { + return false; + } + + if (long.TryParse( + value.Trim(), + NumberStyles.Integer, + CultureInfo.InvariantCulture, + out var longValue)) + { + result = new ByteSize(longValue); + return true; + } + + return false; + } + /// /// Equals the specified other. /// @@ -164,7 +285,44 @@ public override readonly bool Equals(object? obj) => obj is ByteSize x && Equals(x); /// - public override readonly int GetHashCode() => base.GetHashCode(); + public override readonly int GetHashCode() => Value.GetHashCode(); + + /// + /// Compares this instance to another value. + /// + /// The other value to compare to. + /// + /// A negative number if this instance is less than ; + /// zero if they are equal; a positive number if this instance is greater. + /// + public readonly int CompareTo(ByteSize other) + => Value.CompareTo(other.Value); + + /// + /// Compares this instance to another object. + /// + /// An object to compare to, or . + /// + /// A negative number if this instance is less than ; + /// zero if they are equal; a positive number if this instance is greater. + /// + /// Thrown when is not a . + public readonly int CompareTo(object? obj) + { + if (obj is null) + { + return 1; + } + + if (obj is ByteSize other) + { + return CompareTo(other); + } + + throw new ArgumentException( + "Object must be of type ByteSize.", + nameof(obj)); + } /// /// Returns a that represents this instance. diff --git a/src/Atc/Units/DigitalInformation/ByteSizeCalculationData.cs b/src/Atc/Units/DigitalInformation/ByteSizeCalculationData.cs index df5f3100..966248b2 100644 --- a/src/Atc/Units/DigitalInformation/ByteSizeCalculationData.cs +++ b/src/Atc/Units/DigitalInformation/ByteSizeCalculationData.cs @@ -49,4 +49,19 @@ internal static class ByteSizeCalculationData "Peta", "Exa", }; + + /// + /// IEC binary prefix strings (empty, "Ki", "Mi", "Gi", "Ti", "Pi", "Ei"). + /// Used with the "B" suffix to produce "KiB", "MiB", etc. + /// + internal static readonly string[] PrefixesShortBinary = + { + string.Empty, + "Ki", + "Mi", + "Gi", + "Ti", + "Pi", + "Ei", + }; } \ No newline at end of file diff --git a/src/Atc/Units/DigitalInformation/ByteSizeFormatter.cs b/src/Atc/Units/DigitalInformation/ByteSizeFormatter.cs index 5b556dc4..a71f798d 100644 --- a/src/Atc/Units/DigitalInformation/ByteSizeFormatter.cs +++ b/src/Atc/Units/DigitalInformation/ByteSizeFormatter.cs @@ -20,7 +20,7 @@ public ByteSizeFormatter() MaxUnit = ByteSizeUnitType.Exabyte; RoundingRule = ByteSizeRoundingRuleType.Closest; NumberOfDecimals = 0; - NumberFormatInfo = Thread.CurrentThread.CurrentUICulture.NumberFormat; + NumberFormatInfo = CultureInfo.CurrentCulture.NumberFormat; } /// @@ -100,7 +100,7 @@ public string Format(long size) { if (size < 0) { - throw new ArgumentOutOfRangeException(nameof(size)); + return $"{size} B"; } var multiples = ByteSizeCalculationData.BinaryMultiples; @@ -114,11 +114,14 @@ public string Format(long size) return displaySizeStr; } - var prefixes = SuffixFormat == ByteSizeSuffixType.Full - ? ByteSizeCalculationData.PrefixesFull - : ByteSizeCalculationData.PrefixesShort; + var prefixes = SuffixFormat switch + { + ByteSizeSuffixType.Full => ByteSizeCalculationData.PrefixesFull, + ByteSizeSuffixType.ShortBinary => ByteSizeCalculationData.PrefixesShortBinary, + _ => ByteSizeCalculationData.PrefixesShort, + }; - var suffixLastPart = BuildSuffixLastPart(size, prefixIndex); + var suffixLastPart = BuildSuffixLastPart(size, prefixIndex, displaySize); return $"{displaySizeStr} {prefixes[prefixIndex]}{suffixLastPart}"; } @@ -143,7 +146,8 @@ private int GetPrefixIndex( private string BuildSuffixLastPart( long size, - int prefixIndex) + int prefixIndex, + decimal displaySize) { var text = "B"; if (SuffixFormat == ByteSizeSuffixType.Full) @@ -156,7 +160,9 @@ private string BuildSuffixLastPart( } else { - text = "byte"; + text = displaySize > 1 + ? "bytes" + : "byte"; } } diff --git a/src/Atc/Units/DigitalInformation/Enums/ByteSizeSuffixType.cs b/src/Atc/Units/DigitalInformation/Enums/ByteSizeSuffixType.cs index f687df42..4a3d6a6e 100644 --- a/src/Atc/Units/DigitalInformation/Enums/ByteSizeSuffixType.cs +++ b/src/Atc/Units/DigitalInformation/Enums/ByteSizeSuffixType.cs @@ -21,4 +21,10 @@ public enum ByteSizeSuffixType /// Full suffix format (e.g., "byte", "Kilobyte", "Megabyte", "Gigabyte"). /// Full, + + /// + /// Short IEC binary suffix format (e.g., "B", "KiB", "MiB", "GiB"). + /// Uses IEC 80000-13 notation to distinguish 1024-based units from SI decimal units. + /// + ShortBinary, } \ No newline at end of file diff --git a/src/Atc/Units/InternationalSystemOfUnits/InternationalSystemOfUnitsHelper.cs b/src/Atc/Units/InternationalSystemOfUnits/InternationalSystemOfUnitsHelper.cs index 632cabc4..58c600ff 100644 --- a/src/Atc/Units/InternationalSystemOfUnits/InternationalSystemOfUnitsHelper.cs +++ b/src/Atc/Units/InternationalSystemOfUnits/InternationalSystemOfUnitsHelper.cs @@ -1,231 +1,79 @@ -// ReSharper disable SwitchStatementHandlesSomeKnownEnumValuesWithDefault +// ReSharper disable CommentTypo +// ReSharper disable IdentifierTypo namespace Atc.Units.InternationalSystemOfUnits; /// /// Provides utility methods for converting between International System of Units (SI) prefixes. /// /// -/// This helper class supports conversions between various SI unit prefixes such as kilo, mega, giga, milli, centi, etc. -/// Note that not all prefix combinations are currently supported. +/// This helper class supports conversions between all standard SI unit prefixes +/// (Yotta through Yocto) using a table-driven exponent approach. /// public static class InternationalSystemOfUnitsHelper { + private static readonly IReadOnlyDictionary PrefixExponents = + new Dictionary + { + { PrefixType.Yotta, 24 }, + { PrefixType.Zetta, 21 }, + { PrefixType.Exa, 18 }, + { PrefixType.Peta, 15 }, + { PrefixType.Tera, 12 }, + { PrefixType.Giga, 9 }, + { PrefixType.Mega, 6 }, + { PrefixType.Kilo, 3 }, + { PrefixType.Hecto, 2 }, + { PrefixType.Deca, 1 }, + { PrefixType.None, 0 }, + { PrefixType.Deci, -1 }, + { PrefixType.Centi, -2 }, + { PrefixType.Milli, -3 }, + { PrefixType.Micro, -6 }, + { PrefixType.Nano, -9 }, + { PrefixType.Pico, -12 }, + { PrefixType.Femto, -15 }, + { PrefixType.Atto, -18 }, + { PrefixType.Zepto, -21 }, + { PrefixType.Yocto, -24 }, + }; + /// /// Converts a value from one SI prefix type to another with optional decimal precision. /// /// The source SI prefix type. /// The target SI prefix type. - /// The number of decimal places to round to (0 for no rounding). + /// The number of decimal places to round to. Pass 0 for no rounding. /// The value to convert. - /// The converted value in the target prefix type. - /// Thrown when the specified conversion is not supported. + /// The converted value in the target prefix type, optionally rounded. + /// + /// Thrown when or is not a recognised value. + /// /// Thrown when the conversion results in NaN. - [SuppressMessage("Design", "MA0051:Method is too long", Justification = "OK.")] - [SuppressMessage("StyleCop.CSharp.ReadabilityRules", "SA1123:Do not place regions within elements", Justification = "OK. For now.")] public static double Convert( PrefixType prefixTypeFrom, PrefixType prefixTypeTo, int numberOfDecimals, double value) { - var d = double.NaN; - - switch (prefixTypeFrom) + if (!PrefixExponents.TryGetValue(prefixTypeFrom, out var fromExp)) { - case PrefixType.Yotta: - break; - case PrefixType.Zetta: - break; - case PrefixType.Exa: - break; - case PrefixType.Peta: - break; - case PrefixType.Tera: - break; - case PrefixType.Giga: - break; - case PrefixType.Mega: - break; - case PrefixType.Kilo: - break; - case PrefixType.Hecto: - break; - case PrefixType.Deca: - break; - case PrefixType.None: - #region - None - - switch (prefixTypeTo) - { - case PrefixType.Yotta: - case PrefixType.Zetta: - case PrefixType.Exa: - case PrefixType.Peta: - case PrefixType.Tera: - case PrefixType.Giga: - case PrefixType.Mega: - case PrefixType.Kilo: - case PrefixType.Hecto: - case PrefixType.Deca: - throw new NotSupportedException(); - case PrefixType.None: - d = value; - break; - case PrefixType.Deci: - d = value * 10; - break; - case PrefixType.Centi: - d = value * 100; - break; - case PrefixType.Milli: - d = value * 1000; - break; - case PrefixType.Micro: - case PrefixType.Nano: - case PrefixType.Pico: - case PrefixType.Femto: - case PrefixType.Atto: - case PrefixType.Zepto: - case PrefixType.Yocto: - throw new NotSupportedException(); - } - #endregion - break; - case PrefixType.Deci: - break; - case PrefixType.Centi: - #region - Centi - - switch (prefixTypeTo) - { - case PrefixType.Yotta: - case PrefixType.Zetta: - case PrefixType.Exa: - case PrefixType.Peta: - case PrefixType.Tera: - throw new NotSupportedException(); - case PrefixType.Giga: - d = value / 100000000000; - break; - case PrefixType.Mega: - d = value / 100000000; - break; - case PrefixType.Kilo: - d = value / 100000; - break; - case PrefixType.Hecto: - d = value / 10000; - break; - case PrefixType.Deca: - d = value / 1000; - break; - case PrefixType.None: - d = value / 100; - break; - case PrefixType.Deci: - d = value / 10; - break; - case PrefixType.Centi: - d = value; - break; - case PrefixType.Milli: - d = value * 10; - break; - case PrefixType.Micro: - d = value * 1000; - break; - case PrefixType.Nano: - d = value * 1000000; - break; - case PrefixType.Pico: - d = value * 1000000000; - break; - case PrefixType.Femto: - case PrefixType.Atto: - case PrefixType.Zepto: - case PrefixType.Yocto: - throw new NotSupportedException(); - } - #endregion - break; - case PrefixType.Milli: - #region - Milli - - switch (prefixTypeTo) - { - case PrefixType.Yotta: - case PrefixType.Zetta: - case PrefixType.Exa: - case PrefixType.Peta: - case PrefixType.Tera: - throw new NotSupportedException(); - case PrefixType.Giga: - d = value / 1000000000000; - break; - case PrefixType.Mega: - d = value / 1000000000; - break; - case PrefixType.Kilo: - d = value / 1000000; - break; - case PrefixType.Hecto: - d = value / 100000; - break; - case PrefixType.Deca: - d = value / 10000; - break; - case PrefixType.None: - d = value / 1000; - break; - case PrefixType.Deci: - d = value / 100; - break; - case PrefixType.Centi: - d = value / 10; - break; - case PrefixType.Milli: - d = value; - break; - case PrefixType.Micro: - d = value * 1000; - break; - case PrefixType.Nano: - d = value * 1000000; - break; - case PrefixType.Pico: - d = value * 1000000000; - break; - case PrefixType.Femto: - case PrefixType.Atto: - case PrefixType.Zepto: - case PrefixType.Yocto: - throw new NotSupportedException(); - } - #endregion - break; - case PrefixType.Micro: - break; - case PrefixType.Nano: - break; - case PrefixType.Pico: - break; - case PrefixType.Femto: - break; - case PrefixType.Atto: - break; - case PrefixType.Zepto: - break; - case PrefixType.Yocto: - break; + throw new ArgumentOutOfRangeException(nameof(prefixTypeFrom), prefixTypeFrom, "Unsupported SI prefix type."); } - if (double.IsNaN(d)) + if (!PrefixExponents.TryGetValue(prefixTypeTo, out var toExp)) { - throw new ArithmeticException("d IsNaN"); + throw new ArgumentOutOfRangeException(nameof(prefixTypeTo), prefixTypeTo, "Unsupported SI prefix type."); } - if (numberOfDecimals != decimal.Zero) + var result = value * System.Math.Pow(10, fromExp - toExp); + + if (double.IsNaN(result)) { - d = System.Math.Round(d, numberOfDecimals); + throw new ArithmeticException("Conversion resulted in NaN."); } - return d; + return numberOfDecimals != 0 + ? System.Math.Round(result, numberOfDecimals) + : result; } } \ No newline at end of file diff --git a/src/Directory.Build.props b/src/Directory.Build.props index edc3abf9..c29e7f62 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -53,8 +53,8 @@ - - + + \ No newline at end of file diff --git a/test/Atc.CodeAnalysis.CSharp.Tests/Atc.CodeAnalysis.CSharp.Tests.csproj b/test/Atc.CodeAnalysis.CSharp.Tests/Atc.CodeAnalysis.CSharp.Tests.csproj index a50c486a..9fea021f 100644 --- a/test/Atc.CodeAnalysis.CSharp.Tests/Atc.CodeAnalysis.CSharp.Tests.csproj +++ b/test/Atc.CodeAnalysis.CSharp.Tests/Atc.CodeAnalysis.CSharp.Tests.csproj @@ -7,7 +7,7 @@ - + diff --git a/test/Atc.CodeAnalysis.CSharp.Tests/Extensions/InterfaceDeclarationSyntaxExtensionsTests.cs b/test/Atc.CodeAnalysis.CSharp.Tests/Extensions/InterfaceDeclarationSyntaxExtensionsTests.cs index d9cf158e..3f34611d 100644 --- a/test/Atc.CodeAnalysis.CSharp.Tests/Extensions/InterfaceDeclarationSyntaxExtensionsTests.cs +++ b/test/Atc.CodeAnalysis.CSharp.Tests/Extensions/InterfaceDeclarationSyntaxExtensionsTests.cs @@ -2,6 +2,58 @@ namespace Atc.CodeAnalysis.CSharp.Tests.Extensions; public class InterfaceDeclarationSyntaxExtensionsTests { + [Fact] + public void AddSuppressMessageAttribute_Should_Throw_When_InterfaceDeclaration_Is_Null() + { + // Arrange + InterfaceDeclarationSyntax interfaceDeclaration = null!; + var suppressMessage = new SuppressMessageAttribute("category", "checkId") { Justification = "OK." }; + + // Act & Assert + Assert.Throws(() => + interfaceDeclaration.AddSuppressMessageAttribute(suppressMessage)); + } + + [Fact] + public void AddSuppressMessageAttribute_Should_Throw_When_SuppressMessage_Is_Null() + { + // Arrange + var interfaceDeclaration = SyntaxFactory.InterfaceDeclaration("ITestInterface"); + + // Act & Assert + Assert.Throws(() => + interfaceDeclaration.AddSuppressMessageAttribute(null!)); + } + + [Fact] + public void AddSuppressMessageAttribute_Should_Throw_When_Justification_Is_Empty() + { + // Arrange + var interfaceDeclaration = SyntaxFactory.InterfaceDeclaration("ITestInterface"); + var suppressMessage = new SuppressMessageAttribute("category", "checkId"); + + // Act & Assert + Assert.Throws(() => + interfaceDeclaration.AddSuppressMessageAttribute(suppressMessage)); + } + + [Fact] + public void AddSuppressMessageAttribute_Should_Add_Attribute() + { + // Arrange + var interfaceDeclaration = SyntaxFactory.InterfaceDeclaration("ITestInterface"); + var suppressMessage = new SuppressMessageAttribute("Design", "CA1002") { Justification = "OK." }; + + // Act + var result = interfaceDeclaration.AddSuppressMessageAttribute(suppressMessage); + + // Assert + Assert.NotNull(result); + Assert.Single(result.AttributeLists); + var attribute = result.AttributeLists[0].Attributes[0]; + Assert.Equal("SuppressMessage", attribute.Name.ToString(), StringComparer.Ordinal); + } + [Fact] public void AddGeneratedCodeAttribute_Should_Throw_When_InterfaceDeclaration_Is_Null() { diff --git a/test/Atc.CodeAnalysis.CSharp.Tests/Extensions/RecordDeclarationSyntaxExtensionsTests.cs b/test/Atc.CodeAnalysis.CSharp.Tests/Extensions/RecordDeclarationSyntaxExtensionsTests.cs new file mode 100644 index 00000000..bfa72442 --- /dev/null +++ b/test/Atc.CodeAnalysis.CSharp.Tests/Extensions/RecordDeclarationSyntaxExtensionsTests.cs @@ -0,0 +1,110 @@ +namespace Atc.CodeAnalysis.CSharp.Tests.Extensions; + +public class RecordDeclarationSyntaxExtensionsTests +{ + [Fact] + public void AddSuppressMessageAttribute_Should_Throw_When_RecordDeclaration_Is_Null() + { + // Arrange + RecordDeclarationSyntax recordDeclaration = null!; + var suppressMessage = new SuppressMessageAttribute("category", "checkId") { Justification = "OK." }; + + // Act & Assert + Assert.Throws(() => + recordDeclaration.AddSuppressMessageAttribute(suppressMessage)); + } + + [Fact] + public void AddSuppressMessageAttribute_Should_Throw_When_SuppressMessage_Is_Null() + { + // Arrange + var recordDeclaration = SyntaxFactory.RecordDeclaration(SyntaxFactory.Token(SyntaxKind.RecordKeyword), "TestRecord"); + + // Act & Assert + Assert.Throws(() => + recordDeclaration.AddSuppressMessageAttribute(null!)); + } + + [Fact] + public void AddSuppressMessageAttribute_Should_Throw_When_Justification_Is_Empty() + { + // Arrange + var recordDeclaration = SyntaxFactory.RecordDeclaration(SyntaxFactory.Token(SyntaxKind.RecordKeyword), "TestRecord"); + var suppressMessage = new SuppressMessageAttribute("category", "checkId"); + + // Act & Assert + Assert.Throws(() => + recordDeclaration.AddSuppressMessageAttribute(suppressMessage)); + } + + [Fact] + public void AddSuppressMessageAttribute_Should_Add_Attribute() + { + // Arrange + var recordDeclaration = SyntaxFactory.RecordDeclaration(SyntaxFactory.Token(SyntaxKind.RecordKeyword), "TestRecord"); + var suppressMessage = new SuppressMessageAttribute("Design", "CA1002") { Justification = "OK." }; + + // Act + var result = recordDeclaration.AddSuppressMessageAttribute(suppressMessage); + + // Assert + Assert.NotNull(result); + Assert.Single(result.AttributeLists); + var attribute = result.AttributeLists[0].Attributes[0]; + Assert.Equal("SuppressMessage", attribute.Name.ToString(), StringComparer.Ordinal); + } + + [Fact] + public void AddGeneratedCodeAttribute_Should_Throw_When_RecordDeclaration_Is_Null() + { + // Arrange + RecordDeclarationSyntax recordDeclaration = null!; + + // Act & Assert + Assert.Throws(() => + recordDeclaration.AddGeneratedCodeAttribute("Tool", "1.0")); + } + + [Fact] + public void AddGeneratedCodeAttribute_Should_Throw_When_ToolName_Is_Null() + { + // Arrange + var recordDeclaration = SyntaxFactory.RecordDeclaration(SyntaxFactory.Token(SyntaxKind.RecordKeyword), "TestRecord"); + + // Act & Assert + Assert.Throws(() => + recordDeclaration.AddGeneratedCodeAttribute(null!, "1.0")); + } + + [Fact] + public void AddGeneratedCodeAttribute_Should_Throw_When_Version_Is_Null() + { + // Arrange + var recordDeclaration = SyntaxFactory.RecordDeclaration(SyntaxFactory.Token(SyntaxKind.RecordKeyword), "TestRecord"); + + // Act & Assert + Assert.Throws(() => + recordDeclaration.AddGeneratedCodeAttribute("Tool", null!)); + } + + [Fact] + public void AddGeneratedCodeAttribute_Should_Add_Attribute_With_ToolName_And_Version() + { + // Arrange + var recordDeclaration = SyntaxFactory.RecordDeclaration(SyntaxFactory.Token(SyntaxKind.RecordKeyword), "TestRecord"); + const string toolName = "MyCodeGenerator"; + const string version = "1.2.3"; + + // Act + var result = recordDeclaration.AddGeneratedCodeAttribute(toolName, version); + + // Assert + Assert.NotNull(result); + var attributeLists = result.AttributeLists; + Assert.Single(attributeLists); + var attribute = attributeLists[0].Attributes[0]; + Assert.Equal("GeneratedCode", attribute.Name.ToString(), StringComparer.Ordinal); + Assert.NotNull(attribute.ArgumentList); + Assert.Equal(2, attribute.ArgumentList.Arguments.Count); + } +} \ No newline at end of file diff --git a/test/Atc.CodeAnalysis.CSharp.Tests/Extensions/StructDeclarationSyntaxExtensionsTests.cs b/test/Atc.CodeAnalysis.CSharp.Tests/Extensions/StructDeclarationSyntaxExtensionsTests.cs new file mode 100644 index 00000000..ba3cbfdd --- /dev/null +++ b/test/Atc.CodeAnalysis.CSharp.Tests/Extensions/StructDeclarationSyntaxExtensionsTests.cs @@ -0,0 +1,110 @@ +namespace Atc.CodeAnalysis.CSharp.Tests.Extensions; + +public class StructDeclarationSyntaxExtensionsTests +{ + [Fact] + public void AddSuppressMessageAttribute_Should_Throw_When_StructDeclaration_Is_Null() + { + // Arrange + StructDeclarationSyntax structDeclaration = null!; + var suppressMessage = new SuppressMessageAttribute("category", "checkId") { Justification = "OK." }; + + // Act & Assert + Assert.Throws(() => + structDeclaration.AddSuppressMessageAttribute(suppressMessage)); + } + + [Fact] + public void AddSuppressMessageAttribute_Should_Throw_When_SuppressMessage_Is_Null() + { + // Arrange + var structDeclaration = SyntaxFactory.StructDeclaration("TestStruct"); + + // Act & Assert + Assert.Throws(() => + structDeclaration.AddSuppressMessageAttribute(null!)); + } + + [Fact] + public void AddSuppressMessageAttribute_Should_Throw_When_Justification_Is_Empty() + { + // Arrange + var structDeclaration = SyntaxFactory.StructDeclaration("TestStruct"); + var suppressMessage = new SuppressMessageAttribute("category", "checkId"); + + // Act & Assert + Assert.Throws(() => + structDeclaration.AddSuppressMessageAttribute(suppressMessage)); + } + + [Fact] + public void AddSuppressMessageAttribute_Should_Add_Attribute() + { + // Arrange + var structDeclaration = SyntaxFactory.StructDeclaration("TestStruct"); + var suppressMessage = new SuppressMessageAttribute("Design", "CA1002") { Justification = "OK." }; + + // Act + var result = structDeclaration.AddSuppressMessageAttribute(suppressMessage); + + // Assert + Assert.NotNull(result); + Assert.Single(result.AttributeLists); + var attribute = result.AttributeLists[0].Attributes[0]; + Assert.Equal("SuppressMessage", attribute.Name.ToString(), StringComparer.Ordinal); + } + + [Fact] + public void AddGeneratedCodeAttribute_Should_Throw_When_StructDeclaration_Is_Null() + { + // Arrange + StructDeclarationSyntax structDeclaration = null!; + + // Act & Assert + Assert.Throws(() => + structDeclaration.AddGeneratedCodeAttribute("Tool", "1.0")); + } + + [Fact] + public void AddGeneratedCodeAttribute_Should_Throw_When_ToolName_Is_Null() + { + // Arrange + var structDeclaration = SyntaxFactory.StructDeclaration("TestStruct"); + + // Act & Assert + Assert.Throws(() => + structDeclaration.AddGeneratedCodeAttribute(null!, "1.0")); + } + + [Fact] + public void AddGeneratedCodeAttribute_Should_Throw_When_Version_Is_Null() + { + // Arrange + var structDeclaration = SyntaxFactory.StructDeclaration("TestStruct"); + + // Act & Assert + Assert.Throws(() => + structDeclaration.AddGeneratedCodeAttribute("Tool", null!)); + } + + [Fact] + public void AddGeneratedCodeAttribute_Should_Add_Attribute_With_ToolName_And_Version() + { + // Arrange + var structDeclaration = SyntaxFactory.StructDeclaration("TestStruct"); + const string toolName = "MyCodeGenerator"; + const string version = "1.2.3"; + + // Act + var result = structDeclaration.AddGeneratedCodeAttribute(toolName, version); + + // Assert + Assert.NotNull(result); + var attributeLists = result.AttributeLists; + Assert.Single(attributeLists); + var attribute = attributeLists[0].Attributes[0]; + Assert.Equal("GeneratedCode", attribute.Name.ToString(), StringComparer.Ordinal); + Assert.NotNull(attribute.ArgumentList); + Assert.Equal(2, attribute.ArgumentList.Arguments.Count); + } +} \ No newline at end of file diff --git a/test/Atc.CodeAnalysis.CSharp.Tests/SyntaxFactories/SyntaxLiteralExpressionFactoryTests.cs b/test/Atc.CodeAnalysis.CSharp.Tests/SyntaxFactories/SyntaxLiteralExpressionFactoryTests.cs index c25d2cac..ec5d70d0 100644 --- a/test/Atc.CodeAnalysis.CSharp.Tests/SyntaxFactories/SyntaxLiteralExpressionFactoryTests.cs +++ b/test/Atc.CodeAnalysis.CSharp.Tests/SyntaxFactories/SyntaxLiteralExpressionFactoryTests.cs @@ -42,6 +42,17 @@ public void ShouldParseInvalidNumberAsString(string value) Assert.Equal($"\"{value}\"", result.ToString()); } + [Fact] + public void CreateNull_Returns_NullLiteralExpression() + { + // Act + var result = SyntaxLiteralExpressionFactory.CreateNull(); + + // Assert + Assert.Equal(SyntaxKind.NullLiteralExpression, result.Kind()); + Assert.Equal("null", result.ToString()); + } + [Theory] [InlineData(0)] [InlineData(42)] diff --git a/test/Atc.CodeAnalysis.CSharp.Tests/SyntaxFactories/SyntaxObjectCreationExpressionFactoryTests.cs b/test/Atc.CodeAnalysis.CSharp.Tests/SyntaxFactories/SyntaxObjectCreationExpressionFactoryTests.cs index 14db3d99..6a98feaf 100644 --- a/test/Atc.CodeAnalysis.CSharp.Tests/SyntaxFactories/SyntaxObjectCreationExpressionFactoryTests.cs +++ b/test/Atc.CodeAnalysis.CSharp.Tests/SyntaxFactories/SyntaxObjectCreationExpressionFactoryTests.cs @@ -37,7 +37,7 @@ public void Create_With_Namespace_Should_Throw_When_IdentifierName_Is_Null() { // Act & Assert Assert.Throws(() => - SyntaxObjectCreationExpressionFactory.Create("System", null!)); + SyntaxObjectCreationExpressionFactory.Create("System", (string)null!)); } [Fact] @@ -56,4 +56,134 @@ public void Create_With_Namespace_Should_Create_Object_Creation_Expression_With_ Assert.Contains(namespaceName, typeName, StringComparison.Ordinal); Assert.Contains(identifierName, typeName, StringComparison.Ordinal); } + + [Fact] + public void Create_With_ArgumentList_Should_Throw_When_IdentifierName_Is_Null() + { + // Arrange + var argumentList = SyntaxFactory.ArgumentList(); + + // Act & Assert + Assert.Throws(() => + SyntaxObjectCreationExpressionFactory.Create((string)null!, argumentList)); + } + + [Fact] + public void Create_With_ArgumentList_Should_Throw_When_ArgumentList_Is_Null() + { + // Act & Assert + Assert.Throws(() => + SyntaxObjectCreationExpressionFactory.Create("TestClass", (ArgumentListSyntax)null!)); + } + + [Fact] + public void Create_With_ArgumentList_Should_Create_Object_Creation_Expression_With_Arguments() + { + // Arrange + const string identifierName = "TestClass"; + var argumentList = SyntaxFactory.ArgumentList(); + + // Act + var result = SyntaxObjectCreationExpressionFactory.Create(identifierName, argumentList); + + // Assert + Assert.NotNull(result); + Assert.Equal(identifierName, result.Type.ToString(), StringComparer.Ordinal); + Assert.NotNull(result.ArgumentList); + } + + [Fact] + public void Create_With_Namespace_And_ArgumentList_Should_Create_Expression_With_Arguments() + { + // Arrange + const string namespaceName = "System"; + const string identifierName = "Exception"; + var argumentList = SyntaxFactory.ArgumentList(); + + // Act + var result = SyntaxObjectCreationExpressionFactory.Create(namespaceName, identifierName, argumentList); + + // Assert + Assert.NotNull(result); + var typeName = result.Type.ToString(); + Assert.Contains(namespaceName, typeName, StringComparison.Ordinal); + Assert.Contains(identifierName, typeName, StringComparison.Ordinal); + Assert.NotNull(result.ArgumentList); + } + + [Fact] + public void CreateGeneric_With_TypeArgumentList_Should_Create_Generic_Expression() + { + // Arrange + const string identifierName = "List"; + var typeArgumentList = SyntaxFactory.TypeArgumentList( + SyntaxFactory.SeparatedList(new[] + { + SyntaxFactory.PredefinedType(SyntaxFactory.Token(SyntaxKind.StringKeyword)), + })); + + // Act + var result = SyntaxObjectCreationExpressionFactory.CreateGeneric(identifierName, typeArgumentList); + + // Assert + Assert.NotNull(result); + Assert.Contains("List", result.Type.ToString(), StringComparison.Ordinal); + Assert.Contains("string", result.Type.ToString(), StringComparison.Ordinal); + } + + [Fact] + public void CreateGeneric_With_TypeArgumentName_Should_Create_Generic_Expression() + { + // Arrange + const string identifierName = "List"; + const string typeArgumentName = "MyType"; + + // Act + var result = SyntaxObjectCreationExpressionFactory.CreateGeneric(identifierName, typeArgumentName); + + // Assert + Assert.NotNull(result); + Assert.Contains(identifierName, result.Type.ToString(), StringComparison.Ordinal); + Assert.Contains(typeArgumentName, result.Type.ToString(), StringComparison.Ordinal); + } + + [Fact] + public void CreateGeneric_With_TypeArgumentList_And_ArgumentList_Should_Create_Generic_Expression_With_Arguments() + { + // Arrange + const string identifierName = "Dictionary"; + var typeArgumentList = SyntaxFactory.TypeArgumentList( + SyntaxFactory.SeparatedList(new[] + { + SyntaxFactory.PredefinedType(SyntaxFactory.Token(SyntaxKind.StringKeyword)), + SyntaxFactory.PredefinedType(SyntaxFactory.Token(SyntaxKind.IntKeyword)), + })); + var argumentList = SyntaxFactory.ArgumentList(); + + // Act + var result = SyntaxObjectCreationExpressionFactory.CreateGeneric(identifierName, typeArgumentList, argumentList); + + // Assert + Assert.NotNull(result); + Assert.Contains(identifierName, result.Type.ToString(), StringComparison.Ordinal); + Assert.NotNull(result.ArgumentList); + } + + [Fact] + public void CreateGeneric_With_TypeArgumentName_And_ArgumentList_Should_Create_Generic_Expression_With_Arguments() + { + // Arrange + const string identifierName = "List"; + const string typeArgumentName = "MyType"; + var argumentList = SyntaxFactory.ArgumentList(); + + // Act + var result = SyntaxObjectCreationExpressionFactory.CreateGeneric(identifierName, typeArgumentName, argumentList); + + // Assert + Assert.NotNull(result); + Assert.Contains(identifierName, result.Type.ToString(), StringComparison.Ordinal); + Assert.Contains(typeArgumentName, result.Type.ToString(), StringComparison.Ordinal); + Assert.NotNull(result.ArgumentList); + } } \ No newline at end of file diff --git a/test/Atc.CodeDocumentation.Tests/Atc.CodeDocumentation.Tests.csproj b/test/Atc.CodeDocumentation.Tests/Atc.CodeDocumentation.Tests.csproj index f7626ef9..8fe6381d 100644 --- a/test/Atc.CodeDocumentation.Tests/Atc.CodeDocumentation.Tests.csproj +++ b/test/Atc.CodeDocumentation.Tests/Atc.CodeDocumentation.Tests.csproj @@ -7,7 +7,7 @@ - + diff --git a/test/Atc.Console.Spectre.Tests/Atc.Console.Spectre.Tests.csproj b/test/Atc.Console.Spectre.Tests/Atc.Console.Spectre.Tests.csproj index d8e81b0d..a79d246d 100644 --- a/test/Atc.Console.Spectre.Tests/Atc.Console.Spectre.Tests.csproj +++ b/test/Atc.Console.Spectre.Tests/Atc.Console.Spectre.Tests.csproj @@ -7,7 +7,7 @@ - + diff --git a/test/Atc.DotNet.Tests/Atc.DotNet.Tests.csproj b/test/Atc.DotNet.Tests/Atc.DotNet.Tests.csproj index a6034f65..78e71269 100644 --- a/test/Atc.DotNet.Tests/Atc.DotNet.Tests.csproj +++ b/test/Atc.DotNet.Tests/Atc.DotNet.Tests.csproj @@ -13,7 +13,7 @@ - + diff --git a/test/Atc.DotNet.Tests/DotnetBuildHelperTests.cs b/test/Atc.DotNet.Tests/DotnetBuildHelperTests.cs index f3fdc5b9..ca2bf34d 100644 --- a/test/Atc.DotNet.Tests/DotnetBuildHelperTests.cs +++ b/test/Atc.DotNet.Tests/DotnetBuildHelperTests.cs @@ -50,6 +50,152 @@ public async Task Create_ConsoleApp_BadCase() Assert.Single(buildErrors); } + [Fact] + public void ParseErrors_CompilerError_WithProjectSuffix_Counted() + { + const string output = "Program.cs(13,13): error CS0246: The type 'Foo' could not be found [Test.csproj]"; + + var errors = DotnetBuildHelper.ParseErrors(output); + + Assert.Single(errors); + Assert.Equal(1, errors["CS0246"]); + } + + [Fact] + public void ParseErrors_CompilerError_WithoutProjectSuffix_Counted() + { + const string output = "Program.cs(13,13): error CS0246: The type 'Foo' could not be found"; + + var errors = DotnetBuildHelper.ParseErrors(output); + + Assert.Single(errors); + Assert.Equal(1, errors["CS0246"]); + } + + [Fact] + public void ParseErrors_MSBuildError_Counted() + { + const string output = "MSBUILD : error MSB1003: Specify a project or solution file."; + + var errors = DotnetBuildHelper.ParseErrors(output); + + Assert.Single(errors); + Assert.Equal(1, errors["MSB1003"]); + } + + [Fact] + public void ParseErrors_NuGetError_Counted() + { + const string output = "Test.csproj : error NU1101: Unable to find package SomePackage."; + + var errors = DotnetBuildHelper.ParseErrors(output); + + Assert.Single(errors); + Assert.Equal(1, errors["NU1101"]); + } + + [Fact] + public void ParseErrors_MultipleErrors_AggregatedByCode() + { + const string output = """ + Program.cs(5,5): error CS0246: Missing type [Test.csproj] + Program.cs(6,5): error CS0246: Missing type [Test.csproj] + Program.cs(7,5): error CS0103: Name not found [Test.csproj] + """; + + var errors = DotnetBuildHelper.ParseErrors(output); + + Assert.Equal(2, errors.Count); + Assert.Equal(2, errors["CS0246"]); + Assert.Equal(1, errors["CS0103"]); + } + + [Fact] + public void ParseErrors_EmptyOutput_ReturnsEmpty() + { + var errors = DotnetBuildHelper.ParseErrors(string.Empty); + + Assert.Empty(errors); + } + + [Fact] + public void ParseWarnings_CompilerWarning_WithProjectSuffix_Counted() + { + const string output = "Program.cs(5,13): warning CS0168: The variable 'x' is declared but never used [Test.csproj]"; + + var warnings = DotnetBuildHelper.ParseWarnings(output); + + Assert.Single(warnings); + Assert.Equal(1, warnings["CS0168"]); + } + + [Fact] + public void ParseWarnings_CompilerWarning_WithoutProjectSuffix_Counted() + { + const string output = "Program.cs(5,13): warning CS0168: The variable 'x' is declared but never used"; + + var warnings = DotnetBuildHelper.ParseWarnings(output); + + Assert.Single(warnings); + Assert.Equal(1, warnings["CS0168"]); + } + + [Fact] + public void ParseWarnings_MSBuildWarning_Counted() + { + const string output = "MSBUILD : warning MSB3277: Found conflicts between different versions of assembly."; + + var warnings = DotnetBuildHelper.ParseWarnings(output); + + Assert.Single(warnings); + Assert.Equal(1, warnings["MSB3277"]); + } + + [Fact] + public void ParseWarnings_NuGetWarning_Counted() + { + const string output = "Test.csproj : warning NU1701: Package 'OldPkg 1.0.0' was restored using net472."; + + var warnings = DotnetBuildHelper.ParseWarnings(output); + + Assert.Single(warnings); + Assert.Equal(1, warnings["NU1701"]); + } + + [Fact] + public void ParseWarnings_MultipleWarnings_AggregatedByCode() + { + const string output = """ + Program.cs(5,5): warning CS0168: Unused var [Test.csproj] + Program.cs(6,5): warning CS0168: Unused var [Test.csproj] + Program.cs(7,5): warning CS0219: Value assigned but never used [Test.csproj] + """; + + var warnings = DotnetBuildHelper.ParseWarnings(output); + + Assert.Equal(2, warnings.Count); + Assert.Equal(2, warnings["CS0168"]); + Assert.Equal(1, warnings["CS0219"]); + } + + [Fact] + public void ParseWarnings_EmptyOutput_ReturnsEmpty() + { + var warnings = DotnetBuildHelper.ParseWarnings(string.Empty); + + Assert.Empty(warnings); + } + + [Fact] + public void ParseWarnings_DoesNotMatchErrors() + { + const string output = "Program.cs(13,13): error CS0246: The type 'Foo' could not be found"; + + var warnings = DotnetBuildHelper.ParseWarnings(output); + + Assert.Empty(warnings); + } + private static Task CreateCsprojFile(DirectoryInfo workingDirectory) { var file = new FileInfo(Path.Combine(workingDirectory.FullName, "Test.csproj")); diff --git a/test/Atc.OpenApi.Tests/Atc.OpenApi.Tests.csproj b/test/Atc.OpenApi.Tests/Atc.OpenApi.Tests.csproj index 06a005d5..9bc9228c 100644 --- a/test/Atc.OpenApi.Tests/Atc.OpenApi.Tests.csproj +++ b/test/Atc.OpenApi.Tests/Atc.OpenApi.Tests.csproj @@ -7,7 +7,7 @@ - + diff --git a/test/Atc.Rest.Extended.Tests/Atc.Rest.Extended.Tests.csproj b/test/Atc.Rest.Extended.Tests/Atc.Rest.Extended.Tests.csproj index 887d59b3..28241bef 100644 --- a/test/Atc.Rest.Extended.Tests/Atc.Rest.Extended.Tests.csproj +++ b/test/Atc.Rest.Extended.Tests/Atc.Rest.Extended.Tests.csproj @@ -7,7 +7,7 @@ - + diff --git a/test/Atc.Rest.Extended.Tests/GlobalUsings.cs b/test/Atc.Rest.Extended.Tests/GlobalUsings.cs index 2adc1a48..60d222e6 100644 --- a/test/Atc.Rest.Extended.Tests/GlobalUsings.cs +++ b/test/Atc.Rest.Extended.Tests/GlobalUsings.cs @@ -1,6 +1,8 @@ global using System.Diagnostics.CodeAnalysis; global using System.Reflection; +global using Asp.Versioning; + global using Atc.CodeDocumentation.Markdown; global using Atc.Rest.Extended.Extensions; global using Atc.Rest.Extended.Filters; diff --git a/test/Atc.Rest.Extended.Tests/Options/ConfigureApiVersioningOptionsTests.cs b/test/Atc.Rest.Extended.Tests/Options/ConfigureApiVersioningOptionsTests.cs new file mode 100644 index 00000000..c455a348 --- /dev/null +++ b/test/Atc.Rest.Extended.Tests/Options/ConfigureApiVersioningOptionsTests.cs @@ -0,0 +1,18 @@ +namespace Atc.Rest.Extended.Tests.Options; + +public class ConfigureApiVersioningOptionsTests +{ + [Fact] + public void Constructor_WithoutTelemetry_DoesNotThrow() + { + // TelemetryClient was injected but never used, causing DI failure for consumers + // without App Insights. ConfigureApiVersioningOptions must be instantiable without it. + var exception = Record.Exception(() => new ConfigureApiVersioningOptions()); + Assert.Null(exception); + } + + [Fact] + public void Implements_IConfigureOptions_ApiVersioningOptions() + => typeof(ConfigureApiVersioningOptions) + .Should().Implement>(); +} \ No newline at end of file diff --git a/test/Atc.Rest.FluentAssertions.Tests/Assertions/ContentResultAssertionsTests.cs b/test/Atc.Rest.FluentAssertions.Tests/Assertions/ContentResultAssertionsTests.cs index 48732823..7c75a3b0 100644 --- a/test/Atc.Rest.FluentAssertions.Tests/Assertions/ContentResultAssertionsTests.cs +++ b/test/Atc.Rest.FluentAssertions.Tests/Assertions/ContentResultAssertionsTests.cs @@ -87,6 +87,29 @@ public void WithContent_Does_Not_Throw_When_Expected_Match() .NotThrow(); } + [Theory] + [InlineData("application/json; charset=utf-8")] + [InlineData("application/json;charset=utf-8")] + [InlineData("APPLICATION/JSON")] + public void WithContent_Does_Not_Throw_When_ContentType_Has_Charset_Or_Differs_In_Case( + string contentType) + { + // Arrange + var target = new ContentResult + { + Content = TestJsonSerializer.Serialize("FOO"), + ContentType = contentType, + }; + + var sut = new ContentResultAssertions(target); + + // Act & Assert + sut + .Invoking(x => x.WithContent("FOO")) + .Should() + .NotThrow(); + } + [Fact] public void WithStatusCode_Throws_When_StatusCode_Is_Not_As_Expected() { diff --git a/test/Atc.Rest.FluentAssertions.Tests/Atc.Rest.FluentAssertions.Tests.csproj b/test/Atc.Rest.FluentAssertions.Tests/Atc.Rest.FluentAssertions.Tests.csproj index 92c5ff3f..0f540164 100644 --- a/test/Atc.Rest.FluentAssertions.Tests/Atc.Rest.FluentAssertions.Tests.csproj +++ b/test/Atc.Rest.FluentAssertions.Tests/Atc.Rest.FluentAssertions.Tests.csproj @@ -7,7 +7,7 @@ - + diff --git a/test/Atc.Rest.HealthChecks.Tests/Atc.Rest.HealthChecks.Tests.csproj b/test/Atc.Rest.HealthChecks.Tests/Atc.Rest.HealthChecks.Tests.csproj index e62082b9..9c85e7cc 100644 --- a/test/Atc.Rest.HealthChecks.Tests/Atc.Rest.HealthChecks.Tests.csproj +++ b/test/Atc.Rest.HealthChecks.Tests/Atc.Rest.HealthChecks.Tests.csproj @@ -7,7 +7,7 @@ - + diff --git a/test/Atc.Rest.HealthChecks.Tests/Extensions/HealthReportEntryExtensionsTests.cs b/test/Atc.Rest.HealthChecks.Tests/Extensions/HealthReportEntryExtensionsTests.cs index a5e0a32b..8f5163b4 100644 --- a/test/Atc.Rest.HealthChecks.Tests/Extensions/HealthReportEntryExtensionsTests.cs +++ b/test/Atc.Rest.HealthChecks.Tests/Extensions/HealthReportEntryExtensionsTests.cs @@ -71,8 +71,8 @@ public void ToHealthCheck_With_Data() .And.ContainKey("isRunning") .And.ContainKey("duration"); - actual.Data!["isRunning"].Should().Be(true); - actual.Data!["duration"].Should().Be(duration); + actual.Data!["isRunning"].Should().Be("True"); + actual.Data!["duration"].Should().Be(duration.ToString()); } [Fact] @@ -189,7 +189,7 @@ public void ToHealthCheck_Sanitizes_Exception_In_Data() // Assert actual.Data.Should().NotBeNull().And.HaveCount(2); actual.Data!["error"].Should().Be("Cache connection failed"); - actual.Data!["retries"].Should().Be(3); + actual.Data!["retries"].Should().Be("3"); } [Fact] @@ -223,9 +223,9 @@ public void ToHealthCheck_Preserves_NonException_Objects_In_Data() // Assert actual.Data.Should().NotBeNull().And.HaveCount(4); actual.Data!["label"].Should().Be("healthy"); - actual.Data!["flag"].Should().Be(true); - actual.Data!["count"].Should().Be(42); - actual.Data!["duration"].Should().Be(TimeSpan.FromMilliseconds(500)); + actual.Data!["flag"].Should().Be("True"); + actual.Data!["count"].Should().Be("42"); + actual.Data!["duration"].Should().Be(TimeSpan.FromMilliseconds(500).ToString()); } [Fact] @@ -256,7 +256,7 @@ public void ToHealthCheck_Preserves_ResourceHealthCheck_In_Data() // Assert actual.Data.Should().NotBeNull().And.HaveCount(1); - actual.Data!["redis"].Should().Be(resource); + actual.Data!["redis"].Should().Be(resource.ToString()); } [Fact] @@ -323,6 +323,6 @@ public void ToHealthChecks_With_Data() dataBag .Should().NotBeNull() .And.HaveCount(1); - dataBag!["failures"].Should().Be(3); + dataBag!["failures"].Should().Be("3"); } } \ No newline at end of file diff --git a/test/Atc.Rest.Tests/Atc.Rest.Tests.csproj b/test/Atc.Rest.Tests/Atc.Rest.Tests.csproj index 05c6a485..bc3b1760 100644 --- a/test/Atc.Rest.Tests/Atc.Rest.Tests.csproj +++ b/test/Atc.Rest.Tests/Atc.Rest.Tests.csproj @@ -7,7 +7,7 @@ - + diff --git a/test/Atc.Rest.Tests/Extensions/HeaderDictionaryExtensionsTests.cs b/test/Atc.Rest.Tests/Extensions/HeaderDictionaryExtensionsTests.cs index 3f19ac40..666596d6 100644 --- a/test/Atc.Rest.Tests/Extensions/HeaderDictionaryExtensionsTests.cs +++ b/test/Atc.Rest.Tests/Extensions/HeaderDictionaryExtensionsTests.cs @@ -106,6 +106,25 @@ public void GetOrAddRequestId() Assert.True(Guid.TryParse(actual, out _)); } + [Fact] + public void GetOrAddRequestId_Replaces_Unsafe_Value_With_New_Guid() + { + // Arrange - a CR/LF-bearing value (header-injection / log-forging attempt) + var data = new HeaderDictionary + { + new( + "x-request-id", + new StringValues("abc\r\nInjected-Header: evil")), + }; + + // Act + var actual = data.GetOrAddRequestId(); + + // Assert - the unsafe value must be discarded and replaced with a fresh GUID + Assert.NotNull(actual); + Assert.True(Guid.TryParse(actual, out _)); + } + [Fact] public void GetCallingOnBehalfOfIdentity() { diff --git a/test/Atc.Rest.Tests/Filters/ErrorHandlingExceptionFilterAttributeTests.cs b/test/Atc.Rest.Tests/Filters/ErrorHandlingExceptionFilterAttributeTests.cs index f12b4b4d..4924185b 100644 --- a/test/Atc.Rest.Tests/Filters/ErrorHandlingExceptionFilterAttributeTests.cs +++ b/test/Atc.Rest.Tests/Filters/ErrorHandlingExceptionFilterAttributeTests.cs @@ -63,7 +63,7 @@ public void OnException_LiveRequest_ComposesResponseBody() // Assert Assert.True(exceptionContext.ExceptionHandled); Assert.NotNull(exceptionContext.Result); - var content = Assert.IsType(exceptionContext.Result); - Assert.Equal((int)HttpStatusCode.Conflict, content.StatusCode); + var objectResult = Assert.IsType(exceptionContext.Result); + Assert.Equal((int)HttpStatusCode.InternalServerError, objectResult.StatusCode); } } \ No newline at end of file diff --git a/test/Atc.Rest.Tests/Middleware/ExceptionTelemetryMiddlewareTests.cs b/test/Atc.Rest.Tests/Middleware/ExceptionTelemetryMiddlewareTests.cs index 2f223fc5..57ef2d7b 100644 --- a/test/Atc.Rest.Tests/Middleware/ExceptionTelemetryMiddlewareTests.cs +++ b/test/Atc.Rest.Tests/Middleware/ExceptionTelemetryMiddlewareTests.cs @@ -3,21 +3,46 @@ namespace Atc.Rest.Tests.Middleware; public class ExceptionTelemetryMiddlewareTests { [Fact] - public async Task InvokeAsync() + public async Task InvokeAsync_WithTelemetryClient_ReturnsOk() { // Arrange + var services = new ServiceCollection(); using var telemetryConfiguration = new TelemetryConfiguration { ConnectionString = "InstrumentationKey=00000000-0000-0000-0000-000000000000", }; - var telemetryClient = new TelemetryClient(telemetryConfiguration); + services.AddSingleton(new TelemetryClient(telemetryConfiguration)); + var serviceProvider = services.BuildServiceProvider(); + + var middleware = new ExceptionTelemetryMiddleware( + async innerHttpContext => await innerHttpContext.Response.WriteAsync("test response body")); + + var defaultHttpContext = new DefaultHttpContext + { + RequestServices = serviceProvider, + }; + + // Act + await middleware.InvokeAsync(defaultHttpContext); + + // Assert + Assert.Equal((int)HttpStatusCode.OK, defaultHttpContext.Response.StatusCode); + } + + [Fact] + public async Task InvokeAsync_WithoutTelemetryClient_DoesNotThrowOnSuccess() + { + // Arrange — no TelemetryClient registered + var services = new ServiceCollection(); + var serviceProvider = services.BuildServiceProvider(); + var middleware = new ExceptionTelemetryMiddleware( - async innerHttpContext => - { - await innerHttpContext.Response.WriteAsync("test response body"); - }, - telemetryClient); - var defaultHttpContext = new DefaultHttpContext(); + async innerHttpContext => await innerHttpContext.Response.WriteAsync("ok")); + + var defaultHttpContext = new DefaultHttpContext + { + RequestServices = serviceProvider, + }; // Act await middleware.InvokeAsync(defaultHttpContext); @@ -25,4 +50,26 @@ public async Task InvokeAsync() // Assert Assert.Equal((int)HttpStatusCode.OK, defaultHttpContext.Response.StatusCode); } + + [Fact] + public async Task InvokeAsync_WithoutTelemetryClient_ExceptionYields500() + { + // Arrange — no TelemetryClient registered; pipeline throws + var services = new ServiceCollection(); + var serviceProvider = services.BuildServiceProvider(); + + var middleware = new ExceptionTelemetryMiddleware( + _ => throw new InvalidOperationException("boom")); + + var defaultHttpContext = new DefaultHttpContext + { + RequestServices = serviceProvider, + }; + + // Act + await middleware.InvokeAsync(defaultHttpContext); + + // Assert — 500 returned; no NRE from missing TelemetryClient + Assert.Equal((int)HttpStatusCode.InternalServerError, defaultHttpContext.Response.StatusCode); + } } \ No newline at end of file diff --git a/test/Atc.Rest.Tests/Options/ConfigureApiBehaviorOptionsTests.cs b/test/Atc.Rest.Tests/Options/ConfigureApiBehaviorOptionsTests.cs new file mode 100644 index 00000000..05184999 --- /dev/null +++ b/test/Atc.Rest.Tests/Options/ConfigureApiBehaviorOptionsTests.cs @@ -0,0 +1,25 @@ +namespace Atc.Rest.Tests.Options; + +public class ConfigureApiBehaviorOptionsTests +{ + [Fact] + public void Constructor_WithoutTelemetry_DoesNotThrow() + { + // TelemetryClient is optional; when App Insights is not registered, the class + // must still be constructable so DI doesn't fail on startup. + var exception = Record.Exception(() => new ConfigureApiBehaviorOptions()); + Assert.Null(exception); + } + + [Fact] + public void Configure_WithoutTelemetry_SetsExpectedBehaviorOptions() + { + var sut = new ConfigureApiBehaviorOptions(); + var options = new ApiBehaviorOptions(); + + sut.Configure(options); + + Assert.True(options.SuppressInferBindingSourcesForParameters); + Assert.NotNull(options.InvalidModelStateResponseFactory); + } +} \ No newline at end of file diff --git a/test/Atc.Rest.Tests/Results/PaginationTests.cs b/test/Atc.Rest.Tests/Results/PaginationTests.cs index 556707b1..16981856 100644 --- a/test/Atc.Rest.Tests/Results/PaginationTests.cs +++ b/test/Atc.Rest.Tests/Results/PaginationTests.cs @@ -47,4 +47,17 @@ public void Calculate_TotalPages( // Assert Assert.Equal(expectedTotalPages, actual.TotalPages); } + + [Fact] + public void TotalPages_IsNull_When_PageSize_Is_Zero() + { + // Arrange + var sut = new Pagination(items: Array.Empty(), pageSize: 0, queryString: null, continuationToken: null) + { + TotalCount = 10, + }; + + // Act & Assert - guards against the divide-by-zero that produced a garbage page count. + sut.TotalPages.Should().BeNull(); + } } \ No newline at end of file diff --git a/test/Atc.Tests/Atc.Tests.csproj b/test/Atc.Tests/Atc.Tests.csproj index 71d384dc..1cb73960 100644 --- a/test/Atc.Tests/Atc.Tests.csproj +++ b/test/Atc.Tests/Atc.Tests.csproj @@ -7,7 +7,7 @@ - + diff --git a/test/Atc.Tests/CodeComplianceTests.cs b/test/Atc.Tests/CodeComplianceTests.cs index fd51bc8c..55083d5b 100644 --- a/test/Atc.Tests/CodeComplianceTests.cs +++ b/test/Atc.Tests/CodeComplianceTests.cs @@ -33,7 +33,11 @@ public class CodeComplianceTests typeof(UriToAbsoluteUriJsonConverter), // JsonConverter override methods with ref parameters typeof(VersionJsonConverter), // JsonConverter override methods with ref parameters typeof(System.TypeExtensions), + typeof(System.StringExtensions), // AST has limitations with CultureInfo/DateTimeStyles parameter detection typeof(AsyncEnumerableFactory), + typeof(NetworkInformationHelper), // AST/MonoReflection limitations with async methods and default CancellationToken parameters + typeof(JsonSerializerHelper), // AST/MonoReflection limitations with generic async methods and default CancellationToken parameters + typeof(System.IO.StreamExtensions), // AST/MonoReflection limitations with async extension methods and default CancellationToken parameters typeof(ByteExtensions), typeof(EnumerableExtensions), typeof(StringCaseFormatter), // AST has limitations with IFormatProvider/ICustomFormatter interface method detection diff --git a/test/Atc.Tests/Collections/ConcurrentHashSetTests.cs b/test/Atc.Tests/Collections/ConcurrentHashSetTests.cs index a7575181..c07c35a1 100644 --- a/test/Atc.Tests/Collections/ConcurrentHashSetTests.cs +++ b/test/Atc.Tests/Collections/ConcurrentHashSetTests.cs @@ -16,6 +16,31 @@ public void GetEnumerator() list.Dispose(); } + [Fact] + public void GetEnumerator_DoesNotThrow_WhenSetIsMutatedDuringEnumeration() + { + // Arrange + using var set = new ConcurrentHashSet(); + for (var i = 0; i < 1000; i++) + { + set.TryAdd(i); + } + + // Act - enumeration iterates a snapshot, so concurrent mutation must not throw. + var exception = Record.Exception(() => + { + var seed = 1_000; + foreach (var unused in set) + { + set.TryAdd(seed++); + set.TryRemove(0); + } + }); + + // Assert + Assert.Null(exception); + } + [Theory] [InlineData(true, 27)] public void TryAdd( diff --git a/test/Atc.Tests/Comparers/NumericAlphaComparerTests.cs b/test/Atc.Tests/Comparers/NumericAlphaComparerTests.cs index 6177893f..1cb9990d 100644 --- a/test/Atc.Tests/Comparers/NumericAlphaComparerTests.cs +++ b/test/Atc.Tests/Comparers/NumericAlphaComparerTests.cs @@ -37,4 +37,28 @@ public void NumericAlphaComparer_Compare( // Assert Assert.Equal(expected, actual); } + + [Theory] + [InlineData("10A", "9B")] + [InlineData("2B", "1A")] + public void NumericAlphaComparer_Compare_IsConsistentAcrossCultures( + string greater, + string lesser) + { + // The old ExtractLetters used Thread.CurrentThread.CurrentCulture which is locale-dependent. + // With a culture that formats "10" differently (e.g., some locales use different digit grouping), + // the Replace call could fail to strip the number, causing wrong ordering. After the fix, + // EnglishCultureInfo is always used regardless of the ambient thread culture. + var originalCulture = Thread.CurrentThread.CurrentCulture; + try + { + Thread.CurrentThread.CurrentCulture = GlobalizationConstants.EnglishCultureInfo; + var comparer = new NumericAlphaComparer(); + Assert.Equal(1, comparer.Compare(greater, lesser)); + } + finally + { + Thread.CurrentThread.CurrentCulture = originalCulture; + } + } } \ No newline at end of file diff --git a/test/Atc.Tests/Data/Models/LogItemTests.cs b/test/Atc.Tests/Data/Models/LogItemTests.cs new file mode 100644 index 00000000..dbf0dd73 --- /dev/null +++ b/test/Atc.Tests/Data/Models/LogItemTests.cs @@ -0,0 +1,13 @@ +namespace Atc.Tests.Data.Models; + +public class LogItemTests +{ + [Fact] + public void DefaultConstructor_TimeStamp_IsUtc() + { + // DateTime.Now captures local time (Kind=Local), which is DST-sensitive and + // unsuitable for logs. Timestamps should always be UTC. + var item = new LogItem(); + Assert.Equal(DateTimeKind.Utc, item.TimeStamp.Kind); + } +} \ No newline at end of file diff --git a/test/Atc.Tests/Data/SemVer/SemanticVersionTests.cs b/test/Atc.Tests/Data/SemVer/SemanticVersionTests.cs index ec4d9076..1dcfef8b 100644 --- a/test/Atc.Tests/Data/SemVer/SemanticVersionTests.cs +++ b/test/Atc.Tests/Data/SemVer/SemanticVersionTests.cs @@ -88,6 +88,30 @@ public void Constructor_LooseMode( } } + [Theory] + [InlineData("1.0.0-1E3")] + [InlineData("1.0.0-2e5")] + public void Constructor_AlphanumericExponentStyleIdentifier_DoesNotThrow( + string version) + { + // "1E3" and "2e5" contain letters, making them alphanumeric identifiers per SemVer spec. + // NumberStyles.Any causes int.TryParse("1E3") to return 1000, so Clean() returns "1000" + // which differs from "1E3", and the strict-mode validator incorrectly rejects a valid version. + var exception = Record.Exception(() => new SemanticVersion(version)); + Assert.Null(exception); + } + + [Fact] + public void CompareTo_AlphanumericExponentVsNumericPreRelease_AlphanumericSortsLater() + { + // Per SemVer spec §11.4.1: numeric identifiers always have lower precedence than + // alphanumeric identifiers. "1E3" is alphanumeric (contains 'E'), so 1.0.0-1E3 > 1.0.0-1001. + // NumberStyles.Any incorrectly classifies "1E3" as numeric (1000), giving the wrong order. + var numeric = new SemanticVersion("1.0.0-1001", looseMode: true); + var alphanumeric = new SemanticVersion("1.0.0-1E3", looseMode: true); + Assert.True(alphanumeric.CompareTo(numeric) > 0); + } + [Theory] [InlineData("1.2.3")] [InlineData("1.2.3-beta01")] @@ -536,4 +560,25 @@ public void CompareTo_WithSignificantParts( // Assert Assert.Equal(expected, System.Math.Sign(actual)); } + + [Theory] + [InlineData("1.2.3", "1.2.3")] + [InlineData("1.2.3-beta.1", "1.2.3-beta.1")] + public void IFormattable_ToString_ReturnsStandardFormat( + string input, + string expected) + { + IFormattable sut = new SemanticVersion(input); + Assert.Equal(expected, sut.ToString(format: null, formatProvider: null)); + } + + [Fact] + public void IFormattable_ToString_IgnoresFormatAndProvider() + { + IFormattable sut = new SemanticVersion("2.0.0"); + var result1 = sut.ToString("N", null); + var result2 = sut.ToString(null, CultureInfo.InvariantCulture); + Assert.Equal("2.0.0", result1); + Assert.Equal("2.0.0", result2); + } } \ No newline at end of file diff --git a/test/Atc.Tests/Exceptions/ConfigurationExceptionTests.cs b/test/Atc.Tests/Exceptions/ConfigurationExceptionTests.cs new file mode 100644 index 00000000..40b9acb6 --- /dev/null +++ b/test/Atc.Tests/Exceptions/ConfigurationExceptionTests.cs @@ -0,0 +1,24 @@ +namespace Atc.Tests.Exceptions; + +public class ConfigurationExceptionTests +{ + [Fact] + public void ThrowIfMissing_WithPresentValue_DoesNotThrow() + { + string value = "present-value"; + string section = "MySection"; + string key = "MyKey"; + ConfigurationException.ThrowIfMissing(value, section, key); + Assert.NotEmpty(value); + } + + [Fact] + public void ThrowIfInvalid_WhenConditionFalse_DoesNotThrow() + { + bool condition = false; + string section = "MySection"; + string key = "MyKey"; + ConfigurationException.ThrowIfInvalid(condition, section, key); + Assert.False(condition); + } +} \ No newline at end of file diff --git a/test/Atc.Tests/Exceptions/ExceptionsTests.cs b/test/Atc.Tests/Exceptions/ExceptionsTests.cs index 1024e6c4..67a40896 100644 --- a/test/Atc.Tests/Exceptions/ExceptionsTests.cs +++ b/test/Atc.Tests/Exceptions/ExceptionsTests.cs @@ -413,6 +413,113 @@ public void UserNotFoundException( } } + [Fact] + public void SwitchCaseDefaultException_EnumValue_ContainsEnumNameAndValue() + { + var sut = new SwitchCaseDefaultException(DayOfWeek.Monday); + Assert.Contains("DayOfWeek", sut.Message, StringComparison.Ordinal); + Assert.Contains("Monday", sut.Message, StringComparison.Ordinal); + } + + [Fact] + public void SwitchCaseDefaultException_EnumValueAndMessage_ContainsAllParts() + { + var sut = new SwitchCaseDefaultException(DayOfWeek.Friday, "Custom message"); + Assert.Contains("Custom message", sut.Message, StringComparison.Ordinal); + Assert.Contains("DayOfWeek", sut.Message, StringComparison.Ordinal); + Assert.Contains("Friday", sut.Message, StringComparison.Ordinal); + } + + [Fact] + public void SwitchCaseDefaultException_ObjectValue_ContainsTypeAndValue() + { + var sut = new SwitchCaseDefaultException((object?)"unexpected"); + Assert.Contains("String", sut.Message, StringComparison.Ordinal); + Assert.Contains("unexpected", sut.Message, StringComparison.Ordinal); + } + + [Fact] + public void SwitchCaseDefaultException_NullObjectValue_ContainsNullIndicator() + { + var sut = new SwitchCaseDefaultException((object?)null); + Assert.Contains("", sut.Message, StringComparison.Ordinal); + } + + [Fact] + public void SwitchCaseDefaultException_ThrowEnum_ThrowsWithEnumDetails() + { + var ex = Assert.Throws( + () => SwitchCaseDefaultException.Throw(DayOfWeek.Wednesday)); + Assert.Contains("Wednesday", ex.Message, StringComparison.Ordinal); + } + + [Fact] + public void SwitchCaseDefaultException_ThrowObject_ThrowsWithDetails() + { + var ex = Assert.Throws( + () => SwitchCaseDefaultException.Throw((object)"bad")); + Assert.Contains("bad", ex.Message, StringComparison.Ordinal); + } + + [Fact] + public void ConfigurationException_StructuredCtorWithInner_CarriesInnerException() + { + var inner = new InvalidOperationException("root cause"); + var sut = new ConfigurationException("MySection", "MyKey", isMissing: true, inner); + Assert.Contains("MySection", sut.Message, StringComparison.Ordinal); + Assert.Contains("MyKey", sut.Message, StringComparison.Ordinal); + Assert.Same(inner, sut.InnerException); + } + + [Fact] + public void ConfigurationException_ThrowIfMissing_ThrowsWhenNullOrEmpty() + { + Assert.Throws( + () => ConfigurationException.ThrowIfMissing(null, "Sec", "Key")); + Assert.Throws( + () => ConfigurationException.ThrowIfMissing(string.Empty, "Sec", "Key")); + } + + [Fact] + public void ConfigurationException_ThrowIfMissing_DoesNotThrowWhenValuePresent() + { + var exception = Record.Exception( + () => ConfigurationException.ThrowIfMissing("value", "Sec", "Key")); + Assert.Null(exception); + } + + [Fact] + public void ConfigurationException_ThrowIfInvalid_ThrowsWhenConditionTrue() + { + Assert.Throws( + () => ConfigurationException.ThrowIfInvalid(condition: true, "Sec", "Key")); + } + + [Fact] + public void ConfigurationException_ThrowIfInvalid_DoesNotThrowWhenConditionFalse() + { + var exception = Record.Exception( + () => ConfigurationException.ThrowIfInvalid(condition: false, "Sec", "Key")); + Assert.Null(exception); + } + + [Fact] + public void UnexpectedTypeException_Types_ContainsTypeNames() + { + var sut = new UnexpectedTypeException(typeof(string), typeof(int)); + Assert.Contains("string", sut.Message, StringComparison.Ordinal); + Assert.Contains("int", sut.Message, StringComparison.Ordinal); + } + + [Fact] + public void UnexpectedTypeException_TypesAndMessage_ContainsAllParts() + { + var sut = new UnexpectedTypeException(typeof(string), typeof(int), "Custom message"); + Assert.Contains("Custom message", sut.Message, StringComparison.Ordinal); + Assert.Contains("string", sut.Message, StringComparison.Ordinal); + Assert.Contains("int", sut.Message, StringComparison.Ordinal); + } + [Theory] [InlineData("Unexpected ViewModel.", null)] [InlineData("MyMessage", "MyMessage")] diff --git a/test/Atc.Tests/Exceptions/SwitchCaseDefaultExceptionTests.cs b/test/Atc.Tests/Exceptions/SwitchCaseDefaultExceptionTests.cs new file mode 100644 index 00000000..37fdea71 --- /dev/null +++ b/test/Atc.Tests/Exceptions/SwitchCaseDefaultExceptionTests.cs @@ -0,0 +1,38 @@ +namespace Atc.Tests.Exceptions; + +public class SwitchCaseDefaultExceptionTests +{ + [Fact] + public void Throw_WithEnumValue_ThrowsWithEnumDetails() + { + Enum enumValue = DayOfWeek.Monday; + try + { + SwitchCaseDefaultException.Throw(enumValue); + } + catch (SwitchCaseDefaultException ex) + { + Assert.Contains("Monday", ex.Message, StringComparison.Ordinal); + return; + } + + Assert.Fail("Expected SwitchCaseDefaultException to be thrown."); + } + + [Fact] + public void Throw_WithObjectValue_ThrowsWithDetails() + { + object value = "unexpected-value"; + try + { + SwitchCaseDefaultException.Throw(value); + } + catch (SwitchCaseDefaultException ex) + { + Assert.Contains("unexpected-value", ex.Message, StringComparison.Ordinal); + return; + } + + Assert.Fail("Expected SwitchCaseDefaultException to be thrown."); + } +} \ No newline at end of file diff --git a/test/Atc.Tests/Extensions/BaseTypes/ByteExtensionsTests.cs b/test/Atc.Tests/Extensions/BaseTypes/ByteExtensionsTests.cs index 42129557..142fc914 100644 --- a/test/Atc.Tests/Extensions/BaseTypes/ByteExtensionsTests.cs +++ b/test/Atc.Tests/Extensions/BaseTypes/ByteExtensionsTests.cs @@ -24,6 +24,8 @@ public void TakeBytes( [Theory] [InlineData(new byte[] { 1, 0, 0, 0 }, 0, 4, 1)] [InlineData(new byte[] { 255, 0, 0, 0 }, 0, 4, 255)] + [InlineData(new byte[] { 1, 2, 3, 4, 5 }, 0, 1, 1)] + [InlineData(new byte[] { 255, 1, 0, 0 }, 0, 2, 511)] public void TakeBytesAndConvertToInt( byte[] value, int startPosition, @@ -40,6 +42,8 @@ public void TakeBytesAndConvertToInt( [Theory] [InlineData(new byte[] { 1, 0, 0, 0, 0, 0, 0, 0 }, 0, 8, 1L)] [InlineData(new byte[] { 255, 0, 0, 0, 0, 0, 0, 0 }, 0, 8, 255L)] + [InlineData(new byte[] { 1, 2, 3, 4, 5, 6, 7, 8 }, 0, 1, 1L)] + [InlineData(new byte[] { 255, 1, 0, 0, 0, 0, 0, 0 }, 0, 2, 511L)] public void TakeBytesAndConvertToLong( byte[] value, int startPosition, diff --git a/test/Atc.Tests/Extensions/BaseTypes/CharExtensionsTests.cs b/test/Atc.Tests/Extensions/BaseTypes/CharExtensionsTests.cs index aac4aa3c..0ba28281 100644 --- a/test/Atc.Tests/Extensions/BaseTypes/CharExtensionsTests.cs +++ b/test/Atc.Tests/Extensions/BaseTypes/CharExtensionsTests.cs @@ -14,4 +14,62 @@ public void IsAscii( // Assert Assert.Equal(expected, actual); } + + [Theory] + [InlineData(true, 'A')] + [InlineData(true, 'Z')] + [InlineData(true, 'a')] + [InlineData(true, 'z')] + [InlineData(false, '0')] + [InlineData(false, '@')] + [InlineData(false, 'é')] + public void IsAsciiLetter( + bool expected, + char input) + => Assert.Equal(expected, input.IsAsciiLetter()); + + [Theory] + [InlineData(true, '0')] + [InlineData(true, '9')] + [InlineData(false, 'A')] + [InlineData(false, '/')] + [InlineData(false, ':')] + public void IsAsciiDigit( + bool expected, + char input) + => Assert.Equal(expected, input.IsAsciiDigit()); + + [Theory] + [InlineData(true, '0')] + [InlineData(true, '9')] + [InlineData(true, 'A')] + [InlineData(true, 'F')] + [InlineData(true, 'a')] + [InlineData(true, 'f')] + [InlineData(false, 'G')] + [InlineData(false, 'g')] + [InlineData(false, '@')] + public void IsHexDigit( + bool expected, + char input) + => Assert.Equal(expected, input.IsHexDigit()); + + [Theory] + [InlineData(true, 'A')] + [InlineData(true, 'E')] + [InlineData(true, 'I')] + [InlineData(true, 'O')] + [InlineData(true, 'U')] + [InlineData(true, 'a')] + [InlineData(true, 'e')] + [InlineData(true, 'i')] + [InlineData(true, 'o')] + [InlineData(true, 'u')] + [InlineData(false, 'B')] + [InlineData(false, 'z')] + [InlineData(false, '0')] + public void IsVowel( + bool expected, + char input) + => Assert.Equal(expected, input.IsVowel()); } \ No newline at end of file diff --git a/test/Atc.Tests/Extensions/BaseTypes/DateTimeExtensionsTests.cs b/test/Atc.Tests/Extensions/BaseTypes/DateTimeExtensionsTests.cs index 405ded1a..e243529c 100644 --- a/test/Atc.Tests/Extensions/BaseTypes/DateTimeExtensionsTests.cs +++ b/test/Atc.Tests/Extensions/BaseTypes/DateTimeExtensionsTests.cs @@ -76,6 +76,7 @@ public void GetPrettyTimeDiff_EndNow( int arrangeUiLcid) { // Arrange + Thread.CurrentThread.CurrentCulture = new CultureInfo(arrangeUiLcid); Thread.CurrentThread.CurrentUICulture = new CultureInfo(arrangeUiLcid); // Act @@ -95,6 +96,7 @@ public void GetPrettyTimeDiff_EndNow_DecimalPrecision( int arrangeUiLcid) { // Arrange + Thread.CurrentThread.CurrentCulture = new CultureInfo(arrangeUiLcid); Thread.CurrentThread.CurrentUICulture = new CultureInfo(arrangeUiLcid); // Act @@ -122,6 +124,25 @@ public void GetWeekNumber( Assert.Equal(expected, actual); } + [Theory] + [InlineData(1, 1970, 1)] + [InlineData(48, 2019, 12)] + public void GetWeekNumberUi( + int expected, + int year, + int month) + { + // Arrange + Thread.CurrentThread.CurrentUICulture = new CultureInfo(GlobalizationLcidConstants.UnitedStates); + var input = new DateTime(year, month, 1, 0, 0, 0); + + // Act + var actual = input.GetWeekNumberUi(); + + // Assert + Assert.Equal(expected, actual); + } + [Theory] [InlineData(10000, 10, DateTimeDiffCompareType.Milliseconds)] [InlineData(42000, 42, DateTimeDiffCompareType.Milliseconds)] @@ -183,7 +204,7 @@ public void ToIso8601Utc( [InlineData("Sunday, 15 October 2023", GlobalizationLcidConstants.GreatBritain)] [InlineData("søndag den 15. oktober 2023", GlobalizationLcidConstants.Denmark)] [InlineData("Sonntag, 15. Oktober 2023", GlobalizationLcidConstants.Germany)] - public void ToLongDateStringUsingCurrentUiCulture( + public void ToLongDateStringUi( string expected, int arrangeUiLcid) { @@ -192,7 +213,7 @@ public void ToLongDateStringUsingCurrentUiCulture( Thread.CurrentThread.CurrentUICulture = new CultureInfo(arrangeUiLcid); // Act - var actual = dateTime.ToLongDateStringUsingCurrentUiCulture(); + var actual = dateTime.ToLongDateStringUi(); // Assert Assert.Equal(expected, actual); @@ -223,7 +244,7 @@ public void ToLongDateString( [InlineData("15:30:45", GlobalizationLcidConstants.GreatBritain)] [InlineData("15.30.45", GlobalizationLcidConstants.Denmark)] [InlineData("15:30:45", GlobalizationLcidConstants.Germany)] - public void ToLongTimeStringUsingCurrentUiCulture( + public void ToLongTimeStringUi( string expected, int arrangeUiLcid) { @@ -232,7 +253,7 @@ public void ToLongTimeStringUsingCurrentUiCulture( Thread.CurrentThread.CurrentUICulture = new CultureInfo(arrangeUiLcid); // Act - var actual = dateTime.ToLongTimeStringUsingCurrentUiCulture(); + var actual = dateTime.ToLongTimeStringUi(); // Assert Assert.Equal(expected, actual); @@ -263,7 +284,7 @@ public void ToLongTimeString( [InlineData("15/10/2023", GlobalizationLcidConstants.GreatBritain)] [InlineData("15.10.2023", GlobalizationLcidConstants.Denmark)] [InlineData("15.10.2023", GlobalizationLcidConstants.Germany)] - public void ToShortDateStringUsingCurrentUiCulture( + public void ToShortDateStringUi( string expected, int arrangeUiLcid) { @@ -272,7 +293,7 @@ public void ToShortDateStringUsingCurrentUiCulture( Thread.CurrentThread.CurrentUICulture = new CultureInfo(arrangeUiLcid); // Act - var actual = dateTime.ToShortDateStringUsingCurrentUiCulture(); + var actual = dateTime.ToShortDateStringUi(); // Assert Assert.Equal(expected, actual); @@ -303,7 +324,7 @@ public void ToShortDateString( [InlineData("15:30", GlobalizationLcidConstants.GreatBritain)] [InlineData("15.30", GlobalizationLcidConstants.Denmark)] [InlineData("15:30", GlobalizationLcidConstants.Germany)] - public void ToShortTimeStringUsingCurrentUiCulture( + public void ToShortTimeStringUi( string expected, int arrangeUiLcid) { @@ -312,7 +333,7 @@ public void ToShortTimeStringUsingCurrentUiCulture( Thread.CurrentThread.CurrentUICulture = new CultureInfo(arrangeUiLcid); // Act - var actual = dateTime.ToShortTimeStringUsingCurrentUiCulture(); + var actual = dateTime.ToShortTimeStringUi(); // Assert Assert.Equal(expected, actual); @@ -417,4 +438,48 @@ public void ToShortTimeStringUsingSpecificCulture( // Assert Assert.Equal(expected, actual); } + + [Fact] + public void StartOfDay_ReturnsMidnight() + { + var input = new DateTime(2024, 3, 15, 10, 30, 45, DateTimeKind.Utc); + var result = input.StartOfDay(); + Assert.Equal(new DateTime(2024, 3, 15, 0, 0, 0, DateTimeKind.Utc), result); + } + + [Fact] + public void EndOfDay_ReturnsLastTick() + { + var input = new DateTime(2024, 3, 15, 10, 30, 45, DateTimeKind.Utc); + var result = input.EndOfDay(); + Assert.Equal(new DateTime(2024, 3, 15, 0, 0, 0, DateTimeKind.Utc).AddDays(1).AddTicks(-1), result); + } + + [Fact] + public void StartOfMonth_ReturnsFirstDayMidnight() + { + var input = new DateTime(2024, 3, 15, 10, 30, 45, DateTimeKind.Utc); + var result = input.StartOfMonth(); + Assert.Equal(new DateTime(2024, 3, 1, 0, 0, 0, DateTimeKind.Utc), result); + } + + [Fact] + public void EndOfMonth_ReturnsLastTickOfLastDay() + { + var input = new DateTime(2024, 2, 10, 10, 30, 45, DateTimeKind.Utc); + var result = input.EndOfMonth(); + Assert.Equal(new DateTime(2024, 2, 29, 0, 0, 0, DateTimeKind.Utc).AddDays(1).AddTicks(-1), result); + } + + [Theory] + [InlineData(true, 2024, 3, 16)] + [InlineData(true, 2024, 3, 17)] + [InlineData(false, 2024, 3, 18)] + [InlineData(false, 2024, 3, 15)] + public void IsWeekend( + bool expected, + int year, + int month, + int day) + => Assert.Equal(expected, new DateTime(year, month, day).IsWeekend()); } \ No newline at end of file diff --git a/test/Atc.Tests/Extensions/BaseTypes/DateTimeOffsetExtensionsTests.cs b/test/Atc.Tests/Extensions/BaseTypes/DateTimeOffsetExtensionsTests.cs index 27fd0983..9dc781d7 100644 --- a/test/Atc.Tests/Extensions/BaseTypes/DateTimeOffsetExtensionsTests.cs +++ b/test/Atc.Tests/Extensions/BaseTypes/DateTimeOffsetExtensionsTests.cs @@ -71,6 +71,7 @@ public void GetPrettyTimeDiff_EndNow( int arrangeUiLcid) { // Arrange + Thread.CurrentThread.CurrentCulture = new CultureInfo(arrangeUiLcid); Thread.CurrentThread.CurrentUICulture = new CultureInfo(arrangeUiLcid); // Act @@ -90,6 +91,7 @@ public void GetPrettyTimeDiff_EndNow_DecimalPrecision( int arrangeUiLcid) { // Arrange + Thread.CurrentThread.CurrentCulture = new CultureInfo(arrangeUiLcid); Thread.CurrentThread.CurrentUICulture = new CultureInfo(arrangeUiLcid); // Act @@ -117,6 +119,25 @@ public void GetWeekNumber( Assert.Equal(expected, actual); } + [Theory] + [InlineData(1, 1970, 1)] + [InlineData(48, 2019, 12)] + public void GetWeekNumberUi( + int expected, + int year, + int month) + { + // Arrange + Thread.CurrentThread.CurrentUICulture = new CultureInfo(GlobalizationLcidConstants.UnitedStates); + var input = new DateTimeOffset(year, month, 1, 0, 0, 0, TimeSpan.Zero); + + // Act + var actual = input.GetWeekNumberUi(); + + // Assert + Assert.Equal(expected, actual); + } + [Theory] [InlineData(true, 2019, 10, 5, 15)] [InlineData(true, 2019, 10, 10, 15)] @@ -178,7 +199,7 @@ public void SetHourAndMinutes( Assert.Equal(0, actual.Second); Assert.Equal(0, actual.Millisecond); - Assert.Equal(TimeSpan.Zero, actual.Offset); + Assert.Equal(input.Offset, actual.Offset); } [Theory] @@ -239,7 +260,7 @@ public void ToIso8601Utc( [InlineData("Sunday, 15 October 2023", GlobalizationLcidConstants.GreatBritain)] [InlineData("søndag den 15. oktober 2023", GlobalizationLcidConstants.Denmark)] [InlineData("Sonntag, 15. Oktober 2023", GlobalizationLcidConstants.Germany)] - public void ToLongDateStringUsingCurrentUiCulture( + public void ToLongDateStringUi( string expected, int arrangeUiLcid) { @@ -248,7 +269,7 @@ public void ToLongDateStringUsingCurrentUiCulture( Thread.CurrentThread.CurrentUICulture = new CultureInfo(arrangeUiLcid); // Act - var actual = dateTimeOffset.ToLongDateStringUsingCurrentUiCulture(); + var actual = dateTimeOffset.ToLongDateStringUi(); // Assert Assert.Equal(expected, actual); @@ -279,7 +300,7 @@ public void ToLongDateString( [InlineData("15:30:45", GlobalizationLcidConstants.GreatBritain)] [InlineData("15.30.45", GlobalizationLcidConstants.Denmark)] [InlineData("15:30:45", GlobalizationLcidConstants.Germany)] - public void ToLongTimeStringUsingCurrentUiCulture( + public void ToLongTimeStringUi( string expected, int arrangeUiLcid) { @@ -288,7 +309,7 @@ public void ToLongTimeStringUsingCurrentUiCulture( Thread.CurrentThread.CurrentUICulture = new CultureInfo(arrangeUiLcid); // Act - var actual = dateTimeOffset.ToLongTimeStringUsingCurrentUiCulture(); + var actual = dateTimeOffset.ToLongTimeStringUi(); // Assert Assert.Equal(expected, actual); @@ -319,7 +340,7 @@ public void ToLongTimeString( [InlineData("15/10/2023", GlobalizationLcidConstants.GreatBritain)] [InlineData("15.10.2023", GlobalizationLcidConstants.Denmark)] [InlineData("15.10.2023", GlobalizationLcidConstants.Germany)] - public void ToShortDateStringUsingCurrentUiCulture( + public void ToShortDateStringUi( string expected, int arrangeUiLcid) { @@ -328,7 +349,7 @@ public void ToShortDateStringUsingCurrentUiCulture( Thread.CurrentThread.CurrentUICulture = new CultureInfo(arrangeUiLcid); // Act - var actual = dateTimeOffset.ToShortDateStringUsingCurrentUiCulture(); + var actual = dateTimeOffset.ToShortDateStringUi(); // Assert Assert.Equal(expected, actual); @@ -359,7 +380,7 @@ public void ToShortDateString( [InlineData("15:30", GlobalizationLcidConstants.GreatBritain)] [InlineData("15.30", GlobalizationLcidConstants.Denmark)] [InlineData("15:30", GlobalizationLcidConstants.Germany)] - public void ToShortTimeStringUsingCurrentUiCulture( + public void ToShortTimeStringUi( string expected, int arrangeUiLcid) { @@ -368,7 +389,7 @@ public void ToShortTimeStringUsingCurrentUiCulture( Thread.CurrentThread.CurrentUICulture = new CultureInfo(arrangeUiLcid); // Act - var actual = dateTimeOffset.ToShortTimeStringUsingCurrentUiCulture(); + var actual = dateTimeOffset.ToShortTimeStringUi(); // Assert Assert.Equal(expected, actual); @@ -393,4 +414,52 @@ public void ToShortTimeString( // Assert Assert.Equal(expected, actual); } + + [Fact] + public void StartOfDay_ReturnsMidnight_PreservesOffset() + { + var offset = TimeSpan.FromHours(2); + var input = new DateTimeOffset(2024, 3, 15, 10, 30, 45, offset); + var result = input.StartOfDay(); + Assert.Equal(new DateTimeOffset(2024, 3, 15, 0, 0, 0, offset), result); + } + + [Fact] + public void EndOfDay_ReturnsLastTick_PreservesOffset() + { + var offset = TimeSpan.FromHours(2); + var input = new DateTimeOffset(2024, 3, 15, 10, 30, 45, offset); + var result = input.EndOfDay(); + Assert.Equal(new DateTimeOffset(2024, 3, 15, 0, 0, 0, offset).AddDays(1).AddTicks(-1), result); + } + + [Fact] + public void StartOfMonth_ReturnsFirstDayMidnight_PreservesOffset() + { + var offset = TimeSpan.FromHours(-5); + var input = new DateTimeOffset(2024, 3, 15, 10, 30, 45, offset); + var result = input.StartOfMonth(); + Assert.Equal(new DateTimeOffset(2024, 3, 1, 0, 0, 0, offset), result); + } + + [Fact] + public void EndOfMonth_ReturnsLastTickOfLastDay_PreservesOffset() + { + var offset = TimeSpan.FromHours(-5); + var input = new DateTimeOffset(2024, 2, 10, 10, 30, 45, offset); + var result = input.EndOfMonth(); + Assert.Equal(new DateTimeOffset(2024, 2, 29, 0, 0, 0, offset).AddDays(1).AddTicks(-1), result); + } + + [Theory] + [InlineData(true, 2024, 3, 16)] + [InlineData(true, 2024, 3, 17)] + [InlineData(false, 2024, 3, 18)] + [InlineData(false, 2024, 3, 15)] + public void IsWeekend( + bool expected, + int year, + int month, + int day) + => Assert.Equal(expected, new DateTimeOffset(year, month, day, 0, 0, 0, TimeSpan.Zero).IsWeekend()); } \ No newline at end of file diff --git a/test/Atc.Tests/Extensions/BaseTypes/DecimalExtensionsTests.cs b/test/Atc.Tests/Extensions/BaseTypes/DecimalExtensionsTests.cs index b348b7a4..1f50a06c 100644 --- a/test/Atc.Tests/Extensions/BaseTypes/DecimalExtensionsTests.cs +++ b/test/Atc.Tests/Extensions/BaseTypes/DecimalExtensionsTests.cs @@ -106,6 +106,24 @@ public void CurrencyRounding( Assert.Equal(expected, actual); } + [Theory] + [InlineData(12.45, 12.449)] + [InlineData(12.45, 12.450)] + [InlineData(12.45, 12.451)] + public void CurrencyRoundingUi( + decimal expected, + decimal input) + { + // Arrange + Thread.CurrentThread.CurrentUICulture = new CultureInfo(GlobalizationLcidConstants.UnitedStates); + + // Act + var actual = input.CurrencyRoundingUi(); + + // Assert + Assert.Equal(expected, actual); + } + [Theory] [InlineData(0.0, 0, 0)] [InlineData(10.0, 10, 0)] diff --git a/test/Atc.Tests/Extensions/BaseTypes/DoubleExtensionsTests.cs b/test/Atc.Tests/Extensions/BaseTypes/DoubleExtensionsTests.cs index fe8e7518..4ba96802 100644 --- a/test/Atc.Tests/Extensions/BaseTypes/DoubleExtensionsTests.cs +++ b/test/Atc.Tests/Extensions/BaseTypes/DoubleExtensionsTests.cs @@ -4,6 +4,8 @@ public class DoubleExtensionsTests { [Theory] [InlineData(true, 12.3, 12.3)] + [InlineData(true, 0.30000000000000004, 0.3)] // 0.1 + 0.2 in IEEE 754 + [InlineData(false, 12.3, 12.4)] public void IsEqual( bool expected, double a, @@ -162,6 +164,24 @@ public void CurrencyRounding( Assert.Equal(expected, actual); } + [Theory] + [InlineData(12.45, 12.449)] + [InlineData(12.45, 12.450)] + [InlineData(12.45, 12.451)] + public void CurrencyRoundingUi( + double expected, + double input) + { + // Arrange + Thread.CurrentThread.CurrentUICulture = new CultureInfo(GlobalizationLcidConstants.UnitedStates); + + // Act + var actual = input.CurrencyRoundingUi(); + + // Assert + Assert.Equal(expected, actual); + } + [Theory] [InlineData(0.0, 0, 0)] [InlineData(10.0, 10, 0)] @@ -318,7 +338,8 @@ public void RoundOffPercent( [InlineData(3, 9.999)] [InlineData(4, 9.9999000000)] [InlineData(15, 9.1234567891012345)] - [InlineData(30, 5.821e-27)] + [InlineData(0, 5.821e-27)] // value < absolute tolerance (1e-9) so it terminates immediately at 0 + [InlineData(15, 1.0 / 3.0)] // repeating decimal; previously looped forever, now returns cap public void CountDecimalPoints( int expected, double input) diff --git a/test/Atc.Tests/Extensions/BaseTypes/IntegerExtensionsTests.cs b/test/Atc.Tests/Extensions/BaseTypes/IntegerExtensionsTests.cs index a4b94589..4441d3f7 100644 --- a/test/Atc.Tests/Extensions/BaseTypes/IntegerExtensionsTests.cs +++ b/test/Atc.Tests/Extensions/BaseTypes/IntegerExtensionsTests.cs @@ -113,7 +113,7 @@ public void IsBinarySequence( [Theory] [MemberData(nameof(TestMemberDataForExtensionsInteger.MonthNameData), MemberType = typeof(TestMemberDataForExtensionsInteger))] - public void GetMonthNameByMonthNumber( + public void GetMonthNameByMonthNumberUi( int arrangeUiLcid, string expected, int input, @@ -123,7 +123,7 @@ public void GetMonthNameByMonthNumber( Thread.CurrentThread.CurrentUICulture = new CultureInfo(arrangeUiLcid); // Act - var actual = input.GetMonthNameByMonthNumber(pascalCased); + var actual = input.GetMonthNameByMonthNumberUi(pascalCased); // Assert Assert.Equal(expected, actual); @@ -145,6 +145,25 @@ public void GetNumberOfWeeksByYear( Assert.Equal(expected, actual); } + [Theory] + [InlineData(52, 2019)] + [InlineData(53, 2020)] + [InlineData(52, 2021)] + [InlineData(52, 2022)] + public void GetNumberOfWeeksByYearUi( + int expected, + int input) + { + // Arrange + Thread.CurrentThread.CurrentUICulture = new CultureInfo(GlobalizationLcidConstants.UnitedStates); + + // Act + var actual = input.GetNumberOfWeeksByYearUi(); + + // Assert + Assert.Equal(expected, actual); + } + [Theory] [InlineData(2018, 12, 31, 2019, 1)] [InlineData(2019, 12, 30, 2020, 1)] @@ -167,6 +186,29 @@ public void GetFirstDayOfWeekNumberByYear( Assert.Equal(expectedDateTime, actual); } + [Theory] + [InlineData(2018, 12, 31, 2019, 1)] + [InlineData(2019, 12, 30, 2020, 1)] + [InlineData(2021, 1, 4, 2021, 1)] + [InlineData(2022, 1, 3, 2022, 1)] + public void GetFirstDayOfWeekNumberByYearUi( + int expectedYear, + int expectedMonth, + int expectedDay, + int input, + int weekNumber) + { + // Arrange + Thread.CurrentThread.CurrentUICulture = new CultureInfo(GlobalizationLcidConstants.UnitedStates); + var expectedDateTime = new DateTime(expectedYear, expectedMonth, expectedDay); + + // Act + var actual = input.GetFirstDayOfWeekNumberByYearUi(weekNumber); + + // Assert + Assert.Equal(expectedDateTime, actual); + } + [Theory] [InlineData(2019, 1, 6, 2019, 1)] [InlineData(2020, 1, 5, 2020, 1)] @@ -188,4 +230,27 @@ public void GetLastDayOfWeekNumberByYear( // Assert Assert.Equal(expectedDateTime, actual); } + + [Theory] + [InlineData(2019, 1, 6, 2019, 1)] + [InlineData(2020, 1, 5, 2020, 1)] + [InlineData(2021, 1, 10, 2021, 1)] + [InlineData(2022, 1, 9, 2022, 1)] + public void GetLastDayOfWeekNumberByYearUi( + int expectedYear, + int expectedMonth, + int expectedDay, + int input, + int weekNumber) + { + // Arrange + Thread.CurrentThread.CurrentUICulture = new CultureInfo(GlobalizationLcidConstants.UnitedStates); + var expectedDateTime = new DateTime(expectedYear, expectedMonth, expectedDay); + + // Act + var actual = input.GetLastDayOfWeekNumberByYearUi(weekNumber); + + // Assert + Assert.Equal(expectedDateTime, actual); + } } \ No newline at end of file diff --git a/test/Atc.Tests/Extensions/BaseTypes/LongExtensionsTests.cs b/test/Atc.Tests/Extensions/BaseTypes/LongExtensionsTests.cs index fa6c2811..50824953 100644 --- a/test/Atc.Tests/Extensions/BaseTypes/LongExtensionsTests.cs +++ b/test/Atc.Tests/Extensions/BaseTypes/LongExtensionsTests.cs @@ -43,4 +43,45 @@ public void FromUnixTimeMs( // Assert Assert.Equal(expectedDateTimeOffset, actual); } + + [Theory] + [InlineData(true, 1L)] + [InlineData(true, 2L)] + [InlineData(true, 4L)] + [InlineData(true, 1L << 32)] + [InlineData(true, 1L << 62)] + [InlineData(false, 0L)] + [InlineData(false, 3L)] + [InlineData(false, 6L)] + [InlineData(false, -1L)] + public void IsBinarySequence( + bool expected, + long input) + { + Assert.Equal(expected, input.IsBinarySequence()); + } + + [Theory] + [InlineData(500, 1970, 1, 1, 0, 0, 0, 500)] + [InlineData(1500, 1970, 1, 1, 0, 0, 1, 500)] + [InlineData(999, 1970, 1, 1, 0, 0, 0, 999)] + public void FromUnixTimeMs_ShouldPreserveSubSecondMilliseconds( + long input, + int expectedYear, + int expectedMonth, + int expectedDay, + int expectedHour, + int expectedMinute, + int expectedSecond, + int expectedMillisecond) + { + // Arrange + var expectedDateTimeOffset = new DateTimeOffset(expectedYear, expectedMonth, expectedDay, expectedHour, expectedMinute, expectedSecond, expectedMillisecond, TimeSpan.Zero); + + // Act + var actual = input.FromUnixTimeMs(); + + // Assert + Assert.Equal(expectedDateTimeOffset, actual); + } } \ No newline at end of file diff --git a/test/Atc.Tests/Extensions/BaseTypes/TimeSpanExtensionsTests.cs b/test/Atc.Tests/Extensions/BaseTypes/TimeSpanExtensionsTests.cs index bcecebbb..465868ca 100644 --- a/test/Atc.Tests/Extensions/BaseTypes/TimeSpanExtensionsTests.cs +++ b/test/Atc.Tests/Extensions/BaseTypes/TimeSpanExtensionsTests.cs @@ -91,6 +91,25 @@ public void GetPrettyTimeDiff( Assert.NotNull(actual); } + [Theory] + [MemberData(nameof(TestMemberDataForTimeSpanExtensions.GetPrettyTimeUi), MemberType = typeof(TestMemberDataForTimeSpanExtensions))] + public void GetPrettyTimeUi( + string expected, + TimeSpan timeSpan, + int arrangeUiLcid, + int arrangeLcid) + { + // Arrange + Thread.CurrentThread.CurrentUICulture = new CultureInfo(arrangeUiLcid); + Thread.CurrentThread.CurrentCulture = new CultureInfo(arrangeLcid); + + // Act + var actual = timeSpan.GetPrettyTimeUi(); + + // Assert + Assert.Equal(expected, actual); + } + [Theory] [MemberData(nameof(TestMemberDataForTimeSpanExtensions.GetPrettyTimeWithDecimalPrecision), MemberType = typeof(TestMemberDataForTimeSpanExtensions))] public void GetPrettyTimeDiff_DecimalPrecision( diff --git a/test/Atc.Tests/Extensions/DataTableExtensionsTests.cs b/test/Atc.Tests/Extensions/DataTableExtensionsTests.cs index 8df3bf23..347b29a2 100644 --- a/test/Atc.Tests/Extensions/DataTableExtensionsTests.cs +++ b/test/Atc.Tests/Extensions/DataTableExtensionsTests.cs @@ -121,6 +121,21 @@ public void ToXPathNodeIterator() actual.Should().NotBeNull(); } + [Fact] + public void ToXPathNodeIterator_DoesNotStealTableFromItsDataSet() + { + // Arrange + using var owningDataSet = new DataSet("Owner"); + var dt = GenerateTestTable(); + owningDataSet.Tables.Add(dt); + + // Act + _ = dt.ToXPathNodeIterator(); + + // Assert — the table must still belong to the original DataSet after the call + dt.DataSet.Should().BeSameAs(owningDataSet); + } + private static DataTable GenerateTestTable() { var table = new DataTable(); diff --git a/test/Atc.Tests/Extensions/EnumExtensionsTests.cs b/test/Atc.Tests/Extensions/EnumExtensionsTests.cs index f5cedc24..7889bd58 100644 --- a/test/Atc.Tests/Extensions/EnumExtensionsTests.cs +++ b/test/Atc.Tests/Extensions/EnumExtensionsTests.cs @@ -2,8 +2,19 @@ namespace Atc.Tests.Extensions; public class EnumExtensionsTests { + [SuppressMessage("Naming", "S2344:Enumeration type names should not have 'Flags' or 'Enum' suffixes", Justification = "Test fixture name.")] + [Flags] + private enum LongBackedEnum : long + { + None = 0, + A = 1L, + B = 2L, + HighBit = 1L << 33, // beyond uint range — previously caused OverflowException + } + [Theory] [InlineData(true, DayOfWeek.Monday, DayOfWeek.Monday)] + [InlineData(false, DayOfWeek.Monday, DayOfWeek.Tuesday)] public void AreFlagsSet( bool expected, DayOfWeek value1, @@ -12,12 +23,27 @@ public void AreFlagsSet( [Theory] [InlineData(true, DayOfWeek.Monday, DayOfWeek.Monday)] + [InlineData(false, DayOfWeek.Monday, DayOfWeek.Tuesday)] public void IsSet( bool expected, DayOfWeek value1, DayOfWeek value2) => Assert.Equal(expected, ((Enum)value1).IsSet(value2)); + [Fact] + public void IsSet_LongBackedEnum_DoesNotOverflow() + { + Assert.True(((Enum)(LongBackedEnum.A | LongBackedEnum.HighBit)).IsSet(LongBackedEnum.HighBit)); + Assert.False(((Enum)LongBackedEnum.A).IsSet(LongBackedEnum.HighBit)); + } + + [Fact] + public void IsSet_RequiresAllFlagsSet_ConsistentWithHasFlag() + { + Assert.False(((Enum)LongBackedEnum.A).IsSet(LongBackedEnum.A | LongBackedEnum.B)); + Assert.True(((Enum)(LongBackedEnum.A | LongBackedEnum.B)).IsSet(LongBackedEnum.A)); + } + [Theory] [InlineData(true, DayOfWeek.Monday, DayOfWeek.Monday)] [InlineData(false, DayOfWeek.Monday, DayOfWeek.Tuesday)] @@ -87,6 +113,27 @@ public void MapTo_WithoutDefault( public void MapTo_WithoutDefault_Throws() => Assert.Throws(() => TestPetTypeB.Unknown.MapTo()); + [Theory] + [InlineData(true, TestPetTypeA.Dog, TestPetTypeB.Dog)] + [InlineData(true, TestPetTypeA.Cat, TestPetTypeB.Cat)] + public void TryMapTo_MatchingName_ReturnsTrue( + bool expectedSuccess, + TestPetTypeA expectedResult, + TestPetTypeB source) + { + var success = source.TryMapTo(out var result); + Assert.Equal(expectedSuccess, success); + Assert.Equal(expectedResult, result); + } + + [Fact] + public void TryMapTo_NoMatch_ReturnsFalse() + { + var success = TestPetTypeB.Unknown.TryMapTo(out var result); + Assert.False(success); + Assert.Equal(default(TestPetTypeA), result); + } + [Theory] [InlineData("Display Red", TestColorType.Red)] [InlineData("Display Green", TestColorType.Green)] diff --git a/test/Atc.Tests/Extensions/MemoryStreamExtensionsTests.cs b/test/Atc.Tests/Extensions/MemoryStreamExtensionsTests.cs index 1b2947c6..1307bab3 100644 --- a/test/Atc.Tests/Extensions/MemoryStreamExtensionsTests.cs +++ b/test/Atc.Tests/Extensions/MemoryStreamExtensionsTests.cs @@ -3,7 +3,7 @@ namespace Atc.Tests.Extensions; public class MemoryStreamExtensionsTests { [Fact] - public void ToBytes() + public void ToString_ExplicitUtf8() { // Arrange var input = "Hallo world".ToStream() as MemoryStream; @@ -14,4 +14,18 @@ public void ToBytes() // Assert Assert.Equal("Hallo world", actual); } + + [Fact] + public void ToString_DefaultEncoding_IsUtf8() + { + // Arrange — UTF-8 bytes for "Héllo" + var bytes = Encoding.UTF8.GetBytes("Héllo"); + using var input = new MemoryStream(bytes); + + // Act — call the extension explicitly; object.ToString() shadows a no-arg extension call. + var actual = MemoryStreamExtensions.ToString(input); + + // Assert + Assert.Equal("Héllo", actual); + } } \ No newline at end of file diff --git a/test/Atc.Tests/Extensions/ReadOnlyListExtensionsTests.cs b/test/Atc.Tests/Extensions/ReadOnlyListExtensionsTests.cs index aa6c12c6..70fd8984 100644 --- a/test/Atc.Tests/Extensions/ReadOnlyListExtensionsTests.cs +++ b/test/Atc.Tests/Extensions/ReadOnlyListExtensionsTests.cs @@ -17,6 +17,22 @@ public void GetUniqueCombinations( .And.HaveCount(expected); } + [Fact] + public void GetUniqueCombinations_WithCommaContainingElement_PreservesElementIntegrity() + { + // Arrange: "a,b" contains a comma — the old split(',') approach would shred it into "a" and "b". + IReadOnlyList list = new List { "a,b", "c" }; + + // Act + var result = list.GetUniqueCombinations().ToList(); + + // Assert: 3 non-empty subsets: {"a,b"}, {"c"}, {"a,b","c"} + Assert.Equal(3, result.Count); + Assert.Contains(result, r => r.SequenceEqual(new[] { "a,b" }, StringComparer.Ordinal)); + Assert.Contains(result, r => r.SequenceEqual(new[] { "c" }, StringComparer.Ordinal)); + Assert.Contains(result, r => r.SequenceEqual(new[] { "a,b", "c" }, StringComparer.Ordinal)); + } + [Theory] [InlineData(15, new[] { "a", "b", "c", "d" })] public void GetUniqueCombinationsAsCommaSeparated( @@ -46,4 +62,14 @@ public void GetPowerSet( .Should().NotBeNull() .And.HaveCount(expected); } + + [Fact] + public void GetPowerSet_WhenListCountIs32_ThrowsArgumentOutOfRangeException() + { + // `1 << 32` wraps to 1 in int arithmetic (shift mod 32), so the power set of + // 32 elements silently returns 1 subset instead of 2^32. Any count >= 31 is unsupported. + IReadOnlyList list = Enumerable.Range(0, 32).Select(i => i.ToString(GlobalizationConstants.EnglishCultureInfo)).ToList(); + + Assert.Throws(() => list.GetPowerSet().ToList()); + } } \ No newline at end of file diff --git a/test/Atc.Tests/Extensions/Reflection/ConstructorInfoExtensionsTests.cs b/test/Atc.Tests/Extensions/Reflection/ConstructorInfoExtensionsTests.cs new file mode 100644 index 00000000..9789da3c --- /dev/null +++ b/test/Atc.Tests/Extensions/Reflection/ConstructorInfoExtensionsTests.cs @@ -0,0 +1,34 @@ +namespace Atc.Tests.Extensions.Reflection; + +public class ConstructorInfoExtensionsTests +{ + private sealed class SampleClass + { + // ReSharper disable once UnusedParameter.Local + public SampleClass( + int count, + string name) + { + } + } + + [Fact] + public void BeautifyName_NoParams_ReturnsDotCtor() + { + var ctor = typeof(object).GetConstructor(Type.EmptyTypes)!; + Assert.Equal(".ctor()", ctor.BeautifyName()); + } + + [Fact] + public void BeautifyName_WithParams_IncludesParamTypes() + { + var ctor = typeof(SampleClass).GetConstructors().Single(); + var result = ctor.BeautifyName(); + Assert.Equal(".ctor(int count, string name)", result); + } + + [Fact] + public void BeautifyName_Null_Throws() + => Assert.Throws(() => + ((ConstructorInfo)null!).BeautifyName()); +} \ No newline at end of file diff --git a/test/Atc.Tests/Extensions/Reflection/EventInfoExtensionsTests.cs b/test/Atc.Tests/Extensions/Reflection/EventInfoExtensionsTests.cs new file mode 100644 index 00000000..05c9c55b --- /dev/null +++ b/test/Atc.Tests/Extensions/Reflection/EventInfoExtensionsTests.cs @@ -0,0 +1,25 @@ +namespace Atc.Tests.Extensions.Reflection; + +public class EventInfoExtensionsTests +{ + [Fact] + public void BeautifyName_ReturnsEventName() + { + var eventInfo = typeof(AppDomain).GetEvent(nameof(AppDomain.UnhandledException))!; + Assert.Equal("UnhandledException", eventInfo.BeautifyName()); + } + + [Fact] + public void BeautifyName_WithHandlerType_IncludesType() + { + var eventInfo = typeof(AppDomain).GetEvent(nameof(AppDomain.UnhandledException))!; + var result = eventInfo.BeautifyName(includeEventHandlerType: true); + Assert.Contains("UnhandledException", result, StringComparison.Ordinal); + Assert.Contains("UnhandledExceptionEventHandler", result, StringComparison.Ordinal); + } + + [Fact] + public void BeautifyName_Null_Throws() + => Assert.Throws(() => + ((EventInfo)null!).BeautifyName()); +} \ No newline at end of file diff --git a/test/Atc.Tests/Extensions/StreamExtensionsTests.cs b/test/Atc.Tests/Extensions/StreamExtensionsTests.cs index d586fe6b..f790e956 100644 --- a/test/Atc.Tests/Extensions/StreamExtensionsTests.cs +++ b/test/Atc.Tests/Extensions/StreamExtensionsTests.cs @@ -30,6 +30,18 @@ public void CopyToStream_BufferSize() Assert.Equal("Hallo world", actual.ToStringData()); } + [Fact] + public void CopyToStream_NonSeekable_DoesNotThrow() + { + // Arrange — wrap a MemoryStream in a non-seekable decorator + var inner = "Hallo world".ToStream(); + using var input = new NonSeekableStream(inner); + + // Act & Assert + var actual = input.CopyToStream(); + Assert.Equal("Hallo world", actual.ToStringData()); + } + [Fact] public void ToBytes() { @@ -44,6 +56,21 @@ public void ToBytes() Assert.Equal("Hallo world", actual); } + [Fact] + public void ToBytes_NonSeekable_DoesNotThrow() + { + // Arrange + var inner = "Hallo world".ToStream(); + using var input = new NonSeekableStream(inner); + + // Act + var buffer = input.ToBytes(); + var actual = Encoding.UTF8.GetString(buffer, 0, buffer.Length); + + // Assert + Assert.Equal("Hallo world", actual); + } + [Fact] public void ToStringData() { @@ -56,4 +83,243 @@ public void ToStringData() // Assert Assert.Equal("Hallo world", actual); } + + [Fact] + public void ToStringData_DoesNotDisposeCallerStream() + { + // Arrange + var input = "Hallo world".ToStream(); + + // Act + _ = input.ToStringData(); + + // Assert — stream is still usable after the call + Assert.True(input.CanRead); + } + + [Fact] + public void ToStringData_NonSeekable_DoesNotThrow() + { + // Arrange + var inner = "Hallo world".ToStream(); + using var input = new NonSeekableStream(inner); + + // Act + var actual = input.ToStringData(); + + // Assert + Assert.Equal("Hallo world", actual); + } + + [Fact] + public async Task CopyToStreamAsync() + { + // Arrange + var input = "Hallo world".ToStream(); + + // Act + var actual = await input.CopyToStreamAsync(); + + // Assert + Assert.Equal("Hallo world", await actual.ToStringDataAsync()); + } + + [Fact] + public async Task CopyToStreamAsync_BufferSize() + { + // Arrange + var input = "Hallo world".ToStream(); + + // Act + var actual = await input.CopyToStreamAsync(bufferSize: 1024); + + // Assert + Assert.Equal("Hallo world", await actual.ToStringDataAsync()); + } + + [Fact] + public async Task CopyToStreamAsync_NonSeekable_DoesNotThrow() + { + // Arrange + var inner = "Hallo world".ToStream(); + using var input = new NonSeekableStream(inner); + + // Act + var actual = await input.CopyToStreamAsync(); + + // Assert + Assert.Equal("Hallo world", await actual.ToStringDataAsync()); + } + + [Fact] + public async Task CopyToStreamAsync_Cancelled_ThrowsOperationCanceledException() + { + // Arrange + var input = "Hallo world".ToStream(); + using var cts = new CancellationTokenSource(); + await cts.CancelAsync(); + + // Act + var ex = await Record.ExceptionAsync(() => (Task)input.CopyToStreamAsync(cancellationToken: cts.Token)); + + // Assert + Assert.IsAssignableFrom(ex); + } + + [Fact] + public async Task ToBytesAsync() + { + // Arrange + var input = "Hallo world".ToStream(); + + // Act + var buffer = await input.ToBytesAsync(); + var actual = Encoding.UTF8.GetString(buffer, 0, buffer.Length); + + // Assert + Assert.Equal("Hallo world", actual); + } + + [Fact] + public async Task ToBytesAsync_NonSeekable_DoesNotThrow() + { + // Arrange + var inner = "Hallo world".ToStream(); + using var input = new NonSeekableStream(inner); + + // Act + var buffer = await input.ToBytesAsync(); + var actual = Encoding.UTF8.GetString(buffer, 0, buffer.Length); + + // Assert + Assert.Equal("Hallo world", actual); + } + + [Fact] + public async Task ToBytesAsync_Cancelled_ThrowsOperationCanceledException() + { + // Arrange + var input = "Hallo world".ToStream(); + using var cts = new CancellationTokenSource(); + await cts.CancelAsync(); + + // Act + var ex = await Record.ExceptionAsync(() => (Task)input.ToBytesAsync(cts.Token)); + + // Assert + Assert.IsAssignableFrom(ex); + } + + [Fact] + public async Task ToStringDataAsync() + { + // Arrange + var input = "Hallo world".ToStream(); + + // Act + var actual = await input.ToStringDataAsync(); + + // Assert + Assert.Equal("Hallo world", actual); + } + + [Fact] + public async Task ToStringDataAsync_DoesNotDisposeCallerStream() + { + // Arrange + var input = "Hallo world".ToStream(); + + // Act + _ = await input.ToStringDataAsync(); + + // Assert + Assert.True(input.CanRead); + } + + [Fact] + public async Task ToStringDataAsync_NonSeekable_DoesNotThrow() + { + // Arrange + var inner = "Hallo world".ToStream(); + using var input = new NonSeekableStream(inner); + + // Act + var actual = await input.ToStringDataAsync(); + + // Assert + Assert.Equal("Hallo world", actual); + } + + [Fact] + public async Task ToStringDataAsync_Cancelled_ThrowsOperationCanceledException() + { + // Arrange + var input = "Hallo world".ToStream(); + using var cts = new CancellationTokenSource(); + await cts.CancelAsync(); + + // Act + var ex = await Record.ExceptionAsync(() => (Task)input.ToStringDataAsync(cts.Token)); + + // Assert + Assert.IsAssignableFrom(ex); + } + + /// + /// Wraps a stream and hides seek capability to simulate non-seekable sources + /// (e.g. network or compressed streams). + /// + private sealed class NonSeekableStream(Stream inner) : Stream + { + public override bool CanRead + => inner.CanRead; + + public override bool CanSeek + => false; + + public override bool CanWrite + => false; + + public override long Length + => throw new NotSupportedException(); + + public override long Position + { + get => throw new NotSupportedException(); + set => throw new NotSupportedException(); + } + + public override void Flush() + => inner.Flush(); + + public override int Read( + byte[] buffer, + int offset, + int count) + => inner.Read(buffer, offset, count); + + public override long Seek( + long offset, + SeekOrigin origin) + => throw new NotSupportedException(); + + public override void SetLength(long value) + => throw new NotSupportedException(); + + public override void Write( + byte[] buffer, + int offset, + int count) + => throw new NotSupportedException(); + + protected override void Dispose(bool disposing) + { + if (disposing) + { + inner.Dispose(); + } + + base.Dispose(disposing); + } + } } \ No newline at end of file diff --git a/test/Atc.Tests/Extensions/StringExtensionsTests.cs b/test/Atc.Tests/Extensions/StringExtensionsTests.cs index dc5900b7..3918548e 100644 --- a/test/Atc.Tests/Extensions/StringExtensionsTests.cs +++ b/test/Atc.Tests/Extensions/StringExtensionsTests.cs @@ -573,6 +573,7 @@ public void JavaScriptDecode( [Theory] [InlineData("<root><node name='TheName'>Hallo</node></root>", "Hallo")] + [InlineData("<a x="b & c">", "")] public void XmlEncode( string expected, string input) @@ -580,11 +581,18 @@ public void XmlEncode( [Theory] [InlineData("Hallo", "<root><node name='TheName'>Hallo</node></root>")] + [InlineData("", "<a x="b & c">")] public void XmlDecode( string expected, string input) => Assert.Equal(expected, input.XmlDecode()); + [Theory] + [InlineData("")] + [InlineData("plain & simple \"quoted\" 'apos'")] + public void XmlEncode_Then_XmlDecode_RoundTrips(string input) + => Assert.Equal(input, input.XmlEncode().XmlDecode()); + [Theory] [InlineData("abc", "abc")] [InlineData("abc", "bac")] @@ -601,6 +609,11 @@ public void Alphabetize( [InlineData("abc", "âbc")] [InlineData("abc", "ãbc")] [InlineData("abc", "äbc")] + [InlineData("ebc", "èbc")] + [InlineData("ibc", "ìbc")] + [InlineData("ebc", "èbc")] + [InlineData("ibc", "ìbc")] + [InlineData("Obc", "Öbc")] public void NormalizeAccents( string expected, string input) diff --git a/test/Atc.Tests/Extensions/StringHasIsExtensionsTests.cs b/test/Atc.Tests/Extensions/StringHasIsExtensionsTests.cs index 8e6d9276..25b07587 100644 --- a/test/Atc.Tests/Extensions/StringHasIsExtensionsTests.cs +++ b/test/Atc.Tests/Extensions/StringHasIsExtensionsTests.cs @@ -318,6 +318,9 @@ public void IsPersonCprNumber( [InlineData(false, "Hest")] [InlineData(false, "Hest@gris")] [InlineData(true, "Hest@gris.dk")] + [InlineData(true, "user@example.photography")] + [InlineData(true, "user@example.international")] + [InlineData(false, "user@example.aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")] public void IsEmailAddress( bool expected, string input) @@ -372,4 +375,99 @@ public void IsUriOpcTcp( bool expected, string input) => Assert.Equal(expected, input.IsUriOpcTcp()); + + [Theory] + [InlineData(true, "localhost")] + [InlineData(true, "server01")] + [InlineData(true, "dr.dk")] + [InlineData(true, "opcua.demo-this.com")] + [InlineData(true, "example.com.")] + [InlineData(true, "a.b.c.d.e.f")] + [InlineData(true, "xn--mnchen-3ya.de")] + [InlineData(false, "")] + [InlineData(false, " ")] + [InlineData(false, "-leadinghyphen.com")] + [InlineData(false, "trailinghyphen-.com")] + [InlineData(false, "under_score.com")] + [InlineData(false, "double..dot.com")] + [InlineData(false, "space in.host")] + [InlineData(false, "münchen.de")] + public void IsHostName( + bool expected, + string input) + => Assert.Equal(expected, input.IsHostName()); + + [Theory] + [InlineData(true, "192.168.0.27")] + [InlineData(true, "0.0.0.0")] + [InlineData(true, "255.255.255.255")] + [InlineData(false, "1")] + [InlineData(false, "256.0.0.1")] + [InlineData(false, "::1")] + [InlineData(false, "opcua.demo-this.com")] + [InlineData(false, "")] + public void IsIPv4Address( + bool expected, + string input) + => Assert.Equal(expected, input.IsIPv4Address()); + + [Theory] + [InlineData(true, "::1")] + [InlineData(true, "2001:db8::ff00:42:8329")] + [InlineData(true, "fe80::1")] + [InlineData(false, "192.168.0.27")] + [InlineData(false, "opcua.demo-this.com")] + [InlineData(false, "")] + public void IsIPv6Address( + bool expected, + string input) + => Assert.Equal(expected, input.IsIPv6Address()); + + [Theory] + [InlineData(true, "192.168.0.27")] + [InlineData(true, "::1")] + [InlineData(true, "2001:db8::ff00:42:8329")] + [InlineData(false, "opcua.demo-this.com")] + [InlineData(false, "256.0.0.1")] + [InlineData(false, "")] + public void IsIPAddress( + bool expected, + string input) + => Assert.Equal(expected, input.IsIPAddress()); + + [Theory] + [InlineData(true, "1")] + [InlineData(true, "80")] + [InlineData(true, "443")] + [InlineData(true, "8080")] + [InlineData(true, "65535")] + [InlineData(false, "0")] + [InlineData(false, "65536")] + [InlineData(false, "")] + [InlineData(false, "abc")] + [InlineData(false, "-1")] + [InlineData(false, "8080.5")] + public void IsPort( + bool expected, + string input) + => Assert.Equal(expected, input.IsPort()); + + [Theory] + [InlineData(true, "AA:BB:CC:DD:EE:FF")] + [InlineData(true, "aa:bb:cc:dd:ee:ff")] + [InlineData(true, "AA-BB-CC-DD-EE-FF")] + [InlineData(true, "aa-bb-cc-dd-ee-ff")] + [InlineData(true, "AABB.CCDD.EEFF")] + [InlineData(true, "aabb.ccdd.eeff")] + [InlineData(true, "AABBCCDDEEFF")] + [InlineData(true, "aabbccddeeff")] + [InlineData(false, "AA:BB:CC:DD:EE")] + [InlineData(false, "AA:BB:CC:DD:EE:FF:00")] + [InlineData(false, "GG:BB:CC:DD:EE:FF")] + [InlineData(false, "AA BB CC DD EE FF")] + [InlineData(false, "")] + public void IsMacAddress( + bool expected, + string input) + => Assert.Equal(expected, input.IsMacAddress()); } \ No newline at end of file diff --git a/test/Atc.Tests/Extensions/TaskExtensionsTests.cs b/test/Atc.Tests/Extensions/TaskExtensionsTests.cs index 80d6dc93..f27b8417 100644 --- a/test/Atc.Tests/Extensions/TaskExtensionsTests.cs +++ b/test/Atc.Tests/Extensions/TaskExtensionsTests.cs @@ -83,4 +83,20 @@ public void StartAndWaitAllThrottledWithTimeout( // Assert Assert.True(timer.Elapsed.Seconds.Equals(expectedSeconds)); } + + [Fact] + public void StartAndWaitAllThrottled_WhenSlotTimeoutExpires_ThrowsTimeoutException() + { + // Arrange: 2 tasks that sleep 300 ms, max 1 parallel, slot timeout of 50 ms. + // Task 1 starts and holds the semaphore slot. Task 2 waits only 50 ms for the + // slot to become free — well before task 1 finishes — so WaitForExit must throw. + var tasks = new List + { + new(() => Thread.Sleep(300)), + new(() => Thread.Sleep(300)), + }; + + // Act & Assert + Assert.Throws(() => tasks.StartAndWaitAllThrottled(1, 50)); + } } \ No newline at end of file diff --git a/test/Atc.Tests/Extensions/TypeExtensionsTests.cs b/test/Atc.Tests/Extensions/TypeExtensionsTests.cs index 70d669e0..86caaa77 100644 --- a/test/Atc.Tests/Extensions/TypeExtensionsTests.cs +++ b/test/Atc.Tests/Extensions/TypeExtensionsTests.cs @@ -66,6 +66,8 @@ public void IsSimple( [InlineData(false, typeof(DataTypeAttribute), typeof(EmailAddressAttribute))] [InlineData(false, typeof(EmailAddressAttribute), typeof(EmailAddressAttribute))] [InlineData(true, typeof(EmailAddressAttribute), typeof(DataTypeAttribute))] + [InlineData(true, typeof(System.Collections.ObjectModel.ObservableCollection), typeof(System.Collections.ObjectModel.Collection))] + [InlineData(false, typeof(System.Collections.ObjectModel.ObservableCollection), typeof(System.Collections.Generic.List))] public void IsInheritedFrom( bool expected, Type type, @@ -498,7 +500,7 @@ public void BeautifyName_UseFullName_UseHtmlFormat( } [Theory] - [InlineData("Dictionary", typeof(Dictionary), false, false, true)] + [InlineData("Dictionary", typeof(Dictionary), false, false, true)] public void BeautifyName_UseFullName_UseHtmlFormat_UseGenericParameterNamesAsT( string expected, Type type, @@ -510,7 +512,7 @@ public void BeautifyName_UseFullName_UseHtmlFormat_UseGenericParameterNamesAsT( } [Theory] - [InlineData("T, LocalizedDescriptionAttribute?", typeof(Dictionary), false, false, true, true)] + [InlineData("T, T?", typeof(Dictionary), false, false, true, true)] public void BeautifyName_UseFullName_UseGenericParameterNamesAsT_UseSuffixQuestionMarkForGeneric( string expected, Type type, diff --git a/test/Atc.Tests/Factories/AsyncEnumerableFactoryTests.cs b/test/Atc.Tests/Factories/AsyncEnumerableFactoryTests.cs index c10d08e3..39c77ece 100644 --- a/test/Atc.Tests/Factories/AsyncEnumerableFactoryTests.cs +++ b/test/Atc.Tests/Factories/AsyncEnumerableFactoryTests.cs @@ -149,4 +149,147 @@ public async Task FromSingleItem_CanBeEnumeratedMultipleTimes() Assert.Equal(item, firstEnumeration.First()); Assert.Equal(item, secondEnumeration.First()); } + + [Fact] + public async Task FromSingleItem_Cancelled_ThrowsOperationCanceledException() + { + // Arrange + using var cts = new CancellationTokenSource(); + await cts.CancelAsync(); + var collected = new List(); + + // Act & Assert + await Assert.ThrowsAsync(async () => + { + await foreach (var item in AsyncEnumerableFactory.FromSingleItem(42).WithCancellation(cts.Token)) + { + collected.Add(item); + } + }); + } + + [Fact] + public async Task FromItems_ReturnsAllElements() + { + // Arrange + var items = new[] { 1, 2, 3, 4, 5 }; + var result = new List(); + + // Act + await foreach (var value in AsyncEnumerableFactory.FromItems(items)) + { + result.Add(value); + } + + // Assert + Assert.Equal(items, result); + } + + [Fact] + public async Task FromItems_EmptyArray_ReturnsEmpty() + { + // Arrange + var result = new List(); + + // Act + await foreach (var value in AsyncEnumerableFactory.FromItems(Array.Empty())) + { + result.Add(value); + } + + // Assert + Assert.Empty(result); + } + + [Fact] + public async Task FromItems_Cancelled_ThrowsOperationCanceledException() + { + // Arrange + var items = new[] { 1, 2, 3 }; + using var cts = new CancellationTokenSource(); + await cts.CancelAsync(); + var collected = new List(); + + // Act & Assert + await Assert.ThrowsAsync(async () => + { + await foreach (var item in AsyncEnumerableFactory.FromItems(items).WithCancellation(cts.Token)) + { + collected.Add(item); + } + }); + } + + [Fact] + public async Task FromEnumerable_ReturnsAllElements() + { + // Arrange + var source = new List { "a", "b", "c" }; + var result = new List(); + + // Act + await foreach (var value in AsyncEnumerableFactory.FromEnumerable(source)) + { + result.Add(value); + } + + // Assert + Assert.Equal(source, result); + } + + [Fact] + public async Task FromEnumerable_Cancelled_ThrowsOperationCanceledException() + { + // Arrange + var source = new[] { 1, 2, 3 }; + using var cts = new CancellationTokenSource(); + await cts.CancelAsync(); + var collected = new List(); + + // Act & Assert + await Assert.ThrowsAsync(async () => + { + await foreach (var item in AsyncEnumerableFactory.FromEnumerable(source).WithCancellation(cts.Token)) + { + collected.Add(item); + } + }); + } + + [Fact] + public async Task FromTask_YieldsSingleResult() + { + // Arrange + var task = Task.FromResult(42); + + // Act + var result = new List(); + await foreach (var value in AsyncEnumerableFactory.FromTask(task)) + { + result.Add(value); + } + + // Assert + Assert.Single(result); + Assert.Equal(42, result[0]); + } + + [Fact] + public async Task FromTask_Cancelled_ThrowsOperationCanceledException() + { + // Arrange + var task = Task.FromResult(42); + using var cts = new CancellationTokenSource(); + await cts.CancelAsync(); + var collected = new List(); + + // Act & Assert + await Assert.ThrowsAsync(async () => + { + await foreach (var item in AsyncEnumerableFactory.FromTask(task).WithCancellation(cts.Token)) + { + collected.Add(item); + } + }); + } } \ No newline at end of file diff --git a/test/Atc.Tests/Helpers/DateTimeHelperTests.cs b/test/Atc.Tests/Helpers/DateTimeHelperTests.cs index f5696e26..3c1f77af 100644 --- a/test/Atc.Tests/Helpers/DateTimeHelperTests.cs +++ b/test/Atc.Tests/Helpers/DateTimeHelperTests.cs @@ -20,7 +20,7 @@ public class DateTimeHelperTests [InlineData(true, GlobalizationLcidConstants.Germany, "15-10-2023")] [InlineData(true, GlobalizationLcidConstants.Germany, "15/10/2023")] [InlineData(false, GlobalizationLcidConstants.Germany, "15.20.2023")] - public void TryParseUsingCurrentUiCulture( + public void TryParseUi( bool expected, int arrangeUiLcid, string value) @@ -29,7 +29,7 @@ public void TryParseUsingCurrentUiCulture( Thread.CurrentThread.CurrentUICulture = new CultureInfo(arrangeUiLcid); // Act - var actual = DateTimeHelper.TryParseUsingCurrentUiCulture(value, out _); + var actual = DateTimeHelper.TryParseUi(value, out _); // Assert Assert.Equal(expected, actual); @@ -72,7 +72,7 @@ public void TryParseUsingSpecificCulture( [InlineData(true, GlobalizationLcidConstants.Germany, "15-10-2023")] [InlineData(true, GlobalizationLcidConstants.Germany, "15/10/2023")] [InlineData(false, GlobalizationLcidConstants.Germany, "15.20.2023")] - public void TryParseShortDateUsingCurrentUiCulture( + public void TryParseShortDateUi( bool expected, int arrangeUiLcid, string value) @@ -81,7 +81,7 @@ public void TryParseShortDateUsingCurrentUiCulture( Thread.CurrentThread.CurrentUICulture = new CultureInfo(arrangeUiLcid); // Act - var actual = DateTimeHelper.TryParseShortDateUsingCurrentUiCulture(value, out _); + var actual = DateTimeHelper.TryParseShortDateUi(value, out _); // Assert Assert.Equal(expected, actual); @@ -124,7 +124,7 @@ public void TryParseShortDateUsingSpecificCulture( [InlineData(true, GlobalizationLcidConstants.Germany, "15:30")] [InlineData(false, GlobalizationLcidConstants.Germany, "24:30")] [InlineData(false, GlobalizationLcidConstants.Germany, "15.30")] - public void TryParseShortTimeUsingCurrentUiCulture( + public void TryParseShortTimeUi( bool expected, int arrangeUiLcid, string value) @@ -133,7 +133,7 @@ public void TryParseShortTimeUsingCurrentUiCulture( Thread.CurrentThread.CurrentUICulture = new CultureInfo(arrangeUiLcid); // Act - var actual = DateTimeHelper.TryParseShortTimeUsingCurrentUiCulture(value, out _); + var actual = DateTimeHelper.TryParseShortTimeUi(value, out _); // Assert Assert.Equal(expected, actual); @@ -176,7 +176,7 @@ public void TryParseShortTimeUsingSpecificCulture( [InlineData(true, GlobalizationLcidConstants.Germany, "15:30")] [InlineData(false, GlobalizationLcidConstants.Germany, "24:30")] [InlineData(false, GlobalizationLcidConstants.Germany, "15.30")] - public void TryParseShortTimeUsingCurrentUiCultureUtc( + public void TryParseShortTimeUiUtc( bool expected, int arrangeUiLcid, string value) @@ -185,7 +185,7 @@ public void TryParseShortTimeUsingCurrentUiCultureUtc( Thread.CurrentThread.CurrentUICulture = new CultureInfo(arrangeUiLcid); // Act - var actual = DateTimeHelper.TryParseShortTimeUsingCurrentUiCultureUtc(value, out _); + var actual = DateTimeHelper.TryParseShortTimeUiUtc(value, out _); // Assert Assert.Equal(expected, actual); diff --git a/test/Atc.Tests/Helpers/DateTimeOffsetHelperTests.cs b/test/Atc.Tests/Helpers/DateTimeOffsetHelperTests.cs index dc3a62dc..d5f5ac06 100644 --- a/test/Atc.Tests/Helpers/DateTimeOffsetHelperTests.cs +++ b/test/Atc.Tests/Helpers/DateTimeOffsetHelperTests.cs @@ -20,7 +20,7 @@ public class DateTimeOffsetHelperTests [InlineData(true, GlobalizationLcidConstants.Germany, "15-10-2023")] [InlineData(true, GlobalizationLcidConstants.Germany, "15/10/2023")] [InlineData(false, GlobalizationLcidConstants.Germany, "15.20.2023")] - public void TryParseUsingCurrentUiCulture( + public void TryParseUi( bool expected, int arrangeUiLcid, string value) @@ -29,7 +29,7 @@ public void TryParseUsingCurrentUiCulture( Thread.CurrentThread.CurrentUICulture = new CultureInfo(arrangeUiLcid); // Act - var actual = DateTimeOffsetHelper.TryParseUsingCurrentUiCulture(value, out _); + var actual = DateTimeOffsetHelper.TryParseUi(value, out _); // Assert Assert.Equal(expected, actual); @@ -52,7 +52,7 @@ public void TryParseUsingCurrentUiCulture( [InlineData(true, GlobalizationLcidConstants.Germany, "15-10-2023")] [InlineData(true, GlobalizationLcidConstants.Germany, "15/10/2023")] [InlineData(false, GlobalizationLcidConstants.Germany, "15.20.2023")] - public void TryParseShortDateUsingCurrentUiCulture( + public void TryParseShortDateUi( bool expected, int arrangeUiLcid, string value) @@ -61,7 +61,7 @@ public void TryParseShortDateUsingCurrentUiCulture( Thread.CurrentThread.CurrentUICulture = new CultureInfo(arrangeUiLcid); // Act - var actual = DateTimeOffsetHelper.TryParseShortDateUsingCurrentUiCulture(value, out _); + var actual = DateTimeOffsetHelper.TryParseShortDateUi(value, out _); // Assert Assert.Equal(expected, actual); @@ -84,7 +84,7 @@ public void TryParseShortDateUsingCurrentUiCulture( [InlineData(true, GlobalizationLcidConstants.Germany, "15:30")] [InlineData(false, GlobalizationLcidConstants.Germany, "24:30")] [InlineData(false, GlobalizationLcidConstants.Germany, "15.30")] - public void TryParseShortTimeUsingCurrentUiCulture( + public void TryParseShortTimeUi( bool expected, int arrangeUiLcid, string value) @@ -93,7 +93,7 @@ public void TryParseShortTimeUsingCurrentUiCulture( Thread.CurrentThread.CurrentUICulture = new CultureInfo(arrangeUiLcid); // Act - var actual = DateTimeOffsetHelper.TryParseShortTimeUsingCurrentUiCulture(value, out _); + var actual = DateTimeOffsetHelper.TryParseShortTimeUi(value, out _); // Assert Assert.Equal(expected, actual); @@ -116,7 +116,7 @@ public void TryParseShortTimeUsingCurrentUiCulture( [InlineData(true, GlobalizationLcidConstants.Germany, "15:30")] [InlineData(false, GlobalizationLcidConstants.Germany, "24:30")] [InlineData(false, GlobalizationLcidConstants.Germany, "15.30")] - public void TryParseShortTimeUsingCurrentUiCultureUtc( + public void TryParseShortTimeUiUtc( bool expected, int arrangeUiLcid, string value) @@ -125,9 +125,103 @@ public void TryParseShortTimeUsingCurrentUiCultureUtc( Thread.CurrentThread.CurrentUICulture = new CultureInfo(arrangeUiLcid); // Act - var actual = DateTimeOffsetHelper.TryParseShortTimeUsingCurrentUiCultureUtc(value, out _); + var actual = DateTimeOffsetHelper.TryParseShortTimeUiUtc(value, out _); // Assert Assert.Equal(expected, actual); } + + [Theory] + [InlineData(true, GlobalizationLcidConstants.UnitedStates, "10/15/2023")] + [InlineData(true, GlobalizationLcidConstants.UnitedStates, "10-15-2023")] + [InlineData(false, GlobalizationLcidConstants.UnitedStates, "20/15/2023")] + [InlineData(true, GlobalizationLcidConstants.GreatBritain, "15/10/2023")] + [InlineData(false, GlobalizationLcidConstants.GreatBritain, "15/20/2023")] + [InlineData(true, GlobalizationLcidConstants.Denmark, "15.10.2023")] + [InlineData(false, GlobalizationLcidConstants.Denmark, "15.20.2023")] + [InlineData(true, GlobalizationLcidConstants.Germany, "15.10.2023")] + [InlineData(false, GlobalizationLcidConstants.Germany, "15.20.2023")] + public void TryParseUsingSpecificCulture( + bool expected, + int lcid, + string value) + { + var actual = DateTimeOffsetHelper.TryParseUsingSpecificCulture( + value, + new CultureInfo(lcid), + out _); + Assert.Equal(expected, actual); + } + + [Theory] + [InlineData(true, GlobalizationLcidConstants.UnitedStates, "10/15/2023")] + [InlineData(true, GlobalizationLcidConstants.UnitedStates, "10-15-2023")] + [InlineData(false, GlobalizationLcidConstants.UnitedStates, "20/15/2023")] + [InlineData(true, GlobalizationLcidConstants.GreatBritain, "15/10/2023")] + [InlineData(false, GlobalizationLcidConstants.GreatBritain, "15/20/2023")] + [InlineData(true, GlobalizationLcidConstants.Denmark, "15.10.2023")] + [InlineData(false, GlobalizationLcidConstants.Denmark, "15.20.2023")] + [InlineData(true, GlobalizationLcidConstants.Germany, "15.10.2023")] + [InlineData(false, GlobalizationLcidConstants.Germany, "15.20.2023")] + public void TryParseShortDateUsingSpecificCulture( + bool expected, + int lcid, + string value) + { + var actual = DateTimeOffsetHelper.TryParseShortDateUsingSpecificCulture( + value, + new CultureInfo(lcid), + out _); + Assert.Equal(expected, actual); + } + + [Theory] + [InlineData(true, GlobalizationLcidConstants.UnitedStates, "3:30 AM")] + [InlineData(true, GlobalizationLcidConstants.UnitedStates, "3:30 PM")] + [InlineData(false, GlobalizationLcidConstants.UnitedStates, "3:30 X")] + [InlineData(true, GlobalizationLcidConstants.GreatBritain, "03:30")] + [InlineData(true, GlobalizationLcidConstants.GreatBritain, "15:30")] + [InlineData(false, GlobalizationLcidConstants.GreatBritain, "24:30")] + [InlineData(true, GlobalizationLcidConstants.Denmark, "03.30")] + [InlineData(true, GlobalizationLcidConstants.Denmark, "15.30")] + [InlineData(false, GlobalizationLcidConstants.Denmark, "24.30")] + [InlineData(true, GlobalizationLcidConstants.Germany, "03:30")] + [InlineData(true, GlobalizationLcidConstants.Germany, "15:30")] + [InlineData(false, GlobalizationLcidConstants.Germany, "24:30")] + public void TryParseShortTimeUsingSpecificCulture( + bool expected, + int lcid, + string value) + { + var actual = DateTimeOffsetHelper.TryParseShortTimeUsingSpecificCulture( + value, + new CultureInfo(lcid), + out _); + Assert.Equal(expected, actual); + } + + [Theory] + [InlineData(true, GlobalizationLcidConstants.UnitedStates, "3:30 AM")] + [InlineData(true, GlobalizationLcidConstants.UnitedStates, "3:30 PM")] + [InlineData(false, GlobalizationLcidConstants.UnitedStates, "3:30 X")] + [InlineData(true, GlobalizationLcidConstants.GreatBritain, "03:30")] + [InlineData(true, GlobalizationLcidConstants.GreatBritain, "15:30")] + [InlineData(false, GlobalizationLcidConstants.GreatBritain, "24:30")] + [InlineData(true, GlobalizationLcidConstants.Denmark, "03.30")] + [InlineData(true, GlobalizationLcidConstants.Denmark, "15.30")] + [InlineData(false, GlobalizationLcidConstants.Denmark, "24.30")] + [InlineData(true, GlobalizationLcidConstants.Germany, "03:30")] + [InlineData(true, GlobalizationLcidConstants.Germany, "15:30")] + [InlineData(false, GlobalizationLcidConstants.Germany, "24:30")] + public void TryParseShortTimeUsingSpecificCultureUtc( + bool expected, + int lcid, + string value) + { + var actual = DateTimeOffsetHelper.TryParseShortTimeUsingSpecificCultureUtc( + value, + new CultureInfo(lcid), + out _); + Assert.Equal(expected, actual); + } } \ No newline at end of file diff --git a/test/Atc.Tests/Helpers/MathHelperTests.cs b/test/Atc.Tests/Helpers/MathHelperTests.cs index d466acf2..98697ddc 100644 --- a/test/Atc.Tests/Helpers/MathHelperTests.cs +++ b/test/Atc.Tests/Helpers/MathHelperTests.cs @@ -287,6 +287,7 @@ public void Min_List_Int( [Theory] [InlineData(4.4, new[] { 8.5, 4.4, 6 })] + [InlineData(3000000000.0, new[] { 3000000000.0, 5000000000.0 })] public void Min_Array_Double( double expected, double[] input) @@ -300,6 +301,7 @@ public void Min_Array_Double( [Theory] [InlineData(4.4, new[] { 8.5, 4.4, 6 })] + [InlineData(3000000000.0, new[] { 3000000000.0, 5000000000.0 })] public void Min_List_Double( double expected, double[] data) @@ -347,6 +349,7 @@ public void Max_List_Int( [Theory] [InlineData(8.5, new[] { 8.5, 4.4, 6 })] + [InlineData(-3000000000.0, new[] { -3000000000.0, -5000000000.0 })] public void Max_Array_Double( double expected, double[] input) @@ -360,6 +363,7 @@ public void Max_Array_Double( [Theory] [InlineData(8.5, new[] { 8.5, 4.4, 6 })] + [InlineData(-3000000000.0, new[] { -3000000000.0, -5000000000.0 })] public void Max_List_Double( double expected, double[] data) @@ -392,7 +396,7 @@ public void IsEqualToZero( [Theory] [InlineData(true, 1, 1)] - [InlineData(false, 1, 1.00000000000001)] + [InlineData(true, 1, 1.00000000000001)] // diff = 1e-14, within DoubleEpsilon (1e-9) [InlineData(false, 1, 1.000001)] public void IsEquals( bool expected, @@ -410,6 +414,7 @@ public void IsEquals( [InlineData(12.12, 12.12, 1)] [InlineData(12.12, 12.12, 2)] [InlineData(12.12, 12.12, 3)] + [InlineData(3.141592653, 3.141592653, 15)] public void TruncateToMaxPrecision( double expected, double input, diff --git a/test/Atc.Tests/Helpers/NetworkInformationHelperTests.cs b/test/Atc.Tests/Helpers/NetworkInformationHelperTests.cs index 509000fb..e470da44 100644 --- a/test/Atc.Tests/Helpers/NetworkInformationHelperTests.cs +++ b/test/Atc.Tests/Helpers/NetworkInformationHelperTests.cs @@ -53,4 +53,53 @@ public void HasTcpConnection_WithIpAddressAndPort( // Assert - DNS servers typically accept TCP connections on port 53 Assert.True(result); } + + [Fact] + public async Task HasConnectionAsync() + => Assert.True(await NetworkInformationHelper.HasConnectionAsync()); + + [Theory] + [InlineData("8.8.8.8")] + [InlineData("1.1.1.1")] + public async Task HasConnectionAsync_WithIpAddress(string ipAddressString) + { + // Arrange + var ipAddress = IPAddress.Parse(ipAddressString); + + // Act + var result = await NetworkInformationHelper.HasConnectionAsync(ipAddress); + + // Assert + Assert.True(result); + } + + [Fact] + public async Task HasHttpConnectionAsync() + => Assert.True(await NetworkInformationHelper.HasHttpConnectionAsync()); + + [Theory] + [InlineData("https://www.google.com/")] + public async Task HasHttpConnectionAsync_Uri(string url) + => Assert.True(await NetworkInformationHelper.HasHttpConnectionAsync(new Uri(url))); + + [Fact] + public async Task GetPublicIpAddressAsync() + => Assert.NotNull(await NetworkInformationHelper.GetPublicIpAddressAsync()); + + [Theory] + [InlineData("8.8.8.8", 53)] + [InlineData("1.1.1.1", 53)] + public async Task HasTcpConnectionAsync_WithIpAddressAndPort( + string ipAddressString, + int port) + { + // Arrange + var ipAddress = IPAddress.Parse(ipAddressString); + + // Act + var result = await NetworkInformationHelper.HasTcpConnectionAsync(ipAddress, port); + + // Assert + Assert.True(result); + } } \ No newline at end of file diff --git a/test/Atc.Tests/Math/GeoSpatial/GeoSpatialHelperTests.cs b/test/Atc.Tests/Math/GeoSpatial/GeoSpatialHelperTests.cs index fc881950..131dcc9d 100644 --- a/test/Atc.Tests/Math/GeoSpatial/GeoSpatialHelperTests.cs +++ b/test/Atc.Tests/Math/GeoSpatial/GeoSpatialHelperTests.cs @@ -40,4 +40,68 @@ public void Distance( // Assert Assert.Equal(expected, actual); } + + [Fact] + public void Distance_LondonToParis_IsApproximately341Km() + { + // London: lat=51.5074, lon=-0.1278 Paris: lat=48.8566, lon=2.3522 + // Haversine gives ~341 km; the old spherical-law-of-cosines gave ~323 km. + const double londonLat = 51.5074; + const double londonLon = -0.1278; + const double parisLat = 48.8566; + const double parisLon = 2.3522; + + var km = GeoSpatialHelper.Distance(londonLon, londonLat, parisLon, parisLat, DistanceMeasurementType.Kilometers); + + Assert.InRange(km, 338, 344); + } + + [Theory] + [InlineData(0.0, 0.0, 0.0, 0.0, 0.0)] + public void Bearing_CartesianCoordinate( + double expected, + double latitude1, + double longitude1, + double latitude2, + double longitude2) + { + // Arrange + var coordinate1 = new CartesianCoordinate(latitude1, longitude1); + var coordinate2 = new CartesianCoordinate(latitude2, longitude2); + + // Act + var actual = GeoSpatialHelper.Bearing(coordinate1, coordinate2); + + // Assert + Assert.Equal(expected, actual); + } + + [Theory] + [InlineData(0.0, 0.0, 0.0, 0.0, 0.0)] + public void Bearing( + double expected, + double longitude1, + double latitude1, + double longitude2, + double latitude2) + { + // Act + var actual = GeoSpatialHelper.Bearing(longitude1, latitude1, longitude2, latitude2); + + // Assert + Assert.Equal(expected, actual); + } + + [Fact] + public void Bearing_LondonToParis_IsApproximately148Degrees() + { + const double londonLat = 51.5074; + const double londonLon = -0.1278; + const double parisLat = 48.8566; + const double parisLon = 2.3522; + + var actual = GeoSpatialHelper.Bearing(londonLon, londonLat, parisLon, parisLat); + + Assert.InRange(actual, 145, 152); + } } \ No newline at end of file diff --git a/test/Atc.Tests/Math/GeoSpatial/UniversalTransverseMercatorConverterTests.cs b/test/Atc.Tests/Math/GeoSpatial/UniversalTransverseMercatorConverterTests.cs index 2643028a..f858094c 100644 --- a/test/Atc.Tests/Math/GeoSpatial/UniversalTransverseMercatorConverterTests.cs +++ b/test/Atc.Tests/Math/GeoSpatial/UniversalTransverseMercatorConverterTests.cs @@ -45,6 +45,16 @@ public void ToUtm( actual.UtmNorthing.Should().Be(expected.UtmNorthing, $"UtmNorthing on ({description})"); } + [Fact] + public void ToWgs84_EmptyZoneLetter_DoesNotThrow() + { + // utmZoneLetter[0] was accessed before the IsNullOrEmpty guard, causing + // IndexOutOfRangeException when an empty string was passed. + var converter = new UniversalTransverseMercatorConverter(); + var exception = Record.Exception(() => converter.ToWgs84(32, string.Empty, 691875, 6098907)); + Assert.Null(exception); + } + [Theory] [ClassData(typeof(TestClassDataForGeoSpatialToWgs84))] public void ToWgs84( @@ -64,4 +74,24 @@ public void ToWgs84( actual.Latitude.Should().Be(expected.Latitude, $"Latitude on ({description})"); actual.Longitude.Should().Be(expected.Longitude, $"Longitude on ({description})"); } + + [Theory] + [ClassData(typeof(TestClassDataForGeoSpatialToWgs84))] + public void ToWgs84_UtmResult( + string description, + UniversalTransverseMercatorResult input, + int maxDecimalPrecision, + CartesianCoordinate expected) + { + // Arrange + var converter = new UniversalTransverseMercatorConverter(); + + // Act + var actual = converter.ToWgs84(input, maxDecimalPrecision); + + // Assert + actual.Should().NotBeNull(description); + actual.Latitude.Should().Be(expected.Latitude, $"Latitude on ({description})"); + actual.Longitude.Should().Be(expected.Longitude, $"Longitude on ({description})"); + } } \ No newline at end of file diff --git a/test/Atc.Tests/Math/Geometry/TriangleHelperTests.cs b/test/Atc.Tests/Math/Geometry/TriangleHelperTests.cs index fcdbd344..800cb7f5 100644 --- a/test/Atc.Tests/Math/Geometry/TriangleHelperTests.cs +++ b/test/Atc.Tests/Math/Geometry/TriangleHelperTests.cs @@ -46,4 +46,30 @@ public void Pythagorean() expected = 24.494897427831781; Assert.Equal(expected, TriangleHelper.Pythagorean(null, sideB, sideC)); } + + [Theory] + [InlineData(null, 10.0, 5.0)] + [InlineData(10.0, null, 5.0)] + public void Pythagorean_ImpossibleSides_ThrowsArithmeticException( + double? sideA, + double? sideB, + double? sideC) + { + // When a leg is longer than the hypotenuse the radicand is negative. + // Math.Sqrt(-x) returns NaN rather than throwing; we expect ArithmeticException. + Assert.Throws(() => TriangleHelper.Pythagorean(sideA, sideB, sideC)); + } + + [Fact] + public void IsSumOfTheAnglesATriangle_AnglesWithSmallFloatingPointExcess_ReturnsTrue() + { + // Each angle is 60° + 1e-10, so their sum is 180° + 3e-10. + // This is well within any sensible geometric tolerance (1e-9) but far exceeds + // double.Epsilon (~4.9e-324), causing the current IsEqual check to incorrectly + // reject a valid triangle. + const double a = 60.0 + 1e-10; + const double b = 60.0 + 1e-10; + const double c = 60.0 + 1e-10; + Assert.True(TriangleHelper.IsSumOfTheAnglesATriangle(a, b, c)); + } } \ No newline at end of file diff --git a/test/Atc.Tests/Serialization/DynamicJsonTests.cs b/test/Atc.Tests/Serialization/DynamicJsonTests.cs index 83c96575..523341a9 100644 --- a/test/Atc.Tests/Serialization/DynamicJsonTests.cs +++ b/test/Atc.Tests/Serialization/DynamicJsonTests.cs @@ -66,6 +66,16 @@ public void ReturnsNullForNoneExistentPath() Assert.Null(actual); } + [Fact] + public void GetValue_MissingIntermediateSegment_ReturnsNull() + { + // Accessing a path whose intermediate key does not exist should return null, + // not throw KeyNotFoundException from the dictionary indexer. + var dynamicJson = new DynamicJson(JsonPropertyValue); + var actual = dynamicJson.GetValue("missing.nested"); + Assert.Null(actual); + } + [Fact] public void CanSetValueAtPath() { @@ -151,6 +161,26 @@ public void CannotRemoveNonexistentPath() Assert.Equal("The path does not exist: nonexistentProperty", result.ErrorMessage); } + [Fact] + public void SetValue_MissingIntermediateSegment_ReturnsFailure() + { + // When createKeyIfNotExist is false and an intermediate key is absent, + // SetValue should return failure rather than throwing KeyNotFoundException. + var dynamicJson = new DynamicJson(JsonPropertyValue); + var result = dynamicJson.SetValue("missing.nested", "value", createKeyIfNotExist: false); + Assert.False(result.IsSucceeded); + } + + [Fact] + public void RemovePath_MissingIntermediateSegment_ReturnsFailure() + { + // Removing a path whose intermediate key does not exist should return + // failure rather than throwing KeyNotFoundException. + var dynamicJson = new DynamicJson(JsonPropertyValue); + var result = dynamicJson.RemovePath("missing.nested"); + Assert.False(result.IsSucceeded); + } + [Fact] public void ThrowsOnNullPath() { diff --git a/test/Atc.Tests/Serialization/JsonConverters/NumberToStringJsonConverterTests.cs b/test/Atc.Tests/Serialization/JsonConverters/NumberToStringJsonConverterTests.cs index 9897c002..548006b0 100644 --- a/test/Atc.Tests/Serialization/JsonConverters/NumberToStringJsonConverterTests.cs +++ b/test/Atc.Tests/Serialization/JsonConverters/NumberToStringJsonConverterTests.cs @@ -27,6 +27,33 @@ public void Read_ShouldReturnStringRepresentationOfNumber( Assert.Equal(expected, NumberHelper.ParseToDouble(result.ToString()!, GlobalizationConstants.EnglishCultureInfo)); } + [Fact] + public void Read_ShouldUseInvariantCulture_RegardlessOfCurrentCulture() + { + // Arrange + var originalCulture = Thread.CurrentThread.CurrentCulture; + Thread.CurrentThread.CurrentCulture = GlobalizationConstants.DanishCultureInfo; + + try + { + var jsonSerializerOptions = JsonSerializerOptionsFactory.Create(); + var jsonConverter = new NumberToStringJsonConverter(); + var utf8JsonReader = new Utf8JsonReader(Encoding.UTF8.GetBytes("123.45")); + + utf8JsonReader.Read(); + + // Act + var result = jsonConverter.Read(ref utf8JsonReader, typeof(string), jsonSerializerOptions); + + // Assert - invariant culture uses '.' as the decimal separator even under da-DK + Assert.Equal("123.45", result); + } + finally + { + Thread.CurrentThread.CurrentCulture = originalCulture; + } + } + [Theory] [InlineData(123)] [InlineData(123.45)] diff --git a/test/Atc.Tests/Serialization/JsonConverters/VersionJsonConverterTests.cs b/test/Atc.Tests/Serialization/JsonConverters/VersionJsonConverterTests.cs index 8cc06ffe..7d936002 100644 --- a/test/Atc.Tests/Serialization/JsonConverters/VersionJsonConverterTests.cs +++ b/test/Atc.Tests/Serialization/JsonConverters/VersionJsonConverterTests.cs @@ -5,16 +5,17 @@ public sealed class VersionJsonConverterTests [Fact] public void Read_ShouldDeserializeVersionFromObject() { - // Arrange + // STJ serializes Version using its public properties (Major, Minor, Build, Revision), + // not the private backing fields (_Major etc.) that the old implementation read. var jsonSerializerOptions = JsonSerializerOptionsFactory.Create(); var jsonConverter = new VersionJsonConverter(); const string json = """ { - "_Major": 1, - "_Minor": 2, - "_Build": 3, - "_Revision": 4 + "Major": 1, + "Minor": 2, + "Build": 3, + "Revision": 4 } """; diff --git a/test/Atc.Tests/Serialization/JsonSerializerHelperTests.cs b/test/Atc.Tests/Serialization/JsonSerializerHelperTests.cs new file mode 100644 index 00000000..e15eb770 --- /dev/null +++ b/test/Atc.Tests/Serialization/JsonSerializerHelperTests.cs @@ -0,0 +1,103 @@ +namespace Atc.Tests.Serialization; + +public class JsonSerializerHelperTests +{ + private sealed record Person(string Name, int Age); + + [Fact] + public async Task SerializeToStreamAsync_ThenDeserializeFromStreamAsync_RoundTrips() + { + // Arrange + var original = new Person("Alice", 30); + using var stream = new MemoryStream(); + + // Act + await JsonSerializerHelper.SerializeToStreamAsync(original, stream); + stream.Position = 0; + var result = await JsonSerializerHelper.DeserializeFromStreamAsync(stream); + + // Assert + Assert.NotNull(result); + Assert.Equal(original.Name, result.Name); + Assert.Equal(original.Age, result.Age); + } + + [Fact] + public async Task SerializeToStreamAsync_WithOptions_WritesJson() + { + // Arrange + var original = new Person("Bob", 25); + using var stream = new MemoryStream(); + var options = JsonSerializerOptionsFactory.Create(useCamelCase: false, writeIndented: false); + + // Act + await JsonSerializerHelper.SerializeToStreamAsync(original, stream, options); + stream.Position = 0; + using var reader = new StreamReader(stream); + var json = await reader.ReadToEndAsync(); + + // Assert — PascalCase keys expected + Assert.Contains("\"Name\"", json, StringComparison.Ordinal); + Assert.Contains("\"Age\"", json, StringComparison.Ordinal); + } + + [Fact] + public async Task DeserializeFromStreamAsync_WithOptions_Deserializes() + { + // Arrange + const string json = "{\"Name\":\"Carol\",\"Age\":22}"; + using var stream = new MemoryStream(Encoding.UTF8.GetBytes(json)); + var options = JsonSerializerOptionsFactory.Create(propertyNameCaseInsensitive: true, writeIndented: false); + + // Act + var result = await JsonSerializerHelper.DeserializeFromStreamAsync(stream, options); + + // Assert + Assert.NotNull(result); + Assert.Equal("Carol", result.Name); + Assert.Equal(22, result.Age); + } + + [Fact] + public Task SerializeToStreamAsync_NullStream_ThrowsArgumentNullException() + => Assert.ThrowsAsync( + () => JsonSerializerHelper.SerializeToStreamAsync(new Person("x", 1), null!)); + + [Fact] + public Task DeserializeFromStreamAsync_NullStream_ThrowsArgumentNullException() + => Assert.ThrowsAsync( + () => JsonSerializerHelper.DeserializeFromStreamAsync(null!)); + + [Fact] + public async Task SerializeToStreamAsync_Cancelled_ThrowsOperationCanceledException() + { + // Arrange + using var stream = new MemoryStream(); + using var cts = new CancellationTokenSource(); + await cts.CancelAsync(); + + // Act + var ex = await Record.ExceptionAsync( + () => JsonSerializerHelper.SerializeToStreamAsync(new Person("x", 1), stream, cts.Token)); + + // Assert + Assert.IsAssignableFrom(ex); + } + + [Fact] + public async Task DeserializeFromStreamAsync_Cancelled_ThrowsOperationCanceledException() + { + // Arrange + const string json = "{\"Name\":\"x\",\"Age\":1}"; + using var stream = new MemoryStream(Encoding.UTF8.GetBytes(json)); + using var cts = new CancellationTokenSource(); + await cts.CancelAsync(); + + // Act + var ex = await Record.ExceptionAsync( + () => (Task)JsonSerializerHelper.DeserializeFromStreamAsync(stream, cts.Token)); + + // Assert + Assert.IsAssignableFrom(ex); + } +} \ No newline at end of file diff --git a/test/Atc.Tests/Serialization/JsonSerializerOptionsFactoryTests.cs b/test/Atc.Tests/Serialization/JsonSerializerOptionsFactoryTests.cs index 32e73852..0e4a9b52 100644 --- a/test/Atc.Tests/Serialization/JsonSerializerOptionsFactoryTests.cs +++ b/test/Atc.Tests/Serialization/JsonSerializerOptionsFactoryTests.cs @@ -67,4 +67,14 @@ public void Create_WithNullSettings_ThrowsArgumentNullException() // Act & Assert Assert.Throws(() => JsonSerializerOptionsFactory.Create(null!)); } + + [Fact] + public void Default_ReturnsSameInstance() + { + var first = JsonSerializerOptionsFactory.Default; + var second = JsonSerializerOptionsFactory.Default; + Assert.Same(first, second); + Assert.Equal(JsonNamingPolicy.CamelCase, first.PropertyNamingPolicy); + Assert.True(first.WriteIndented); + } } \ No newline at end of file diff --git a/test/Atc.Tests/Structs/Point2DTests.cs b/test/Atc.Tests/Structs/Point2DTests.cs index a31c59eb..38381f3c 100644 --- a/test/Atc.Tests/Structs/Point2DTests.cs +++ b/test/Atc.Tests/Structs/Point2DTests.cs @@ -22,6 +22,23 @@ public void IsDefault( Assert.Equal(expected, actual); } + [Fact] + public void IsDefault_WithTinyNonZeroX_ReturnsFalse() + { + // Arrange — value must exceed DoubleEpsilon (1e-9) to be considered non-default + var input = new Point2D(DoubleExtensions.DoubleEpsilon * 10, 0); + + // Act / Assert + Assert.False(input.IsDefault); + } + + [Fact] + public void IsDefault_WithTinyNonZeroY_ReturnsFalse() + { + var input = new Point2D(0, DoubleExtensions.DoubleEpsilon * 10); + Assert.False(input.IsDefault); + } + [Theory] [InlineData("0, 0", 0, 0)] [InlineData("1, 0", 1, 0)] diff --git a/test/Atc.Tests/Structs/Point3DTests.cs b/test/Atc.Tests/Structs/Point3DTests.cs index d8989050..1372d817 100644 --- a/test/Atc.Tests/Structs/Point3DTests.cs +++ b/test/Atc.Tests/Structs/Point3DTests.cs @@ -23,6 +23,13 @@ public void IsDefault( Assert.Equal(expected, actual); } + [Fact] + public void IsDefault_WithTinyNonZeroZ_ReturnsFalse() + { + var input = new Point3D(0, 0, double.Epsilon); + Assert.False(input.IsDefault); + } + [Theory] [InlineData("0, 0, 0", 0, 0, 0)] [InlineData("1, 0, 0", 1, 0, 0)] diff --git a/test/Atc.Tests/Units/DigitalInformation/ByteSizeFormatterTests.cs b/test/Atc.Tests/Units/DigitalInformation/ByteSizeFormatterTests.cs index 32c6b0cd..b6e969e8 100644 --- a/test/Atc.Tests/Units/DigitalInformation/ByteSizeFormatterTests.cs +++ b/test/Atc.Tests/Units/DigitalInformation/ByteSizeFormatterTests.cs @@ -3,6 +3,40 @@ namespace Atc.Tests.Units.DigitalInformation; public class ByteSizeFormatterTests { + [Fact] + public void Constructor_UsesCurrentCulture_NotUICulture() + { + // Verify the constructor reads CurrentCulture (number/date formatting) rather + // than CurrentUICulture (UI language), which is the wrong culture property for + // a byte-size formatter. + var prevCulture = Thread.CurrentThread.CurrentCulture; + var prevUICulture = Thread.CurrentThread.CurrentUICulture; + try + { + Thread.CurrentThread.CurrentCulture = CultureInfo.GetCultureInfo("en-US"); + Thread.CurrentThread.CurrentUICulture = CultureInfo.GetCultureInfo("da-DK"); + + var formatter = new ByteSizeFormatter(); + + Assert.Equal(",", formatter.NumberFormatInfo.NumberGroupSeparator); + } + finally + { + Thread.CurrentThread.CurrentCulture = prevCulture; + Thread.CurrentThread.CurrentUICulture = prevUICulture; + } + } + + [Fact] + public void Format_NegativeSize_DoesNotThrow() + { + // ByteSize.ToString() delegates to the formatter; a throwing formatter is a + // debugging hazard because the debugger shows an exception instead of the value. + var formatter = new ByteSizeFormatter(); + var exception = Record.Exception(() => formatter.Format(-1)); + Assert.Null(exception); + } + [Theory] [InlineData("1", 1)] [InlineData("1", 1024L)] @@ -59,7 +93,9 @@ public void Format_Suffix_Short( [InlineData("1 byte", 1)] [InlineData("2 bytes", 2)] [InlineData("1 Kilobyte", 1024L)] + [InlineData("2 Kilobytes", 2 * 1024L)] [InlineData("1 Megabyte", 1024L * 1024L)] + [InlineData("2 Megabytes", 2 * 1024L * 1024L)] [InlineData("1 Gigabyte", 1024L * 1024L * 1024L)] [InlineData("1 Terabyte", 1024L * 1024L * 1024L * 1024L)] [InlineData("1 Petabyte", 1024L * 1024L * 1024L * 1024L * 1024L)] @@ -182,7 +218,7 @@ public void Format_Rounding_Down( [InlineData("1,536 B", 1024L + 512, 0, ByteSizeUnitType.Byte, ByteSizeUnitType.Byte, GlobalizationLcidConstants.UnitedStates)] [InlineData("2,048 B", 2 * 1024L, 0, ByteSizeUnitType.Byte, ByteSizeUnitType.Byte, GlobalizationLcidConstants.UnitedStates)] [InlineData("378,630,729,272 B", 378630729272, 0, ByteSizeUnitType.Byte, ByteSizeUnitType.Byte, GlobalizationLcidConstants.UnitedStates)] - public void Format_MinMax( + public void Format_MinMax_Units( string expected, long size, int numberOfDecimals, @@ -205,4 +241,30 @@ public void Format_MinMax( // Assert Assert.Equal(expected, actual); } + + [Theory] + [InlineData("1 B", 1)] + [InlineData("1 KiB", 1024L)] + [InlineData("2 KiB", 2 * 1024L)] + [InlineData("1 MiB", 1024L * 1024L)] + [InlineData("1 GiB", 1024L * 1024L * 1024L)] + [InlineData("1 TiB", 1024L * 1024L * 1024L * 1024L)] + [InlineData("1 PiB", 1024L * 1024L * 1024L * 1024L * 1024L)] + [InlineData("1 EiB", 1024L * 1024L * 1024L * 1024L * 1024L * 1024L)] + public void Format_Suffix_ShortBinary( + string expected, + long size) + { + // Arrange + var formatter = new ByteSizeFormatter + { + SuffixFormat = ByteSizeSuffixType.ShortBinary, + }; + + // Atc + var actual = formatter.Format(size); + + // Assert + Assert.Equal(expected, actual); + } } \ No newline at end of file diff --git a/test/Atc.Tests/Units/DigitalInformation/ByteSizeTests.cs b/test/Atc.Tests/Units/DigitalInformation/ByteSizeTests.cs index b762c765..ebc08194 100644 --- a/test/Atc.Tests/Units/DigitalInformation/ByteSizeTests.cs +++ b/test/Atc.Tests/Units/DigitalInformation/ByteSizeTests.cs @@ -50,4 +50,137 @@ public void Format_Default_Formatter( Assert.Equal(expected, actual); Assert.Equal(expected, byteSize.ToString(formatter)); } + + [Fact] + public void GetHashCode_ShouldBeConsistentWithEquality() + { + // Arrange + var a = new ByteSize(2048); + var b = new ByteSize(2048); + + // Assert + Assert.Equal(a, b); + Assert.Equal(a.GetHashCode(), b.GetHashCode()); + Assert.Equal(a.Value.GetHashCode(), a.GetHashCode()); + } + + [Fact] + public void GetHashCode_AllowsReliableUseAsHashSetKey() + { + // Arrange + var set = new HashSet + { + new(2048), + }; + + // Act & Assert - a value-equal instance must be found (broken when GetHashCode used base.GetHashCode()). + Assert.Contains(new ByteSize(2048), set); + Assert.DoesNotContain(new ByteSize(4096), set); + } + + [Theory] + [InlineData(-1, 1024, 2048)] + [InlineData(1, 2048, 1024)] + [InlineData(0, 512, 512)] + public void CompareTo_OrdersCorrectly( + int expectedSign, + long a, + long b) + { + // Arrange + var left = new ByteSize(a); + var right = new ByteSize(b); + + // Atc + var actual = System.Math.Sign(left.CompareTo(right)); + + // Assert + Assert.Equal(expectedSign, actual); + } + + [Fact] + public void CompareTo_WithObject_OrdersCorrectly() + { + // Arrange + var small = new ByteSize(512); + var large = new ByteSize(2048); + object boxedSmall = small; + object boxedLarge = large; + + // Atc & Assert + Assert.True(small.CompareTo(boxedSmall) == 0); + Assert.True(small.CompareTo(boxedLarge) < 0); + Assert.True(large.CompareTo(boxedSmall) > 0); + Assert.Throws(() => small.CompareTo("not a ByteSize")); + } + + [Fact] + public void ComparisonOperators_WorkCorrectly() + { + var small = new ByteSize(100); + var large = new ByteSize(200); + Assert.True(small < large); + Assert.True(small <= large); + Assert.True(large > small); + Assert.True(large >= small); + Assert.False(small > large); + } + + [Fact] + public void ArithmeticOperators_AddAndSubtract() + { + var a = new ByteSize(1024); + var b = new ByteSize(512); + Assert.Equal(1536L, (a + b).Value); + Assert.Equal(512L, (a - b).Value); + } + + [Fact] + public void TryParse_WithValidInput_ReturnsTrueAndValue() + { + // Arrange + string value = "4096"; + + // Atc + var ok = ByteSize.TryParse(value, out var result); + + // Assert + Assert.True(ok); + Assert.Equal(4096L, result.Value); + } + + [Theory] + [InlineData(true, "1024", 1024)] + [InlineData(true, "-512", -512)] + [InlineData(true, " 0 ", 0)] + [InlineData(false, "1.5", 0)] + [InlineData(false, "abc", 0)] + [InlineData(false, null, 0)] + public void TryParse( + bool expectedResult, + string? input, + long expectedValue) + { + var ok = ByteSize.TryParse(input, out var result); + Assert.Equal(expectedResult, ok); + if (ok) + { + Assert.Equal(expectedValue, result.Value); + } + } + + [Fact] + public void Parse_ValidString_ReturnsByteSize() + { + var result = ByteSize.Parse("4096"); + Assert.Equal(4096L, result.Value); + } + + [Fact] + public void Parse_InvalidString_ThrowsFormatException() + => Assert.Throws(() => ByteSize.Parse("not-a-number")); + + [Fact] + public void Parse_NullString_ThrowsArgumentNullException() + => Assert.Throws(() => ByteSize.Parse(null!)); } \ No newline at end of file diff --git a/test/Atc.Tests/Units/InternationalSystemOfUnits/InternationalSystemOfUnitsHelperTests.cs b/test/Atc.Tests/Units/InternationalSystemOfUnits/InternationalSystemOfUnitsHelperTests.cs index e64f0900..beeac562 100644 --- a/test/Atc.Tests/Units/InternationalSystemOfUnits/InternationalSystemOfUnitsHelperTests.cs +++ b/test/Atc.Tests/Units/InternationalSystemOfUnits/InternationalSystemOfUnitsHelperTests.cs @@ -5,6 +5,35 @@ public class InternationalSystemOfUnitsHelperTests [Theory] [InlineData(0.0006, PrefixType.Centi, PrefixType.Kilo, 4, 57)] [InlineData(0.00057, PrefixType.Centi, PrefixType.Kilo, 10, 57)] + [InlineData(1.0, PrefixType.Kilo, PrefixType.Kilo, 0, 1.0)] + [InlineData(1.0, PrefixType.None, PrefixType.None, 0, 1.0)] + [InlineData(1.0, PrefixType.Micro, PrefixType.Micro, 0, 1.0)] + [InlineData(1000.0, PrefixType.Kilo, PrefixType.None, 0, 1.0)] + [InlineData(1000000.0, PrefixType.Kilo, PrefixType.Milli, 0, 1.0)] + [InlineData(1.0, PrefixType.Kilo, PrefixType.Mega, 0, 1000.0)] + [InlineData(1000.0, PrefixType.Mega, PrefixType.Kilo, 0, 1.0)] + [InlineData(1.0, PrefixType.Mega, PrefixType.Mega, 0, 1.0)] + [InlineData(1000000.0, PrefixType.Mega, PrefixType.None, 0, 1.0)] + [InlineData(1000.0, PrefixType.Giga, PrefixType.Mega, 0, 1.0)] + [InlineData(1000000.0, PrefixType.Giga, PrefixType.Kilo, 0, 1.0)] + [InlineData(1000000000.0, PrefixType.Giga, PrefixType.None, 0, 1.0)] + [InlineData(1000.0, PrefixType.Tera, PrefixType.Giga, 0, 1.0)] + [InlineData(1000000000.0, PrefixType.Tera, PrefixType.Kilo, 0, 1.0)] + [InlineData(100.0, PrefixType.Hecto, PrefixType.None, 0, 1.0)] + [InlineData(10.0, PrefixType.Deca, PrefixType.None, 0, 1.0)] + [InlineData(1000.0, PrefixType.Hecto, PrefixType.Deci, 0, 1.0)] + [InlineData(1.0, PrefixType.Deci, PrefixType.None, 0, 10.0)] + [InlineData(10.0, PrefixType.Deci, PrefixType.Centi, 0, 1.0)] + [InlineData(1.0, PrefixType.Deci, PrefixType.Deci, 0, 1.0)] + [InlineData(1.0, PrefixType.Micro, PrefixType.Milli, 0, 1000.0)] + [InlineData(1.0, PrefixType.Nano, PrefixType.Micro, 0, 1000.0)] + [InlineData(1.0, PrefixType.Pico, PrefixType.Nano, 0, 1000.0)] + [InlineData(1.0, PrefixType.None, PrefixType.Kilo, 0, 1000.0)] + [InlineData(1.0, PrefixType.None, PrefixType.Mega, 0, 1000000.0)] + [InlineData(1.0, PrefixType.None, PrefixType.Deca, 0, 10.0)] + [InlineData(1.0, PrefixType.None, PrefixType.Hecto, 0, 100.0)] + [InlineData(1000000.0, PrefixType.None, PrefixType.Micro, 0, 1.0)] + [InlineData(1000000000.0, PrefixType.None, PrefixType.Nano, 0, 1.0)] public void Convert( double expected, PrefixType prefixTypeFrom, diff --git a/test/Atc.Tests/XUnitTestData/TestMemberDataForTimeSpanExtensions.cs b/test/Atc.Tests/XUnitTestData/TestMemberDataForTimeSpanExtensions.cs index 811c0878..d568e2af 100644 --- a/test/Atc.Tests/XUnitTestData/TestMemberDataForTimeSpanExtensions.cs +++ b/test/Atc.Tests/XUnitTestData/TestMemberDataForTimeSpanExtensions.cs @@ -28,6 +28,21 @@ public static TheoryData GetPrettyTime() { "15,000 ms", new TimeSpan(0, 0, 0, 0, 15), GlobalizationLcidConstants.Germany }, }; + public static TheoryData GetPrettyTimeUi() + => new() + { + { "11,509 dage", new TimeSpan(11, 12, 13, 14, 15), GlobalizationLcidConstants.Denmark, GlobalizationLcidConstants.UnitedStates }, + { "12,221 timer", new TimeSpan(0, 12, 13, 14, 15), GlobalizationLcidConstants.Denmark, GlobalizationLcidConstants.UnitedStates }, + { "13,234 min", new TimeSpan(0, 0, 13, 14, 15), GlobalizationLcidConstants.Denmark, GlobalizationLcidConstants.UnitedStates }, + { "14,015 sek", new TimeSpan(0, 0, 0, 14, 15), GlobalizationLcidConstants.Denmark, GlobalizationLcidConstants.UnitedStates }, + { "15,000 ms", new TimeSpan(0, 0, 0, 0, 15), GlobalizationLcidConstants.Denmark, GlobalizationLcidConstants.UnitedStates }, + { "11.509 days", new TimeSpan(11, 12, 13, 14, 15), GlobalizationLcidConstants.UnitedStates, GlobalizationLcidConstants.Denmark }, + { "12.221 hours", new TimeSpan(0, 12, 13, 14, 15), GlobalizationLcidConstants.UnitedStates, GlobalizationLcidConstants.Denmark }, + { "13.234 min", new TimeSpan(0, 0, 13, 14, 15), GlobalizationLcidConstants.UnitedStates, GlobalizationLcidConstants.Denmark }, + { "14.015 sec", new TimeSpan(0, 0, 0, 14, 15), GlobalizationLcidConstants.UnitedStates, GlobalizationLcidConstants.Denmark }, + { "15.000 ms", new TimeSpan(0, 0, 0, 0, 15), GlobalizationLcidConstants.UnitedStates, GlobalizationLcidConstants.Denmark }, + }; + public static TheoryData GetPrettyTimeWithDecimalPrecision() => new() { diff --git a/test/Atc.XUnit.Tests/Atc.XUnit.Tests.csproj b/test/Atc.XUnit.Tests/Atc.XUnit.Tests.csproj index f7626ef9..8fe6381d 100644 --- a/test/Atc.XUnit.Tests/Atc.XUnit.Tests.csproj +++ b/test/Atc.XUnit.Tests/Atc.XUnit.Tests.csproj @@ -7,7 +7,7 @@ - + diff --git a/test/Directory.Build.props b/test/Directory.Build.props index 81bce418..58992e32 100644 --- a/test/Directory.Build.props +++ b/test/Directory.Build.props @@ -19,9 +19,9 @@ - - - + + + all