diff --git a/.editorconfig b/.editorconfig index 9a5251c9f..d595bfc2c 100644 --- a/.editorconfig +++ b/.editorconfig @@ -1,3 +1,16 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true + +# Default: prefer spaces for data/markup +indent_style = space +indent_size = 2 +tab_width = 2 + # This style rule concern the use of the range operator, which is available in C# 8.0 and later. # https://docs.microsoft.com/en-us/dotnet/fundamentals/code-analysis/style-rules/ide0057 [*.{cs,vb}] @@ -42,6 +55,19 @@ dotnet_diagnostic.IDE0008.severity = none indent_style = space indent_size = 4 +# IDE0161: Namespace declaration preferences +# https://learn.microsoft.com/en-us/dotnet/fundamentals/code-analysis/style-rules/ide0161 +# Prefer file-scoped namespaces for new files; existing block-scoped files should not be converted unless explicitly asked +[*.cs] +csharp_style_namespace_declarations = file_scoped:suggestion + +# Top-level statements: DO NOT USE +# https://learn.microsoft.com/en-us/dotnet/fundamentals/code-analysis/style-rules/ide0210-ide0211 +# This is enforced via a style preference set to error severity, not a language-level prohibition. +# Always use explicit class declarations with a proper namespace and Main method where applicable. +[*.cs] +csharp_style_prefer_top_level_statements = false:error + [*.xml] indent_style = space indent_size = 2 diff --git a/AGENTS.md b/AGENTS.md index 2c1c48a56..1f1598f73 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,123 +1,218 @@ # AGENTS.md -Purpose -- This file guides agentic coding tools working in this repo. -- Follow the repo rules first; do not invent commands or conventions. - -Repository Basics -- Workspace root: this file lives in repo root. -- Solution lives at repo root (see `.docfx/index.md`). -- Projects: - - `src/` = packages shipped to NuGet. - - `test/` = unit and functional tests. - - `tuning/` = BenchmarkDotNet benchmarks. - - `tooling/` = internal tools. - -Toolchain -- .NET targets: net10.0, net9.0, netstandard2.0 (source); tests also net48 on Windows. -- Use `dotnet` CLI; CI runs on Linux and Windows. - -Build Commands -- Build solution (all): `dotnet build -c Release` -- Build a project: `dotnet build src/Cuemon.Core/Cuemon.Core.csproj -c Release` -- Pack (if needed): `dotnet pack -c Release` - -Lint / Analyzers -- No separate lint command is defined. -- Code style is enforced during build (`EnforceCodeStyleInBuild=true`). -- For tests and benchmarks, analyzers are disabled (see `Directory.Build.props`). -- Prefer build to surface style issues: `dotnet build -c Release`. - -Test Commands -- Run all tests in a project: - - `dotnet test test/Cuemon.Core.Tests/Cuemon.Core.Tests.csproj -c Release` -- Run a single test (recommended): - - `dotnet test test/Cuemon.Core.Tests/Cuemon.Core.Tests.csproj -c Release --filter "FullyQualifiedName~DateSpanTest.Parse_ShouldGetOneMonthOfDifference_UsingIso8601String"` -- Run all tests under `test/`: - - `dotnet test -c Release test/` - -Integration Tests (SQL Server) -- CI runs `test/Cuemon.Data.SqlClient.Tests.csproj` with a SQL Server container. -- Expect a connection string env var: - - `CONNECTIONSTRINGS__ADVENTUREWORKS` (see CI workflow). -- Use docker compose if you need local parity. - -Benchmarks (BenchmarkDotNet) -- Benchmarks live under `tuning/` and are not unit tests. -- Use `tooling/bdn-runner` to run benchmarks when needed. - -Cursor Rules -- None found in `.cursor/rules/` or `.cursorrules`. - -Copilot Rules (must follow) -- Source: `.github/copilot-instructions.md`. -- Tests must inherit `Codebelt.Extensions.Xunit.Test` and use `using Xunit;`. -- Do NOT use `Xunit.Abstractions` or `using Xunit.Abstractions`. -- Test namespaces MUST match the SUT namespace (no `.Tests` suffix). -- Test class names end with `Test`. -- Do not use `InternalsVisibleTo`; test through public APIs. -- Benchmarks follow the benchmark prompt and naming rules. -- XML doc comments should match existing style for public/protected APIs. - -Code Style and Conventions - -General Principles (from `.docfx/index.md`) -- Follow Framework Design Guidelines and Microsoft Engineering Guidelines. -- Prefer SOLID, DRY, and separation of concerns. -- Do not duplicate code; apply the boy scout rule. +This file guides agentic coding tools working in this repo. +Follow the repo rules first; do not invent commands or conventions. + +## Repository Layout + +- Solution: `Cuemon.slnx` in repo root. +- `src/` — NuGet packages (shipped to nuget.org). +- `test/` — xUnit v3 unit and functional tests. +- `tuning/` — BenchmarkDotNet benchmarks. +- `tooling/` — internal CLI tools. +- `.nuget//` — per-package `README.md` and `PackageReleaseNotes.txt`. + +## Toolchain + +- .NET SDK with `LangVersion=latest`. +- Source TFMs: `net10.0;net9.0;netstandard2.0`. +- Test TFMs: `net10.0;net9.0` on Linux; adds `net48` on Windows. +- Benchmark TFMs: `net10.0;net9.0`. +- Central package management via `Directory.Packages.props` (`ManagePackageVersionsCentrally=true`). +- CI runs on Linux (ubuntu-24.04) and Windows (windows-2025), both X64 and ARM64. +- TFM compatibility is mandatory: proposals and code changes must work for all source TFMs. Do not assume `net9.0`/`net10.0` APIs exist in `netstandard2.0`; use conditional compilation (`#if NET9_0_OR_GREATER`) or compatible fallbacks where needed. + +## Build Commands + +``` +dotnet build -c Release # entire solution +dotnet build src/Cuemon.Core/Cuemon.Core.csproj -c Release # single project +dotnet pack -c Release # pack all NuGet packages +``` + +## Lint / Analyzers + +- No separate lint step; code style is enforced during build (`EnforceCodeStyleInBuild=true` for source projects). +- Analyzers are **disabled** for test and benchmark projects (`RunAnalyzers=false`, `AnalysisLevel=none`). +- Run `dotnet build -c Release` on source projects to surface style violations. + +## Test Commands + +``` +# all tests in one project +dotnet test test/Cuemon.Core.Tests/Cuemon.Core.Tests.csproj -c Release -Formatting -- Indentation: 4 spaces for `.cs` and `.vb` (`.editorconfig`). -- XML files: 2 spaces. -- Many modern style analyzers are disabled; keep existing style in files. +# single test (recommended when iterating) +dotnet test test/Cuemon.Core.Tests/Cuemon.Core.Tests.csproj -c Release \ + --filter "FullyQualifiedName~DateSpanTest.Parse_ShouldGetOneMonthOfDifference_UsingIso8601String" -Imports +# all tests +dotnet test -c Release test/ +``` + +### Integration Tests (SQL Server) + +- Project: `test/Cuemon.Data.SqlClient.Tests/Cuemon.Data.SqlClient.Tests.csproj`. +- Requires env var `CONNECTIONSTRINGS__ADVENTUREWORKS`. +- CI spins up SQL Server via `docker-compose.yml`; use the same locally. + +### Benchmarks + +- Live under `tuning/`; run with `tooling/bdn-runner`. +- Not unit tests; do not include in test runs. + +## Cursor / Copilot Rules + +- No Cursor rules (`.cursor/rules/` and `.cursorrules` are absent). +- Copilot rules live in `.github/copilot-instructions.md` — **must follow**. + +## Code Style and Conventions + +### General Principles +- Follow Framework Design Guidelines and Microsoft Engineering Guidelines. +- Adhere to SOLID, DRY, separation of concerns. +- Apply the boy scout rule; do not duplicate code. + +### Formatting +- 4 spaces for `.cs` / `.vb`; 2 spaces for `.xml` (`.editorconfig`). +- Keep existing style in files; many modern analyzers are explicitly disabled. + +### Namespace Style +- **Prefer file-scoped namespaces** (`namespace Cuemon.Foo;`) for new files. +- The current majority of the codebase uses **block-scoped namespaces** — do not convert existing files unless explicitly asked. +- When editing an existing file, follow whichever style that file already uses. +- **Never use top-level statements.** Always use explicit class declarations with a proper namespace. + +### Disabled Analyzers (key rules — do NOT introduce these patterns) + +| Rule | What it forces | Why disabled | +|------|---------------|--------------| +| IDE0066 | switch expressions | style consistency | +| IDE0063 | using declarations | style consistency | +| IDE0290 | primary constructors | style consistency | +| IDE0022 | expression-bodied methods | style consistency | +| IDE0300/0301/0028/0305 | collection expressions | netstandard2.0 compat | +| CA1846/1847/1865-1867 | Span/char overloads | netstandard2.0 compat | +| IDE0330 | `System.Threading.Lock` | requires net9.0+ | +| Performance category | various | netstandard2.0 compat | + +### Imports - Keep `using` directives explicit and minimal. -- Follow existing ordering in the file; do not auto-reorder unless needed. +- Follow existing ordering; do not auto-reorder. -Types and Language Features -- Avoid style rules that are explicitly disabled in `.editorconfig`: - - No forced switch expressions, using declarations, or primary constructors. - - Avoid forced collection expressions for arrays/empty/initializers. - - Avoid forced expression-bodied members. -- Use explicit types when it improves clarity; do not blindly enforce `var`. +### Types and `var` +- Do not blindly enforce `var`; use explicit types when it improves clarity. +- IDE0008 (use explicit type) is disabled — either form is acceptable. -Naming +### Naming - Public API naming follows .NET Framework Design Guidelines. -- Test class names end with `Test`; benchmark class names end with `Benchmark`. -- Namespaces for tests/benchmarks match the production namespace exactly. +- Test classes end with `Test`; benchmark classes end with `Benchmark`. +- Namespaces for tests and benchmarks **must match the production namespace exactly** (no `.Tests` / `.Benchmarks` suffix). Override `` in `.csproj`. + +### Error Handling +- Use guard clauses and `Validator.ThrowIfNull` patterns. +- Prefer deterministic, testable error paths; never swallow exceptions. -Error Handling -- Use guard clauses and `Validator.ThrowIfNull` style patterns where present. -- Prefer deterministic, testable error paths; avoid swallowing exceptions. +### Extension Methods +- Extension methods belong **only** in `Cuemon.Extensions.*` projects. +- Non-extension assemblies may expose similar APIs only behind the `IDecorator` interface. -Extension Methods -- Extension methods belong only in `Cuemon.Extensions.*` projects. -- Non-extension assemblies must hide extension-like APIs via `IDecorator`. +## Writing Tests -Tests (Unit / Functional) +- Test framework: **xUnit v3** (`xunit.v3` package). - Base class: `Test` from `Codebelt.Extensions.Xunit`. -- Use `[Fact]` for unit tests, `[Theory]` for parameterized tests. +- **Do NOT** use `Xunit.Abstractions` or `using Xunit.Abstractions` (removed in xUnit v3). +- Constructor signature: `public FooTest(ITestOutputHelper output) : base(output) { }`. +- Use `TestOutput.WriteLine(...)` for output, inherited from `Test`. +- Use `[Fact]` for unit tests, `[Theory]` with `[InlineData]` for parameterized tests. +- Assertions: xUnit `Assert.*` methods only. - Keep tests deterministic and isolated; prefer fakes/stubs/spies. -- Avoid mocking unless necessary; Moq allowed in special cases. -- Never mock `IMarshaller`; use `new JsonMarshaller()` instead. - -Benchmarks -- Namespace matches production namespace; no `.Benchmarks` suffix. -- Place under `tuning/` in matching benchmark project. -- Use `[MemoryDiagnoser]` and `[GroupBenchmarksBy]` where relevant. -- Use deterministic data and `GlobalSetup` for expensive prep. - -Docs / XML Comments -- Public and protected members should have XML doc comments. -- Follow existing wording and style; see `.github/copilot-instructions.md`. - -Release Notes -- Package release notes live under `.nuget//PackageReleaseNotes.txt`. -- Keep notes updated for public API changes. - -Suggested Workflow for Agents -- Identify correct project location (src/test/tuning/tooling). -- Follow namespace and naming rules before writing code. -- Build or run targeted tests when changing logic. -- Keep changes minimal and consistent with local style. +- Mocking (Moq) only under special circumstances; **never mock `IMarshaller`** — use `new JsonMarshaller()`. +- Do NOT use `InternalsVisibleTo`; test through public APIs (Public Facade Testing pattern). +- Assembly naming: `Cuemon.Foo.Tests` for unit tests, `Cuemon.Foo.FunctionalTests` for functional tests. + +### Test File Template + +```csharp +using Codebelt.Extensions.Xunit; +using Xunit; + +namespace Cuemon.Foo // matches SUT namespace exactly +{ + public class BarTest : Test + { + public BarTest(ITestOutputHelper output) : base(output) { } + + [Fact] + public void Method_ShouldExpectedBehavior_WhenCondition() + { + // Arrange / Act / Assert + } + } +} +``` + +## Writing Benchmarks + +- Place in `tuning/` in a `*.Benchmarks` project; namespace matches production (no `.Benchmarks` suffix). +- Use `[MemoryDiagnoser]`, `[GroupBenchmarksBy(BenchmarkLogicalGroupRule.ByCategory)]`. +- Use `[GlobalSetup]` for expensive prep; keep measured methods focused. +- Use `[Params]` for multiple input sizes; use deterministic data; avoid external systems. +- Mark one method `Baseline = true`; use descriptive `Description` values. + +## XML Documentation + +- All public and protected members should have XML doc comments. +- Follow existing wording and style in the codebase. +- See `.github/copilot-instructions.md` for detailed examples. + +## Release Notes + +- Per-package notes in `.nuget//PackageReleaseNotes.txt`. +- Keep updated for public API changes. + +## Commit Style (Gitmoji) + +This repo uses **gitmoji** commit messages — do **not** use Conventional Commits (`feat:`, `fix:`, etc.). + +Format: ` ` + +**Always use the actual Unicode emoji character**, not the GitHub shortcode (e.g., use `✨` not `:sparkles:`). + +Example: `✨ Add DateSpan.TryParse overload` + +### Common Gitmojis + +| Emoji | Use for | +|-------|---------| +| ✨ | New feature | +| 🐛 | Bug fix | +| ♻️ | Refactoring | +| ✅ | Adding / updating unit test / functional test | +| 📝 | Documentation | +| ⚡ | Performance improvement | +| 🎨 | Code style / formatting | +| 🔥 | Removing code or files | +| 🚧 | Work in progress | +| 📦 | Package / dependency update | +| 🔧 | Configuration / tooling | +| 🚚 | Moving / renaming files | +| 💥 | Breaking change | +| 🩹 | Non-critical fix | + +### Rules + +1. **One emoji per commit** — each commit has exactly one primary gitmoji. +2. **Be specific** — choose the most appropriate emoji, not a generic one. +3. **Consistent scope** — use consistent scope names across commits. +4. **Clear messages** — the subject line should be understandable without a body. +5. **Atomic commits** — each commit should be independently buildable and testable. + +## Agent Workflow + +1. Identify the correct project area (`src/`, `test/`, `tuning/`, `tooling/`). +2. Follow namespace and naming rules **before** writing any code. +3. Before potentially refactoring any code, verify the code in question is well tested; if coverage is missing, add or update tests first to reduce regression risk. +4. Build the affected source project to check for style violations. +5. Run targeted tests when changing logic. +6. Keep changes minimal and consistent with existing local style. diff --git a/src/Cuemon.Core/Extensions/Collections/Generic/StackDecoratorExtensions.cs b/src/Cuemon.Core/Extensions/Collections/Generic/StackDecoratorExtensions.cs new file mode 100644 index 000000000..3aa1b85c4 --- /dev/null +++ b/src/Cuemon.Core/Extensions/Collections/Generic/StackDecoratorExtensions.cs @@ -0,0 +1,34 @@ +#if NETSTANDARD2_0_OR_GREATER +using System.Collections.Generic; + +namespace Cuemon.Collections.Generic +{ + /// + /// Extension methods for the class hidden behind the interface. + /// + /// + /// + public static class StackDecoratorExtensions + { + /// + /// Returns a value that indicates whether there is an object at the top of the enclosed of the , and if one is present, copies it to the result parameter, and removes it from the enclosed of the . + /// + /// Specifies the type of elements in the stack. + /// The to extend. + /// If present, the object at the top of the enclosed ; otherwise, the default value of . + /// true if there is an object at the top of the enclosed ; false if the enclosed is empty. + public static bool TryPop(this IDecorator> decorator, out T result) + { + Validator.ThrowIfNull(decorator); + var stack = decorator.Inner; + if (stack.Count > 0) + { + result = stack.Pop(); + return true; + } + result = default; + return false; + } + } +} +#endif diff --git a/src/Cuemon.Core/Patterns.cs b/src/Cuemon.Core/Patterns.cs index bb32c344a..3c0041187 100644 --- a/src/Cuemon.Core/Patterns.cs +++ b/src/Cuemon.Core/Patterns.cs @@ -1,6 +1,7 @@ -using System; +using System; using System.Linq; using System.Runtime.InteropServices; +using System.Threading; using Cuemon.Configuration; namespace Cuemon @@ -18,6 +19,36 @@ public sealed class Patterns /// The singleton instance of the Patterns functionality. public static Patterns Use { get; } = ExtendedPatterns; + /// + /// Determines whether the specified is considered fatal and should not be swallowed. + /// + /// The exception to evaluate. + /// true if is a fatal runtime exception such as , , , , or ; otherwise, false. + /// Designed for use as an exception filter: catch (Exception ex) when (!Patterns.IsFatalException(ex)) to prevent accidentally swallowing critical runtime exceptions. + public static bool IsFatalException(Exception exception) + { +#pragma warning disable CS0618 // ExecutionEngineException is obsolete + return exception is OutOfMemoryException + || exception is StackOverflowException + || exception is SEHException + || exception is AccessViolationException + || exception is ThreadAbortException + || exception is ThreadInterruptedException + || exception is ExecutionEngineException; +#pragma warning restore CS0618 + } + + /// + /// Determines whether the specified is considered recoverable and can be safely caught. + /// + /// The exception to evaluate. + /// true if is not considered fatal; otherwise, false. + /// Designed for use as an exception filter: catch (Exception ex) when (Patterns.IsRecoverableException(ex)) to avoid double-negation. + public static bool IsRecoverableException(Exception exception) + { + return !IsFatalException(exception); + } + /// /// Returns a value that indicates whether the specified can be invoked without an exception. /// @@ -32,18 +63,8 @@ public static bool TryInvoke(Action method) method(); return true; } - catch (Exception ex) + catch (Exception ex) when (IsRecoverableException(ex)) { - if (ex is OutOfMemoryException || - ex is StackOverflowException || - ex is SEHException || - ex is AccessViolationException || -#pragma warning disable CS0618 // Type or member is obsolete - ex is ExecutionEngineException) // fatal exceptions; re-throw for .NET "legacy" (.NET Core will handle these by a high-level catch-all handler) -#pragma warning restore CS0618 // Type or member is obsolete - { - throw; - } return false; } } @@ -64,18 +85,8 @@ public static bool TryInvoke(Func method, out TResult result) result = method(); return true; } - catch (Exception ex) + catch (Exception ex) when (!IsFatalException(ex)) { - if (ex is OutOfMemoryException || - ex is StackOverflowException || - ex is SEHException || - ex is AccessViolationException || -#pragma warning disable CS0618 // Type or member is obsolete - ex is ExecutionEngineException) // fatal exceptions; re-throw for .NET "legacy" (.NET Core will handle these by a high-level catch-all handler) -#pragma warning restore CS0618 // Type or member is obsolete - { - throw; - } result = default; return false; } diff --git a/src/Cuemon.Core/Reflection/AssemblyContext.cs b/src/Cuemon.Core/Reflection/AssemblyContext.cs new file mode 100644 index 000000000..af4588311 --- /dev/null +++ b/src/Cuemon.Core/Reflection/AssemblyContext.cs @@ -0,0 +1,76 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using Cuemon.Collections.Generic; + +namespace Cuemon.Reflection; + +/// +/// Provides a set of static methods and properties to manage and filter assemblies in the current application domain. +/// +public static class AssemblyContext +{ + /// + /// Gets the qualified assemblies from the current application domain. + /// + /// The which may be configured. + /// A read-only list of instances that match the filter criteria defined in . + /// + /// failed to configure an instance of in a valid state. + /// + public static IReadOnlyList GetCurrentDomainAssemblies(Action setup = null) + { + Validator.ThrowIfInvalidConfigurator(setup, out var options); + return AppDomain + .CurrentDomain + .GetAssemblies() + .Where(options.AssemblyFilter) + .SelectMany(options.IncludeReferencedAssemblies + ? a => GetReferencedAssemblies(a, options.ReferencedAssemblyFilter) + : (Func>)(a => new[] { a })) + .Distinct() + .Except(options.ExcludedAssemblies) + .ToList() + .AsReadOnly(); + } + + /// + /// Recursively enumerates an and all of its referenced assemblies that satisfy the . + /// + /// The root from which to start traversal. + /// A predicate used to filter which references are followed during traversal. + /// An of instances reachable from that pass the . + private static IEnumerable GetReferencedAssemblies(Assembly assembly, Func assemblyReferenceFilter) + { + var stack = new Stack(); + var guard = new HashSet(); + + yield return assembly; + + stack.Push(assembly); + guard.Add(assembly.FullName); + + while (TryPop(stack, out var assemblyToTraverse)) + { + foreach (var assemblyName in assemblyToTraverse.GetReferencedAssemblies().Where(assemblyReferenceFilter)) + { + if (!guard.Add(assemblyName.FullName)) { continue; } + if (Patterns.TryInvoke(() => Assembly.Load(assemblyName), out var referencedAssembly) && referencedAssembly != null) + { + stack.Push(referencedAssembly); + yield return referencedAssembly; + } + } + } + } + + private static bool TryPop(Stack stack, out Assembly assembly) + { +#if NET9_0_OR_GREATER + return stack.TryPop(out assembly); +#else + return Decorator.RawEnclose(stack).TryPop(out assembly); +#endif + } +} diff --git a/src/Cuemon.Core/Reflection/AssemblyContextOptions.cs b/src/Cuemon.Core/Reflection/AssemblyContextOptions.cs new file mode 100644 index 000000000..f06c62de5 --- /dev/null +++ b/src/Cuemon.Core/Reflection/AssemblyContextOptions.cs @@ -0,0 +1,180 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Reflection; +using System.Linq; +using Cuemon.Configuration; + +namespace Cuemon.Reflection; + +/// +/// Provides configuration options for the class. +/// +/// +public class AssemblyContextOptions : IValidatableParameterObject +{ + private const string SystemPrefix = nameof(System); + private const string MicrosoftPrefix = nameof(Microsoft); + private static readonly ConcurrentDictionary FrameworkNamespaceCache = new(); + + /// + /// Initializes a new instance of the class. + /// + /// + /// The following table shows the initial property values for an instance of . + /// + /// + /// Property + /// Initial Value + /// + /// + /// + /// true + /// + /// + /// + /// The default implementation excludes assemblies whose full name starts with System, Microsoft or have a root namespace of System or Microsoft + /// + /// + /// + /// The default implementation excludes assemblies whose full name starts with System or Microsoft + /// + /// + /// + /// The default implementation excludes this assembly, e.g., Cuemon.Core + /// + /// + /// + public AssemblyContextOptions() + { + AssemblyFilter = DefaultAssemblyFilter; + ReferencedAssemblyFilter = DefaultReferencedAssemblyFilter; + ExcludedAssemblies = new List() + { + typeof(AssemblyContextOptions).Assembly + }; + } + + private static Func DefaultAssemblyFilter { get; } = assembly => + { + var fullName = assembly?.FullName; + if (string.IsNullOrEmpty(fullName)) { return false; } + + if (fullName.StartsWith(SystemPrefix, StringComparison.Ordinal) || + fullName.StartsWith(MicrosoftPrefix, StringComparison.Ordinal)) + { + return false; + } + + return !HasFrameworkRootNamespace(assembly); + }; + + private static Func DefaultReferencedAssemblyFilter { get; } = assemblyName => + { + var fullName = assemblyName?.FullName; + return !string.IsNullOrEmpty(fullName) && + !fullName.StartsWith(SystemPrefix, StringComparison.Ordinal) && + !fullName.StartsWith(MicrosoftPrefix, StringComparison.Ordinal); + }; + + private static bool HasFrameworkRootNamespace(IEnumerable types) + { + if (types == null) { return false; } + + return types + .Select(t => t?.Namespace) + .Where(ns => !string.IsNullOrEmpty(ns)) + .Select(ns => + { + var dot = ns.IndexOf('.'); + return dot < 0 ? ns : ns.Substring(0, dot); + }) + .Any(root => root == SystemPrefix || root == MicrosoftPrefix); + } + + private static bool HasFrameworkRootNamespace(Assembly assembly) + { + var key = assembly.FullName; + if (string.IsNullOrEmpty(key)) { return false; } + + return FrameworkNamespaceCache.GetOrAdd(key, _ => + { + try + { + if (HasFrameworkRootNamespace(assembly.GetExportedTypes())) + { + return true; + } + } + catch (ReflectionTypeLoadException ex) + { + if (HasFrameworkRootNamespace(ex.Types)) + { + return true; + } + } + catch (Exception ex) when (Patterns.IsRecoverableException(ex)) + { + return false; + } + return false; + }); + } + + /// + /// Gets or sets the collection of assemblies that are unconditionally excluded from the result of . + /// + /// A mutable of instances that will never appear in the resolved output, regardless of the or predicates. + /// + /// By default this collection contains the assembly that defines itself (i.e., Cuemon.Core), + /// preventing internal infrastructure assemblies from leaking into consumer results. + /// Add additional assemblies to this collection when callers must suppress specific entries from the resolved set. + /// + public ICollection ExcludedAssemblies { get; set; } + + /// + /// Gets or sets a value indicating whether referenced assemblies should be included when resolving assembly context. + /// + /// true if referenced assemblies should be included; otherwise, false. + /// When set to true, the assembly context will include assemblies that are referenced by the assemblies in the current application domain. When set to false, only the assemblies in the current application domain will be included. + public bool IncludeReferencedAssemblies { get; set; } = true; + + /// + /// Gets or sets a predicate used to filter which assemblies are included during assembly context resolution. + /// + /// A that receives an and returns true if the assembly should be included; otherwise, false. + /// + /// This filter is applied to assemblies in the current application domain. + /// The default predicate excludes any assembly whose starts with System or Microsoft, + /// and additionally excludes any assembly whose exported types are rooted in the System or Microsoft namespace, + /// limiting resolution to application-level dependencies. Results of the type-scan are cached per assembly identity to avoid + /// repeated enumeration on subsequent calls. + /// + public Func AssemblyFilter { get; set; } + + /// + /// Gets or sets a predicate used to filter which referenced assemblies are included during assembly context resolution. + /// + /// A that receives an and returns true if the assembly should be included; otherwise, false. + /// + /// This filter is only applied when is true. + /// The default predicate excludes any assembly whose starts with System or Microsoft, + /// limiting resolution to application-level dependencies. + /// + public Func ReferencedAssemblyFilter { get; set; } + + /// + /// Determines whether the public read-write properties of this instance are in a valid state. + /// + /// + /// cannot be null - or - + /// cannot be null - or - + /// cannot be null. + /// + public void ValidateOptions() + { + Validator.ThrowIfInvalidState(AssemblyFilter is null); + Validator.ThrowIfInvalidState(ExcludedAssemblies is null); + Validator.ThrowIfInvalidState(ReferencedAssemblyFilter is null); + } +} diff --git a/src/Cuemon.Extensions.Collections.Generic/StackExtensions.cs b/src/Cuemon.Extensions.Collections.Generic/StackExtensions.cs new file mode 100644 index 000000000..ba1a4d30d --- /dev/null +++ b/src/Cuemon.Extensions.Collections.Generic/StackExtensions.cs @@ -0,0 +1,25 @@ +#if NETSTANDARD2_0_OR_GREATER +using System.Collections.Generic; +using Cuemon.Collections.Generic; + +namespace Cuemon.Extensions.Collections.Generic +{ + /// + /// Extension methods for the class. + /// + public static class StackExtensions + { + /// + /// Returns a value that indicates whether there is an object at the top of the , and if one is present, copies it to the result parameter, and removes it from the . + /// + /// Specifies the type of elements in the stack. + /// The to extend. + /// If present, the object at the top of the ; otherwise, the default value of . + /// true if there is an object at the top of the ; false if the is empty. + public static bool TryPop(this Stack stack, out T result) + { + return Decorator.EncloseToExpose(stack).TryPop(out result); + } + } +} +#endif diff --git a/test/Cuemon.Core.Tests/PatternsTest.cs b/test/Cuemon.Core.Tests/PatternsTest.cs index 4da51f77c..bcf4b3b06 100644 --- a/test/Cuemon.Core.Tests/PatternsTest.cs +++ b/test/Cuemon.Core.Tests/PatternsTest.cs @@ -1,4 +1,7 @@ -using System; +using System; +using System.IO; +using System.Runtime.InteropServices; +using System.Text; using Codebelt.Extensions.Xunit; using Cuemon.Text; using Cuemon.Threading; @@ -41,5 +44,161 @@ public void ConfigureExchange_ShouldSwapOptions_VerifyDefaultValues() Assert.Equal(o1.Encoding, o2.Encoding); Assert.Equal(o1.Preamble, o2.Preamble); } + + [Fact] + public void ConfigureRevert_ShouldReturnDelegateThatProducesEquivalentOptions() + { + var original = Patterns.Configure(o => o.Encoding = Encoding.UTF32); + var revertDelegate = Patterns.ConfigureRevert(original); + var reverted = Patterns.Configure(revertDelegate); + + Assert.Equal(original.Encoding, reverted.Encoding); + Assert.Equal(original.Preamble, reverted.Preamble); + } + + [Fact] + public void ConfigureRevertExchange_ShouldExchangeAndRevertOptions() + { + var original = Patterns.Configure(o => o.Encoding = Encoding.UTF32); + var exchangeDelegate = Patterns.ConfigureRevertExchange(original); + var result = Patterns.Configure(exchangeDelegate); + + Assert.Equal(Encoding.UTF32, result.Encoding); + Assert.Equal(original.Preamble, result.Preamble); + } + + [Fact] + public void CreateInstance_ShouldInitializeWithFactory() + { + var sut = Patterns.CreateInstance(o => o.Encoding = Encoding.UTF32); + + Assert.NotNull(sut); + Assert.Equal(Encoding.UTF32, sut.Encoding); + } + + [Fact] + public void CreateInstance_ShouldCreateDefaultInstance_WhenFactoryIsNull() + { + var defaults = new AsyncEncodingOptions(); + var sut = Patterns.CreateInstance(null); + + Assert.NotNull(sut); + Assert.Equal(defaults.Encoding, sut.Encoding); + Assert.Equal(defaults.Preamble, sut.Preamble); + } + + [Fact] + public void TryInvoke_ShouldReturnTrue_WhenActionSucceeds() + { + var invoked = false; + + var result = Patterns.TryInvoke(() => { invoked = true; }); + + Assert.True(result); + Assert.True(invoked); + } + + [Fact] + public void TryInvoke_ShouldReturnFalse_WhenActionThrows() + { + var result = Patterns.TryInvoke(() => throw new InvalidOperationException()); + + Assert.False(result); + } + + [Fact] + public void TryInvoke_ShouldReturnTrueAndResult_WhenFuncSucceeds() + { + var result = Patterns.TryInvoke(() => 42, out var value); + + Assert.True(result); + Assert.Equal(42, value); + } + + [Fact] + public void TryInvoke_ShouldReturnFalseAndDefault_WhenFuncThrows() + { + var result = Patterns.TryInvoke(() => throw new InvalidOperationException(), out var value); + + Assert.False(result); + Assert.Equal(default, value); + } + + [Fact] + public void InvokeOrDefault_ShouldReturnResult_WhenMethodSucceeds() + { + var result = Patterns.InvokeOrDefault(() => 42); + + Assert.Equal(42, result); + } + + [Fact] + public void InvokeOrDefault_ShouldReturnFallback_WhenMethodThrows() + { + var result = Patterns.InvokeOrDefault(() => throw new InvalidOperationException(), -1); + + Assert.Equal(-1, result); + } + + [Fact] + public void IsFatalException_ShouldReturnTrue_WhenExceptionIsFatal() + { + Assert.True(Patterns.IsFatalException(new OutOfMemoryException())); + Assert.True(Patterns.IsFatalException(new StackOverflowException())); + Assert.True(Patterns.IsFatalException(new AccessViolationException())); + Assert.True(Patterns.IsFatalException(new SEHException())); + } + + [Fact] + public void IsFatalException_ShouldReturnFalse_WhenExceptionIsNotFatal() + { + Assert.False(Patterns.IsFatalException(new InvalidOperationException())); + Assert.False(Patterns.IsFatalException(new ArgumentNullException())); + Assert.False(Patterns.IsFatalException(new NotSupportedException())); + } + + [Fact] + public void IsRecoverableException_ShouldReturnTrue_WhenExceptionIsNotFatal() + { + Assert.True(Patterns.IsRecoverableException(new InvalidOperationException())); + Assert.True(Patterns.IsRecoverableException(new ArgumentNullException())); + Assert.True(Patterns.IsRecoverableException(new NotSupportedException())); + } + + [Fact] + public void IsRecoverableException_ShouldReturnFalse_WhenExceptionIsFatal() + { + Assert.False(Patterns.IsRecoverableException(new OutOfMemoryException())); + Assert.False(Patterns.IsRecoverableException(new StackOverflowException())); + Assert.False(Patterns.IsRecoverableException(new AccessViolationException())); + Assert.False(Patterns.IsRecoverableException(new SEHException())); + } + + [Fact] + public void SafeInvoke_ShouldReturnResult_WhenTesterSucceeds() + { + using var result = Patterns.SafeInvoke( + () => new MemoryStream(new byte[] { 1, 2, 3 }), + ms => ms); + + Assert.NotNull(result); + Assert.Equal(3, result.Length); + } + + [Fact] + public void SafeInvoke_ShouldReturnNull_AndInvokeCatcher_WhenTesterThrows() + { + Exception caught = null; + + var result = Patterns.SafeInvoke( + () => new MemoryStream(), + _ => throw new InvalidOperationException("tester failure"), + ex => caught = ex); + + Assert.Null(result); + Assert.NotNull(caught); + Assert.IsType(caught); + Assert.Equal("tester failure", caught.Message); + } } -} \ No newline at end of file +} diff --git a/test/Cuemon.Core.Tests/Reflection/AssemblyContextOptionsTest.cs b/test/Cuemon.Core.Tests/Reflection/AssemblyContextOptionsTest.cs new file mode 100644 index 000000000..4ec04f5ec --- /dev/null +++ b/test/Cuemon.Core.Tests/Reflection/AssemblyContextOptionsTest.cs @@ -0,0 +1,166 @@ +using System; +using System.Linq; +using System.Reflection; +using Codebelt.Extensions.Xunit; +using Xunit; + +namespace Cuemon.Reflection +{ + public class AssemblyContextOptionsTest : Test + { + public AssemblyContextOptionsTest(ITestOutputHelper output) : base(output) + { + } + + [Fact] + public void AssemblyContextOptions_ShouldHaveDefaultValues() + { + var sut = new AssemblyContextOptions(); + + Assert.True(sut.IncludeReferencedAssemblies); + Assert.NotNull(sut.AssemblyFilter); + Assert.NotNull(sut.ReferencedAssemblyFilter); + Assert.NotNull(sut.ExcludedAssemblies); + Assert.Contains(typeof(AssemblyContextOptions).Assembly, sut.ExcludedAssemblies); + } + + [Fact] + public void ValidateOptions_ShouldThrowInvalidOperationException_WhenAssemblyFilterIsNull() + { + var sut1 = new AssemblyContextOptions() + { + AssemblyFilter = null + }; + + var sut2 = Assert.Throws(() => sut1.ValidateOptions()); + var sut3 = Assert.Throws(() => Validator.ThrowIfInvalidOptions(sut1)); + + Assert.Equal("Operation is not valid due to the current state of the object. (Expression 'AssemblyFilter is null')", sut2.Message); + Assert.StartsWith("AssemblyContextOptions are not in a valid state.", sut3.Message); + Assert.Contains("sut1", sut3.Message); + Assert.IsType(sut3.InnerException); + } + + [Fact] + public void ValidateOptions_ShouldThrowInvalidOperationException_WhenExcludedAssembliesIsNull() + { + var sut1 = new AssemblyContextOptions() + { + ExcludedAssemblies = null + }; + + var sut2 = Assert.Throws(() => sut1.ValidateOptions()); + var sut3 = Assert.Throws(() => Validator.ThrowIfInvalidOptions(sut1)); + + Assert.Equal("Operation is not valid due to the current state of the object. (Expression 'ExcludedAssemblies is null')", sut2.Message); + Assert.StartsWith("AssemblyContextOptions are not in a valid state.", sut3.Message); + Assert.Contains("sut1", sut3.Message); + Assert.IsType(sut3.InnerException); + } + + [Fact] + public void ValidateOptions_ShouldThrowInvalidOperationException_WhenReferencedAssemblyFilterIsNull() + { + var sut1 = new AssemblyContextOptions() + { + ReferencedAssemblyFilter = null + }; + + var sut2 = Assert.Throws(() => sut1.ValidateOptions()); + var sut3 = Assert.Throws(() => Validator.ThrowIfInvalidOptions(sut1)); + + Assert.Equal("Operation is not valid due to the current state of the object. (Expression 'ReferencedAssemblyFilter is null')", sut2.Message); + Assert.StartsWith("AssemblyContextOptions are not in a valid state.", sut3.Message); + Assert.Contains("sut1", sut3.Message); + Assert.IsType(sut3.InnerException); + } + + [Fact] + public void DefaultAssemblyFilter_ShouldExcludeSystemAssemblies() + { + var sut = new AssemblyContextOptions(); + var systemAssembly = typeof(UriKind).Assembly; // System.dll + var corlibAssembly = typeof(string).Assembly; // mscorlib + + TestOutput.WriteLine(systemAssembly.FullName); + TestOutput.WriteLine(corlibAssembly.FullName); + + Assert.False(sut.AssemblyFilter(systemAssembly)); + Assert.False(sut.AssemblyFilter(corlibAssembly)); + } + + [Fact] + public void DefaultAssemblyFilter_ShouldExcludeMicrosoftAssemblies() + { + var sut = new AssemblyContextOptions(); + var microsoftAssembly = AppDomain.CurrentDomain.GetAssemblies() + .First(a => a.FullName.StartsWith("Microsoft.", StringComparison.Ordinal)); + + Assert.False(sut.AssemblyFilter(microsoftAssembly)); + + TestOutput.WriteLine(microsoftAssembly.FullName); + } + + [Fact] + public void DefaultAssemblyFilter_ShouldReturnSameResult_WhenCalledMultipleTimes() + { + var sut = new AssemblyContextOptions(); + var assembly = typeof(AssemblyContextOptions).Assembly; + + var first = sut.AssemblyFilter(assembly); + var second = sut.AssemblyFilter(assembly); + var third = sut.AssemblyFilter(assembly); + + TestOutput.WriteLine($"Results: {first}, {second}, {third}"); + + Assert.Equal(first, second); + Assert.Equal(second, third); + } + +#if NET9_0_OR_GREATER + [Fact] + public void DefaultAssemblyFilter_ShouldIncludeNonSystemNonMicrosoftAssemblies() + { + var sut = new AssemblyContextOptions(); + var cuemonAssembly = typeof(AssemblyContextOptions).Assembly; // Cuemon.Core + + TestOutput.WriteLine(cuemonAssembly.FullName); + + Assert.True(sut.AssemblyFilter(cuemonAssembly)); + } +#endif + + [Theory] + [InlineData("System.Runtime")] + [InlineData("System.Collections")] + public void DefaultReferencedAssemblyFilter_ShouldExcludeSystemAssemblyNames(string name) + { + var sut = new AssemblyContextOptions(); + var assemblyName = new AssemblyName(name); + + Assert.False(sut.ReferencedAssemblyFilter(assemblyName)); + } + + [Theory] + [InlineData("Microsoft.Extensions.Logging")] + [InlineData("Microsoft.AspNetCore.Http")] + public void DefaultReferencedAssemblyFilter_ShouldExcludeMicrosoftAssemblyNames(string name) + { + var sut = new AssemblyContextOptions(); + var assemblyName = new AssemblyName(name); + + Assert.False(sut.ReferencedAssemblyFilter(assemblyName)); + } + + [Theory] + [InlineData("Cuemon.Core")] + [InlineData("Cuemon.Extensions.Core")] + public void DefaultReferencedAssemblyFilter_ShouldIncludeNonSystemNonMicrosoftAssemblyNames(string name) + { + var sut = new AssemblyContextOptions(); + var assemblyName = new AssemblyName(name); + + Assert.True(sut.ReferencedAssemblyFilter(assemblyName)); + } + } +} diff --git a/test/Cuemon.Core.Tests/Reflection/AssemblyContextTest.cs b/test/Cuemon.Core.Tests/Reflection/AssemblyContextTest.cs new file mode 100644 index 000000000..6cb1caec3 --- /dev/null +++ b/test/Cuemon.Core.Tests/Reflection/AssemblyContextTest.cs @@ -0,0 +1,131 @@ +using Codebelt.Extensions.Xunit; +using Cuemon.Collections.Generic; +using System; +using System.Linq; +using Xunit; + +namespace Cuemon.Reflection +{ + public class AssemblyContextTest : Test + { + public AssemblyContextTest(ITestOutputHelper output) : base(output) + { + } + + [Fact] + public void GetCurrentDomainAssemblies_ShouldReturnNonEmptyList_WithDefaultOptions() + { + var result = AssemblyContext.GetCurrentDomainAssemblies(); + + Assert.NotNull(result); + Assert.NotEmpty(result); + + TestOutput.WriteLine($"Total assemblies returned: {result.Count}"); + foreach (var assembly in result.Take(5)) + { + TestOutput.WriteLine(assembly.GetName().Name); + } + } + + [Fact] + public void GetCurrentDomainAssemblies_ShouldExcludeCuemonCoreAssembly() + { + var cuemonCore = typeof(AssemblyContext).Assembly; + + var result = AssemblyContext.GetCurrentDomainAssemblies(); + + TestOutput.WriteLine($"Excluded assembly: {cuemonCore.GetName().Name}"); + + Assert.DoesNotContain(cuemonCore, result); + } + + [Fact] + public void GetCurrentDomainAssemblies_ShouldContainTestAssembly_WithDefaultOptions() + { + var testAssembly = GetType().Assembly; + + var result = AssemblyContext.GetCurrentDomainAssemblies(); + + TestOutput.WriteLine($"Test assembly: {testAssembly.GetName().Name}"); + + Assert.Contains(testAssembly, result); + } + + [Fact] + public void GetCurrentDomainAssemblies_ShouldReturnDistinctAssemblies() + { + var result = AssemblyContext.GetCurrentDomainAssemblies(); + + TestOutput.WriteLine($"Total: {result.Count}, distinct: {result.Distinct().Count()}"); + + Assert.Equal(result.Count, result.Distinct().Count()); + } + + [Fact] + public void GetCurrentDomainAssemblies_ShouldThrowArgumentException_WhenSetupIsInvalid() + { + var result = Assert.Throws(() => + AssemblyContext.GetCurrentDomainAssemblies(o => o.AssemblyFilter = null)); + + Assert.StartsWith("Delegate must configure the public read-write properties to be in a valid state.", result.Message); + Assert.Contains("setup", result.Message); + Assert.IsType(result.InnerException); + } + + [Fact] + public void GetCurrentDomainAssemblies_ShouldOnlyReturnDomainAssemblies_WhenReferencedAssembliesNotIncluded() + { + var domainSnapshot = AppDomain.CurrentDomain.GetAssemblies(); + + var result = AssemblyContext.GetCurrentDomainAssemblies(o => + { + o.AssemblyFilter = _ => true; + o.IncludeReferencedAssemblies = false; + }); + + TestOutput.WriteLine($"Domain assemblies: {domainSnapshot.Length}, returned: {result.Count}"); + + Assert.All(result, assembly => Assert.Contains(assembly, domainSnapshot)); + } + + [Fact] + public void GetCurrentDomainAssemblies_ShouldRespectCustomAssemblyFilter_WhenPermissive() + { + var defaultResult = AssemblyContext.GetCurrentDomainAssemblies(o => o.IncludeReferencedAssemblies = false); + var permissiveResult = AssemblyContext.GetCurrentDomainAssemblies(o => + { + o.AssemblyFilter = _ => true; + o.IncludeReferencedAssemblies = false; + }); + + TestOutput.WriteLine($"Default filter count: {defaultResult.Count}, permissive filter count: {permissiveResult.Count}"); + + Assert.True(permissiveResult.Count > defaultResult.Count); + } + + [Fact] + public void GetCurrentDomainAssemblies_ShouldReturnAtLeastAsManyAssemblies_WhenReferencedAssembliesIncluded() + { + var withRefs = AssemblyContext.GetCurrentDomainAssemblies(o => o.IncludeReferencedAssemblies = true); + var withoutRefs = AssemblyContext.GetCurrentDomainAssemblies(o => o.IncludeReferencedAssemblies = false); + + TestOutput.WriteLine($"With referenced: {withRefs.Count}, without referenced: {withoutRefs.Count}"); + + Assert.True(withRefs.Count >= withoutRefs.Count); + } + + [Fact] + public void GetCurrentDomainAssemblies_ShouldExcludeSystemAndMicrosoftAssemblies_WithDefaultOptions() + { + var result = AssemblyContext.GetCurrentDomainAssemblies(); + + Assert.All(result, assembly => + { + Assert.False(assembly.FullName.StartsWith("System", StringComparison.Ordinal), + $"Expected '{assembly.GetName().Name}' to be excluded by the default System filter."); + Assert.False(assembly.FullName.StartsWith("Microsoft", StringComparison.Ordinal), + $"Expected '{assembly.GetName().Name}' to be excluded by the default Microsoft filter."); + }); + } + } +} diff --git a/test/Cuemon.Extensions.Collections.Generic.Tests/StackExtensionsTest.cs b/test/Cuemon.Extensions.Collections.Generic.Tests/StackExtensionsTest.cs new file mode 100644 index 000000000..54a2466a1 --- /dev/null +++ b/test/Cuemon.Extensions.Collections.Generic.Tests/StackExtensionsTest.cs @@ -0,0 +1,109 @@ +#if !NETCOREAPP2_0_OR_GREATER +using System.Collections.Generic; +using Codebelt.Extensions.Xunit; +using Xunit; + +namespace Cuemon.Extensions.Collections.Generic +{ + public class StackExtensionsTest : Test + { + public StackExtensionsTest(ITestOutputHelper output) : base(output) + { + } + + [Fact] + public void TryPop_ShouldReturnTrueAndTopElement_WhenStackIsNonEmpty() + { + var sut = new Stack(); + sut.Push(1); + sut.Push(2); + sut.Push(3); // top = 3 (LIFO) + + var result = sut.TryPop(out var value); + + TestOutput.WriteLine($"TryPop result: {result}, value: {value}"); + + Assert.True(result); + Assert.Equal(3, value); + } + + [Fact] + public void TryPop_ShouldReturnFalseAndDefaultValue_WhenStackIsEmpty() + { + var sut = new Stack(); + + var result = sut.TryPop(out var value); + + TestOutput.WriteLine($"TryPop result: {result}, value: {value}"); + + Assert.False(result); + Assert.Equal(default, value); + } + + [Fact] + public void TryPop_ShouldReturnFalseAndNull_WhenStackOfReferenceTypeIsEmpty() + { + var sut = new Stack(); + + var result = sut.TryPop(out var value); + + TestOutput.WriteLine($"TryPop result: {result}, value: {value ?? "null"}"); + + Assert.False(result); + Assert.Null(value); + } + + [Fact] + public void TryPop_ShouldDecrementCount_AfterSuccessfulPop() + { + var sut = new Stack(new[] { 10, 20, 30 }); + + Assert.Equal(3, sut.Count); + + sut.TryPop(out _); + + TestOutput.WriteLine($"Count after pop: {sut.Count}"); + + Assert.Equal(2, sut.Count); + } + + [Fact] + public void TryPop_ShouldPreserveLifoOrdering_WhenCalledSequentially() + { + var sut = new Stack(); + sut.Push(1); + sut.Push(2); + sut.Push(3); + + sut.TryPop(out var first); + sut.TryPop(out var second); + sut.TryPop(out var third); + + TestOutput.WriteLine($"LIFO order: {first}, {second}, {third}"); + + Assert.Equal(3, first); + Assert.Equal(2, second); + Assert.Equal(1, third); + Assert.Equal(0, sut.Count); + } + + [Fact] + public void TryPop_ShouldReturnFalse_AfterAllElementsAreExhausted() + { + var sut = new Stack(new[] { "a", "b" }); + + var first = sut.TryPop(out var v1); + var second = sut.TryPop(out var v2); + var third = sut.TryPop(out var v3); // empty at this point + + TestOutput.WriteLine($"Popped: {v1}, {v2}; exhausted: {!third}"); + + Assert.True(first); + Assert.True(second); + Assert.False(third); + Assert.Null(v3); + Assert.Equal(0, sut.Count); + } + } +} +#endif